Skip to content

Commit 5a4e24d

Browse files
rpapaniclaude
andcommitted
fix(cm-client): preserve broken symlinks when zipping repositories
Replace zip-lib archiveFolder with direct yazl usage in zipRepository to handle broken (dangling) symlinks. zip-lib calls fs.stat() on symlink targets which throws ENOENT for broken symlinks, failing the entire code import. The new approach uses lstat to preserve symlinks as-is in the zip. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 29c7744 commit 5a4e24d

File tree

3 files changed

+223
-18
lines changed

3 files changed

+223
-18
lines changed

packages/spacecat-shared-cloud-manager-client/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"@adobe/spacecat-shared-ims-client": "1.11.6",
3939
"@adobe/spacecat-shared-utils": "1.81.1",
4040
"@aws-sdk/client-s3": "3.1014.0",
41+
"yazl": "3.3.1",
4142
"zip-lib": "1.2.3"
4243
},
4344
"devDependencies": {

packages/spacecat-shared-cloud-manager-client/src/index.js

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,17 @@
1212

1313
import { execFileSync } from 'child_process';
1414
import {
15-
existsSync, mkdtempSync, readdirSync, readFileSync,
15+
createWriteStream,
16+
existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync,
1617
readlinkSync, rmSync, statfsSync, writeFileSync,
1718
} from 'fs';
1819
import os from 'os';
1920
import path from 'path';
2021
import { hasText, tracingFetch as fetch } from '@adobe/spacecat-shared-utils';
2122
import { ImsClient } from '@adobe/spacecat-shared-ims-client';
2223
import { GetObjectCommand } from '@aws-sdk/client-s3';
23-
import { archiveFolder, extract } from 'zip-lib';
24+
import { extract } from 'zip-lib';
25+
import yazl from 'yazl';
2426

2527
const GIT_BIN = process.env.GIT_BIN_PATH || '/opt/bin/git';
2628
const CLONE_DIR_PREFIX = 'cm-repo-';
@@ -314,6 +316,7 @@ export default class CloudManagerClient {
314316
/**
315317
* Recursively validates that all symlinks under rootDir point to targets
316318
* within rootDir. Throws if any symlink escapes the root boundary.
319+
* Logs a warning for broken symlinks (target does not exist).
317320
* @param {string} dir - Directory to scan
318321
* @param {string} rootDir - The root boundary all symlink targets must stay within
319322
*/
@@ -329,12 +332,43 @@ export default class CloudManagerClient {
329332
`Symlink escapes repository root: ${path.relative(rootDir, fullPath)} -> ${target}`,
330333
);
331334
}
335+
if (!existsSync(resolved)) {
336+
this.log.warn(`Broken symlink: ${path.relative(rootDir, fullPath)} -> ${target} (target does not exist)`);
337+
}
332338
} else if (entry.isDirectory()) {
333339
this.#validateSymlinks(fullPath, rootDir);
334340
}
335341
}
336342
}
337343

