fix: contain file-explorer writes and browse reachable out-of-base binds (#1465)

The file-explorer editor save resolved a dangling symlink leaf to the link
path and wrote through it with a plain writeFile, which followed the link and
created a file outside the bind/stack root. Reject a resolved leaf that is
itself a symlink (mirroring the managed-stack guard) and promote the save
through the atomic stage-and-rename helper, so the editor save matches its
documented atomicity and can never leave a partial file or land outside the
root.

Bind-root discovery reported every source outside the compose base as
unreachable without probing it, so a config directory mounted into both the
app and the Sencho container was wrongly non-browsable. Probe the declared
source as Sencho actually sees it; dangerous host roots, docker-socket mounts,
and managed-area overlaps stay blocked, and the dangerous classification also
reads the literal declared source so it holds across platforms.
This commit is contained in:
Anso
2026-06-26 09:23:50 -04:00
committed by GitHub
parent 315e8b6379
commit 1c82e3e1d4
4 changed files with 156 additions and 32 deletions
@@ -117,6 +117,35 @@ describe('StackFileRootsService.listRoots', () => {
expect(bind?.browsable).toBe(false);
});
it('classifies a reachable absolute bind outside the compose base as browsable and writable', async () => {
// A config directory mounted into both the app and the Sencho container can
// legitimately live outside the compose base. When Sencho can stat it, the
// root must be fully browsable/editable, not silently dropped as unreachable.
const outside = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'sfr-ext-')));
try {
stub({ rendered: renderModel({ web: [{ type: 'bind', source: outside, target: '/config', read_only: false }] }) });
const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true });
const bind = roots.find((r) => r.kind === 'bind');
expect(bind?.accessible).toBe(true);
expect(bind?.browsable).toBe(true);
expect(bind?.writable).toBe(true);
expect(bind?.managedSourceOverlap).toBe(false);
expect(bind?.hostPathOrName).toBe(outside);
} finally {
await fs.rm(outside, { recursive: true, force: true }).catch(() => {});
}
});
it('marks an unreachable absolute bind outside the compose base as non-browsable', async () => {
const outsideMissing = path.join(os.tmpdir(), 'sfr-ext-absent-xyz-12345');
stub({ rendered: renderModel({ web: [{ type: 'bind', source: outsideMissing, target: '/config' }] }) });
const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true });
const bind = roots.find((r) => r.kind === 'bind');
expect(bind?.accessible).toBe(false);
expect(bind?.browsable).toBe(false);
expect(bind?.warning).toBeTruthy();
});
it('blocks a dangerous host bind (/etc) and never exposes it as browsable', async () => {
stub({ rendered: renderModel({ web: [{ type: 'bind', source: '/etc', target: '/host-etc' }] }) });
const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true });
@@ -125,6 +154,20 @@ describe('StackFileRootsService.listRoots', () => {
expect(bind?.browsable).toBe(false);
});
it('blocks a dangerous declared source even when realpath rewrites it to a benign canonical', async () => {
// Guards the dangerousSource term: realpath can rewrite a dangerous POSIX
// source to a benign-looking canonical (a non-existent POSIX path resolves
// drive-prefixed on a non-Linux host), so isDangerousHostPath(canonical)
// alone would miss it. The classification must also read the literal source.
vi.spyOn(fs, 'realpath').mockResolvedValue('/srv/benign-canonical' as never);
vi.spyOn(fs, 'stat').mockResolvedValue({ isDirectory: () => true } as never);
stub({ rendered: renderModel({ web: [{ type: 'bind', source: '/etc', target: '/host-etc' }] }) });
const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true });
const bind = roots.find((r) => r.kind === 'bind');
expect(bind?.dangerous).toBe(true);
expect(bind?.browsable).toBe(false);
});
it('folds a bind equal to the stack dir into stack-source (no second editable root)', async () => {
stub({ rendered: renderModel({ web: [{ type: 'bind', source: stackDir, target: '/app' }] }) });
const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true });