fix: prevent ENAMETOOLONG crash for long parallel:matrix job names (#1865)
build / yamllint (push) Has been cancelled
build / test (push) Has been cancelled
build / CodeQL (push) Has been cancelled
build / smoke-test (1.2.23, 24) (push) Has been cancelled
build / smoke-test (1.2.23, 25) (push) Has been cancelled
build / smoke-test (1.3.10, 24) (push) Has been cancelled
build / smoke-test (1.3.10, 25) (push) Has been cancelled
build / typecheck (push) Has been cancelled
build / eslint (push) Has been cancelled
build / unused-deps (push) Has been cancelled

Co-authored-by: Gyan Ranjan A <gyan.a.ranjan@ericsson.com>
Co-authored-by: Mads Jon Nielsen <madsjon@gmail.com>
This commit is contained in:
Gyan Ranjan
2026-05-31 14:36:21 +05:30
committed by GitHub
parent 5e5c4aca57
commit 12063424d4
5 changed files with 107 additions and 1 deletions
+14 -1
View File
@@ -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";
@@ -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}`;
}
static safeBashString (s: string) {
@@ -0,0 +1 @@
.gitlab-ci-local-*
@@ -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
@@ -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
View File
@@ -266,3 +266,66 @@ describe("getServiceAlias", () => {
expect(Utils.getServiceAlias({...base, name: "library/nginx", alias: "my-nginx"})).toBe("my-nginx");
});
});
describe("safeDockerString", () => {
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
});
});