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 05b2f0f7ca..20f8169ded 100644 Binary files a/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-spi-test-tags.zip and b/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-spi-test-tags.zip differ