From 0c5ab90852d0076c6a3097e05d997f30350b890a Mon Sep 17 00:00:00 2001 From: Konstantin Schaper Date: Tue, 24 Nov 2020 22:07:24 +0100 Subject: [PATCH] work in progress --- .../main/java/sonia/scm/repository/Tag.java | 7 + .../sonia/scm/repository/TagCreatedEvent.java | 41 +++++ .../sonia/scm/repository/TagDeletedEvent.java | 40 +++++ .../scm/repository/api/TagCommandBuilder.java | 80 +++++++++ .../scm/repository/api/TagCreateRequest.java | 37 ++++ .../scm/repository/api/TagDeleteRequest.java | 36 ++++ .../sonia/scm/repository/spi/TagCommand.java | 39 ++++ .../java/sonia/scm/repository/GitUtil.java | 80 +++++++++ .../spi/GitRepositoryServiceProvider.java | 7 +- .../spi/GitRepositoryServiceResolver.java | 7 +- .../scm/repository/spi/GitTagCommand.java | 168 ++++++++++++++++++ .../scm/repository/spi/GitTagsCommand.java | 34 +++- .../repository/spi/GitBranchCommandTest.java | 1 - .../spi/GitRepositoryServiceProviderTest.java | 6 +- .../scm/repository/spi/GitTagCommandTest.java | 87 +++++++++ .../repository/spi/GitTagsCommandTest.java | 31 +++- .../repository/spi/scm-git-spi-test-tags.zip | Bin 44688 -> 46440 bytes 17 files changed, 688 insertions(+), 13 deletions(-) create mode 100644 scm-core/src/main/java/sonia/scm/repository/TagCreatedEvent.java create mode 100644 scm-core/src/main/java/sonia/scm/repository/TagDeletedEvent.java create mode 100644 scm-core/src/main/java/sonia/scm/repository/api/TagCommandBuilder.java create mode 100644 scm-core/src/main/java/sonia/scm/repository/api/TagCreateRequest.java create mode 100644 scm-core/src/main/java/sonia/scm/repository/api/TagDeleteRequest.java create mode 100644 scm-core/src/main/java/sonia/scm/repository/spi/TagCommand.java create mode 100644 scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitTagCommand.java create mode 100644 scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitTagCommandTest.java diff --git a/scm-core/src/main/java/sonia/scm/repository/Tag.java b/scm-core/src/main/java/sonia/scm/repository/Tag.java index cdfac1c0e6..cd89b706b1 100644 --- a/scm-core/src/main/java/sonia/scm/repository/Tag.java +++ b/scm-core/src/main/java/sonia/scm/repository/Tag.java @@ -28,6 +28,8 @@ import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.ToString; +import java.util.ArrayList; +import java.util.List; import java.util.Optional; /** @@ -44,6 +46,7 @@ public final class Tag { private final String name; private final String revision; private final Long date; + private final List signatures = new ArrayList<>(); /** * Constructs a new tag. @@ -89,4 +92,8 @@ public final class Tag { public Optional getDate() { return Optional.ofNullable(date); } + + public void addSignature(Signature signature) { + this.signatures.add(signature); + } } diff --git a/scm-core/src/main/java/sonia/scm/repository/TagCreatedEvent.java b/scm-core/src/main/java/sonia/scm/repository/TagCreatedEvent.java new file mode 100644 index 0000000000..d268394769 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/TagCreatedEvent.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.repository; + +import lombok.Value; +import sonia.scm.event.Event; + +/** + * This event is fired when a new tag was created by the SCM-Manager. + * Warning: This event will not be fired if a new tag was pushed. + * @since 2.10.0 + */ +@Event +@Value +public class TagCreatedEvent { + Repository repository; + String revision; + String tagName; +} diff --git a/scm-core/src/main/java/sonia/scm/repository/TagDeletedEvent.java b/scm-core/src/main/java/sonia/scm/repository/TagDeletedEvent.java new file mode 100644 index 0000000000..13703c9223 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/TagDeletedEvent.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.repository; + +import lombok.Value; +import sonia.scm.event.Event; + +/** + * This event is fired when a tag was deleted by the SCM-Manager. + * Warning: This event will not be fired if a tag was removed by a push of a git client. + * @since 2.10.0 + */ +@Event +@Value +public class TagDeletedEvent { + Repository repository; + String tagName; +} diff --git a/scm-core/src/main/java/sonia/scm/repository/api/TagCommandBuilder.java b/scm-core/src/main/java/sonia/scm/repository/api/TagCommandBuilder.java new file mode 100644 index 0000000000..ceb7f5ab79 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/api/TagCommandBuilder.java @@ -0,0 +1,80 @@ +/* + * 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 sonia.scm.event.ScmEventBus; +import sonia.scm.repository.Repository; +import sonia.scm.repository.Tag; +import sonia.scm.repository.TagCreatedEvent; +import sonia.scm.repository.TagDeletedEvent; +import sonia.scm.repository.spi.TagCommand; + +import java.io.IOException; + +public class TagCommandBuilder { + private final Repository repository; + private final TagCommand command; + private final ScmEventBus eventBus; + + public TagCommandBuilder(Repository repository, TagCommand command) { + this.repository = repository; + this.command = command; + this.eventBus = ScmEventBus.getInstance(); + } + + TagCreateCommandBuilder create() { + return new TagCreateCommandBuilder(); + } + + TagDeleteCommandBuilder delete() { + return new TagDeleteCommandBuilder(); + } + + private class TagCreateCommandBuilder { + private TagCreateRequest request = new TagCreateRequest(); + + void setRevision(String revision) { + request.setRevision(revision); + } + + void setName(String name) { + request.setName(name); + } + + Tag execute() throws IOException { + Tag tag = command.create(request); + eventBus.post(new TagCreatedEvent(repository, request.getRevision(), request.getName())); + return tag; + } + } + + private class TagDeleteCommandBuilder { + private TagDeleteRequest request = new TagDeleteRequest(); + void execute() throws IOException { + command.delete(request); + eventBus.post(new TagDeletedEvent(repository, request.getName())); + } + } +} diff --git a/scm-core/src/main/java/sonia/scm/repository/api/TagCreateRequest.java b/scm-core/src/main/java/sonia/scm/repository/api/TagCreateRequest.java new file mode 100644 index 0000000000..d74e0b642e --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/api/TagCreateRequest.java @@ -0,0 +1,37 @@ +/* + * 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.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class TagCreateRequest { + private String revision; + private String name; +} diff --git a/scm-core/src/main/java/sonia/scm/repository/api/TagDeleteRequest.java b/scm-core/src/main/java/sonia/scm/repository/api/TagDeleteRequest.java new file mode 100644 index 0000000000..1e75aaf619 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/api/TagDeleteRequest.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.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class TagDeleteRequest { + private String name; +} diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/TagCommand.java b/scm-core/src/main/java/sonia/scm/repository/spi/TagCommand.java new file mode 100644 index 0000000000..66d6dd8386 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/spi/TagCommand.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 sonia.scm.repository.Tag; +import sonia.scm.repository.api.TagDeleteRequest; +import sonia.scm.repository.api.TagCreateRequest; + +import java.io.IOException; + +/** + * @since 2.11 + */ +public interface TagCommand { + Tag create(TagCreateRequest request) throws IOException; + void delete(TagDeleteRequest request) throws IOException; +} 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 6baa48111b..b1e0ec2b34 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 @@ -30,6 +30,7 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Strings; import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.Multimap; +import org.checkerframework.checker.nullness.Opt; import org.eclipse.jgit.api.FetchCommand; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; @@ -52,17 +53,23 @@ import org.eclipse.jgit.transport.RefSpec; import org.eclipse.jgit.treewalk.TreeWalk; import org.eclipse.jgit.util.FS; import org.eclipse.jgit.util.LfsFactory; +import org.eclipse.jgit.util.RawParseUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.ContextEntry; +import sonia.scm.security.GPG; +import sonia.scm.security.PublicKey; import sonia.scm.util.HttpUtil; import sonia.scm.util.Util; import sonia.scm.web.GitUserAgentProvider; import javax.servlet.http.HttpServletRequest; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.util.Arrays; +import java.util.Collections; import java.util.Map; import java.util.Optional; import java.util.concurrent.TimeUnit; @@ -451,6 +458,26 @@ public final class GitUtil return commit; } + public static RevTag getTag(org.eclipse.jgit.lib.Repository repository, + RevWalk revWalk, Ref ref) + throws IOException + { + RevTag tag = null; + ObjectId id = ref.getObjectId(); + + if (id != null) + { + if (revWalk == null) + { + revWalk = new RevWalk(repository); + } + + tag = revWalk.parseTag(id); + } + + return tag; + } + /** * Method description * @@ -683,6 +710,59 @@ public final class GitUtil return name; } + private static final byte[] GPG_HEADER = {'P', 'G', 'P'}; + + public static Optional getTagSignature(RevObject revObject, GPG gpg) { + if (revObject instanceof RevTag) { + RevTag tag = (RevTag) revObject; + byte[] raw = tag.getFullMessage().getBytes(); + + int start = RawParseUtils.headerStart(GPG_HEADER, raw, 0); + if (start < 0) { + return Optional.empty(); + } + + int end = RawParseUtils.headerEnd(raw, start); + byte[] signature = Arrays.copyOfRange(raw, start, end); + + String publicKeyId = gpg.findPublicKeyId(signature); + if (Strings.isNullOrEmpty(publicKeyId)) { + // key not found + return Optional.of(new Signature(publicKeyId, "gpg", SignatureStatus.NOT_FOUND, null, Collections.emptySet())); + } + + Optional publicKeyById = gpg.findPublicKey(publicKeyId); + if (!publicKeyById.isPresent()) { + // key not found + return Optional.of(new Signature(publicKeyId, "gpg", SignatureStatus.NOT_FOUND, null, Collections.emptySet())); + } + + PublicKey publicKey = publicKeyById.get(); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try { + byte[] headerPrefix = Arrays.copyOfRange(raw, 0, start - GPG_HEADER.length - 1); + baos.write(headerPrefix); + + byte[] headerSuffix = Arrays.copyOfRange(raw, end + 1, raw.length); + baos.write(headerSuffix); + } catch (IOException ex) { + // this will never happen, because we are writing into memory + throw new IllegalStateException("failed to write into memory", ex); + } + + boolean verified = publicKey.verify(baos.toByteArray(), signature); + return Optional.of(new Signature( + publicKeyId, + "gpg", + verified ? SignatureStatus.VERIFIED : SignatureStatus.INVALID, + publicKey.getOwner().orElse(null), + publicKey.getContacts() + )); + } + return Optional.empty(); + } + /** * Returns true if the request comes from a git client. * 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 fae69a47cf..d54320575d 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 @@ -29,6 +29,7 @@ import com.google.inject.AbstractModule; import com.google.inject.Injector; import sonia.scm.repository.Feature; import sonia.scm.repository.api.Command; +import sonia.scm.security.GPG; import java.util.EnumSet; import java.util.Set; @@ -61,12 +62,14 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider protected static final Set FEATURES = EnumSet.of(Feature.INCOMING_REVISION); private final GitContext context; + private final GPG gpg; private final Injector commandInjector; //~--- constructors --------------------------------------------------------- - GitRepositoryServiceProvider(Injector injector, GitContext context) { + GitRepositoryServiceProvider(Injector injector, GitContext context, GPG gpg) { this.context = context; + this.gpg = gpg; commandInjector = injector.createChildInjector(new AbstractModule() { @Override protected void configure() { @@ -142,7 +145,7 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider @Override public TagsCommand getTagsCommand() { - return new GitTagsCommand(context); + return new GitTagsCommand(context, gpg); } @Override diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceResolver.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceResolver.java index 7ff06dd140..1eb9b55e9b 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceResolver.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceResolver.java @@ -31,6 +31,7 @@ import com.google.inject.Injector; import sonia.scm.plugin.Extension; import sonia.scm.repository.GitRepositoryHandler; import sonia.scm.repository.Repository; +import sonia.scm.security.GPG; /** * @@ -41,17 +42,19 @@ public class GitRepositoryServiceResolver implements RepositoryServiceResolver { private final Injector injector; private final GitContextFactory contextFactory; + private final GPG gpg; @Inject - public GitRepositoryServiceResolver(Injector injector, GitContextFactory contextFactory) { + public GitRepositoryServiceResolver(Injector injector, GitContextFactory contextFactory, GPG gpg) { this.injector = injector; this.contextFactory = contextFactory; + this.gpg = gpg; } @Override public GitRepositoryServiceProvider resolve(Repository repository) { if (GitRepositoryHandler.TYPE_NAME.equalsIgnoreCase(repository.getType())) { - return new GitRepositoryServiceProvider(injector, contextFactory.create(repository)); + return new GitRepositoryServiceProvider(injector, contextFactory.create(repository), gpg); } return null; } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitTagCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitTagCommand.java new file mode 100644 index 0000000000..31fb9d6249 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitTagCommand.java @@ -0,0 +1,168 @@ +/* + * 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.Strings; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.lib.Ref; +import org.eclipse.jgit.revwalk.RevObject; +import org.eclipse.jgit.revwalk.RevTag; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.util.RawParseUtils; +import sonia.scm.repository.GitUtil; +import sonia.scm.repository.InternalRepositoryException; +import sonia.scm.repository.Signature; +import sonia.scm.repository.SignatureStatus; +import sonia.scm.repository.Tag; +import sonia.scm.repository.api.TagDeleteRequest; +import sonia.scm.repository.api.TagCreateRequest; +import sonia.scm.security.GPG; +import sonia.scm.security.PublicKey; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.Optional; + +public class GitTagCommand extends AbstractGitCommand implements TagCommand { + private final GPG gpg; + + GitTagCommand(GitContext context, GPG gpg) { + super(context); + this.gpg = gpg; + } + + @Override + public Tag create(TagCreateRequest request) { + try (Git git = new Git(context.open())) { + Tag tag; + String revision = request.getRevision(); + + RevObject revObject = null; + Long tagTime = null; + + if (!Strings.isNullOrEmpty(revision)) { + ObjectId id = git.getRepository().resolve(revision); + + try (RevWalk walk = new RevWalk(git.getRepository())) { + revObject = walk.parseAny(id); + tagTime = GitUtil.getTagTime(walk, id); + } + } + + Ref ref; + + if (revObject != null) { + ref = + git.tag() + .setObjectId(revObject) + .setTagger(new PersonIdent("SCM-Manager", "noreply@scm-manager.org")) + .setName(request.getName()) + .call(); + } else { + throw new InternalRepositoryException(repository, "could not create tag because revision does not exist"); + } + + ObjectId objectId; + if (ref.isPeeled()) { + objectId = ref.getPeeledObjectId(); + } else { + objectId = ref.getObjectId(); + } + tag = new Tag(request.getName(), objectId.toString(), tagTime); + + try (RevWalk walk = new RevWalk(git.getRepository())) { + revObject = walk.parseTag(objectId); + tag.addSignature(getTagSignature((RevTag) revObject)); + } + + return tag; + } catch (IOException | GitAPIException ex) { + throw new InternalRepositoryException(repository, "could not create tag " + request.getName(), ex); + } + } + + @Override + public void delete(TagDeleteRequest request) { + try (Git git = new Git(context.open())) { + git.tagDelete().setTags(request.getName()).call(); + } catch (GitAPIException | IOException e) { + throw new InternalRepositoryException(repository, "could not delete tag", e); + } + } + + private static final byte[] GPG_HEADER = {'g', 'p', 'g', 's', 'i', 'g'}; + + private Signature getTagSignature(RevTag tag) { + byte[] raw = tag.getFullMessage().getBytes(); + + int start = RawParseUtils.headerStart(GPG_HEADER, raw, 0); + if (start < 0) { + return null; + } + + int end = RawParseUtils.headerEnd(raw, start); + byte[] signature = Arrays.copyOfRange(raw, start, end); + + String publicKeyId = gpg.findPublicKeyId(signature); + if (Strings.isNullOrEmpty(publicKeyId)) { + // key not found + return new Signature(publicKeyId, "gpg", SignatureStatus.NOT_FOUND, null, Collections.emptySet()); + } + + Optional publicKeyById = gpg.findPublicKey(publicKeyId); + if (!publicKeyById.isPresent()) { + // key not found + return new Signature(publicKeyId, "gpg", SignatureStatus.NOT_FOUND, null, Collections.emptySet()); + } + + PublicKey publicKey = publicKeyById.get(); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try { + byte[] headerPrefix = Arrays.copyOfRange(raw, 0, start - GPG_HEADER.length - 1); + baos.write(headerPrefix); + + byte[] headerSuffix = Arrays.copyOfRange(raw, end + 1, raw.length); + baos.write(headerSuffix); + } catch (IOException ex) { + // this will never happen, because we are writing into memory + throw new IllegalStateException("failed to write into memory", ex); + } + + boolean verified = publicKey.verify(baos.toByteArray(), signature); + return new Signature( + publicKeyId, + "gpg", + verified ? SignatureStatus.VERIFIED : SignatureStatus.INVALID, + publicKey.getOwner().orElse(null), + publicKey.getContacts() + ); + } +} 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 60b1105547..06e500ce4c 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 @@ -30,17 +30,22 @@ 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.errors.IncorrectObjectTypeException; import org.eclipse.jgit.lib.Ref; 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.Signature; import sonia.scm.repository.Tag; +import sonia.scm.security.GPG; import java.io.IOException; import java.util.List; +import java.util.Optional; //~--- JDK imports ------------------------------------------------------------ @@ -49,20 +54,23 @@ import java.util.List; */ public class GitTagsCommand extends AbstractGitCommand implements TagsCommand { + private final GPG gpg; + /** * Constructs ... * * @param context */ - public GitTagsCommand(GitContext context) { + public GitTagsCommand(GitContext context, GPG gpp) { super(context); + this.gpg = gpp; } //~--- get methods ---------------------------------------------------------- @Override public List getTags() throws IOException { - List tags = null; + List tags; RevWalk revWalk = null; @@ -74,7 +82,7 @@ public class GitTagsCommand extends AbstractGitCommand implements TagsCommand { List tagList = git.tagList().call(); tags = Lists.transform(tagList, - new TransformFuntion(git.getRepository(), revWalk)); + new TransformFuntion(git.getRepository(), revWalk, gpg, git)); } catch (GitAPIException ex) { throw new InternalRepositoryException(repository, "could not read tags from repository", ex); } finally { @@ -109,9 +117,13 @@ public class GitTagsCommand extends AbstractGitCommand implements TagsCommand { * @param revWalk */ public TransformFuntion(org.eclipse.jgit.lib.Repository repository, - RevWalk revWalk) { + RevWalk revWalk, + GPG gpg, + Git git) { this.repository = repository; this.revWalk = revWalk; + this.gpg = gpg; + this.git = git; } //~--- methods ------------------------------------------------------------ @@ -133,6 +145,18 @@ public class GitTagsCommand extends AbstractGitCommand implements TagsCommand { String name = GitUtil.getTagName(ref); tag = new Tag(name, revObject.getId().name(), GitUtil.getTagTime(revWalk, ref.getObjectId())); + + try { + RevTag revTag = GitUtil.getTag(repository, revWalk, ref); + + final Optional tagSignature = GitUtil.getTagSignature(revTag, gpg); + if (tagSignature.isPresent()) { + tag.addSignature(tagSignature.get()); + } + } catch (IncorrectObjectTypeException e) { + // Ignore, this must be a lightweight tag + } + } } catch (IOException ex) { @@ -153,5 +177,7 @@ public class GitTagsCommand extends AbstractGitCommand implements TagsCommand { * Field description */ private RevWalk revWalk; + private final GPG gpg; + private final Git git; } } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBranchCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBranchCommandTest.java index 0df9332cfe..1fba957da5 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBranchCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBranchCommandTest.java @@ -32,7 +32,6 @@ import org.mockito.invocation.InvocationOnMock; import org.mockito.junit.MockitoJUnitRunner; import sonia.scm.event.ScmEventBus; import sonia.scm.repository.Branch; -import sonia.scm.repository.BranchCreatedEvent; import sonia.scm.repository.PostReceiveRepositoryHookEvent; import sonia.scm.repository.PreReceiveRepositoryHookEvent; import sonia.scm.repository.api.BranchRequest; diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitRepositoryServiceProviderTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitRepositoryServiceProviderTest.java index aac6eaef49..4878a8f4b8 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitRepositoryServiceProviderTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitRepositoryServiceProviderTest.java @@ -32,6 +32,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import sonia.scm.repository.GitRepositoryHandler; +import sonia.scm.security.GPG; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.verify; @@ -45,6 +46,9 @@ class GitRepositoryServiceProviderTest { @Mock private GitContext context; + @Mock + private GPG gpg; + @Test void shouldCreatePushCommand() { GitRepositoryServiceProvider provider = createProvider(); @@ -59,7 +63,7 @@ class GitRepositoryServiceProviderTest { } private GitRepositoryServiceProvider createProvider() { - return new GitRepositoryServiceProvider(createParentInjector(), context); + return new GitRepositoryServiceProvider(createParentInjector(), context, gpg); } private Injector createParentInjector() { 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 new file mode 100644 index 0000000000..7c883cc087 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitTagCommandTest.java @@ -0,0 +1,87 @@ +/* + * 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.GpgSigner; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import sonia.scm.repository.GitTestHelper; +import sonia.scm.repository.Tag; +import sonia.scm.repository.api.TagDeleteRequest; +import sonia.scm.repository.api.TagCreateRequest; +import sonia.scm.security.GPG; + +import java.io.IOException; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@RunWith(MockitoJUnitRunner.class) +public class GitTagCommandTest extends AbstractGitCommandTestBase { + + @Mock + private GPG gpg; + + @Before + public void setSigner() { + GpgSigner.setDefault(new GitTestHelper.SimpleGpgSigner()); + } + + @Test + public void shouldCreateATag() throws IOException { + createCommand().create(new TagCreateRequest("592d797cd36432e591416e8b2b98154f4f163411", "newtag")); + Optional tag = findTag(createContext(), "newtag"); + assertThat(tag).isNotEmpty(); + } + + @Test + public void shouldDeleteATag() throws IOException { + final GitContext context = createContext(); + Optional tag = findTag(context, "test-tag"); + assertThat(tag).isNotEmpty(); + + createCommand().delete(new TagDeleteRequest("test-tag")); + + tag = findTag(context, "test-tag"); + assertThat(tag).isEmpty(); + } + + private GitTagCommand createCommand() { + return new GitTagCommand(createContext(), gpg); + } + + private List readTags(GitContext context) throws IOException { + return new GitTagsCommand(context, gpg).getTags(); + } + + private Optional findTag(GitContext context, String name) throws IOException { + List branches = readTags(context); + return branches.stream().filter(b -> name.equals(b.getName())).findFirst(); + } +} 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 9112aa3133..27711d8b55 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 @@ -29,14 +29,22 @@ import com.github.sdorra.shiro.SubjectAware; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.mockito.ArgumentMatchers; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; import sonia.scm.repository.Tag; +import sonia.scm.security.GPG; import java.io.IOException; import java.util.List; +import static org.assertj.core.api.Assertions.anyOf; import static org.assertj.core.api.Assertions.assertThat; @SubjectAware(configuration = "classpath:sonia/scm/configuration/shiro.ini", username = "admin", password = "secret") +@RunWith(MockitoJUnitRunner.class) public class GitTagsCommandTest extends AbstractGitCommandTestBase { @Rule @@ -48,24 +56,41 @@ public class GitTagsCommandTest extends AbstractGitCommandTestBase { @Rule public ShiroRule shiro = new ShiroRule(); + @Mock + GPG gpg; + @Test public void shouldGetDatesCorrectly() throws IOException { final GitContext gitContext = createContext(); - final GitTagsCommand tagsCommand = new GitTagsCommand(gitContext); + final GitTagsCommand tagsCommand = new GitTagsCommand(gitContext, gpg); final List tags = tagsCommand.getTags(); - assertThat(tags).hasSize(2); + assertThat(tags).hasSize(3); Tag annotatedTag = tags.get(0); assertThat(annotatedTag.getName()).isEqualTo("1.0.0"); assertThat(annotatedTag.getDate()).contains(1598348105000L); // Annotated - Take tag date assertThat(annotatedTag.getRevision()).isEqualTo("fcd0ef1831e4002ac43ea539f4094334c79ea9ec"); - Tag lightweightTag = tags.get(1); + Tag lightweightTag = tags.get(2); assertThat(lightweightTag.getName()).isEqualTo("test-tag"); assertThat(lightweightTag.getDate()).contains(1339416344000L); // Lightweight - Take commit date assertThat(lightweightTag.getRevision()).isEqualTo("86a6645eceefe8b9a247db5eb16e3d89a7e6e6d1"); } + @Test + public void shouldGetSignatures() throws IOException { + Mockito.when(gpg.findPublicKeyId(ArgumentMatchers.any())).thenReturn("2BA27721F113C005CC16F06BAE63EFBC49F140CF"); + + final GitContext gitContext = createContext(); + final GitTagsCommand tagsCommand = new GitTagsCommand(gitContext, gpg); + final List tags = tagsCommand.getTags(); + + assertThat(tags).hasSize(3); + + Tag signedTag = tags.get(1); + assertThat(signedTag.getSignatures()).isNotEmpty(); + } + @Override protected String getZippedRepositoryResource() { return "sonia/scm/repository/spi/scm-git-spi-test-tags.zip"; diff --git a/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-spi-test-tags.zip b/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-spi-test-tags.zip index 05b2f0f7cab59ed00cab488ed71e36611e5f6983..20f8169dededa874b4f141ab0a9f5687c4927f73 100644 GIT binary patch delta 6967 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 delta 5402 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 E2T