@zereight/mcp-gitlab 2.1.46 → 2.1.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -216,11 +216,17 @@ describe('GitLab MCP Server - Streamable HTTP Transport', () => {
216
216
  cleanup();
217
217
  console.log('Client disconnected from Streamable HTTP server');
218
218
  });
219
- test('should return 405 for GET /mcp', async () => {
219
+ test('should return 406 for GET /mcp without Accept: text/event-stream', async () => {
220
220
  const response = await fetch(`http://${HOST}:${port}/mcp`);
221
- assert.strictEqual(response.status, 405, 'GET /mcp should respond with 405');
221
+ assert.strictEqual(response.status, 406, 'GET /mcp should respond with 406 when Accept header is missing text/event-stream');
222
222
  const body = await response.json();
223
- assert.strictEqual(body?.error, 'Method Not Allowed');
223
+ assert.strictEqual(body?.error, 'Not Acceptable');
224
+ });
225
+ test('should return 405 for unsupported HTTP methods like PUT /mcp', async () => {
226
+ const response = await fetch(`http://${HOST}:${port}/mcp`, { method: 'PUT' });
227
+ assert.strictEqual(response.status, 405, 'PUT /mcp should respond with 405');
228
+ const allowHeader = response.headers.get('allow');
229
+ assert.ok(allowHeader?.includes('GET') && allowHeader?.includes('POST') && allowHeader?.includes('DELETE'), 'Allow header should contain GET, POST, DELETE');
224
230
  });
225
231
  test('should list tools via Streamable HTTP', async () => {
226
232
  const tools = await client.listTools();
@@ -2,6 +2,7 @@ import { describe, test, before, after } from 'node:test';
2
2
  import assert from 'node:assert';
3
3
  import { spawn } from 'node:child_process';
4
4
  import fs from 'node:fs';
5
+ import os from 'node:os';
5
6
  import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server.js';
6
7
  const MOCK_TOKEN = 'glpat-mock-token-12345';
7
8
  const TEST_PROJECT_ID = '123';
@@ -125,6 +126,32 @@ describe('download_attachment', () => {
125
126
  raw.result.content.some(c => typeof c.text === 'string' && c.text.toLowerCase().includes('traversal'));
126
127
  assert.ok(isRpcError || isContentError, 'Should return an error mentioning directory traversal');
127
128
  });
129
+ test('local_path through a symlink directory is rejected', async () => {
130
+ const linkDir = `omc-test-symlink-${RUN_ID}`;
131
+ fs.rmSync(linkDir, { recursive: true, force: true });
132
+ fs.symlinkSync(os.tmpdir(), linkDir);
133
+ try {
134
+ const raw = await callDownloadAttachment({
135
+ project_id: TEST_PROJECT_ID,
136
+ secret: TEST_SECRET,
137
+ filename: 'image.png',
138
+ local_path: linkDir,
139
+ }, env);
140
+ const msg = (typeof raw.error?.message === 'string' ? raw.error.message : '') +
141
+ (Array.isArray(raw.result?.content)
142
+ ? raw.result.content.map(c => c.text || '').join(' ')
143
+ : '');
144
+ const lower = msg.toLowerCase();
145
+ assert.ok(lower.includes('symbolic') ||
146
+ lower.includes('symlink') ||
147
+ lower.includes('escapes') ||
148
+ lower.includes('traversal') ||
149
+ lower.includes('invalid'), `Expected symlink rejection, got: ${msg}`);
150
+ }
151
+ finally {
152
+ fs.rmSync(linkDir, { recursive: true, force: true });
153
+ }
154
+ });
128
155
  test('filename with directory traversal is rejected before writing to disk', async () => {
129
156
  const raw = await callDownloadAttachment({ project_id: TEST_PROJECT_ID, secret: TEST_SECRET, filename: 'image.png#../../owned.txt' }, env);
130
157
  const isRpcError = typeof raw.error?.message === 'string' &&
@@ -0,0 +1,128 @@
1
+ import assert from "node:assert";
2
+ import { after, before, describe, test } from "node:test";
3
+ import { Buffer } from "node:buffer";
4
+ import { CustomHeaderClient } from "./clients/custom-header-client.js";
5
+ import { cleanupServers, findAvailablePort, HOST, launchServer, TransportMode, } from "./utils/server-launcher.js";
6
+ import { findMockServerPort, MockGitLabServer } from "./utils/mock-gitlab-server.js";
7
+ const MOCK_TOKEN = "glpat-get-file-contents-test-token";
8
+ const TEST_PROJECT_ID = "123";
9
+ describe("get_file_contents", { timeout: 20_000 }, () => {
10
+ let mockGitLab;
11
+ let server;
12
+ let mcpUrl;
13
+ before(async () => {
14
+ const binaryContent = Buffer.from([
15
+ 0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x37, 0x0a, 0xc7, 0xff, 0xd8, 0x00,
16
+ ]).toString("base64");
17
+ const mockPort = await findMockServerPort();
18
+ mockGitLab = new MockGitLabServer({
19
+ port: mockPort,
20
+ validTokens: [MOCK_TOKEN],
21
+ });
22
+ mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/repository/files/sample.pdf`, (_req, res) => {
23
+ res.json({
24
+ file_name: "sample.pdf",
25
+ file_path: "sample.pdf",
26
+ encoding: "base64",
27
+ content: binaryContent,
28
+ });
29
+ });
30
+ mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/repository/files/README.md`, (_req, res) => {
31
+ res.json({
32
+ file_name: "README.md",
33
+ file_path: "README.md",
34
+ encoding: "base64",
35
+ content: Buffer.from("Hello, 世界\n", "utf8").toString("base64"),
36
+ });
37
+ });
38
+ mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/repository/files/empty.txt`, (_req, res) => {
39
+ res.json({
40
+ file_name: "empty.txt",
41
+ file_path: "empty.txt",
42
+ encoding: "base64",
43
+ content: "",
44
+ });
45
+ });
46
+ await mockGitLab.start();
47
+ const port = await findAvailablePort(3480);
48
+ server = await launchServer({
49
+ mode: TransportMode.STREAMABLE_HTTP,
50
+ port,
51
+ timeout: 10_000,
52
+ env: {
53
+ STREAMABLE_HTTP: "true",
54
+ REMOTE_AUTHORIZATION: "true",
55
+ GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
56
+ },
57
+ });
58
+ mcpUrl = `http://${HOST}:${port}/mcp`;
59
+ });
60
+ after(async () => {
61
+ cleanupServers([server]);
62
+ if (mockGitLab)
63
+ await mockGitLab.stop();
64
+ });
65
+ test("preserves non-UTF-8 file content as base64", async () => {
66
+ const expectedContent = Buffer.from([
67
+ 0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x37, 0x0a, 0xc7, 0xff, 0xd8, 0x00,
68
+ ]).toString("base64");
69
+ const client = new CustomHeaderClient({ Authorization: `Bearer ${MOCK_TOKEN}` });
70
+ await client.connect(mcpUrl);
71
+ try {
72
+ const result = await Promise.race([
73
+ client.callTool("get_file_contents", {
74
+ project_id: TEST_PROJECT_ID,
75
+ file_path: "sample.pdf",
76
+ ref: "main",
77
+ }),
78
+ new Promise((_, reject) => {
79
+ const timer = setTimeout(() => reject(new Error("get_file_contents timed out")), 5_000);
80
+ timer.unref();
81
+ }),
82
+ ]);
83
+ const responseText = result.content?.[0]?.type === "text" ? result.content[0].text : "";
84
+ const file = JSON.parse(responseText);
85
+ assert.strictEqual(file.content, expectedContent);
86
+ assert.strictEqual(file.encoding, "base64");
87
+ }
88
+ finally {
89
+ await client.disconnect();
90
+ }
91
+ });
92
+ test("decodes valid UTF-8 file content", async () => {
93
+ const client = new CustomHeaderClient({ Authorization: `Bearer ${MOCK_TOKEN}` });
94
+ await client.connect(mcpUrl);
95
+ try {
96
+ const result = await client.callTool("get_file_contents", {
97
+ project_id: TEST_PROJECT_ID,
98
+ file_path: "README.md",
99
+ ref: "main",
100
+ });
101
+ const responseText = result.content?.[0]?.type === "text" ? result.content[0].text : "";
102
+ const file = JSON.parse(responseText);
103
+ assert.strictEqual(file.content, "Hello, 世界\n");
104
+ assert.strictEqual(file.encoding, "utf8");
105
+ }
106
+ finally {
107
+ await client.disconnect();
108
+ }
109
+ });
110
+ test("decodes empty file content as UTF-8", async () => {
111
+ const client = new CustomHeaderClient({ Authorization: `Bearer ${MOCK_TOKEN}` });
112
+ await client.connect(mcpUrl);
113
+ try {
114
+ const result = await client.callTool("get_file_contents", {
115
+ project_id: TEST_PROJECT_ID,
116
+ file_path: "empty.txt",
117
+ ref: "main",
118
+ });
119
+ const responseText = result.content?.[0]?.type === "text" ? result.content[0].text : "";
120
+ const file = JSON.parse(responseText);
121
+ assert.strictEqual(file.content, "");
122
+ assert.strictEqual(file.encoding, "utf8");
123
+ }
124
+ finally {
125
+ await client.disconnect();
126
+ }
127
+ });
128
+ });
@@ -123,8 +123,9 @@ describe('job artifacts tools', () => {
123
123
  });
124
124
  await mockGitLab.start();
125
125
  mockGitLabUrl = mockGitLab.getUrl();
126
- // Create a temp directory for download tests
127
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitlab-mcp-test-'));
126
+ // Relative temp dir — absolute local_path is rejected as traversal
127
+ tmpDir = `gitlab-mcp-test-artifacts-${process.pid}`;
128
+ fs.mkdirSync(tmpDir, { recursive: true });
128
129
  });
129
130
  after(async () => {
130
131
  await mockGitLab.stop();
@@ -167,6 +168,30 @@ describe('job artifacts tools', () => {
167
168
  assert.ok(fs.existsSync(result.file_path), `File should exist at ${result.file_path}`);
168
169
  assert.ok(fs.existsSync(nestedLocalPath), `Directory should be created at ${nestedLocalPath}`);
169
170
  });
171
+ test('download_job_artifacts rejects local_path with directory traversal', async () => {
172
+ await assert.rejects(() => callTool('download_job_artifacts', { project_id: TEST_PROJECT_ID, job_id: TEST_JOB_ID, local_path: '../../../tmp' }, {
173
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
174
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
175
+ }), (err) => {
176
+ const msg = String(err.message || err).toLowerCase();
177
+ return msg.includes('traversal') || msg.includes('invalid');
178
+ });
179
+ });
180
+ test('download_job_artifacts rejects symlink local_path escape', async () => {
181
+ const linkName = path.join(tmpDir, 'symlink-escape');
182
+ fs.symlinkSync(os.tmpdir(), linkName);
183
+ await assert.rejects(() => callTool('download_job_artifacts', { project_id: TEST_PROJECT_ID, job_id: TEST_JOB_ID, local_path: linkName }, {
184
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
185
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
186
+ }), (err) => {
187
+ const msg = String(err.message || err).toLowerCase();
188
+ return (msg.includes('symbolic') ||
189
+ msg.includes('symlink') ||
190
+ msg.includes('escapes') ||
191
+ msg.includes('traversal') ||
192
+ msg.includes('invalid'));
193
+ });
194
+ });
170
195
  test('get_job_artifact_file returns file content', async () => {
171
196
  const result = await callTool('get_job_artifact_file', {
172
197
  project_id: TEST_PROJECT_ID,
@@ -6,6 +6,7 @@ const MOCK_TOKEN = "glpat-mock-token-approval";
6
6
  const TEST_PROJECT_ID = "123";
7
7
  const TEST_MR_IID_WITH_FALLBACK = "88";
8
8
  const TEST_MR_IID_WITH_APPROVAL_STATE = "89";
9
+ const TEST_MR_IID_WITHOUT_RULES = "90";
9
10
  async function callTool(toolName, args, env) {
10
11
  return new Promise((resolve, reject) => {
11
12
  const proc = spawn("node", ["build/index.js"], {
@@ -133,6 +134,33 @@ describe("merge request approval state tools", () => {
133
134
  ],
134
135
  });
135
136
  });
137
+ // A project without approval rules answers 200 with an empty rules array, while
138
+ // the approval itself is only visible on /approvals.
139
+ mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/merge_requests/${TEST_MR_IID_WITHOUT_RULES}/approval_state`, (_req, res) => {
140
+ res.json({
141
+ approval_rules_overwritten: false,
142
+ rules: [],
143
+ });
144
+ });
145
+ mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/merge_requests/${TEST_MR_IID_WITHOUT_RULES}/approvals`, (_req, res) => {
146
+ res.json({
147
+ approved: true,
148
+ user_has_approved: false,
149
+ user_can_approve: true,
150
+ approved_by: [
151
+ {
152
+ user: {
153
+ id: "35",
154
+ username: "sergey.kravchenya",
155
+ name: "Sergey Kravchenya",
156
+ state: "active",
157
+ avatar_url: "https://gitlab.mock/uploads/avatar.png",
158
+ web_url: "https://gitlab.mock/sergey.kravchenya",
159
+ },
160
+ },
161
+ ],
162
+ });
163
+ });
136
164
  await mockGitLab.start();
137
165
  mockGitLabUrl = mockGitLab.getUrl();
138
166
  });
@@ -153,6 +181,20 @@ describe("merge request approval state tools", () => {
153
181
  assert.ok(Array.isArray(result.approved_by));
154
182
  assert.strictEqual(result.approved_by[0].username, "sergey.kravchenya");
155
183
  });
184
+ test("finds approvers via approvals when approval_state reports no rules", async () => {
185
+ const result = await callTool("get_merge_request_approval_state", {
186
+ project_id: TEST_PROJECT_ID,
187
+ merge_request_iid: TEST_MR_IID_WITHOUT_RULES,
188
+ }, {
189
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
190
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
191
+ });
192
+ assert.strictEqual(result.source_endpoint, "approvals");
193
+ assert.strictEqual(result.approved, true);
194
+ assert.deepStrictEqual(result.approved_by_usernames, ["sergey.kravchenya"]);
195
+ assert.ok(Array.isArray(result.approved_by));
196
+ assert.strictEqual(result.approved_by[0].username, "sergey.kravchenya");
197
+ });
156
198
  test("returns deduplicated approvers from approval_state rules", async () => {
157
199
  const result = await callTool("get_merge_request_approval_state", {
158
200
  project_id: TEST_PROJECT_ID,
@@ -89,7 +89,7 @@ describe('upload_markdown', () => {
89
89
  await mockGitLab.stop();
90
90
  });
91
91
  test('Content-Type is multipart/form-data with a boundary', async () => {
92
- const tmpFile = path.join(os.tmpdir(), 'mcp-upload-ct-test.txt');
92
+ const tmpFile = `mcp-upload-ct-test-${process.pid}.txt`;
93
93
  fs.writeFileSync(tmpFile, 'content-type test');
94
94
  try {
95
95
  await callUploadMarkdown({ project_id: TEST_PROJECT_ID, file_path: tmpFile }, env);
@@ -102,7 +102,7 @@ describe('upload_markdown', () => {
102
102
  }
103
103
  });
104
104
  test('multipart body contains a "file" field with the file content', async () => {
105
- const tmpFile = path.join(os.tmpdir(), 'mcp-upload-body-test.txt');
105
+ const tmpFile = `mcp-upload-body-test-${process.pid}.txt`;
106
106
  const fileContent = 'hello from multipart upload test';
107
107
  fs.writeFileSync(tmpFile, fileContent);
108
108
  try {
@@ -116,19 +116,19 @@ describe('upload_markdown', () => {
116
116
  }
117
117
  });
118
118
  test('multipart body includes the original filename', async () => {
119
- const tmpFile = path.join(os.tmpdir(), 'mcp-upload-filename-check.txt');
119
+ const tmpFile = `mcp-upload-filename-check-${process.pid}.txt`;
120
120
  fs.writeFileSync(tmpFile, 'filename check');
121
121
  try {
122
122
  await callUploadMarkdown({ project_id: TEST_PROJECT_ID, file_path: tmpFile }, env);
123
123
  assert.ok(lastRawBody, 'Request body must be captured');
124
- assert.ok(lastRawBody.includes('mcp-upload-filename-check.txt'), 'Multipart body should include the original filename');
124
+ assert.ok(lastRawBody.includes(`mcp-upload-filename-check-${process.pid}.txt`), 'Multipart body should include the original filename');
125
125
  }
126
126
  finally {
127
127
  fs.unlinkSync(tmpFile);
128
128
  }
129
129
  });
130
130
  test('returns markdown, url, alt, and full_path from upload response', async () => {
131
- const tmpFile = path.join(os.tmpdir(), 'mcp-upload-response-test.txt');
131
+ const tmpFile = `mcp-upload-response-test-${process.pid}.txt`;
132
132
  fs.writeFileSync(tmpFile, 'response field test');
133
133
  try {
134
134
  const raw = await callUploadMarkdown({ project_id: TEST_PROJECT_ID, file_path: tmpFile }, env);
@@ -148,7 +148,7 @@ describe('upload_markdown', () => {
148
148
  test('accepts upload responses without id from older self-hosted GitLab', async () => {
149
149
  const { id: _id, ...idlessUploadResponse } = MOCK_UPLOAD_RESPONSE;
150
150
  uploadResponse = idlessUploadResponse;
151
- const tmpFile = path.join(os.tmpdir(), 'mcp-upload-idless-response-test.txt');
151
+ const tmpFile = `mcp-upload-idless-response-test-${process.pid}.txt`;
152
152
  fs.writeFileSync(tmpFile, 'idless response field test');
153
153
  try {
154
154
  const raw = await callUploadMarkdown({ project_id: TEST_PROJECT_ID, file_path: tmpFile }, env);
@@ -167,9 +167,40 @@ describe('upload_markdown', () => {
167
167
  }
168
168
  });
169
169
  test('returns an error when the file does not exist', async () => {
170
- const raw = await callUploadMarkdown({ project_id: TEST_PROJECT_ID, file_path: '/nonexistent/no-such-file.txt' }, env);
170
+ const raw = await callUploadMarkdown({ project_id: TEST_PROJECT_ID, file_path: `mcp-upload-missing-${process.pid}.txt` }, env);
171
171
  const hasError = typeof raw.error?.message === 'string' ||
172
172
  raw.result?.content?.some(c => c.text && (c.text.toLowerCase().includes('not found') || c.text.toLowerCase().includes('error')));
173
173
  assert.ok(hasError, 'Should return an error for a nonexistent file path');
174
174
  });
175
+ test('rejects absolute file_path (path traversal / arbitrary read)', async () => {
176
+ const raw = await callUploadMarkdown({ project_id: TEST_PROJECT_ID, file_path: '/etc/passwd' }, env);
177
+ const msg = (typeof raw.error?.message === 'string' ? raw.error.message : '') +
178
+ (raw.result?.content?.map(c => c.text || '').join(' ') || '');
179
+ assert.ok(msg.toLowerCase().includes('traversal') || msg.toLowerCase().includes('invalid'), `Expected traversal rejection, got: ${msg}`);
180
+ });
181
+ test('rejects file_path with ".." traversal', async () => {
182
+ const raw = await callUploadMarkdown({ project_id: TEST_PROJECT_ID, file_path: '../../../etc/passwd' }, env);
183
+ const msg = (typeof raw.error?.message === 'string' ? raw.error.message : '') +
184
+ (raw.result?.content?.map(c => c.text || '').join(' ') || '');
185
+ assert.ok(msg.toLowerCase().includes('traversal') || msg.toLowerCase().includes('invalid'), `Expected traversal rejection, got: ${msg}`);
186
+ });
187
+ test('rejects file_path that is a symlink escape', async () => {
188
+ const linkName = `mcp-upload-symlink-${process.pid}.txt`;
189
+ const target = path.join(os.tmpdir(), `mcp-upload-secret-${process.pid}.txt`);
190
+ fs.writeFileSync(target, 'secret-content');
191
+ fs.symlinkSync(target, linkName);
192
+ try {
193
+ const raw = await callUploadMarkdown({ project_id: TEST_PROJECT_ID, file_path: linkName }, env);
194
+ const msg = (typeof raw.error?.message === 'string' ? raw.error.message : '') +
195
+ (raw.result?.content?.map(c => c.text || '').join(' ') || '');
196
+ assert.ok(msg.toLowerCase().includes('symbolic') ||
197
+ msg.toLowerCase().includes('symlink') ||
198
+ msg.toLowerCase().includes('escapes') ||
199
+ msg.toLowerCase().includes('invalid'), `Expected symlink rejection, got: ${msg}`);
200
+ }
201
+ finally {
202
+ fs.rmSync(linkName, { force: true });
203
+ fs.rmSync(target, { force: true });
204
+ }
205
+ });
175
206
  });
@@ -0,0 +1,104 @@
1
+ import { describe, test } from "node:test";
2
+ import assert from "node:assert";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { assertSafeRelativePath, openSafeOutputWriteStream, readSafeExistingFile, resolveSafeExistingPath, resolveSafeOutputDir, resolveSafeOutputFile, } from "../../utils/helpers.js";
7
+ describe("assertSafeRelativePath", () => {
8
+ test("allows relative paths", () => {
9
+ assert.strictEqual(assertSafeRelativePath("uploads"), "uploads");
10
+ assert.strictEqual(assertSafeRelativePath("uploads/nested"), path.normalize("uploads/nested"));
11
+ });
12
+ test("rejects absolute paths", () => {
13
+ assert.throws(() => assertSafeRelativePath("/etc/passwd", "file_path"), /traversal/);
14
+ });
15
+ test("rejects parent-directory traversal", () => {
16
+ assert.throws(() => assertSafeRelativePath("../../../tmp", "local_path"), /traversal/);
17
+ assert.throws(() => assertSafeRelativePath("foo/../../etc", "local_path"), /traversal/);
18
+ });
19
+ });
20
+ describe("resolveSafeExistingPath / resolveSafeOutputDir", () => {
21
+ let base;
22
+ test("setup base dir", () => {
23
+ base = fs.mkdtempSync(path.join(os.tmpdir(), "safe-path-"));
24
+ });
25
+ test("resolves an existing file inside the base", () => {
26
+ const file = path.join(base, "ok.txt");
27
+ fs.writeFileSync(file, "hello");
28
+ const resolved = resolveSafeExistingPath("ok.txt", "file_path", base);
29
+ assert.strictEqual(resolved, fs.realpathSync(file));
30
+ });
31
+ test("rejects symlink file escape on read", () => {
32
+ const target = path.join(os.tmpdir(), `outside-target-${process.pid}.txt`);
33
+ fs.writeFileSync(target, "secret");
34
+ fs.symlinkSync(target, path.join(base, "link.txt"));
35
+ try {
36
+ assert.throws(() => resolveSafeExistingPath("link.txt", "file_path", base), /symbolic links|escapes/);
37
+ }
38
+ finally {
39
+ fs.unlinkSync(target);
40
+ }
41
+ });
42
+ test("rejects symlink directory escape on write", () => {
43
+ fs.symlinkSync(os.tmpdir(), path.join(base, "escape-dir"));
44
+ assert.throws(() => resolveSafeOutputDir("escape-dir", "local_path", base), /symbolic links|escapes/);
45
+ });
46
+ test("creates nested output dirs that stay inside the base", () => {
47
+ const out = resolveSafeOutputDir(path.join("artifacts", "run-1"), "local_path", base);
48
+ const baseReal = fs.realpathSync(base);
49
+ const rel = path.relative(baseReal, out);
50
+ assert.ok(rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)));
51
+ assert.ok(fs.statSync(out).isDirectory());
52
+ });
53
+ test("resolveSafeOutputFile rejects destination file symlink", () => {
54
+ const outDir = path.join(base, "out");
55
+ fs.mkdirSync(outDir, { recursive: true });
56
+ const target = path.join(os.tmpdir(), `outside-write-${process.pid}.txt`);
57
+ fs.writeFileSync(target, "before");
58
+ fs.symlinkSync(target, path.join(outDir, "payload.bin"));
59
+ try {
60
+ assert.throws(() => resolveSafeOutputFile("payload.bin", "out", "local_path", base), /symbolic links/);
61
+ }
62
+ finally {
63
+ fs.unlinkSync(target);
64
+ }
65
+ });
66
+ test("resolveSafeOutputFile returns contained path for new files", () => {
67
+ const dest = resolveSafeOutputFile("fresh.bin", "downloads", "local_path", base);
68
+ assert.ok(dest.startsWith(fs.realpathSync(base) + path.sep));
69
+ assert.ok(dest.endsWith(`${path.sep}fresh.bin`));
70
+ });
71
+ test("anchors to cwd snapshot when baseDir is omitted", () => {
72
+ const nested = path.join(base, "anchored");
73
+ fs.mkdirSync(nested, { recursive: true });
74
+ const file = path.join(nested, "data.txt");
75
+ fs.writeFileSync(file, "anchored");
76
+ const originalCwd = process.cwd();
77
+ try {
78
+ process.chdir(nested);
79
+ const resolved = resolveSafeExistingPath("data.txt", "file_path");
80
+ assert.strictEqual(resolved, fs.realpathSync(file));
81
+ }
82
+ finally {
83
+ process.chdir(originalCwd);
84
+ }
85
+ });
86
+ test("readSafeExistingFile reads without returning a reusable path", () => {
87
+ const file = path.join(base, "inline.txt");
88
+ fs.writeFileSync(file, "payload");
89
+ const { buffer, basename } = readSafeExistingFile("inline.txt", "file_path", base);
90
+ assert.strictEqual(buffer.toString(), "payload");
91
+ assert.strictEqual(basename, "inline.txt");
92
+ });
93
+ test("openSafeOutputWriteStream writes without a separate createWriteStream call", async () => {
94
+ const { stream, path: dest } = openSafeOutputWriteStream("streamed.bin", "downloads", "local_path", base);
95
+ await new Promise((resolve, reject) => {
96
+ stream.on("error", reject);
97
+ stream.end("streamed", () => resolve());
98
+ });
99
+ assert.strictEqual(fs.readFileSync(dest, "utf8"), "streamed");
100
+ });
101
+ test("cleanup base dir", () => {
102
+ fs.rmSync(base, { recursive: true, force: true });
103
+ });
104
+ });