diff --git a/gradle/changelog/mirror_lfs.yaml b/gradle/changelog/mirror_lfs.yaml new file mode 100644 index 0000000000..e3ec80a377 --- /dev/null +++ b/gradle/changelog/mirror_lfs.yaml @@ -0,0 +1,2 @@ +- type: added + description: Mirror LFS files for git ([#2075](https://github.com/scm-manager/scm-manager/pull/2075)) diff --git a/scm-core/src/main/java/sonia/scm/net/HttpConnectionOptions.java b/scm-core/src/main/java/sonia/scm/net/HttpConnectionOptions.java index a9954f9379..d05fb3dc83 100644 --- a/scm-core/src/main/java/sonia/scm/net/HttpConnectionOptions.java +++ b/scm-core/src/main/java/sonia/scm/net/HttpConnectionOptions.java @@ -33,6 +33,9 @@ import javax.annotation.Nullable; import javax.net.ssl.KeyManager; import java.net.URL; import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; import java.util.Optional; import java.util.concurrent.TimeUnit; @@ -65,6 +68,7 @@ public final class HttpConnectionOptions { private int connectionTimeout = DEFAULT_CONNECTION_TIMEOUT; private int readTimeout = DEFAULT_READ_TIMEOUT; private boolean ignoreProxySettings = false; + private Map connectionProperties = new HashMap<>(); /** * Returns optional local proxy configuration. @@ -86,6 +90,10 @@ public final class HttpConnectionOptions { return Optional.empty(); } + public Map getConnectionProperties() { + return Collections.unmodifiableMap(connectionProperties); + } + /** * Disable certificate validation. * WARNING: This option should only be used for internal test. @@ -162,4 +170,15 @@ public final class HttpConnectionOptions { this.ignoreProxySettings = true; return this; } + + /** + * Add a request property that will be converted to headers in the request. + * @param key The property (aka header) name (eg. "User-Agent"). + * @param value The value of the property. + * @return {@code this} + */ + public HttpConnectionOptions addRequestProperty(String key, String value) { + connectionProperties.put(key, value); + return this; + } } diff --git a/scm-core/src/main/java/sonia/scm/net/HttpURLConnectionFactory.java b/scm-core/src/main/java/sonia/scm/net/HttpURLConnectionFactory.java index 49704cf6f0..55b6fc3c9c 100644 --- a/scm-core/src/main/java/sonia/scm/net/HttpURLConnectionFactory.java +++ b/scm-core/src/main/java/sonia/scm/net/HttpURLConnectionFactory.java @@ -181,6 +181,8 @@ public final class HttpURLConnectionFactory { if (connection instanceof HttpsURLConnection) { applySSLSettings((HttpsURLConnection) connection); } + options.getConnectionProperties() + .forEach(connection::setRequestProperty); return connection; } diff --git a/scm-core/src/main/java/sonia/scm/repository/api/MirrorCommandBuilder.java b/scm-core/src/main/java/sonia/scm/repository/api/MirrorCommandBuilder.java index 5aaef43bc2..128559b35c 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/MirrorCommandBuilder.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/MirrorCommandBuilder.java @@ -58,6 +58,7 @@ public final class MirrorCommandBuilder { private Collection credentials = emptyList(); private List publicKeys = emptyList(); private MirrorFilter filter = new MirrorFilter() {}; + private boolean ignoreLfs; @Nullable private ProxyConfiguration proxyConfiguration; @@ -99,6 +100,16 @@ public final class MirrorCommandBuilder { return this; } + /** + * If set to true, lfs files will not be mirrored. Defaults to false. + * @return This builder instance + * @since 2.37.0 + */ + public MirrorCommandBuilder setIgnoreLfs(boolean ignoreLfs) { + this.ignoreLfs = ignoreLfs; + return this; + } + /** * Set the proxy configuration which should be used to access the source repository of the mirror. * If not proxy configuration is set the global configuration should be used instead. @@ -130,6 +141,7 @@ public final class MirrorCommandBuilder { mirrorCommandRequest.setFilter(filter); mirrorCommandRequest.setPublicKeys(publicKeys); mirrorCommandRequest.setProxyConfiguration(proxyConfiguration); + mirrorCommandRequest.setIgnoreLfs(ignoreLfs); Preconditions.checkArgument(mirrorCommandRequest.isValid(), "source url has to be specified"); return mirrorCommandRequest; } diff --git a/scm-core/src/main/java/sonia/scm/repository/api/MirrorCommandResult.java b/scm-core/src/main/java/sonia/scm/repository/api/MirrorCommandResult.java index 3fb5ff76e2..354950b1bc 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/MirrorCommandResult.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/MirrorCommandResult.java @@ -35,11 +35,17 @@ public final class MirrorCommandResult { private final ResultType result; private final List log; private final Duration duration; + private final LfsUpdateResult lfsUpdateResult; public MirrorCommandResult(ResultType result, List log, Duration duration) { + this(result, log, duration, null); + } + + public MirrorCommandResult(ResultType result, List log, Duration duration, LfsUpdateResult lfsUpdateResult) { this.result = result; this.log = log; this.duration = duration; + this.lfsUpdateResult = lfsUpdateResult; } public ResultType getResult() { @@ -54,9 +60,38 @@ public final class MirrorCommandResult { return duration; } + public LfsUpdateResult getLfsUpdateResult() { + return lfsUpdateResult; + } + public enum ResultType { OK, REJECTED_UPDATES, FAILED } + + public static class LfsUpdateResult { + private int overallCount = 0; + private int failureCount = 0; + + public void increaseOverallCount() { + overallCount++; + } + + public void increaseFailureCount() { + failureCount++; + } + + public int getOverallCount() { + return overallCount; + } + + public int getFailureCount() { + return failureCount; + } + + public boolean hasFailures() { + return failureCount > 0; + } + } } diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/MirrorCommandRequest.java b/scm-core/src/main/java/sonia/scm/repository/spi/MirrorCommandRequest.java index 0fb9594cf9..eadc48ac83 100644 --- a/scm-core/src/main/java/sonia/scm/repository/spi/MirrorCommandRequest.java +++ b/scm-core/src/main/java/sonia/scm/repository/spi/MirrorCommandRequest.java @@ -53,6 +53,7 @@ public final class MirrorCommandRequest { @Nullable private ProxyConfiguration proxyConfiguration; + private boolean ignoreLfs; public String getSourceUrl() { return sourceUrl; @@ -86,6 +87,14 @@ public final class MirrorCommandRequest { this.filter = filter; } + public void setIgnoreLfs(boolean ignoreLfs) { + this.ignoreLfs = ignoreLfs; + } + + public boolean isIgnoreLfs() { + return ignoreLfs; + } + public boolean isValid() { return StringUtils.isNotBlank(sourceUrl); } diff --git a/scm-core/src/test/java/sonia/scm/net/HttpURLConnectionFactoryTest.java b/scm-core/src/test/java/sonia/scm/net/HttpURLConnectionFactoryTest.java index 9652dbac1f..847c6948aa 100644 --- a/scm-core/src/test/java/sonia/scm/net/HttpURLConnectionFactoryTest.java +++ b/scm-core/src/test/java/sonia/scm/net/HttpURLConnectionFactoryTest.java @@ -345,6 +345,17 @@ class HttpURLConnectionFactoryTest { assertThat(keyManagers).containsOnly(keyManager); } + @Test + void shouldSetGivenRequestProperties() throws IOException { + HttpConnectionOptions options = new HttpConnectionOptions(); + options.addRequestProperty("valid_until", "end of the universe"); + + HttpURLConnection connection = connectionFactory.create(new URL("https://hitchhiker.org"), options); + + verify(connection).setRequestProperty("valid_until", "end of the universe"); + assertThat(usedProxy).isNull(); + } + private TrustManager[] usedTrustManagers(HttpURLConnection connection) throws KeyManagementException { ArgumentCaptor captor = ArgumentCaptor.forClass(TrustManager[].class); assertThat(connection).isInstanceOfSatisfying( diff --git a/scm-it/gradle.lockfile b/scm-it/gradle.lockfile index f5860a1f80..ee55f44a58 100644 --- a/scm-it/gradle.lockfile +++ b/scm-it/gradle.lockfile @@ -209,12 +209,18 @@ org.reactivestreams:reactive-streams:1.0.3=testCompileClasspath,testCompileClass org.slf4j:jcl-over-slf4j:1.7.30=testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy org.slf4j:slf4j-api:1.7.30=testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy org.tmatesoft.sqljet:sqljet:1.1.14=testRuntimeClasspath,testRuntimeClasspathCopy -sonia.jgit:org.eclipse.jgit.gpg.bc:5.11.1.202105131744-r-scm1=testRuntimeClasspath,testRuntimeClasspathCopy -sonia.jgit:org.eclipse.jgit.http.apache:5.11.1.202105131744-r-scm1=testRuntimeClasspath,testRuntimeClasspathCopy -sonia.jgit:org.eclipse.jgit.http.server:5.11.1.202105131744-r-scm1=testRuntimeClasspath,testRuntimeClasspathCopy -sonia.jgit:org.eclipse.jgit.lfs.server:5.11.1.202105131744-r-scm1=testRuntimeClasspath,testRuntimeClasspathCopy -sonia.jgit:org.eclipse.jgit.lfs:5.11.1.202105131744-r-scm1=testRuntimeClasspath,testRuntimeClasspathCopy -sonia.jgit:org.eclipse.jgit:5.11.1.202105131744-r-scm1=testCompileClasspath,testCompileClasspathCopy,testRuntimeClasspath,testRuntimeClasspathCopy +sonia.jgit:org.eclipse.jgit.gpg.bc:5.11.1.202105131744-r-scm2=testRuntimeClasspath +sonia.jgit:org.eclipse.jgit.gpg.bc:5.11.1.202105131744-r-scm3=testRuntimeClasspathCopy +sonia.jgit:org.eclipse.jgit.http.apache:5.11.1.202105131744-r-scm2=testRuntimeClasspath +sonia.jgit:org.eclipse.jgit.http.apache:5.11.1.202105131744-r-scm3=testRuntimeClasspathCopy +sonia.jgit:org.eclipse.jgit.http.server:5.11.1.202105131744-r-scm2=testRuntimeClasspath +sonia.jgit:org.eclipse.jgit.http.server:5.11.1.202105131744-r-scm3=testRuntimeClasspathCopy +sonia.jgit:org.eclipse.jgit.lfs.server:5.11.1.202105131744-r-scm2=testRuntimeClasspath +sonia.jgit:org.eclipse.jgit.lfs.server:5.11.1.202105131744-r-scm3=testRuntimeClasspathCopy +sonia.jgit:org.eclipse.jgit.lfs:5.11.1.202105131744-r-scm2=testRuntimeClasspath +sonia.jgit:org.eclipse.jgit.lfs:5.11.1.202105131744-r-scm3=testRuntimeClasspathCopy +sonia.jgit:org.eclipse.jgit:5.11.1.202105131744-r-scm2=testCompileClasspath,testRuntimeClasspath +sonia.jgit:org.eclipse.jgit:5.11.1.202105131744-r-scm3=testCompileClasspathCopy,testRuntimeClasspathCopy sonia.svnkit:svnkit-dav:1.10.3-scm2=testRuntimeClasspath,testRuntimeClasspathCopy sonia.svnkit:svnkit:1.10.3-scm2=testRuntimeClasspath,testRuntimeClasspathCopy empty=annotationProcessor,annotationProcessorCopy,archives,archivesCopy,compileClasspath,compileClasspathCopy,corePlugin,corePluginCopy,default,defaultCopy,itPlugin,itPluginCopy,itWebApp,itWebAppCopy,runtimeClasspath,runtimeClasspathCopy,testAnnotationProcessor,testAnnotationProcessorCopy diff --git a/scm-plugins/scm-git-plugin/build.gradle b/scm-plugins/scm-git-plugin/build.gradle index 5dc2fc28e2..024fb7b63f 100644 --- a/scm-plugins/scm-git-plugin/build.gradle +++ b/scm-plugins/scm-git-plugin/build.gradle @@ -26,7 +26,7 @@ plugins { id 'org.scm-manager.smp' version '0.11.0' } -def jgitVersion = '5.11.1.202105131744-r-scm1' +def jgitVersion = '5.11.1.202105131744-r-scm3' dependencies { // required by scm-it diff --git a/scm-plugins/scm-git-plugin/gradle.lockfile b/scm-plugins/scm-git-plugin/gradle.lockfile index e0f43d24d2..11c8816694 100644 --- a/scm-plugins/scm-git-plugin/gradle.lockfile +++ b/scm-plugins/scm-git-plugin/gradle.lockfile @@ -154,13 +154,19 @@ org.slf4j:jcl-over-slf4j:1.7.30=compileClasspath,default,runtimeClasspath,runtim org.slf4j:slf4j-api:1.7.25=swaggerDeps org.slf4j:slf4j-api:1.7.30=annotationProcessor,compileClasspath,default,runtimeClasspath,runtimePluginElements,scmCoreDependency,testCompileClasspath,testRuntimeClasspath org.yaml:snakeyaml:1.26=swaggerDeps -sonia.jgit:org.eclipse.jgit.gpg.bc:5.11.1.202105131744-r-scm1=compileClasspath,default,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -sonia.jgit:org.eclipse.jgit.http.apache:5.11.1.202105131744-r-scm1=compileClasspath,default,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -sonia.jgit:org.eclipse.jgit.http.server:5.11.1.202105131744-r-scm1=compileClasspath,default,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -sonia.jgit:org.eclipse.jgit.junit.http:5.11.1.202105131744-r-scm1=testCompileClasspath,testRuntimeClasspath -sonia.jgit:org.eclipse.jgit.junit:5.11.1.202105131744-r-scm1=testCompileClasspath,testRuntimeClasspath -sonia.jgit:org.eclipse.jgit.lfs.server:5.11.1.202105131744-r-scm1=compileClasspath,default,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -sonia.jgit:org.eclipse.jgit.lfs:5.11.1.202105131744-r-scm1=compileClasspath,default,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -sonia.jgit:org.eclipse.jgit:5.11.1.202105131744-r-scm1=compileClasspath,default,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +sonia.jgit:org.eclipse.jgit.gpg.bc:5.11.1.202105131744-r-scm1=default +sonia.jgit:org.eclipse.jgit.gpg.bc:5.11.1.202105131744-r-scm3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +sonia.jgit:org.eclipse.jgit.http.apache:5.11.1.202105131744-r-scm1=default +sonia.jgit:org.eclipse.jgit.http.apache:5.11.1.202105131744-r-scm3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +sonia.jgit:org.eclipse.jgit.http.server:5.11.1.202105131744-r-scm1=default +sonia.jgit:org.eclipse.jgit.http.server:5.11.1.202105131744-r-scm3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +sonia.jgit:org.eclipse.jgit.junit.http:5.11.1.202105131744-r-scm3=testCompileClasspath,testRuntimeClasspath +sonia.jgit:org.eclipse.jgit.junit:5.11.1.202105131744-r-scm3=testCompileClasspath,testRuntimeClasspath +sonia.jgit:org.eclipse.jgit.lfs.server:5.11.1.202105131744-r-scm1=default +sonia.jgit:org.eclipse.jgit.lfs.server:5.11.1.202105131744-r-scm3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +sonia.jgit:org.eclipse.jgit.lfs:5.11.1.202105131744-r-scm1=default +sonia.jgit:org.eclipse.jgit.lfs:5.11.1.202105131744-r-scm3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +sonia.jgit:org.eclipse.jgit:5.11.1.202105131744-r-scm1=default +sonia.jgit:org.eclipse.jgit:5.11.1.202105131744-r-scm3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath sonia.scm:scm-webapp:2.32.2-SNAPSHOT=scmServer empty=archives,optionalPlugin,plugin diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMirrorCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMirrorCommand.java index ae513fc923..8acaf36782 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMirrorCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMirrorCommand.java @@ -61,6 +61,7 @@ import sonia.scm.repository.api.MirrorFilter; import sonia.scm.repository.api.MirrorFilter.Result; import sonia.scm.repository.api.UsernamePasswordCredential; import sonia.scm.store.ConfigurationStore; +import sonia.scm.web.lfs.LfsBlobStoreFactory; import javax.inject.Inject; import java.io.IOException; @@ -86,16 +87,18 @@ import static sonia.scm.repository.api.MirrorCommandResult.ResultType.OK; import static sonia.scm.repository.api.MirrorCommandResult.ResultType.REJECTED_UPDATES; /** - * Implementation of the mirror command for git. This implementation makes use of a special - * "ref" called mirror. A synchronization works in principal in the following way: + * Implementation of the mirror command for git. + * + * The general workflow is the same for the first call and all subsequent updates and looks like this: + * *
    - *
  1. The mirror reference is updated. This is done by calling the jgit equivalent of - *
    git fetch -pf  "refs/heads/*:refs/mirror/heads/*" "refs/tags/*:refs/mirror/tags/*"
    - *
  2. - *
  3. These updates are then presented to the filter. Here single updates can be rejected. - * Such rejected updates have to be reverted in the mirror, too. - *
  4. - *
  5. Accepted ref updates are copied to the "normal" refs.
  6. + *
  7. Create a local working copy for the repository.
  8. + *
  9. Fetch updates from the source and override all local refs in the working copy + * (like git fetch "refs/heads/*:refs/heads/*" "refs/tags/*:refs/tags/*").
  10. + *
  11. Iterate the updates and decide, whether to keep each single update or not. If an update is rejected, + * the reference is set to its old oid (or deleted, if this is a new reference).
  12. + *
  13. Push the changed references from the working copy to the repository.
  14. + *
  15. Release the working copy.
  16. *
*/ public class GitMirrorCommand extends AbstractGitCommand implements MirrorCommand { @@ -108,6 +111,7 @@ public class GitMirrorCommand extends AbstractGitCommand implements MirrorComman private final GitWorkingCopyFactory workingCopyFactory; private final GitHeadModifier gitHeadModifier; private final GitRepositoryConfigStoreProvider storeProvider; + private final LfsLoader lfsLoader; @Inject GitMirrorCommand(GitContext context, @@ -116,7 +120,8 @@ public class GitMirrorCommand extends AbstractGitCommand implements MirrorComman GitTagConverter gitTagConverter, GitWorkingCopyFactory workingCopyFactory, GitHeadModifier gitHeadModifier, - GitRepositoryConfigStoreProvider storeProvider) { + GitRepositoryConfigStoreProvider storeProvider, + LfsLoader lfsLoader) { super(context); this.mirrorHttpConnectionProvider = mirrorHttpConnectionProvider; this.converterFactory = converterFactory; @@ -124,6 +129,7 @@ public class GitMirrorCommand extends AbstractGitCommand implements MirrorComman this.workingCopyFactory = workingCopyFactory; this.gitHeadModifier = gitHeadModifier; this.storeProvider = storeProvider; + this.lfsLoader = lfsLoader; } @Override @@ -163,6 +169,7 @@ public class GitMirrorCommand extends AbstractGitCommand implements MirrorComman private final Git git; private final Collection deletedRefs = new ArrayList<>(); + private final MirrorCommandResult.LfsUpdateResult lfsUpdateResult = new MirrorCommandResult.LfsUpdateResult(); private FetchResult fetchResult; private GitFilterContext filterContext; @@ -185,7 +192,7 @@ public class GitMirrorCommand extends AbstractGitCommand implements MirrorComman result = FAILED; LOG.info("got exception while trying to synchronize mirror for repository {}", context.getRepository(), e); mirrorLog.add("failed to synchronize: " + e.getMessage()); - return new MirrorCommandResult(FAILED, mirrorLog, stopwatch.stop().elapsed()); + return new MirrorCommandResult(FAILED, mirrorLog, stopwatch.stop().elapsed(), lfsUpdateResult); } } @@ -198,7 +205,7 @@ public class GitMirrorCommand extends AbstractGitCommand implements MirrorComman if (fetchResult.getTrackingRefUpdates().isEmpty()) { LOG.trace("No updates found for mirror repository {}", repository); mirrorLog.add("No updates found"); - return new MirrorCommandResult(result, mirrorLog, stopwatch.stop().elapsed()); + return new MirrorCommandResult(result, mirrorLog, stopwatch.stop().elapsed(), lfsUpdateResult); } else { handleBranches(); handleTags(); @@ -206,14 +213,15 @@ public class GitMirrorCommand extends AbstractGitCommand implements MirrorComman if (!defaultBranchSelector.isChanged()) { mirrorLog.add("No effective changes detected"); - return new MirrorCommandResult(result, mirrorLog, stopwatch.stop().elapsed()); + return new MirrorCommandResult(result, mirrorLog, stopwatch.stop().elapsed(), lfsUpdateResult); } defaultBranchSelector.newDefaultBranch().ifPresent(this::setNewDefaultBranch); String[] pushRefSpecs = generatePushRefSpecs().toArray(new String[0]); push(pushRefSpecs); - return new MirrorCommandResult(result, mirrorLog, stopwatch.stop().elapsed()); + ResultType finalResult = lfsUpdateResult.hasFailures()? FAILED: result; + return new MirrorCommandResult(finalResult, mirrorLog, stopwatch.stop().elapsed(), lfsUpdateResult); } private void setNewDefaultBranch(String newDefaultBranch) { @@ -357,6 +365,10 @@ public class GitMirrorCommand extends AbstractGitCommand implements MirrorComman LOG.trace("updating {} ref in {}: {}", refType.typeForLog, GitMirrorCommand.this.repository, targetRef); defaultBranchSelector.accepted(refType, referenceName); logger.logChange(ref, referenceName, getUpdateType(ref)); + + if (!mirrorCommandRequest.isIgnoreLfs()) { + lfsLoader.inspectTree(ref.getNewObjectId(), mirrorCommandRequest, git.getRepository(), mirrorLog, lfsUpdateResult, repository); + } } } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/LfsLoader.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/LfsLoader.java new file mode 100644 index 0000000000..85f0301d9b --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/LfsLoader.java @@ -0,0 +1,177 @@ +/* + * 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.eclipse.jgit.lfs.Lfs; +import org.eclipse.jgit.lfs.LfsPointer; +import org.eclipse.jgit.lfs.Protocol; +import org.eclipse.jgit.lfs.SmudgeFilter; +import org.eclipse.jgit.lfs.lib.AnyLongObjectId; +import org.eclipse.jgit.lfs.lib.LfsPointerFilter; +import org.eclipse.jgit.lib.ConfigConstants; +import org.eclipse.jgit.lib.Constants; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.transport.http.HttpConnectionFactory; +import org.eclipse.jgit.treewalk.TreeWalk; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.repository.api.MirrorCommandResult.LfsUpdateResult; +import sonia.scm.store.BlobStore; +import sonia.scm.web.lfs.LfsBlobStoreFactory; + +import javax.inject.Inject; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; +import java.util.List; + +class LfsLoader { + + private static final Logger LOG = LoggerFactory.getLogger(LfsLoader.class); + + private final LfsBlobStoreFactory lfsBlobStoreFactory; + private final MirrorHttpConnectionProvider mirrorHttpConnectionProvider; + + @Inject + LfsLoader(LfsBlobStoreFactory lfsBlobStoreFactory, MirrorHttpConnectionProvider mirrorHttpConnectionProvider) { + this.lfsBlobStoreFactory = lfsBlobStoreFactory; + this.mirrorHttpConnectionProvider = mirrorHttpConnectionProvider; + } + + void inspectTree(ObjectId newObjectId, + MirrorCommandRequest mirrorCommandRequest, + Repository gitRepository, + List mirrorLog, + LfsUpdateResult lfsUpdateResult, + sonia.scm.repository.Repository repository) { + String mirrorUrl = mirrorCommandRequest.getSourceUrl(); + EntryHandler entryHandler = new EntryHandler(repository, gitRepository, mirrorCommandRequest, mirrorLog, lfsUpdateResult); + + try { + gitRepository + .getConfig() + .setString(ConfigConstants.CONFIG_SECTION_LFS, null, ConfigConstants.CONFIG_KEY_URL, computeLfsUrl(mirrorUrl)); + + TreeWalk treeWalk = new TreeWalk(gitRepository); + treeWalk.setFilter(new LfsPointerFilter()); + + RevWalk revWalk = new RevWalk(gitRepository); + revWalk.markStart(revWalk.parseCommit(newObjectId)); + + for (RevCommit commit : revWalk) { + treeWalk.reset(); + treeWalk.addTree(commit.getTree()); + while (treeWalk.next()) { + entryHandler.handleTreeEntry(treeWalk); + } + } + } catch (Exception e) { + LOG.warn("failed to load lfs files", e); + mirrorLog.add("Failed to load lfs files:"); + mirrorLog.add(e.getMessage()); + lfsUpdateResult.increaseFailureCount(); + } + } + + private String computeLfsUrl(String mirrorUrl) { + if (mirrorUrl.endsWith(".git")) { + return mirrorUrl + Protocol.INFO_LFS_ENDPOINT; + } else { + return mirrorUrl + ".git" + Protocol.INFO_LFS_ENDPOINT; + } + } + + private class EntryHandler { + + private final BlobStore lfsBlobStore; + private final Repository gitRepository; + private final List mirrorLog; + private final LfsUpdateResult lfsUpdateResult; + private final sonia.scm.repository.Repository repository; + private final HttpConnectionFactory httpConnectionFactory; + + private EntryHandler(sonia.scm.repository.Repository repository, + Repository gitRepository, + MirrorCommandRequest mirrorCommandRequest, + List mirrorLog, + LfsUpdateResult lfsUpdateResult) { + this.lfsBlobStore = lfsBlobStoreFactory.getLfsBlobStore(repository); + this.repository = repository; + this.gitRepository = gitRepository; + this.mirrorLog = mirrorLog; + this.lfsUpdateResult = lfsUpdateResult; + this.httpConnectionFactory = mirrorHttpConnectionProvider.createHttpConnectionFactory(mirrorCommandRequest, mirrorLog); + } + + private void handleTreeEntry(TreeWalk treeWalk) { + try (InputStream is = gitRepository.open(treeWalk.getObjectId(0), Constants.OBJ_BLOB).openStream()) { + LfsPointer lfsPointer = LfsPointer.parseLfsPointer(is); + AnyLongObjectId oid = lfsPointer.getOid(); + + if (lfsBlobStore.get(oid.name()) == null) { + Path tempFilePath = loadLfsFile(lfsPointer); + storeLfsBlob(oid, tempFilePath); + Files.delete(tempFilePath); + } + } catch (Exception e) { + LOG.warn("failed to load lfs file", e); + mirrorLog.add("Failed to load lfs file:"); + mirrorLog.add(e.getMessage()); + lfsUpdateResult.increaseFailureCount(); + } + } + + private Path loadLfsFile(LfsPointer lfsPointer) throws IOException { + lfsUpdateResult.increaseOverallCount(); + LOG.trace("trying to load lfs file '{}' for repository {}", lfsPointer.getOid(), repository); + mirrorLog.add(String.format("Loading lfs file with id '%s'", lfsPointer.getOid().name())); + Lfs lfs = new Lfs(gitRepository); + lfs.getMediaFile(lfsPointer.getOid()); + + Collection paths = SmudgeFilter.downloadLfsResource( + lfs, + gitRepository, + httpConnectionFactory, + lfsPointer + ); + return paths.iterator().next(); + } + + private void storeLfsBlob(AnyLongObjectId oid, Path tempFilePath) throws IOException { + LOG.trace("temporary lfs file: {}", tempFilePath); + Files.copy( + tempFilePath, + lfsBlobStore + .create(oid.name()) + .getOutputStream() + ); + } + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/MirrorHttpConnectionProvider.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/MirrorHttpConnectionProvider.java index e137dc4277..88fbdaafad 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/MirrorHttpConnectionProvider.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/MirrorHttpConnectionProvider.java @@ -24,12 +24,15 @@ package sonia.scm.repository.spi; +import org.eclipse.jgit.transport.UserAgent; import org.eclipse.jgit.transport.http.HttpConnectionFactory; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.net.HttpConnectionOptions; import sonia.scm.net.HttpURLConnectionFactory; import sonia.scm.repository.api.Pkcs12ClientCertificateCredential; +import sonia.scm.repository.api.UsernamePasswordCredential; +import sonia.scm.util.HttpUtil; import sonia.scm.web.ScmHttpConnectionFactory; import javax.inject.Inject; @@ -37,8 +40,10 @@ import javax.net.ssl.KeyManager; import javax.net.ssl.KeyManagerFactory; import java.io.ByteArrayInputStream; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.security.GeneralSecurityException; import java.security.KeyStore; +import java.util.Base64; import java.util.List; class MirrorHttpConnectionProvider { @@ -59,6 +64,13 @@ class MirrorHttpConnectionProvider { .ifPresent(c -> options.withKeyManagers(createKeyManagers(c, log))); mirrorCommandRequest.getProxyConfiguration() .ifPresent(options::withProxyConfiguration); + mirrorCommandRequest.getCredential(UsernamePasswordCredential.class) + .ifPresent(credential -> { + String encodedAuth = Base64.getEncoder().encodeToString((credential.username() + ":" + new String(credential.password())).getBytes(StandardCharsets.UTF_8)); + String authHeaderValue = "Basic " + encodedAuth; + options.addRequestProperty("Authorization", authHeaderValue); + }); + options.addRequestProperty(HttpUtil.HEADER_USERAGENT, "git-lfs/2"); return new ScmHttpConnectionFactory(httpURLConnectionFactory, options); } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitLfsLockApiDetector.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitLfsLockApiDetector.java index dbd4d43bce..c70b62c56f 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitLfsLockApiDetector.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitLfsLockApiDetector.java @@ -24,18 +24,40 @@ package sonia.scm.web; +import lombok.extern.slf4j.Slf4j; import sonia.scm.plugin.Extension; import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.core.MediaType; +import java.util.Arrays; +@Slf4j @Extension public class GitLfsLockApiDetector implements ScmClientDetector { - public static final String LOCK_APPLICATION_TYPE = "application/vnd.git-lfs+json"; + private static final String APPLICATION_TYPE = "application"; + private static final String LFS_VND_SUB_TYPE = "vnd.git-lfs+json"; @Override public boolean isScmClient(HttpServletRequest request, UserAgent userAgent) { - return LOCK_APPLICATION_TYPE.equals(request.getHeader("Content-Type")) - || LOCK_APPLICATION_TYPE.equals(request.getHeader("Accept")); + return isLfsType(request, "Content-Type") + || isLfsType(request, "Accept"); + } + + private boolean isLfsType(HttpServletRequest request, String name) { + String headerValue = request.getHeader(name); + + if (headerValue == null) { + return false; + } + + log.trace("checking '{}' header with value '{}'", name, headerValue); + + return Arrays.stream(headerValue.split(",\\s*")) + .anyMatch(v -> { + MediaType headerMediaType = MediaType.valueOf(v); + return APPLICATION_TYPE.equals(headerMediaType.getType()) + && LFS_VND_SUB_TYPE.equals(headerMediaType.getSubtype()); + }); } } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitLfsObjectApiDetector.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitLfsObjectApiDetector.java new file mode 100644 index 0000000000..db9cfa8711 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitLfsObjectApiDetector.java @@ -0,0 +1,43 @@ +/* + * 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.web; + +import lombok.extern.slf4j.Slf4j; +import sonia.scm.plugin.Extension; + +import javax.servlet.http.HttpServletRequest; +import java.util.regex.Pattern; + +@Slf4j +@Extension +public class GitLfsObjectApiDetector implements ScmClientDetector { + + private static final Pattern OBJECT_PATH_PATTERN = Pattern.compile("/[^/]*/repo/[^/]*/[^/]*\\.git/info/lfs/objects/.*"); + + @Override + public boolean isScmClient(HttpServletRequest request, UserAgent userAgent) { + return OBJECT_PATH_PATTERN.matcher(request.getRequestURI()).matches(); + } +} diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMirrorCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMirrorCommandTest.java index d95235ac43..cbc174f7bf 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMirrorCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMirrorCommandTest.java @@ -64,6 +64,7 @@ import javax.net.ssl.TrustManager; import java.io.File; import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Optional; import java.util.function.Consumer; @@ -74,9 +75,12 @@ import static java.util.Collections.singletonList; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.Assert.assertThrows; import static org.mockito.AdditionalMatchers.not; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static sonia.scm.repository.api.MirrorCommandResult.ResultType.FAILED; @@ -104,6 +108,8 @@ public class GitMirrorCommandTest extends AbstractGitCommandTestBase { private final GitRepositoryConfigStoreProvider storeProvider = mock(GitRepositoryConfigStoreProvider.class); private final ConfigurationStore configurationStore = mock(ConfigurationStore.class); + private final LfsLoader lfsLoader = mock(LfsLoader.class); + private final GitRepositoryConfig gitRepositoryConfig = new GitRepositoryConfig(); @Before @@ -136,11 +142,12 @@ public class GitMirrorCommandTest extends AbstractGitCommandTestBase { gitTagConverter, workingCopyFactory, gitHeadModifier, - storeProvider); + storeProvider, + lfsLoader); } @Before - public void initializeStore() { + public void initializeStores() { when(storeProvider.get(repository)).thenReturn(configurationStore); when(configurationStore.get()).thenReturn(gitRepositoryConfig); } @@ -833,6 +840,50 @@ public class GitMirrorCommandTest extends AbstractGitCommandTestBase { })); } + @Test + public void shouldCallLfsLoader() { + callMirrorCommand(); + + Arrays.stream(new String[] { + "a8495c0335a13e6e432df90b3727fa91943189a7", + "d81ad6c63d7e2162308d69637b339dedd1d9201c", + "2f95f02d9c568594d31e78464bd11a96c62e3f91", + "91b99de908fcd04772798a31c308a64aea1a5523", + "03ca33468c2094249973d0ca11b80243a20de368", + "1fcebf45a215a43f0713a57b807d55e8387a6d70", + "383b954b27e052db6880d57f1c860dc208795247", + "35597e9e98fe53167266583848bfef985c2adb27", + "3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4", + "86a6645eceefe8b9a247db5eb16e3d89a7e6e6d1" + // one revision is missing here ("fcd0ef1831e4002ac43ea539f4094334c79ea9ec"), because this is iterated twice, what is hard to test + }).forEach(expectedRevision -> + verify(lfsLoader) + .inspectTree(eq(ObjectId.fromString(expectedRevision)), any(), any(), any(), any(), eq(repository))); + } + + @Test + public void shouldMarkMirrorAsFailedIfLfsFileFailes() { + doAnswer(invocation -> { + invocation.getArgument(4, MirrorCommandResult.LfsUpdateResult.class).increaseFailureCount(); + return null; + }) + .when(lfsLoader) + .inspectTree(eq(ObjectId.fromString("a8495c0335a13e6e432df90b3727fa91943189a7")), any(), any(), any(), any(), eq(repository)); + + + MirrorCommandResult mirrorCommandResult = callMirrorCommand(); + + assertThat(mirrorCommandResult.getResult()).isEqualTo(FAILED); + } + + @Test + public void shouldNotCallLfsLoaderIfDeactivated() { + callMirrorCommand(repositoryDirectory.getAbsolutePath(), c -> c.setIgnoreLfs(true)); + + verify(lfsLoader, never()) + .inspectTree(any(), any(), any(), any(), any(), any()); + } + public static class DefaultBranchSelectorTest { public static final List BRANCHES = asList("master", "one", "two", "three"); diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/MirrorHttpConnectionProviderTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/MirrorHttpConnectionProviderTest.java index 37172c7795..422049f78b 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/MirrorHttpConnectionProviderTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/MirrorHttpConnectionProviderTest.java @@ -36,6 +36,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import sonia.scm.net.HttpConnectionOptions; import sonia.scm.net.HttpURLConnectionFactory; import sonia.scm.net.ProxyConfiguration; +import sonia.scm.repository.api.SimpleUsernamePasswordCredential; import java.io.IOException; import java.net.URL; @@ -79,6 +80,25 @@ class MirrorHttpConnectionProviderTest { assertThat(value.getProxyConfiguration()).containsSame(proxy); } + @Test + void shouldConfigureAuthorizationHeader() throws IOException { + MirrorCommandRequest request = new MirrorCommandRequest(); + request.setCredentials(List.of(new SimpleUsernamePasswordCredential("dent", "yellow".toCharArray()))); + + HttpConnectionOptions value = create(request); + + assertThat(value.getConnectionProperties()).containsEntry("Authorization", "Basic ZGVudDp5ZWxsb3c="); + } + + @Test + void shouldSetUserAgentHeader() throws IOException { + MirrorCommandRequest request = new MirrorCommandRequest(); + + HttpConnectionOptions value = create(request); + + assertThat(value.getConnectionProperties()).containsEntry("User-Agent", "git-lfs/2"); + } + private HttpConnectionOptions create(MirrorCommandRequest request) throws IOException { List log = new ArrayList<>(); diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/GitLfsLockApiDetectorTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/GitLfsLockApiDetectorTest.java new file mode 100644 index 0000000000..d4fe77e196 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/GitLfsLockApiDetectorTest.java @@ -0,0 +1,79 @@ +/* + * 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.web; + +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import javax.servlet.http.HttpServletRequest; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class GitLfsLockApiDetectorTest { + + @Mock + private HttpServletRequest request; + + private static Stream testParameters() { + return Stream.of( + Arguments.of("text/html, image/gif, image/jpeg", false), + Arguments.of("text/html, image/gif, image/jpeg, *; q=.2, */*; q=.2", false), + Arguments.of("*", false), + Arguments.of("application/vnd.git-lfs+json; charset=utf-8", true), + Arguments.of("application/vnd.git-lfs+json", true) + ); + } + + @ParameterizedTest + @MethodSource("testParameters") + void shouldHandleContentTypeHeaderCorrectly(String headerValue, boolean expected) { + when(request.getHeader("Content-Type")) + .thenReturn(headerValue); + + boolean result = new GitLfsLockApiDetector().isScmClient(request, null); + + assertThat(result).isEqualTo(expected); + } + + @ParameterizedTest + @MethodSource("testParameters") + void shouldHandleAcceptHeaderCorrectly(String headerValue, boolean expected) { + when(request.getHeader("Content-Type")) + .thenReturn(null); + when(request.getHeader("Accept")) + .thenReturn(headerValue); + + boolean result = new GitLfsLockApiDetector().isScmClient(request, null); + + assertThat(result).isEqualTo(expected); + } +} diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/GitLfsObjectApiDetectorTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/GitLfsObjectApiDetectorTest.java new file mode 100644 index 0000000000..af3e989bdc --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/GitLfsObjectApiDetectorTest.java @@ -0,0 +1,63 @@ +/* + * 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.web; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import javax.servlet.http.HttpServletRequest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class GitLfsObjectApiDetectorTest { + + @Mock + private HttpServletRequest request; + + @Test + void shouldAcceptObjectRequest() { + when(request.getRequestURI()) + .thenReturn("/scm/repo/scmadmin/lfs.git/info/lfs/objects/abc123"); + + boolean result = new GitLfsObjectApiDetector().isScmClient(request, null); + + assertThat(result).isTrue(); + } + + @Test + void shouldRejectFakeObjectRequest() { + when(request.getRequestURI()) + .thenReturn("/scm/repo/scmadmin/lfs.git/code/info/lfs/objects/abc123"); + + boolean result = new GitLfsObjectApiDetector().isScmClient(request, null); + + assertThat(result).isFalse(); + } +}