From a826cd398dceb5d3466e2ecbf7f9d1f4c488093c Mon Sep 17 00:00:00 2001 From: Anso Date: Wed, 5 Aug 2026 09:22:36 -0400 Subject: [PATCH] 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. --- .../__tests__/filesystem-stack-paths.test.ts | 44 ++++++++ .../src/__tests__/stack-files-routes.test.ts | 105 +++++++++++++++++- backend/src/routes/stacks.ts | 8 +- backend/src/services/FileSystemService.ts | 1 - docs/features/stack-file-explorer.mdx | 23 +++- 5 files changed, 169 insertions(+), 12 deletions(-) diff --git a/backend/src/__tests__/filesystem-stack-paths.test.ts b/backend/src/__tests__/filesystem-stack-paths.test.ts index 00b29665..e09e21d1 100644 --- a/backend/src/__tests__/filesystem-stack-paths.test.ts +++ b/backend/src/__tests__/filesystem-stack-paths.test.ts @@ -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 diff --git a/backend/src/__tests__/stack-files-routes.test.ts b/backend/src/__tests__/stack-files-routes.test.ts index 2195bb10..b24e54b4 100644 --- a/backend/src/__tests__/stack-files-routes.test.ts +++ b/backend/src/__tests__/stack-files-routes.test.ts @@ -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 () => { diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index 9c03e2ac..3ed76d97 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -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 { diff --git a/backend/src/services/FileSystemService.ts b/backend/src/services/FileSystemService.ts index fecb9dd0..d803469d 100644 --- a/backend/src/services/FileSystemService.ts +++ b/backend/src/services/FileSystemService.ts @@ -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 diff --git a/docs/features/stack-file-explorer.mdx b/docs/features/stack-file-explorer.mdx index a9fa8762..eef7cc41 100644 --- a/docs/features/stack-file-explorer.mdx +++ b/docs/features/stack-file-explorer.mdx @@ -78,16 +78,19 @@ Each row is fully clickable across the pane, so right-clicking anywhere on a row Protection applies to the **Stack source** root. On a volume root a file named `compose.yaml` or `.env` is an ordinary config file: it opens directly in the viewer and has no delete restriction, because it is the application's own file rather than the stack's compose definition. -On the stack source root, the amber dot in the tree marks the five canonical stack files: `compose.yaml`, `compose.yml`, `docker-compose.yaml`, `docker-compose.yml`, and `.env`. +On the stack source root, the amber dot in the tree marks the five canonical stack files (`compose.yaml`, `compose.yml`, `docker-compose.yaml`, `docker-compose.yml`, and `.env`) plus `.blueprint.json`. Stack source tree with an amber dot marker next to compose.yaml -Two behaviours follow from the marker: +Three behaviours follow from the marker: - **Dedicated tab redirect.** Clicking `compose.yaml`, `compose.yml`, or `.env` jumps you to the matching **compose.yaml** or **.env** tab so the save-and-deploy controls stay in front of you. `docker-compose.yaml` and `docker-compose.yml` are still flagged as protected, but they open in the regular file viewer because they are not the canonical Sencho file. - **Delete is blocked at the stack root.** The five canonical files at the stack root cannot be deleted through the explorer: the delete is rejected. Remove the whole stack via Stack Actions instead. A same-named file nested in a subdirectory is an ordinary file and can be deleted after the type-to-confirm step. +- **Content edits and permission changes are allowed.** You can save those files and change their Unix mode bits from the explorer. + +The stack source root also keeps `.blueprint.json` (the marker a blueprint apply writes) under delete, rename, and copy-onto protection. Content edits and permission changes on that marker are allowed; a later apply recreates it. An override file such as `compose.override.yaml` or `docker-compose.override.yml` is not one of the five canonical names, so it carries no amber dot and no delete restriction: Sencho treats it as an ordinary file even at the stack root. @@ -115,7 +118,7 @@ Click **Save** to write the file to disk. If you have unsaved edits and click a Saves use optimistic concurrency: the editor remembers when the file was last loaded, and a save that targets a stale version returns a "file changed elsewhere" notice with the current server-side content. Your typed buffer is preserved so you can review the new version and reapply your edits before saving again. -Writes to the stack source root and to bind-mount volumes are atomic at the filesystem level: Sencho stages the new content into a sibling temporary file, fsyncs, and promotes it via rename, so a crash or power loss never leaves a half-written target on disk. Named-volume writes go through the helper container instead and are not atomic; see [Named-volume editing details](#named-volume-editing-details) above. +Writes to the stack source root and to bind-mount volumes are atomic at the filesystem level: Sencho stages the new content into a sibling temporary file, fsyncs, and promotes it via rename, so a crash or power loss never leaves a half-written target on disk. That rewrite also resets the file's mode bits and owner to the Sencho process defaults (typically `644` owned by the container user). If you had tightened permissions (for example `600` on `.env`), set them again after the save. Named-volume writes go through the helper container instead and are not atomic; see [Named-volume editing details](#named-volume-editing-details) above. Existing named-volume edits keep the file's owner and mode. Editing a file does not restart any containers. If your stack reads the file at runtime (for example, a config file mounted as a volume), restart the relevant service after saving so the container picks up the new content. @@ -213,6 +216,12 @@ When your account has stack edit permission, the toggles are interactive and the Permissions are applied with `chmod`. Permission changes on symlinks are not supported; edit the target file's permissions directly. +Permission changes work on protected stack-source names too, including `.env`, the four compose filenames, and `.blueprint.json`. + +While another stack operation is running, permission changes on the compose filenames and `.blueprint.json` at the stack source root are refused the same way a content save is. `.env` is not part of that lock. Retry after the operation finishes. + +Saving, uploading a replacement, applying a blueprint, or restoring a backup returns the file's mode bits to the filesystem default (typically `644` inside the Sencho container). Re-apply permissions after those writes if you had tightened them. + ## Deleting There are three delete entry points. All three require stack edit permission, and all three open the same confirmation modal. @@ -271,7 +280,13 @@ The Permissions dialog opens for everyone; only users with stack edit permission Every service that mounts the volume declares it read-only (for example `./config:/config:ro`). The explorer browses it but disables the edit, upload, delete, and rename controls. Change the mount to read/write in the compose file and redeploy if you need to edit its contents from Sencho. - The five canonical stack files (`compose.yaml`, `compose.yml`, `docker-compose.yaml`, `docker-compose.yml`, `.env`) are protected because removing them mid-life breaks the stack. To delete a stack entirely, use **Delete stack** in the stack toolbar's overflow menu rather than removing these files individually. + The five canonical stack files (`compose.yaml`, `compose.yml`, `docker-compose.yaml`, `docker-compose.yml`, `.env`) are protected because removing them mid-life breaks the stack. `.blueprint.json` is protected the same way. To delete a stack entirely, use **Delete stack** in the stack toolbar's overflow menu rather than removing these files individually. + + + Another stack operation is running. Permission changes on compose filenames and `.blueprint.json` at the stack source are refused until that operation finishes. `.env` and ordinary files are not part of that lock. Retry after it completes. + + + A content save, upload replacement, blueprint apply, or backup restore returns mode bits to the filesystem default (typically `644` inside the Sencho container). Open **Permissions** again and re-apply the bits you want. Another writer (a teammate, a deploy hook, or an out-of-band edit on the host) saved the file after you loaded it. Use the diff to compare your edits against the current version, then save again. Your typed buffer is kept so you do not have to retype your changes.