diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties
index bb038dfb..6601d92c 100644
--- a/server/skillhub-app/src/main/resources/messages.properties
+++ b/server/skillhub-app/src/main/resources/messages.properties
@@ -225,6 +225,7 @@ error.suite.publish.notPrivate=Only private Skill Suites can be published withou
error.suite.publish.notDraft=Skill Suite version {0} is not a draft
error.suite.displayName.required=Skill Suite display name is required
error.suite.version.required=Skill Suite version is required
+error.suite.version.invalid=Skill Suite version must use 1-64 portable characters: letters, numbers, dot, underscore, plus, or hyphen
error.suite.slug.exists=A Skill Suite with slug {0} already exists in this namespace
error.suite.access.denied=You do not have permission to view this Skill Suite
error.suite.install.unavailable=The Skill Suite cannot be installed because one or more members are unavailable
diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties
index add78f54..e43d42bf 100644
--- a/server/skillhub-app/src/main/resources/messages_zh.properties
+++ b/server/skillhub-app/src/main/resources/messages_zh.properties
@@ -225,6 +225,7 @@ error.suite.publish.notPrivate=只有私有技能套件可以免审核发布
error.suite.publish.notDraft=技能套件版本 {0} 不是草稿
error.suite.displayName.required=技能套件显示名称不能为空
error.suite.version.required=技能套件版本不能为空
+error.suite.version.invalid=技能套件版本须为 1-64 位,且只能包含字母、数字、点、下划线、加号或连字符
error.suite.slug.exists=当前命名空间已存在 slug 为 {0} 的技能套件
error.suite.access.denied=无权查看该技能套件
error.suite.install.unavailable=技能套件中存在当前不可用的成员,无法安装
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteDraftService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteDraftService.java
index c6a6587f..d76eb04f 100644
--- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteDraftService.java
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteDraftService.java
@@ -17,12 +17,15 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
+import java.util.regex.Pattern;
/** Creates Suite drafts after member coordinates have been resolved to exact Skill versions. */
@Service
public class SkillSuiteDraftService {
private static final Logger log = LoggerFactory.getLogger(SkillSuiteDraftService.class);
+ private static final Pattern PORTABLE_VERSION_PATTERN =
+ Pattern.compile("[A-Za-z0-9][A-Za-z0-9._+-]{0,63}");
private final SkillSuiteRepository suiteRepository;
private final SkillSuiteVersionRepository versionRepository;
@@ -202,6 +205,9 @@ public class SkillSuiteDraftService {
if (command.version() == null || command.version().isBlank()) {
throw new DomainBadRequestException("error.suite.version.required");
}
+ if (!PORTABLE_VERSION_PATTERN.matcher(command.version()).matches()) {
+ throw new DomainBadRequestException("error.suite.version.invalid");
+ }
SkillSuiteCompositionPolicy.validate(command.members(), command.entrySkillVersionId());
}
diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteDraftServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteDraftServiceTest.java
index 73a7ff5f..579d0277 100644
--- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteDraftServiceTest.java
+++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteDraftServiceTest.java
@@ -4,6 +4,7 @@ import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
+import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -17,6 +18,7 @@ import java.util.Optional;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -126,6 +128,28 @@ class SkillSuiteDraftServiceTest {
verify(publicationValidator).validate(suite, version);
}
+ @Test
+ void rejectsSuiteVersionsThatCannotBeUsedSafelyAcrossCliShells() {
+ Namespace namespace = new Namespace("team", "Team", "owner");
+ setId(namespace, 1L);
+ when(namespaceRepository.findById(1L)).thenReturn(Optional.of(namespace));
+ SkillSuiteActionContext context = new SkillSuiteActionContext(
+ "author", Map.of(1L, NamespaceRole.MEMBER), Set.of(),
+ "request-3", "127.0.0.1", "test");
+
+ for (String version : List.of("1.0.0; touch pwned", "a".repeat(65))) {
+ CreateSkillSuiteDraftCommand command = new CreateSkillSuiteDraftCommand(
+ 1L, "writers", "Writers", "Summary", null, version,
+ SkillVisibility.PUBLIC, null, 40L,
+ List.of(new SkillSuiteMemberSelection(
+ 30L, 40L, "global", "writer", "1.0.0", "sha256:abc")));
+
+ assertThatThrownBy(() -> service.create(command, context))
+ .isInstanceOf(DomainBadRequestException.class)
+ .hasMessage("error.suite.version.invalid");
+ }
+ }
+
private void setId(Object target, Long id) {
try {
var field = target.getClass().getDeclaredField("id");
diff --git a/web/src/pages/suite-detail.test.tsx b/web/src/pages/suite-detail.test.tsx
index c277eef5..4bf9795c 100644
--- a/web/src/pages/suite-detail.test.tsx
+++ b/web/src/pages/suite-detail.test.tsx
@@ -189,4 +189,17 @@ describe('SuiteDetailPage', () => {
)).not.toBeNull()
expect(within(sidebar).getByLabelText('suite.copyInstallCommand')).not.toBeNull()
})
+
+ it('does not expose a copyable shell command for an unsafe legacy version', () => {
+ const unsafeSuite = suite()
+ unsafeSuite.version = '1.0.0; touch pwned'
+ mocks.detail = { data: unsafeSuite, isLoading: false, error: null }
+
+ render(
{command}
-
- {command}
+
+ + {t('skillDetail.installCommandUnsafeVersion')} +
+ )} {versions?.length ? (