/** * This file is part of git-as-svn. It is subject to the license terms * in the LICENSE file found in the top-level directory of this distribution * and at http://www.gnu.org/licenses/gpl-2.0.html. No part of git-as-svn, * including this file, may be copied, modified, propagated, or distributed * except according to the terms contained in the LICENSE file. */ package svnserver.server.command; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.tmatesoft.svn.core.*; import org.tmatesoft.svn.core.io.ISVNDeltaConsumer; import org.tmatesoft.svn.core.io.diff.SVNDeltaGenerator; import org.tmatesoft.svn.core.io.diff.SVNDiffWindow; import svnserver.StringHelper; import svnserver.parser.MessageParser; import svnserver.parser.SvnServerParser; import svnserver.parser.SvnServerWriter; import svnserver.parser.token.ListBeginToken; import svnserver.parser.token.ListEndToken; import svnserver.repository.Depth; import svnserver.repository.SvnForbiddenException; import svnserver.repository.VcsCopyFrom; import svnserver.repository.VcsFile; import svnserver.server.SessionContext; import svnserver.server.step.CheckPermissionStep; import java.io.*; import java.util.*; /** * Delta commands. * <pre> * To reduce round-trip delays, report commands do not return responses. * Any errors resulting from a report call will be returned to the client * by the command which invoked the report (following an abort-edit * call). Errors resulting from an abort-report call are ignored. * * set-path: * params: ( path:string rev:number start-empty:bool * ? [ lock-token:string ] ? depth:word ) * * delete-path: * params: ( path:string ) * * link-path: * params: ( path:string url:string rev:number start-empty:bool * ? [ lock-token:string ] ? depth:word ) * * finish-report: * params: ( ) * * abort-report * params: ( ) * </pre> * * @author Artem V. Navrotskiy <bozaro@users.noreply.github.com> */ public final class DeltaCmd extends BaseCmd<DeltaParams> { @NotNull private final Class<? extends DeltaParams> arguments; public DeltaCmd(@NotNull Class<? extends DeltaParams> arguments) { this.arguments = arguments; } @NotNull @Override public Class<? extends DeltaParams> getArguments() { return arguments; } public static class DeleteParams { @NotNull private final String path; public DeleteParams(@NotNull String path) { this.path = path; } } public static class SetPathParams { @NotNull private final String path; private final int rev; private final boolean startEmpty; @NotNull private final String[] lockToken; @NotNull private final Depth depth; public SetPathParams(@NotNull String path, int rev, boolean startEmpty, @NotNull String[] lockToken, @NotNull String depth) { this.path = path; this.rev = rev; this.startEmpty = startEmpty; this.lockToken = lockToken; this.depth = Depth.parse(depth); } @Override public String toString() { return "SetPathParams{" + "path='" + path + '\'' + ", rev=" + rev + ", startEmpty=" + startEmpty + ", lockToken=" + Arrays.toString(lockToken) + ", depth=" + depth + '}'; } } @NotNull private static final Logger log = LoggerFactory.getLogger(DeltaCmd.class); @Override protected void processCommand(@NotNull SessionContext context, @NotNull DeltaParams args) throws IOException, SVNException { log.debug("Enter report mode"); ReportPipeline pipeline = new ReportPipeline(args); pipeline.reportCommand(context); } public static class ReportPipeline { private int lastTokenId; @NotNull private final Map<String, BaseCmd<?>> commands; @NotNull private final DeltaParams params; @NotNull private final Map<String, Set<String>> forcedPaths = new HashMap<>(); @NotNull private final Set<String> deletedPaths = new HashSet<>(); @NotNull private final Map<String, SetPathParams> paths = new HashMap<>(); @NotNull private final Deque<HeaderEntry> pathStack = new ArrayDeque<>(); @FunctionalInterface private interface HeaderWriter { void write(@NotNull SvnServerWriter writer) throws IOException, SVNException; } private class HeaderEntry implements AutoCloseable { private final SessionContext context; private final VcsFile file; private final HeaderWriter beginWriter; private final HeaderWriter endWriter; private boolean writed = false; public HeaderEntry(@NotNull SessionContext context, @Nullable VcsFile file, @NotNull HeaderWriter beginWriter, @NotNull HeaderWriter endWriter) throws IOException { this.context = context; this.file = file; this.beginWriter = beginWriter; this.endWriter = endWriter; pathStack.addLast(this); } public void write() throws IOException, SVNException { if (!writed) { writed = true; beginWriter.write(context.getWriter()); } } @Override public void close() throws IOException, SVNException { if (writed) { endWriter.write(context.getWriter()); } pathStack.removeLast(); } } private SvnServerWriter getWriter(@NotNull SessionContext context) throws IOException, SVNException { for (HeaderEntry entry : pathStack) { entry.write(); } return context.getWriter(); } public ReportPipeline(@NotNull DeltaParams params) { this.params = params; commands = new HashMap<>(); commands.put("delete-path", new LambdaCmd<>(DeleteParams.class, this::deletePath)); commands.put("set-path", new LambdaCmd<>(SetPathParams.class, this::setPathReport)); commands.put("abort-report", new LambdaCmd<>(NoParams.class, this::abortReport)); commands.put("finish-report", new LambdaCmd<>(NoParams.class, this::finishReport)); } private void abortReport(@NotNull SessionContext context, @NotNull NoParams args) throws IOException, SVNException { final SvnServerWriter writer = getWriter(context); writer .listBegin() .word("success") .listBegin() .listEnd() .listEnd(); } private void finishReport(@NotNull SessionContext context, @NotNull NoParams args) { context.push(new CheckPermissionStep(this::complete, null)); } public void setPathReport(@NotNull String path, int rev, boolean startEmpty, @NotNull SVNDepth depth) throws SVNException { final String wcPath = wcPath(path); paths.put(wcPath, new SetPathParams(path, rev, startEmpty, new String[0], depth.getName())); forcePath(wcPath); } private void setPathReport(@NotNull SessionContext context, @NotNull SetPathParams args) throws SVNException { context.push(this::reportCommand); final String wcPath = wcPath(args.path); paths.put(wcPath, args); forcePath(wcPath); } private void deletePath(@NotNull SessionContext context, @NotNull DeleteParams args) throws SVNException { context.push(this::reportCommand); final String wcPath = wcPath(args.path); forcePath(wcPath); deletedPaths.add(wcPath); } private void forcePath(@NotNull String wcPath) { String path = wcPath; while (!path.isEmpty()) { final String parent = StringHelper.parentDir(path); final Set<String> items = forcedPaths.computeIfAbsent(parent, s -> new HashSet<>()); if (!items.add(path)) { break; } path = parent; } } private void complete(@NotNull SessionContext context) throws IOException, SVNException { sendResponse(context, params.getPath(), params.getRev(context)); } protected void sendDelta(@NotNull SessionContext context, @NotNull String path, int rev) throws IOException, SVNException { final SetPathParams rootParams = paths.get(wcPath("")); if (rootParams == null) throw new SVNException(SVNErrorMessage.create(SVNErrorCode.STREAM_MALFORMED_DATA)); final SvnServerWriter writer = getWriter(context); writer .listBegin() .word("target-rev") .listBegin().number(rev).listEnd() .listEnd(); final String tokenId = createTokenId(); final int rootRev = rootParams.rev; writer .listBegin() .word("open-root") .listBegin() .listBegin() .number(rootRev) .listEnd() .string(tokenId) .listEnd() .listEnd(); final String fullPath = context.getRepositoryPath(path); final SVNURL targetPath = params.getTargetPath(); final VcsFile newFile; if (targetPath == null) newFile = context.getFile(rev, fullPath); else newFile = context.getFile(rev, targetPath); final VcsFile oldFile = getPrevFile(context, path, context.getFile(rootRev, fullPath)); updateEntry(context, path, oldFile, newFile, tokenId, path.isEmpty(), rootParams.depth, params.getDepth()); writer .listBegin() .word("close-dir") .listBegin().string(tokenId).listEnd() .listEnd(); } protected void sendResponse(@NotNull SessionContext context, @NotNull String path, int rev) throws IOException, SVNException { final SvnServerWriter writer = getWriter(context); sendDelta(context, path, rev); writer .listBegin() .word("close-edit") .listBegin().listEnd() .listEnd(); final SvnServerParser parser = context.getParser(); parser.readToken(ListBeginToken.class); final String clientStatus = parser.readText(); switch (clientStatus) { case "failure": { parser.readToken(ListBeginToken.class); parser.readToken(ListBeginToken.class); final int errorCode = parser.readNumber(); final String errorMessage = parser.readText(); final String errorFile = parser.readText(); final int errorLine = parser.readNumber(); parser.readToken(ListEndToken.class); parser.readToken(ListEndToken.class); parser.readToken(ListEndToken.class); if (errorFile.isEmpty()) { log.error("Received client error: {} {}", errorCode, errorMessage); } else { log.error("Received client error [%s:%d]: {} {}", errorFile, errorLine, errorCode, errorMessage); } writer .listBegin() .word("abort-edit") .listBegin().listEnd() .listEnd(); writer .listBegin() .word("failure") .listBegin() .listBegin() .number(errorCode) .string(errorMessage) .string(errorFile) .number(errorLine) .listEnd() .listEnd() .listEnd(); writer .listBegin(); break; } case "success": { parser.skipItems(); writer .listBegin() .word("success") .listBegin().listEnd() .listEnd(); break; } default: { log.error("Unexpected client status: {}", clientStatus); throw new EOFException("Unexpected client status"); } } } private String createTokenId() { return "t" + String.valueOf(++lastTokenId); } private void updateDir(@NotNull SessionContext context, @NotNull String wcPath, @Nullable VcsFile prevFile, @NotNull VcsFile newFile, @NotNull String parentTokenId, boolean rootDir, @NotNull Depth wcDepth, @NotNull Depth requestedDepth) throws IOException, SVNException { final String tokenId; final HeaderEntry header; VcsFile oldFile; try { newFile.getEntries(); } catch (SvnForbiddenException ignored) { getWriter(context) .listBegin() .word("absent-dir") .listBegin() .string(newFile.getFileName()) .string(parentTokenId) .listEnd() .listEnd(); return; } if (rootDir && wcPath.isEmpty()) { tokenId = parentTokenId; oldFile = prevFile; header = null; } else { tokenId = createTokenId(); header = sendEntryHeader(context, wcPath, prevFile, newFile, "dir", parentTokenId, tokenId, writer -> writer .listBegin() .word("close-dir") .listBegin().string(tokenId).listEnd() .listEnd()); oldFile = header.file; } if (getStartEmpty(wcPath)) { oldFile = null; } if (rootDir) { sendRevProps(getWriter(context), newFile, "dir", tokenId); } updateProps(context, "dir", tokenId, oldFile, newFile); updateDirEntries(context, wcPath, oldFile, newFile, tokenId, wcDepth, requestedDepth); if (header != null) { header.close(); } } private void updateDirEntries(@NotNull SessionContext context, @NotNull String wcPath, @Nullable VcsFile oldFile, @NotNull VcsFile newFile, @NotNull String tokenId, @NotNull Depth wcDepth, @NotNull Depth requestedDepth) throws IOException, SVNException { final Depth.Action dirAction = wcDepth.determineAction(requestedDepth, true); final Depth.Action fileAction = wcDepth.determineAction(requestedDepth, false); final Map<String, VcsFile> newEntries = new TreeMap<>(); for (VcsFile entry : newFile.getEntries()) { newEntries.put(entry.getFileName(), entry); } final Set<String> forced = new HashSet<>(forcedPaths.getOrDefault(wcPath, Collections.emptySet())); final Map<String, VcsFile> oldEntries; if (oldFile != null) { oldEntries = new TreeMap<>(); for (VcsFile oldEntry : oldFile.getEntries()) { final String entryPath = joinPath(wcPath, oldEntry.getFileName()); if (newEntries.containsKey(oldEntry.getFileName())) { oldEntries.put(oldEntry.getFileName(), oldEntry); continue; } removeEntry(context, entryPath, oldEntry.getLastChange().getId(), tokenId); forced.remove(entryPath); } } else { oldEntries = Collections.emptyMap(); } for (String entryPath : forced) { String entryName = StringHelper.getChildPath(wcPath, entryPath); if ((entryName != null) && newEntries.containsKey(entryName)) { continue; } removeEntry(context, entryPath, newFile.getLastChange().getId(), tokenId); } for (VcsFile newEntry : newFile.getEntries()) { final String entryPath = joinPath(wcPath, newEntry.getFileName()); final VcsFile oldEntry = getPrevFile(context, entryPath, oldEntries.get(newEntry.getFileName())); final Depth.Action action = newEntry.isDirectory() ? dirAction : fileAction; if (!forced.remove(entryPath) && newEntry.equals(oldEntry) && action == Depth.Action.Normal && requestedDepth == wcDepth) // Same entry with same depth parameter. continue; if (action == Depth.Action.Skip) continue; final Depth entryDepth = getWcDepth(entryPath, wcDepth); updateEntry(context, entryPath, action == Depth.Action.Upgrade ? null : oldEntry, newEntry, tokenId, false, entryDepth, requestedDepth.deepen()); } } private void updateProps(@NotNull SessionContext context, @NotNull String type, @NotNull String tokenId, @Nullable VcsFile oldFile, @NotNull VcsFile newFile) throws IOException, SVNException { final Map<String, String> oldProps = oldFile != null ? oldFile.getProperties() : new HashMap<>(); if (oldFile == null) { getWriter(context); } for (Map.Entry<String, String> entry : newFile.getProperties().entrySet()) { if (!entry.getValue().equals(oldProps.remove(entry.getKey()))) { changeProp(getWriter(context), type, tokenId, entry.getKey(), entry.getValue()); } } for (String propName : oldProps.keySet()) { changeProp(getWriter(context), type, tokenId, propName, null); } } private void updateFile(@NotNull SessionContext context, @NotNull String wcPath, @Nullable VcsFile prevFile, @NotNull VcsFile newFile, @NotNull String parentTokenId) throws IOException, SVNException { final String tokenId = createTokenId(); final String md5 = newFile.getMd5(); try (final HeaderEntry header = sendEntryHeader(context, wcPath, prevFile, newFile, "file", parentTokenId, tokenId, writer -> writer .listBegin() .word("close-file") .listBegin() .string(tokenId) .listBegin() .string(md5) .listEnd() .listEnd() .listEnd())) { final VcsFile oldFile = header.file; if (oldFile == null || !newFile.getContentHash().equals(oldFile.getContentHash())) { final SvnServerWriter writer = getWriter(context); writer .listBegin() .word("apply-textdelta") .listBegin() .string(tokenId) .listBegin() .listEnd() .listEnd() .listEnd(); if (params.needDeltas()) { final SVNDeltaGenerator deltaGenerator = new SVNDeltaGenerator(); try (InputStream source = openStream(oldFile); InputStream target = newFile.openStream()) { final boolean compress = context.isCompressionEnabled(); final String validateMd5 = deltaGenerator.sendDelta(newFile.getFileName(), source, 0, target, new ISVNDeltaConsumer() { private boolean header = true; @Override public void applyTextDelta(String path, String baseChecksum) throws SVNException { } @Override public OutputStream textDeltaChunk(String path, SVNDiffWindow diffWindow) throws SVNException { try (ByteArrayOutputStream stream = new ByteArrayOutputStream()) { diffWindow.writeTo(stream, header, compress); header = false; writer .listBegin() .word("textdelta-chunk") .listBegin() .string(tokenId) .binary(stream.toByteArray()) .listEnd() .listEnd(); return null; } catch (IOException e) { throw new SVNException(SVNErrorMessage.UNKNOWN_ERROR_MESSAGE, e); } } @Override public void textDeltaEnd(String path) throws SVNException { } }, true); if (!validateMd5.equals(md5)) { throw new IllegalStateException("MD5 checksum mismatch: some shit happends."); } } } writer .listBegin() .word("textdelta-end") .listBegin() .string(tokenId) .listEnd() .listEnd(); } updateProps(context, "file", tokenId, oldFile, newFile); } } @NotNull private InputStream openStream(@Nullable VcsFile file) throws IOException, SVNException { return file == null ? new ByteArrayInputStream(new byte[0]) : file.openStream(); } @NotNull private Depth getWcDepth(@NotNull String wcPath, @NotNull Depth parentWcDepth) { final SetPathParams params = paths.get(wcPath); if (params == null) return parentWcDepth.deepen(); return params.depth; } private boolean getStartEmpty(@NotNull String wcPath) { final SetPathParams params = paths.get(wcPath); return params != null && params.startEmpty; } @Nullable private VcsFile getPrevFile(@NotNull SessionContext context, @NotNull String wcPath, @Nullable VcsFile oldFile) throws IOException, SVNException { if (deletedPaths.contains(wcPath)) return null; final SetPathParams pathParams = paths.get(wcPath); if (pathParams == null) return oldFile; if (pathParams.rev == 0) return null; return context.getFile(pathParams.rev, wcPath); } private void updateEntry(@NotNull SessionContext context, @NotNull String wcPath, @Nullable VcsFile oldFile, @Nullable VcsFile newFile, @NotNull String parentTokenId, boolean rootDir, @NotNull Depth wcDepth, @NotNull Depth requestedDepth) throws IOException, SVNException { if (oldFile != null) if (newFile == null || !oldFile.getKind().equals(newFile.getKind())) removeEntry(context, wcPath, oldFile.getLastChange().getId(), parentTokenId); if (newFile == null) return; if (newFile.isDirectory()) updateDir(context, wcPath, oldFile, newFile, parentTokenId, rootDir, wcDepth, requestedDepth); else { try { updateFile(context, wcPath, oldFile, newFile, parentTokenId); } catch (SvnForbiddenException ignored) { getWriter(context) .listBegin() .word("absent-file") .listBegin() .string(newFile.getFileName()) .string(parentTokenId) .listEnd() .listEnd(); } } } private void removeEntry(@NotNull SessionContext context, @NotNull String wcPath, int rev, @NotNull String parentTokenId) throws IOException, SVNException { if (deletedPaths.contains(wcPath)) { return; } getWriter(context) .listBegin() .word("delete-entry") .listBegin() .string(wcPath) .listBegin() .number(rev) .listEnd() .string(parentTokenId) .listEnd() .listEnd(); } private void sendOpenEntry(@NotNull SvnServerWriter writer, @NotNull String command, @NotNull String fileName, @NotNull String parentTokenId, @NotNull String tokenId, @Nullable Integer revision) throws IOException { writer .listBegin() .word(command) .listBegin() .string(fileName) .string(parentTokenId) .string(tokenId) .listBegin(); if (revision != null) { writer.number(revision); } writer .listEnd() .listEnd() .listEnd(); } @NotNull private HeaderEntry sendEntryHeader(@NotNull SessionContext context, @NotNull String wcPath, @Nullable VcsFile oldFile, @NotNull VcsFile newFile, @NotNull String type, @NotNull String parentTokenId, @NotNull String tokenId, @NotNull HeaderWriter endWriter) throws IOException, SVNException { if (oldFile == null) { final VcsCopyFrom copyFrom = params.getSendCopyFrom().getCopyFrom(wcPath(""), newFile); final VcsFile entryFile = copyFrom != null ? context.getRepository().getRevisionInfo(copyFrom.getRevision()).getFile(copyFrom.getPath()) : null; final HeaderEntry entry = new HeaderEntry(context, entryFile, writer -> { sendNewEntry(writer, "add-" + type, wcPath, parentTokenId, tokenId, copyFrom); sendRevProps(writer, newFile, type, tokenId); }, endWriter); getWriter(context); return entry; } else { return new HeaderEntry(context, oldFile, writer -> { sendOpenEntry(writer, "open-" + type, wcPath, parentTokenId, tokenId, oldFile.getLastChange().getId()); sendRevProps(writer, newFile, type, tokenId); }, endWriter); } } private void sendRevProps(@NotNull SvnServerWriter writer, @NotNull VcsFile newFile, @NotNull String type, @NotNull String tokenId) throws IOException, SVNException { if (params.isIncludeInternalProps()) { for (Map.Entry<String, String> prop : newFile.getRevProperties().entrySet()) { changeProp(writer, type, tokenId, prop.getKey(), prop.getValue()); } } } private void sendNewEntry(@NotNull SvnServerWriter writer, @NotNull String command, @NotNull String fileName, @NotNull String parentTokenId, @NotNull String tokenId, @Nullable VcsCopyFrom copyFrom) throws IOException { writer .listBegin() .word(command) .listBegin() .string(fileName) .string(parentTokenId) .string(tokenId) .listBegin(); if (copyFrom != null) { writer.string(copyFrom.getPath()); writer.number(copyFrom.getRevision()); } writer .listEnd() .listEnd() .listEnd(); } private void changeProp(@NotNull SvnServerWriter writer, @NotNull String type, @NotNull String tokenId, @NotNull String key, @Nullable String value) throws IOException { writer .listBegin() .word("change-" + type + "-prop") .listBegin() .string(tokenId) .string(key) .listBegin(); if (value != null) { writer .string(value); } writer .listEnd() .listEnd() .listEnd(); } @NotNull private String wcPath(@NotNull String name) { return joinPath(params.getPath(), name); } @NotNull private String joinPath(@NotNull String prefix, @NotNull String name) { if (name.isEmpty()) return prefix; return prefix.isEmpty() ? name : (prefix + "/" + name); } private void reportCommand(@NotNull SessionContext context) throws IOException, SVNException { final SvnServerParser parser = context.getParser(); final SvnServerWriter writer = context.getWriter(); parser.readToken(ListBeginToken.class); final String cmd = parser.readText(); log.debug("Report command: {}", cmd); final BaseCmd command = commands.get(cmd); if (command != null) { Object param = MessageParser.parse(command.getArguments(), parser); parser.readToken(ListEndToken.class); //noinspection unchecked command.process(context, param); } else { log.error("Unsupported command: {}", cmd); BaseCmd.sendError(writer, SVNErrorMessage.create(SVNErrorCode.RA_SVN_UNKNOWN_CMD, "Unsupported command: " + cmd)); parser.skipItems(); } } } }