mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 04:38:11 +00:00
fix(stack-files): symlink-aware delete and chmod (#1214)
deleteStackPath now lstats the leaf and unlinks the link entry itself when it is a symbolic link, so the file the user clicked on in the tree is what gets removed (the linked target stays intact). chmodStackPath rejects with LINK_CHMOD_UNSUPPORTED on a symlink rather than silently mutating the target's permissions; Node's lchmod is macOS-only and following the link is the bug being fixed here. Path-component symlinks are still resolved via the existing resolveSafeStackPath, so a symlinked parent that escapes the stack dir still surfaces SYMLINK_ESCAPE before the leaf is inspected. Service-level tests cover delete on internal-target / external-target / broken / dir-target symlinks, chmod rejection on symlinks (including the broken case), and non-symlink regression checks. Route-level tests pin the 409 LINK_CHMOD_UNSUPPORTED mapping and the link-only-delete behaviour. The describe blocks are platform-gated; Windows symlink creation needs admin/developer-mode and is skipped along with the existing SYMLINK_ESCAPE test. Docs updated to describe both behaviours in plain product terms.
This commit is contained in:
@@ -942,3 +942,54 @@ describe('protected stack files', () => {
|
||||
if (res.status === 409) expect(res.body.code).toBe('PROTECTED_FILE');
|
||||
});
|
||||
});
|
||||
|
||||
// ── symlink semantics ────────────────────────────────────────────────────────
|
||||
// Symlink creation requires admin/developer-mode on Windows; skip on that
|
||||
// platform so the suite stays green where the OS denies the setup itself.
|
||||
|
||||
describe.skipIf(isWindows)('symlink semantics (Linux/macOS only)', () => {
|
||||
it('PUT /files/permissions returns 409 LINK_CHMOD_UNSUPPORTED on a symlink', async () => {
|
||||
const targetPath = path.join(stacksDir, STACK, 'symlink-target.txt');
|
||||
const linkPath = path.join(stacksDir, STACK, 'symlink-link.txt');
|
||||
await fs.writeFile(targetPath, 'payload');
|
||||
await fs.chmod(targetPath, 0o644);
|
||||
await fs.symlink(targetPath, linkPath);
|
||||
|
||||
try {
|
||||
const res = await request(app)
|
||||
.put(`/api/stacks/${STACK}/files/permissions`)
|
||||
.query({ path: 'symlink-link.txt' })
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ mode: 0o600 });
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.code).toBe('LINK_CHMOD_UNSUPPORTED');
|
||||
|
||||
const stat = await fs.stat(targetPath);
|
||||
expect(stat.mode & 0o777).toBe(0o644);
|
||||
} finally {
|
||||
await fs.unlink(linkPath).catch(() => {});
|
||||
await fs.unlink(targetPath).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it('DELETE /files removes a symlink and leaves the target intact', async () => {
|
||||
const targetPath = path.join(stacksDir, STACK, 'sym-delete-target.txt');
|
||||
const linkPath = path.join(stacksDir, STACK, 'sym-delete-link.txt');
|
||||
await fs.writeFile(targetPath, 'survives');
|
||||
await fs.symlink(targetPath, linkPath);
|
||||
|
||||
try {
|
||||
const res = await request(app)
|
||||
.delete(`/api/stacks/${STACK}/files`)
|
||||
.query({ path: 'sym-delete-link.txt' })
|
||||
.set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(204);
|
||||
|
||||
await expect(fs.lstat(linkPath)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
const targetContent = await fs.readFile(targetPath, 'utf-8');
|
||||
expect(targetContent).toBe('survives');
|
||||
} finally {
|
||||
await fs.unlink(targetPath).catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user