File lock for svn (#1847)

Implements the file lock command for SVN.
This commit is contained in:
René Pfeuffer
2021-11-11 10:58:26 +01:00
committed by GitHub
parent e255eafa29
commit 7a72359bc9
7 changed files with 516 additions and 2 deletions

View File

@@ -0,0 +1,166 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.repository.spi;
import org.apache.shiro.SecurityUtils;
import org.tmatesoft.svn.core.SVNException;
import org.tmatesoft.svn.core.SVNLock;
import org.tmatesoft.svn.core.auth.ISVNAuthenticationManager;
import org.tmatesoft.svn.core.io.SVNRepository;
import sonia.scm.repository.InternalRepositoryException;
import sonia.scm.repository.api.FileLock;
import sonia.scm.repository.api.FileLockedException;
import sonia.scm.repository.api.LockCommandResult;
import sonia.scm.repository.api.UnlockCommandResult;
import java.time.Instant;
import java.util.Arrays;
import java.util.Collection;
import java.util.Optional;
import java.util.function.Predicate;
import static java.util.Collections.singletonMap;
import static java.util.Optional.ofNullable;
import static java.util.stream.Collectors.toList;
import static org.tmatesoft.svn.core.auth.BasicAuthenticationManager.newInstance;
import static sonia.scm.ContextEntry.ContextBuilder.entity;
public class SvnFileLockCommand extends AbstractSvnCommand implements FileLockCommand {
private static final String LOCK_MESSAGE_PREFIX = "locked by SCM-Manager for ";
protected SvnFileLockCommand(SvnContext context) {
super(context);
}
@Override
public LockCommandResult lock(LockCommandRequest request) {
String fileToLock = request.getFile();
try {
doLock(fileToLock);
return new LockCommandResult(true);
} catch (SVNException e) {
throw new InternalRepositoryException(entity("File", fileToLock).in(repository), "failed to lock file", e);
}
}
private void doLock(String fileToLock) throws SVNException {
SVNRepository svnRepository = open();
String currentUser = initializeAuthentication(svnRepository);
getFileLock(fileToLock, svnRepository)
.ifPresent(lock -> {
throw new FileLockedException(repository.getNamespaceAndName(), lock);
});
svnRepository.lock(singletonMap(fileToLock, null), LOCK_MESSAGE_PREFIX + currentUser, false, null);
}
@Override
public UnlockCommandResult unlock(UnlockCommandRequest request) {
return unlock(request, any -> true);
}
public UnlockCommandResult unlock(UnlockCommandRequest request, Predicate<SvnFileLock> predicate) {
String fileToUnlock = request.getFile();
try {
doUnlock(request, predicate);
return new UnlockCommandResult(true);
} catch (SVNException e) {
throw new InternalRepositoryException(entity("File", fileToUnlock).in(repository), "failed to unlock file", e);
}
}
private void doUnlock(UnlockCommandRequest request, Predicate<SvnFileLock> predicate) throws SVNException {
String fileToUnlock = request.getFile();
SVNRepository svnRepository = open();
initializeAuthentication(svnRepository);
Optional<SvnFileLock> fileLock = getFileLock(fileToUnlock, svnRepository);
if (fileLock.isPresent()) {
SvnFileLock lock = fileLock.get();
if (shouldPreventUnlock(request, predicate, lock)) {
throw new FileLockedException(repository.getNamespaceAndName(), lock);
}
svnRepository.unlock(singletonMap(fileToUnlock, lock.getId()), request.isForce(), null);
}
}
@Override
public Optional<FileLock> status(LockStatusCommandRequest request) {
String file = request.getFile();
try {
return getFileLock(file, open()).map(lock -> lock);
} catch (SVNException e) {
throw new InternalRepositoryException(entity("File", file).in(repository), "failed to read lock status", e);
}
}
@Override
public Collection<FileLock> getAll() {
try {
SVNRepository svnRepository = open();
return Arrays.stream(svnRepository.getLocks("/"))
.map(this::createLock)
.collect(toList());
} catch (SVNException e) {
throw new InternalRepositoryException(repository, "failed to read locks", e);
}
}
private Optional<SvnFileLock> getFileLock(String file, SVNRepository svnRepository) throws SVNException {
return ofNullable(svnRepository.getLock(file)).map(this::createLock);
}
private SvnFileLock createLock(SVNLock lock) {
String path = lock.getPath();
if (path.startsWith("/")) {
path = path.substring(1);
}
return new SvnFileLock(path, lock.getID(), lock.getOwner(), lock.getCreationDate().toInstant(), lock.getComment());
}
private boolean shouldPreventUnlock(UnlockCommandRequest request, Predicate<SvnFileLock> predicate, SvnFileLock lock) {
return !request.isForce() && !getCurrentUser().equals(lock.getUserId()) || !predicate.test(lock);
}
private String initializeAuthentication(SVNRepository svnRepository) {
String currentUser = getCurrentUser();
ISVNAuthenticationManager authenticationManager = newInstance(currentUser, null);
svnRepository.setAuthenticationManager(authenticationManager);
return currentUser;
}
private String getCurrentUser() {
return SecurityUtils.getSubject().getPrincipal().toString();
}
static class SvnFileLock extends FileLock {
private SvnFileLock(String path, String id, String userId, Instant timestamp, String message) {
super(path, id, userId, timestamp, message);
}
boolean isCreatedByScmManager() {
return getMessage().filter(message -> message.startsWith(LOCK_MESSAGE_PREFIX)).isPresent();
}
}
}

View File

@@ -34,7 +34,6 @@ import org.tmatesoft.svn.core.wc.SVNClientManager;
import org.tmatesoft.svn.core.wc.SVNWCClient;
import org.tmatesoft.svn.core.wc.SVNWCUtil;
import sonia.scm.ConcurrentModificationException;
import sonia.scm.ContextEntry;
import sonia.scm.repository.InternalRepositoryException;
import sonia.scm.repository.Repository;
import sonia.scm.repository.SvnWorkingCopyFactory;
@@ -57,10 +56,13 @@ public class SvnModifyCommand implements ModifyCommand {
private final SvnWorkingCopyFactory workingCopyFactory;
private final Repository repository;
private final SvnFileLockCommand lockCommand;
SvnModifyCommand(SvnContext context, SvnWorkingCopyFactory workingCopyFactory) {
this.context = context;
this.repository = context.getRepository();
this.workingCopyFactory = workingCopyFactory;
this.lockCommand = new SvnFileLockCommand(context);
}
@Override
@@ -137,6 +139,7 @@ public class SvnModifyCommand implements ModifyCommand {
@Override
public void doScmDelete(String toBeDeleted) {
unlock(toBeDeleted);
try {
wcClient.doDelete(new File(workingDirectory, toBeDeleted), true, true, false);
} catch (SVNException e) {
@@ -146,6 +149,7 @@ public class SvnModifyCommand implements ModifyCommand {
@Override
public void addFileToScm(String name, Path file) {
unlock(name);
try {
wcClient.doAdd(
file.toFile(),
@@ -161,6 +165,19 @@ public class SvnModifyCommand implements ModifyCommand {
}
}
private void unlock(String toBeDeleted) {
lockCommand.unlock(
createUnlockRequest(toBeDeleted),
SvnFileLockCommand.SvnFileLock::isCreatedByScmManager
);
}
private UnlockCommandRequest createUnlockRequest(String toBeDeleted) {
UnlockCommandRequest request = new UnlockCommandRequest();
request.setFile(toBeDeleted);
return request;
}
@Override
public File getWorkDir() {
return workingDirectory;

View File

@@ -55,7 +55,8 @@ public class SvnRepositoryServiceProvider extends RepositoryServiceProvider {
Command.MODIFY,
Command.LOOKUP,
Command.FULL_HEALTH_CHECK,
Command.MIRROR
Command.MIRROR,
Command.FILE_LOCK
);
public static final Set<Feature> FEATURES = EnumSet.of(
@@ -155,4 +156,9 @@ public class SvnRepositoryServiceProvider extends RepositoryServiceProvider {
public MirrorCommand getMirrorCommand() {
return new SvnMirrorCommand(context, trustManager, globalProxyConfiguration);
}
@Override
public FileLockCommand getFileLockCommand() {
return new SvnFileLockCommand(context);
}
}

View File

@@ -0,0 +1,259 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.repository.spi;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.util.ThreadContext;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.tmatesoft.svn.core.SVNException;
import org.tmatesoft.svn.core.SVNLock;
import org.tmatesoft.svn.core.io.SVNRepository;
import sonia.scm.repository.api.FileLock;
import sonia.scm.repository.api.FileLockedException;
import sonia.scm.repository.api.LockCommandResult;
import sonia.scm.repository.api.UnlockCommandResult;
import java.util.Collection;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.Assert.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class SvnFileLockCommandTest extends AbstractSvnCommandTestBase {
@Before
public void mockDefaultSubject() {
mockSubject("trillian");
}
private void mockSubject(String name) {
Subject subject = mock(Subject.class);
ThreadContext.bind(subject);
when(subject.getPrincipal()).thenReturn(name);
}
@After
public void unbindSubject() {
ThreadContext.unbindSubject();
}
@Test
public void shouldLockFile() throws SVNException {
SvnFileLockCommand lockCommand = new SvnFileLockCommand(createContext());
LockCommandRequest request = new LockCommandRequest();
request.setFile("a.txt");
LockCommandResult lockResult = lockCommand.lock(request);
assertThat(lockResult.isSuccessful()).isTrue();
assertSingleLock("a.txt", "trillian");
}
@Test
public void shouldUnlockFile() throws SVNException {
createLock("a.txt");
SvnFileLockCommand lockCommand = new SvnFileLockCommand(createContext());
UnlockCommandRequest request = new UnlockCommandRequest();
request.setFile("a.txt");
UnlockCommandResult unlockResult = lockCommand.unlock(request);
assertThat(unlockResult.isSuccessful()).isTrue();
assertThat(getLocks("a.txt")).isEmpty();
}
@Test
public void shouldUnlockFileOnlyIfPredicateMatches() throws SVNException {
createLock("a.txt");
SvnFileLockCommand lockCommand = new SvnFileLockCommand(createContext());
UnlockCommandRequest request = new UnlockCommandRequest();
request.setFile("a.txt");
assertThrows(FileLockedException.class, () -> lockCommand.unlock(request, lock -> false));
assertThat(getLocks("a.txt")).isNotEmpty();
lockCommand.unlock(request, lock -> true);
assertThat(getLocks("a.txt")).isEmpty();
}
@Test
public void shouldNotFailUnlockingNotLockedFile() throws SVNException {
SvnFileLockCommand lockCommand = new SvnFileLockCommand(createContext());
UnlockCommandRequest request = new UnlockCommandRequest();
request.setFile("a.txt");
UnlockCommandResult unlockResult = lockCommand.unlock(request);
assertThat(unlockResult.isSuccessful()).isTrue();
assertThat(getLocks("a.txt")).isEmpty();
}
@Test
public void shouldFailToUnlockFileLockedByOtherUser() throws SVNException {
createLockFromOtherUser();
SvnFileLockCommand lockCommand = new SvnFileLockCommand(createContext());
UnlockCommandRequest request = new UnlockCommandRequest();
request.setFile("a.txt");
assertThrows(
"File a.txt locked by dent.",
FileLockedException.class,
() -> lockCommand.unlock(request));
assertThat(getLocks("a.txt")).isNotEmpty();
}
@Test
public void shouldUnlockFileLockedByOtherUserWithForce() throws SVNException {
createLockFromOtherUser();
SvnFileLockCommand lockCommand = new SvnFileLockCommand(createContext());
UnlockCommandRequest request = new UnlockCommandRequest();
request.setFile("a.txt");
request.setForce(true);
UnlockCommandResult unlockResult = lockCommand.unlock(request);
assertThat(unlockResult.isSuccessful()).isTrue();
assertThat(getLocks("a.txt")).isEmpty();
}
@Test
public void shouldNotOverwriteLockFromOtherUser() {
createLockFromOtherUser();
SvnFileLockCommand lockCommand = new SvnFileLockCommand(createContext());
LockCommandRequest request = new LockCommandRequest();
request.setFile("a.txt");
assertThrows(
"File a.txt locked by dent.",
FileLockedException.class,
() -> lockCommand.lock(request));
}
@Test
public void shouldNotRemoveLockFromOtherUserEvenWithPredicate() {
createLockFromOtherUser();
SvnFileLockCommand lockCommand = new SvnFileLockCommand(createContext());
UnlockCommandRequest request = new UnlockCommandRequest();
request.setFile("a.txt");
assertThrows(
"File a.txt locked by dent.",
FileLockedException.class,
() -> lockCommand.unlock(request, lock -> true));
}
@Test
public void shouldGetEmptyLocksWithoutLocks() {
SvnFileLockCommand lockCommand = new SvnFileLockCommand(createContext());
Collection<FileLock> locks = lockCommand.getAll();
assertThat(locks).isEmpty();
}
@Test
public void shouldGetLocks() {
createLock("a.txt");
createLock("c/e.txt");
SvnFileLockCommand lockCommand = new SvnFileLockCommand(createContext());
Collection<FileLock> locks = lockCommand.getAll();
assertThat(locks)
.hasSize(2)
.extracting("path")
.containsExactly("a.txt", "c/e.txt");
}
@Test
public void shouldGetNoStatusForUnlockedFile() {
SvnFileLockCommand lockCommand = new SvnFileLockCommand(createContext());
LockStatusCommandRequest lockStatusCommandRequest = new LockStatusCommandRequest();
lockStatusCommandRequest.setFile("a.txt");
Optional<FileLock> status = lockCommand.status(lockStatusCommandRequest);
assertThat(status).isEmpty();
}
@Test
public void shouldGetStatusForLockedFile() {
createLock("a.txt");
SvnFileLockCommand lockCommand = new SvnFileLockCommand(createContext());
LockStatusCommandRequest lockStatusCommandRequest = new LockStatusCommandRequest();
lockStatusCommandRequest.setFile("a.txt");
Optional<FileLock> status = lockCommand.status(lockStatusCommandRequest);
assertThat(status)
.get()
.extracting("userId")
.isEqualTo("trillian");
}
private void createLockFromOtherUser() {
mockSubject("dent");
createLock("a.txt");
mockDefaultSubject();
}
private void createLock(String path) {
SvnFileLockCommand lockCommand = new SvnFileLockCommand(createContext());
LockCommandRequest request = new LockCommandRequest();
request.setFile(path);
lockCommand.lock(request);
}
private void assertSingleLock(String path, String user) throws SVNException {
SVNLock[] locks = getLocks(path);
assertThat(locks)
.extracting("path")
.containsExactly("/" +
path);
assertThat(locks)
.extracting("owner")
.containsExactly(user);
}
private SVNLock[] getLocks(String path) throws SVNException {
SVNRepository svnRepository = createContext().open();
return svnRepository.getLocks(path);
}
}

View File

@@ -35,12 +35,15 @@ import org.junit.rules.TemporaryFolder;
import sonia.scm.AlreadyExistsException;
import sonia.scm.ConcurrentModificationException;
import sonia.scm.repository.Person;
import sonia.scm.repository.api.FileLock;
import sonia.scm.repository.api.FileLockedException;
import sonia.scm.repository.work.NoneCachingWorkingCopyPool;
import sonia.scm.repository.work.WorkdirProvider;
import sonia.scm.repository.work.WorkingCopy;
import java.io.File;
import java.io.IOException;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -191,4 +194,54 @@ public class SvnModifyCommandTest extends AbstractSvnCommandTestBase {
// nothing to check here; we just want to ensure that no exception is thrown
}
@Test
public void shouldFailIfLockedByOtherPerson() {
Subject subject = mock(Subject.class);
when(subject.getPrincipal()).thenReturn("Perrin");
ThreadContext.bind(subject);
lockFile();
initSecurityManager();
ModifyCommandRequest request = new ModifyCommandRequest();
request.addRequest(new ModifyCommandRequest.DeleteFileRequest("a.txt", false));
request.setCommitMessage("this should not happen");
request.setAuthor(new Person("Arthur Dent", "dent@hitchhiker.com"));
assertThrows(FileLockedException.class, () -> svnModifyCommand.execute(request));
WorkingCopy<File, File> workingCopy = workingCopyFactory.createWorkingCopy(context, null);
assertThat(new File(workingCopy.getWorkingRepository().getAbsolutePath() + "/a.txt")).exists();
}
@Test
public void shouldSucceedIfLockedByUser() {
lockFile();
ModifyCommandRequest request = new ModifyCommandRequest();
request.addRequest(new ModifyCommandRequest.DeleteFileRequest("a.txt", false));
request.setCommitMessage("this should not happen");
request.setAuthor(new Person("Arthur Dent", "dent@hitchhiker.com"));
svnModifyCommand.execute(request);
WorkingCopy<File, File> workingCopy = workingCopyFactory.createWorkingCopy(context, null);
assertThat(new File(workingCopy.getWorkingRepository().getAbsolutePath() + "/a.txt")).doesNotExist();
assertThat(getLock()).isEmpty();
}
private void lockFile() {
SvnFileLockCommand svnFileLockCommand = new SvnFileLockCommand(context);
LockCommandRequest lockRequest = new LockCommandRequest();
lockRequest.setFile("a.txt");
svnFileLockCommand.lock(lockRequest);
}
private Optional<FileLock> getLock() {
SvnFileLockCommand svnFileLockCommand = new SvnFileLockCommand(context);
LockStatusCommandRequest request = new LockStatusCommandRequest();
request.setFile("a.txt");
return svnFileLockCommand.status(request);
}
}