Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import checksum from "checksum";
import base64url from "base64url";
import execa, {ExecaError} from "execa";
import assert from "node:assert";
import {createHash} from "node:crypto";
import {CICDVariable} from "./variables-from-files.js";
import {GitData} from "./git-data.js";
import {globbySync} from "globby";
Expand Down Expand Up @@ -49,10 +50,22 @@ export class Utils {
return url.replace(/^https:\/\//g, "").replace(/^http:\/\//g, "");
}

// gcl-${safeJobName}-${jobId}-build → wrapper is 17 chars (jobId max 6 digits)
static readonly MAX_FILENAME_LENGTH = 255 - 17; // NAME_MAX (bytes) - wrapper

static safeDockerString (jobName: string) {
return jobName.replace(/[^\w-]+/g, (match) => {
// INVARIANT: \w without /u is ASCII-only ([A-Za-z0-9_]), so `encoded` is pure ASCII
// and .length === byte length. NAME_MAX is a byte limit — adding /u would break this.
// We hash `jobName` (not `encoded`) because base64url encoding isn't injective.
const encoded = jobName.replace(/[^\w-]+/g, (match) => {
return base64url.encode(match);
});
if (encoded.length <= Utils.MAX_FILENAME_LENGTH) {
return encoded;
}
const hash = createHash("sha256").update(jobName).digest("hex").substring(0, 16);
const prefix = encoded.substring(0, Utils.MAX_FILENAME_LENGTH - 1 - hash.length);
return `${prefix}-${hash}`;
Comment thread
firecow marked this conversation as resolved.
}

static safeBashString (s: string) {
Expand Down
1 change: 1 addition & 0 deletions tests/test-cases/parallel-matrix-long-name/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.gitlab-ci-local-*
12 changes: 12 additions & 0 deletions tests/test-cases/parallel-matrix-long-name/.gitlab-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
build-job:
stage: build
script:
- echo "APP='${APP}' $CI_NODE_INDEX/$CI_NODE_TOTAL"
parallel:
matrix:
- APP:
- "my-app-controller,My app controller to be used as reference for development teams,python311,\
controller,common,controller/setup.py,controller/setup_c.py,controller/setup_n.py,controller/tests/**/*,\
controller/coverage/*,controller/build/**/*,controller/coverage/coverage-unit.xml,75,true"
- short
17 changes: 17 additions & 0 deletions tests/test-cases/parallel-matrix-long-name/integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {WriteStreamsMock} from "../../../src/write-streams.js";
import {handler} from "../../../src/handler.js";

test.concurrent("parallel-matrix-long-name - completes without ENAMETOOLONG", async () => {
const writeStreams = new WriteStreamsMock();
await handler({
cwd: "tests/test-cases/parallel-matrix-long-name",
shellIsolation: true,
stateDir: ".gitlab-ci-local-parallel-matrix-long-name",
}, writeStreams);

// Both matrix entries must complete — the long one would crash with ENAMETOOLONG before the fix
const passing = writeStreams.stdoutLines.filter(l => l.includes(" PASS "));
expect(passing.length).toBe(2);
expect(writeStreams.stdoutLines.some(l => l.includes("build-job: [short]"))).toBe(true);
expect(writeStreams.stdoutLines.some(l => l.includes("build-job: [my-app-controller,"))).toBe(true);
});
63 changes: 63 additions & 0 deletions tests/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,3 +266,66 @@ describe("getServiceAlias", () => {
expect(Utils.getServiceAlias({...base, name: "library/nginx", alias: "my-nginx"})).toBe("my-nginx");
});
});

describe("safeDockerString", () => {
Comment thread
firecow marked this conversation as resolved.
it("should return encoded name unchanged when within limit", () => {
const result = Utils.safeDockerString("short-job-name");
expect(result).toBe("short-job-name");
});

it("should encode non-alphanumeric characters", () => {
const result = Utils.safeDockerString("job/name");
expect(result).toBe("jobLwname"); // '/' → 'Lw'
});

it("should truncate and hash when encoded name exceeds MAX_FILENAME_LENGTH", () => {
const longName = "my-group/common/python-unit-test: [my-app-controller,My app controller to be used as reference for development teams,python311,controller,common,controller/setup.py,controller/setup_c.py,controller/setup_n.py,controller/tests/**/*,controller/coverage/*,controller/build/**/*,controller/coverage/coverage-unit.xml,75,true]";
const result = Utils.safeDockerString(longName);
expect(result.length).toBeLessThanOrEqual(Utils.MAX_FILENAME_LENGTH);
});

it("should produce deterministic output for the same input", () => {
const longName = "a".repeat(50) + "/" + "b".repeat(200);
const result1 = Utils.safeDockerString(longName);
const result2 = Utils.safeDockerString(longName);
expect(result1).toBe(result2);
});

it("should produce different output for different long inputs", () => {
const name1 = "job: [" + "a".repeat(300) + "]";
const name2 = "job: [" + "b".repeat(300) + "]";
const result1 = Utils.safeDockerString(name1);
const result2 = Utils.safeDockerString(name2);
expect(result1).not.toBe(result2);
});

it("should handle extremely long job names (1000+ chars)", () => {
const extremeName = "group/subgroup/job: [" + "x".repeat(2000) + "]";
const result = Utils.safeDockerString(extremeName);
expect(result.length).toBeLessThanOrEqual(Utils.MAX_FILENAME_LENGTH);
expect(result.length).toBeGreaterThan(16); // has prefix + hash
});

it("should keep volume name within NAME_MAX=255 (worst-case suffix)", () => {
const longName = "my-group/common/python-unit-test: [" + "a/b/c,".repeat(100) + "]";
const safeJobName = Utils.safeDockerString(longName);
const worstCaseVolume = `gcl-${safeJobName}-999999-build`;
expect(worstCaseVolume.length).toBeLessThanOrEqual(255);
});

it("should not hash names that are exactly at the limit", () => {
// Create a name whose encoded form is exactly MAX_FILENAME_LENGTH
const name = "a".repeat(Utils.MAX_FILENAME_LENGTH);
const result = Utils.safeDockerString(name);
expect(result).toBe(name); // all alphanumeric, no encoding, no hash
});

it("should hash names whose encoded form is one char over the limit", () => {
// 'a' stays as 'a', '/' encodes to 'Lw' (2 chars)
// Build a string that encodes to exactly MAX_FILENAME_LENGTH + 1
const name = "a".repeat(Utils.MAX_FILENAME_LENGTH - 1) + "/"; // '/' -> 'Lw' = +2, total = MAX+1
const result = Utils.safeDockerString(name);
expect(result.length).toBeLessThanOrEqual(Utils.MAX_FILENAME_LENGTH);
expect(result).toContain("-"); // has hash separator
});
});