mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 10:49:35 +00:00
fix: allow chmod on protected stack files (#1772)
* fix: allow chmod on protected stack files Identity protection still blocks delete, rename, and copy-onto-reserved-name for compose and .env at the stack root. Permission changes are ordinary edits and must succeed from the explorer. * fix: gate chmod on compose files during stack ops Chmod on compose filenames and .blueprint.json now follows the same stack-op lock as content writes and uploads. Document allowed blueprint chmod and that content saves reset mode bits.
This commit is contained in:
@@ -819,6 +819,50 @@ describe('FileSystemService stack methods', () => {
|
||||
expect(await fs.readFile(externalFile, 'utf-8')).toBe('external');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chmod on protected stack files', () => {
|
||||
it('chmodStackPath succeeds on .env', async () => {
|
||||
const envPath = path.join(stackDir, '.env');
|
||||
await fs.writeFile(envPath, 'KEY=val\n');
|
||||
await fs.chmod(envPath, 0o644);
|
||||
|
||||
const service = FileSystemService.getInstance();
|
||||
await service.chmodStackPath(STACK, '.env', 0o600);
|
||||
|
||||
if (!isWindows) {
|
||||
const stat = await fs.stat(envPath);
|
||||
expect(stat.mode & 0o777).toBe(0o600);
|
||||
}
|
||||
});
|
||||
|
||||
it('chmodStackPath succeeds on compose.yaml', async () => {
|
||||
const composePath = path.join(stackDir, 'compose.yaml');
|
||||
await fs.writeFile(composePath, 'services: {}\n');
|
||||
await fs.chmod(composePath, 0o644);
|
||||
|
||||
const service = FileSystemService.getInstance();
|
||||
await service.chmodStackPath(STACK, 'compose.yaml', 0o600);
|
||||
|
||||
if (!isWindows) {
|
||||
const stat = await fs.stat(composePath);
|
||||
expect(stat.mode & 0o777).toBe(0o600);
|
||||
}
|
||||
});
|
||||
|
||||
it('chmodStackPath succeeds on .blueprint.json', async () => {
|
||||
const markerPath = path.join(stackDir, '.blueprint.json');
|
||||
await fs.writeFile(markerPath, '{"blueprintId":1,"revision":1}\n');
|
||||
await fs.chmod(markerPath, 0o644);
|
||||
|
||||
const service = FileSystemService.getInstance();
|
||||
await service.chmodStackPath(STACK, '.blueprint.json', 0o600);
|
||||
|
||||
if (!isWindows) {
|
||||
const stat = await fs.stat(markerPath);
|
||||
expect(stat.mode & 0o777).toBe(0o600);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Root-scoped (bind-mount) behaviour: the file methods accept an arbitrary
|
||||
|
||||
@@ -1128,6 +1128,14 @@ describe('PUT /api/stacks/:stackName/files/content', () => {
|
||||
expect(composeRes.status).toBe(409);
|
||||
expect(composeRes.body.code).toBe('stack_op_in_progress');
|
||||
|
||||
const trailingRes = await request(app)
|
||||
.put(`/api/stacks/${STACK}/files/content`)
|
||||
.query({ path: 'compose.yaml/' })
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ content: 'services:\n app:\n image: nginx\n' });
|
||||
expect(trailingRes.status).toBe(409);
|
||||
expect(trailingRes.body.code).toBe('stack_op_in_progress');
|
||||
|
||||
const markerRes = await request(app)
|
||||
.put(`/api/stacks/${STACK}/files/content`)
|
||||
.query({ path: '.blueprint.json' })
|
||||
@@ -1739,6 +1747,63 @@ describe('PUT /api/stacks/:stackName/files/permissions', () => {
|
||||
.send({ mode: 0o600 });
|
||||
expect(res.status).toBe(204);
|
||||
});
|
||||
|
||||
it('blocks root trust file chmod while a stack op lock is held', async () => {
|
||||
await fs.writeFile(path.join(stacksDir, STACK, '.blueprint.json'), '{"blueprintId":1,"revision":1}\n');
|
||||
await fs.mkdir(path.join(stacksDir, STACK, 'config'), { recursive: true });
|
||||
await fs.writeFile(path.join(stacksDir, STACK, 'config', 'app.conf'), 'ok\n');
|
||||
await fs.writeFile(path.join(stacksDir, STACK, 'config', 'compose.yaml'), 'services: {}\n');
|
||||
const { StackOpLockService } = await import('../services/StackOpLockService');
|
||||
StackOpLockService.getInstance().tryAcquire(1, STACK, 'deploy', 'admin');
|
||||
try {
|
||||
const composeRes = await request(app)
|
||||
.put(`/api/stacks/${STACK}/files/permissions`)
|
||||
.query({ path: 'compose.yaml' })
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ mode: 0o600 });
|
||||
expect(composeRes.status).toBe(409);
|
||||
expect(composeRes.body.code).toBe('stack_op_in_progress');
|
||||
|
||||
const trailingRes = await request(app)
|
||||
.put(`/api/stacks/${STACK}/files/permissions`)
|
||||
.query({ path: 'compose.yaml/' })
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ mode: 0o600 });
|
||||
expect(trailingRes.status).toBe(409);
|
||||
expect(trailingRes.body.code).toBe('stack_op_in_progress');
|
||||
|
||||
const markerRes = await request(app)
|
||||
.put(`/api/stacks/${STACK}/files/permissions`)
|
||||
.query({ path: '.blueprint.json' })
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ mode: 0o600 });
|
||||
expect(markerRes.status).toBe(409);
|
||||
expect(markerRes.body.code).toBe('stack_op_in_progress');
|
||||
|
||||
const envRes = await request(app)
|
||||
.put(`/api/stacks/${STACK}/files/permissions`)
|
||||
.query({ path: '.env' })
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ mode: 0o600 });
|
||||
expect(envRes.status).toBe(204);
|
||||
|
||||
const nestedRes = await request(app)
|
||||
.put(`/api/stacks/${STACK}/files/permissions`)
|
||||
.query({ path: 'config/app.conf' })
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ mode: 0o600 });
|
||||
expect(nestedRes.status).toBe(204);
|
||||
|
||||
const nestedComposeRes = await request(app)
|
||||
.put(`/api/stacks/${STACK}/files/permissions`)
|
||||
.query({ path: 'config/compose.yaml' })
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ mode: 0o600 });
|
||||
expect(nestedComposeRes.status).toBe(204);
|
||||
} finally {
|
||||
StackOpLockService.getInstance().release(1, STACK);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── DELETE /:stackName/files ──────────────────────────────────────────────────
|
||||
@@ -1992,14 +2057,46 @@ describe('protected stack files', () => {
|
||||
await fs.unlink(path.join(stacksDir, STACK, 'pretend.yaml'));
|
||||
});
|
||||
|
||||
it('PUT /files/permissions refuses .env with 409 PROTECTED_FILE', async () => {
|
||||
it('PUT /files/permissions succeeds on .env', async () => {
|
||||
const mode = 0o600;
|
||||
const res = await request(app)
|
||||
.put(`/api/stacks/${STACK}/files/permissions`)
|
||||
.query({ path: '.env' })
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ mode: 0o644 });
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.code).toBe('PROTECTED_FILE');
|
||||
.send({ mode });
|
||||
expect(res.status).toBe(204);
|
||||
// Windows Node only distinguishes writable vs read-only; exact Unix bits are Linux/macOS.
|
||||
if (!isWindows) {
|
||||
expect((await fs.stat(path.join(stacksDir, STACK, '.env'))).mode & 0o777).toBe(mode);
|
||||
}
|
||||
});
|
||||
|
||||
it('PUT /files/permissions succeeds on compose.yaml', async () => {
|
||||
const mode = 0o600;
|
||||
const res = await request(app)
|
||||
.put(`/api/stacks/${STACK}/files/permissions`)
|
||||
.query({ path: 'compose.yaml' })
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ mode });
|
||||
expect(res.status).toBe(204);
|
||||
if (!isWindows) {
|
||||
expect((await fs.stat(path.join(stacksDir, STACK, 'compose.yaml'))).mode & 0o777).toBe(mode);
|
||||
}
|
||||
});
|
||||
|
||||
it('PUT /files/permissions succeeds on .blueprint.json', async () => {
|
||||
const markerPath = path.join(stacksDir, STACK, '.blueprint.json');
|
||||
await fs.writeFile(markerPath, '{"blueprintId":1,"revision":1}\n');
|
||||
const mode = 0o600;
|
||||
const res = await request(app)
|
||||
.put(`/api/stacks/${STACK}/files/permissions`)
|
||||
.query({ path: '.blueprint.json' })
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ mode });
|
||||
expect(res.status).toBe(204);
|
||||
if (!isWindows) {
|
||||
expect((await fs.stat(markerPath)).mode & 0o777).toBe(mode);
|
||||
}
|
||||
});
|
||||
|
||||
it('DELETE /files still succeeds on a non-protected file', async () => {
|
||||
|
||||
@@ -145,7 +145,7 @@ function releaseStackOpLock(req: Request, stackName: string): void {
|
||||
StackOpLockService.getInstance().release(req.nodeId, stackName);
|
||||
}
|
||||
|
||||
/** Root compose + blueprint marker on the stack-source root must not change while a lifecycle op holds the stack lock. */
|
||||
/** Root compose + blueprint marker on the stack-source root must not change (content or mode) while a lifecycle op holds the stack lock. */
|
||||
const STACK_OP_LOCKED_ROOT_TRUST_FILES = new Set([
|
||||
'compose.yaml',
|
||||
'compose.yml',
|
||||
@@ -162,8 +162,9 @@ function rejectIfStackOpBlocksRootTrustFileWrite(
|
||||
root: StackFileRoot,
|
||||
): boolean {
|
||||
if (root.kind !== 'stack-source') return false;
|
||||
if (relPath.includes('/')) return false;
|
||||
const base = relPath.toLowerCase();
|
||||
const normalized = relPath.endsWith('/') ? relPath.slice(0, -1) : relPath;
|
||||
if (normalized.includes('/')) return false;
|
||||
const base = normalized.toLowerCase();
|
||||
if (!STACK_OP_LOCKED_ROOT_TRUST_FILES.has(base)) return false;
|
||||
const existing = StackOpLockService.getInstance().get(req.nodeId, stackName);
|
||||
if (!existing) return false;
|
||||
@@ -3662,6 +3663,7 @@ stacksRouter.put('/:stackName/files/permissions', async (req: Request, res: Resp
|
||||
}
|
||||
const root = await resolveRootForOp(req, res, stackName, 'write');
|
||||
if (!root) return;
|
||||
if (rejectIfStackOpBlocksRootTrustFileWrite(req, res, stackName, relPath, root)) return;
|
||||
const startedAt = Date.now();
|
||||
logFileDiag('chmod start', { stackName, relPath, nodeId: req.nodeId, mode, rootKind: root.kind });
|
||||
try {
|
||||
|
||||
@@ -2068,7 +2068,6 @@ export class FileSystemService {
|
||||
if (!Number.isInteger(mode) || mode < 0 || mode > 0o777) {
|
||||
throw Object.assign(new Error('Invalid permission bits'), { code: 'INVALID_PATH' });
|
||||
}
|
||||
if ((scope?.protectedEnabled ?? true) && isProtectedRelPath(relPath)) throw protectedFileError(relPath);
|
||||
const leafPath = await this.resolveScopedLeafPath(stackName, relPath, scope);
|
||||
|
||||
// chmod on a symlink is rejected. Following the link would silently
|
||||
|
||||
Reference in New Issue
Block a user