Fix branch and tag name validation

The validation for branch and tag names has been
too limited. This led to errors in the frontend for
branches, that had been created using the
cli tools for git or hg but have not been seen as
valid by SCM-Manager.

To fix this, the patterns to validate branch and
tag names are relaxed and relate to the git
rules (https://git-scm.com/docs/git-check-ref-format).
Because these rules could not be expressed
using regular expressions alone, in addition
possible exceptions will be handled in the
git branch and tag commands.

Committed-by: Konstantin Schaper <konstantin.schaper@cloudogu.com>
Co-authored-by: Eduard Heimbuch <eduard.heimbuch@cloudogu.com>
Co-authored-by: René Pfeuffer <rene.pfeuffer@cloudogu.com>
Co-authored-by: Konstantin Schaper <konstantin.schaper@cloudogu.com>
This commit is contained in:
Rene Pfeuffer
2023-04-05 11:45:15 +02:00
committed by SCM-Manager
parent b53f8bcf12
commit 8eb2687e10
15 changed files with 151 additions and 19 deletions

View File

@@ -0,0 +1,2 @@
- type: fixed
description: Branch and tag validation regarding special characters

View File

@@ -45,9 +45,28 @@ import java.util.regex.Pattern;
@XmlAccessorType(XmlAccessType.FIELD) @XmlAccessorType(XmlAccessType.FIELD)
public final class Branch implements Serializable, Validateable { public final class Branch implements Serializable, Validateable {
private static final String VALID_CHARACTERS_AT_START_AND_END = "\\w-,;\\]{}@&+=$#`|<>"; /*
private static final String VALID_CHARACTERS = VALID_CHARACTERS_AT_START_AND_END + "/."; The regex for branches is based on the rules for git branch names taken
public static final String VALID_BRANCH_NAMES = "[" + VALID_CHARACTERS_AT_START_AND_END + "]([" + VALID_CHARACTERS + "]*[" + VALID_CHARACTERS_AT_START_AND_END + "])?"; from the reference format check (https://git-scm.com/docs/git-check-ref-format)
Below you find the rules, taken from the site. Rules 3, 8 and 9 are not implemented,
because they cannot simply be checked using a regular expression.
1. They can include slash / for hierarchical (directory) grouping, but no slash-separated component can begin with a dot . or end with the sequence .lock.
2. [not relevant for branches]
3. They cannot have two consecutive dots .. anywhere.
4. They cannot have ASCII control characters (i.e. bytes whose values are lower than \040, or \177 DEL), space, tilde ~, caret ^, or colon : anywhere.
5. They cannot have question-mark ?, asterisk *, or open bracket [ anywhere. See the --refspec-pattern option below for an exception to this rule.
6. They cannot begin or end with a slash / or contain multiple consecutive slashes (see the --normalize option below for an exception to this rule)
7. They cannot end with a dot ..
8. They cannot contain a sequence @{.
9. They cannot be the single character @.
10. They cannot contain a \.
*/
private static final String ILLEGAL_CHARACTERS = "\\\\/\\s\\[~^:?*";
private static final String VALID_PATH_PART = "[^." + ILLEGAL_CHARACTERS + "](?:[^" + ILLEGAL_CHARACTERS + "]*[^." + ILLEGAL_CHARACTERS + "])?";
public static final String VALID_BRANCH_NAMES = VALID_PATH_PART + "(?:/" + VALID_PATH_PART + ")*";
public static final Pattern VALID_BRANCH_NAME_PATTERN = Pattern.compile(VALID_BRANCH_NAMES); public static final Pattern VALID_BRANCH_NAME_PATTERN = Pattern.compile(VALID_BRANCH_NAMES);
private static final long serialVersionUID = -4602244691711222413L; private static final long serialVersionUID = -4602244691711222413L;

View File

@@ -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 org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.assertj.core.api.Assertions.assertThat;
@ExtendWith(MockitoExtension.class)
class BranchTest {
@ParameterizedTest
@ValueSource(strings = {
"a",
"test",
"feature/nöice",
"😄",
"very_long/and/complex%branch+name"
})
void shouldAcceptValidBranchName(String branchName) {
assertThat(branchName).matches(Branch.VALID_BRANCH_NAME_PATTERN);
}
@ParameterizedTest
@ValueSource(strings = {
"/",
"/feature/ugly",
"./start",
".hidden",
"full_stop.",
"very/.hidden",
"some\\place",
"some//place",
"some space",
"home/~",
"some_:",
"2^8",
"real?",
"find*all",
"some[set"
})
void shouldRejectInvalidBranchName(String branchName) {
assertThat(branchName).doesNotMatch(Branch.VALID_BRANCH_NAME_PATTERN);
}
}

View File

@@ -24,9 +24,11 @@
package sonia.scm.repository.spi; package sonia.scm.repository.spi;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.api.errors.CannotDeleteCurrentBranchException; import org.eclipse.jgit.api.errors.CannotDeleteCurrentBranchException;
import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.api.errors.GitAPIException;
import org.eclipse.jgit.api.errors.InvalidRefNameException;
import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Ref; import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.lib.Repository;
@@ -59,7 +61,9 @@ import static java.util.Collections.singleton;
import static java.util.Collections.singletonList; import static java.util.Collections.singletonList;
import static org.eclipse.jgit.lib.ObjectId.zeroId; import static org.eclipse.jgit.lib.ObjectId.zeroId;
import static sonia.scm.ContextEntry.ContextBuilder.entity; import static sonia.scm.ContextEntry.ContextBuilder.entity;
import static sonia.scm.ScmConstraintViolationException.Builder.doThrow;
@Slf4j
public class GitBranchCommand extends AbstractGitCommand implements BranchCommand { public class GitBranchCommand extends AbstractGitCommand implements BranchCommand {
private final HookContextFactory hookContextFactory; private final HookContextFactory hookContextFactory;
@@ -88,6 +92,10 @@ public class GitBranchCommand extends AbstractGitCommand implements BranchComman
Ref ref = git.branchCreate().setStartPoint(request.getParentBranch()).setName(request.getNewBranch()).call(); Ref ref = git.branchCreate().setStartPoint(request.getParentBranch()).setName(request.getNewBranch()).call();
eventBus.post(new PostReceiveRepositoryHookEvent(hookEvent)); eventBus.post(new PostReceiveRepositoryHookEvent(hookEvent));
return Branch.normalBranch(request.getNewBranch(), GitUtil.getId(ref.getObjectId())); return Branch.normalBranch(request.getNewBranch(), GitUtil.getId(ref.getObjectId()));
} catch (InvalidRefNameException e) {
log.debug("got exception for invalid branch name {}", request.getNewBranch(), e);
doThrow().violation("Invalid branch name", "name").when(true);
return null;
} catch (GitAPIException | IOException ex) { } catch (GitAPIException | IOException ex) {
throw new InternalRepositoryException(repository, "could not create branch " + request.getNewBranch(), ex); throw new InternalRepositoryException(repository, "could not create branch " + request.getNewBranch(), ex);
} }

View File

@@ -25,9 +25,11 @@
package sonia.scm.repository.spi; package sonia.scm.repository.spi;
import com.google.common.base.Strings; import com.google.common.base.Strings;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.SecurityUtils; import org.apache.shiro.SecurityUtils;
import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.api.errors.GitAPIException;
import org.eclipse.jgit.api.errors.InvalidTagNameException;
import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.PersonIdent; import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Ref; import org.eclipse.jgit.lib.Ref;
@@ -68,7 +70,9 @@ import static org.eclipse.jgit.lib.ObjectId.fromString;
import static org.eclipse.jgit.lib.ObjectId.zeroId; import static org.eclipse.jgit.lib.ObjectId.zeroId;
import static sonia.scm.ContextEntry.ContextBuilder.entity; import static sonia.scm.ContextEntry.ContextBuilder.entity;
import static sonia.scm.NotFoundException.notFound; import static sonia.scm.NotFoundException.notFound;
import static sonia.scm.ScmConstraintViolationException.Builder.doThrow;
@Slf4j
public class GitTagCommand extends AbstractGitCommand implements TagCommand { public class GitTagCommand extends AbstractGitCommand implements TagCommand {
public static final String REFS_TAGS_PREFIX = "refs/tags/"; public static final String REFS_TAGS_PREFIX = "refs/tags/";
private final HookContextFactory hookContextFactory; private final HookContextFactory hookContextFactory;
@@ -129,6 +133,10 @@ public class GitTagCommand extends AbstractGitCommand implements TagCommand {
eventBus.post(new PostReceiveRepositoryHookEvent(hookEvent)); eventBus.post(new PostReceiveRepositoryHookEvent(hookEvent));
return tag; return tag;
} catch (InvalidTagNameException e) {
log.debug("got exception for invalid tag name {}", request.getName(), e);
doThrow().violation("Invalid tag name", "name").when(true);
return null;
} catch (IOException | GitAPIException ex) { } catch (IOException | GitAPIException ex) {
throw new InternalRepositoryException(repository, "could not create tag " + name + " for revision " + revision, ex); throw new InternalRepositoryException(repository, "could not create tag " + name + " for revision " + revision, ex);
} }

View File

@@ -30,8 +30,8 @@ import org.junit.Test;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.junit.MockitoJUnitRunner; import org.mockito.junit.MockitoJUnitRunner;
import sonia.scm.ScmConstraintViolationException;
import sonia.scm.event.ScmEventBus; import sonia.scm.event.ScmEventBus;
import sonia.scm.repository.Branch; import sonia.scm.repository.Branch;
import sonia.scm.repository.Changeset; import sonia.scm.repository.Changeset;
@@ -42,7 +42,6 @@ import sonia.scm.repository.PreProcessorUtil;
import sonia.scm.repository.PreReceiveRepositoryHookEvent; import sonia.scm.repository.PreReceiveRepositoryHookEvent;
import sonia.scm.repository.api.BranchRequest; import sonia.scm.repository.api.BranchRequest;
import sonia.scm.repository.api.HookChangesetBuilder; import sonia.scm.repository.api.HookChangesetBuilder;
import sonia.scm.repository.api.HookContext;
import sonia.scm.repository.api.HookContextFactory; import sonia.scm.repository.api.HookContextFactory;
import java.io.IOException; import java.io.IOException;
@@ -130,6 +129,15 @@ public class GitBranchCommandTest extends AbstractGitCommandTestBase {
assertThrows(CannotDeleteDefaultBranchException.class, () -> command.deleteOrClose(branchToBeDeleted)); assertThrows(CannotDeleteDefaultBranchException.class, () -> command.deleteOrClose(branchToBeDeleted));
} }
@Test
public void shouldThrowViolationExceptionForInvalidBranchName() {
BranchRequest branchRequest = new BranchRequest();
branchRequest.setNewBranch("invalid..name");
GitBranchCommand command = createCommand();
assertThrows(ScmConstraintViolationException.class, () -> command.branch(branchRequest));
}
private GitBranchCommand createCommand() { private GitBranchCommand createCommand() {
return new GitBranchCommand(createContext(), hookContextFactory, eventBus, converterFactory); return new GitBranchCommand(createContext(), hookContextFactory, eventBus, converterFactory);
} }

View File

@@ -41,6 +41,7 @@ import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner; import org.mockito.junit.MockitoJUnitRunner;
import sonia.scm.ScmConstraintViolationException;
import sonia.scm.event.ScmEventBus; import sonia.scm.event.ScmEventBus;
import sonia.scm.repository.Changeset; import sonia.scm.repository.Changeset;
import sonia.scm.repository.GitChangesetConverter; import sonia.scm.repository.GitChangesetConverter;
@@ -63,6 +64,7 @@ import java.util.List;
import java.util.Optional; import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
@@ -184,6 +186,14 @@ public class GitTagCommandTest extends AbstractGitCommandTestBase {
.containsExactly("383b954b27e052db6880d57f1c860dc208795247"); .containsExactly("383b954b27e052db6880d57f1c860dc208795247");
} }
@Test
public void shouldThrowViolationExceptionForInvalidBranchName() {
TagCreateRequest tagRequest = new TagCreateRequest("592d797cd36432e591416e8b2b98154f4f163411", "invalid..name");
GitTagCommand command = createCommand();
assertThrows(ScmConstraintViolationException.class, () -> command.create(tagRequest));
}
private GitTagCommand createCommand() { private GitTagCommand createCommand() {
return new GitTagCommand(createContext(), hookContextFactory, eventBus, converterFactory); return new GitTagCommand(createContext(), hookContextFactory, eventBus, converterFactory);
} }

View File

@@ -28,7 +28,9 @@ export const isNameValid = (name: string) => {
return nameRegex.test(name); return nameRegex.test(name);
}; };
export const branchRegex = /^[\w-,;\]{}@&+=$#`|<>]([\w-,;\]{}@&+=$#`|<>/.]*[\w-,;\]{}@&+=$#`|<>])?$/; // See validation regex in Java class "Branch" for further details
export const branchRegex =
/^[^.\\\s[~^:?*](?:[^\\\s[~^:?*]*[^.\\\s[~^:?*])?(?:\/[^.\\\s[~^:?*](?:[^\\\s[~^:?*]*[^.\\\s[~^:?*])?)*$/;
export const isBranchValid = (name: string) => { export const isBranchValid = (name: string) => {
return branchRegex.test(name); return branchRegex.test(name);

View File

@@ -25,6 +25,7 @@ import React from "react";
import { WithTranslation, withTranslation } from "react-i18next"; import { WithTranslation, withTranslation } from "react-i18next";
import { Branch, Repository } from "@scm-manager/ui-types"; import { Branch, Repository } from "@scm-manager/ui-types";
import { Button, ButtonAddons } from "@scm-manager/ui-components"; import { Button, ButtonAddons } from "@scm-manager/ui-components";
import { encodePart } from "../../sources/components/content/FileLink";
type Props = WithTranslation & { type Props = WithTranslation & {
repository: Repository; repository: Repository;
@@ -35,10 +36,10 @@ class BranchButtonGroup extends React.Component<Props> {
render() { render() {
const { repository, branch, t } = this.props; const { repository, branch, t } = this.props;
const changesetLink = `/repo/${repository.namespace}/${repository.name}/branch/${encodeURIComponent( const changesetLink = `/repo/${repository.namespace}/${repository.name}/branch/${encodePart(
branch.name branch.name
)}/changesets/`; )}/changesets/`;
const sourcesLink = `/repo/${repository.namespace}/${repository.name}/sources/${encodeURIComponent(branch.name)}/`; const sourcesLink = `/repo/${repository.namespace}/${repository.name}/sources/${encodePart(branch.name)}/`;
return ( return (
<ButtonAddons> <ButtonAddons>

View File

@@ -33,6 +33,7 @@ import DefaultBranchTag from "./DefaultBranchTag";
import AheadBehindTag from "./AheadBehindTag"; import AheadBehindTag from "./AheadBehindTag";
import BranchCommitDateCommitter from "./BranchCommitDateCommitter"; import BranchCommitDateCommitter from "./BranchCommitDateCommitter";
import { useKeyboardIteratorTarget } from "@scm-manager/ui-shortcuts"; import { useKeyboardIteratorTarget } from "@scm-manager/ui-shortcuts";
import { encodePart } from "../../sources/components/content/FileLink";
type Props = { type Props = {
repository: Repository; repository: Repository;
@@ -63,7 +64,7 @@ const MobileFlowSpan = styled.span`
`; `;
const BranchRow: FC<Props> = ({ repository, baseUrl, branch, onDelete, details }) => { const BranchRow: FC<Props> = ({ repository, baseUrl, branch, onDelete, details }) => {
const to = `${baseUrl}/${encodeURIComponent(branch.name)}/info`; const to = `${baseUrl}/${encodePart(branch.name)}/info`;
const [t] = useTranslation("repos"); const [t] = useTranslation("repos");
const ref = useKeyboardIteratorTarget(); const ref = useKeyboardIteratorTarget();

View File

@@ -29,6 +29,7 @@ import { Repository } from "@scm-manager/ui-types";
import { ErrorNotification, Loading, Subtitle } from "@scm-manager/ui-components"; import { ErrorNotification, Loading, Subtitle } from "@scm-manager/ui-components";
import BranchForm from "../components/BranchForm"; import BranchForm from "../components/BranchForm";
import { useBranches, useCreateBranch } from "@scm-manager/ui-api"; import { useBranches, useCreateBranch } from "@scm-manager/ui-api";
import { encodePart } from "../../sources/components/content/FileLink";
type Props = { type Props = {
repository: Repository; repository: Repository;
@@ -54,7 +55,7 @@ const CreateBranch: FC<Props> = ({ repository }) => {
if (createdBranch) { if (createdBranch) {
return ( return (
<Redirect <Redirect
to={`/repo/${repository.namespace}/${repository.name}/branch/${encodeURIComponent(createdBranch.name)}/info`} to={`/repo/${repository.namespace}/${repository.name}/branch/${encodeURIComponent(encodePart(createdBranch.name))}/info`}
/> />
); );
} }

View File

@@ -29,6 +29,7 @@ import CodeActionBar from "../codeSection/components/CodeActionBar";
import { urls } from "@scm-manager/ui-components"; import { urls } from "@scm-manager/ui-components";
import Changesets from "./Changesets"; import Changesets from "./Changesets";
import { RepositoryRevisionContextProvider } from "@scm-manager/ui-api"; import { RepositoryRevisionContextProvider } from "@scm-manager/ui-api";
import { encodePart } from "../sources/components/content/FileLink";
type Props = { type Props = {
repository: Repository; repository: Repository;
@@ -53,14 +54,14 @@ const ChangesetRoot: FC<Props> = ({ repository, baseUrl, branches, selectedBranc
const evaluateSwitchViewLink = () => { const evaluateSwitchViewLink = () => {
if (selectedBranch) { if (selectedBranch) {
return `${baseUrl}/sources/${encodeURIComponent(selectedBranch)}/`; return `${baseUrl}/sources/${encodePart(selectedBranch)}/`;
} }
return `${baseUrl}/sources/`; return `${baseUrl}/sources/`;
}; };
const onSelectBranch = (branch?: Branch) => { const onSelectBranch = (branch?: Branch) => {
if (branch) { if (branch) {
history.push(`${baseUrl}/branch/${encodeURIComponent(branch.name)}/changesets/`); history.push(`${baseUrl}/branch/${encodePart(branch.name)}/changesets/`);
} else { } else {
history.push(`${baseUrl}/changesets/`); history.push(`${baseUrl}/changesets/`);
} }

View File

@@ -48,11 +48,10 @@ const isLocalRepository = (repositoryUrl: string) => {
}; };
export const encodePart = (part: string) => { export const encodePart = (part: string) => {
const encoded = encodeURIComponent(part);
if (part.includes("%")) { if (part.includes("%")) {
return encodeURIComponent(encoded); return encodeURIComponent(part.replace(/%/g, "%25"));
} }
return encoded; return encodeURIComponent(part);
}; };
export const createRelativeLink = (repositoryUrl: string) => { export const createRelativeLink = (repositoryUrl: string) => {

View File

@@ -34,6 +34,7 @@ import replaceBranchWithRevision from "../ReplaceBranchWithRevision";
import FileSearchButton from "../../codeSection/components/FileSearchButton"; import FileSearchButton from "../../codeSection/components/FileSearchButton";
import { isEmptyDirectory, isRootFile } from "../utils/files"; import { isEmptyDirectory, isRootFile } from "../utils/files";
import CompareLink from "../../compare/CompareLink"; import CompareLink from "../../compare/CompareLink";
import { encodePart } from "../components/content/FileLink";
type Props = { type Props = {
repository: Repository; repository: Repository;
@@ -65,7 +66,7 @@ const Sources: FC<Props> = ({ repository, branches, selectedBranch, baseUrl }) =
if (branches && branches.length > 0 && !selectedBranch) { if (branches && branches.length > 0 && !selectedBranch) {
const defaultBranch = branches?.filter((b) => b.defaultBranch === true)[0]; const defaultBranch = branches?.filter((b) => b.defaultBranch === true)[0];
history.replace( history.replace(
`${baseUrl}/sources/${defaultBranch ? encodeURIComponent(defaultBranch.name) : encodeURIComponent(branches[0].name)}/` `${baseUrl}/sources/${defaultBranch ? encodePart(defaultBranch.name) : encodePart(branches[0].name)}/`
); );
} }
}, [branches, selectedBranch, history, baseUrl]); }, [branches, selectedBranch, history, baseUrl]);
@@ -93,10 +94,10 @@ const Sources: FC<Props> = ({ repository, branches, selectedBranch, baseUrl }) =
let url; let url;
if (branch) { if (branch) {
if (path) { if (path) {
url = `${baseUrl}/sources/${encodeURIComponent(branch.name)}/${path}`; url = `${baseUrl}/sources/${encodePart(branch.name)}/${path}`;
url = !url.endsWith("/") ? url + "/" : url; url = !url.endsWith("/") ? url + "/" : url;
} else { } else {
url = `${baseUrl}/sources/${encodeURIComponent(branch.name)}/`; url = `${baseUrl}/sources/${encodePart(branch.name)}/`;
} }
} else { } else {
return; return;

View File

@@ -28,6 +28,7 @@ import classNames from "classnames";
import { Tag, Link } from "@scm-manager/ui-types"; import { Tag, Link } from "@scm-manager/ui-types";
import { Button, DateFromNow } from "@scm-manager/ui-components"; import { Button, DateFromNow } from "@scm-manager/ui-components";
import { useKeyboardIteratorTarget } from "@scm-manager/ui-shortcuts"; import { useKeyboardIteratorTarget } from "@scm-manager/ui-shortcuts";
import { encodePart } from "../../sources/components/content/FileLink";
type Props = { type Props = {
tag: Tag; tag: Tag;
@@ -47,7 +48,7 @@ const TagRow: FC<Props> = ({ tag, baseUrl, onDelete }) => {
); );
} }
const to = `${baseUrl}/${encodeURIComponent(tag.name)}/info`; const to = `${baseUrl}/${encodePart(tag.name)}/info`;
return ( return (
<tr> <tr>
<td className="is-vertical-align-middle"> <td className="is-vertical-align-middle">