Skip to content

Commit b90cf02

Browse files
Copilotfgreinacher
andcommitted
feat: validate generic package asset labels for GitLab API restrictions
Co-authored-by: fgreinacher <[email protected]>
1 parent 24023a7 commit b90cf02

File tree

4 files changed

+242
-12
lines changed

4 files changed

+242
-12
lines changed

lib/definitions/errors.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,19 @@ Your configuration for the \`labels\` option is \`${stringify(labels)}\`.`,
3939
4040
Your configuration for the \`assignee\` option is \`${stringify(assignee)}\`.`,
4141
}),
42+
EINVALIDGENERICPACKAGELABEL: ({label, asset}) => ({
43+
message: 'Invalid label for generic package asset.',
44+
details: `The label \`${label}\` for a generic package asset is invalid. GitLab generic package filenames must follow these rules:
45+
46+
- Can contain: Letters (A-Z, a-z), Numbers (0-9), and special characters: . _ - + ~ @ /
47+
- Cannot start with: ~ or @
48+
- Cannot end with: ~ or @
49+
- Cannot contain spaces
50+
51+
Your asset configuration: \`${stringify(asset)}\`
52+
53+
Please update the label to comply with [GitLab's package naming restrictions](https://docs.gitlab.com/ee/user/packages/generic_packages/#publish-a-package-file).`,
54+
}),
4255
EINVALIDGITLABURL: () => ({
4356
message: 'The git repository URL is not a valid GitLab URL.',
4457
details: `The **semantic-release** \`repositoryUrl\` option must a valid GitLab URL with the format \`<GitLab_URL>/<projectPath>.git\`.

lib/verify.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,33 @@ export default async (pluginConfig, context) => {
4545
.filter(([option, value]) => !isValid(option, value))
4646
.map(([option, value]) => getError(`EINVALID${option.toUpperCase()}`, { [option]: value }));
4747

48+
// Validate generic package labels
49+
if (options.assets && Array.isArray(options.assets)) {
50+
const isValidGenericPackageLabel = (label) => {
51+
// GitLab generic package filename restrictions
52+
// Can contain: A-Z, a-z, 0-9, . _ - + ~ @ /
53+
// Cannot start with: ~ or @
54+
// Cannot end with: ~ or @
55+
// Cannot contain spaces
56+
if (!label) return true; // label is optional
57+
if (typeof label !== "string") return false;
58+
if (/\s/.test(label)) return false; // no spaces
59+
if (/^[~@]/.test(label)) return false; // cannot start with ~ or @
60+
if (/[~@]$/.test(label)) return false; // cannot end with ~ or @
61+
// Check if it only contains allowed characters
62+
if (!/^[A-Za-z0-9._\-+~@/]+$/.test(label)) return false;
63+
return true;
64+
};
65+
66+
options.assets.forEach((asset) => {
67+
if (isPlainObject(asset) && asset.target === "generic_package" && asset.label) {
68+
if (!isValidGenericPackageLabel(asset.label)) {
69+
errors.push(getError("EINVALIDGENERICPACKAGELABEL", { label: asset.label, asset }));
70+
}
71+
}
72+
});
73+
}
74+
4875
if (!projectPath) {
4976
errors.push(getError("EINVALIDGITLABURL"));
5077
}

test/publish.test.js

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ test.serial("Publish a release with generics", async (t) => {
141141
const encodedGitTag = encodeURIComponent(nextRelease.gitTag);
142142
const encodedVersion = encodeURIComponent(nextRelease.version);
143143
const uploaded = { file: { url: "/uploads/file.css" } };
144-
const generic = { path: "file.css", label: "Style package", target: "generic_package", status: "hidden" };
144+
const generic = { path: "file.css", label: "style-package", target: "generic_package", status: "hidden" };
145145
const assets = [generic];
146146
const encodedLabel = encodeURIComponent(generic.label);
147147
const expectedUrl = `https://gitlab.com/api/v4/projects/${encodedProjectPath}/packages/generic/release/${encodedVersion}/${encodedLabel}`;
@@ -152,7 +152,7 @@ test.serial("Publish a release with generics", async (t) => {
152152
assets: {
153153
links: [
154154
{
155-
name: "Style package",
155+
name: "style-package",
156156
url: expectedUrl,
157157
link_type: "package",
158158
},
@@ -189,7 +189,7 @@ test.serial("Publish a release with generics: with asset.packageName (fixed text
189189
const uploaded = { file: { url: "/uploads/file.css" } };
190190
const generic = {
191191
path: "file.css",
192-
label: "Style package",
192+
label: "style-package",
193193
target: "generic_package",
194194
status: "hidden",
195195
packageName: "microk8s",
@@ -205,7 +205,7 @@ test.serial("Publish a release with generics: with asset.packageName (fixed text
205205
assets: {
206206
links: [
207207
{
208-
name: "Style package",
208+
name: "style-package",
209209
url: expectedUrl,
210210
link_type: "package",
211211
},
@@ -248,7 +248,7 @@ test.serial("Publish a release with generics: with asset.packageName (template)"
248248
const uploaded = { file: { url: "/uploads/file.css" } };
249249
const generic = {
250250
path: "file.css",
251-
label: "Style package",
251+
label: "style-package",
252252
target: "generic_package",
253253
status: "hidden",
254254
packageName: "${nextRelease.channel}",
@@ -263,7 +263,7 @@ test.serial("Publish a release with generics: with asset.packageName (template)"
263263
assets: {
264264
links: [
265265
{
266-
name: "Style package",
266+
name: "style-package",
267267
url: expectedUrl,
268268
link_type: "package",
269269
},
@@ -344,7 +344,7 @@ test.serial("Publish a release with generics and external storage provider (http
344344
const encodedGitTag = encodeURIComponent(nextRelease.gitTag);
345345
const encodedVersion = encodeURIComponent(nextRelease.version);
346346
const uploaded = { file: { url: "http://aws.example.com/bucket/gitlab/file.css" } };
347-
const generic = { path: "file.css", label: "Style package", target: "generic_package", status: "hidden" };
347+
const generic = { path: "file.css", label: "style-package", target: "generic_package", status: "hidden" };
348348
const assets = [generic];
349349
const encodedLabel = encodeURIComponent(generic.label);
350350
const expectedUrl = `https://gitlab.com/api/v4/projects/${encodedProjectPath}/packages/generic/release/${encodedVersion}/${encodedLabel}`;
@@ -355,7 +355,7 @@ test.serial("Publish a release with generics and external storage provider (http
355355
assets: {
356356
links: [
357357
{
358-
name: "Style package",
358+
name: "style-package",
359359
url: expectedUrl,
360360
link_type: "package",
361361
},
@@ -390,7 +390,7 @@ test.serial("Publish a release with generics and external storage provider (http
390390
const encodedGitTag = encodeURIComponent(nextRelease.gitTag);
391391
const encodedVersion = encodeURIComponent(nextRelease.version);
392392
const uploaded = { file: { url: "https://aws.example.com/bucket/gitlab/file.css" } };
393-
const generic = { path: "file.css", label: "Style package", target: "generic_package", status: "hidden" };
393+
const generic = { path: "file.css", label: "style-package", target: "generic_package", status: "hidden" };
394394
const assets = [generic];
395395
const encodedLabel = encodeURIComponent(generic.label);
396396
const expectedUrl = `https://gitlab.com/api/v4/projects/${encodedProjectPath}/packages/generic/release/${encodedVersion}/${encodedLabel}`;
@@ -401,7 +401,7 @@ test.serial("Publish a release with generics and external storage provider (http
401401
assets: {
402402
links: [
403403
{
404-
name: "Style package",
404+
name: "style-package",
405405
url: expectedUrl,
406406
link_type: "package",
407407
},
@@ -436,7 +436,7 @@ test.serial("Publish a release with generics and external storage provider (ftp)
436436
const encodedGitTag = encodeURIComponent(nextRelease.gitTag);
437437
const encodedVersion = encodeURIComponent(nextRelease.version);
438438
const uploaded = { file: { url: "ftp://drive.example.com/gitlab/file.css" } };
439-
const generic = { path: "file.css", label: "Style package", target: "generic_package", status: "hidden" };
439+
const generic = { path: "file.css", label: "style-package", target: "generic_package", status: "hidden" };
440440
const assets = [generic];
441441
const encodedLabel = encodeURIComponent(generic.label);
442442
const expectedUrl = `https://gitlab.com/api/v4/projects/${encodedProjectPath}/packages/generic/release/${encodedVersion}/${encodedLabel}`;
@@ -447,7 +447,7 @@ test.serial("Publish a release with generics and external storage provider (ftp)
447447
assets: {
448448
links: [
449449
{
450-
name: "Style package",
450+
name: "style-package",
451451
url: expectedUrl,
452452
link_type: "package",
453453
},

test/verify.test.js

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -988,3 +988,193 @@ test.serial(
988988
t.true(gitlab.isDone());
989989
}
990990
);
991+
992+
test.serial("Throw SemanticReleaseError if generic package asset label contains spaces", async (t) => {
993+
const owner = "test_user";
994+
const repo = "test_repo";
995+
const env = { GITLAB_TOKEN: "gitlab_token" };
996+
const assets = [{ path: "file.css", label: "Style package", target: "generic_package" }];
997+
const gitlab = authenticate(env)
998+
.get(`/projects/${owner}%2F${repo}`)
999+
.reply(200, { permissions: { project_access: { access_level: 40 } } });
1000+
1001+
const {
1002+
errors: [error],
1003+
} = await t.throwsAsync(
1004+
verify(
1005+
{ assets },
1006+
{ env, options: { repositoryUrl: `https://gitlab.com/${owner}/${repo}.git` }, logger: t.context.logger }
1007+
)
1008+
);
1009+
t.is(error.name, "SemanticReleaseError");
1010+
t.is(error.code, "EINVALIDGENERICPACKAGELABEL");
1011+
t.true(gitlab.isDone());
1012+
});
1013+
1014+
test.serial("Throw SemanticReleaseError if generic package asset label starts with ~", async (t) => {
1015+
const owner = "test_user";
1016+
const repo = "test_repo";
1017+
const env = { GITLAB_TOKEN: "gitlab_token" };
1018+
const assets = [{ path: "file.css", label: "~invalid", target: "generic_package" }];
1019+
const gitlab = authenticate(env)
1020+
.get(`/projects/${owner}%2F${repo}`)
1021+
.reply(200, { permissions: { project_access: { access_level: 40 } } });
1022+
1023+
const {
1024+
errors: [error],
1025+
} = await t.throwsAsync(
1026+
verify(
1027+
{ assets },
1028+
{ env, options: { repositoryUrl: `https://gitlab.com/${owner}/${repo}.git` }, logger: t.context.logger }
1029+
)
1030+
);
1031+
t.is(error.name, "SemanticReleaseError");
1032+
t.is(error.code, "EINVALIDGENERICPACKAGELABEL");
1033+
t.true(gitlab.isDone());
1034+
});
1035+
1036+
test.serial("Throw SemanticReleaseError if generic package asset label starts with @", async (t) => {
1037+
const owner = "test_user";
1038+
const repo = "test_repo";
1039+
const env = { GITLAB_TOKEN: "gitlab_token" };
1040+
const assets = [{ path: "file.css", label: "@invalid", target: "generic_package" }];
1041+
const gitlab = authenticate(env)
1042+
.get(`/projects/${owner}%2F${repo}`)
1043+
.reply(200, { permissions: { project_access: { access_level: 40 } } });
1044+
1045+
const {
1046+
errors: [error],
1047+
} = await t.throwsAsync(
1048+
verify(
1049+
{ assets },
1050+
{ env, options: { repositoryUrl: `https://gitlab.com/${owner}/${repo}.git` }, logger: t.context.logger }
1051+
)
1052+
);
1053+
t.is(error.name, "SemanticReleaseError");
1054+
t.is(error.code, "EINVALIDGENERICPACKAGELABEL");
1055+
t.true(gitlab.isDone());
1056+
});
1057+
1058+
test.serial("Throw SemanticReleaseError if generic package asset label ends with ~", async (t) => {
1059+
const owner = "test_user";
1060+
const repo = "test_repo";
1061+
const env = { GITLAB_TOKEN: "gitlab_token" };
1062+
const assets = [{ path: "file.css", label: "invalid~", target: "generic_package" }];
1063+
const gitlab = authenticate(env)
1064+
.get(`/projects/${owner}%2F${repo}`)
1065+
.reply(200, { permissions: { project_access: { access_level: 40 } } });
1066+
1067+
const {
1068+
errors: [error],
1069+
} = await t.throwsAsync(
1070+
verify(
1071+
{ assets },
1072+
{ env, options: { repositoryUrl: `https://gitlab.com/${owner}/${repo}.git` }, logger: t.context.logger }
1073+
)
1074+
);
1075+
t.is(error.name, "SemanticReleaseError");
1076+
t.is(error.code, "EINVALIDGENERICPACKAGELABEL");
1077+
t.true(gitlab.isDone());
1078+
});
1079+
1080+
test.serial("Throw SemanticReleaseError if generic package asset label ends with @", async (t) => {
1081+
const owner = "test_user";
1082+
const repo = "test_repo";
1083+
const env = { GITLAB_TOKEN: "gitlab_token" };
1084+
const assets = [{ path: "file.css", label: "invalid@", target: "generic_package" }];
1085+
const gitlab = authenticate(env)
1086+
.get(`/projects/${owner}%2F${repo}`)
1087+
.reply(200, { permissions: { project_access: { access_level: 40 } } });
1088+
1089+
const {
1090+
errors: [error],
1091+
} = await t.throwsAsync(
1092+
verify(
1093+
{ assets },
1094+
{ env, options: { repositoryUrl: `https://gitlab.com/${owner}/${repo}.git` }, logger: t.context.logger }
1095+
)
1096+
);
1097+
t.is(error.name, "SemanticReleaseError");
1098+
t.is(error.code, "EINVALIDGENERICPACKAGELABEL");
1099+
t.true(gitlab.isDone());
1100+
});
1101+
1102+
test.serial("Throw SemanticReleaseError if generic package asset label contains invalid characters", async (t) => {
1103+
const owner = "test_user";
1104+
const repo = "test_repo";
1105+
const env = { GITLAB_TOKEN: "gitlab_token" };
1106+
const assets = [{ path: "file.css", label: "invalid$char", target: "generic_package" }];
1107+
const gitlab = authenticate(env)
1108+
.get(`/projects/${owner}%2F${repo}`)
1109+
.reply(200, { permissions: { project_access: { access_level: 40 } } });
1110+
1111+
const {
1112+
errors: [error],
1113+
} = await t.throwsAsync(
1114+
verify(
1115+
{ assets },
1116+
{ env, options: { repositoryUrl: `https://gitlab.com/${owner}/${repo}.git` }, logger: t.context.logger }
1117+
)
1118+
);
1119+
t.is(error.name, "SemanticReleaseError");
1120+
t.is(error.code, "EINVALIDGENERICPACKAGELABEL");
1121+
t.true(gitlab.isDone());
1122+
});
1123+
1124+
test.serial("Does not throw for valid generic package asset labels", async (t) => {
1125+
const owner = "test_user";
1126+
const repo = "test_repo";
1127+
const env = { GITLAB_TOKEN: "gitlab_token" };
1128+
const assets = [
1129+
{ path: "file.css", label: "valid-label_123.tar.gz", target: "generic_package" },
1130+
{ path: "file2.css", label: "another.valid+label~0", target: "generic_package" },
1131+
{ path: "file3.css", label: "path/to/file", target: "generic_package" },
1132+
];
1133+
const gitlab = authenticate(env)
1134+
.get(`/projects/${owner}%2F${repo}`)
1135+
.reply(200, { permissions: { project_access: { access_level: 40 } } });
1136+
1137+
await t.notThrowsAsync(
1138+
verify(
1139+
{ assets },
1140+
{ env, options: { repositoryUrl: `https://gitlab.com/${owner}/${repo}.git` }, logger: t.context.logger }
1141+
)
1142+
);
1143+
t.true(gitlab.isDone());
1144+
});
1145+
1146+
test.serial("Does not throw for generic package assets without labels", async (t) => {
1147+
const owner = "test_user";
1148+
const repo = "test_repo";
1149+
const env = { GITLAB_TOKEN: "gitlab_token" };
1150+
const assets = [{ path: "file.css", target: "generic_package" }];
1151+
const gitlab = authenticate(env)
1152+
.get(`/projects/${owner}%2F${repo}`)
1153+
.reply(200, { permissions: { project_access: { access_level: 40 } } });
1154+
1155+
await t.notThrowsAsync(
1156+
verify(
1157+
{ assets },
1158+
{ env, options: { repositoryUrl: `https://gitlab.com/${owner}/${repo}.git` }, logger: t.context.logger }
1159+
)
1160+
);
1161+
t.true(gitlab.isDone());
1162+
});
1163+
1164+
test.serial("Does not throw for non-generic package assets with spaces in labels", async (t) => {
1165+
const owner = "test_user";
1166+
const repo = "test_repo";
1167+
const env = { GITLAB_TOKEN: "gitlab_token" };
1168+
const assets = [{ path: "file.css", label: "Valid Label With Spaces" }];
1169+
const gitlab = authenticate(env)
1170+
.get(`/projects/${owner}%2F${repo}`)
1171+
.reply(200, { permissions: { project_access: { access_level: 40 } } });
1172+
1173+
await t.notThrowsAsync(
1174+
verify(
1175+
{ assets },
1176+
{ env, options: { repositoryUrl: `https://gitlab.com/${owner}/${repo}.git` }, logger: t.context.logger }
1177+
)
1178+
);
1179+
t.true(gitlab.isDone());
1180+
});

0 commit comments

Comments
 (0)