From dd0975b49aae56a845de837d4bdd4b0c95ebbc68 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Fri, 4 Jun 2021 14:05:47 +0200 Subject: [PATCH] Feature/mirror (#1683) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add mirror command and extension points. Co-authored-by: René Pfeuffer Co-authored-by: Sebastian Sdorra Co-authored-by: Konstantin Schaper --- gradle/changelog/git_ssl_context.yaml | 2 + gradle/changelog/mirror_command.yaml | 2 + gradle/changelog/svn_mirror_command.yaml | 2 + .../v2/resources/RepositoryLinkProvider.java | 38 + .../java/sonia/scm/collect/EvictingQueue.java | 79 ++ .../DefaultRepositoryExportingCheck.java | 2 + .../EventDrivenRepositoryArchiveCheck.java | 2 + .../sonia/scm/repository/ReadOnlyCheck.java | 81 ++ ...zer.java => ReadOnlyCheckInitializer.java} | 11 +- .../scm/repository/ReadOnlyException.java | 48 ++ .../repository/RepositoryArchivedCheck.java | 26 +- .../RepositoryArchivedException.java | 12 +- .../repository/RepositoryExportingCheck.java | 19 +- .../RepositoryExportingException.java | 12 +- .../repository/RepositoryPermissionGuard.java | 40 +- .../repository/RepositoryReadOnlyChecker.java | 61 +- .../WrappedRepositoryHookEvent.java | 23 +- .../sonia/scm/repository/api/Command.java | 7 +- .../sonia/scm/repository/api/Credential.java | 28 + .../repository/api/MirrorCommandBuilder.java | 118 +++ .../repository/api/MirrorCommandResult.java | 62 ++ .../scm/repository/api/MirrorFilter.java | 124 +++ .../Pkcs12ClientCertificateCredential.java | 36 + .../scm/repository/api/RepositoryService.java | 72 +- .../api/SimpleUsernamePasswordCredential.java | 46 + .../api/UsernamePasswordCredential.java | 32 + .../scm/repository/spi/MirrorCommand.java | 39 + .../repository/spi/MirrorCommandRequest.java | 95 +++ .../spi/RepositoryServiceProvider.java | 7 + .../sonia/scm/security/CipherHandler.java | 43 +- .../java/sonia/scm/security/CipherUtil.java | 99 +-- .../java/sonia/scm/security/PublicKey.java | 11 + .../sonia/scm/security/PublicKeyParser.java | 40 + .../java/sonia/scm/store/AbstractStore.java | 8 +- .../scm/xml/XmlCipherByteArrayAdapter.java | 45 + .../sonia/scm/collect/EvictingQueueTest.java | 61 ++ .../DefaultRepositoryExportingCheckTest.java | 31 + .../scm/repository/ReadOnlyCheckTest.java | 76 ++ .../RepositoryArchivedCheckTest.java | 62 ++ .../RepositoryPermissionGuardTest.java | 15 +- .../RepositoryReadOnlyCheckerHack.java | 46 + .../RepositoryReadOnlyCheckerTest.java | 142 +++- .../repository/api/RepositoryServiceTest.java | 69 +- .../sonia/scm/security/CipherHandlerTest.java | 66 ++ .../scm/store/JAXBConfigurationStore.java | 3 +- .../store/JAXBConfigurationStoreFactory.java | 2 +- .../scm/store/TypedStoreContextTest.java | 11 +- scm-plugins/scm-git-plugin/build.gradle | 1 + .../protocolcommand/git/MirrorRefFilter.java | 44 + .../git/ScmUploadPackFactory.java | 6 +- ...ploadPackFactoryForHttpServletRequest.java | 40 + .../scm/repository/GPGSignatureResolver.java | 70 ++ .../scm/repository/GitChangesetConverter.java | 5 +- .../GitChangesetConverterFactory.java | 50 +- .../GitHttpTransportRegistration.java | 55 ++ .../java/sonia/scm/repository/GitUtil.java | 8 + .../scm/repository/spi/GitMirrorCommand.java | 567 +++++++++++++ .../scm/repository/spi/GitPullCommand.java | 53 +- .../spi/GitRepositoryServiceProvider.java | 8 +- .../scm/repository/spi/GitTagConverter.java | 88 ++ .../scm/repository/spi/GitTagsCommand.java | 112 +-- .../spi/MirrorHttpConnectionProvider.java | 74 ++ ...PostReceiveRepositoryHookEventFactory.java | 85 ++ .../java/sonia/scm/web/GitServletModule.java | 7 +- .../java/sonia/scm/web/ScmGitServlet.java | 3 + .../scm/web/ScmHttpConnectionFactory.java | 98 +++ .../git/MirrorRefFilterTest.java | 51 ++ .../repository/GPGSignatureResolverTest.java | 96 +++ .../spi/GitIncomingCommandTest.java | 9 +- .../repository/spi/GitMirrorCommandTest.java | 796 ++++++++++++++++++ .../spi/GitModificationsCommandTest.java | 11 +- .../scm/repository/spi/GitTagCommandTest.java | 2 +- .../repository/spi/GitTagsCommandTest.java | 4 +- .../scm/repository/SvnRepositoryHandler.java | 135 ++- .../scm/repository/spi/SvnMirrorCommand.java | 146 ++++ .../spi/SvnRepositoryServiceProvider.java | 14 +- .../spi/SvnRepositoryServiceResolver.java | 9 +- .../spi/AbstractSvnCommandTestBase.java | 56 +- .../repository/spi/SvnMirrorCommandTest.java | 122 +++ .../sonia/scm/web/JsonMockHttpRequest.java | 238 ++++++ .../sonia/scm/web/JsonMockHttpResponse.java | 155 ++++ .../sonia/scm/web/MockScmPathInfoStore.java | 41 + .../java/sonia/scm/web/RestDispatcher.java | 1 + scm-ui/ui-components/src/Duration.stories.tsx | 53 ++ scm-ui/ui-components/src/Duration.tsx | 73 ++ .../ui-components/src/OverviewPageActions.tsx | 2 +- scm-ui/ui-components/src/Tag.stories.tsx | 37 +- scm-ui/ui-components/src/Tag.tsx | 92 +- .../src/__snapshots__/storyshots.test.ts.snap | 789 ++++++++++++++--- scm-ui/ui-components/src/forms/FileInput.tsx | 86 ++ scm-ui/ui-components/src/forms/TagGroup.tsx | 4 +- scm-ui/ui-components/src/forms/index.ts | 1 + scm-ui/ui-components/src/index.ts | 1 + scm-ui/ui-components/src/repos/DiffFile.tsx | 13 +- .../src/repos/RepositoryEntry.stories.tsx | 33 + .../src/repos/RepositoryEntry.tsx | 49 +- .../src/repos/RepositoryFlag.tsx | 42 + scm-ui/ui-components/src/repos/index.ts | 6 +- scm-ui/ui-components/src/styleConstants.ts | 40 + scm-ui/ui-extensions/src/extensionPoints.ts | 5 +- .../ui-webapp/public/locales/de/commons.json | 14 + .../ui-webapp/public/locales/en/commons.json | 14 + scm-ui/ui-webapp/src/containers/loadBundle.ts | 24 +- .../components/RepositoryDetailTable.tsx | 1 - .../src/repos/containers/RepositoryRoot.tsx | 51 +- .../DefaultRepositoryLinkProvider.java | 44 + .../lifecycle/modules/ScmServletModule.java | 3 + .../scm/security/gpg/DefaultPublicKey.java | 9 +- .../security/gpg/DefaultPublicKeyParser.java | 47 ++ .../sonia/scm/security/gpg/GPGModule.java | 2 + .../gpg/DefaultPublicKeyParserTest.java | 63 ++ 111 files changed, 6018 insertions(+), 796 deletions(-) create mode 100644 gradle/changelog/git_ssl_context.yaml create mode 100644 gradle/changelog/mirror_command.yaml create mode 100644 gradle/changelog/svn_mirror_command.yaml create mode 100644 scm-core/src/main/java/sonia/scm/api/v2/resources/RepositoryLinkProvider.java create mode 100644 scm-core/src/main/java/sonia/scm/collect/EvictingQueue.java create mode 100644 scm-core/src/main/java/sonia/scm/repository/ReadOnlyCheck.java rename scm-core/src/main/java/sonia/scm/repository/{RepositoryPermissionGuardInitializer.java => ReadOnlyCheckInitializer.java} (77%) create mode 100644 scm-core/src/main/java/sonia/scm/repository/ReadOnlyException.java create mode 100644 scm-core/src/main/java/sonia/scm/repository/api/Credential.java create mode 100644 scm-core/src/main/java/sonia/scm/repository/api/MirrorCommandBuilder.java create mode 100644 scm-core/src/main/java/sonia/scm/repository/api/MirrorCommandResult.java create mode 100644 scm-core/src/main/java/sonia/scm/repository/api/MirrorFilter.java create mode 100644 scm-core/src/main/java/sonia/scm/repository/api/Pkcs12ClientCertificateCredential.java create mode 100644 scm-core/src/main/java/sonia/scm/repository/api/SimpleUsernamePasswordCredential.java create mode 100644 scm-core/src/main/java/sonia/scm/repository/api/UsernamePasswordCredential.java create mode 100644 scm-core/src/main/java/sonia/scm/repository/spi/MirrorCommand.java create mode 100644 scm-core/src/main/java/sonia/scm/repository/spi/MirrorCommandRequest.java create mode 100644 scm-core/src/main/java/sonia/scm/security/PublicKeyParser.java create mode 100644 scm-core/src/main/java/sonia/scm/xml/XmlCipherByteArrayAdapter.java create mode 100644 scm-core/src/test/java/sonia/scm/collect/EvictingQueueTest.java create mode 100644 scm-core/src/test/java/sonia/scm/repository/ReadOnlyCheckTest.java create mode 100644 scm-core/src/test/java/sonia/scm/repository/RepositoryArchivedCheckTest.java create mode 100644 scm-core/src/test/java/sonia/scm/repository/RepositoryReadOnlyCheckerHack.java create mode 100644 scm-core/src/test/java/sonia/scm/security/CipherHandlerTest.java create mode 100644 scm-plugins/scm-git-plugin/src/main/java/sonia/scm/protocolcommand/git/MirrorRefFilter.java create mode 100644 scm-plugins/scm-git-plugin/src/main/java/sonia/scm/protocolcommand/git/ScmUploadPackFactoryForHttpServletRequest.java create mode 100644 scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GPGSignatureResolver.java create mode 100644 scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitHttpTransportRegistration.java create mode 100644 scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMirrorCommand.java create mode 100644 scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitTagConverter.java create mode 100644 scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/MirrorHttpConnectionProvider.java create mode 100644 scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/PostReceiveRepositoryHookEventFactory.java create mode 100644 scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/ScmHttpConnectionFactory.java create mode 100644 scm-plugins/scm-git-plugin/src/test/java/sonia/scm/protocolcommand/git/MirrorRefFilterTest.java create mode 100644 scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GPGSignatureResolverTest.java create mode 100644 scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMirrorCommandTest.java create mode 100644 scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnMirrorCommand.java create mode 100644 scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/SvnMirrorCommandTest.java create mode 100644 scm-test/src/main/java/sonia/scm/web/JsonMockHttpRequest.java create mode 100644 scm-test/src/main/java/sonia/scm/web/JsonMockHttpResponse.java create mode 100644 scm-test/src/main/java/sonia/scm/web/MockScmPathInfoStore.java create mode 100644 scm-ui/ui-components/src/Duration.stories.tsx create mode 100644 scm-ui/ui-components/src/Duration.tsx create mode 100644 scm-ui/ui-components/src/forms/FileInput.tsx create mode 100644 scm-ui/ui-components/src/repos/RepositoryFlag.tsx create mode 100644 scm-ui/ui-components/src/styleConstants.ts create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/DefaultRepositoryLinkProvider.java create mode 100644 scm-webapp/src/main/java/sonia/scm/security/gpg/DefaultPublicKeyParser.java create mode 100644 scm-webapp/src/test/java/sonia/scm/security/gpg/DefaultPublicKeyParserTest.java diff --git a/gradle/changelog/git_ssl_context.yaml b/gradle/changelog/git_ssl_context.yaml new file mode 100644 index 0000000000..8c0fdb3d03 --- /dev/null +++ b/gradle/changelog/git_ssl_context.yaml @@ -0,0 +1,2 @@ +- type: changed + description: Inject custom trust manager to git https connections ([#1675](https://github.com/scm-manager/scm-manager/pull/1675)) diff --git a/gradle/changelog/mirror_command.yaml b/gradle/changelog/mirror_command.yaml new file mode 100644 index 0000000000..98fe014a80 --- /dev/null +++ b/gradle/changelog/mirror_command.yaml @@ -0,0 +1,2 @@ +- type: added + description: Add mirror command and extension points ([#1683](https://github.com/scm-manager/scm-manager/pull/1683)) diff --git a/gradle/changelog/svn_mirror_command.yaml b/gradle/changelog/svn_mirror_command.yaml new file mode 100644 index 0000000000..ad0d6441c6 --- /dev/null +++ b/gradle/changelog/svn_mirror_command.yaml @@ -0,0 +1,2 @@ +- type: added + description: Implement Subversion mirror command ([#1660](https://github.com/scm-manager/scm-manager/pull/1660)) diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/RepositoryLinkProvider.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/RepositoryLinkProvider.java new file mode 100644 index 0000000000..e66a0c41ff --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/RepositoryLinkProvider.java @@ -0,0 +1,38 @@ +/* + * 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.api.v2.resources; + +import sonia.scm.repository.NamespaceAndName; + +public interface RepositoryLinkProvider { + + /** + * Returns the internal api link for the given repository. + * + * @param namespaceAndName The namespace and name of the repository. + * @return Internal api link for the given repository. + */ + String get(NamespaceAndName namespaceAndName); +} diff --git a/scm-core/src/main/java/sonia/scm/collect/EvictingQueue.java b/scm-core/src/main/java/sonia/scm/collect/EvictingQueue.java new file mode 100644 index 0000000000..5bc56469fa --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/collect/EvictingQueue.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.collect; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Preconditions; +import com.google.common.collect.ForwardingQueue; +import com.google.errorprone.annotations.CanIgnoreReturnValue; + +import javax.validation.constraints.NotNull; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import java.util.ArrayDeque; +import java.util.Queue; + +@XmlAccessorType(XmlAccessType.FIELD) +public final class EvictingQueue extends ForwardingQueue { + + private final ArrayDeque delegate; + @VisibleForTesting + int maxSize; + + public EvictingQueue() { + this.delegate = new ArrayDeque<>(); + this.maxSize = 100; + } + + private EvictingQueue(int maxSize) { + Preconditions.checkArgument(maxSize >= 0, "maxSize (%s) must >= 0", maxSize); + this.delegate = new ArrayDeque<>(maxSize); + this.maxSize = maxSize; + } + + public static EvictingQueue create(int maxSize) { + return new EvictingQueue<>(maxSize); + } + + protected Queue delegate() { + return this.delegate; + } + + @Override + @CanIgnoreReturnValue + public boolean add(@NotNull E e) { + Preconditions.checkNotNull(e); + if (this.maxSize == 0) { + return false; + } else { + while (this.size() >= this.maxSize) { + this.delegate.remove(); + } + this.delegate.add(e); + return true; + } + } + +} diff --git a/scm-core/src/main/java/sonia/scm/repository/DefaultRepositoryExportingCheck.java b/scm-core/src/main/java/sonia/scm/repository/DefaultRepositoryExportingCheck.java index 5f01a12f4e..629282ebb2 100644 --- a/scm-core/src/main/java/sonia/scm/repository/DefaultRepositoryExportingCheck.java +++ b/scm-core/src/main/java/sonia/scm/repository/DefaultRepositoryExportingCheck.java @@ -26,6 +26,7 @@ package sonia.scm.repository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import sonia.scm.plugin.Extension; import java.util.Collections; import java.util.HashMap; @@ -36,6 +37,7 @@ import java.util.function.Supplier; /** * Default implementation of {@link RepositoryExportingCheck}. This tracks the exporting status of repositories. */ +@Extension public final class DefaultRepositoryExportingCheck implements RepositoryExportingCheck { private static final Logger LOG = LoggerFactory.getLogger(DefaultRepositoryExportingCheck.class); diff --git a/scm-core/src/main/java/sonia/scm/repository/EventDrivenRepositoryArchiveCheck.java b/scm-core/src/main/java/sonia/scm/repository/EventDrivenRepositoryArchiveCheck.java index a016802bb9..089c874ab0 100644 --- a/scm-core/src/main/java/sonia/scm/repository/EventDrivenRepositoryArchiveCheck.java +++ b/scm-core/src/main/java/sonia/scm/repository/EventDrivenRepositoryArchiveCheck.java @@ -25,6 +25,7 @@ package sonia.scm.repository; import com.github.legman.Subscribe; +import sonia.scm.plugin.Extension; import java.util.Collection; import java.util.Collections; @@ -35,6 +36,7 @@ import java.util.HashSet; * {@link RepositoryModificationEvent}s. The initial set of archived repositories is read by * {@link EventDrivenRepositoryArchiveCheckInitializer} on startup. */ +@Extension public final class EventDrivenRepositoryArchiveCheck implements RepositoryArchivedCheck { private static final Collection ARCHIVED_REPOSITORIES = Collections.synchronizedSet(new HashSet<>()); diff --git a/scm-core/src/main/java/sonia/scm/repository/ReadOnlyCheck.java b/scm-core/src/main/java/sonia/scm/repository/ReadOnlyCheck.java new file mode 100644 index 0000000000..31f302d0ee --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/ReadOnlyCheck.java @@ -0,0 +1,81 @@ +/* + * 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; + +import sonia.scm.plugin.ExtensionPoint; + +import static sonia.scm.ContextEntry.ContextBuilder.entity; + +/** + * Read only check could be used to mark a repository as read only. + * @since 2.19.0 + */ +@ExtensionPoint +public interface ReadOnlyCheck { + + /** + * Returns the reason for the write protection. + * @return reason for write protection + */ + String getReason(); + + /** + * Returns {@code true} if the repository with the given id is read only. + * @param repositoryId repository id + * @return {@code true} if repository is read only + */ + boolean isReadOnly(String repositoryId); + + /** + * Returns {@code true} if the repository is read only. + * @param repository repository + * @return {@code true} if repository is read only + */ + default boolean isReadOnly(Repository repository) { + return isReadOnly(repository.getId()); + } + + /** + * Throws a {@link ReadOnlyException} if the repository is read only. + * @param repository repository + */ + default void check(Repository repository) { + check(repository.getId()); + } + + /** + * Throws a {@link ReadOnlyException} if the repository with th id is read only. + * @param repositoryId repository id + */ + default void check(String repositoryId) { + if (isReadOnly(repositoryId)) { + throw new ReadOnlyException(entity(Repository.class, repositoryId).build(), getReason()); + } + } + + default boolean isReadOnly(String permission, String repositoryId) { + return isReadOnly(repositoryId); + } +} diff --git a/scm-core/src/main/java/sonia/scm/repository/RepositoryPermissionGuardInitializer.java b/scm-core/src/main/java/sonia/scm/repository/ReadOnlyCheckInitializer.java similarity index 77% rename from scm-core/src/main/java/sonia/scm/repository/RepositoryPermissionGuardInitializer.java rename to scm-core/src/main/java/sonia/scm/repository/ReadOnlyCheckInitializer.java index 148782c73c..bc8e1956c4 100644 --- a/scm-core/src/main/java/sonia/scm/repository/RepositoryPermissionGuardInitializer.java +++ b/scm-core/src/main/java/sonia/scm/repository/ReadOnlyCheckInitializer.java @@ -30,23 +30,28 @@ import sonia.scm.SCMContextProvider; import sonia.scm.plugin.Extension; import javax.inject.Inject; +import java.util.Set; /** - * Initializes read only permissions for {@link RepositoryPermissionGuard} at startup. + * Initializes read only permissions and their checks at startup. */ @Extension @EagerSingleton -final class RepositoryPermissionGuardInitializer implements Initable { +final class ReadOnlyCheckInitializer implements Initable { private final PermissionProvider permissionProvider; + private final Set readOnlyChecks; @Inject - RepositoryPermissionGuardInitializer(PermissionProvider permissionProvider) { + ReadOnlyCheckInitializer(PermissionProvider permissionProvider, Set readOnlyChecks) { this.permissionProvider = permissionProvider; + this.readOnlyChecks = readOnlyChecks; } @Override public void init(SCMContextProvider context) { RepositoryPermissionGuard.setReadOnlyVerbs(permissionProvider.readOnlyVerbs()); + RepositoryPermissionGuard.setReadOnlyChecks(readOnlyChecks); + RepositoryReadOnlyChecker.setReadOnlyChecks(readOnlyChecks); } } diff --git a/scm-core/src/main/java/sonia/scm/repository/ReadOnlyException.java b/scm-core/src/main/java/sonia/scm/repository/ReadOnlyException.java new file mode 100644 index 0000000000..357f705f2d --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/ReadOnlyException.java @@ -0,0 +1,48 @@ +/* + * 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; + +import sonia.scm.ContextEntry; +import sonia.scm.ExceptionWithContext; + +import java.util.List; + +/** + * Read only exception is thrown if someone tries to execute a write command on a read only repository. + * + * @since 2.19.0 + */ +public class ReadOnlyException extends ExceptionWithContext { + + public ReadOnlyException(List context, String message) { + super(context, message); + } + + @Override + public String getCode() { + return "BaSXkAztI1"; + } + +} diff --git a/scm-core/src/main/java/sonia/scm/repository/RepositoryArchivedCheck.java b/scm-core/src/main/java/sonia/scm/repository/RepositoryArchivedCheck.java index b45e343e17..313634358e 100644 --- a/scm-core/src/main/java/sonia/scm/repository/RepositoryArchivedCheck.java +++ b/scm-core/src/main/java/sonia/scm/repository/RepositoryArchivedCheck.java @@ -29,7 +29,7 @@ package sonia.scm.repository; * * @since 2.12.0 */ -public interface RepositoryArchivedCheck { +public interface RepositoryArchivedCheck extends ReadOnlyCheck { /** * Checks whether the repository with the given id is archived or not. @@ -47,4 +47,28 @@ public interface RepositoryArchivedCheck { default boolean isArchived(Repository repository) { return isArchived(repository.getId()); } + + @Override + default boolean isReadOnly(String repositoryId) { + return isArchived(repositoryId); + } + + @Override + default String getReason() { + return "repository is archived"; + } + + @Override + default void check(Repository repository) { + if (repository.isArchived() || isArchived(repository)) { + throw new RepositoryArchivedException(repository); + } + } + + @Override + default void check(String repositoryId) { + if (isArchived(repositoryId)) { + throw new RepositoryArchivedException(repositoryId); + } + } } diff --git a/scm-core/src/main/java/sonia/scm/repository/RepositoryArchivedException.java b/scm-core/src/main/java/sonia/scm/repository/RepositoryArchivedException.java index cd463d3030..132a6c45ae 100644 --- a/scm-core/src/main/java/sonia/scm/repository/RepositoryArchivedException.java +++ b/scm-core/src/main/java/sonia/scm/repository/RepositoryArchivedException.java @@ -24,12 +24,11 @@ package sonia.scm.repository; -import sonia.scm.ExceptionWithContext; - import static java.lang.String.format; import static sonia.scm.ContextEntry.ContextBuilder.entity; -public class RepositoryArchivedException extends ExceptionWithContext { +@SuppressWarnings("java:S110") // large history is ok for exceptions +public class RepositoryArchivedException extends ReadOnlyException { public static final String CODE = "3hSIlptme1"; @@ -37,6 +36,13 @@ public class RepositoryArchivedException extends ExceptionWithContext { super(entity(repository).build(), format("Repository %s is marked as archived and must not be modified", repository)); } + public RepositoryArchivedException(String repositoryId) { + super( + entity(Repository.class, repositoryId).build(), + format("Repository with id %s is marked as archived and must not be modified", repositoryId) + ); + } + @Override public String getCode() { return CODE; diff --git a/scm-core/src/main/java/sonia/scm/repository/RepositoryExportingCheck.java b/scm-core/src/main/java/sonia/scm/repository/RepositoryExportingCheck.java index 6fd87797de..ab451359c4 100644 --- a/scm-core/src/main/java/sonia/scm/repository/RepositoryExportingCheck.java +++ b/scm-core/src/main/java/sonia/scm/repository/RepositoryExportingCheck.java @@ -31,7 +31,7 @@ import java.util.function.Supplier; * * @since 2.14.0 */ -public interface RepositoryExportingCheck { +public interface RepositoryExportingCheck extends ReadOnlyCheck { /** * Checks whether the repository with the given id is currently (that is, at this moment) being exported or not. @@ -59,4 +59,21 @@ public interface RepositoryExportingCheck { * @return The result of the callback. */ T withExportingLock(Repository repository, Supplier callback); + + @Override + default boolean isReadOnly(String repositoryId) { + return isExporting(repositoryId); + } + + @Override + default String getReason() { + return "repository is exporting"; + } + + @Override + default void check(String repositoryId) { + if (isExporting(repositoryId)) { + throw new RepositoryExportingException(repositoryId); + } + } } diff --git a/scm-core/src/main/java/sonia/scm/repository/RepositoryExportingException.java b/scm-core/src/main/java/sonia/scm/repository/RepositoryExportingException.java index d9dd389b24..deb706f89d 100644 --- a/scm-core/src/main/java/sonia/scm/repository/RepositoryExportingException.java +++ b/scm-core/src/main/java/sonia/scm/repository/RepositoryExportingException.java @@ -24,12 +24,11 @@ package sonia.scm.repository; -import sonia.scm.ExceptionWithContext; - import static java.lang.String.format; import static sonia.scm.ContextEntry.ContextBuilder.entity; -public class RepositoryExportingException extends ExceptionWithContext { +@SuppressWarnings("java:S110") // large history is ok for exceptions +public class RepositoryExportingException extends ReadOnlyException { public static final String CODE = "1mSNlpe1V1"; @@ -37,6 +36,13 @@ public class RepositoryExportingException extends ExceptionWithContext { super(entity(repository).build(), format("Repository %s is currently being exported and must not be modified", repository)); } + public RepositoryExportingException(String repositoryId) { + super( + entity(Repository.class, repositoryId).build(), + format("Repository with id %s is currently being exported and must not be modified", repositoryId) + ); + } + @Override public String getCode() { return CODE; diff --git a/scm-core/src/main/java/sonia/scm/repository/RepositoryPermissionGuard.java b/scm-core/src/main/java/sonia/scm/repository/RepositoryPermissionGuard.java index 89a7c3b232..ad45ebb1ba 100644 --- a/scm-core/src/main/java/sonia/scm/repository/RepositoryPermissionGuard.java +++ b/scm-core/src/main/java/sonia/scm/repository/RepositoryPermissionGuard.java @@ -26,6 +26,7 @@ package sonia.scm.repository; import com.github.sdorra.ssp.PermissionActionCheckInterceptor; import com.github.sdorra.ssp.PermissionGuard; +import com.google.common.collect.ImmutableSet; import org.apache.shiro.authz.AuthorizationException; import org.apache.shiro.subject.Subject; @@ -34,45 +35,62 @@ import java.util.Collections; import java.util.HashSet; import java.util.function.BooleanSupplier; -import static sonia.scm.repository.DefaultRepositoryExportingCheck.isRepositoryExporting; -import static sonia.scm.repository.EventDrivenRepositoryArchiveCheck.isRepositoryArchived; - /** * This intercepts permission checks for repositories and blocks write permissions for archived repositories. - * Read only permissions are set at startup by {@link RepositoryPermissionGuardInitializer}. + * Read only permissions are set at startup by {@link ReadOnlyCheckInitializer}. */ public class RepositoryPermissionGuard implements PermissionGuard { private static final Collection READ_ONLY_VERBS = Collections.synchronizedSet(new HashSet<>()); + private static Collection readOnlyChecks = Collections.emptySet(); static void setReadOnlyVerbs(Collection readOnlyVerbs) { READ_ONLY_VERBS.addAll(readOnlyVerbs); } + /** + * Sets static read only checks. + * @param readOnlyChecks read only checks + * @since 2.19.0 + */ + static void setReadOnlyChecks(Collection readOnlyChecks) { + RepositoryPermissionGuard.readOnlyChecks = ImmutableSet.copyOf(readOnlyChecks); + } + @Override public PermissionActionCheckInterceptor intercept(String permission) { if (READ_ONLY_VERBS.contains(permission)) { return new PermissionActionCheckInterceptor() {}; } else { - return new WriteInterceptor(); + return new WriteInterceptor(permission); } } private static class WriteInterceptor implements PermissionActionCheckInterceptor { + + private final String permission; + + private WriteInterceptor(String permission) { + this.permission = permission; + } + @Override public void check(Subject subject, String id, Runnable delegate) { delegate.run(); - if (isRepositoryArchived(id)) { - throw new AuthorizationException("repository is archived"); - } - if (isRepositoryExporting(id)) { - throw new AuthorizationException("repository is exporting"); + for (ReadOnlyCheck check : readOnlyChecks) { + if (check.isReadOnly(permission, id)) { + throw new AuthorizationException(check.getReason()); + } } } @Override public boolean isPermitted(Subject subject, String id, BooleanSupplier delegate) { - return !isRepositoryArchived(id) && !isRepositoryExporting(id) && delegate.getAsBoolean(); + return isWritable(id) && delegate.getAsBoolean(); + } + + private boolean isWritable(String id) { + return readOnlyChecks.stream().noneMatch(c -> c.isReadOnly(permission, id)); } } } diff --git a/scm-core/src/main/java/sonia/scm/repository/RepositoryReadOnlyChecker.java b/scm-core/src/main/java/sonia/scm/repository/RepositoryReadOnlyChecker.java index 62324f6736..d3b5b3b8ae 100644 --- a/scm-core/src/main/java/sonia/scm/repository/RepositoryReadOnlyChecker.java +++ b/scm-core/src/main/java/sonia/scm/repository/RepositoryReadOnlyChecker.java @@ -24,7 +24,15 @@ package sonia.scm.repository; +import com.google.common.collect.ImmutableSet; + import javax.inject.Inject; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Stream; /** * Checks, whether a repository has to be considered read only. Currently, this includes {@link RepositoryArchivedCheck} @@ -34,13 +42,38 @@ import javax.inject.Inject; */ public final class RepositoryReadOnlyChecker { - private final RepositoryArchivedCheck archivedCheck; - private final RepositoryExportingCheck exportingCheck; + private static Set staticChecks = Collections.emptySet(); + /** + * Set static read only checks. + * + * @param readOnlyChecks static read only checks + */ + static void setReadOnlyChecks(Collection readOnlyChecks) { + staticChecks = ImmutableSet.copyOf(readOnlyChecks); + } + + /** + * We should use {@link #staticChecks} instead of checks. + * Checks exists only for backward compatibility. + */ + private final Set checks = new HashSet<>(); + + /** + * Constructs a new read only checker, which uses only static checks. + */ @Inject + public RepositoryReadOnlyChecker() { + } + + /** + * Constructs a new read only checker. + * + * @deprecated use {@link RepositoryReadOnlyChecker#setReadOnlyChecks(Collection)} instead + */ + @Deprecated public RepositoryReadOnlyChecker(RepositoryArchivedCheck archivedCheck, RepositoryExportingCheck exportingCheck) { - this.archivedCheck = archivedCheck; - this.exportingCheck = exportingCheck; + this.checks.addAll(Arrays.asList(archivedCheck, exportingCheck)); } /** @@ -58,29 +91,15 @@ public final class RepositoryReadOnlyChecker { * @return true if any check locks the repository to read only access. */ public boolean isReadOnly(String repositoryId) { - return archivedCheck.isArchived(repositoryId) || exportingCheck.isExporting(repositoryId); + return Stream.concat(checks.stream(), staticChecks.stream()).anyMatch(check -> check.isReadOnly(repositoryId)); } /** * Checks if the repository may be modified. * - * @throws RepositoryArchivedException if the repository is archived - * @throws RepositoryExportingException if the repository is currently being exported + * @throws ReadOnlyException if the repository is marked as read only */ public static void checkReadOnly(Repository repository) { - if (isArchived(repository)) { - throw new RepositoryArchivedException(repository); - } - if (isExporting(repository)) { - throw new RepositoryExportingException(repository); - } - } - - private static boolean isExporting(Repository repository) { - return DefaultRepositoryExportingCheck.isRepositoryExporting(repository.getId()); - } - - private static boolean isArchived(Repository repository) { - return repository.isArchived() || EventDrivenRepositoryArchiveCheck.isRepositoryArchived(repository.getId()); + staticChecks.forEach(check -> check.check(repository)); } } diff --git a/scm-core/src/main/java/sonia/scm/repository/WrappedRepositoryHookEvent.java b/scm-core/src/main/java/sonia/scm/repository/WrappedRepositoryHookEvent.java index 2b6811457b..ee615a62ea 100644 --- a/scm-core/src/main/java/sonia/scm/repository/WrappedRepositoryHookEvent.java +++ b/scm-core/src/main/java/sonia/scm/repository/WrappedRepositoryHookEvent.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.repository; /** @@ -30,17 +30,14 @@ package sonia.scm.repository; * @author Sebastian Sdorra * @since 1.23 */ -public class WrappedRepositoryHookEvent extends RepositoryHookEvent -{ +public class WrappedRepositoryHookEvent extends RepositoryHookEvent { /** * Constructs a new WrappedRepositoryHookEvent. * - * * @param wrappedEvent event to wrap */ - protected WrappedRepositoryHookEvent(RepositoryHookEvent wrappedEvent) - { + protected WrappedRepositoryHookEvent(RepositoryHookEvent wrappedEvent) { super(wrappedEvent.getContext(), wrappedEvent.getRepository(), wrappedEvent.getType()); } @@ -50,28 +47,24 @@ public class WrappedRepositoryHookEvent extends RepositoryHookEvent /** * Returns a wrapped instance of the {@link RepositoryHookEvent}- * - * * @param event event to wrap - * * @return wrapper */ - public static WrappedRepositoryHookEvent wrap(RepositoryHookEvent event) - { + public static WrappedRepositoryHookEvent wrap(RepositoryHookEvent event) { WrappedRepositoryHookEvent wrappedEvent = null; - switch (event.getType()) - { - case POST_RECEIVE : + switch (event.getType()) { + case POST_RECEIVE: wrappedEvent = new PostReceiveRepositoryHookEvent(event); break; - case PRE_RECEIVE : + case PRE_RECEIVE: wrappedEvent = new PreReceiveRepositoryHookEvent(event); break; - default : + default: throw new IllegalArgumentException("unsupported hook event type"); } diff --git a/scm-core/src/main/java/sonia/scm/repository/api/Command.java b/scm-core/src/main/java/sonia/scm/repository/api/Command.java index 5a97709888..18acd0ab65 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/Command.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/Command.java @@ -72,5 +72,10 @@ public enum Command /** * @since 2.17.0 */ - FULL_HEALTH_CHECK; + FULL_HEALTH_CHECK, + + /** + * @since 2.19.0 + */ + MIRROR; } diff --git a/scm-core/src/main/java/sonia/scm/repository/api/Credential.java b/scm-core/src/main/java/sonia/scm/repository/api/Credential.java new file mode 100644 index 0000000000..31a0c286aa --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/api/Credential.java @@ -0,0 +1,28 @@ +/* + * 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.api; + +public interface Credential { +} 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 new file mode 100644 index 0000000000..99e7c9aa52 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/api/MirrorCommandBuilder.java @@ -0,0 +1,118 @@ +/* + * 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.api; + +import com.google.common.annotations.Beta; +import com.google.common.base.Preconditions; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.repository.Repository; +import sonia.scm.repository.spi.MirrorCommand; +import sonia.scm.repository.spi.MirrorCommandRequest; +import sonia.scm.security.PublicKey; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; + +/** + * @since 2.19.0 + */ +@Beta +public final class MirrorCommandBuilder { + + private static final Logger LOG = LoggerFactory.getLogger(MirrorCommandBuilder.class); + + private final MirrorCommand mirrorCommand; + private final Repository targetRepository; + + private String sourceUrl; + private Collection credentials = emptyList(); + private List publicKeys = emptyList(); + private MirrorFilter filter = new MirrorFilter() {}; + + MirrorCommandBuilder(MirrorCommand mirrorCommand, Repository targetRepository) { + this.mirrorCommand = mirrorCommand; + this.targetRepository = targetRepository; + } + + public MirrorCommandBuilder setCredentials(Credential credential, Credential... furtherCredentials) { + this.credentials = new ArrayList<>(); + credentials.add(credential); + credentials.addAll(asList(furtherCredentials)); + return this; + } + + public MirrorCommandBuilder setCredentials(Collection credentials) { + this.credentials = credentials; + return this; + } + + public MirrorCommandBuilder setPublicKeys(PublicKey... publicKeys) { + this.publicKeys = Arrays.asList(publicKeys); + return this; + } + + public MirrorCommandBuilder setPublicKeys(Collection publicKeys) { + this.publicKeys = new ArrayList<>(publicKeys); + return this; + } + + public MirrorCommandBuilder setSourceUrl(String sourceUrl) { + this.sourceUrl = sourceUrl; + return this; + } + + public MirrorCommandBuilder setFilter(MirrorFilter filter) { + this.filter = filter; + return this; + } + + public MirrorCommandResult initialCall() { + LOG.info("Creating mirror for {} in repository {}", sourceUrl, targetRepository); + MirrorCommandRequest mirrorCommandRequest = createRequest(); + return mirrorCommand.mirror(mirrorCommandRequest); + } + + public MirrorCommandResult update() { + LOG.debug("Updating mirror for {} in repository {}", sourceUrl, targetRepository); + MirrorCommandRequest mirrorCommandRequest = createRequest(); + return mirrorCommand.update(mirrorCommandRequest); + } + + private MirrorCommandRequest createRequest() { + MirrorCommandRequest mirrorCommandRequest = new MirrorCommandRequest(); + mirrorCommandRequest.setSourceUrl(sourceUrl); + mirrorCommandRequest.setCredentials(credentials); + mirrorCommandRequest.setFilter(filter); + mirrorCommandRequest.setPublicKeys(publicKeys); + 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 new file mode 100644 index 0000000000..3fb5ff76e2 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/api/MirrorCommandResult.java @@ -0,0 +1,62 @@ +/* + * 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.api; + +import com.google.common.annotations.Beta; + +import java.time.Duration; +import java.util.List; + +@Beta +public final class MirrorCommandResult { + + private final ResultType result; + private final List log; + private final Duration duration; + + public MirrorCommandResult(ResultType result, List log, Duration duration) { + this.result = result; + this.log = log; + this.duration = duration; + } + + public ResultType getResult() { + return result; + } + + public List getLog() { + return log; + } + + public Duration getDuration() { + return duration; + } + + public enum ResultType { + OK, + REJECTED_UPDATES, + FAILED + } +} diff --git a/scm-core/src/main/java/sonia/scm/repository/api/MirrorFilter.java b/scm-core/src/main/java/sonia/scm/repository/api/MirrorFilter.java new file mode 100644 index 0000000000..abf0a4a469 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/api/MirrorFilter.java @@ -0,0 +1,124 @@ +/* + * 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.api; + +import com.google.common.annotations.Beta; +import sonia.scm.repository.Changeset; +import sonia.scm.repository.Tag; + +import java.util.Collection; +import java.util.Optional; + +import static java.util.Collections.emptyList; + +@Beta +public interface MirrorFilter { + + default Filter getFilter(FilterContext context) { + return new Filter() {}; + } + + interface Filter { + + default Result acceptBranch(BranchUpdate branch) { + return Result.accept(); + } + + default Result acceptTag(TagUpdate tag) { + return Result.accept(); + } + } + + interface FilterContext { + + default Collection getBranchUpdates() { + return emptyList(); + } + + default Collection getTagUpdates() { + return emptyList(); + } + } + + class Result { + private final boolean accepted; + private final String rejectReason; + + private Result(boolean accepted, String rejectReason) { + this.accepted = accepted; + this.rejectReason = rejectReason; + } + + public static Result reject(String rejectReason) { + return new Result(false, rejectReason); + } + + public static Result reject() { + return new Result(false, null); + } + + public static Result accept() { + return new Result(true, null); + } + + public boolean isAccepted() { + return accepted; + } + + public Optional getRejectReason() { + return Optional.ofNullable(rejectReason); + } + } + + enum UpdateType { + CREATE, DELETE, UPDATE + } + + interface BranchUpdate { + String getBranchName(); + + Optional getChangeset(); + + Optional getNewRevision(); + + Optional getOldRevision(); + + Optional getUpdateType(); + + boolean isForcedUpdate(); + } + + interface TagUpdate { + String getTagName(); + + Optional getTag(); + + Optional getNewRevision(); + + Optional getOldRevision(); + + Optional getUpdateType(); + } +} diff --git a/scm-core/src/main/java/sonia/scm/repository/api/Pkcs12ClientCertificateCredential.java b/scm-core/src/main/java/sonia/scm/repository/api/Pkcs12ClientCertificateCredential.java new file mode 100644 index 0000000000..02b06667db --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/api/Pkcs12ClientCertificateCredential.java @@ -0,0 +1,36 @@ +/* + * 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.api; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class Pkcs12ClientCertificateCredential implements Credential { + + private final byte[] certificate; + private final char[] password; +} diff --git a/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java b/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java index 66185e75d9..b64e4998c7 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java @@ -158,8 +158,7 @@ public final class RepositoryService implements Closeable { * by the implementation of the repository service provider. */ public BlameCommandBuilder getBlameCommand() { - LOG.debug("create blame command for repository {}", - repository.getNamespaceAndName()); + LOG.debug("create blame command for repository {}", repository); return new BlameCommandBuilder(cacheManager, provider.getBlameCommand(), repository, preProcessorUtil); @@ -173,8 +172,7 @@ public final class RepositoryService implements Closeable { * by the implementation of the repository service provider. */ public BranchesCommandBuilder getBranchesCommand() { - LOG.debug("create branches command for repository {}", - repository.getNamespaceAndName()); + LOG.debug("create branches command for repository {}", repository); return new BranchesCommandBuilder(cacheManager, provider.getBranchesCommand(), repository); @@ -190,8 +188,7 @@ public final class RepositoryService implements Closeable { public BranchCommandBuilder getBranchCommand() { RepositoryReadOnlyChecker.checkReadOnly(getRepository()); RepositoryPermissions.push(getRepository()).check(); - LOG.debug("create branch command for repository {}", - repository.getNamespaceAndName()); + LOG.debug("create branch command for repository {}", repository); return new BranchCommandBuilder(repository, provider.getBranchCommand()); } @@ -204,8 +201,7 @@ public final class RepositoryService implements Closeable { * by the implementation of the repository service provider. */ public BrowseCommandBuilder getBrowseCommand() { - LOG.debug("create browse command for repository {}", - repository.getNamespaceAndName()); + LOG.debug("create browse command for repository {}", repository); return new BrowseCommandBuilder(cacheManager, provider.getBrowseCommand(), repository, preProcessorUtil); @@ -220,8 +216,7 @@ public final class RepositoryService implements Closeable { * @since 1.43 */ public BundleCommandBuilder getBundleCommand() { - LOG.debug("create bundle command for repository {}", - repository.getNamespaceAndName()); + LOG.debug("create bundle command for repository {}", repository); return new BundleCommandBuilder(provider.getBundleCommand(), repositoryExportingCheck, repository); } @@ -234,8 +229,7 @@ public final class RepositoryService implements Closeable { * by the implementation of the repository service provider. */ public CatCommandBuilder getCatCommand() { - LOG.debug("create cat command for repository {}", - repository.getNamespaceAndName()); + LOG.debug("create cat command for repository {}", repository); return new CatCommandBuilder(provider.getCatCommand()); } @@ -249,8 +243,7 @@ public final class RepositoryService implements Closeable { * by the implementation of the repository service provider. */ public DiffCommandBuilder getDiffCommand() { - LOG.debug("create diff command for repository {}", - repository.getNamespaceAndName()); + LOG.debug("create diff command for repository {}", repository); return new DiffCommandBuilder(provider.getDiffCommand(), provider.getSupportedFeatures()); } @@ -264,8 +257,7 @@ public final class RepositoryService implements Closeable { * by the implementation of the repository service provider. */ public DiffResultCommandBuilder getDiffResultCommand() { - LOG.debug("create diff result command for repository {}", - repository.getNamespaceAndName()); + LOG.debug("create diff result command for repository {}", repository); return new DiffResultCommandBuilder(provider.getDiffResultCommand(), provider.getSupportedFeatures()); } @@ -280,8 +272,7 @@ public final class RepositoryService implements Closeable { * @since 1.31 */ public IncomingCommandBuilder getIncomingCommand() { - LOG.debug("create incoming command for repository {}", - repository.getNamespaceAndName()); + LOG.debug("create incoming command for repository {}", repository); return new IncomingCommandBuilder(cacheManager, provider.getIncomingCommand(), repository, preProcessorUtil); @@ -295,8 +286,7 @@ public final class RepositoryService implements Closeable { * by the implementation of the repository service provider. */ public LogCommandBuilder getLogCommand() { - LOG.debug("create log command for repository {}", - repository.getNamespaceAndName()); + LOG.debug("create log command for repository {}", repository); return new LogCommandBuilder(cacheManager, provider.getLogCommand(), repository, preProcessorUtil, provider.getSupportedFeatures()); @@ -323,8 +313,7 @@ public final class RepositoryService implements Closeable { * @since 1.31 */ public OutgoingCommandBuilder getOutgoingCommand() { - LOG.debug("create outgoing command for repository {}", - repository.getNamespaceAndName()); + LOG.debug("create outgoing command for repository {}", repository); return new OutgoingCommandBuilder(cacheManager, provider.getOutgoingCommand(), repository, preProcessorUtil); @@ -340,8 +329,7 @@ public final class RepositoryService implements Closeable { */ public PullCommandBuilder getPullCommand() { RepositoryReadOnlyChecker.checkReadOnly(getRepository()); - LOG.debug("create pull command for repository {}", - repository.getNamespaceAndName()); + LOG.debug("create pull command for repository {}", repository); return new PullCommandBuilder(provider.getPullCommand(), repository); } @@ -355,8 +343,7 @@ public final class RepositoryService implements Closeable { * @since 1.31 */ public PushCommandBuilder getPushCommand() { - LOG.debug("create push command for repository {}", - repository.getNamespaceAndName()); + LOG.debug("create push command for repository {}", repository); return new PushCommandBuilder(provider.getPushCommand()); } @@ -378,8 +365,7 @@ public final class RepositoryService implements Closeable { * by the implementation of the repository service provider. */ public TagsCommandBuilder getTagsCommand() { - LOG.debug("create tags command for repository {}", - repository.getNamespaceAndName()); + LOG.debug("create tags command for repository {}", repository); return new TagsCommandBuilder(cacheManager, provider.getTagsCommand(), repository); @@ -406,8 +392,7 @@ public final class RepositoryService implements Closeable { * @since 1.43 */ public UnbundleCommandBuilder getUnbundleCommand() { - LOG.debug("create unbundle command for repository {}", - repository.getNamespaceAndName()); + LOG.debug("create unbundle command for repository {}", repository); return new UnbundleCommandBuilder(provider.getUnbundleCommand(), repository); @@ -424,8 +409,7 @@ public final class RepositoryService implements Closeable { */ public MergeCommandBuilder getMergeCommand() { RepositoryReadOnlyChecker.checkReadOnly(getRepository()); - LOG.debug("create merge command for repository {}", - repository.getNamespaceAndName()); + LOG.debug("create merge command for repository {}", repository); return new MergeCommandBuilder(provider.getMergeCommand(), eMail); } @@ -446,8 +430,7 @@ public final class RepositoryService implements Closeable { */ public ModifyCommandBuilder getModifyCommand() { RepositoryReadOnlyChecker.checkReadOnly(getRepository()); - LOG.debug("create modify command for repository {}", - repository.getNamespaceAndName()); + LOG.debug("create modify command for repository {}", repository); return new ModifyCommandBuilder(provider.getModifyCommand(), workdirProvider, repository.getId(), eMail); } @@ -461,8 +444,7 @@ public final class RepositoryService implements Closeable { * @since 2.10.0 */ public LookupCommandBuilder getLookupCommand() { - LOG.debug("create lookup command for repository {}", - repository.getNamespaceAndName()); + LOG.debug("create lookup command for repository {}", repository); return new LookupCommandBuilder(provider.getLookupCommand()); } @@ -476,11 +458,25 @@ public final class RepositoryService implements Closeable { * @since 2.17.0 */ public FullHealthCheckCommandBuilder getFullCheckCommand() { - LOG.debug("create full check command for repository {}", - repository.getNamespaceAndName()); + LOG.debug("create full check command for repository {}", repository); return new FullHealthCheckCommandBuilder(provider.getFullHealthCheckCommand()); } + /** + * The mirror command creates a 'mirror' of an existing repository (specified by a URL) by copying all data + * to the repository of this service. Therefore this repository has to be empty (otherwise the behaviour is + * not specified). + * + * @return instance of {@link MirrorCommandBuilder} + * @throws CommandNotSupportedException if the command is not supported + * by the implementation of the repository service provider. + * @since 2.19.0 + */ + public MirrorCommandBuilder getMirrorCommand() { + LOG.debug("create mirror command for repository {}", repository); + return new MirrorCommandBuilder(provider.getMirrorCommand(), repository); + } + /** * Returns true if the command is supported by the repository service. * diff --git a/scm-core/src/main/java/sonia/scm/repository/api/SimpleUsernamePasswordCredential.java b/scm-core/src/main/java/sonia/scm/repository/api/SimpleUsernamePasswordCredential.java new file mode 100644 index 0000000000..c78dbf571f --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/api/SimpleUsernamePasswordCredential.java @@ -0,0 +1,46 @@ +/* + * 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.api; + +public class SimpleUsernamePasswordCredential implements UsernamePasswordCredential { + + private final String username; + private final char[] password; + + public SimpleUsernamePasswordCredential(String username, char[] password) { + this.username = username; + this.password = password; + } + + @Override + public String username() { + return username; + } + + @Override + public char[] password() { + return password; + } +} diff --git a/scm-core/src/main/java/sonia/scm/repository/api/UsernamePasswordCredential.java b/scm-core/src/main/java/sonia/scm/repository/api/UsernamePasswordCredential.java new file mode 100644 index 0000000000..20ca7e0d05 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/api/UsernamePasswordCredential.java @@ -0,0 +1,32 @@ +/* + * 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.api; + +public interface UsernamePasswordCredential extends Credential { + + String username(); + + char[] password(); +} diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/MirrorCommand.java b/scm-core/src/main/java/sonia/scm/repository/spi/MirrorCommand.java new file mode 100644 index 0000000000..1b0bdd48a6 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/spi/MirrorCommand.java @@ -0,0 +1,39 @@ +/* + * 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 com.google.common.annotations.Beta; +import sonia.scm.repository.api.MirrorCommandResult; + +/** + * @since 2.19.0 + */ +@Beta +public interface MirrorCommand { + + MirrorCommandResult mirror(MirrorCommandRequest mirrorCommandRequest); + + MirrorCommandResult update(MirrorCommandRequest mirrorCommandRequest); +} 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 new file mode 100644 index 0000000000..03ec51b2b4 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/spi/MirrorCommandRequest.java @@ -0,0 +1,95 @@ +/* + * 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 com.google.common.annotations.Beta; +import org.apache.commons.lang.StringUtils; +import sonia.scm.repository.api.Credential; +import sonia.scm.repository.api.MirrorFilter; +import sonia.scm.security.PublicKey; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static java.util.Collections.emptyList; +import static java.util.Collections.unmodifiableCollection; + +/** + * @since 2.19.0 + */ +@Beta +public final class MirrorCommandRequest { + + private String sourceUrl; + private Collection credentials = emptyList(); + private List publicKeys = emptyList(); + private MirrorFilter filter = new MirrorFilter() {}; + + public String getSourceUrl() { + return sourceUrl; + } + + public void setSourceUrl(String sourceUrl) { + this.sourceUrl = sourceUrl; + } + + public Collection getCredentials() { + return unmodifiableCollection(credentials); + } + + public Optional getCredential(Class credentialClass) { + return getCredentials() + .stream() + .filter(credentialClass::isInstance) + .map(credentialClass::cast) + .findFirst(); + } + + public void setCredentials(Collection credentials) { + this.credentials = credentials; + } + + public MirrorFilter getFilter() { + return filter; + } + + public void setFilter(MirrorFilter filter) { + this.filter = filter; + } + + public boolean isValid() { + return StringUtils.isNotBlank(sourceUrl); + } + + public void setPublicKeys(List publicKeys) { + this.publicKeys = publicKeys; + } + + public List getPublicKeys() { + return Collections.unmodifiableList(publicKeys); + } +} diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/RepositoryServiceProvider.java b/scm-core/src/main/java/sonia/scm/repository/spi/RepositoryServiceProvider.java index d96a904d33..b4d9939ace 100644 --- a/scm-core/src/main/java/sonia/scm/repository/spi/RepositoryServiceProvider.java +++ b/scm-core/src/main/java/sonia/scm/repository/spi/RepositoryServiceProvider.java @@ -298,4 +298,11 @@ public abstract class RepositoryServiceProvider implements Closeable public FullHealthCheckCommand getFullHealthCheckCommand() { throw new CommandNotSupportedException(Command.FULL_HEALTH_CHECK); } + + /** + * @since 2.19.0 + */ + public MirrorCommand getMirrorCommand() { + throw new CommandNotSupportedException(Command.MIRROR); + } } diff --git a/scm-core/src/main/java/sonia/scm/security/CipherHandler.java b/scm-core/src/main/java/sonia/scm/security/CipherHandler.java index 5cb2d391b3..7483562467 100644 --- a/scm-core/src/main/java/sonia/scm/security/CipherHandler.java +++ b/scm-core/src/main/java/sonia/scm/security/CipherHandler.java @@ -21,13 +21,16 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.security; +import java.util.Base64; + +import static java.nio.charset.StandardCharsets.UTF_8; /** * Encrypts and decrypts string values. - * + * * @author Sebastian Sdorra * @since 1.7 */ @@ -41,7 +44,23 @@ public interface CipherHandler * * @return decrypted value */ - public String decode(String value); + String decode(String value); + + /** + * Decrypts the given value. If not implemented explicitly, this creates a string + * from the byte array, decodes this with {@link #decode(String)}, and interprets + * this string as base 64 encoded byte array. + *

+ * if {@link #encode(byte[])} is overridden by an implementation, this has to be + * implemented accordingly. + * + * @param value encrypted value + * + * @return decrypted value + */ + default byte[] decode(byte[] value) { + return Base64.getDecoder().decode(decode(new String(value, UTF_8))); + } /** * Encrypts the given value. @@ -50,5 +69,21 @@ public interface CipherHandler * * @return encrypted value */ - public String encode(String value); + String encode(String value); + + /** + * Encrypts the given value. If not implemented explicitly, this encoded the given + * byte array as a base 64 string, encodes this string with {@link #encode(String)}, + * and returns the bytes of this resulting string. + *

+ * if {@link #decode(byte[])} is overridden by an implementation, this has to be + * implemented accordingly. + * + * @param value plain byte array to encrypt. + * + * @return encrypted value + */ + default byte[] encode(byte[] value) { + return encode(Base64.getEncoder().encodeToString(value)).getBytes(UTF_8); + } } diff --git a/scm-core/src/main/java/sonia/scm/security/CipherUtil.java b/scm-core/src/main/java/sonia/scm/security/CipherUtil.java index 4682ee2a24..2254a566b1 100644 --- a/scm-core/src/main/java/sonia/scm/security/CipherUtil.java +++ b/scm-core/src/main/java/sonia/scm/security/CipherUtil.java @@ -21,10 +21,8 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - -package sonia.scm.security; -//~--- non-JDK imports -------------------------------------------------------- +package sonia.scm.security; import sonia.scm.SCMContext; import sonia.scm.util.ServiceUtil; @@ -34,52 +32,31 @@ import sonia.scm.util.ServiceUtil; * @author Sebastian Sdorra * @since 1.7 */ -public final class CipherUtil -{ +public final class CipherUtil { - /** Field description */ private static volatile CipherUtil instance; - //~--- constructors --------------------------------------------------------- + private CipherHandler cipherHandler; + private KeyGenerator keyGenerator; - /** - * Constructs ... - * - */ - private CipherUtil() - { + private CipherUtil() { keyGenerator = ServiceUtil.getService(KeyGenerator.class); - if (keyGenerator == null) - { + if (keyGenerator == null) { keyGenerator = new UUIDKeyGenerator(); } cipherHandler = ServiceUtil.getService(CipherHandler.class); - if (cipherHandler == null) - { - cipherHandler = new DefaultCipherHandler(SCMContext.getContext(), - keyGenerator); + if (cipherHandler == null) { + cipherHandler = new DefaultCipherHandler(SCMContext.getContext(), keyGenerator); } } - //~--- get methods ---------------------------------------------------------- - - /** - * Method description - * - * - * @return - */ - public static CipherUtil getInstance() - { - if (instance == null) - { - synchronized (CipherUtil.class) - { - if (instance == null) - { + public static CipherUtil getInstance() { + if (instance == null) { + synchronized (CipherUtil.class) { + if (instance == null) { instance = new CipherUtil(); } } @@ -88,63 +65,29 @@ public final class CipherUtil return instance; } - //~--- methods -------------------------------------------------------------- - - /** - * Method description - * - * - * @param value - * - * @return - */ - public String decode(String value) - { + public String decode(String value) { return cipherHandler.decode(value); } - /** - * Method description - * - * - * @param value - * - * @return - */ - public String encode(String value) - { + public byte[] decode(byte[] value) { + return cipherHandler.decode(value); + } + + public String encode(String value) { return cipherHandler.encode(value); } - //~--- get methods ---------------------------------------------------------- + public byte[] encode(byte[] value) { + return cipherHandler.encode(value); + } - /** - * Method description - * - * - * @return - */ public CipherHandler getCipherHandler() { return cipherHandler; } - /** - * Method description - * - * - * @return - */ public KeyGenerator getKeyGenerator() { return keyGenerator; } - - //~--- fields --------------------------------------------------------------- - - /** Field description */ - private CipherHandler cipherHandler; - - /** Field description */ - private KeyGenerator keyGenerator; } diff --git a/scm-core/src/main/java/sonia/scm/security/PublicKey.java b/scm-core/src/main/java/sonia/scm/security/PublicKey.java index 003863d696..7584bb28f5 100644 --- a/scm-core/src/main/java/sonia/scm/security/PublicKey.java +++ b/scm-core/src/main/java/sonia/scm/security/PublicKey.java @@ -28,6 +28,7 @@ import sonia.scm.repository.Person; import java.io.ByteArrayInputStream; import java.io.InputStream; +import java.util.Collections; import java.util.Optional; import java.util.Set; @@ -45,6 +46,16 @@ public interface PublicKey { */ String getId(); + /** + * Returns ids from gpg sub keys. + * + * @return sub key ids + * @since 2.19.0 + */ + default Set getSubkeys() { + return Collections.emptySet(); + } + /** * Returns the username of the owner or an empty optional. * diff --git a/scm-core/src/main/java/sonia/scm/security/PublicKeyParser.java b/scm-core/src/main/java/sonia/scm/security/PublicKeyParser.java new file mode 100644 index 0000000000..c5a7126630 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/security/PublicKeyParser.java @@ -0,0 +1,40 @@ +/* + * 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.security; + +/** + * Public key parser. + * + * @since 2.19.0 + */ +public interface PublicKeyParser { + + /** + * Parses the given public key. + * @param raw raw representation of public key + * @return parsed public key + */ + PublicKey parse(String raw); +} diff --git a/scm-core/src/main/java/sonia/scm/store/AbstractStore.java b/scm-core/src/main/java/sonia/scm/store/AbstractStore.java index 0cdb72ae4e..73f9d7a1c3 100644 --- a/scm-core/src/main/java/sonia/scm/store/AbstractStore.java +++ b/scm-core/src/main/java/sonia/scm/store/AbstractStore.java @@ -24,6 +24,8 @@ package sonia.scm.store; +import java.util.function.BooleanSupplier; + /** * Base class for {@link ConfigurationStore}. * @@ -38,9 +40,9 @@ public abstract class AbstractStore implements ConfigurationStore { * stored object */ protected T storeObject; - private final boolean readOnly; + private final BooleanSupplier readOnly; - protected AbstractStore(boolean readOnly) { + protected AbstractStore(BooleanSupplier readOnly) { this.readOnly = readOnly; } @@ -55,7 +57,7 @@ public abstract class AbstractStore implements ConfigurationStore { @Override public void set(T object) { - if (readOnly) { + if (readOnly.getAsBoolean()) { throw new StoreReadOnlyException(object); } writeObject(object); diff --git a/scm-core/src/main/java/sonia/scm/xml/XmlCipherByteArrayAdapter.java b/scm-core/src/main/java/sonia/scm/xml/XmlCipherByteArrayAdapter.java new file mode 100644 index 0000000000..1a980334ec --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/xml/XmlCipherByteArrayAdapter.java @@ -0,0 +1,45 @@ +/* + * 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.xml; + +import sonia.scm.security.CipherUtil; + +import javax.xml.bind.annotation.adapters.XmlAdapter; + +/** + * @since 2.19.0 + */ +public class XmlCipherByteArrayAdapter extends XmlAdapter { + + @Override + public byte[] marshal(byte[] v) throws Exception { + return CipherUtil.getInstance().encode(v); + } + + @Override + public byte[] unmarshal(byte[] v) throws Exception { + return CipherUtil.getInstance().decode(v); + } +} diff --git a/scm-core/src/test/java/sonia/scm/collect/EvictingQueueTest.java b/scm-core/src/test/java/sonia/scm/collect/EvictingQueueTest.java new file mode 100644 index 0000000000..8fc2201440 --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/collect/EvictingQueueTest.java @@ -0,0 +1,61 @@ +/* + * 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.collect; + +import org.junit.jupiter.api.Test; + +import java.util.Queue; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class EvictingQueueTest { + + @Test + void shouldReturnFalseIfMaxSizeIsZero() { + Queue queue = EvictingQueue.create(0); + assertThat(queue.add("a")).isFalse(); + } + + @Test + void shouldEvictFirstAddedEntry() { + Queue queue = EvictingQueue.create(2); + assertThat(queue.add("a")).isTrue(); + assertThat(queue.add("b")).isTrue(); + assertThat(queue.add("c")).isTrue(); + assertThat(queue).containsOnly("b", "c"); + } + + @Test + void shouldCreateWithDefaultSize() { + EvictingQueue queue = new EvictingQueue<>(); + assertThat(queue.maxSize).isEqualTo(100); + } + + @Test + void shouldFailWithNegativeSize() { + assertThrows(IllegalArgumentException.class, () -> EvictingQueue.create(-1)); + } +} diff --git a/scm-core/src/test/java/sonia/scm/repository/DefaultRepositoryExportingCheckTest.java b/scm-core/src/test/java/sonia/scm/repository/DefaultRepositoryExportingCheckTest.java index ef29ca41fe..e81c688d45 100644 --- a/scm-core/src/test/java/sonia/scm/repository/DefaultRepositoryExportingCheckTest.java +++ b/scm-core/src/test/java/sonia/scm/repository/DefaultRepositoryExportingCheckTest.java @@ -26,7 +26,10 @@ package sonia.scm.repository; import org.junit.jupiter.api.Test; +import java.util.function.Supplier; + import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; class DefaultRepositoryExportingCheckTest { @@ -62,4 +65,32 @@ class DefaultRepositoryExportingCheckTest { boolean readOnly = check.isExporting(EXPORTING_REPOSITORY); assertThat(readOnly).isFalse(); } + + @Test + void shouldThrowExportingException() { + RepositoryExportingCheck check = new TestingRepositoryExportingCheck(); + + assertThrows(RepositoryExportingException.class, () -> check.check(EXPORTING_REPOSITORY)); + } + + @Test + void shouldThrowExportingExceptionWithId() { + RepositoryExportingCheck check = new TestingRepositoryExportingCheck(); + + assertThrows(RepositoryExportingException.class, () -> check.check("exporting_hog")); + } + + private static class TestingRepositoryExportingCheck implements RepositoryExportingCheck { + + + @Override + public boolean isExporting(String repositoryId) { + return "exporting_hog".equals(repositoryId); + } + + @Override + public T withExportingLock(Repository repository, Supplier callback) { + return null; + } + } } diff --git a/scm-core/src/test/java/sonia/scm/repository/ReadOnlyCheckTest.java b/scm-core/src/test/java/sonia/scm/repository/ReadOnlyCheckTest.java new file mode 100644 index 0000000000..50031ceaa4 --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/repository/ReadOnlyCheckTest.java @@ -0,0 +1,76 @@ +/* + * 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; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +class ReadOnlyCheckTest { + + private final ReadOnlyCheck check = new TestingReadOnlyCheck(); + + private final Repository repository = new Repository("42", "git", "hitchhiker", "hog"); + + @Test + void shouldDelegateToMethodWithRepositoryId() { + assertThat(check.isReadOnly(repository)).isTrue(); + } + + @Test + void shouldThrowReadOnlyException() { + assertThrows(ReadOnlyException.class, () -> check.check(repository)); + } + + @Test + void shouldThrowReadOnlyExceptionForId() { + assertThrows(ReadOnlyException.class, () -> check.check("42")); + } + + @Test + void shouldNotThrowException() { + assertDoesNotThrow(() -> check.check("21")); + } + + @Test + void shouldDelegateToNormalCheck() { + assertThat(check.isReadOnly("any", "42")).isTrue(); + } + + private class TestingReadOnlyCheck implements ReadOnlyCheck { + + @Override + public String getReason() { + return "Testing"; + } + + @Override + public boolean isReadOnly(String repositoryId) { + return repositoryId.equals("42"); + } + } + +} diff --git a/scm-core/src/test/java/sonia/scm/repository/RepositoryArchivedCheckTest.java b/scm-core/src/test/java/sonia/scm/repository/RepositoryArchivedCheckTest.java new file mode 100644 index 0000000000..f7ca51cde9 --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/repository/RepositoryArchivedCheckTest.java @@ -0,0 +1,62 @@ +/* + * 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; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class RepositoryArchivedCheckTest { + + private final RepositoryArchivedCheck check = new TestingRepositoryArchivedCheck(); + + private final Repository repository = new Repository("42", "git", "hitchhiker", "hog"); + + @Test + void shouldThrowForRepositoryMarkedAsArchived() { + assertThrows(RepositoryArchivedException.class, () -> check.check(repository)); + } + + @Test + void shouldThrowForRepositoryMarkedAsArchivedWithId() { + assertThrows(RepositoryArchivedException.class, () -> check.check("42")); + } + + @Test + void shouldThrowForArchivedRepository() { + Repository repository = new Repository("21", "hg", "hitchhiker", "puzzle42"); + repository.setArchived(true); + assertThrows(RepositoryArchivedException.class, () -> check.check(repository)); + } + + private static class TestingRepositoryArchivedCheck implements RepositoryArchivedCheck { + @Override + public boolean isArchived(String repositoryId) { + return "42".equals(repositoryId); + } + } + +} diff --git a/scm-core/src/test/java/sonia/scm/repository/RepositoryPermissionGuardTest.java b/scm-core/src/test/java/sonia/scm/repository/RepositoryPermissionGuardTest.java index 3e28b18d0e..6719b87f3a 100644 --- a/scm-core/src/test/java/sonia/scm/repository/RepositoryPermissionGuardTest.java +++ b/scm-core/src/test/java/sonia/scm/repository/RepositoryPermissionGuardTest.java @@ -32,6 +32,7 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.AfterEachCallback; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.api.extension.InvocationInterceptor; @@ -40,6 +41,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.lang.reflect.Method; +import java.util.Collections; import java.util.function.BooleanSupplier; import static java.util.Collections.singletonList; @@ -117,12 +119,14 @@ class RepositoryPermissionGuardTest { @BeforeEach void mockArchivedRepository() { + RepositoryPermissionGuard.setReadOnlyChecks(Collections.singleton(new EventDrivenRepositoryArchiveCheck())); EventDrivenRepositoryArchiveCheck.setAsArchived("1"); } @AfterEach void removeArchiveFlag() { EventDrivenRepositoryArchiveCheck.removeFromArchived("1"); + RepositoryPermissionGuard.setReadOnlyChecks(Collections.emptySet()); } @Test @@ -174,12 +178,14 @@ class RepositoryPermissionGuardTest { } } - private static class WrapInExportCheck implements InvocationInterceptor { + private static class WrapInExportCheck implements InvocationInterceptor, AfterEachCallback { public void interceptTestMethod(Invocation invocation, ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) { - new DefaultRepositoryExportingCheck().withExportingLock(new Repository("1", "git", "space", "X"), () -> { + DefaultRepositoryExportingCheck check = new DefaultRepositoryExportingCheck(); + RepositoryPermissionGuard.setReadOnlyChecks(Collections.singleton(check)); + check.withExportingLock(new Repository("1", "git", "space", "X"), () -> { try { invocation.proceed(); return null; @@ -188,5 +194,10 @@ class RepositoryPermissionGuardTest { } }); } + + @Override + public void afterEach(ExtensionContext context) { + RepositoryPermissionGuard.setReadOnlyChecks(Collections.emptySet()); + } } } diff --git a/scm-core/src/test/java/sonia/scm/repository/RepositoryReadOnlyCheckerHack.java b/scm-core/src/test/java/sonia/scm/repository/RepositoryReadOnlyCheckerHack.java new file mode 100644 index 0000000000..a403e7dfa6 --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/repository/RepositoryReadOnlyCheckerHack.java @@ -0,0 +1,46 @@ +/* + * 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; + +import java.util.Collection; + +/** + * This class exists to bypass the visibility of the {@link RepositoryReadOnlyChecker} methods. + * This is necessary to use those methods from test which are located in other packages. + */ +public class RepositoryReadOnlyCheckerHack { + + private RepositoryReadOnlyCheckerHack() { + } + + /** + * Bypass the visibility of the {@link RepositoryReadOnlyChecker#setReadOnlyChecks(Collection)} method. + * + * @param readOnlyChecks checks to register + */ + public static void setReadOnlyChecks(Collection readOnlyChecks) { + RepositoryReadOnlyChecker.setReadOnlyChecks(readOnlyChecks); + } +} diff --git a/scm-core/src/test/java/sonia/scm/repository/RepositoryReadOnlyCheckerTest.java b/scm-core/src/test/java/sonia/scm/repository/RepositoryReadOnlyCheckerTest.java index 5a147af76f..c2beb5482e 100644 --- a/scm-core/src/test/java/sonia/scm/repository/RepositoryReadOnlyCheckerTest.java +++ b/scm-core/src/test/java/sonia/scm/repository/RepositoryReadOnlyCheckerTest.java @@ -24,15 +24,21 @@ package sonia.scm.repository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import java.util.Arrays; +import java.util.Collections; import java.util.function.Supplier; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; class RepositoryReadOnlyCheckerTest { - private final Repository repository = new Repository("1", "git","hitchhiker", "HeartOfGold"); + private final Repository repository = new Repository("1", "git", "hitchhiker", "HeartOfGold"); private boolean archived = false; private boolean exporting = false; @@ -50,30 +56,126 @@ class RepositoryReadOnlyCheckerTest { } }; - private final RepositoryReadOnlyChecker checker = new RepositoryReadOnlyChecker(archivedCheck, exportingCheck); - - @Test - void shouldReturnFalseIfAllChecksFalse() { - boolean readOnly = checker.isReadOnly(repository); - - assertThat(readOnly).isFalse(); + @BeforeEach + void resetStates() { + archived = false; + exporting = false; } - @Test - void shouldReturnTrueIfArchivedIsTrue() { - archived = true; - - boolean readOnly = checker.isReadOnly(repository); - - assertThat(readOnly).isTrue(); + @AfterEach + void unregisterStaticChecks() { + RepositoryReadOnlyChecker.setReadOnlyChecks(Collections.emptySet()); } - @Test - void shouldReturnTrueIfExportingIsTrue() { - exporting = true; + @Nested + class LegacyChecks { - boolean readOnly = checker.isReadOnly(repository); + private final RepositoryReadOnlyChecker checker = new RepositoryReadOnlyChecker(archivedCheck, exportingCheck); + + @Test + void shouldReturnFalseIfAllChecksFalse() { + boolean readOnly = checker.isReadOnly(repository); + + assertThat(readOnly).isFalse(); + } + + @Test + void shouldReturnTrueIfArchivedIsTrue() { + archived = true; + + boolean readOnly = checker.isReadOnly(repository); + + assertThat(readOnly).isTrue(); + } + + @Test + void shouldReturnTrueIfExportingIsTrue() { + exporting = true; + + boolean readOnly = checker.isReadOnly(repository); + + assertThat(readOnly).isTrue(); + } + + @Test + void shouldHandleLegacyAndStatic() { + assertThat(checker.isReadOnly(repository)).isFalse(); + + RepositoryReadOnlyChecker.setReadOnlyChecks(Collections.singleton(new SampleReadOnlyCheck(true))); + assertThat(checker.isReadOnly(repository)).isTrue(); + + RepositoryReadOnlyChecker.setReadOnlyChecks(Collections.emptySet()); + assertThat(checker.isReadOnly(repository)).isFalse(); + + exporting = true; + assertThat(checker.isReadOnly(repository)).isTrue(); + } - assertThat(readOnly).isTrue(); } + + @Nested + class StaticChecks { + + private final RepositoryReadOnlyChecker checker = new RepositoryReadOnlyChecker(); + + @BeforeEach + void registerStaticChecks() { + RepositoryReadOnlyChecker.setReadOnlyChecks(Arrays.asList( + archivedCheck, exportingCheck + )); + } + + @Test + void shouldReturnFalseIfAllChecksFalse() { + boolean readOnly = checker.isReadOnly(repository); + + assertThat(readOnly).isFalse(); + } + + @Test + void shouldReturnTrueIfArchivedIsTrue() { + archived = true; + + boolean readOnly = checker.isReadOnly(repository); + + assertThat(readOnly).isTrue(); + } + + @Test + void shouldReturnTrueIfExportingIsTrue() { + exporting = true; + + boolean readOnly = checker.isReadOnly(repository); + + assertThat(readOnly).isTrue(); + } + + @Test + void shouldThrowReadOnlyException() { + exporting = true; + + assertThrows(ReadOnlyException.class, () -> RepositoryReadOnlyChecker.checkReadOnly(repository)); + } + + } + + private static class SampleReadOnlyCheck implements ReadOnlyCheck { + + private final boolean readOnly; + + public SampleReadOnlyCheck(boolean readOnly) { + this.readOnly = readOnly; + } + + @Override + public String getReason() { + return "testing purposes"; + } + + @Override + public boolean isReadOnly(String repositoryId) { + return readOnly; + } + } + } diff --git a/scm-core/src/test/java/sonia/scm/repository/api/RepositoryServiceTest.java b/scm-core/src/test/java/sonia/scm/repository/api/RepositoryServiceTest.java index daee946fed..b8f3581c61 100644 --- a/scm-core/src/test/java/sonia/scm/repository/api/RepositoryServiceTest.java +++ b/scm-core/src/test/java/sonia/scm/repository/api/RepositoryServiceTest.java @@ -28,6 +28,7 @@ import org.apache.shiro.subject.Subject; import org.apache.shiro.util.ThreadContext; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; @@ -35,9 +36,11 @@ import org.mockito.junit.jupiter.MockitoExtension; import sonia.scm.SCMContext; import sonia.scm.config.ScmConfiguration; import sonia.scm.repository.DefaultRepositoryExportingCheck; +import sonia.scm.repository.EventDrivenRepositoryArchiveCheck; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryArchivedException; import sonia.scm.repository.RepositoryExportingException; +import sonia.scm.repository.RepositoryReadOnlyCheckerHack; import sonia.scm.repository.spi.HttpScmProtocol; import sonia.scm.repository.spi.RepositoryServiceProvider; import sonia.scm.user.EMail; @@ -59,7 +62,7 @@ import static org.mockito.Mockito.when; class RepositoryServiceTest { private final RepositoryServiceProvider provider = mock(RepositoryServiceProvider.class); - private final Repository repository = new Repository("", "git", "space", "repo"); + private final Repository repository = new Repository("42", "git", "space", "repo"); private final EMail eMail = new EMail(new ScmConfiguration()); @@ -112,34 +115,54 @@ class RepositoryServiceTest { assertThrows(IllegalArgumentException.class, () -> repositoryService.getProtocol(UnknownScmProtocol.class)); } - @Test - void shouldFailForArchivedRepository() { - repository.setArchived(true); - RepositoryService repositoryService = new RepositoryService(null, provider, repository, null, Collections.singleton(new DummyScmProtocolProvider()), null, eMail, null); + @Nested + class RepositoryReadOnlyCheckerTests { - assertThrows(RepositoryArchivedException.class, repositoryService::getModifyCommand); - assertThrows(RepositoryArchivedException.class, repositoryService::getBranchCommand); - assertThrows(RepositoryArchivedException.class, repositoryService::getPullCommand); - assertThrows(RepositoryArchivedException.class, repositoryService::getTagCommand); - assertThrows(RepositoryArchivedException.class, repositoryService::getMergeCommand); - assertThrows(RepositoryArchivedException.class, repositoryService::getModifyCommand); - } + @AfterEach + void clearChecks() { + RepositoryReadOnlyCheckerHack.setReadOnlyChecks(Collections.emptySet()); + repository.setArchived(false); + } + + @Test + void shouldFailForExportingRepository() { + DefaultRepositoryExportingCheck check = new DefaultRepositoryExportingCheck(); + + RepositoryReadOnlyCheckerHack.setReadOnlyChecks(Collections.singleton(check)); + + check.withExportingLock(repository, () -> { + RepositoryService repositoryService = new RepositoryService(null, provider, repository, null, Collections.singleton(new DummyScmProtocolProvider()), null, eMail, null); + + assertThrows(RepositoryExportingException.class, repositoryService::getModifyCommand); + assertThrows(RepositoryExportingException.class, repositoryService::getBranchCommand); + assertThrows(RepositoryExportingException.class, repositoryService::getPullCommand); + assertThrows(RepositoryExportingException.class, repositoryService::getTagCommand); + assertThrows(RepositoryExportingException.class, repositoryService::getMergeCommand); + assertThrows(RepositoryExportingException.class, repositoryService::getModifyCommand); + return null; + }); + } + + @Test + void shouldFailForArchivedRepository() { + EventDrivenRepositoryArchiveCheck check = new EventDrivenRepositoryArchiveCheck(); + RepositoryReadOnlyCheckerHack.setReadOnlyChecks(Collections.singleton(check)); + + repository.setArchived(true); - @Test - void shouldFailForExportingRepository() { - new DefaultRepositoryExportingCheck().withExportingLock(repository, () -> { RepositoryService repositoryService = new RepositoryService(null, provider, repository, null, Collections.singleton(new DummyScmProtocolProvider()), null, eMail, null); - assertThrows(RepositoryExportingException.class, repositoryService::getModifyCommand); - assertThrows(RepositoryExportingException.class, repositoryService::getBranchCommand); - assertThrows(RepositoryExportingException.class, repositoryService::getPullCommand); - assertThrows(RepositoryExportingException.class, repositoryService::getTagCommand); - assertThrows(RepositoryExportingException.class, repositoryService::getMergeCommand); - assertThrows(RepositoryExportingException.class, repositoryService::getModifyCommand); - return null; - }); + assertThrows(RepositoryArchivedException.class, repositoryService::getModifyCommand); + assertThrows(RepositoryArchivedException.class, repositoryService::getBranchCommand); + assertThrows(RepositoryArchivedException.class, repositoryService::getPullCommand); + assertThrows(RepositoryArchivedException.class, repositoryService::getTagCommand); + assertThrows(RepositoryArchivedException.class, repositoryService::getMergeCommand); + assertThrows(RepositoryArchivedException.class, repositoryService::getModifyCommand); + } + } + private static class DummyHttpProtocol extends HttpScmProtocol { private final boolean anonymousEnabled; diff --git a/scm-core/src/test/java/sonia/scm/security/CipherHandlerTest.java b/scm-core/src/test/java/sonia/scm/security/CipherHandlerTest.java new file mode 100644 index 0000000000..e88374c9d9 --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/security/CipherHandlerTest.java @@ -0,0 +1,66 @@ +/* + * 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.security; + +import org.junit.jupiter.api.Test; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; + +class CipherHandlerTest { + + public static final String SECRET_TEXT = "secret text"; + public static final String SECRET_TEXT_AS_BASE64 = "c2VjcmV0IHRleHQ="; + public static final String ENCRYPTED_TEXT = "unreadable bytes"; + + @Test + void shouldDelegateToStringEncryptionForBytes() { + CipherHandler cipherHandler = new CipherHandler() { + @Override + public String decode(String value) { + if (value.equals(ENCRYPTED_TEXT)) { + return SECRET_TEXT_AS_BASE64; + } else { + throw new IllegalArgumentException("unexpected data: " + value); + } + } + + @Override + public String encode(String value) { + if (value.equals(SECRET_TEXT_AS_BASE64)) { + return ENCRYPTED_TEXT; + } else { + throw new IllegalArgumentException("unexpected data: " + value); + } + } + }; + + byte[] encodedBytes = cipherHandler.encode(SECRET_TEXT.getBytes(UTF_8)); + + byte[] originalBytes = cipherHandler.decode(encodedBytes); + + assertThat(originalBytes).isEqualTo(SECRET_TEXT.getBytes(UTF_8)); + } +} diff --git a/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationStore.java b/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationStore.java index 474a7b76a8..1b0de60299 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationStore.java +++ b/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationStore.java @@ -28,6 +28,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.File; +import java.util.function.BooleanSupplier; /** * JAXB implementation of {@link ConfigurationStore}. @@ -46,7 +47,7 @@ public class JAXBConfigurationStore extends AbstractStore { private final Class type; private final File configFile; - public JAXBConfigurationStore(TypedStoreContext context, Class type, File configFile, boolean readOnly) { + public JAXBConfigurationStore(TypedStoreContext context, Class type, File configFile, BooleanSupplier readOnly) { super(readOnly); this.context = context; this.type = type; diff --git a/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationStoreFactory.java b/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationStoreFactory.java index cbbb901fd7..25f5f750e6 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationStoreFactory.java +++ b/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationStoreFactory.java @@ -57,7 +57,7 @@ public class JAXBConfigurationStoreFactory extends FileBasedStoreFactory impleme getStoreLocation(storeParameters.getName().concat(StoreConstants.FILE_EXTENSION), storeParameters.getType(), storeParameters.getRepositoryId()), - mustBeReadOnly(storeParameters) + () -> mustBeReadOnly(storeParameters) ); } } diff --git a/scm-dao-xml/src/test/java/sonia/scm/store/TypedStoreContextTest.java b/scm-dao-xml/src/test/java/sonia/scm/store/TypedStoreContextTest.java index 66a390d2ae..47321a5caf 100644 --- a/scm-dao-xml/src/test/java/sonia/scm/store/TypedStoreContextTest.java +++ b/scm-dao-xml/src/test/java/sonia/scm/store/TypedStoreContextTest.java @@ -47,11 +47,12 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) +@SuppressWarnings("unstableApiUsage") class TypedStoreContextTest { @Test void shouldMarshallAndUnmarshall(@TempDir Path tempDir) { - TypedStoreContext context = context(Sample.class); + TypedStoreContext context = context(); File file = tempDir.resolve("test.xml").toFile(); context.marshal(new Sample("awesome"), file); @@ -62,7 +63,7 @@ class TypedStoreContextTest { @Test void shouldWorkWithMarshallerAndUnmarshaller(@TempDir Path tempDir) { - TypedStoreContext context = context(Sample.class); + TypedStoreContext context = context(); File file = tempDir.resolve("test.xml").toFile(); @@ -115,10 +116,12 @@ class TypedStoreContextTest { assertThat(sample.value).isEqualTo("awesome!!"); } - private TypedStoreContext context(Class type) { - return TypedStoreContext.of(params(type)); + @SuppressWarnings("unchecked") + private TypedStoreContext context() { + return TypedStoreContext.of(params((Class) Sample.class)); } + @SuppressWarnings("unchecked") private TypedStoreParameters params(Class type) { TypedStoreParameters params = mock(TypedStoreParameters.class); when(params.getType()).thenReturn(type); diff --git a/scm-plugins/scm-git-plugin/build.gradle b/scm-plugins/scm-git-plugin/build.gradle index d77a1e6675..1d9211cfd6 100644 --- a/scm-plugins/scm-git-plugin/build.gradle +++ b/scm-plugins/scm-git-plugin/build.gradle @@ -37,6 +37,7 @@ dependencies { implementation "sonia.jgit:org.eclipse.jgit.gpg.bc:${jgitVersion}" implementation libraries.commonsCompress + testImplementation "sonia.jgit:org.eclipse.jgit.junit.http:${jgitVersion}" testImplementation libraries.shiroUnit testImplementation libraries.awaitility } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/protocolcommand/git/MirrorRefFilter.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/protocolcommand/git/MirrorRefFilter.java new file mode 100644 index 0000000000..13baa14106 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/protocolcommand/git/MirrorRefFilter.java @@ -0,0 +1,44 @@ +/* + * 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.protocolcommand.git; + +import org.eclipse.jgit.lib.Ref; +import sonia.scm.repository.spi.GitMirrorCommand; + +import java.util.Map; +import java.util.stream.Collectors; + +final class MirrorRefFilter { + + private MirrorRefFilter() { + } + + static Map filterMirrors(Map refs) { + return refs.entrySet() + .stream() + .filter(entry -> !entry.getKey().startsWith(GitMirrorCommand.MIRROR_REF_PREFIX)) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/protocolcommand/git/ScmUploadPackFactory.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/protocolcommand/git/ScmUploadPackFactory.java index da77233c39..f547ae69d8 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/protocolcommand/git/ScmUploadPackFactory.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/protocolcommand/git/ScmUploadPackFactory.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.protocolcommand.git; import org.eclipse.jgit.lib.Repository; @@ -32,6 +32,8 @@ import sonia.scm.protocolcommand.RepositoryContext; public class ScmUploadPackFactory implements UploadPackFactory { @Override public UploadPack create(RepositoryContext repositoryContext, Repository repository) { - return new UploadPack(repository); + UploadPack uploadPack = new UploadPack(repository); + uploadPack.setRefFilter(MirrorRefFilter::filterMirrors); + return uploadPack; } } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/protocolcommand/git/ScmUploadPackFactoryForHttpServletRequest.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/protocolcommand/git/ScmUploadPackFactoryForHttpServletRequest.java new file mode 100644 index 0000000000..cec28340b9 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/protocolcommand/git/ScmUploadPackFactoryForHttpServletRequest.java @@ -0,0 +1,40 @@ +/* + * 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.protocolcommand.git; + +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.transport.UploadPack; +import org.eclipse.jgit.transport.resolver.UploadPackFactory; + +import javax.servlet.http.HttpServletRequest; + +public class ScmUploadPackFactoryForHttpServletRequest implements UploadPackFactory { + @Override + public UploadPack create(HttpServletRequest repositoryContext, Repository repository) { + UploadPack uploadPack = new UploadPack(repository); + uploadPack.setRefFilter(MirrorRefFilter::filterMirrors); + return uploadPack; + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GPGSignatureResolver.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GPGSignatureResolver.java new file mode 100644 index 0000000000..6eff6b18cf --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GPGSignatureResolver.java @@ -0,0 +1,70 @@ +/* + * 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; + +import com.google.common.collect.ImmutableMap; +import sonia.scm.security.GPG; +import sonia.scm.security.PublicKey; + +import java.util.Map; +import java.util.Optional; + +class GPGSignatureResolver { + + private final GPG gpg; + private final Map additionalPublicKeys; + + GPGSignatureResolver(GPG gpg, Iterable additionalPublicKeys) { + this.gpg = gpg; + this.additionalPublicKeys = createKeyMap(additionalPublicKeys); + } + + private Map createKeyMap(Iterable additionalPublicKeys) { + ImmutableMap.Builder builder = ImmutableMap.builder(); + for (PublicKey key : additionalPublicKeys) { + appendKey(builder, key); + } + return builder.build(); + } + + private void appendKey(ImmutableMap.Builder builder, PublicKey key) { + builder.put(key.getId(), key); + for (String subkey : key.getSubkeys()) { + builder.put(subkey, key); + } + } + + String findPublicKeyId(byte[] signature) { + return gpg.findPublicKeyId(signature); + } + + Optional findPublicKey(String id) { + PublicKey publicKey = additionalPublicKeys.get(id); + if (publicKey != null) { + return Optional.of(publicKey); + } + return gpg.findPublicKey(id); + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverter.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverter.java index 1262353e1d..2984be829f 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverter.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverter.java @@ -35,7 +35,6 @@ import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevWalk; import org.eclipse.jgit.treewalk.TreeWalk; import org.eclipse.jgit.util.RawParseUtils; -import sonia.scm.security.GPG; import sonia.scm.security.PublicKey; import sonia.scm.util.Util; @@ -56,11 +55,11 @@ import java.util.Optional; */ public class GitChangesetConverter implements Closeable { - private final GPG gpg; + private final GPGSignatureResolver gpg; private final Multimap tags; private final TreeWalk treeWalk; - public GitChangesetConverter(GPG gpg, org.eclipse.jgit.lib.Repository repository, RevWalk revWalk) { + GitChangesetConverter(GPGSignatureResolver gpg, org.eclipse.jgit.lib.Repository repository, RevWalk revWalk) { this.gpg = gpg; this.tags = GitUtil.createTagMap(repository, revWalk); this.treeWalk = new TreeWalk(repository); diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverterFactory.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverterFactory.java index 4f4389fa2e..2cc518fa2e 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverterFactory.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverterFactory.java @@ -27,8 +27,13 @@ package sonia.scm.repository; import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.revwalk.RevWalk; import sonia.scm.security.GPG; +import sonia.scm.security.PublicKey; import javax.inject.Inject; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; public class GitChangesetConverterFactory { @@ -40,11 +45,52 @@ public class GitChangesetConverterFactory { } public GitChangesetConverter create(Repository repository) { - return new GitChangesetConverter(gpg, repository, new RevWalk(repository)); + return builder(repository).create(); } public GitChangesetConverter create(Repository repository, RevWalk revWalk) { - return new GitChangesetConverter(gpg, repository, revWalk); + return builder(repository).withRevWalk(revWalk).create(); + } + + public Builder builder(Repository repository) { + return new Builder(gpg, repository); + } + + public static class Builder { + + private final GPG gpg; + private final Repository repository; + private RevWalk revWalk; + private final List additionalPublicKeys = new ArrayList<>(); + + private Builder(GPG gpg, Repository repository) { + this.gpg = gpg; + this.repository = repository; + } + + public Builder withRevWalk(RevWalk revWalk) { + this.revWalk = revWalk; + return this; + } + + public Builder withAdditionalPublicKeys(PublicKey... publicKeys) { + additionalPublicKeys.addAll(Arrays.asList(publicKeys)); + return this; + } + + public Builder withAdditionalPublicKeys(Collection publicKeys) { + additionalPublicKeys.addAll(publicKeys); + return this; + } + + public GitChangesetConverter create() { + return new GitChangesetConverter( + new GPGSignatureResolver(gpg, additionalPublicKeys), + repository, + revWalk != null ? revWalk : new RevWalk(repository) + ); + } + } } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitHttpTransportRegistration.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitHttpTransportRegistration.java new file mode 100644 index 0000000000..6094815ed6 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitHttpTransportRegistration.java @@ -0,0 +1,55 @@ +/* + * 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; + +import org.eclipse.jgit.transport.HttpTransport; +import sonia.scm.web.ScmHttpConnectionFactory; +import sonia.scm.plugin.Extension; + +import javax.inject.Inject; +import javax.servlet.ServletContextEvent; +import javax.servlet.ServletContextListener; + +@Extension +public class GitHttpTransportRegistration implements ServletContextListener { + + private final ScmHttpConnectionFactory connectionFactory; + + @Inject + public GitHttpTransportRegistration(ScmHttpConnectionFactory connectionFactory) { + this.connectionFactory = connectionFactory; + } + + @Override + public void contextInitialized(ServletContextEvent servletContextEvent) { + // Override default http connection factory to inject our own ssl context + HttpTransport.setConnectionFactory(connectionFactory); + } + + @Override + public void contextDestroyed(ServletContextEvent servletContextEvent) { + // Nothing to destroy + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitUtil.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitUtil.java index 0127b0f75a..4767bbf396 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitUtil.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitUtil.java @@ -49,6 +49,7 @@ import org.eclipse.jgit.revwalk.filter.RevFilter; import org.eclipse.jgit.storage.file.FileRepositoryBuilder; import org.eclipse.jgit.transport.FetchResult; import org.eclipse.jgit.transport.RefSpec; +import org.eclipse.jgit.transport.TagOpt; import org.eclipse.jgit.treewalk.TreeWalk; import org.eclipse.jgit.util.FS; import org.eclipse.jgit.util.LfsFactory; @@ -97,6 +98,7 @@ public final class GitUtil { * the logger for GitUtil */ private static final Logger logger = LoggerFactory.getLogger(GitUtil.class); + private static final String REF_SPEC = "refs/heads/*:refs/heads/*"; //~--- constructors --------------------------------------------------------- @@ -691,4 +693,10 @@ public final class GitUtil { private static RefSpec createRefSpec(Repository repository) { return new RefSpec(String.format(REFSPEC, repository.getId())); } + + public static FetchCommand createFetchCommandWithBranchAndTagUpdate(Git git) { + return git.fetch() + .setRefSpecs(new RefSpec(REF_SPEC)) + .setTagOpt(TagOpt.FETCH_TAGS); + } } 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 new file mode 100644 index 0000000000..78cef9f19d --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMirrorCommand.java @@ -0,0 +1,567 @@ +/* + * 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 com.google.common.base.Stopwatch; +import com.google.common.base.Strings; +import org.eclipse.jgit.api.FetchCommand; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.lib.Constants; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.Ref; +import org.eclipse.jgit.lib.RefUpdate; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevObject; +import org.eclipse.jgit.revwalk.RevTag; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.transport.FetchResult; +import org.eclipse.jgit.transport.ReceiveCommand; +import org.eclipse.jgit.transport.TrackingRefUpdate; +import org.eclipse.jgit.transport.TransportHttp; +import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.repository.Changeset; +import sonia.scm.repository.GitChangesetConverter; +import sonia.scm.repository.GitChangesetConverterFactory; +import sonia.scm.repository.InternalRepositoryException; +import sonia.scm.repository.Tag; +import sonia.scm.repository.api.MirrorCommandResult; +import sonia.scm.repository.api.MirrorCommandResult.ResultType; +import sonia.scm.repository.api.MirrorFilter; +import sonia.scm.repository.api.MirrorFilter.Result; +import sonia.scm.repository.api.Pkcs12ClientCertificateCredential; +import sonia.scm.repository.api.UsernamePasswordCredential; + +import javax.inject.Inject; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; + +import static java.lang.String.format; +import static java.util.Collections.unmodifiableMap; +import static java.util.Optional.empty; +import static java.util.Optional.of; +import static org.eclipse.jgit.lib.RefUpdate.Result.NEW; +import static sonia.scm.repository.api.MirrorCommandResult.ResultType.FAILED; +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: + *

    + *
  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. + *
+ */ +public class GitMirrorCommand extends AbstractGitCommand implements MirrorCommand { + + public static final String MIRROR_REF_PREFIX = "refs/mirror/"; + + private static final Logger LOG = LoggerFactory.getLogger(GitMirrorCommand.class); + + private final PostReceiveRepositoryHookEventFactory postReceiveRepositoryHookEventFactory; + private final MirrorHttpConnectionProvider mirrorHttpConnectionProvider; + private final GitChangesetConverterFactory converterFactory; + private final GitTagConverter gitTagConverter; + + @Inject + GitMirrorCommand(GitContext context, PostReceiveRepositoryHookEventFactory postReceiveRepositoryHookEventFactory, MirrorHttpConnectionProvider mirrorHttpConnectionProvider, GitChangesetConverterFactory converterFactory, GitTagConverter gitTagConverter) { + super(context); + this.mirrorHttpConnectionProvider = mirrorHttpConnectionProvider; + this.postReceiveRepositoryHookEventFactory = postReceiveRepositoryHookEventFactory; + this.converterFactory = converterFactory; + this.gitTagConverter = gitTagConverter; + } + + @Override + public MirrorCommandResult mirror(MirrorCommandRequest mirrorCommandRequest) { + return update(mirrorCommandRequest); + } + + @Override + public MirrorCommandResult update(MirrorCommandRequest mirrorCommandRequest) { + try (Repository repository = context.open(); Git git = Git.wrap(repository)) { + return new Worker(mirrorCommandRequest, repository, git).update(); + } catch (IOException e) { + throw new InternalRepositoryException(context.getRepository(), "error during git fetch", e); + } + } + + private class Worker { + + private final MirrorCommandRequest mirrorCommandRequest; + private final List mirrorLog = new ArrayList<>(); + private final Stopwatch stopwatch; + + private final Repository repository; + private final Git git; + + private FetchResult fetchResult; + private GitFilterContext filterContext; + private MirrorFilter.Filter filter; + + private ResultType result = OK; + + private Worker(MirrorCommandRequest mirrorCommandRequest, Repository repository, Git git) { + this.mirrorCommandRequest = mirrorCommandRequest; + this.repository = repository; + this.git = git; + stopwatch = Stopwatch.createStarted(); + } + + MirrorCommandResult update() { + try { + return doUpdate(); + } catch (GitAPIException e) { + result = FAILED; + mirrorLog.add("failed to synchronize: " + e.getMessage()); + return new MirrorCommandResult(FAILED, mirrorLog, stopwatch.stop().elapsed()); + } + } + + private MirrorCommandResult doUpdate() throws GitAPIException { + fetchResult = createFetchCommand().call(); + filterContext = new GitFilterContext(); + filter = mirrorCommandRequest.getFilter().getFilter(filterContext); + + if (fetchResult.getTrackingRefUpdates().isEmpty()) { + mirrorLog.add("No updates found"); + } else { + handleBranches(); + handleTags(); + } + + postReceiveRepositoryHookEventFactory.fireForFetch(git, fetchResult); + return new MirrorCommandResult(result, mirrorLog, stopwatch.stop().elapsed()); + } + + private void handleBranches() { + LoggerWithHeader logger = new LoggerWithHeader("Branches:"); + doForEachRefStartingWith(MIRROR_REF_PREFIX + "heads", ref -> handleBranch(logger, ref)); + } + + private void handleBranch(LoggerWithHeader logger, TrackingRefUpdate ref) { + MirrorReferenceUpdateHandler refHandler = new MirrorReferenceUpdateHandler(logger, ref, "heads/", "branch"); + refHandler.handleRef(ref1 -> refHandler.testFilterForBranch()); + } + + private void handleTags() { + LoggerWithHeader logger = new LoggerWithHeader("Tags:"); + doForEachRefStartingWith(MIRROR_REF_PREFIX + "tags", ref -> handleTag(logger, ref)); + } + + private void handleTag(LoggerWithHeader logger, TrackingRefUpdate ref) { + MirrorReferenceUpdateHandler refHandler = new MirrorReferenceUpdateHandler(logger, ref, "tags/", "tag"); + refHandler.handleRef(ref1 -> refHandler.testFilterForTag()); + } + + private class MirrorReferenceUpdateHandler { + private final LoggerWithHeader logger; + private final TrackingRefUpdate ref; + private final String refType; + private final String typeForLog; + + public MirrorReferenceUpdateHandler(LoggerWithHeader logger, TrackingRefUpdate ref, String refType, String typeForLog) { + this.logger = logger; + this.ref = ref; + this.refType = refType; + this.typeForLog = typeForLog; + } + + private void handleRef(Function filter) { + Result filterResult = filter.apply(ref); + try { + String referenceName = ref.getLocalName().substring(MIRROR_REF_PREFIX.length() + refType.length()); + if (filterResult.isAccepted()) { + handleAcceptedReference(referenceName); + } else { + handleRejectedRef(referenceName, filterResult); + } + } catch (Exception e) { + handleReferenceUpdateException(e); + } + } + + private Result testFilterForBranch() { + try { + return filter.acceptBranch(filterContext.getBranchUpdate(ref.getLocalName())); + } catch (Exception e) { + return handleExceptionFromFilter(e); + } + } + + private void handleReferenceUpdateException(Exception e) { + LOG.warn("got exception processing ref {} in repository {}", ref.getLocalName(), GitMirrorCommand.this.repository, e); + mirrorLog.add(format("got error processing reference %s: %s", ref.getLocalName(), e.getMessage())); + mirrorLog.add("mirror may be damaged"); + } + + private void handleRejectedRef(String referenceName, Result filterResult) throws IOException { + result = REJECTED_UPDATES; + LOG.trace("{} ref rejected in {}: {}", typeForLog, GitMirrorCommand.this.repository, ref.getLocalName()); + if (ref.getResult() == NEW) { + deleteReference(ref.getLocalName()); + } else { + updateReference(ref.getLocalName(), ref.getOldObjectId()); + } + logger.logChange(ref, referenceName, filterResult.getRejectReason().orElse("rejected due to filter")); + } + + private void handleAcceptedReference(String referenceName) throws IOException { + String targetRef = "refs/" + refType + referenceName; + if (isDeletedReference(ref)) { + LOG.trace("deleting {} ref in {}: {}", typeForLog, GitMirrorCommand.this.repository, targetRef); + deleteReference(targetRef); + logger.logChange(ref, referenceName, "deleted"); + } else { + LOG.trace("updating {} ref in {}: {}", typeForLog, GitMirrorCommand.this.repository, targetRef); + updateReference(targetRef, ref.getNewObjectId()); + logger.logChange(ref, referenceName, getUpdateType(ref)); + } + } + + private Result testFilterForTag() { + try { + return filter.acceptTag(filterContext.getTagUpdate(ref.getLocalName())); + } catch (Exception e) { + return handleExceptionFromFilter(e); + } + } + + private Result handleExceptionFromFilter(Exception e) { + LOG.warn("got exception from filter for ref {} in repository {}", ref.getLocalName(), GitMirrorCommand.this.repository, e); + mirrorLog.add("! got error checking filter for update: " + e.getMessage()); + return Result.reject("exception in filter"); + } + + private void deleteReference(String targetRef) throws IOException { + RefUpdate deleteUpdate = repository.getRefDatabase().newUpdate(targetRef, true); + deleteUpdate.setForceUpdate(true); + deleteUpdate.delete(); + } + + private boolean isDeletedReference(TrackingRefUpdate ref) { + return ref.asReceiveCommand().getType() == ReceiveCommand.Type.DELETE; + } + + private void updateReference(String reference, ObjectId objectId) throws IOException { + LOG.trace("updating ref in {}: {} -> {}", GitMirrorCommand.this.repository, reference, objectId); + RefUpdate refUpdate = repository.getRefDatabase().newUpdate(reference, true); + refUpdate.setNewObjectId(objectId); + refUpdate.forceUpdate(); + } + + private String getUpdateType(TrackingRefUpdate trackingRefUpdate) { + return trackingRefUpdate.getResult().name().toLowerCase(Locale.ENGLISH); + } + } + + private class LoggerWithHeader { + private final String header; + private boolean headerWritten = false; + + private LoggerWithHeader(String header) { + this.header = header; + } + + void logChange(TrackingRefUpdate ref, String branchName, String type) { + logLine( + format("- %s..%s %s (%s)", + ref.getOldObjectId().abbreviate(9).name(), + ref.getNewObjectId().abbreviate(9).name(), + branchName, + type + )); + } + + void logLine(String line) { + if (!headerWritten) { + headerWritten = true; + mirrorLog.add(header); + } + mirrorLog.add(line); + } + } + + private void doForEachRefStartingWith(String prefix, RefUpdateConsumer refUpdateConsumer) { + fetchResult.getTrackingRefUpdates() + .stream() + .filter(ref -> ref.getLocalName().startsWith(prefix)) + .forEach(ref -> { + try { + refUpdateConsumer.accept(ref); + } catch (IOException e) { + throw new InternalRepositoryException(GitMirrorCommand.this.repository, "error updating mirror references", e); + } + }); + } + + private FetchCommand createFetchCommand() { + FetchCommand fetchCommand = Git.wrap(repository).fetch() + .setRefSpecs("refs/heads/*:" + MIRROR_REF_PREFIX + "heads/*", "refs/tags/*:" + MIRROR_REF_PREFIX + "tags/*") + .setForceUpdate(true) + .setRemoveDeletedRefs(true) + .setRemote(mirrorCommandRequest.getSourceUrl()); + + mirrorCommandRequest.getCredential(Pkcs12ClientCertificateCredential.class) + .ifPresent(c -> fetchCommand.setTransportConfigCallback(transport -> { + if (transport instanceof TransportHttp) { + TransportHttp transportHttp = (TransportHttp) transport; + transportHttp.setHttpConnectionFactory(mirrorHttpConnectionProvider.createHttpConnectionFactory(c, mirrorLog)); + } + })); + mirrorCommandRequest.getCredential(UsernamePasswordCredential.class) + .ifPresent(c -> fetchCommand + .setCredentialsProvider( + new UsernamePasswordCredentialsProvider( + Strings.nullToEmpty(c.username()), + Strings.nullToEmpty(new String(c.password())) + )) + ); + + return fetchCommand; + } + + private class GitFilterContext implements MirrorFilter.FilterContext { + + private final Map branchUpdates; + private final Map tagUpdates; + + public GitFilterContext() { + Map extractedBranchUpdates = new HashMap<>(); + Map extractedTagUpdates = new HashMap<>(); + + fetchResult.getTrackingRefUpdates().forEach(refUpdate -> { + if (refUpdate.getLocalName().startsWith(MIRROR_REF_PREFIX + "heads")) { + extractedBranchUpdates.put(refUpdate.getLocalName(), new GitBranchUpdate(refUpdate)); + } + if (refUpdate.getLocalName().startsWith(MIRROR_REF_PREFIX + "tags")) { + extractedTagUpdates.put(refUpdate.getLocalName(), new GitTagUpdate(refUpdate)); + } + }); + + this.branchUpdates = unmodifiableMap(extractedBranchUpdates); + this.tagUpdates = unmodifiableMap(extractedTagUpdates); + } + + @Override + public Collection getBranchUpdates() { + return branchUpdates.values(); + } + + @Override + public Collection getTagUpdates() { + return tagUpdates.values(); + } + + MirrorFilter.BranchUpdate getBranchUpdate(String ref) { + return branchUpdates.get(ref); + } + + MirrorFilter.TagUpdate getTagUpdate(String ref) { + return tagUpdates.get(ref); + } + } + + private class GitBranchUpdate implements MirrorFilter.BranchUpdate { + + private final TrackingRefUpdate refUpdate; + + private final String branchName; + + private Changeset changeset; + + public GitBranchUpdate(TrackingRefUpdate refUpdate) { + this.refUpdate = refUpdate; + this.branchName = refUpdate.getLocalName().substring(MIRROR_REF_PREFIX.length() + "heads/".length()); + } + + @Override + public String getBranchName() { + return branchName; + } + + @Override + public Optional getChangeset() { + if (isOfTypeOrEmpty(getUpdateType(), MirrorFilter.UpdateType.DELETE)) { + return empty(); + } + if (changeset == null) { + changeset = computeChangeset(); + } + return of(changeset); + } + + @Override + public Optional getNewRevision() { + if (isOfTypeOrEmpty(getUpdateType(), MirrorFilter.UpdateType.DELETE)) { + return empty(); + } + return of(refUpdate.getNewObjectId().name()); + } + + @Override + public Optional getOldRevision() { + if (isOfTypeOrEmpty(getUpdateType(), MirrorFilter.UpdateType.CREATE)) { + return empty(); + } + return of(refUpdate.getOldObjectId().name()); + } + + @Override + public Optional getUpdateType() { + return getUpdateTypeFor(refUpdate.asReceiveCommand()); + } + + @Override + public boolean isForcedUpdate() { + return refUpdate.getResult() == RefUpdate.Result.FORCED; + } + + private Changeset computeChangeset() { + try (RevWalk revWalk = new RevWalk(repository); GitChangesetConverter gitChangesetConverter = converter(revWalk)) { + try { + RevCommit revCommit = revWalk.parseCommit(refUpdate.getNewObjectId()); + return gitChangesetConverter.createChangeset(revCommit, refUpdate.getLocalName()); + } catch (Exception e) { + throw new InternalRepositoryException(context.getRepository(), "got exception while validating branch", e); + } + } + } + + private GitChangesetConverter converter(RevWalk revWalk) { + return converterFactory.builder(repository) + .withRevWalk(revWalk) + .withAdditionalPublicKeys(mirrorCommandRequest.getPublicKeys()) + .create(); + } + } + + private class GitTagUpdate implements MirrorFilter.TagUpdate { + + private final TrackingRefUpdate refUpdate; + + private final String tagName; + + private Tag tag; + + public GitTagUpdate(TrackingRefUpdate refUpdate) { + this.refUpdate = refUpdate; + this.tagName = refUpdate.getLocalName().substring(MIRROR_REF_PREFIX.length() + "tags/".length()); + } + + @Override + public String getTagName() { + return tagName; + } + + @Override + public Optional getTag() { + if (isOfTypeOrEmpty(getUpdateType(), MirrorFilter.UpdateType.DELETE)) { + return empty(); + } + if (tag == null) { + tag = computeTag(); + } + return of(tag); + } + + @Override + public Optional getNewRevision() { + return getTag().map(Tag::getRevision); + } + + @Override + public Optional getOldRevision() { + if (isOfTypeOrEmpty(getUpdateType(), MirrorFilter.UpdateType.CREATE)) { + return empty(); + } + return of(refUpdate.getOldObjectId().name()); + } + + @Override + public Optional getUpdateType() { + return getUpdateTypeFor(refUpdate.asReceiveCommand()); + } + + private Tag computeTag() { + try (RevWalk revWalk = new RevWalk(repository)) { + try { + RevObject revObject = revWalk.parseAny(refUpdate.getNewObjectId()); + if (revObject.getType() == Constants.OBJ_TAG) { + RevTag revTag = revWalk.parseTag(revObject.getId()); + return gitTagConverter.buildTag(revTag, revWalk); + } else if (revObject.getType() == Constants.OBJ_COMMIT) { + Ref ref = repository.getRefDatabase().findRef(refUpdate.getLocalName()); + Tag t = gitTagConverter.buildTag(repository, revWalk, ref); + return new Tag(tagName, t.getRevision(), t.getDate().orElse(null), t.getDeletable()); + } else { + throw new InternalRepositoryException(context.getRepository(), "invalid object type for tag"); + } + } catch (Exception e) { + throw new InternalRepositoryException(context.getRepository(), "got exception while validating tag", e); + } + } + } + } + + private boolean isOfTypeOrEmpty(Optional updateType, MirrorFilter.UpdateType type) { + return !updateType.isPresent() || updateType.get() == type; + } + + private Optional getUpdateTypeFor(ReceiveCommand receiveCommand) { + switch (receiveCommand.getType()) { + case UPDATE: + case UPDATE_NONFASTFORWARD: + return of(MirrorFilter.UpdateType.UPDATE); + case CREATE: + return of(MirrorFilter.UpdateType.CREATE); + case DELETE: + return of(MirrorFilter.UpdateType.DELETE); + default: + return empty(); + } + } + } + + private interface RefUpdateConsumer { + void accept(TrackingRefUpdate refUpdate) throws IOException; + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitPullCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitPullCommand.java index 3bf5b10298..ab31a24e4e 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitPullCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitPullCommand.java @@ -32,26 +32,20 @@ import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.transport.FetchResult; -import org.eclipse.jgit.transport.RefSpec; -import org.eclipse.jgit.transport.TagOpt; import org.eclipse.jgit.transport.TrackingRefUpdate; import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.ContextEntry; -import sonia.scm.event.ScmEventBus; import sonia.scm.repository.GitRepositoryHandler; import sonia.scm.repository.GitUtil; import sonia.scm.repository.Repository; -import sonia.scm.repository.Tag; import sonia.scm.repository.api.ImportFailedException; import sonia.scm.repository.api.PullResponse; import javax.inject.Inject; import java.io.File; import java.io.IOException; -import java.util.List; -import java.util.stream.Collectors; /** * @author Sebastian Sdorra @@ -59,19 +53,15 @@ import java.util.stream.Collectors; public class GitPullCommand extends AbstractGitPushOrPullCommand implements PullCommand { - private static final String REF_SPEC = "refs/heads/*:refs/heads/*"; private static final Logger LOG = LoggerFactory.getLogger(GitPullCommand.class); - private final ScmEventBus eventBus; - private final GitRepositoryHookEventFactory eventFactory; + private final PostReceiveRepositoryHookEventFactory postReceiveRepositoryHookEventFactory; @Inject public GitPullCommand(GitRepositoryHandler handler, GitContext context, - ScmEventBus eventBus, - GitRepositoryHookEventFactory eventFactory) { + PostReceiveRepositoryHookEventFactory postReceiveRepositoryHookEventFactory) { super(handler, context); - this.eventBus = eventBus; - this.eventFactory = eventFactory; + this.postReceiveRepositoryHookEventFactory = postReceiveRepositoryHookEventFactory; } @Override @@ -158,8 +148,8 @@ public class GitPullCommand extends AbstractGitPushOrPullCommand org.eclipse.jgit.lib.Repository source = null; - try { - source = Git.open(sourceDirectory).getRepository(); + try (Git git = Git.open(sourceDirectory)) { + source = git.getRepository(); response = new PullResponse(push(source, getRemoteUrl(targetDirectory))); } finally { GitUtil.close(source); @@ -177,16 +167,14 @@ public class GitPullCommand extends AbstractGitPushOrPullCommand FetchResult result; try { //J- - result = git.fetch() + result = GitUtil.createFetchCommandWithBranchAndTagUpdate(git) .setCredentialsProvider( new UsernamePasswordCredentialsProvider( - Strings.nullToEmpty(request.getUsername()), + Strings.nullToEmpty(request.getUsername()), Strings.nullToEmpty(request.getPassword()) ) ) - .setRefSpecs(new RefSpec(REF_SPEC)) .setRemote(request.getRemoteUrl().toExternalForm()) - .setTagOpt(TagOpt.FETCH_TAGS) .call(); //J+ @@ -206,31 +194,6 @@ public class GitPullCommand extends AbstractGitPushOrPullCommand } private void firePostReceiveRepositoryHookEvent(Git git, FetchResult result) { - try { - List branches = getBranchesFromFetchResult(result); - List tags = getTagsFromFetchResult(result); - GitLazyChangesetResolver changesetResolver = new GitLazyChangesetResolver(context.getRepository(), git); - eventBus.post(eventFactory.createEvent(context, branches, tags, changesetResolver)); - } catch (IOException e) { - throw new ImportFailedException( - ContextEntry.ContextBuilder.entity(context.getRepository()).build(), - "Could not fire post receive repository hook event after unbundle", - e - ); - } - } - - private List getTagsFromFetchResult(FetchResult result) { - return result.getAdvertisedRefs().stream() - .filter(r -> r.getName().startsWith("refs/tags/")) - .map(r -> new Tag(r.getName().substring("refs/tags/".length()), r.getObjectId().getName())) - .collect(Collectors.toList()); - } - - private List getBranchesFromFetchResult(FetchResult result) { - return result.getAdvertisedRefs().stream() - .filter(r -> r.getName().startsWith("refs/heads/")) - .map(r -> r.getLeaf().getName().substring("refs/heads/".length())) - .collect(Collectors.toList()); + postReceiveRepositoryHookEventFactory.fireForFetch(git, result); } } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java index 5c51d0b928..3d981a112c 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java @@ -56,7 +56,8 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider { Command.MERGE, Command.MODIFY, Command.BUNDLE, - Command.UNBUNDLE + Command.UNBUNDLE, + Command.MIRROR ); protected static final Set FEATURES = EnumSet.of(Feature.INCOMING_REVISION); @@ -171,6 +172,11 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider { return commandInjector.getInstance(GitUnbundleCommand.class); } + @Override + public MirrorCommand getMirrorCommand() { + return commandInjector.getInstance(GitMirrorCommand.class); + } + @Override public Set getSupportedCommands() { return COMMANDS; diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitTagConverter.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitTagConverter.java new file mode 100644 index 0000000000..917574fff5 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitTagConverter.java @@ -0,0 +1,88 @@ +/* + * 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.lib.Constants; +import org.eclipse.jgit.lib.Ref; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevObject; +import org.eclipse.jgit.revwalk.RevTag; +import org.eclipse.jgit.revwalk.RevWalk; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.repository.GitUtil; +import sonia.scm.repository.Tag; +import sonia.scm.security.GPG; + +import javax.inject.Inject; +import java.io.IOException; + +class GitTagConverter { + + private static final Logger LOG = LoggerFactory.getLogger(GitTagConverter.class); + + private final GPG gpg; + + @Inject + GitTagConverter(GPG gpg) { + this.gpg = gpg; + } + + public Tag buildTag(RevTag revTag, RevWalk revWalk) { + Tag tag = null; + try { + RevCommit revCommit = revWalk.parseCommit(revTag.getObject().getId()); + tag = new Tag(revTag.getTagName(), revCommit.getId().name(), revTag.getTaggerIdent().getWhen().getTime()); + GitUtil.getTagSignature(revTag, gpg, revWalk).ifPresent(tag::addSignature); + } catch (IOException ex) { + LOG.error("could not get commit for tag", ex); + } + return tag; + } + + public Tag buildTag(Repository repository, RevWalk revWalk, Ref ref) { + Tag tag = null; + + try { + RevCommit revCommit = GitUtil.getCommit(repository, revWalk, ref); + if (revCommit != null) { + String name = GitUtil.getTagName(ref); + tag = new Tag(name, revCommit.getId().name(), GitUtil.getTagTime(revWalk, ref.getObjectId())); + RevObject revObject = revWalk.parseAny(ref.getObjectId()); + if (revObject.getType() == Constants.OBJ_TAG) { + RevTag revTag = (RevTag) revObject; + GitUtil.getTagSignature(revTag, gpg, revWalk) + .ifPresent(tag::addSignature); + } + } + } catch (IOException ex) { + LOG.error("could not get commit for tag", ex); + } + + return tag; + } + +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitTagsCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitTagsCommand.java index f4b9358265..b9e3fffea6 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitTagsCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitTagsCommand.java @@ -26,28 +26,19 @@ package sonia.scm.repository.spi; //~--- non-JDK imports -------------------------------------------------------- -import com.google.common.base.Function; -import com.google.common.collect.Lists; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; -import org.eclipse.jgit.lib.Constants; import org.eclipse.jgit.lib.Ref; -import org.eclipse.jgit.lib.Repository; -import org.eclipse.jgit.revwalk.RevCommit; -import org.eclipse.jgit.revwalk.RevObject; -import org.eclipse.jgit.revwalk.RevTag; import org.eclipse.jgit.revwalk.RevWalk; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import sonia.scm.repository.GitUtil; import sonia.scm.repository.InternalRepositoryException; import sonia.scm.repository.Tag; -import sonia.scm.security.GPG; import javax.inject.Inject; import java.io.IOException; import java.util.List; +import static java.util.stream.Collectors.toList; + //~--- JDK imports ------------------------------------------------------------ /** @@ -55,7 +46,7 @@ import java.util.List; */ public class GitTagsCommand extends AbstractGitCommand implements TagsCommand { - private final GPG gpg; + private final GitTagConverter gitTagConverter; /** * Constructs ... @@ -63,108 +54,23 @@ public class GitTagsCommand extends AbstractGitCommand implements TagsCommand { * @param context */ @Inject - public GitTagsCommand(GitContext context, GPG gpp) { + public GitTagsCommand(GitContext context, GitTagConverter gitTagConverter) { super(context); - this.gpg = gpp; + this.gitTagConverter = gitTagConverter; } //~--- get methods ---------------------------------------------------------- @Override public List getTags() throws IOException { - List tags; - - RevWalk revWalk = null; - - try (Git git = new Git(open())) { - revWalk = new RevWalk(git.getRepository()); - + try (Git git = new Git(open()); RevWalk revWalk = new RevWalk(git.getRepository())) { List tagList = git.tagList().call(); - tags = Lists.transform(tagList, - new TransformFunction(git.getRepository(), revWalk, gpg)); + return tagList.stream() + .map(ref -> gitTagConverter.buildTag(git.getRepository(), revWalk, ref)) + .collect(toList()); } catch (GitAPIException ex) { throw new InternalRepositoryException(repository, "could not read tags from repository", ex); - } finally { - GitUtil.release(revWalk); } - - return tags; - } - - //~--- inner classes -------------------------------------------------------- - - /** - * Class description - * - * @author Enter your name here... - * @version Enter version here..., 12/07/06 - */ - private static class TransformFunction implements Function { - - /** - * the logger for TransformFuntion - */ - private static final Logger logger = - LoggerFactory.getLogger(TransformFunction.class); - - //~--- constructors ------------------------------------------------------- - - /** - * Constructs ... - * @param repository - * @param revWalk - */ - public TransformFunction(Repository repository, - RevWalk revWalk, - GPG gpg) { - this.repository = repository; - this.revWalk = revWalk; - this.gpg = gpg; - } - - //~--- methods ------------------------------------------------------------ - - /** - * Method description - * - * @param ref - * @return - */ - @Override - public Tag apply(Ref ref) { - Tag tag = null; - - try { - RevCommit revCommit = GitUtil.getCommit(repository, revWalk, ref); - if (revCommit != null) { - String name = GitUtil.getTagName(ref); - tag = new Tag(name, revCommit.getId().name(), GitUtil.getTagTime(revWalk, ref.getObjectId())); - RevObject revObject = revWalk.parseAny(ref.getObjectId()); - if (revObject.getType() == Constants.OBJ_TAG) { - RevTag revTag = (RevTag) revObject; - GitUtil.getTagSignature(revTag, gpg, revWalk) - .ifPresent(tag::addSignature); - } - } - } catch (IOException ex) { - logger.error("could not get commit for tag", ex); - } - - return tag; - } - - //~--- fields ------------------------------------------------------------- - - /** - * Field description - */ - private final org.eclipse.jgit.lib.Repository repository; - - /** - * Field description - */ - private final RevWalk revWalk; - private final GPG gpg; } } 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 new file mode 100644 index 0000000000..329347f62b --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/MirrorHttpConnectionProvider.java @@ -0,0 +1,74 @@ +/* + * 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.transport.http.HttpConnectionFactory2; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.repository.api.Pkcs12ClientCertificateCredential; +import sonia.scm.web.ScmHttpConnectionFactory; + +import javax.inject.Inject; +import javax.inject.Provider; +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.TrustManager; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.util.List; + +class MirrorHttpConnectionProvider { + + private static final Logger LOG = LoggerFactory.getLogger(MirrorHttpConnectionProvider.class); + + private final Provider trustManagerProvider; + + @Inject + public MirrorHttpConnectionProvider(Provider trustManagerProvider) { + this.trustManagerProvider = trustManagerProvider; + } + + public HttpConnectionFactory2 createHttpConnectionFactory(Pkcs12ClientCertificateCredential credential, List log) { + return new ScmHttpConnectionFactory(trustManagerProvider, createKeyManagers(credential, log)); + } + + private KeyManager[] createKeyManagers(Pkcs12ClientCertificateCredential credential, List log) { + try { + KeyStore pkcs12 = KeyStore.getInstance("PKCS12"); + pkcs12.load(new ByteArrayInputStream(credential.getCertificate()), credential.getPassword()); + KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + keyManagerFactory.init(pkcs12, credential.getPassword()); + log.add("added pkcs12 certificate"); + return keyManagerFactory.getKeyManagers(); + } catch (IOException | GeneralSecurityException e) { + LOG.info("could not create key store from pkcs12 credential", e); + log.add("failed to add pkcs12 certificate: " + e.getMessage()); + } + + return new KeyManager[0]; + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/PostReceiveRepositoryHookEventFactory.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/PostReceiveRepositoryHookEventFactory.java new file mode 100644 index 0000000000..b9d7d982c5 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/PostReceiveRepositoryHookEventFactory.java @@ -0,0 +1,85 @@ +/* + * 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.api.Git; +import org.eclipse.jgit.transport.FetchResult; +import sonia.scm.ContextEntry; +import sonia.scm.event.ScmEventBus; +import sonia.scm.repository.PostReceiveRepositoryHookEvent; +import sonia.scm.repository.RepositoryHookEvent; +import sonia.scm.repository.Tag; +import sonia.scm.repository.WrappedRepositoryHookEvent; +import sonia.scm.repository.api.ImportFailedException; + +import javax.inject.Inject; +import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; + +class PostReceiveRepositoryHookEventFactory { + + private final ScmEventBus eventBus; + private final GitRepositoryHookEventFactory eventFactory; + private final GitContext context; + + @Inject + PostReceiveRepositoryHookEventFactory(ScmEventBus eventBus, GitRepositoryHookEventFactory eventFactory, GitContext context) { + this.eventBus = eventBus; + this.eventFactory = eventFactory; + this.context = context; + } + + void fireForFetch(Git git, FetchResult result) { + PostReceiveRepositoryHookEvent event; + try { + List branches = getBranchesFromFetchResult(result); + List tags = getTagsFromFetchResult(result); + GitLazyChangesetResolver changesetResolver = new GitLazyChangesetResolver(context.getRepository(), git); + event = new PostReceiveRepositoryHookEvent(WrappedRepositoryHookEvent.wrap(eventFactory.createEvent(context, branches, tags, changesetResolver))); + } catch (IOException e) { + throw new ImportFailedException( + ContextEntry.ContextBuilder.entity(context.getRepository()).build(), + "Could not fire post receive repository hook event after fetch", + e + ); + } + eventBus.post(event); + } + + private List getTagsFromFetchResult(FetchResult result) { + return result.getAdvertisedRefs().stream() + .filter(r -> r.getName().startsWith("refs/tags/")) + .map(r -> new Tag(r.getName().substring("refs/tags/".length()), r.getObjectId().getName())) + .collect(Collectors.toList()); + } + + private List getBranchesFromFetchResult(FetchResult result) { + return result.getAdvertisedRefs().stream() + .filter(r -> r.getName().startsWith("refs/heads/")) + .map(r -> r.getLeaf().getName().substring("refs/heads/".length())) + .collect(Collectors.toList()); + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitServletModule.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitServletModule.java index 7cd1042115..496c0891f1 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitServletModule.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitServletModule.java @@ -38,16 +38,13 @@ import sonia.scm.repository.spi.SimpleGitWorkingCopyFactory; import sonia.scm.web.lfs.LfsBlobStoreFactory; /** - * * @author Sebastian Sdorra */ @Extension -public class GitServletModule extends ServletModule -{ +public class GitServletModule extends ServletModule { @Override - protected void configureServlets() - { + protected void configureServlets() { bind(GitRepositoryViewer.class); bind(GitRepositoryResolver.class); bind(GitReceivePackFactory.class); diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/ScmGitServlet.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/ScmGitServlet.java index 034e8fa84d..0fb4ec9196 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/ScmGitServlet.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/ScmGitServlet.java @@ -30,6 +30,7 @@ import com.google.inject.Singleton; import org.eclipse.jgit.http.server.GitServlet; import org.eclipse.jgit.lfs.lib.Constants; import org.slf4j.Logger; +import sonia.scm.protocolcommand.git.ScmUploadPackFactoryForHttpServletRequest; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryRequestListenerUtil; import sonia.scm.repository.spi.ScmProviderHttpServlet; @@ -71,6 +72,7 @@ public class ScmGitServlet extends GitServlet implements ScmProviderHttpServlet @Inject public ScmGitServlet(GitRepositoryResolver repositoryResolver, GitReceivePackFactory receivePackFactory, + ScmUploadPackFactoryForHttpServletRequest scmUploadPackFactory, GitRepositoryViewer repositoryViewer, RepositoryRequestListenerUtil repositoryRequestListenerUtil, LfsServletFactory lfsServletFactory) @@ -81,6 +83,7 @@ public class ScmGitServlet extends GitServlet implements ScmProviderHttpServlet setRepositoryResolver(repositoryResolver); setReceivePackFactory(receivePackFactory); + setUploadPackFactory(scmUploadPackFactory); } //~--- methods -------------------------------------------------------------- diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/ScmHttpConnectionFactory.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/ScmHttpConnectionFactory.java new file mode 100644 index 0000000000..5e157221aa --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/ScmHttpConnectionFactory.java @@ -0,0 +1,98 @@ +/* + * 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.eclipse.jgit.internal.JGitText; +import org.eclipse.jgit.transport.http.HttpConnection; +import org.eclipse.jgit.transport.http.JDKHttpConnection; +import org.eclipse.jgit.transport.http.JDKHttpConnectionFactory; +import org.eclipse.jgit.transport.http.NoCheckX509TrustManager; + +import javax.inject.Inject; +import javax.inject.Provider; +import javax.net.ssl.KeyManager; +import javax.net.ssl.TrustManager; +import java.security.GeneralSecurityException; +import java.text.MessageFormat; + +public class ScmHttpConnectionFactory extends JDKHttpConnectionFactory { + + private final Provider trustManagerProvider; + private final KeyManager[] keyManagers; + + @Inject + public ScmHttpConnectionFactory(Provider trustManagerProvider) { + this(trustManagerProvider, null); + } + + public ScmHttpConnectionFactory(Provider trustManagerProvider, KeyManager[] keyManagers) { + this.trustManagerProvider = trustManagerProvider; + this.keyManagers = keyManagers; + } + + @Override + public GitSession newSession() { + return new ScmConnectionSession(trustManagerProvider.get(), keyManagers); + } + + private static class ScmConnectionSession implements GitSession { + + private final TrustManager trustManager; + private final KeyManager[] keyManagers; + + private ScmConnectionSession(TrustManager trustManager, KeyManager[] keyManagers) { + this.trustManager = trustManager; + this.keyManagers = keyManagers; + } + + @Override + @SuppressWarnings("java:S5527") + public JDKHttpConnection configure(HttpConnection connection, + boolean sslVerify) throws GeneralSecurityException { + if (!(connection instanceof JDKHttpConnection)) { + throw new IllegalArgumentException(MessageFormat.format( + JGitText.get().httpWrongConnectionType, + JDKHttpConnection.class.getName(), + connection.getClass().getName())); + } + JDKHttpConnection conn = (JDKHttpConnection) connection; + String scheme = conn.getURL().getProtocol(); + if ("https".equals(scheme) && sslVerify) { //$NON-NLS-1$ + // sslVerify == true: use the JDK defaults + conn.configure(keyManagers, new TrustManager[]{trustManager}, null); + } else if ("https".equals(scheme)) { + conn.configure(keyManagers, new TrustManager[]{new NoCheckX509TrustManager()}, null); + conn.setHostnameVerifier((name, value) -> true); + } + + return conn; + } + + @Override + public void close() { + // Nothing + } + } +} diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/protocolcommand/git/MirrorRefFilterTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/protocolcommand/git/MirrorRefFilterTest.java new file mode 100644 index 0000000000..1105ebbb83 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/protocolcommand/git/MirrorRefFilterTest.java @@ -0,0 +1,51 @@ +/* + * 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.protocolcommand.git; + +import org.eclipse.jgit.lib.Ref; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.entry; +import static org.mockito.Mockito.mock; + +class MirrorRefFilterTest { + + @Test + void shouldRemoveMirrorRefs() { + Map refs = new HashMap<>(); + Ref master = mock(Ref.class); + Ref mirror = mock(Ref.class); + refs.put("refs/heads/master", master); + refs.put("refs/mirror/some/other", mirror); + + Map filteredRefs = MirrorRefFilter.filterMirrors(refs); + + assertThat(filteredRefs).containsOnly(entry("refs/heads/master", master)); + } +} diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GPGSignatureResolverTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GPGSignatureResolverTest.java new file mode 100644 index 0000000000..ed9feacf1e --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GPGSignatureResolverTest.java @@ -0,0 +1,96 @@ +/* + * 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; + +import com.google.common.collect.ImmutableSet; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.security.GPG; +import sonia.scm.security.PublicKey; + +import java.util.Optional; + +import static java.util.Collections.emptySet; +import static java.util.Collections.singleton; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class GPGSignatureResolverTest { + + @Mock + private GPG gpg; + + @Test + void shouldDelegateFindPublicKeyId() { + GPGSignatureResolver signatureResolver = new GPGSignatureResolver(gpg, emptySet()); + + byte[] signature = new byte[]{4, 2}; + when(gpg.findPublicKeyId(signature)).thenReturn("0x42"); + + assertThat(signatureResolver.findPublicKeyId(signature)).isEqualTo("0x42"); + } + + @Test + void shouldResolveStoredGpgKey() { + GPGSignatureResolver signatureResolver = new GPGSignatureResolver(gpg, emptySet()); + + PublicKey publicKey = createPublicKey("0x42"); + when(gpg.findPublicKey("0x42")).thenReturn(Optional.of(publicKey)); + + Optional resolverPublicKey = signatureResolver.findPublicKey("0x42"); + assertThat(resolverPublicKey).contains(publicKey); + } + + @Test + void shouldResolveKeyFormList() { + PublicKey publicKey = createPublicKey("0x21"); + GPGSignatureResolver signatureResolver = new GPGSignatureResolver(gpg, singleton(publicKey)); + + Optional resolverPublicKey = signatureResolver.findPublicKey("0x21"); + assertThat(resolverPublicKey).contains(publicKey); + } + + @Test + void shouldResolveSubkeyFormList() { + PublicKey publicKey = createPublicKey("0x21", "0x42"); + GPGSignatureResolver signatureResolver = new GPGSignatureResolver(gpg, singleton(publicKey)); + + Optional resolverPublicKey = signatureResolver.findPublicKey("0x42"); + assertThat(resolverPublicKey).contains(publicKey); + } + + private PublicKey createPublicKey(String id, String... subkeys) { + PublicKey publicKey = mock(PublicKey.class); + lenient().when(publicKey.getId()).thenReturn(id); + lenient().when(publicKey.getSubkeys()).thenReturn(ImmutableSet.copyOf(subkeys)); + return publicKey; + } + +} diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitIncomingCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitIncomingCommandTest.java index fedab56e9f..a0121552ed 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitIncomingCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitIncomingCommandTest.java @@ -93,12 +93,13 @@ public class GitIncomingCommandTest commit(outgoing, "added a"); + GitContext context = new GitContext(incomingDirectory, incomingRepository, new GitRepositoryConfigStoreProvider(new InMemoryConfigurationStoreFactory()), new GitConfig()); + PostReceiveRepositoryHookEventFactory postReceiveRepositoryHookEventFactory = new PostReceiveRepositoryHookEventFactory(eventBus, eventFactory, context); + GitPullCommand pull = new GitPullCommand( handler, - new GitContext(incomingDirectory, incomingRepository, new GitRepositoryConfigStoreProvider(new InMemoryConfigurationStoreFactory()), new GitConfig()), - eventBus, - eventFactory - ); + context, + postReceiveRepositoryHookEventFactory); PullCommandRequest req = new PullCommandRequest(); req.setRemoteRepository(outgoingRepository); pull.pull(req); 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 new file mode 100644 index 0000000000..bc2c2cb447 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMirrorCommandTest.java @@ -0,0 +1,796 @@ +/* + * 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.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.junit.http.AppServer; +import org.eclipse.jgit.junit.http.SimpleHttpServer; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.Ref; +import org.eclipse.jgit.revwalk.RevObject; +import org.eclipse.jgit.revwalk.RevWalk; +import org.junit.Before; +import org.junit.Test; +import sonia.scm.api.v2.resources.GitRepositoryConfigStoreProvider; +import sonia.scm.repository.GitChangesetConverterFactory; +import sonia.scm.repository.GitConfig; +import sonia.scm.repository.api.MirrorCommandResult; +import sonia.scm.repository.api.MirrorFilter; +import sonia.scm.repository.api.SimpleUsernamePasswordCredential; +import sonia.scm.security.GPG; +import sonia.scm.store.InMemoryConfigurationStoreFactory; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; + +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static sonia.scm.repository.api.MirrorCommandResult.ResultType.FAILED; +import static sonia.scm.repository.api.MirrorCommandResult.ResultType.OK; +import static sonia.scm.repository.api.MirrorCommandResult.ResultType.REJECTED_UPDATES; + +public class GitMirrorCommandTest extends AbstractGitCommandTestBase { + + public static final Consumer ACCEPT_ALL = r -> { + }; + public static final Consumer REJECT_ALL = r -> r.setFilter(new DenyAllMirrorFilter()); + private final PostReceiveRepositoryHookEventFactory postReceiveRepositoryHookEventFactory = mock(PostReceiveRepositoryHookEventFactory.class); + private final MirrorHttpConnectionProvider mirrorHttpConnectionProvider = mock(MirrorHttpConnectionProvider.class); + private final GPG gpg = mock(GPG.class); + private final GitChangesetConverterFactory gitChangesetConverterFactory = new GitChangesetConverterFactory(gpg); + private final GitTagConverter gitTagConverter = new GitTagConverter(gpg); + + private File clone; + private GitMirrorCommand command; + + @Before + public void bendContextToNewRepository() throws IOException, GitAPIException { + clone = tempFolder.newFolder(); + Git.init().setBare(true).setDirectory(clone).call(); + + GitContext emptyContext = createMirrorContext(clone); + command = new GitMirrorCommand(emptyContext, postReceiveRepositoryHookEventFactory, mirrorHttpConnectionProvider, gitChangesetConverterFactory, gitTagConverter); + } + + @Test + public void shouldCreateInitialMirror() throws IOException, GitAPIException { + MirrorCommandResult result = callMirrorCommand(); + + assertThat(result.getResult()).isEqualTo(OK); + assertThat(result.getLog()).contains("Branches:") + .contains("- 000000000..fcd0ef183 master (new)") + .contains("- 000000000..3f76a12f0 test-branch (new)") + .contains("Tags:") + .contains("- 000000000..86a6645ec test-tag (new)"); + + try (Git createdMirror = Git.open(clone)) { + assertThat(createdMirror.branchList().call()).isNotEmpty(); + assertThat(createdMirror.tagList().call()).isNotEmpty(); + } + + verify(postReceiveRepositoryHookEventFactory).fireForFetch(any(), any()); + } + + @Test + public void shouldCreateEmptyLogWhenNoChangesFound() { + callMirrorCommand(); + + MirrorCommandResult result = callUpdate(ACCEPT_ALL); + + assertThat(result.getResult()).isEqualTo(OK); + assertThat(result.getLog()).containsExactly("No updates found"); + } + + @Test + public void shouldUpdateMirrorWithNewBranch() throws IOException, GitAPIException { + callMirrorCommand(); + + try (Git existingClone = Git.open(repositoryDirectory)) { + existingClone.branchCreate().setName("added-branch").call(); + } + + MirrorCommandResult result = callUpdate(ACCEPT_ALL); + + assertThat(result.getResult()).isEqualTo(OK); + assertThat(result.getLog()).containsExactly( + "Branches:", + "- 000000000..fcd0ef183 added-branch (new)" + ); + + try (Git updatedMirror = Git.open(clone)) { + Optional addedBranch = findBranch(updatedMirror, "added-branch"); + assertThat(addedBranch).isPresent(); + } + + // event should be thrown two times, once for the initial clone, and once for the update + verify(postReceiveRepositoryHookEventFactory, times(2)).fireForFetch(any(), any()); + } + + @Test + public void shouldUpdateMirrorWithForcedBranch() throws IOException, GitAPIException { + callMirrorCommand(); + + try (Git existingClone = Git.open(repositoryDirectory)) { + existingClone.branchCreate().setStartPoint("9e93d8631675a89615fac56b09209686146ff3c0").setName("test-branch").setForce(true).call(); + } + + MirrorCommandResult result = callUpdate(ACCEPT_ALL); + + assertThat(result.getResult()).isEqualTo(OK); + assertThat(result.getLog()).containsExactly( + "Branches:", + "- 3f76a12f0..9e93d8631 test-branch (forced)" + ); + + try (Git updatedMirror = Git.open(clone)) { + Optional updatedBranch = findBranch(updatedMirror, "test-branch"); + assertThat(updatedBranch).hasValueSatisfying(ref -> assertThat(ref.getObjectId().getName()).isEqualTo("9e93d8631675a89615fac56b09209686146ff3c0")); + } + } + + @Test + public void shouldUpdateMirrorWithDeletedBranch() throws IOException, GitAPIException { + callMirrorCommand(); + + try (Git existingClone = Git.open(repositoryDirectory)) { + existingClone.branchDelete().setBranchNames("test-branch").setForce(true).call(); + } + + MirrorCommandResult result = callUpdate(ACCEPT_ALL); + + assertThat(result.getResult()).isEqualTo(OK); + assertThat(result.getLog()).containsExactly( + "Branches:", + "- 3f76a12f0..000000000 test-branch (deleted)" + ); + + try (Git updatedMirror = Git.open(clone)) { + Optional deletedBranch = findBranch(updatedMirror, "test-branch"); + assertThat(deletedBranch).isNotPresent(); + } + } + + @Test + public void shouldUpdateMirrorWithNewTag() throws IOException, GitAPIException { + callMirrorCommand(); + + try (Git existingClone = Git.open(repositoryDirectory)) { + RevObject revObject = getRevObject(existingClone, "9e93d8631675a89615fac56b09209686146ff3c0"); + existingClone.tag().setName("added-tag").setAnnotated(false).setObjectId(revObject).call(); + } + + MirrorCommandResult result = callUpdate(ACCEPT_ALL); + + assertThat(result.getResult()).isEqualTo(OK); + assertThat(result.getLog()).containsExactly( + "Tags:", + "- 000000000..9e93d8631 added-tag (new)" + ); + + try (Git updatedMirror = Git.open(clone)) { + Optional addedTag = findTag(updatedMirror, "added-tag"); + assertThat(addedTag).hasValueSatisfying(ref -> assertThat(ref.getObjectId().getName()).isEqualTo("9e93d8631675a89615fac56b09209686146ff3c0")); + } + + // event should be thrown two times, once for the initial clone, and once for the update + verify(postReceiveRepositoryHookEventFactory, times(2)).fireForFetch(any(), any()); + } + + @Test + public void shouldUpdateMirrorWithChangedTag() throws IOException, GitAPIException { + callMirrorCommand(); + + try (Git existingClone = Git.open(repositoryDirectory)) { + RevObject revObject = getRevObject(existingClone, "9e93d8631675a89615fac56b09209686146ff3c0"); + existingClone.tag().setName("test-tag").setObjectId(revObject).setForceUpdate(true).setAnnotated(false).call(); + } + + MirrorCommandResult result = callUpdate(ACCEPT_ALL); + + assertThat(result.getResult()).isEqualTo(OK); + assertThat(result.getLog()).containsExactly( + "Tags:", + "- 86a6645ec..9e93d8631 test-tag (forced)" + ); + + try (Git updatedMirror = Git.open(clone)) { + Optional updatedTag = findTag(updatedMirror, "test-tag"); + assertThat(updatedTag).hasValueSatisfying(ref -> assertThat(ref.getObjectId().getName()).isEqualTo("9e93d8631675a89615fac56b09209686146ff3c0")); + } + } + + @Test + public void shouldUpdateMirrorWithDeletedTag() throws IOException, GitAPIException { + callMirrorCommand(); + + try (Git existingClone = Git.open(repositoryDirectory)) { + existingClone.tagDelete().setTags("test-tag").call(); + } + + MirrorCommandResult result = callUpdate(ACCEPT_ALL); + + assertThat(result.getResult()).isEqualTo(OK); + assertThat(result.getLog()).containsExactly( + "Tags:", + "- 86a6645ec..000000000 test-tag (deleted)" + ); + + try (Git updatedMirror = Git.open(clone)) { + Optional deletedTag = findTag(updatedMirror, "test-tag"); + assertThat(deletedTag).isNotPresent(); + } + } + + @Test + public void shouldRevertRejectedAddedBranch() throws IOException, GitAPIException { + callMirrorCommand(); + + try (Git existingClone = Git.open(repositoryDirectory)) { + existingClone.branchCreate().setName("added-branch").call(); + } + + MirrorCommandResult result = callUpdate(REJECT_ALL); + + assertThat(result.getResult()).isEqualTo(REJECTED_UPDATES); + assertThat(result.getLog()).containsExactly( + "Branches:", + "- 000000000..fcd0ef183 added-branch (rejected due to filter)" + ); + + try (Git updatedMirror = Git.open(clone)) { + Optional rejectedBranch = findBranch(updatedMirror, "added-branch"); + assertThat(rejectedBranch).isNotPresent(); + } + } + + @Test + public void shouldRevertRejectedChangedBranch() throws IOException, GitAPIException { + callMirrorCommand(); + + try (Git existingClone = Git.open(repositoryDirectory)) { + existingClone.branchCreate().setStartPoint("9e93d8631675a89615fac56b09209686146ff3c0").setName("test-branch").setForce(true).call(); + } + + MirrorCommandResult result = callUpdate(REJECT_ALL); + + assertThat(result.getResult()).isEqualTo(REJECTED_UPDATES); + assertThat(result.getLog()).containsExactly( + "Branches:", + "- 3f76a12f0..9e93d8631 test-branch (rejected due to filter)" + ); + + try (Git updatedMirror = Git.open(clone)) { + Optional rejectedBranch = findBranch(updatedMirror, "test-branch"); + assertThat(rejectedBranch).get().extracting("objectId.name").hasToString("3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4"); + } + } + + @Test + public void shouldRevertRejectedDeletedBranch() throws IOException, GitAPIException { + callMirrorCommand(); + + try (Git existingClone = Git.open(repositoryDirectory)) { + existingClone.branchDelete().setBranchNames("test-branch").setForce(true).call(); + } + + MirrorCommandResult result = callUpdate(REJECT_ALL); + + assertThat(result.getResult()).isEqualTo(REJECTED_UPDATES); + assertThat(result.getLog()).containsExactly( + "Branches:", + "- 3f76a12f0..000000000 test-branch (rejected due to filter)" + ); + + try (Git updatedMirror = Git.open(clone)) { + Optional rejectedBranch = findBranch(updatedMirror, "test-branch"); + assertThat(rejectedBranch).get().extracting("objectId.name").hasToString("3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4"); + } + } + + @Test + public void shouldRevertRejectedNewTag() throws IOException, GitAPIException { + callMirrorCommand(); + + try (Git existingClone = Git.open(repositoryDirectory)) { + RevObject revObject = getRevObject(existingClone, "9e93d8631675a89615fac56b09209686146ff3c0"); + existingClone.tag().setName("added-tag").setAnnotated(false).setObjectId(revObject).call(); + } + + MirrorCommandResult result = callUpdate(REJECT_ALL); + + assertThat(result.getResult()).isEqualTo(REJECTED_UPDATES); + assertThat(result.getLog()).containsExactly( + "Tags:", + "- 000000000..9e93d8631 added-tag (rejected due to filter)" + ); + + try (Git updatedMirror = Git.open(clone)) { + Optional rejectedTag = findTag(updatedMirror, "added-tag"); + assertThat(rejectedTag).isNotPresent(); + } + } + + @Test + public void shouldRevertRejectedChangedTag() throws IOException, GitAPIException { + callMirrorCommand(); + + try (Git existingClone = Git.open(repositoryDirectory)) { + RevObject revObject = getRevObject(existingClone, "9e93d8631675a89615fac56b09209686146ff3c0"); + existingClone.tag().setName("test-tag").setObjectId(revObject).setForceUpdate(true).setAnnotated(false).call(); + } + + MirrorCommandResult result = callUpdate(REJECT_ALL); + + assertThat(result.getResult()).isEqualTo(REJECTED_UPDATES); + assertThat(result.getLog()).containsExactly( + "Tags:", + "- 86a6645ec..9e93d8631 test-tag (rejected due to filter)" + ); + + try (Git updatedMirror = Git.open(clone)) { + Optional rejectedTag = findTag(updatedMirror, "test-tag"); + assertThat(rejectedTag).hasValueSatisfying(ref -> assertThat(ref.getObjectId().getName()).isEqualTo("86a6645eceefe8b9a247db5eb16e3d89a7e6e6d1")); + } + } + + @Test + public void shouldRevertRejectedDeletedTag() throws IOException, GitAPIException { + callMirrorCommand(); + + try (Git existingClone = Git.open(repositoryDirectory)) { + existingClone.tagDelete().setTags("test-tag").call(); + } + + MirrorCommandResult result = callUpdate(REJECT_ALL); + + assertThat(result.getResult()).isEqualTo(REJECTED_UPDATES); + assertThat(result.getLog()).containsExactly( + "Tags:", + "- 86a6645ec..000000000 test-tag (rejected due to filter)" + ); + + try (Git updatedMirror = Git.open(clone)) { + Optional rejectedTag = findTag(updatedMirror, "test-tag"); + assertThat(rejectedTag).hasValueSatisfying(ref -> assertThat(ref.getObjectId().getName()).isEqualTo("86a6645eceefe8b9a247db5eb16e3d89a7e6e6d1")); + } + } + + @Test + public void shouldRejectWithCustomMessage() throws IOException, GitAPIException { + callMirrorCommand(); + + try (Git existingClone = Git.open(repositoryDirectory)) { + existingClone.tagDelete().setTags("test-tag").call(); + } + + MirrorCommandResult result = callUpdate(r -> r.setFilter(new DenyAllWithReasonMirrorFilter("thou shalt not pass"))); + + assertThat(result.getResult()).isEqualTo(REJECTED_UPDATES); + assertThat(result.getLog()).containsExactly( + "Tags:", + "- 86a6645ec..000000000 test-tag (thou shalt not pass)" + ); + } + + @Test + public void shouldLogExceptionsFromFilter() throws IOException, GitAPIException { + callMirrorCommand(); + + try (Git existingClone = Git.open(repositoryDirectory)) { + existingClone.tagDelete().setTags("test-tag").call(); + } + + MirrorCommandResult result = callUpdate(r -> r.setFilter(new ErroneousMirrorFilterThrowingExceptions())); + + assertThat(result.getResult()).isEqualTo(REJECTED_UPDATES); + assertThat(result.getLog()).containsExactly( + "! got error checking filter for update: this tag creates an exception", + "Tags:", + "- 86a6645ec..000000000 test-tag (exception in filter)" + ); + } + + @Test + public void shouldMarkForcedBranchUpdate() throws IOException, GitAPIException { + callMirrorCommand(); + + try (Git existingClone = Git.open(repositoryDirectory)) { + existingClone.branchCreate().setStartPoint("9e93d8631675a89615fac56b09209686146ff3c0").setName("test-branch").setForce(true).call(); + } + + InvocationCheck filterInvokedCheck = mock(InvocationCheck.class); + + callUpdate(r -> r.setFilter(new MirrorFilter() { + @Override + public Filter getFilter(FilterContext context) { + filterInvokedCheck.invoked(); + context.getBranchUpdates().forEach(branchUpdate -> { + assertThat(branchUpdate.getBranchName()).isEqualTo("test-branch"); + assertThat(branchUpdate.isForcedUpdate()).isTrue(); + }); + return MirrorFilter.super.getFilter(context); + } + })); + + verify(filterInvokedCheck).invoked(); + } + + @Test + public void shouldNotMarkFastForwardBranchUpdateAsForced() throws IOException, GitAPIException { + callMirrorCommand(); + + try (Git existingClone = Git.open(repositoryDirectory)) { + existingClone.branchCreate().setStartPoint("d81ad6c63d7e2162308d69637b339dedd1d9201c").setName("master").setForce(true).call(); + } + + InvocationCheck filterInvokedCheck = mock(InvocationCheck.class); + + callUpdate(r -> r.setFilter(new MirrorFilter() { + @Override + public Filter getFilter(FilterContext context) { + filterInvokedCheck.invoked(); + context.getBranchUpdates().forEach(branchUpdate -> { + assertThat(branchUpdate.getBranchName()).isEqualTo("master"); + assertThat(branchUpdate.isForcedUpdate()).isFalse(); + }); + return MirrorFilter.super.getFilter(context); + } + })); + + verify(filterInvokedCheck).invoked(); + } + + @Test + public void shouldUseCredentials() throws Exception { + SimpleHttpServer simpleHttpServer = new SimpleHttpServer(Git.open(repositoryDirectory).getRepository()); + simpleHttpServer.start(); + + try { + MirrorCommandResult result = + callMirrorCommand( + simpleHttpServer.getUri().toASCIIString(), + createCredential(AppServer.username, AppServer.password)); + + assertThat(result.getResult()).isEqualTo(OK); + assertThat(result.getLog()).contains("Branches:") + .contains("- 000000000..fcd0ef183 master (new)") + .contains("- 000000000..3f76a12f0 test-branch (new)") + .contains("Tags:") + .contains("- 000000000..86a6645ec test-tag (new)"); + } finally { + simpleHttpServer.stop(); + } + } + + @Test + public void shouldFailWithIncorrectCredentials() throws Exception { + SimpleHttpServer simpleHttpServer = new SimpleHttpServer(Git.open(repositoryDirectory).getRepository()); + simpleHttpServer.start(); + + try { + MirrorCommandResult result = + callMirrorCommand( + simpleHttpServer.getUri().toASCIIString(), + createCredential("wrong", "credentials")); + + assertThat(result.getResult()).isEqualTo(FAILED); + + verify(postReceiveRepositoryHookEventFactory, never()).fireForFetch(any(), any()); + } finally { + simpleHttpServer.stop(); + } + } + + @Test + public void shouldCreateUpdateObjectForCreatedTags() throws IOException, GitAPIException { + try (Git updatedSource = Git.open(repositoryDirectory)) { + RevObject revObject = getRevObject(updatedSource, "9e93d8631675a89615fac56b09209686146ff3c0"); + updatedSource.tag().setAnnotated(true).setName("42").setMessage("annotated tag").setObjectId(revObject).call(); + } + + List collectedTagUpdates = callMirrorAndCollectUpdates().tagUpdates; + + assertThat(collectedTagUpdates) + .anySatisfy(update -> { + assertThat(update.getUpdateType()).get().isEqualTo(MirrorFilter.UpdateType.CREATE); + assertThat(update.getTagName()).isEqualTo("42"); + assertThat(update.getNewRevision()).get().isEqualTo("9e93d8631675a89615fac56b09209686146ff3c0"); + assertThat(update.getOldRevision()).isEmpty(); + assertThat(update.getTag()).get().extracting("name").isEqualTo("42"); + assertThat(update.getTag()).get().extracting("revision").isEqualTo("9e93d8631675a89615fac56b09209686146ff3c0"); + }) + .anySatisfy(tagUpdate -> { + assertThat(tagUpdate.getUpdateType()).get().isEqualTo(MirrorFilter.UpdateType.CREATE); + assertThat(tagUpdate.getTagName()).isEqualTo("test-tag"); + assertThat(tagUpdate.getNewRevision()).get().isEqualTo("86a6645eceefe8b9a247db5eb16e3d89a7e6e6d1"); + assertThat(tagUpdate.getOldRevision()).isEmpty(); + assertThat(tagUpdate.getTag()).get().extracting("name").isEqualTo("test-tag"); + assertThat(tagUpdate.getTag()).get().extracting("revision").isEqualTo("86a6645eceefe8b9a247db5eb16e3d89a7e6e6d1"); + }); + } + + @Test + public void shouldCreateUpdateObjectForDeletedTags() throws IOException, GitAPIException { + callMirrorCommand(); + + try (Git updatedSource = Git.open(repositoryDirectory)) { + updatedSource.tagDelete().setTags("test-tag").call(); + } + + List collectedTagUPdates = callMirrorAndCollectUpdates().tagUpdates; + + assertThat(collectedTagUPdates) + .anySatisfy(update -> { + assertThat(update.getUpdateType()).get().isEqualTo(MirrorFilter.UpdateType.DELETE); + assertThat(update.getTagName()).isEqualTo("test-tag"); + assertThat(update.getNewRevision()).isEmpty(); + assertThat(update.getOldRevision()).get().isEqualTo("86a6645eceefe8b9a247db5eb16e3d89a7e6e6d1"); + assertThat(update.getTag()).isEmpty(); + }); + } + + @Test + public void shouldCreateUpdateObjectForUpdatedTags() throws IOException, GitAPIException { + callMirrorCommand(); + + try (Git updatedSource = Git.open(repositoryDirectory)) { + RevObject revObject = getRevObject(updatedSource, "9e93d8631675a89615fac56b09209686146ff3c0"); + updatedSource.tag().setName("test-tag").setObjectId(revObject).setForceUpdate(true).call(); + } + + List collectedTagUpdates = callMirrorAndCollectUpdates().tagUpdates; + + assertThat(collectedTagUpdates) + .anySatisfy(update -> { + assertThat(update.getUpdateType()).get().isEqualTo(MirrorFilter.UpdateType.UPDATE); + assertThat(update.getTagName()).isEqualTo("test-tag"); + assertThat(update.getNewRevision()).get().isEqualTo("9e93d8631675a89615fac56b09209686146ff3c0"); + assertThat(update.getOldRevision()).get().isEqualTo("86a6645eceefe8b9a247db5eb16e3d89a7e6e6d1"); + assertThat(update.getTag()).get().extracting("name").isEqualTo("test-tag"); + assertThat(update.getTag()).get().extracting("revision").isEqualTo("9e93d8631675a89615fac56b09209686146ff3c0"); + }); + } + + @Test + public void shouldCreateUpdateObjectForNewBranch() { + List collectedBranchUpdates = callMirrorAndCollectUpdates().branchUpdates; + + assertThat(collectedBranchUpdates) + .anySatisfy(update -> { + assertThat(update.getUpdateType()).get().isEqualTo(MirrorFilter.UpdateType.CREATE); + assertThat(update.getBranchName()).isEqualTo("test-branch"); + assertThat(update.getNewRevision()).get().isEqualTo("3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4"); + assertThat(update.getOldRevision()).isEmpty(); + assertThat(update.getChangeset()).get().extracting("id").isEqualTo("3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4"); + }); + } + + @Test + public void shouldCreateUpdateObjectForForcedUpdatedBranch() throws IOException, GitAPIException { + callMirrorCommand(); + + try (Git existingClone = Git.open(repositoryDirectory)) { + existingClone.branchCreate().setStartPoint("9e93d8631675a89615fac56b09209686146ff3c0").setName("test-branch").setForce(true).call(); + } + + List collectedBranchUpdates = callMirrorAndCollectUpdates().branchUpdates; + + assertThat(collectedBranchUpdates) + .anySatisfy(update -> { + assertThat(update.getUpdateType()).get().isEqualTo(MirrorFilter.UpdateType.UPDATE); + assertThat(update.isForcedUpdate()).isTrue(); + assertThat(update.getBranchName()).isEqualTo("test-branch"); + assertThat(update.getNewRevision()).get().isEqualTo("9e93d8631675a89615fac56b09209686146ff3c0"); + assertThat(update.getOldRevision()).get().isEqualTo("3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4"); + assertThat(update.getChangeset()).get().extracting("id").isEqualTo("9e93d8631675a89615fac56b09209686146ff3c0"); + }); + } + + @Test + public void shouldCreateUpdateObjectForFastForwardUpdatedBranch() throws IOException, GitAPIException { + callMirrorCommand(); + + try (Git existingClone = Git.open(repositoryDirectory)) { + existingClone.branchCreate().setStartPoint("a8495c0335a13e6e432df90b3727fa91943189a7").setName("master").setForce(true).call(); + } + + List collectedBranchUpdates = callMirrorAndCollectUpdates().branchUpdates; + + assertThat(collectedBranchUpdates) + .anySatisfy(update -> { + assertThat(update.getUpdateType()).get().isEqualTo(MirrorFilter.UpdateType.UPDATE); + assertThat(update.isForcedUpdate()).isFalse(); + assertThat(update.getBranchName()).isEqualTo("master"); + assertThat(update.getNewRevision()).get().isEqualTo("a8495c0335a13e6e432df90b3727fa91943189a7"); + assertThat(update.getOldRevision()).get().isEqualTo("fcd0ef1831e4002ac43ea539f4094334c79ea9ec"); + assertThat(update.getChangeset()).get().extracting("id").isEqualTo("a8495c0335a13e6e432df90b3727fa91943189a7"); + }); + } + + @Test + public void shouldCreateUpdateObjectForDeletedBranch() throws IOException, GitAPIException { + callMirrorCommand(); + + try (Git updatedSource = Git.open(repositoryDirectory)) { + updatedSource.branchDelete().setBranchNames("test-branch").setForce(true).call(); + } + + List collectedBranchUpdates = callMirrorAndCollectUpdates().branchUpdates; + + assertThat(collectedBranchUpdates) + .anySatisfy(update -> { + assertThat(update.getBranchName()).isEqualTo("test-branch"); + assertThat(update.getNewRevision()).isEmpty(); + assertThat(update.getOldRevision()).get().isEqualTo("3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4"); + assertThat(update.getChangeset()).isEmpty(); + }); + } + + private Updates callMirrorAndCollectUpdates() { + Updates updates = new Updates(); + + MirrorCommandRequest request = new MirrorCommandRequest(); + request.setSourceUrl(repositoryDirectory.getAbsolutePath()); + request.setFilter(new MirrorFilter() { + @Override + public Filter getFilter(FilterContext context) { + return new Filter() { + @Override + public Result acceptTag(TagUpdate tagUpdate) { + updates.tagUpdates.add(tagUpdate); + return Result.accept(); + } + @Override + public Result acceptBranch(BranchUpdate branchUpdate) { + updates.branchUpdates.add(branchUpdate); + return Result.accept(); + } + }; + } + }); + + command.mirror(request); + return updates; + } + + private class Updates { + private final List branchUpdates = new ArrayList<>(); + private final List tagUpdates = new ArrayList<>(); + } + + private RevObject getRevObject(Git existingClone, String revision) throws IOException { + RevWalk walk = new RevWalk(existingClone.getRepository()); + ObjectId id = existingClone.getRepository().resolve(revision); + return walk.parseAny(id); + } + + private MirrorCommandResult callUpdate(Consumer requestModifier) { + MirrorCommandRequest request = new MirrorCommandRequest(); + request.setSourceUrl(repositoryDirectory.getAbsolutePath()); + requestModifier.accept(request); + return command.update(request); + } + + private Optional findBranch(Git git, String branchName) throws GitAPIException { + return git.branchList().call().stream().filter(ref -> ref.getName().equals("refs/heads/" + branchName)).findFirst(); + } + + private Optional findTag(Git git, String tagName) throws GitAPIException { + return git.tagList().call().stream().filter(ref -> ref.getName().equals("refs/tags/" + tagName)).findFirst(); + } + + private MirrorCommandResult callMirrorCommand() { + return callMirrorCommand(repositoryDirectory.getAbsolutePath(), c -> { + }); + } + + private MirrorCommandResult callMirrorCommand(String source, Consumer requestConsumer) { + MirrorCommandRequest request = new MirrorCommandRequest(); + request.setSourceUrl(source); + requestConsumer.accept(request); + return command.mirror(request); + } + + private Consumer createCredential(String wrong, String credentials) { + return request -> request.setCredentials(singletonList(new SimpleUsernamePasswordCredential(wrong, credentials.toCharArray()))); + } + + private GitContext createMirrorContext(File clone) { + return new GitContext(clone, repository, new GitRepositoryConfigStoreProvider(InMemoryConfigurationStoreFactory.create()), new GitConfig()); + } + + private static class DenyAllMirrorFilter implements MirrorFilter { + @Override + public Filter getFilter(FilterContext context) { + return new Filter() { + @Override + public Result acceptBranch(BranchUpdate branch) { + return Result.reject(); + } + + @Override + public Result acceptTag(TagUpdate tag) { + return Result.reject(); + } + }; + } + } + + private static class DenyAllWithReasonMirrorFilter implements MirrorFilter { + + private final String reason; + + private DenyAllWithReasonMirrorFilter(String reason) { + this.reason = reason; + } + + @Override + public Filter getFilter(FilterContext context) { + return new Filter() { + @Override + public Result acceptBranch(BranchUpdate branch) { + return Result.reject(reason); + } + + @Override + public Result acceptTag(TagUpdate tag) { + return Result.reject(reason); + } + }; + } + } + + private static class ErroneousMirrorFilterThrowingExceptions implements MirrorFilter { + + @Override + public Filter getFilter(FilterContext context) { + return new Filter() { + @Override + public Result acceptBranch(BranchUpdate branch) { + throw new RuntimeException("this branch creates an exception"); + } + + @Override + public Result acceptTag(TagUpdate tag) { + throw new RuntimeException("this tag creates an exception"); + } + }; + } + } + + private interface InvocationCheck { + void invoked(); + } +} diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModificationsCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModificationsCommandTest.java index 0cda0dc724..cf6d0828ab 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModificationsCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModificationsCommandTest.java @@ -28,14 +28,13 @@ import org.eclipse.jgit.revwalk.RevCommit; import org.junit.Before; import org.junit.Test; import sonia.scm.repository.GitConfig; -import sonia.scm.repository.GitTestHelper; import sonia.scm.repository.Modifications; import java.io.File; import java.io.IOException; import java.util.function.Consumer; -import static org.assertj.core.api.Java6Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThat; public class GitModificationsCommandTest extends AbstractRemoteCommandTestBase { @@ -112,12 +111,12 @@ public class GitModificationsCommandTest extends AbstractRemoteCommandTestBase { PushCommandRequest request = new PushCommandRequest(); request.setRemoteRepository(incomingRepository); cmd.push(request); + GitContext context = new GitContext(incomingDirectory, incomingRepository, null, new GitConfig()); + PostReceiveRepositoryHookEventFactory postReceiveRepositoryHookEventFactory = new PostReceiveRepositoryHookEventFactory(eventBus, eventFactory, context); GitPullCommand pullCommand = new GitPullCommand( handler, - new GitContext(incomingDirectory, incomingRepository, null, new GitConfig()), - eventBus, - eventFactory - ); + context, + postReceiveRepositoryHookEventFactory); PullCommandRequest pullRequest = new PullCommandRequest(); pullRequest.setRemoteRepository(incomingRepository); pullCommand.pull(pullRequest); diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitTagCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitTagCommandTest.java index 77178a3e4b..353daa0ebc 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitTagCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitTagCommandTest.java @@ -154,7 +154,7 @@ public class GitTagCommandTest extends AbstractGitCommandTestBase { } private List readTags(GitContext context) throws IOException { - return new GitTagsCommand(context, gpg).getTags(); + return new GitTagsCommand(context, new GitTagConverter(gpg)).getTags(); } private Optional findTag(GitContext context, String name) throws IOException { diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitTagsCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitTagsCommandTest.java index fa20318e15..d19d7568bb 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitTagsCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitTagsCommandTest.java @@ -55,7 +55,7 @@ public class GitTagsCommandTest extends AbstractGitCommandTestBase { @Test public void shouldGetDatesCorrectly() throws IOException { final GitContext gitContext = createContext(); - final GitTagsCommand tagsCommand = new GitTagsCommand(gitContext, gpg); + final GitTagsCommand tagsCommand = new GitTagsCommand(gitContext, new GitTagConverter(gpg)); final List tags = tagsCommand.getTags(); assertThat(tags).hasSize(3); @@ -94,7 +94,7 @@ public class GitTagsCommandTest extends AbstractGitCommandTestBase { when(publicKey.verify(signedContent.getBytes(), signature.getBytes())).thenReturn(true); final GitContext gitContext = createContext(); - final GitTagsCommand tagsCommand = new GitTagsCommand(gitContext, gpg); + final GitTagsCommand tagsCommand = new GitTagsCommand(gitContext, new GitTagConverter(gpg)); final List tags = tagsCommand.getTags(); assertThat(tags).hasSize(3); diff --git a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/SvnRepositoryHandler.java b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/SvnRepositoryHandler.java index e1ea9dde09..4c176de302 100644 --- a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/SvnRepositoryHandler.java +++ b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/SvnRepositoryHandler.java @@ -24,8 +24,6 @@ package sonia.scm.repository; -//~--- non-JDK imports -------------------------------------------------------- - import com.google.inject.Inject; import com.google.inject.Singleton; import org.slf4j.Logger; @@ -50,40 +48,28 @@ import java.io.IOException; import static sonia.scm.ContextEntry.ContextBuilder.entity; -//~--- JDK imports ------------------------------------------------------------ - /** - * * @author Sebastian Sdorra */ @Singleton @Extension -public class SvnRepositoryHandler - extends AbstractSimpleRepositoryHandler -{ +public class SvnRepositoryHandler extends AbstractSimpleRepositoryHandler { public static final String PROPERTY_UUID = "svn.uuid"; - public static final String RESOURCE_VERSION = "sonia/scm/version/scm-svn-plugin"; - public static final String TYPE_DISPLAYNAME = "Subversion"; - public static final String TYPE_NAME = "svn"; - public static final RepositoryType TYPE = new RepositoryType(TYPE_NAME, - TYPE_DISPLAYNAME, - SvnRepositoryServiceProvider.COMMANDS); + public static final RepositoryType TYPE = new RepositoryType(TYPE_NAME, TYPE_DISPLAYNAME, SvnRepositoryServiceProvider.COMMANDS); - private static final Logger logger = - LoggerFactory.getLogger(SvnRepositoryHandler.class); + private static final Logger LOG = LoggerFactory.getLogger(SvnRepositoryHandler.class); private SvnRepositoryHook hook; @Inject public SvnRepositoryHandler(ConfigurationStoreFactory storeFactory, HookEventFacade eventFacade, RepositoryLocationResolver repositoryLocationResolver, - PluginLoader pluginLoader) - { + PluginLoader pluginLoader) { super(storeFactory, repositoryLocationResolver, pluginLoader); // register logger @@ -93,116 +79,95 @@ public class SvnRepositoryHandler FSRepositoryFactory.setup(); // register hook - if (eventFacade != null) - { + if (eventFacade != null) { hook = new SvnRepositoryHook(eventFacade, this); FSHooks.registerHook(hook); - } - else if (logger.isWarnEnabled()) - { - logger.warn( + } else if (LOG.isWarnEnabled()) { + LOG.warn( "unable to register hook, beacause of missing repositorymanager"); } } @Override - public ImportHandler getImportHandler() - { + public ImportHandler getImportHandler() { return new SvnImportHandler(this); } @Override - public RepositoryType getType() - { + public RepositoryType getType() { return TYPE; } @Override - public String getVersionInformation() - { + public String getVersionInformation() { return getStringFromResource(RESOURCE_VERSION, DEFAULT_VERSION_INFORMATION); } @Override protected void create(Repository repository, File directory) throws InternalRepositoryException { - Compatibility comp = config.getCompatibility(); - - if (logger.isDebugEnabled()) - { - StringBuilder log = new StringBuilder("create svn repository \""); - - log.append(directory.getName()).append("\": pre14Compatible="); - log.append(comp.isPre14Compatible()).append(", pre15Compatible="); - log.append(comp.isPre15Compatible()).append(", pre16Compatible="); - log.append(comp.isPre16Compatible()).append(", pre17Compatible="); - log.append(comp.isPre17Compatible()).append(", with17Compatible="); - log.append(comp.isWith17Compatible()); - logger.debug(log.toString()); - } SVNRepository svnRepository = null; - try - { - SVNURL url = SVNRepositoryFactory.createLocalRepository(directory, null, - true, false, comp.isPre14Compatible(), - comp.isPre15Compatible(), comp.isPre16Compatible(), - comp.isPre17Compatible(), comp.isWith17Compatible()); + try { + SVNURL url = createSvnUrl(directory); svnRepository = SVNRepositoryFactory.create(url); String uuid = svnRepository.getRepositoryUUID(true); - if (Util.isNotEmpty(uuid)) - { - if (logger.isDebugEnabled()) - { - logger.debug("store repository uuid {} for {}", uuid, + if (Util.isNotEmpty(uuid)) { + if (LOG.isDebugEnabled()) { + LOG.debug("store repository uuid {} for {}", uuid, repository.getName()); } repository.setProperty(PROPERTY_UUID, uuid); - } - else if (logger.isWarnEnabled()) - { - logger.warn("could not read repository uuid for {}", + } else if (LOG.isWarnEnabled()) { + LOG.warn("could not read repository uuid for {}", repository.getName()); } - } - catch (SVNException ex) - { - logger.error("could not create svn repository", ex); - throw new InternalRepositoryException(entity(repository), "could not create repository", ex); - } - finally - { + } catch (SVNException ex) { + throw new InternalRepositoryException(repository, "could not create repository", ex); + } finally { SvnUtil.closeSession(svnRepository); } } - /** - * Method description - * - * - * @return - */ + public SVNURL createSvnUrl(File directory) { + Compatibility comp = config.getCompatibility(); + + if (LOG.isDebugEnabled()) { + + LOG.debug("create svn repository \"{}\": " + + "pre14Compatible={}, " + + "pre15Compatible={}, " + + "pre16Compatible={}, " + + "pre17Compatible={}, " + + "with17Compatible={}", + directory.getName(), + comp.isPre14Compatible(), + comp.isPre15Compatible(), + comp.isPre16Compatible(), + comp.isPre17Compatible(), + comp.isWith17Compatible()); + } + try { + return SVNRepositoryFactory.createLocalRepository(directory, null, + true, false, comp.isPre14Compatible(), + comp.isPre15Compatible(), comp.isPre16Compatible(), + comp.isPre17Compatible(), comp.isWith17Compatible()); + } catch (SVNException ex) { + throw new InternalRepositoryException(entity(File.class, directory.toString()), "could not create svn url", ex); + } + } + @Override - protected SvnConfig createInitialConfig() - { + protected SvnConfig createInitialConfig() { return new SvnConfig(); } - //~--- get methods ---------------------------------------------------------- - - /** - * Method description - * - * - * @return - */ @Override - protected Class getConfigClass() - { + protected Class getConfigClass() { return SvnConfig.class; } diff --git a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnMirrorCommand.java b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnMirrorCommand.java new file mode 100644 index 0000000000..d0d1c94ccd --- /dev/null +++ b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnMirrorCommand.java @@ -0,0 +1,146 @@ +/* + * 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 com.google.common.base.Stopwatch; +import com.google.common.collect.ImmutableList; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.tmatesoft.svn.core.SVNException; +import org.tmatesoft.svn.core.SVNURL; +import org.tmatesoft.svn.core.auth.BasicAuthenticationManager; +import org.tmatesoft.svn.core.auth.ISVNAuthenticationManager; +import org.tmatesoft.svn.core.auth.SVNAuthentication; +import org.tmatesoft.svn.core.auth.SVNPasswordAuthentication; +import org.tmatesoft.svn.core.auth.SVNSSLAuthentication; +import org.tmatesoft.svn.core.wc.SVNWCUtil; +import org.tmatesoft.svn.core.wc.admin.SVNAdminClient; +import sonia.scm.repository.InternalRepositoryException; +import sonia.scm.repository.api.MirrorCommandResult; +import sonia.scm.repository.api.Pkcs12ClientCertificateCredential; +import sonia.scm.repository.api.UsernamePasswordCredential; + +import javax.net.ssl.TrustManager; +import java.util.ArrayList; +import java.util.Collection; + +import static java.util.Arrays.asList; +import static sonia.scm.repository.api.MirrorCommandResult.ResultType.FAILED; +import static sonia.scm.repository.api.MirrorCommandResult.ResultType.OK; + +public class SvnMirrorCommand extends AbstractSvnCommand implements MirrorCommand { + + private static final Logger LOG = LoggerFactory.getLogger(SvnMirrorCommand.class); + + private final TrustManager trustManager; + + SvnMirrorCommand(SvnContext context, TrustManager trustManager) { + super(context); + this.trustManager = trustManager; + } + + @Override + public MirrorCommandResult mirror(MirrorCommandRequest mirrorCommandRequest) { + SVNURL url = createUrlForLocalRepository(); + return withAdminClient(mirrorCommandRequest, admin -> { + SVNURL source = SVNURL.parseURIEncoded(mirrorCommandRequest.getSourceUrl()); + admin.doCompleteSynchronize(source, url); + }); + } + + @Override + public MirrorCommandResult update(MirrorCommandRequest mirrorCommandRequest) { + SVNURL url = createUrlForLocalRepository(); + return withAdminClient(mirrorCommandRequest, admin -> admin.doSynchronize(url)); + } + + private MirrorCommandResult withAdminClient(MirrorCommandRequest mirrorCommandRequest, AdminConsumer consumer) { + Stopwatch stopwatch = Stopwatch.createStarted(); + long beforeUpdate; + long afterUpdate; + try { + beforeUpdate = context.open().getLatestRevision(); + SVNURL url = createUrlForLocalRepository(); + SVNAdminClient admin = createAdminClient(url, mirrorCommandRequest); + + consumer.accept(admin); + afterUpdate = context.open().getLatestRevision(); + } catch (SVNException e) { + LOG.info("Could not mirror svn repository", e); + return new MirrorCommandResult( + FAILED, + asList( + "failed to synchronize. See following error message for more details:", + e.getMessage() + ), + stopwatch.stop().elapsed()); + } + return new MirrorCommandResult( + OK, + ImmutableList.of("Updated from revision " + beforeUpdate + " to revision " + afterUpdate), + stopwatch.stop().elapsed() + ); + } + + private SVNURL createUrlForLocalRepository() { + try { + return SVNURL.fromFile(context.getDirectory()); + } catch (SVNException e) { + throw new InternalRepositoryException(repository, "could not create svn url for local repository", e); + } + } + + private SVNAdminClient createAdminClient(SVNURL url, MirrorCommandRequest mirrorCommandRequest) { + Collection authentications = new ArrayList<>(); + mirrorCommandRequest.getCredential(Pkcs12ClientCertificateCredential.class) + .map(c -> createTlsAuth(url, c)) + .ifPresent(authentications::add); + mirrorCommandRequest.getCredential(UsernamePasswordCredential.class) + .map(c -> SVNPasswordAuthentication.newInstance(c.username(), c.password(), false, url, false)) + .ifPresent(authentications::add); + ISVNAuthenticationManager authManager = new BasicAuthenticationManager( + authentications.toArray(new SVNAuthentication[authentications.size()])) { + @Override + public TrustManager getTrustManager(SVNURL url) { + return trustManager; + } + }; + + return new SVNAdminClient(authManager, SVNWCUtil.createDefaultOptions(true)); + } + + private SVNSSLAuthentication createTlsAuth(SVNURL url, Pkcs12ClientCertificateCredential c) { + return SVNSSLAuthentication.newInstance( + c.getCertificate(), + c.getPassword(), + false, + url, + true); + } + + private interface AdminConsumer { + void accept(SVNAdminClient adminClient) throws SVNException; + } +} diff --git a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnRepositoryServiceProvider.java b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnRepositoryServiceProvider.java index c286891a4d..7de87958f1 100644 --- a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnRepositoryServiceProvider.java +++ b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnRepositoryServiceProvider.java @@ -32,6 +32,7 @@ import sonia.scm.repository.SvnWorkingCopyFactory; import sonia.scm.repository.api.Command; import sonia.scm.repository.api.HookContextFactory; +import javax.net.ssl.TrustManager; import java.io.IOException; import java.util.Set; @@ -51,7 +52,8 @@ public class SvnRepositoryServiceProvider extends RepositoryServiceProvider { Command.UNBUNDLE, Command.MODIFY, Command.LOOKUP, - Command.FULL_HEALTH_CHECK + Command.FULL_HEALTH_CHECK, + Command.MIRROR ); //J+ @@ -59,14 +61,17 @@ public class SvnRepositoryServiceProvider extends RepositoryServiceProvider { private final SvnContext context; private final SvnWorkingCopyFactory workingCopyFactory; private final HookContextFactory hookContextFactory; + private final TrustManager trustManager; SvnRepositoryServiceProvider(SvnRepositoryHandler handler, Repository repository, SvnWorkingCopyFactory workingCopyFactory, - HookContextFactory hookContextFactory) { + HookContextFactory hookContextFactory, + TrustManager trustManager) { this.context = new SvnContext(repository, handler.getDirectory(repository.getId())); this.workingCopyFactory = workingCopyFactory; this.hookContextFactory = hookContextFactory; + this.trustManager = trustManager; } @Override @@ -133,4 +138,9 @@ public class SvnRepositoryServiceProvider extends RepositoryServiceProvider { public FullHealthCheckCommand getFullHealthCheckCommand() { return new SvnFullHealthCheckCommand(context); } + + @Override + public MirrorCommand getMirrorCommand() { + return new SvnMirrorCommand(context, trustManager); + } } diff --git a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnRepositoryServiceResolver.java b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnRepositoryServiceResolver.java index 827e3827ec..27d277a308 100644 --- a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnRepositoryServiceResolver.java +++ b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnRepositoryServiceResolver.java @@ -31,20 +31,25 @@ import sonia.scm.repository.SvnRepositoryHandler; import sonia.scm.repository.SvnWorkingCopyFactory; import sonia.scm.repository.api.HookContextFactory; +import javax.net.ssl.TrustManager; + @Extension public class SvnRepositoryServiceResolver implements RepositoryServiceResolver { private final SvnRepositoryHandler handler; private final SvnWorkingCopyFactory workingCopyFactory; private final HookContextFactory hookContextFactory; + private final TrustManager trustManager; @Inject public SvnRepositoryServiceResolver(SvnRepositoryHandler handler, SvnWorkingCopyFactory workingCopyFactory, - HookContextFactory hookContextFactory) { + HookContextFactory hookContextFactory, + TrustManager trustManager) { this.handler = handler; this.workingCopyFactory = workingCopyFactory; this.hookContextFactory = hookContextFactory; + this.trustManager = trustManager; } @Override @@ -52,7 +57,7 @@ public class SvnRepositoryServiceResolver implements RepositoryServiceResolver { SvnRepositoryServiceProvider provider = null; if (SvnRepositoryHandler.TYPE_NAME.equalsIgnoreCase(repository.getType())) { - provider = new SvnRepositoryServiceProvider(handler, repository, workingCopyFactory, hookContextFactory); + provider = new SvnRepositoryServiceProvider(handler, repository, workingCopyFactory, hookContextFactory, trustManager); } return provider; diff --git a/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/AbstractSvnCommandTestBase.java b/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/AbstractSvnCommandTestBase.java index 8228d42f39..962a940c2c 100644 --- a/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/AbstractSvnCommandTestBase.java +++ b/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/AbstractSvnCommandTestBase.java @@ -24,78 +24,36 @@ package sonia.scm.repository.spi; -//~--- non-JDK imports -------------------------------------------------------- - import org.junit.After; import java.io.IOException; -/** - * - * @author Sebastian Sdorra - */ -public class AbstractSvnCommandTestBase extends ZippedRepositoryTestBase -{ +public class AbstractSvnCommandTestBase extends ZippedRepositoryTestBase { - /** - * Method description - * - * - * @throws IOException - */ @After - public void close() throws IOException - { - if (context != null) - { + public void close() throws IOException { + if (context != null) { context.close(); } } - /** - * Method description - * - * - * @return - */ - public SvnContext createContext() - { - if (context == null) - { + public SvnContext createContext() { + if (context == null) { context = new SvnContext(repository, repositoryDirectory); } return context; } - //~--- get methods ---------------------------------------------------------- - - /** - * Method description - * - * - * @return - */ @Override - protected String getType() - { + protected String getType() { return "svn"; } - /** - * Method description - * - * - * @return - */ @Override - protected String getZippedRepositoryResource() - { + protected String getZippedRepositoryResource() { return "sonia/scm/repository/spi/scm-svn-spi-test.zip"; } - //~--- fields --------------------------------------------------------------- - - /** Field description */ private SvnContext context; } diff --git a/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/SvnMirrorCommandTest.java b/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/SvnMirrorCommandTest.java new file mode 100644 index 0000000000..93dd81b998 --- /dev/null +++ b/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/SvnMirrorCommandTest.java @@ -0,0 +1,122 @@ +/* + * 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.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.tmatesoft.svn.core.SVNException; +import org.tmatesoft.svn.core.SVNURL; +import org.tmatesoft.svn.core.auth.BasicAuthenticationManager; +import org.tmatesoft.svn.core.auth.SVNAuthentication; +import org.tmatesoft.svn.core.io.SVNRepositoryFactory; +import org.tmatesoft.svn.core.wc.SVNWCUtil; +import org.tmatesoft.svn.core.wc.admin.SVNAdminClient; +import sonia.scm.repository.RepositoryTestData; +import sonia.scm.repository.api.MirrorCommandResult; +import sonia.scm.repository.api.SimpleUsernamePasswordCredential; + +import javax.net.ssl.X509TrustManager; +import java.io.File; +import java.io.IOException; +import java.util.function.Consumer; + +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; +import static sonia.scm.repository.api.MirrorCommandResult.ResultType.OK; + +@RunWith(MockitoJUnitRunner.class) +public class SvnMirrorCommandTest extends AbstractSvnCommandTestBase { + + @Mock + private X509TrustManager trustManager; + + private SvnContext emptyContext; + + @Before + public void bendContextToNewRepository() throws IOException, SVNException { + emptyContext = createEmptyContext(); + } + + @Test + public void shouldDoInitialMirror() { + MirrorCommandResult result = callMirror(emptyContext, repositoryDirectory, c -> { + }); + + assertThat(result.getResult()).isEqualTo(OK); + } + + @Test + public void shouldDoMirrorUpdate() throws SVNException { + // Initialize destination repo before update + SVNAdminClient svnAdminClient = new SVNAdminClient(new BasicAuthenticationManager(new SVNAuthentication[]{}), SVNWCUtil.createDefaultOptions(false)); + svnAdminClient.doInitialize(SVNURL.fromFile(repositoryDirectory), emptyContext.createUrl()); + + MirrorCommandResult result = callMirrorUpdate(emptyContext, repositoryDirectory); + + assertThat(result.getResult()).isEqualTo(OK); + assertThat(result.getLog()).contains("Updated from revision 0 to revision 5"); + } + + @Test + public void shouldUseCredentials() { + MirrorCommandResult result = callMirror(emptyContext, repositoryDirectory, createCredential("svnadmin", "secret")); + + assertThat(result.getResult()).isEqualTo(OK); + } + + private MirrorCommandResult callMirrorUpdate(SvnContext context, File source) { + MirrorCommandRequest request = createRequest(source); + return createMirrorCommand(context).update(request); + } + + private MirrorCommandResult callMirror(SvnContext context, File source, Consumer consumer) { + MirrorCommandRequest request = createRequest(source); + consumer.accept(request); + return createMirrorCommand(context).mirror(request); + } + + private MirrorCommandRequest createRequest(File source) { + MirrorCommandRequest request = new MirrorCommandRequest(); + request.setSourceUrl("file://" + source.getAbsolutePath()); + return request; + } + + private SvnMirrorCommand createMirrorCommand(SvnContext context) { + return new SvnMirrorCommand(context, trustManager); + } + + private Consumer createCredential(String username, String password) { + return request -> request.setCredentials(singletonList(new SimpleUsernamePasswordCredential(username, password.toCharArray()))); + } + + private SvnContext createEmptyContext() throws SVNException, IOException { + File dir = tempFolder.newFolder(); + SVNRepositoryFactory.createLocalRepository(dir, true, true); + return new SvnContext(RepositoryTestData.createHappyVerticalPeopleTransporter(), dir); + } +} diff --git a/scm-test/src/main/java/sonia/scm/web/JsonMockHttpRequest.java b/scm-test/src/main/java/sonia/scm/web/JsonMockHttpRequest.java new file mode 100644 index 0000000000..b19e17a5d6 --- /dev/null +++ b/scm-test/src/main/java/sonia/scm/web/JsonMockHttpRequest.java @@ -0,0 +1,238 @@ +/* + * 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.jboss.resteasy.mock.MockHttpRequest; +import org.jboss.resteasy.specimpl.ResteasyUriInfo; +import org.jboss.resteasy.spi.HttpRequest; +import org.jboss.resteasy.spi.ResteasyAsynchronousContext; + +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.MultivaluedMap; +import java.io.InputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Enumeration; +import java.util.List; + +import static java.nio.charset.StandardCharsets.UTF_8; + +public class JsonMockHttpRequest implements HttpRequest { + + private final MockHttpRequest delegate; + private boolean contentTypeSet = false; + + private JsonMockHttpRequest(MockHttpRequest delegate) { + this.delegate = delegate; + } + + /** + * @see MockHttpRequest#post(String) + */ + public static JsonMockHttpRequest post(String url) throws URISyntaxException { + return new JsonMockHttpRequest(MockHttpRequest.post(url)); + } + + /** + * @see MockHttpRequest#put(String) + */ + public static JsonMockHttpRequest put(String url) throws URISyntaxException { + return new JsonMockHttpRequest(MockHttpRequest.put(url)); + } + + /** + * @see MockHttpRequest#setHttpMethod(String) + */ + public void setHttpMethod(String method) { + delegate.setHttpMethod(method); + } + + /** + * @see MockHttpRequest#getAsynchronousContext() + */ + public ResteasyAsynchronousContext getAsynchronousContext() { + return delegate.getAsynchronousContext(); + } + + /** + * @see MockHttpRequest#setAsynchronousContext(ResteasyAsynchronousContext) + */ + public void setAsynchronousContext(ResteasyAsynchronousContext asynchronousContext) { + delegate.setAsynchronousContext(asynchronousContext); + } + + public JsonMockHttpRequest header(String name, String value) { + delegate.header(name, value); + return this; + } + + public JsonMockHttpRequest accept(List accepts) { + delegate.accept(accepts); + return this; + } + + public JsonMockHttpRequest accept(MediaType accept) { + delegate.accept(accept); + return this; + } + + public JsonMockHttpRequest accept(String type) { + delegate.accept(type); + return this; + } + + public JsonMockHttpRequest language(String language) { + delegate.language(language); + return this; + } + + public JsonMockHttpRequest cookie(String name, String value) { + delegate.cookie(name, value); + return this; + } + + public JsonMockHttpRequest contentType(String type) { + contentTypeSet = true; + delegate.contentType(type); + return this; + } + + public JsonMockHttpRequest contentType(MediaType type) { + contentTypeSet = true; + delegate.contentType(type); + return this; + } + + public JsonMockHttpRequest content(byte[] bytes) { + delegate.content(bytes); + return this; + } + + public JsonMockHttpRequest json(String json) { + if (!contentTypeSet) { + contentType("application/json"); + } + return content(json.replaceAll("'", "\"").getBytes(UTF_8)); + } + + public JsonMockHttpRequest content(InputStream stream) { + delegate.content(stream); + return this; + } + + public JsonMockHttpRequest addFormHeader(String name, String value) { + delegate.addFormHeader(name, value); + return this; + } + + public HttpHeaders getHttpHeaders() { + return delegate.getHttpHeaders(); + } + + public MultivaluedMap getMutableHeaders() { + return delegate.getMutableHeaders(); + } + + public InputStream getInputStream() { + return delegate.getInputStream(); + } + + public void setInputStream(InputStream stream) { + delegate.setInputStream(stream); + } + + public ResteasyUriInfo getUri() { + return delegate.getUri(); + } + + public String getHttpMethod() { + return delegate.getHttpMethod(); + } + + public void initialRequestThreadFinished() { + delegate.initialRequestThreadFinished(); + } + + public Object getAttribute(String attribute) { + return delegate.getAttribute(attribute); + } + + public void setAttribute(String name, Object value) { + delegate.setAttribute(name, value); + } + + public void removeAttribute(String name) { + delegate.removeAttribute(name); + } + + public Enumeration getAttributeNames() { + return delegate.getAttributeNames(); + } + + public ResteasyAsynchronousContext getAsyncContext() { + return delegate.getAsyncContext(); + } + + public void forward(String path) { + delegate.forward(path); + } + + public boolean wasForwarded() { + return delegate.wasForwarded(); + } + + public String getRemoteHost() { + return delegate.getRemoteHost(); + } + + public String getRemoteAddress() { + return delegate.getRemoteAddress(); + } + + public boolean formParametersRead() { + return delegate.formParametersRead(); + } + + public MultivaluedMap getFormParameters() { + return delegate.getFormParameters(); + } + + public MultivaluedMap getDecodedFormParameters() { + return delegate.getDecodedFormParameters(); + } + + public boolean isInitial() { + return delegate.isInitial(); + } + + public void setRequestUri(URI requestUri) throws IllegalStateException { + delegate.setRequestUri(requestUri); + } + + public void setRequestUri(URI baseUri, URI requestUri) throws IllegalStateException { + delegate.setRequestUri(baseUri, requestUri); + } +} diff --git a/scm-test/src/main/java/sonia/scm/web/JsonMockHttpResponse.java b/scm-test/src/main/java/sonia/scm/web/JsonMockHttpResponse.java new file mode 100644 index 0000000000..c5aaa13390 --- /dev/null +++ b/scm-test/src/main/java/sonia/scm/web/JsonMockHttpResponse.java @@ -0,0 +1,155 @@ +/* + * 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 com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.jboss.resteasy.mock.MockHttpResponse; +import org.jboss.resteasy.spi.AsyncOutputStream; +import org.jboss.resteasy.spi.HttpResponse; + +import javax.ws.rs.core.MultivaluedMap; +import javax.ws.rs.core.NewCookie; +import java.io.IOException; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.util.List; + +public class JsonMockHttpResponse implements HttpResponse { + + private final MockHttpResponse delegate = new MockHttpResponse(); + + @Override + public int getStatus() { + return delegate.getStatus(); + } + + @Override + public void setStatus(int status) { + delegate.setStatus(status); + } + + @Override + public MultivaluedMap getOutputHeaders() { + return delegate.getOutputHeaders(); + } + + @Override + public OutputStream getOutputStream() throws IOException { + return delegate.getOutputStream(); + } + + @Override + public void setOutputStream(OutputStream os) { + delegate.setOutputStream(os); + } + + public byte[] getOutput() { + return delegate.getOutput(); + } + + public String getContentAsString() throws UnsupportedEncodingException { + return delegate.getContentAsString(); + } + + @Override + public void addNewCookie(NewCookie cookie) { + delegate.addNewCookie(cookie); + } + + @Override + public void sendError(int status) throws IOException { + delegate.sendError(status); + } + + @Override + public void sendError(int status, String message) throws IOException { + delegate.sendError(status, message); + } + + public List getNewCookies() { + return delegate.getNewCookies(); + } + + public String getErrorMessage() { + return delegate.getErrorMessage(); + } + + public boolean isErrorSent() { + return delegate.isErrorSent(); + } + + @Override + public boolean isCommitted() { + return delegate.isCommitted(); + } + + @Override + public void reset() { + delegate.reset(); + } + + @Override + public void flushBuffer() throws IOException { + delegate.flushBuffer(); + } + + @Override + public AsyncOutputStream getAsyncOutputStream() throws IOException { + return delegate.getAsyncOutputStream(); + } + + @Override + public void close() throws IOException { + delegate.close(); + } + + @Override + public void setSuppressExceptionDuringChunkedTransfer(boolean suppressExceptionDuringChunkedTransfer) { + delegate.setSuppressExceptionDuringChunkedTransfer(suppressExceptionDuringChunkedTransfer); + } + + @Override + public boolean suppressExceptionDuringChunkedTransfer() { + return delegate.suppressExceptionDuringChunkedTransfer(); + } + + public T getContentAs(Class clazz) { + try { + return new ObjectMapper().readValue(getContentAsString(), clazz); + } catch (JsonProcessingException | UnsupportedEncodingException e) { + throw new RuntimeException("could not unmarshal content for class " + clazz, e); + } + } + + public JsonNode getContentAsJson() { + try { + return new ObjectMapper().readTree(getContentAsString()); + } catch (JsonProcessingException | UnsupportedEncodingException e) { + throw new RuntimeException("could not unmarshal json content", e); + } + } +} diff --git a/scm-test/src/main/java/sonia/scm/web/MockScmPathInfoStore.java b/scm-test/src/main/java/sonia/scm/web/MockScmPathInfoStore.java new file mode 100644 index 0000000000..6309b016cc --- /dev/null +++ b/scm-test/src/main/java/sonia/scm/web/MockScmPathInfoStore.java @@ -0,0 +1,41 @@ +/* + * 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 sonia.scm.api.v2.resources.ScmPathInfoStore; + +import javax.inject.Provider; +import java.net.URI; + +import static com.google.inject.util.Providers.of; + +public class MockScmPathInfoStore { + + public static Provider forUri(String uri) { + ScmPathInfoStore store = new ScmPathInfoStore(); + store.set(() -> URI.create(uri)); + return of(store); + } +} diff --git a/scm-test/src/main/java/sonia/scm/web/RestDispatcher.java b/scm-test/src/main/java/sonia/scm/web/RestDispatcher.java index a57f4ef202..4879e64307 100644 --- a/scm-test/src/main/java/sonia/scm/web/RestDispatcher.java +++ b/scm-test/src/main/java/sonia/scm/web/RestDispatcher.java @@ -28,6 +28,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.shiro.authz.AuthorizationException; import org.apache.shiro.authz.UnauthorizedException; import org.jboss.resteasy.mock.MockDispatcherFactory; +import org.jboss.resteasy.mock.MockHttpRequest; import org.jboss.resteasy.spi.Dispatcher; import org.jboss.resteasy.spi.HttpRequest; import org.jboss.resteasy.spi.HttpResponse; diff --git a/scm-ui/ui-components/src/Duration.stories.tsx b/scm-ui/ui-components/src/Duration.stories.tsx new file mode 100644 index 0000000000..aeb4a53e5d --- /dev/null +++ b/scm-ui/ui-components/src/Duration.stories.tsx @@ -0,0 +1,53 @@ +/* + * 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. + */ + +import { storiesOf } from "@storybook/react"; +import Duration from "./Duration"; +import React from "react"; + +storiesOf("Duration", module).add("Duration", () => ( +
+

+ +

+

+ +

+

+ +

+

+ +

+

+ +

+

+ +

+

+ +

+
+)); diff --git a/scm-ui/ui-components/src/Duration.tsx b/scm-ui/ui-components/src/Duration.tsx new file mode 100644 index 0000000000..57550d0415 --- /dev/null +++ b/scm-ui/ui-components/src/Duration.tsx @@ -0,0 +1,73 @@ +/* + * 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. + */ + +import React, { FC } from "react"; +import { useTranslation } from "react-i18next"; + +type Unit = "ms" | "s" | "m" | "h" | "d" | "w"; + +type Props = { + duration: number; +}; + +export const parse = (duration: number) => { + let value = duration; + let unit: Unit = "ms"; + if (value > 1000) { + unit = "s"; + value /= 1000; + if (value > 60) { + unit = "m"; + value /= 60; + if (value > 60) { + unit = "h"; + value /= 60; + if (value > 24) { + unit = "d"; + value /= 24; + if (value > 7) { + unit = "w"; + value /= 7; + } + } + } + } + } + return { + duration: Math.round(value), + unit, + }; +}; + +const Duration: FC = ({ duration }) => { + const [t] = useTranslation("commons"); + const parsed = parse(duration); + return ( + + ); +}; + +export default Duration; diff --git a/scm-ui/ui-components/src/OverviewPageActions.tsx b/scm-ui/ui-components/src/OverviewPageActions.tsx index 2aef455188..1dd667afbe 100644 --- a/scm-ui/ui-components/src/OverviewPageActions.tsx +++ b/scm-ui/ui-components/src/OverviewPageActions.tsx @@ -74,7 +74,7 @@ const OverviewPageActions: FC = ({ if (showCreateButton) { return (
-
); } diff --git a/scm-ui/ui-components/src/Tag.stories.tsx b/scm-ui/ui-components/src/Tag.stories.tsx index 1c5c51497a..f2ebe33e4c 100644 --- a/scm-ui/ui-components/src/Tag.stories.tsx +++ b/scm-ui/ui-components/src/Tag.stories.tsx @@ -24,10 +24,10 @@ import styled from "styled-components"; import { storiesOf } from "@storybook/react"; -import * as React from "react"; +import React, { ReactNode } from "react"; import Tag from "./Tag"; -import { ReactNode } from "react"; import { MemoryRouter } from "react-router-dom"; +import { Color, colors, sizes } from "./styleConstants"; const Wrapper = styled.div` margin: 2rem; @@ -35,26 +35,43 @@ const Wrapper = styled.div` `; const Spacing = styled.div` - padding: 1em; + padding: 0.5rem; `; -const colors = ["primary", "link", "info", "success", "warning", "danger"]; - const RoutingDecorator = (story: () => ReactNode) => {story()}; storiesOf("Tag", module) .addDecorator(RoutingDecorator) - .addDecorator(storyFn => {storyFn()}) + .addDecorator((storyFn) => {storyFn()}) .add("Default", () => ) + .add("Rounded", () => ) .add("With Icon", () => ) .add("Colors", () => (
- {colors.map(color => ( - + {colors.map((color) => ( + ))}
)) - .add("With title", () => ) - .add("Clickable", () => alert("Not so fast")}/>); + .add("Outlined", () => ( +
+ {(["success", "black", "danger"] as Color[]).map((color) => ( + + + + ))} +
+ )) + .add("With title", () => ) + .add("Clickable", () => alert("Not so fast")} />) + .add("Sizes", () => ( +
+ {sizes.map((size) => ( + + + + ))} +
+ )); diff --git a/scm-ui/ui-components/src/Tag.tsx b/scm-ui/ui-components/src/Tag.tsx index 0f3e958f18..7fc9ae4493 100644 --- a/scm-ui/ui-components/src/Tag.tsx +++ b/scm-ui/ui-components/src/Tag.tsx @@ -21,50 +21,84 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import * as React from "react"; +import React, { FC, HTMLAttributes } from "react"; import classNames from "classnames"; +import { Color, Size } from "./styleConstants"; +import styled, { css } from "styled-components"; type Props = { className?: string; - color: string; + color?: Color; + outlined?: boolean; + rounded?: boolean; icon?: string; - label: string; + label?: string; title?: string; + size?: Size; onClick?: () => void; onRemove?: () => void; }; -class Tag extends React.Component { - static defaultProps = { - color: "light" - }; +type InnerTagProps = HTMLAttributes & { + small: boolean; +}; - render() { - const { className, color, icon, label, title, onClick, onRemove } = this.props; - let showIcon = null; - if (icon) { - showIcon = ( - <> - -   - - ); - } - let showDelete = null; - if (onRemove) { - showDelete = ; - } +const smallMixin = css` + font-size: 0.7rem !important; + padding: 0.25rem !important; + font-weight: bold; +`; - return ( +const InnerTag = styled.span` + ${(props) => props.small && smallMixin}; +`; + +const Tag: FC = ({ + className, + color = "light", + outlined, + size = "normal", + rounded, + icon, + label, + title, + onClick, + onRemove, + children, +}) => { + let showIcon = null; + if (icon) { + showIcon = ( <> - - {showIcon} - {label} - - {showDelete} + +   ); } -} + let showDelete = null; + if (onRemove) { + showDelete = ; + } + + return ( + <> + + {showIcon} + {label} + {children} + + {showDelete} + + ); +}; export default Tag; diff --git a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap index 2bd0d73d88..8485602fbd 100644 --- a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap +++ b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap @@ -4812,7 +4812,7 @@ exports[`Storyshots Diff Binaries 1`] = ` Main.java modify @@ -5031,7 +5031,7 @@ exports[`Storyshots Diff Binaries 1`] = ` conflict.png add @@ -5097,7 +5097,7 @@ exports[`Storyshots Diff Collapsed 1`] = ` src/main/java/com/cloudogu/scm/review/events/EventListener.java modify @@ -5210,7 +5210,7 @@ exports[`Storyshots Diff Collapsed 1`] = ` src/main/js/ChangeNotification.tsx modify @@ -5323,7 +5323,7 @@ exports[`Storyshots Diff Collapsed 1`] = ` src/main/resources/locales/de/plugins.json modify @@ -5436,7 +5436,7 @@ exports[`Storyshots Diff Collapsed 1`] = ` src/main/resources/locales/en/plugins.json modify @@ -5549,7 +5549,7 @@ exports[`Storyshots Diff Collapsed 1`] = ` src/test/java/com/cloudogu/scm/review/events/ClientTest.java modify @@ -5662,7 +5662,7 @@ exports[`Storyshots Diff Collapsed 1`] = ` Main.java modify @@ -5784,7 +5784,7 @@ exports[`Storyshots Diff CollapsingWithFunction 1`] = ` src/main/java/com/cloudogu/scm/review/events/EventListener.java modify @@ -5859,7 +5859,7 @@ exports[`Storyshots Diff CollapsingWithFunction 1`] = ` src/main/js/ChangeNotification.tsx modify @@ -6717,7 +6717,7 @@ exports[`Storyshots Diff CollapsingWithFunction 1`] = ` src/main/resources/locales/de/plugins.json modify @@ -7171,7 +7171,7 @@ exports[`Storyshots Diff CollapsingWithFunction 1`] = ` src/main/resources/locales/en/plugins.json modify @@ -7625,7 +7625,7 @@ exports[`Storyshots Diff CollapsingWithFunction 1`] = ` src/test/java/com/cloudogu/scm/review/events/ClientTest.java modify @@ -7700,7 +7700,7 @@ exports[`Storyshots Diff CollapsingWithFunction 1`] = ` Main.java modify @@ -7784,7 +7784,7 @@ exports[`Storyshots Diff Default 1`] = ` src/main/java/com/cloudogu/scm/review/events/EventListener.java modify @@ -8365,7 +8365,7 @@ exports[`Storyshots Diff Default 1`] = ` src/main/js/ChangeNotification.tsx modify @@ -9223,7 +9223,7 @@ exports[`Storyshots Diff Default 1`] = ` src/main/resources/locales/de/plugins.json modify @@ -9677,7 +9677,7 @@ exports[`Storyshots Diff Default 1`] = ` src/main/resources/locales/en/plugins.json modify @@ -10131,7 +10131,7 @@ exports[`Storyshots Diff Default 1`] = ` src/test/java/com/cloudogu/scm/review/events/ClientTest.java modify @@ -11192,7 +11192,7 @@ exports[`Storyshots Diff Default 1`] = ` Main.java modify @@ -11730,7 +11730,7 @@ exports[`Storyshots Diff Expandable 1`] = ` src/main/java/com/cloudogu/scm/review/events/EventListener.java modify @@ -12348,7 +12348,7 @@ exports[`Storyshots Diff Expandable 1`] = ` src/main/js/ChangeNotification.tsx modify @@ -13303,7 +13303,7 @@ exports[`Storyshots Diff Expandable 1`] = ` src/main/resources/locales/de/plugins.json modify @@ -13829,7 +13829,7 @@ exports[`Storyshots Diff Expandable 1`] = ` src/main/resources/locales/en/plugins.json modify @@ -14355,7 +14355,7 @@ exports[`Storyshots Diff Expandable 1`] = ` src/test/java/com/cloudogu/scm/review/events/ClientTest.java modify @@ -15571,7 +15571,7 @@ exports[`Storyshots Diff Expandable 1`] = ` Main.java modify @@ -16146,7 +16146,7 @@ exports[`Storyshots Diff File Annotation 1`] = ` src/main/java/com/cloudogu/scm/review/events/EventListener.java modify @@ -16731,7 +16731,7 @@ exports[`Storyshots Diff File Annotation 1`] = ` src/main/js/ChangeNotification.tsx modify @@ -17593,7 +17593,7 @@ exports[`Storyshots Diff File Annotation 1`] = ` src/main/resources/locales/de/plugins.json modify @@ -18051,7 +18051,7 @@ exports[`Storyshots Diff File Annotation 1`] = ` src/main/resources/locales/en/plugins.json modify @@ -18509,7 +18509,7 @@ exports[`Storyshots Diff File Annotation 1`] = ` src/test/java/com/cloudogu/scm/review/events/ClientTest.java modify @@ -19574,7 +19574,7 @@ exports[`Storyshots Diff File Annotation 1`] = ` Main.java modify @@ -20116,7 +20116,7 @@ exports[`Storyshots Diff File Controls 1`] = ` src/main/java/com/cloudogu/scm/review/events/EventListener.java modify @@ -20715,7 +20715,7 @@ exports[`Storyshots Diff File Controls 1`] = ` src/main/js/ChangeNotification.tsx modify @@ -21591,7 +21591,7 @@ exports[`Storyshots Diff File Controls 1`] = ` src/main/resources/locales/de/plugins.json modify @@ -22063,7 +22063,7 @@ exports[`Storyshots Diff File Controls 1`] = ` src/main/resources/locales/en/plugins.json modify @@ -22535,7 +22535,7 @@ exports[`Storyshots Diff File Controls 1`] = ` src/test/java/com/cloudogu/scm/review/events/ClientTest.java modify @@ -23614,7 +23614,7 @@ exports[`Storyshots Diff File Controls 1`] = ` Main.java modify @@ -24170,7 +24170,7 @@ exports[`Storyshots Diff Hunks 1`] = ` src/main/java/com/cloudogu/scm/review/pullrequest/service/DefaultPullRequestService.java modify @@ -25015,7 +25015,7 @@ exports[`Storyshots Diff Line Annotation 1`] = ` src/main/java/com/cloudogu/scm/review/events/EventListener.java modify @@ -25608,7 +25608,7 @@ exports[`Storyshots Diff Line Annotation 1`] = ` src/main/js/ChangeNotification.tsx modify @@ -26478,7 +26478,7 @@ exports[`Storyshots Diff Line Annotation 1`] = ` src/main/resources/locales/de/plugins.json modify @@ -26932,7 +26932,7 @@ exports[`Storyshots Diff Line Annotation 1`] = ` src/main/resources/locales/en/plugins.json modify @@ -27386,7 +27386,7 @@ exports[`Storyshots Diff Line Annotation 1`] = ` src/test/java/com/cloudogu/scm/review/events/ClientTest.java modify @@ -28447,7 +28447,7 @@ exports[`Storyshots Diff Line Annotation 1`] = ` Main.java modify @@ -28997,7 +28997,7 @@ exports[`Storyshots Diff OnClick 1`] = ` src/main/java/com/cloudogu/scm/review/events/EventListener.java modify @@ -29618,7 +29618,7 @@ exports[`Storyshots Diff OnClick 1`] = ` src/main/js/ChangeNotification.tsx modify @@ -30538,7 +30538,7 @@ exports[`Storyshots Diff OnClick 1`] = ` src/main/resources/locales/de/plugins.json modify @@ -31022,7 +31022,7 @@ exports[`Storyshots Diff OnClick 1`] = ` src/main/resources/locales/en/plugins.json modify @@ -31506,7 +31506,7 @@ exports[`Storyshots Diff OnClick 1`] = ` src/test/java/com/cloudogu/scm/review/events/ClientTest.java modify @@ -32643,7 +32643,7 @@ exports[`Storyshots Diff OnClick 1`] = ` Main.java modify @@ -33217,7 +33217,7 @@ exports[`Storyshots Diff Side-By-Side 1`] = ` src/main/java/com/cloudogu/scm/review/events/EventListener.java modify @@ -33891,7 +33891,7 @@ exports[`Storyshots Diff Side-By-Side 1`] = ` src/main/js/ChangeNotification.tsx modify @@ -34839,7 +34839,7 @@ exports[`Storyshots Diff Side-By-Side 1`] = ` src/main/resources/locales/de/plugins.json modify @@ -35345,7 +35345,7 @@ exports[`Storyshots Diff Side-By-Side 1`] = ` src/main/resources/locales/en/plugins.json modify @@ -35851,7 +35851,7 @@ exports[`Storyshots Diff Side-By-Side 1`] = ` src/test/java/com/cloudogu/scm/review/events/ClientTest.java modify @@ -37081,7 +37081,7 @@ exports[`Storyshots Diff Side-By-Side 1`] = ` Main.java modify @@ -37692,7 +37692,7 @@ exports[`Storyshots Diff SyntaxHighlighting (Markdown) 1`] = ` CHANGELOG.md modify @@ -38055,7 +38055,7 @@ exports[`Storyshots Diff SyntaxHighlighting 1`] = ` src/main/java/com/cloudogu/scm/review/events/EventListener.java modify @@ -38636,7 +38636,7 @@ exports[`Storyshots Diff SyntaxHighlighting 1`] = ` src/main/js/ChangeNotification.tsx modify @@ -39494,7 +39494,7 @@ exports[`Storyshots Diff SyntaxHighlighting 1`] = ` src/main/resources/locales/de/plugins.json modify @@ -39948,7 +39948,7 @@ exports[`Storyshots Diff SyntaxHighlighting 1`] = ` src/main/resources/locales/en/plugins.json modify @@ -40402,7 +40402,7 @@ exports[`Storyshots Diff SyntaxHighlighting 1`] = ` src/test/java/com/cloudogu/scm/review/events/ClientTest.java modify @@ -41463,7 +41463,7 @@ exports[`Storyshots Diff SyntaxHighlighting 1`] = ` Main.java modify @@ -42001,7 +42001,7 @@ exports[`Storyshots Diff WithLinkToFile 1`] = ` src/main/java/com/cloudogu/scm/review/events/EventListener.java modify @@ -42619,7 +42619,7 @@ exports[`Storyshots Diff WithLinkToFile 1`] = ` src/main/js/ChangeNotification.tsx modify @@ -43574,7 +43574,7 @@ exports[`Storyshots Diff WithLinkToFile 1`] = ` src/main/resources/locales/de/plugins.json modify @@ -44100,7 +44100,7 @@ exports[`Storyshots Diff WithLinkToFile 1`] = ` src/main/resources/locales/en/plugins.json modify @@ -44626,7 +44626,7 @@ exports[`Storyshots Diff WithLinkToFile 1`] = ` src/test/java/com/cloudogu/scm/review/events/ClientTest.java modify @@ -45842,7 +45842,7 @@ exports[`Storyshots Diff WithLinkToFile 1`] = ` Main.java modify @@ -46388,6 +46388,62 @@ exports[`Storyshots Diff WithLinkToFile 1`] = ` `; +exports[`Storyshots Duration Duration 1`] = ` +
+

+ +

+

+ +

+

+ +

+

+ +

+

+ +

+

+ +

+

+ +

+
+`; + exports[`Storyshots Forms|AddKeyValueEntryToTableField Default 1`] = `
- - heartOfGold - - - repository.archived + + heartOfGold + + + + repository.archived +

- - heartOfGold - - + + + heartOfGold + + +

- - - heartOfGold - - + + + + heartOfGold + + +

- - heartOfGold - - + + + heartOfGold + + +

- - heartOfGold - - - repository.exporting + + heartOfGold + + + + repository.exporting + + +

+

+ The starship Heart of Gold was the first spacecraft to make use of the Infinite Improbability Drive +

+
+ +
+ + + +`; + +exports[`Storyshots RepositoryEntry HealthCheck Failure 1`] = ` +
+ +
+
+

+ Logo +

+
+
+
+
+

+ + + heartOfGold + + + + repository.healthCheckFailure +

- - heartOfGold - - - repository.archived - - - repository.exporting + + heartOfGold + + + + repository.archived + + + repository.exporting +

- - heartOfGold - - + + + heartOfGold + + +

`; +exports[`Storyshots RepositoryEntry RepositoryFlag EP 1`] = ` +

+`; + exports[`Storyshots SplitAndReplace Simple replacement 1`] = ` Array [
Click here @@ -78699,55 +79095,91 @@ exports[`Storyshots Tag Colors 1`] = ` >
+ black + +
+
+ + dark + +
+
+ + light + +
+
+ + white + +
+
+ primary
link
info
success
warning
danger @@ -78761,19 +79193,112 @@ exports[`Storyshots Tag Default 1`] = ` className="Tagstories__Wrapper-wsn8kx-0 biUGWd" > Default tag
`; +exports[`Storyshots Tag Outlined 1`] = ` +
+
+
+ + success + +
+
+ + black + +
+
+ + danger + +
+
+
+`; + +exports[`Storyshots Tag Rounded 1`] = ` +
+ + Rounded tag + +
+`; + +exports[`Storyshots Tag Sizes 1`] = ` +
+
+
+ + small + +
+
+ + normal + +
+
+ + medium + +
+
+ + large + +
+
+
+`; + exports[`Storyshots Tag With Icon 1`] = `
hover me diff --git a/scm-ui/ui-components/src/forms/FileInput.tsx b/scm-ui/ui-components/src/forms/FileInput.tsx new file mode 100644 index 0000000000..f57e42c3d0 --- /dev/null +++ b/scm-ui/ui-components/src/forms/FileInput.tsx @@ -0,0 +1,86 @@ +/* + * 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. + */ +import React, { ChangeEvent, FC, FocusEvent } from "react"; +import classNames from "classnames"; +import LabelWithHelpIcon from "./LabelWithHelpIcon"; +import { createAttributesForTesting } from "../devBuild"; + +type Props = { + name?: string; + className?: string; + label?: string; + placeholder?: string; + helpText?: string; + disabled?: boolean; + testId?: string; + onChange?: (event: ChangeEvent) => void; + onBlur?: (event: FocusEvent) => void; + ref?: React.Ref; +}; + +const FileInput: FC = ({ + name, + testId, + helpText, + placeholder, + disabled, + label, + className, + ref, + onBlur, + onChange +}) => { + const handleChange = (event: ChangeEvent) => { + if (onChange && event.target.files) { + onChange(event); + } + }; + + const handleBlur = (event: FocusEvent) => { + if (onBlur && event.target.files) { + onBlur(event); + } + }; + + return ( +
+ +
+ +
+
+ ); +}; + +export default FileInput; diff --git a/scm-ui/ui-components/src/forms/TagGroup.tsx b/scm-ui/ui-components/src/forms/TagGroup.tsx index fb583e2219..33ab0f84ec 100644 --- a/scm-ui/ui-components/src/forms/TagGroup.tsx +++ b/scm-ui/ui-components/src/forms/TagGroup.tsx @@ -56,7 +56,7 @@ export default class TagGroup extends React.Component { return (
- this.removeEntry(item)} /> + this.removeEntry(item)} />
); @@ -66,7 +66,7 @@ export default class TagGroup extends React.Component { } removeEntry = (item: DisplayedUser) => { - const newItems = this.props.items.filter(name => name !== item); + const newItems = this.props.items.filter((name) => name !== item); this.props.onRemove(newItems); }; } diff --git a/scm-ui/ui-components/src/forms/index.ts b/scm-ui/ui-components/src/forms/index.ts index 49f2eb2dc6..2f8bf6a977 100644 --- a/scm-ui/ui-components/src/forms/index.ts +++ b/scm-ui/ui-components/src/forms/index.ts @@ -39,3 +39,4 @@ export { default as PasswordConfirmation } from "./PasswordConfirmation"; export { default as LabelWithHelpIcon } from "./LabelWithHelpIcon"; export { default as DropDown } from "./DropDown"; export { default as FileUpload } from "./FileUpload"; +export { default as FileInput } from "./FileInput"; diff --git a/scm-ui/ui-components/src/index.ts b/scm-ui/ui-components/src/index.ts index f4459a9001..ad5a44dea4 100644 --- a/scm-ui/ui-components/src/index.ts +++ b/scm-ui/ui-components/src/index.ts @@ -44,6 +44,7 @@ export { validation, repositories }; export { default as DateFromNow } from "./DateFromNow"; export { default as DateShort } from "./DateShort"; +export { default as Duration } from "./Duration"; export { default as ErrorNotification } from "./ErrorNotification"; export { default as ErrorPage } from "./ErrorPage"; export { default as Icon } from "./Icon"; diff --git a/scm-ui/ui-components/src/repos/DiffFile.tsx b/scm-ui/ui-components/src/repos/DiffFile.tsx index 7cb1d7df10..56f7e20ffd 100644 --- a/scm-ui/ui-components/src/repos/DiffFile.tsx +++ b/scm-ui/ui-components/src/repos/DiffFile.tsx @@ -396,10 +396,17 @@ class DiffFile extends React.Component { if (key === value) { value = file.type; } - const color = - value === "added" ? "success is-outlined" : value === "deleted" ? "danger is-outlined" : "info is-outlined"; - return ; + const color = value === "added" ? "success" : value === "deleted" ? "danger" : "info"; + return ( + + ); }; hasContent = (file: FileDiff) => file && !file.isBinary && file.hunks && file.hunks.length > 0; diff --git a/scm-ui/ui-components/src/repos/RepositoryEntry.stories.tsx b/scm-ui/ui-components/src/repos/RepositoryEntry.stories.tsx index 748b53895e..27e403a52b 100644 --- a/scm-ui/ui-components/src/repos/RepositoryEntry.stories.tsx +++ b/scm-ui/ui-components/src/repos/RepositoryEntry.stories.tsx @@ -33,6 +33,8 @@ import { Repository } from "@scm-manager/ui-types"; import Image from "../Image"; import Icon from "../Icon"; import { MemoryRouter } from "react-router-dom"; +import { Color } from "../styleConstants"; +import RepositoryFlag from "./RepositoryFlag"; const baseDate = "2020-03-26T12:13:42+02:00"; @@ -48,6 +50,14 @@ const bindAvatar = (binder: Binder, avatar: string) => { }); }; +const bindFlag = (binder: Binder, color: Color, label: string) => { + binder.bind("repository.card.flags", () => ( + + {label} + + )); +}; + const bindBeforeTitle = (binder: Binder, extension: ReactNode) => { binder.bind("repository.card.beforeTitle", () => { return extension; @@ -76,6 +86,17 @@ const QuickLink = ( const archivedRepository = { ...repository, archived: true }; const exportingRepository = { ...repository, exporting: true }; +const healthCheckFailedRepository = { + ...repository, + healthCheckFailures: [ + { + id: "4211", + summary: "Something failed", + description: "Something realy bad happend", + url: "https://something-realy-bad.happend" + } + ] +}; const archivedExportingRepository = { ...repository, archived: true, exporting: true }; storiesOf("RepositoryEntry", module) @@ -109,6 +130,18 @@ storiesOf("RepositoryEntry", module) bindAvatar(binder, Git); return withBinder(binder, exportingRepository); }) + .add("HealthCheck Failure", () => { + const binder = new Binder("title"); + bindAvatar(binder, Git); + return withBinder(binder, healthCheckFailedRepository); + }) + .add("RepositoryFlag EP", () => { + const binder = new Binder("title"); + bindAvatar(binder, Git); + bindFlag(binder, "success", "awesome"); + bindFlag(binder, "warning", "ouhhh..."); + return withBinder(binder, healthCheckFailedRepository); + }) .add("MultiRepositoryTags", () => { const binder = new Binder("title"); bindAvatar(binder, Git); diff --git a/scm-ui/ui-components/src/repos/RepositoryEntry.tsx b/scm-ui/ui-components/src/repos/RepositoryEntry.tsx index 8cdf25a0c9..661fa9ff79 100644 --- a/scm-ui/ui-components/src/repos/RepositoryEntry.tsx +++ b/scm-ui/ui-components/src/repos/RepositoryEntry.tsx @@ -30,6 +30,7 @@ import { ExtensionPoint } from "@scm-manager/ui-extensions"; import { withTranslation, WithTranslation } from "react-i18next"; import styled from "styled-components"; import HealthCheckFailureDetail from "./HealthCheckFailureDetail"; +import RepositoryFlag from "./RepositoryFlag"; type DateProp = Date | string; @@ -44,37 +45,23 @@ type State = { showHealthCheck: boolean; }; -const RepositoryTag = styled.span` - margin-left: 0.2rem; - background-color: #9a9a9a; - padding: 0.25rem; - border-radius: 5px; - color: white; - overflow: visible; - pointer-events: all; - font-weight: bold; - font-size: 0.7rem; -`; -const RepositoryWarnTag = styled.span` - margin-left: 0.2rem; - background-color: #f14668; - padding: 0.25rem; - border-radius: 5px; - color: white; - overflow: visible; - pointer-events: all; - font-weight: bold; - font-size: 0.7rem; - cursor: help; +const Title = styled.span` + display: flex; + align-items: center; + + & > * { + margin-right: 0.25rem; + } `; class RepositoryEntry extends React.Component { constructor(props: Props) { super(props); this.state = { - showHealthCheck: false + showHealthCheck: false, }; } + createLink = (repository: Repository) => { return `/repo/${repository.namespace}/${repository.name}`; }; @@ -170,31 +157,33 @@ class RepositoryEntry extends React.Component { const { repository, t } = this.props; const repositoryFlags = []; if (repository.archived) { - repositoryFlags.push({t("repository.archived")}); + repositoryFlags.push({t("repository.archived")}); } if (repository.exporting) { - repositoryFlags.push({t("repository.exporting")}); + repositoryFlags.push({t("repository.exporting")}); } if (repository.healthCheckFailures && repository.healthCheckFailures.length > 0) { repositoryFlags.push( - { this.setState({ showHealthCheck: true }); }} > {t("repository.healthCheckFailure")} - + ); } return ( - <> + <ExtensionPoint name="repository.card.beforeTitle" props={{ repository }} /> - <strong>{repository.name}</strong> {repositoryFlags.map(flag => flag)} - </> + <strong>{repository.name}</strong> {repositoryFlags} + <ExtensionPoint name="repository.flags" props={{ repository }} renderAll={true} /> + ); }; diff --git a/scm-ui/ui-components/src/repos/RepositoryFlag.tsx b/scm-ui/ui-components/src/repos/RepositoryFlag.tsx new file mode 100644 index 0000000000..a96f939f4b --- /dev/null +++ b/scm-ui/ui-components/src/repos/RepositoryFlag.tsx @@ -0,0 +1,42 @@ +/* + * 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. + */ + +import React, { FC } from "react"; +import Tag from "../Tag"; +import { Color, Size } from "../styleConstants"; + +type Props = { + color?: Color; + title?: string; + onClick?: () => void; + size?: Size; +}; + +const RepositoryFlag: FC = ({ children, size = "small", ...props }) => ( + + {children} + +); + +export default RepositoryFlag; diff --git a/scm-ui/ui-components/src/repos/index.ts b/scm-ui/ui-components/src/repos/index.ts index f5795e77cb..30ce5e5af4 100644 --- a/scm-ui/ui-components/src/repos/index.ts +++ b/scm-ui/ui-components/src/repos/index.ts @@ -29,10 +29,11 @@ import { AnnotationFactory, AnnotationFactoryContext, DiffEventHandler, - DiffEventContext + DiffEventContext, } from "./DiffTypes"; import { FileDiff as File, FileChangeType, Hunk, Change, ChangeType } from "@scm-manager/ui-types"; + export { diffs }; export * from "./annotate"; @@ -46,6 +47,7 @@ export { default as LoadingDiff } from "./LoadingDiff"; export { DefaultCollapsed, DefaultCollapsedFunction } from "./defaultCollapsed"; export { default as RepositoryAvatar } from "./RepositoryAvatar"; export { default as RepositoryEntry } from "./RepositoryEntry"; +export { default as RepositoryFlag } from "./RepositoryFlag"; export { default as RepositoryEntryLink } from "./RepositoryEntryLink"; export { default as JumpToFileButton } from "./JumpToFileButton"; export { default as CommitAuthor } from "./CommitAuthor"; @@ -61,5 +63,5 @@ export { AnnotationFactory, AnnotationFactoryContext, DiffEventHandler, - DiffEventContext + DiffEventContext, }; diff --git a/scm-ui/ui-components/src/styleConstants.ts b/scm-ui/ui-components/src/styleConstants.ts new file mode 100644 index 0000000000..b1d7f22e30 --- /dev/null +++ b/scm-ui/ui-components/src/styleConstants.ts @@ -0,0 +1,40 @@ +/* + * 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. + */ + +export const colors = [ + "black", + "dark", + "light", + "white", + "primary", + "link", + "info", + "success", + "warning", + "danger", +] as const; +export type Color = typeof colors[number]; + +export const sizes = ["small", "normal", "medium", "large"] as const; +export type Size = typeof sizes[number]; diff --git a/scm-ui/ui-extensions/src/extensionPoints.ts b/scm-ui/ui-extensions/src/extensionPoints.ts index 2eb9cafe4b..99afb9ccc5 100644 --- a/scm-ui/ui-extensions/src/extensionPoints.ts +++ b/scm-ui/ui-extensions/src/extensionPoints.ts @@ -26,8 +26,9 @@ import { ExtensionPointDefinition } from "./binder"; import { IndexResources, NamespaceStrategies, + Repository, RepositoryCreation, - RepositoryTypeCollection + RepositoryTypeCollection, } from "@scm-manager/ui-types"; type RepositoryCreatorSubFormProps = { @@ -55,3 +56,5 @@ export type RepositoryCreatorExtension = { }; export type RepositoryCreator = ExtensionPointDefinition<"repos.creator", RepositoryCreatorExtension>; + +export type RepositoryFlags = ExtensionPointDefinition<"repository.flags", { repository: Repository }>; diff --git a/scm-ui/ui-webapp/public/locales/de/commons.json b/scm-ui/ui-webapp/public/locales/de/commons.json index 84947ef992..48a8a37e4e 100644 --- a/scm-ui/ui-webapp/public/locales/de/commons.json +++ b/scm-ui/ui-webapp/public/locales/de/commons.json @@ -132,5 +132,19 @@ "empty": "Keine Benachrichtigungen", "dismiss": "Löschen", "dismissAll": "Alle löschen" + }, + "duration": { + "ms": "{{count}} Millisekunde", + "ms_plural": "{{count}} Millisekunden", + "s": "{{count}} Sekunde", + "s_plural": "{{count}} Sekunden", + "m": "{{count}} Minute", + "m_plural": "{{count}} Minuten", + "h": "{{count}} Stunde", + "h_plural": "{{count}} Stunden", + "d": "{{count}} Tag", + "d_plural": "{{count}} Tage", + "w": "{{count}} Woche", + "w_plural": "{{count}} Wochen" } } diff --git a/scm-ui/ui-webapp/public/locales/en/commons.json b/scm-ui/ui-webapp/public/locales/en/commons.json index 0db9fdb611..9eceddd829 100644 --- a/scm-ui/ui-webapp/public/locales/en/commons.json +++ b/scm-ui/ui-webapp/public/locales/en/commons.json @@ -133,5 +133,19 @@ "empty": "No notifications", "dismiss": "Dismiss", "dismissAll": "Dismiss all" + }, + "duration": { + "ms": "{{count}} millisecond", + "ms_plural": "{{count}} milliseconds", + "s": "{{count}} second", + "s_plural": "{{count}} seconds", + "m": "{{count}} minute", + "m_plural": "{{count}} minutes", + "h": "{{count}} hour", + "h_plural": "{{count}} hours", + "d": "{{count}} day", + "d_plural": "{{count}} days", + "w": "{{count}} week", + "w_plural": "{{count}} weeks" } } diff --git a/scm-ui/ui-webapp/src/containers/loadBundle.ts b/scm-ui/ui-webapp/src/containers/loadBundle.ts index 87b127a13b..33847e18ef 100644 --- a/scm-ui/ui-webapp/src/containers/loadBundle.ts +++ b/scm-ui/ui-webapp/src/containers/loadBundle.ts @@ -31,6 +31,7 @@ import * as ReactDOM from "react-dom"; import * as ReactRouterDom from "react-router-dom"; import * as Redux from "redux"; import * as ReactRedux from "react-redux"; +import ReactQueryDefault, * as ReactQuery from "react-query"; import StyledComponentsDefault, * as StyledComponents from "styled-components"; import ReactHookFormDefault, * as ReactHookForm from "react-hook-form"; import * as ReactI18Next from "react-i18next"; @@ -39,6 +40,7 @@ import QueryStringDefault, * as QueryString from "query-string"; import * as UIExtensions from "@scm-manager/ui-extensions"; import * as UIComponents from "@scm-manager/ui-components"; import { urls } from "@scm-manager/ui-components"; +import * as UIApi from "@scm-manager/ui-api"; type PluginModule = { name: string; @@ -53,12 +55,12 @@ const BundleLoader = { headers: { Cache: "no-cache", // identify the request as ajax request - "X-Requested-With": "XMLHttpRequest" - } - }).then(response => { + "X-Requested-With": "XMLHttpRequest", + }, + }).then((response) => { return response.text(); }); - } + }, }; SystemJS.registry.set(BundleLoader.name, SystemJS.newModule(BundleLoader)); @@ -70,9 +72,9 @@ SystemJS.config({ // @ts-ignore typing missing, but seems required esModule: true, authorization: true, - loader: BundleLoader.name - } - } + loader: BundleLoader.name, + }, + }, }); // We have to patch the resolve methods of SystemJS @@ -87,13 +89,13 @@ const resolveModuleUrl = (key: string) => { }; const defaultResolve = SystemJS.resolve; -SystemJS.resolve = function(key, parentName) { +SystemJS.resolve = function (key, parentName) { const module = resolveModuleUrl(key); return defaultResolve.apply(this, [module, parentName]); }; const defaultResolveSync = SystemJS.resolveSync; -SystemJS.resolveSync = function(key, parentName) { +SystemJS.resolveSync = function (key, parentName) { const module = resolveModuleUrl(key); return defaultResolveSync.apply(this, [module, parentName]); }; @@ -105,7 +107,7 @@ const expose = (name: string, cmp: any, defaultCmp?: any) => { // https://github.com/systemjs/systemjs/issues/1749 mod = { ...cmp, - __useDefault: defaultCmp + __useDefault: defaultCmp, }; } SystemJS.set(name, SystemJS.newModule(mod)); @@ -117,10 +119,12 @@ expose("react-router-dom", ReactRouterDom); expose("styled-components", StyledComponents, StyledComponentsDefault); expose("react-i18next", ReactI18Next); expose("react-hook-form", ReactHookForm, ReactHookFormDefault); +expose("react-query", ReactQuery, ReactQueryDefault); expose("classnames", ClassNames, ClassNamesDefault); expose("query-string", QueryString, QueryStringDefault); expose("@scm-manager/ui-extensions", UIExtensions); expose("@scm-manager/ui-components", UIComponents); +expose("@scm-manager/ui-api", UIApi); // redux is deprecated in favor of ui-api, // which will be exported soon diff --git a/scm-ui/ui-webapp/src/repos/components/RepositoryDetailTable.tsx b/scm-ui/ui-webapp/src/repos/components/RepositoryDetailTable.tsx index f1a5fbcf2e..9e2e6536b4 100644 --- a/scm-ui/ui-webapp/src/repos/components/RepositoryDetailTable.tsx +++ b/scm-ui/ui-webapp/src/repos/components/RepositoryDetailTable.tsx @@ -25,7 +25,6 @@ import React from "react"; import { WithTranslation, withTranslation } from "react-i18next"; import { Repository } from "@scm-manager/ui-types"; import { DateFromNow, MailLink } from "@scm-manager/ui-components"; -import { ExtensionPoint } from "@scm-manager/ui-extensions"; type Props = WithTranslation & { repository: Repository; diff --git a/scm-ui/ui-webapp/src/repos/containers/RepositoryRoot.tsx b/scm-ui/ui-webapp/src/repos/containers/RepositoryRoot.tsx index 30db024b58..18cadad2a9 100644 --- a/scm-ui/ui-webapp/src/repos/containers/RepositoryRoot.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/RepositoryRoot.tsx @@ -22,7 +22,7 @@ * SOFTWARE. */ import React, { useState } from "react"; -import { Redirect, Route, Link as RouteLink, Switch, useRouteMatch } from "react-router-dom"; +import { Link as RouteLink, Redirect, Route, Switch, useRouteMatch } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { binder, ExtensionPoint } from "@scm-manager/ui-extensions"; import { Changeset, Link } from "@scm-manager/ui-types"; @@ -36,12 +36,13 @@ import { NavLink, Page, PrimaryContentColumn, + RepositoryFlag, SecondaryNavigation, SecondaryNavigationColumn, StateMenuContextProvider, SubNavigation, Tooltip, - urls + urls, } from "@scm-manager/ui-components"; import RepositoryDetails from "../components/RepositoryDetails"; import EditRepo from "./EditRepo"; @@ -57,25 +58,14 @@ import ChangesetView from "./ChangesetView"; import SourceExtensions from "../sources/containers/SourceExtensions"; import TagsOverview from "../tags/container/TagsOverview"; import TagRoot from "../tags/container/TagRoot"; -import styled from "styled-components"; import { useIndexLinks, useRepository } from "@scm-manager/ui-api"; +import styled from "styled-components"; -const RepositoryTag = styled.span` - margin-left: 0.2rem; - background-color: #9a9a9a; - padding: 0.4rem; - border-radius: 5px; - color: white; - font-weight: bold; -`; - -const RepositoryWarnTag = styled.span` - margin-left: 0.2rem; - background-color: #f14668; - padding: 0.4rem; - border-radius: 5px; - color: white; +const TagGroup = styled.div` font-weight: bold; + & > * { + margin-right: 0.25rem; + } `; type UrlParams = { @@ -88,7 +78,7 @@ const useRepositoryFromUrl = (match: match) => { const { data: repository, ...rest } = useRepository(namespace, name); return { repository, - ...rest + ...rest, }; }; @@ -122,7 +112,7 @@ const RepositoryRoot = () => { error, repoLink: (indexLinks.repositories as Link)?.href, indexLinks, - match + match, }; const redirectUrlFactory = binder.getExtension("repository.redirect", props); @@ -133,16 +123,16 @@ const RepositoryRoot = () => { redirectedUrl = url + "/info"; } - const fileControlFactoryFactory: (changeset: Changeset) => FileControlFactory = changeset => file => { + const fileControlFactoryFactory: (changeset: Changeset) => FileControlFactory = (changeset) => (file) => { const baseUrl = `${url}/code/sources`; const sourceLink = file.newPath && { url: `${baseUrl}/${changeset.id}/${file.newPath}/`, - label: t("diff.jumpToSource") + label: t("diff.jumpToSource"), }; const targetLink = file.oldPath && changeset._embedded?.parents?.length === 1 && { url: `${baseUrl}/${changeset._embedded.parents[0].id}/${file.oldPath}`, - label: t("diff.jumpToTarget") + label: t("diff.jumpToTarget"), }; const links = []; @@ -172,7 +162,7 @@ const RepositoryRoot = () => { if (repository.archived) { repositoryFlags.push( - {t("repository.archived")} + {t("repository.archived")} ); } @@ -180,7 +170,7 @@ const RepositoryRoot = () => { if (repository.exporting) { repositoryFlags.push( - {t("repository.exporting")} + {t("repository.exporting")} ); } @@ -188,9 +178,9 @@ const RepositoryRoot = () => { if (repository.healthCheckFailures && repository.healthCheckFailures.length > 0) { repositoryFlags.push( - setShowHealthCheck(true)}> + setShowHealthCheck(true)} color="danger"> {t("repository.healthCheckFailure")} - + ); } @@ -207,7 +197,7 @@ const RepositoryRoot = () => { const extensionProps = { repository, url, - indexLinks + indexLinks, }; const matchesBranches = (route: any) => { @@ -258,7 +248,10 @@ const RepositoryRoot = () => { afterTitle={ <> - {repositoryFlags.map(flag => flag)} + + {repositoryFlags} + + } > diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DefaultRepositoryLinkProvider.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DefaultRepositoryLinkProvider.java new file mode 100644 index 0000000000..1c5937040e --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DefaultRepositoryLinkProvider.java @@ -0,0 +1,44 @@ +/* + * 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.api.v2.resources; + +import sonia.scm.repository.NamespaceAndName; + +import javax.inject.Inject; + +public class DefaultRepositoryLinkProvider implements RepositoryLinkProvider { + + private final ResourceLinks resourceLinks; + + @Inject + public DefaultRepositoryLinkProvider(ResourceLinks resourceLinks) { + this.resourceLinks = resourceLinks; + } + + @Override + public String get(NamespaceAndName namespaceAndName) { + return resourceLinks.repository().self(namespaceAndName.getNamespace(), namespaceAndName.getName()); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java b/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java index 36145b7087..482068c324 100644 --- a/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java +++ b/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java @@ -42,6 +42,8 @@ import sonia.scm.Undecorated; import sonia.scm.api.rest.ObjectMapperProvider; import sonia.scm.api.v2.resources.BranchLinkProvider; import sonia.scm.api.v2.resources.DefaultBranchLinkProvider; +import sonia.scm.api.v2.resources.DefaultRepositoryLinkProvider; +import sonia.scm.api.v2.resources.RepositoryLinkProvider; import sonia.scm.cache.CacheManager; import sonia.scm.cache.GuavaCacheManager; import sonia.scm.config.ScmConfiguration; @@ -263,6 +265,7 @@ class ScmServletModule extends ServletModule { // bind api link provider bind(BranchLinkProvider.class).to(DefaultBranchLinkProvider.class); + bind(RepositoryLinkProvider.class).to(DefaultRepositoryLinkProvider.class); // bind url helper bind(RootURL.class).to(DefaultRootURL.class); diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/DefaultPublicKey.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/DefaultPublicKey.java index f4853c86b7..32d067fd85 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/gpg/DefaultPublicKey.java +++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/DefaultPublicKey.java @@ -39,6 +39,7 @@ import org.slf4j.LoggerFactory; import sonia.scm.repository.Person; import sonia.scm.security.PublicKey; +import javax.annotation.Nullable; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; @@ -55,7 +56,7 @@ public class DefaultPublicKey implements PublicKey { private final String raw; private final Set contacts; - public DefaultPublicKey(String id, String owner, String raw, Set contacts) { + public DefaultPublicKey(String id, @Nullable String owner, String raw, Set contacts) { this.id = id; this.owner = owner; this.raw = raw; @@ -67,6 +68,12 @@ public class DefaultPublicKey implements PublicKey { return id; } + @Override + public Set getSubkeys() { + Keys keys = Keys.resolve(raw); + return keys.getSubs(); + } + @Override public Optional getOwner() { if (owner == null) { diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/DefaultPublicKeyParser.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/DefaultPublicKeyParser.java new file mode 100644 index 0000000000..0925c86cfc --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/DefaultPublicKeyParser.java @@ -0,0 +1,47 @@ +/* + * 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.security.gpg; + +import sonia.scm.security.NotPublicKeyException; +import sonia.scm.security.PublicKey; +import sonia.scm.security.PublicKeyParser; + +import java.util.Collections; + +import static sonia.scm.ContextEntry.ContextBuilder.noContext; + +public class DefaultPublicKeyParser implements PublicKeyParser { + @Override + public PublicKey parse(String raw) { + if (!raw.contains("PUBLIC KEY")) { + throw new NotPublicKeyException(noContext(), "The provided key is not a public key"); + } + + Keys keys = Keys.resolve(raw); + String master = keys.getMaster(); + + return new DefaultPublicKey(master, null, raw, Collections.emptySet()); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/GPGModule.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/GPGModule.java index 7087a971a4..65a6368979 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/gpg/GPGModule.java +++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/GPGModule.java @@ -27,11 +27,13 @@ package sonia.scm.security.gpg; import com.google.inject.AbstractModule; import sonia.scm.plugin.Extension; import sonia.scm.security.GPG; +import sonia.scm.security.PublicKeyParser; @Extension public class GPGModule extends AbstractModule { @Override protected void configure() { bind(GPG.class).to(DefaultGPG.class); + bind(PublicKeyParser.class).to(DefaultPublicKeyParser.class); } } diff --git a/scm-webapp/src/test/java/sonia/scm/security/gpg/DefaultPublicKeyParserTest.java b/scm-webapp/src/test/java/sonia/scm/security/gpg/DefaultPublicKeyParserTest.java new file mode 100644 index 0000000000..5b0d34aad5 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/security/gpg/DefaultPublicKeyParserTest.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.security.gpg; + +import org.junit.jupiter.api.Test; +import sonia.scm.security.NotPublicKeyException; +import sonia.scm.security.PublicKey; + +import java.io.IOException; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class DefaultPublicKeyParserTest { + + private final DefaultPublicKeyParser keyParser = new DefaultPublicKeyParser(); + + @Test + void shouldParsePublicKey() throws IOException { + String raw = GPGTestHelper.readResourceAsString("single.asc"); + PublicKey key = keyParser.parse(raw); + assertThat(key.getId()).isEqualTo("0x975922F193B07D6E"); + } + + @Test + void shouldParsePublicKeyWithSubkeys() throws IOException { + String raw = GPGTestHelper.readResourceAsString("subkeys.asc"); + PublicKey key = keyParser.parse(raw); + assertThat(key.getId()).isEqualTo("0x13B13D4C8A9350A1"); + assertThat(key.getSubkeys()).containsOnly( + "0x247E908C6FD35473", "0xE50E1DD8B90D3A6B", "0xBF49759E43DD0E60" + ); + } + + @Test + void shouldFailForNonPublicKey() { + String raw = "=== PRIVATE KEY === abcd"; + assertThrows(NotPublicKeyException.class, () -> keyParser.parse(raw)); + } + +}