344+
/**
345+
* Recursively walks a directory and adds all entries to a yazl ZipFile.
346+
* Uses lstat (not stat) so broken symlinks are preserved as-is without
347+
* following the link target.
348+
* @param {import('yazl').ZipFile} zip - yazl ZipFile instance
349+
* @param {string} dir - Current directory being walked
350+
* @param {string} rootDir - Repository root (for computing relative paths)
351+
*/
352+
#addDirToZip(zip, dir, rootDir) {
353+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
354+
const fullPath = path.join(dir, entry.name);
355+
const metadataPath = path.relative(rootDir, fullPath);
356+
357+
if (entry.isSymbolicLink()) {
358+
const linkTarget = readlinkSync(fullPath);
359+
const stat = lstatSync(fullPath);
360+
zip.addBuffer(Buffer.from(linkTarget), metadataPath, {
361+
mtime: stat.mtime,
362+
mode: stat.mode,
363+
});
364+
} else if (entry.isDirectory()) {
365+
this.#addDirToZip(zip, fullPath, rootDir);
366+
} else {
367+
zip.addFile(fullPath, metadataPath);
368+
}
369+
}
370+
}
371+
338372
/**
339373
* Zips the entire cloned repository including .git history.
340374
* Downstream services that read the ZIP from S3 need the full git history.
@@ -346,15 +380,30 @@ export default class CloudManagerClient {
346380
throw new Error(`Clone path does not exist: ${clonePath}`);
347381
}
348382

349-
// zip-lib is path-based (not buffer-based like adm-zip), so we write to
350-
// a temp file and read the result back into a Buffer for the caller.
383+
// yazl (and zip-lib) are path-based (not buffer-based like adm-zip), so we
384+
// write to a temp file and read the result back into a Buffer for the caller.
351385
const zipDir = mkdtempSync(path.join(os.tmpdir(), ZIP_DIR_PREFIX));
352386
const zipFile = path.join(zipDir, 'repo.zip');
353387

354388
try {
355389
this.log.info(`Zipping repository at ${clonePath}`);
356390
this.#validateSymlinks(clonePath, clonePath);
357-
await archiveFolder(clonePath, zipFile, { followSymlinks: false });
391+
392+
// Use yazl directly instead of zip-lib's archiveFolder because zip-lib
393+
// calls fs.stat() on symlink targets to determine file type, which fails
394+
// for broken symlinks (ENOENT). yazl with lstat preserves symlinks as-is.
395+
const zip = new yazl.ZipFile();
396+
this.#addDirToZip(zip, clonePath, clonePath);
397+
zip.end();
398+
399+
await new Promise((resolve, reject) => {
400+
const output = createWriteStream(zipFile);
401+
output.on('close', resolve);
402+
output.on('error', reject);
403+
zip.outputStream.on('error', reject);
404+
zip.outputStream.pipe(output);
405+
});
406+
358407
this.#logTmpDiskUsage('zip');
359408
return readFileSync(zipFile);
360409
} catch (error) {

packages/spacecat-shared-cloud-manager-client/test/cloud-manager-client.test.js

Lines changed: 168 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
/* eslint-env mocha */
1414

