/**
* 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.SVNErrorCode;
import org.tmatesoft.svn.core.SVNErrorMessage;
import org.tmatesoft.svn.core.SVNException;
import org.tmatesoft.svn.core.SVNURL;
import org.tmatesoft.svn.core.internal.delta.SVNDeltaReader;
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.*;
import svnserver.repository.locks.LockDesc;
import svnserver.repository.locks.LockManagerWrite;
import svnserver.server.SessionContext;
import svnserver.server.step.CheckPermissionStep;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Commit client changes.
* <p><pre>
* get-dir
* commit
* params: ( logmsg:string ? ( ( lock-path:string lock-token:string ) ... )
* keep-locks:bool ? rev-props:proplist )
* response: ( )
* Upon receiving response, client switches to editor command set.
* Upon successful completion of edit, server sends auth-request.
* After auth exchange completes, server sends commit-info.
* If rev-props is present, logmsg is ignored. Only the svn:log entry in
* rev-props (if any) will be used.
* commit-info: ( new-rev:number date:string author:string
* ? ( post-commit-err:string ) )
* NOTE: when revving this, make 'logmsg' optional, or delete that parameter
* and have the log message specified in 'rev-props'.
* </pre>
*
* @author Artem V. Navrotskiy <bozaro@users.noreply.github.com>
*/
public final class CommitCmd extends BaseCmd<CommitCmd.CommitParams> {
public static final class LockInfo {
@NotNull
private final String path;
@NotNull
private final String lockToken;
public LockInfo(@NotNull String path, @NotNull String lockToken) {
this.path = path;
this.lockToken = lockToken;
}
}
public static class CommitParams {
private final boolean keepLocks;
@NotNull
private final String message;
@NotNull
private final LockInfo[] locks;
public CommitParams(@NotNull String message, @NotNull LockInfo[] locks, boolean keepLocks) {
this.message = message;
this.locks = locks;
this.keepLocks = keepLocks;
}
}
public static class OpenRootParams {
@NotNull
private final int rev[];
@NotNull
private final String token;
public OpenRootParams(@NotNull int[] rev, @NotNull String token) {
this.rev = rev;
this.token = token;
}
}
public static class OpenParams {
@NotNull
private final String name;
@NotNull
private final String parentToken;
@NotNull
private final String token;
@NotNull
private final int rev[];
public OpenParams(@NotNull String name, @NotNull String parentToken, @NotNull String token, @NotNull int[] rev) {
this.name = name;
this.parentToken = parentToken;
this.token = token;
this.rev = rev;
}
}
public static class CopyParams {
@Nullable
private final SVNURL copyFrom;
private final int rev;
public CopyParams(@NotNull String copyFrom, int rev) throws SVNException {
this.copyFrom = copyFrom.isEmpty() ? null : SVNURL.parseURIEncoded(copyFrom);
this.rev = rev;
}
}
public static class AddParams {
@NotNull
private final String name;
@NotNull
private final String parentToken;
@NotNull
private final String token;
@NotNull
private final CopyParams copyParams;
public AddParams(@NotNull String name, @NotNull String parentToken, @NotNull String token, @NotNull CopyParams copyParams) {
this.name = name;
this.parentToken = parentToken;
this.token = token;
this.copyParams = copyParams;
}
}
public static class DeleteParams {
@NotNull
private final String name;
@NotNull
private final int rev[];
@NotNull
private final String parentToken;
public DeleteParams(@NotNull String name, @NotNull int[] rev, @NotNull String parentToken) {
this.name = name;
this.rev = rev;
this.parentToken = parentToken;
}
}
public static class TokenParams {
@NotNull
private final String token;
public TokenParams(@NotNull String token) {
this.token = token;
}
}
public static class ChangePropParams {
@NotNull
private final String token;
@NotNull
private final String name;
@NotNull
private final String[] value;
public ChangePropParams(@NotNull String token, @NotNull String name, @NotNull String[] value) {
this.token = token;
this.name = name;
this.value = value;
}
}
public static class ChecksumParams {
@NotNull
private final String token;
@NotNull
private final String[] checksum;
public ChecksumParams(@NotNull String token, @NotNull String[] checksum) {
this.token = token;
this.checksum = checksum;
}
}
public static class DeltaChunkParams {
@NotNull
private final String token;
@NotNull
private final byte[] chunk;
public DeltaChunkParams(@NotNull String token, @NotNull byte[] chunk) {
this.token = token;
this.chunk = chunk;
}
}
private static final int MAX_PASS_COUNT = 10;
@NotNull
private static final Logger log = LoggerFactory.getLogger(DeltaCmd.class);
@NotNull
@Override
public Class<CommitParams> getArguments() {
return CommitParams.class;
}
@Override
protected void processCommand(@NotNull SessionContext context, @NotNull CommitParams args) throws IOException, SVNException {
final SvnServerWriter writer = context.getWriter();
writer
.listBegin()
.word("success")
.listBegin()
.listEnd()
.listEnd();
log.debug("Enter editor mode");
EditorPipeline pipeline = new EditorPipeline(context, args);
pipeline.editorCommand(context);
}
@Override
protected void permissionCheck(@NotNull SessionContext context, @NotNull CommitParams args) throws IOException, SVNException {
context.checkWrite(context.getRepositoryPath(""));
}
private static class FileUpdater {
@NotNull
private final VcsDeltaConsumer deltaConsumer;
@NotNull
private final SVNDeltaReader reader = new SVNDeltaReader();
public FileUpdater(@NotNull VcsDeltaConsumer deltaConsumer) {
this.deltaConsumer = deltaConsumer;
}
}
private static class EntryUpdater {
// New parent entry (destination)
@NotNull
private final VcsEntry entry;
// Old source entry (source)
@Nullable
private final VcsFile source;
@NotNull
private final Map<String, String> props;
@NotNull
private final List<VcsConsumer<VcsCommitBuilder>> changes = new ArrayList<>();
private final boolean head;
private EntryUpdater(@NotNull VcsEntry entry, @Nullable VcsFile source, boolean head) throws IOException, SVNException {
this.entry = entry;
this.source = source;
this.head = head;
this.props = source == null ? new HashMap<>() : new HashMap<>(source.getProperties());
}
@NotNull
public VcsFile getEntry(@NotNull String name) throws IOException, SVNException {
if (source == null) {
throw new SVNException(SVNErrorMessage.create(SVNErrorCode.ENTRY_NOT_FOUND, "Can't find node: " + name));
}
final VcsFile file = source.getEntry(name);
if (file == null) {
throw new SVNException(SVNErrorMessage.create(SVNErrorCode.ENTRY_NOT_FOUND, "Can't find node: " + name + " in " + source.getFullPath()));
}
return file;
}
}
private static class EditorPipeline {
@NotNull
private final EntryUpdater rootEntry;
@NotNull
private final Map<String, BaseCmd<?>> commands;
@NotNull
private final Map<String, BaseCmd<?>> exitCommands;
@NotNull
private final String message;
@NotNull
private final Map<String, EntryUpdater> paths;
@NotNull
private final Map<String, FileUpdater> files;
@NotNull
private final Map<String, String> locks;
@NotNull
private final VcsWriter writer;
private boolean keepLocks;
private boolean aborted = false;
public EditorPipeline(@NotNull SessionContext context, @NotNull CommitParams params) throws IOException, SVNException {
this.message = params.message;
this.keepLocks = params.keepLocks;
this.writer = context.getRepository().createWriter(context.getUser());
final VcsFile entry = context.getRepository().getLatestRevision().getFile("");
if (entry == null) {
throw new IllegalStateException("Repository root entry not found.");
}
this.rootEntry = new EntryUpdater(entry, entry, true);
paths = new HashMap<>();
files = new HashMap<>();
locks = getLocks(context, params.locks);
commands = new HashMap<>();
commands.put("add-dir", new LambdaCmd<>(AddParams.class, this::addDir));
commands.put("add-file", new LambdaCmd<>(AddParams.class, this::addFile));
commands.put("change-dir-prop", new LambdaCmd<>(ChangePropParams.class, this::changeDirProp));
commands.put("change-file-prop", new LambdaCmd<>(ChangePropParams.class, this::changeFileProp));
commands.put("delete-entry", new LambdaCmd<>(DeleteParams.class, this::deleteEntry));
commands.put("open-root", new LambdaCmd<>(OpenRootParams.class, this::openRoot));
commands.put("open-dir", new LambdaCmd<>(OpenParams.class, this::openDir));
commands.put("open-file", new LambdaCmd<>(OpenParams.class, this::openFile));
commands.put("close-dir", new LambdaCmd<>(TokenParams.class, this::closeDir));
commands.put("close-file", new LambdaCmd<>(ChecksumParams.class, this::closeFile));
commands.put("textdelta-chunk", new LambdaCmd<>(DeltaChunkParams.class, this::deltaChunk));
commands.put("textdelta-end", new LambdaCmd<>(TokenParams.class, this::deltaEnd));
commands.put("apply-textdelta", new LambdaCmd<>(ChecksumParams.class, this::deltaApply));
exitCommands = new HashMap<>();
exitCommands.put("close-edit", new LambdaCmd<>(NoParams.class, this::closeEdit));
exitCommands.put("abort-edit", new LambdaCmd<>(NoParams.class, this::abortEdit));
}
private static Map<String, String> getLocks(@NotNull SessionContext context, @NotNull LockInfo[] locks) throws SVNException {
final Map<String, String> result = new HashMap<>();
for (LockInfo lock : locks) {
result.put(context.getRepositoryPath(lock.path), lock.lockToken);
}
return result;
}
private void changeDirProp(@NotNull SessionContext context, @NotNull ChangePropParams args) throws SVNException {
final EntryUpdater dir = paths.get(args.token);
if (dir == null) {
throw new SVNException(SVNErrorMessage.create(SVNErrorCode.ILLEGAL_TARGET, "Invalid path token: " + args.token));
}
changeProp(dir.props, args);
}
private void changeFileProp(@NotNull SessionContext context, @NotNull ChangePropParams args) throws SVNException {
changeProp(getFile(args.token).deltaConsumer.getProperties(), args);
}
private void changeProp(@NotNull Map<String, String> props, @NotNull ChangePropParams args) {
if (args.value.length > 0) {
props.put(args.name, args.value[0]);
} else {
props.remove(args.name);
}
}
private void openRoot(@NotNull SessionContext context, @NotNull OpenRootParams args) throws SVNException, IOException {
final String fullPath = context.getRepositoryPath("");
final String[] rootPath = fullPath.split("/");
EntryUpdater lastUpdater = rootEntry;
for (int i = 1; i < rootPath.length; ++i) {
String name = rootPath[i];
final VcsFile entry = lastUpdater.getEntry(name);
final EntryUpdater updater = new EntryUpdater(entry, entry, true);
lastUpdater.changes.add(treeBuilder -> {
treeBuilder.openDir(name);
updateDir(treeBuilder, updater);
treeBuilder.closeDir();
});
lastUpdater = updater;
}
final int rev = args.rev.length > 0 ? args.rev[0] : -1;
if (rev >= 0) {
if (lastUpdater.source == null) {
throw new IllegalStateException();
}
checkUpToDate(lastUpdater.source, rev, false);
final Map<String, String> props = lastUpdater.props;
lastUpdater.changes.add(treeBuilder -> treeBuilder.checkDirProperties(props));
}
paths.put(args.token, lastUpdater);
}
private void openDir(@NotNull SessionContext context, @NotNull OpenParams args) throws SVNException, IOException {
final EntryUpdater parent = getParent(args.parentToken);
final int rev = args.rev.length > 0 ? args.rev[0] : -1;
log.debug("Modify dir: {} (rev: {})", args.name, rev);
final VcsFile sourceDir = parent.getEntry(StringHelper.baseName(args.name));
final EntryUpdater dir = new EntryUpdater(sourceDir, sourceDir, parent.head);
if ((rev >= 0) && (parent.head)) {
checkUpToDate(sourceDir, rev, false);
}
paths.put(args.token, dir);
parent.changes.add(treeBuilder -> {
treeBuilder.openDir(StringHelper.baseName(args.name));
updateDir(treeBuilder, dir);
if (rev >= 0) {
treeBuilder.checkDirProperties(dir.props);
}
treeBuilder.closeDir();
});
}
@NotNull
private VcsCommitBuilder updateDir(@NotNull VcsCommitBuilder treeBuilder, @NotNull EntryUpdater updater) throws IOException, SVNException {
for (VcsConsumer<VcsCommitBuilder> consumer : updater.changes) {
consumer.accept(treeBuilder);
}
return treeBuilder;
}
private void addDir(@NotNull SessionContext context, @NotNull AddParams args) throws SVNException, IOException {
final EntryUpdater parent = getParent(args.parentToken);
final VcsFile source;
context.checkWrite(StringHelper.joinPath(parent.entry.getFullPath(), args.name));
if (args.copyParams.copyFrom != null) {
log.debug("Copy dir: {} from {} (rev: {})", args.name, args.copyParams.copyFrom, args.copyParams.rev);
source = context.getFile(args.copyParams.rev, args.copyParams.copyFrom);
if (source == null) {
throw new SVNException(SVNErrorMessage.create(SVNErrorCode.ENTRY_NOT_FOUND, "Directory not found: " + args.copyParams.copyFrom + "@" + args.copyParams.rev));
}
} else {
log.debug("Add dir: {}", args.name);
source = null;
}
final EntryUpdater updater = new EntryUpdater(parent.entry, source, false);
paths.put(args.token, updater);
parent.changes.add(treeBuilder -> {
treeBuilder.addDir(StringHelper.baseName(args.name), source);
updateDir(treeBuilder, updater);
treeBuilder.checkDirProperties(updater.props);
treeBuilder.closeDir();
});
}
private void addFile(@NotNull SessionContext context, @NotNull AddParams args) throws SVNException, IOException {
final EntryUpdater parent = getParent(args.parentToken);
final VcsDeltaConsumer deltaConsumer;
context.checkWrite(StringHelper.joinPath(parent.entry.getFullPath(), args.name));
if (args.copyParams.copyFrom != null) {
log.debug("Copy file: {} (rev: {}) from {} (rev: {})", parent, args.copyParams.copyFrom, args.copyParams.rev);
final VcsFile file = context.getFile(args.copyParams.rev, args.copyParams.copyFrom);
if (file == null) {
throw new SVNException(SVNErrorMessage.create(SVNErrorCode.ENTRY_NOT_FOUND, "Can't find path: " + args.copyParams.copyFrom + "@" + args.copyParams.rev));
}
deltaConsumer = writer.modifyFile(parent.entry, args.name, file);
} else {
log.debug("Add file: {}", parent);
deltaConsumer = writer.createFile(parent.entry, args.name);
}
files.put(args.token, new FileUpdater(deltaConsumer));
parent.changes.add(treeBuilder -> treeBuilder.saveFile(StringHelper.baseName(args.name), deltaConsumer, false));
}
private void deleteEntry(@NotNull SessionContext context, @NotNull DeleteParams args) throws SVNException, IOException {
final EntryUpdater parent = getParent(args.parentToken);
final int rev = args.rev.length > 0 ? args.rev[0] : -1;
context.checkWrite(StringHelper.joinPath(parent.entry.getFullPath(), args.name));
log.debug("Delete entry: {} (rev: {})", args.name, rev);
final VcsFile entry = parent.getEntry(StringHelper.baseName(args.name));
if (parent.head && (rev >= 0) && (parent.source != null)) {
checkUpToDate(entry, rev, true);
}
parent.changes.add(treeBuilder -> treeBuilder.delete(entry.getFileName()));
}
private void openFile(@NotNull SessionContext context, @NotNull OpenParams args) throws SVNException, IOException {
final EntryUpdater parent = getParent(args.parentToken);
final int rev = args.rev.length > 0 ? args.rev[0] : -1;
context.checkWrite(StringHelper.joinPath(parent.entry.getFullPath(), args.name));
log.debug("Modify file: {} (rev: {})", args.name, rev);
VcsFile vcsFile = parent.getEntry(StringHelper.baseName(args.name));
final VcsDeltaConsumer deltaConsumer = writer.modifyFile(parent.entry, vcsFile.getFileName(), vcsFile);
files.put(args.token, new FileUpdater(deltaConsumer));
if (parent.head && (rev >= 0)) {
checkUpToDate(vcsFile, rev, true);
}
parent.changes.add(treeBuilder -> treeBuilder.saveFile(StringHelper.baseName(args.name), deltaConsumer, true));
}
private void checkUpToDate(@NotNull VcsFile vcsFile, int rev, boolean checkLock) throws IOException, SVNException {
if (vcsFile.getLastChange().getId() > rev) {
throw new SVNException(SVNErrorMessage.create(SVNErrorCode.WC_NOT_UP_TO_DATE, "Working copy is not up-to-date: " + vcsFile.getFullPath()));
}
rootEntry.changes.add(treeBuilder -> treeBuilder.checkUpToDate(vcsFile.getFullPath(), rev, checkLock));
}
private void closeFile(@NotNull SessionContext context, @NotNull ChecksumParams args) throws SVNException, IOException {
final FileUpdater file = files.remove(args.token);
if (file == null) {
throw new SVNException(SVNErrorMessage.create(SVNErrorCode.ILLEGAL_TARGET, "Invalid file token: " + args.token));
}
if (args.checksum.length != 0) {
file.deltaConsumer.validateChecksum(args.checksum[0]);
}
}
private void deltaApply(@NotNull SessionContext context, @NotNull ChecksumParams args) throws SVNException, IOException {
getFile(args.token).deltaConsumer.applyTextDelta(null, args.checksum.length == 0 ? null : args.checksum[0]);
}
private void deltaChunk(@NotNull SessionContext context, @NotNull DeltaChunkParams args) throws SVNException, IOException {
getFile(args.token).reader.nextWindow(args.chunk, 0, args.chunk.length, "", getFile(args.token).deltaConsumer);
}
private void deltaEnd(@NotNull SessionContext context, @NotNull TokenParams args) throws SVNException, IOException {
getFile(args.token).deltaConsumer.textDeltaEnd(null);
}
@NotNull
private FileUpdater getFile(@NotNull String token) throws SVNException {
final FileUpdater file = files.get(token);
if (file == null) {
throw new SVNException(SVNErrorMessage.create(SVNErrorCode.ILLEGAL_TARGET, "Invalid file token: " + token));
}
return file;
}
@NotNull
private EntryUpdater getParent(@NotNull String parentToken) throws SVNException {
final EntryUpdater parent = paths.get(parentToken);
if (parent == null) {
throw new SVNException(SVNErrorMessage.create(SVNErrorCode.ILLEGAL_TARGET, "Invalid path token: " + parentToken));
}
return parent;
}
private void closeDir(@NotNull SessionContext context, @NotNull TokenParams args) throws SVNException {
paths.remove(args.token);
}
private void abortEdit(@NotNull SessionContext context, @NotNull NoParams args) throws IOException {
final SvnServerWriter writer = context.getWriter();
writer
.listBegin()
.word("success")
.listBegin()
.listEnd()
.listEnd();
}
private void closeEdit(@NotNull SessionContext context, @NotNull NoParams args) throws IOException, SVNException {
if (context.getUser().isAnonymous()) {
throw new SVNException(SVNErrorMessage.create(SVNErrorCode.RA_NOT_AUTHORIZED, "Anonymous users cannot create commits"));
}
if (!paths.isEmpty()) {
throw new SVNException(SVNErrorMessage.create(SVNErrorCode.INCOMPLETE_DATA, "Found not closed directory tokens: " + paths.keySet()));
}
if (!files.isEmpty()) {
throw new SVNException(SVNErrorMessage.create(SVNErrorCode.INCOMPLETE_DATA, "Found not closed file tokens: " + files.keySet()));
}
final VcsRevision revision = context.getRepository().wrapLockWrite((lockManager) -> {
final List<LockDesc> oldLocks = getLocks(lockManager, locks);
for (int pass = 0; ; ++pass) {
if (pass >= MAX_PASS_COUNT) {
throw new SVNException(SVNErrorMessage.create(SVNErrorCode.CANCELLED, "Cant commit changes to upstream repository."));
}
final VcsRevision newRevision = updateDir(writer.createCommitBuilder(lockManager, locks), rootEntry).commit(context.getUser(), message);
if (newRevision != null) {
if (keepLocks) {
lockManager.renewLocks(oldLocks.toArray(new LockDesc[oldLocks.size()]));
}
return newRevision;
}
}
});
context.push(new CheckPermissionStep((svnContext) -> complete(svnContext, revision), null));
final SvnServerWriter writer = context.getWriter();
writer
.listBegin()
.word("success")
.listBegin()
.listEnd()
.listEnd();
}
@NotNull
private List<LockDesc> getLocks(LockManagerWrite lockManager, Map<String, String> locks) {
final List<LockDesc> result = new ArrayList<>();
for (Map.Entry<String, String> entry : locks.entrySet()) {
final LockDesc lock = lockManager.getLock(entry.getKey());
if (lock != null && lock.getToken().equals(entry.getValue())) {
result.add(lock);
}
}
return result;
}
private void complete(@NotNull SessionContext context, @NotNull VcsRevision revision) throws IOException, SVNException {
final SvnServerWriter writer = context.getWriter();
writer
.listBegin()
.number(revision.getId()) // rev number
.listBegin().stringNullable(revision.getDateString()).listEnd() // date
.listBegin().stringNullable(revision.getAuthor()).listEnd()
.listBegin().listEnd()
.listEnd();
}
private void editorCommand(@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("Editor command: {}", cmd);
BaseCmd command = exitCommands.get(cmd);
if (command == null) {
context.push(this::editorCommand);
command = commands.get(cmd);
}
if ((command != null) && (!aborted)) {
try {
Object param = MessageParser.parse(command.getArguments(), parser);
parser.readToken(ListEndToken.class);
//noinspection unchecked
command.process(context, param);
} catch (SVNException e) {
if (e.getErrorMessage().getErrorCode() != SVNErrorCode.RA_NOT_AUTHORIZED) {
log.warn("Found error in cmd " + cmd, e);
}
aborted = true;
throw e;
} catch (Throwable e) {
log.warn("Found error in cmd " + cmd, e);
aborted = true;
throw e;
}
} else if (command != null) {
parser.skipItems();
} else {
log.error("Unsupported command: {}", cmd);
BaseCmd.sendError(writer, SVNErrorMessage.create(SVNErrorCode.RA_SVN_UNKNOWN_CMD, "Unsupported command: " + cmd));
parser.skipItems();
}
}
}
}