15+
import { EventEmitter } from 'events';
1516
import os from 'os';
1617
import path from 'path';
1718
import { expect, use } from 'chai';
@@ -97,25 +98,50 @@ function getGitArgsStr(call) {
9798
describe('CloudManagerClient', () => {
9899
let CloudManagerClient;
99100
const execFileSyncStub = sinon.stub();
101+
const createWriteStreamStub = sinon.stub();
100102
const existsSyncStub = sinon.stub();
103+
const lstatSyncStub = sinon.stub();
101104
const mkdtempSyncStub = sinon.stub();
102105
const readdirSyncStub = sinon.stub();
103106
const readFileSyncStub = sinon.stub().returns(Buffer.from('zip-content'));
104107
const readlinkSyncStub = sinon.stub();
105108
const rmSyncStub = sinon.stub();
106109
const statfsSyncStub = sinon.stub();
107110
const writeSyncStub = sinon.stub();
108-
const archiveFolderStub = sinon.stub().resolves();
109111
const extractStub = sinon.stub().resolves();
110112

113+
// Mock yazl ZipFile — records all entries and simulates piping to a write stream
114+
const mockZipEntries = [];
115+
const mockYazlZipFile = {
116+
addFile: sinon.stub().callsFake((filePath, metadataPath) => {
117+
mockZipEntries.push({ type: 'file', filePath, metadataPath });
118+
}),
119+
addBuffer: sinon.stub().callsFake((buffer, metadataPath, opts) => {
120+
mockZipEntries.push({
121+
type: 'buffer', buffer, metadataPath, opts,
122+
});
123+
}),
124+
end: sinon.stub(),
125+
outputStream: {
126+
on: sinon.stub().returnsThis(),
127+
pipe: sinon.stub().callsFake((writable) => {
128+
// Simulate async close after pipe
129+
process.nextTick(() => writable.emit('close'));
130+
}),
131+
},
132+
};
133+
const YazlMock = { ZipFile: sinon.stub().returns(mockYazlZipFile) };
134+
111135
// esmock's initial module resolution can exceed mocha's default 2s timeout
112136
// eslint-disable-next-line prefer-arrow-callback
113137
before(async function () {
114138
this.timeout(5000);
115139
const mod = await esmock('../src/index.js', {
116140
child_process: { execFileSync: execFileSyncStub },
117141
fs: {
142+
createWriteStream: createWriteStreamStub,
118143
existsSync: existsSyncStub,
144+
lstatSync: lstatSyncStub,
119145
mkdtempSync: mkdtempSyncStub,
120146
readdirSync: readdirSyncStub,
121147
readFileSync: readFileSyncStub,
@@ -124,7 +150,8 @@ describe('CloudManagerClient', () => {
124150
statfsSync: statfsSyncStub,
125151
writeFileSync: writeSyncStub,
126152
},
127-
'zip-lib': { archiveFolder: archiveFolderStub, extract: extractStub },
153+
'zip-lib': { extract: extractStub },
154+
yazl: YazlMock,
128155
}, {
129156
'@adobe/spacecat-shared-ims-client': {
130157
ImsClient: { createFrom: createFromStub },
@@ -136,8 +163,16 @@ describe('CloudManagerClient', () => {
136163
beforeEach(() => {
137164
execFileSyncStub.reset();
138165
execFileSyncStub.returns('');
166+
createWriteStreamStub.reset();
167+
createWriteStreamStub.callsFake(() => {
168+
const emitter = new EventEmitter();
169+
emitter.write = sinon.stub();
170+
emitter.end = sinon.stub();
171+
return emitter;
172+
});
139173
existsSyncStub.reset();
140174
existsSyncStub.returns(false);
175+
lstatSyncStub.reset();
141176
mkdtempSyncStub.reset();
142177
mkdtempSyncStub.callsFake((prefix) => `${prefix}XXXXXX`);
143178
readdirSyncStub.reset();
@@ -149,10 +184,21 @@ describe('CloudManagerClient', () => {
149184
statfsSyncStub.reset();
150185
statfsSyncStub.returns({ bsize: 4096, blocks: 131072, bfree: 65536 });
151186
writeSyncStub.reset();
152-
archiveFolderStub.reset();
153-
archiveFolderStub.resolves();
154187
extractStub.reset();
155188
extractStub.resolves();
189+
// Reset yazl mocks
190+
mockZipEntries.length = 0;
191+
YazlMock.ZipFile.reset();
192+
YazlMock.ZipFile.returns(mockYazlZipFile);
193+
mockYazlZipFile.addFile.reset();
194+
mockYazlZipFile.addBuffer.reset();
195+
mockYazlZipFile.end.reset();
196+
mockYazlZipFile.outputStream.on.reset();
197+
mockYazlZipFile.outputStream.on.returnsThis();
198+
mockYazlZipFile.outputStream.pipe.reset();
199+
mockYazlZipFile.outputStream.pipe.callsFake((writable) => {
200+
process.nextTick(() => writable.emit('close'));
201+
});
156202
createFromStub.reset();
157203
createFromStub.returns(mockImsClient);
158204
mockImsClient.getServiceAccessToken.reset();
@@ -878,10 +924,9 @@ describe('CloudManagerClient', () => {
878924
expect(mkdtempSyncStub).to.have.been.calledOnce;
879925
expect(mkdtempSyncStub.firstCall.args[0]).to.match(/cm-zip-$/);
880926

881-
// Should archive the folder with followSymlinks: false
882-
expect(archiveFolderStub).to.have.been.calledOnce;
883-
expect(archiveFolderStub.firstCall.args[0]).to.equal(clonePath);
884-
expect(archiveFolderStub.firstCall.args[2]).to.deep.equal({ followSymlinks: false });
927+
// Should use yazl to create the zip
928+
expect(YazlMock.ZipFile).to.have.been.calledOnce;
929+
expect(mockYazlZipFile.end).to.have.been.calledOnce;
885930

886931
// Should read the zip file into a buffer
887932
expect(readFileSyncStub).to.have.been.calledOnce;
@@ -907,23 +952,133 @@ describe('CloudManagerClient', () => {
907952
await expect(client.zipRepository(clonePath))
908953
.to.be.rejectedWith('Symlink escapes repository root: evil-link -> /etc/passwd');
909954

910-
// archiveFolder should never be called
911-
expect(archiveFolderStub).to.not.have.been.called;
955+
// yazl should never be used
956+
expect(YazlMock.ZipFile).to.not.have.been.called;
912957

913958
// Should still clean up the temp zip directory
914959
expect(rmSyncStub).to.have.been.calledOnce;
915960
expect(rmSyncStub.firstCall.args[0]).to.match(/cm-zip-/);
916961
});
917962

918-
it('throws when archiveFolder fails and cleans up temp dir', async () => {
963+
it('preserves broken symlinks as-is in the zip', async () => {
964+
const clonePath = '/tmp/cm-repo-zip-test';
965+
existsSyncStub.withArgs(clonePath).returns(true);
966+
967+
const enabledFarmsPath = path.join(clonePath, 'dispatcher', 'enabled_farms');
968+
const brokenLinkPath = path.join(enabledFarmsPath, 'broken.farm');
969+
const brokenTarget = '../available_farms/missing.farm';
970+
const resolvedTarget = path.resolve(enabledFarmsPath, brokenTarget);
971+
const symlinkMtime = new Date('2025-01-01');
972+
const symlinkMode = 0o120777;
973+
974+
// Root dir has a 'dispatcher' directory
975+
readdirSyncStub.withArgs(clonePath, { withFileTypes: true }).returns([{
976+
name: 'dispatcher',
977+
isSymbolicLink: () => false,
978+
isDirectory: () => true,
979+
}]);
980+
// dispatcher has 'enabled_farms' directory
981+
readdirSyncStub.withArgs(path.join(clonePath, 'dispatcher'), { withFileTypes: true }).returns([{
982+
name: 'enabled_farms',
983+
isSymbolicLink: () => false,
984+
isDirectory: () => true,
985+
}]);
986+
// enabled_farms has a broken symlink
987+
readdirSyncStub.withArgs(enabledFarmsPath, { withFileTypes: true }).returns([{
988+
name: 'broken.farm',
989+
isSymbolicLink: () => true,
990+
isDirectory: () => false,
991+
}]);
992+
readlinkSyncStub.withArgs(brokenLinkPath).returns(brokenTarget);
993+
lstatSyncStub.withArgs(brokenLinkPath).returns({ mtime: symlinkMtime, mode: symlinkMode });
994+
// Target does not exist — broken symlink
995+
existsSyncStub.withArgs(resolvedTarget).returns(false);
996+
997+
const ctx = createContext();
998+
const client = CloudManagerClient.createFrom(ctx);
999+
const result = await client.zipRepository(clonePath);
1000+
1001+
expect(Buffer.isBuffer(result)).to.be.true;
1002+
1003+
// Should log a warning about the broken symlink
1004+
expect(ctx.log.warn).to.have.been.calledWithMatch(/Broken symlink.*broken\.farm.*missing\.farm/);
1005+
1006+
// Should add the symlink via addBuffer with symlink mode bits preserved
1007+
expect(mockYazlZipFile.addBuffer).to.have.been.calledOnce;
1008+
const [buf, metadataPath, opts] = mockYazlZipFile.addBuffer.firstCall.args;
1009+
expect(buf.toString()).to.equal(brokenTarget);
1010+
expect(metadataPath).to.equal(path.relative(clonePath, brokenLinkPath));
1011+
expect(opts.mode).to.equal(symlinkMode);
1012+
expect(opts.mtime).to.equal(symlinkMtime);
1013+
});
1014+
1015+
it('adds regular files via yazl addFile', async () => {
9191016
const clonePath = '/tmp/cm-repo-zip-test';
9201017
existsSyncStub.withArgs(clonePath).returns(true);
921-
archiveFolderStub.rejects(new Error('failed to read directory'));
1018+
1019+
readdirSyncStub.withArgs(clonePath, { withFileTypes: true }).returns([{
1020+
name: 'index.html',
1021+
isSymbolicLink: () => false,
1022+
isDirectory: () => false,
1023+
}]);
1024+
1025+
const client = CloudManagerClient.createFrom(createContext());
1026+
await client.zipRepository(clonePath);
1027+
1028+
expect(mockYazlZipFile.addFile).to.have.been.calledOnce;
1029+
expect(mockYazlZipFile.addFile.firstCall.args[0]).to.equal(path.join(clonePath, 'index.html'));
1030+
expect(mockYazlZipFile.addFile.firstCall.args[1]).to.equal('index.html');
1031+
});
1032+
1033+
it('adds valid symlinks via addBuffer with lstat mode', async () => {
1034+
const clonePath = '/tmp/cm-repo-zip-test';
1035+
existsSyncStub.withArgs(clonePath).returns(true);
1036+
1037+
const subDir = path.join(clonePath, 'enabled');
1038+
const linkPath = path.join(subDir, 'link.farm');
1039+
const linkTarget = '../available/target.farm';
1040+
const resolvedTarget = path.resolve(subDir, linkTarget);
1041+
1042+
readdirSyncStub.withArgs(clonePath, { withFileTypes: true }).returns([{
1043+
name: 'enabled',
1044+
isSymbolicLink: () => false,
1045+
isDirectory: () => true,
1046+
}]);
1047+
readdirSyncStub.withArgs(subDir, { withFileTypes: true }).returns([{
1048+
name: 'link.farm',
1049+
isSymbolicLink: () => true,
1050+
isDirectory: () => false,
1051+
}]);
1052+
readlinkSyncStub.withArgs(linkPath).returns(linkTarget);
1053+
lstatSyncStub.withArgs(linkPath).returns({ mtime: new Date(), mode: 0o120777 });
1054+
existsSyncStub.withArgs(resolvedTarget).returns(true);
1055+
1056+
const client = CloudManagerClient.createFrom(createContext());
1057+
await client.zipRepository(clonePath);
1058+
1059+
// Symlink stored as buffer with link target content
1060+
expect(mockYazlZipFile.addBuffer).to.have.been.calledOnce;
1061+
expect(mockYazlZipFile.addBuffer.firstCall.args[0].toString()).to.equal(linkTarget);
1062+
});
1063+
1064+
it('throws when yazl zip fails and cleans up temp dir', async () => {
1065+
const clonePath = '/tmp/cm-repo-zip-test';
1066+
existsSyncStub.withArgs(clonePath).returns(true);
1067+
1068+
// Make the output stream emit an error
1069+
mockYazlZipFile.outputStream.pipe.callsFake((_) => {
1070+
process.nextTick(() => {
1071+
// Emit error on the outputStream error handler
1072+
const errorHandler = mockYazlZipFile.outputStream.on.getCalls()
1073+
.find((c) => c.args[0] === 'error');
1074+
if (errorHandler) errorHandler.args[1](new Error('write failed'));
1075+
});
1076+
});
9221077

9231078
const client = CloudManagerClient.createFrom(createContext());
9241079

9251080
await expect(client.zipRepository(clonePath))
926-
.to.be.rejectedWith('Failed to zip repository: failed to read directory');
1081+
.to.be.rejectedWith('Failed to zip repository: write failed');
9271082

9281083
// Should still clean up the temp zip directory
9291084
expect(rmSyncStub).to.have.been.calledOnce;

0 commit comments

Comments
 (0)