Files
sencho/backend/src/services/FileSystemService.ts
T
Anso 578ce7684d feat(git): complete-project materialization with a managed-project manifest (#1786)
* feat(git): add managed-project manifest types and DB cache columns

Introduces the canonical managed-project manifest contract types (schema v1)
and the stack_git_sources cache columns manifest_version / manifest_state /
manifest_generation. The manifest file remains the source of truth; the DB
column carries the two states the file cannot express (migration_required,
absent).

* feat(git): add vendored Docker .dockerignore matcher

Implements docker patternmatcher semantics for build-context materialization:
basename matching for slash-less patterns, anchored root patterns, ** crossing,
last-match-wins negation, dir-only patterns, char classes, comments and
escapes. Table-driven tests cover the full rule set.

* feat(git): add pure Compose input declaration parser

Walks explicit compose files plus recursive include/extends.file graphs and
emits every repository-local input (include, extends, env_file, configs,
secrets, label_file, build contexts, bind mounts) with declaring-file
provenance. Side-effect free: file contents are injected via a read callback.
Parse errors and dynamic \${VAR} paths are collected for refusal at
classification time instead of throwing.

* feat(git): add Compose input discovery service

Classifies every declared input against the cloned tree as managed,
unmanaged, or refused: containment, symlink/device/LFS/submodule guards,
file and path-depth caps, dockerignore-aware build-context planning with the
repo-root context bound, implicit override discovery for single-file stacks,
and the shared walkAndCopy candidate builder with aggregate caps.

* feat(git): add managed-project manifest service

Owns the canonical inventory at <DATA_DIR>/git-managed/<nodeId>/<stackName>:
untrusted reads with shape/enum/identity validation, bounds config,
candidate build with completion-marker gating, transactional promotion with
crash marker + previous-generation restore, boot sweep that declines over
hand-repaired state, lazy migration from applied_deploy_spec with
conservative deletion authority, and the detach export render.

* feat(git): complete-project pull/apply with staged promotion and detach export

Pull now discovers and stages the complete project (candidate in the managed
area, validated with the exact invocation including -p), apply promotes it
transactionally with a local-modification refusal keyed to manifest hashes,
legacy v2 pending blobs migrate conservatively, delete becomes an async
detach/export contract, stack deletion and create-rollback reap the managed
area, the boot sweep restores crashed promotions under the per-stack lock,
and rollback readiness discloses the partial-revert scope for Git-managed
stacks. GET /git-source carries the manifest summary and a new manifest
read endpoint is added.

* feat(git): surface the managed-project manifest in the Git source panel

Adds a collapsible manifest summary (pinned revision, managed/unmanaged/
refused counts, lazy-fetched input inventory with role chips, refusal
callout, migration banners), a refusal callout in the pull diff dialog, the
detach-and-export confirm copy, and the rollback partial-revert note in the
rollback readiness section.

* test(git): e2e coverage for complete-project materialization

Adds a local smart-HTTPS git server (e2e/gitServer.helper.ts with a committed
dev-only CA, NODE_EXTRA_CA_CERTS wired into CI) and four specs: full-project
create records the manifest, apply refuses local modifications naming the
diverged file, multi-file detach exports a deployable compose.yaml, and an
out-of-bound include aborts the pull with an actionable refusal.

* fix(git): harden the materialization transaction and crash recovery

Review-driven hardening: promotion now writes the manifest only after the
candidate rename (every crash window leaves the old manifest on disk, so the
sweep restores correctly), the promotion marker is atomic and a corrupt marker
flags migration_required instead of reading as a clean slate, restore rewrites
the manifest file and keeps the marker on partial failure, stale cleanup fails
the promotion instead of recording false tombstones and handles directories,
generation retention is previousDir-explicit, include/extends shared graphs
dedupe instead of false-cycling, the discovery read callback is containment
and size bound, sync_env owns the stack-root .env hash, compose entries carry
content hashes so the divergence guard covers compose.yaml, the summary is
synthesized from the DB cache so migration_required surfaces in the UI, corrupt
v3 pending blobs throw instead of degrading to legacy, create-rollback never
touches a pre-existing stack, and the boot sweep isolates per-stack failures.

* fix(git): byte-exact promotion, sync-env ownership, and render/marker hardening

Audit-driven corrections: candidate files are written byte-exact (Buffers
through the guarded FileSystemService write paths, size bound on stat.size)
so binary build contexts, configs, and secrets survive promotion and the
divergence guard stays silent; syncEnv is now passed to discovery and the
sync-env entry is de-duplicated by path so sync-env stacks with a repo .env
cannot double-record or deadlock; docker compose config output over the cap
fails the detach render instead of truncating; the promotion marker is
batched; a failed first promotion keeps the marker and flags
migration_required; the detach confirmation names the secret consequence.
Regression tests: binary round-trip with repeat-apply hash stability,
syncEnv discovery branches, sync-env pull/apply/pull/apply, partial-state
manifest, plus the existing suites (229/229 affected, only the documented
pre-existing Windows filesystem-backup EBUSY flake outside them).

* fix(git): exact-generation restore, context file ownership, dockerfile rebase, detach finality

Audit round 2 corrections: restore removes paths a failed promotion introduced
(exact prior generation, first-promotion failures clean the partial set and keep
the marker); build contexts are file-granular (per-file hashes in the manifest,
divergence guard covers context subtrees, files removed upstream are cleared on
promotion); explicit dockerfiles resolve relative to their build context with
in-repo ../ forms materialized as managed inputs; repo-root contexts no longer
double-copy managed files; detach removes auto-discovered override files so the
flattened model is final; lint errors fixed. Regression tests: exact restore,
context reconciliation + local-edit detection, dockerfile rebase and repo-escape
refusal, repo-root overlap, detach override removal. 213/213 affected backend
tests.

* fix(git): audit round 3: root-context normalization, build-service identity, Docker ignore rust, deep manifest validation, exact-set restore, detach atomicity, CRLF normalization

B-1: introducedPaths helper computes the exact file set a failed promotion would
leave (top-level + context files); restore removes introduced paths for an exact
prior generation; sweep accepts the incoming inventory for crash-window recovery.

B-2: repo-root context (build: .) canonicalized to canonical empty relative path
across discovery/context plan/entry/validation; walkAndCopy skips the candidate
control marker and sync-env-owned .env so root contexts never copy Sencho metadata
into the live stack dir.

B-3: DeclaredInput gains a service field; collectBuild threads it so a compose
file with two services and two different Dockerfiles pairs each context with its
own dockerfile. Additional contexts never inherit the service dockerfile.

B-4: docker ignore-file selection implemented per Docker build-context rules
(root .dockerignore, with Dockerfile-specific <name>.dockerignore precedence when
present); out-of-context Dockerfiles go through classifyPath for symlink/device/
LFS/submodule/depth/size guards instead of a bare stat.

B-5: deep manifest validation of buildContext entries (safe relative paths, sha256
format, no duplicate/case-colliding file paths); marker fields validated on read;
pre-correction manifests without files[] normalized to empty arrays for safe
degradation.

B-6: detach re-ordered to remove overrides BEFORE writing flattened compose.yaml;
if override removal fails nothing was written, the model is untouched, and detach
is safely re-runnable.

S-1: ComposeService.ts LFs normalized to repository convention.

All 213 affected backend tests pass; tsc + lint clean both sides.

* fix(git): audit round 4: root-context safety, Docker ignore wiring, marker-based exact restore, detach ordering, shared-input dedup, deep validation

B-1: the promotion marker now carries introduced paths computed from the incoming
manifest during promotion; boot recovery uses them for exact-generation restore
regardless of whether the incoming manifest is still available. `introducedPaths`
excludes tombstoned prior entries and only counts present prior files.

B-2: root-context entries (build: ., materializedPath "") are no longer emitted
as managed input entries — they are tracked exclusively in buildContexts[] with
per-file inventories. `writeStackFileFromCandidate` and `verifyContextOnDisk` both
accept empty repoPath safely.

B-3: Dockerfile-specific .dockerignore matcher is now ASSIGNED to matcher (the
variable was loaded but discarded). The directory resolution for the specific
ignore file correctly uses the clone-relative path instead of double-joining the
context root.

B-4: detach now writes the flattened compose.yaml BEFORE deleting overrides; a
compose-write failure leaves the stack unchanged (no overrides deleted, no row
dropped); a later retry produces the same flattened model.

S-1: discovery deduplicates managed inputs by case-insensitive materialized path
at the classification boundary, so two services sharing an env_file produce one
entry and the candidate writer never hits a duplicate-path rejection.

S-2: manifest validation adds collision detection between input paths and context
file paths.

All 213 affected backend tests pass; tsc clean; lint 0 errors.

* fix(git): audit round 5: root-context collision fix, marker-based recovery, Docker ignore root case, context divergence, detach rollback

B-1: root-context files that collided with managed input paths (compose.yaml) are
filtered from the context inventory so the manifest collision check passes.

B-2: after candidate promotion renames, the marker is updated to point at the
applied generation; the boot sweep now accepts an applied-gen directory without
a .candidate-complete marker as valid for recovery.

B-3: marker written/introduced paths reject empty strings (isNonEmptyRelPath)
while the manifest generation-dir fields still allow empty as the unset sentinel.

B-4: Dockerfile-specific ignore directory resolution fixed for root Dockerfile
cases (slice(0, -1) on a single-segment name previously dropped the last char).

B-5: context divergence now reports locally-added files inside contexts as
divergence, using the managed input path set to skip compose.yaml/.env/files
that have a non-context owner.

B-6: detach now snapshots the prior compose.yaml before overwriting; any failure
in override deletion or managed-area removal restores the snapshot so the stack
is byte-identical to pre-detach state.

S-1/B-8: discovery counts computed from the deduped input array; lint errors
(2 unused variables) fixed.

All 213 affected backend tests pass; tsc clean; lint 0 errors.

* fix(git): audit round 6: root-context promotion, recovery ordering, context-file merge, divergence precision, detach rollback

B-1: root-context files now explicitly promoted from the candidate via a
context-file loop after the managed-input promotion step. Root-context stale
cleanup paths no longer produce absolute /file paths (conditionally join on
repoPath).

B-2: the marker is updated to the applied generation BEFORE the candidate rename,
so every crash window finds a directory the sweep recognizes. The sweep also
accepts applied-generation directories (non-empty dirs without a candidate
completion marker) when the marker points at them.

B-3: shared-context plans are merged after planning: files from every Dockerfile
that shares a context root are unioned into one inventory so no service loses
required inputs.

B-4: the context divergence walk now compares stack-relative paths against the
managed-input set (repoPath-prefixed childRel) so nested managed inputs inside
non-root contexts are correctly skipped and local additions are still refused.

B-5: detach snapshots every override file before deletion, restores them all on
any failure, and tolerates absent/corrupt manifests (no manifest means no
materialized overrides to clean, not a hard abort).

All 213 affected backend tests pass; tsc clean; lint 0 errors.

* fix(git): audit round 7: root Dockerfile containment, inventory-driven context copy, file-only marker recovery, detach transaction

B-1: root-context Dockerfile containment check fixed for root contexts
("" or "."). Any repo-relative Dockerfile without ../ is in-context.

B-2: context copy now reads from the plan inventory (plan.context.files)
instead of re-walking the source with the first matcher. Merged plans
(shared contexts with different Dockerfiles) copy the exact union.

B-3: directory entries are excluded from the marker written list so
recovery never tries to hash a directory; every context file is
individually tracked. Rename before marker update so the marker
always points at an existing directory.

B-4: detach aborts on corrupt manifests, distinguishes snapshot
ENOENT from read errors, surfaces rollback failures in the error
message, and keeps DB deletion as the final commit step after
all disk mutations succeed.

All 213 affected backend tests pass; tsc clean; lint 0 errors.

* fix(git): sanitize log messages and fix CodeQL log-injection finding

The one genuine CodeQL alert (log-injection + format-string at line 1002)
is resolved by wrapping stackName with sanitizeForLog(), matching existing
precedent in ComposeService.ts and routes/stacks.ts. All other log sites
in this file also use sanitizeForLog for user-controlled values.

* fix(git): enforce context bounds after shared-context merge

The merged context plan union can exceed GITSOURCE_MAX_BUILD_CONTEXT_BYTES
even when each individual plan fits. Recheck the cap against the unionized
inventory after merging.

* fix(git): harden materialization recovery

* fix(git): audit round 8 - invocation-faithful discovery, safe promotion, redacted manifest API

B-1: an omitted build context now defaults to the declaring file's project
directory, and build-secret long syntax parses source as a top-level secret
name instead of a file path, so valid projects no longer refuse or fail to
build.
B-2: dynamic ${VAR} inputs are persisted as explicit unmanaged manifest
entries instead of vanishing, build contexts inside or containing submodules
are refused (dockerignore-excluded submodules exempt), and pull responses
surface clone-time warnings.
B-3: relative paths in merged (-f) files resolve against the base file's
directory (or the context dir) with the materialized path rebased to the
runtime stack root; include/extends-reached files keep their own directory;
implicit override auto-discovery is suppressed when a context dir forces
explicit -f, matching the deploy invocation.
B-4: promotion refuses introduced paths that already exist in the live stack
as unowned local files before the first live mutation; the synced .env and
fresh-stack creation stay exempt.
B-5: upsert rejects repository or branch changes on a stack with a manifest
file (actionable detach-first error), and apply's corrupt-manifest message
distinguishes identity-stamp corruption.
B-6: the manifest endpoint returns a redacted public projection: no hashes,
sizes, provenance, or deletion authority, and high-sensitivity paths and
notes are null.
B-7: detach deletes only entries proven to be implicit auto-discovered
overrides; same-basename explicit files survive.
S-1: the manifest panel no longer refetches on a failed request; retry is an
explicit action.
S-2: fresh create persists the manifest cache columns after the row insert so
list and response projections report the real state.
S-3: git-sources.mdx matches the corrected detach, submodule, and dynamic-path
behavior.

* fix(git): align GitSourcePanel manifest fixture with the public projection; exclude guarded manifest service from CodeQL path-injection

The panel test fixture still used the internal manifest shape; with the
redacted public projection the label fell back to the dependency kind and
duplicated the badge. The manifest service's per-stack paths are validated
by isValidStackName at the route and inside managedRoot, use constant
filenames, and pass containment checks; the CodeQL PR analysis surfaces the
pre-existing rename sink whenever the diff touches the service layer.

* fix(git): restore ComposeService.ts line endings to the base convention

The file was committed with CRLF at the PR base; a round-3 commit
normalized it to LF, making the base-to-head diff show 1,412 additions
and 1,322 deletions for ~90 substantive lines. Restoring CRLF collapses
the diff to the functional changes only.

* fix(git): remove ineffective CodeQL source-path exclusion

query-filters match query metadata, not analyzed source locations, so the
file-scoped js/path-injection exclusion added in round 8 had no effect.
The manifest service's guarded per-stack paths stay protected by the
route and managedRoot validation, and the code-scanning gate stays green
through the per-alert dismissals.

* fix(git): audit round 9 - runtime path equivalence, complete input grammar, pre-manifest adoption guard, redacted refusals

B-1: the introduced-path collision guard now runs unconditionally with an
explicit adoption policy: 'all' for fresh creation, the legacy-ownership
allowlist (applied compose files + synced .env, matched exactly as
stack-relative paths) for existing pre-manifest stacks, fail closed
otherwise. The first complete-project apply can no longer overwrite an
unowned local file.
B-2: include map path and env_file accept string or list forms, include
project_directory re-bases the included subtree, label_file accepts lists,
and additional_contexts accepts mapping or NAME=VALUE list forms with
builder-supplied (type://, service:) values recorded unmanaged.
B-3: the parser resolves every declaration in both the repository and the
runtime (stack-relative) coordinate systems. The primary compose file lands
at the stack root, so its include/extends graph and every project-relative
path declared in it or in merged (-f) files shifts by the primary's
repository directory prefix; the classifier consumes the resolved pair
instead of re-resolving.
B-4: absolute (POSIX, Windows drive/UNC, drive-relative, root-relative) and
home-relative paths are detected before normalization or base joining and
classified as host inputs (unmanaged) or actionable refusals for
include/extends, never adopting a same-named repository file.
S-1: refusals carry sensitivity, stamped at every refusal site; the public
projection (summary, pull response, and the pull-abort message) redacts
high-sensitivity refusals, scrubbing path text from reasons and the OS
error text that could embed absolute paths. Dynamic include/extends are
refused; URL includes are high sensitivity.
S-3: ComposeService.ts line endings restored (separate commit).
S-2: invalid CodeQL source-path filter removed (separate commit).

* chore: bump nanoid to 3.3.18 via npm audit fix

The nanoid advisory GHSA-2v37-7h3g-55p8 (high) covers <3.3.17 and was
published after the last green CI run; both lockfiles pinned 3.3.16.
npm audit fix bumps the transitive dependency to 3.3.18.

* fix(git): audit round 10 - included-project envs, project-base includes, optional inputs, drive-letter binds

B-1: every included project's default interpolation .env is inventoried
(present: managed, sensitive, hashed, copied; absent: tolerated as
unmanaged). interpolation: false and same-base includes skip the entry.
B-2: include, include-env, and extends.file paths resolve against the
current level's EFFECTIVE PROJECT base (compose-go local resource loader
WorkingDir), not the declaring file's directory: ordered (-f) files use
the context dir or the first file's directory; nested includes use the
including include-entry's project directory. Long-form path lists derive
one project directory from the FIRST resolved path (the compose-go main
file rule) and apply it to every file in the list. Runtime coordinates
follow the same bases, so a context dir shifts the primary's include graph
under the project directory.
S-1: env_file map form preserves required; a missing optional file is
recorded as an unmanaged entry (missing-file and submodule cases), never a
refusal. external: false file-backed configs and secrets use their file;
only external: true applies the external behavior.
S-2: drive-letter and drive-relative short-form bind mounts are parsed
(the separator is the colon after the drive prefix) and recorded as host
entries instead of being mistaken for named volumes.
S-3: frontend lockfile libc metadata restored to the base graph (the base
already carries nanoid 3.3.18).
S-4: operator docs corrected to distinguish refused include/extends from
unmanaged absolute host data inputs and dynamic data paths.

* fix(git): audit round 11 - boot sweep data-loss guard, honest manifest summary, dead refusal UI removal

B-1: the boot orphan sweep no longer treats a failed or empty stack listing
as 'every stack is gone'. FileSystemService gains getStacksStrict() (the
soft getStacks() still swallows for its existing callers); sweepOrphans
aborts the whole sweep on a listing failure and, for each row missing from
the listing, lstat-verifies the stack directory is genuinely gone (ENOENT
only) before deleting its managed area, under the per-stack lock. The
manifest summary now reports migration_required (never a stale active with
zero counts) when the manifest file is missing while the DB cache claims an
applied state.
C-2: removed the unreachable refusal surfaces (all discovery refusals are
actionable, so buildMaterialization aborts before any refusal is persisted:
the 'Unsupported inputs' and 'Some project inputs are not materialized'
UI blocks can never render). The backend refusal schema stays for
read-compatibility; the PR body claim is corrected.
C-3: e2e mobile-check seeding failures now fail the test loudly (asserted
responses with the HTTP status, pre-clean of a leftover stack) instead of
silently degrading to an overflow-only assertion.

* fix(e2e): seed mobile-check from the local fixture git server

The seed pointed at docker/awesome-compose.git with compose_paths
['compose.yaml'], but that repository has no root compose.yaml, so the
git-source PUT always failed with FILE_NOT_FOUND and the previous
conditional assertion silently masked it. The seed now uses the local TLS
fixture git server (the same one the git-sources suite uses), making the
PUT deterministic with no external network dependency.

* fix(git): isolate monorepo overrides and harden materialization errors

Scope implicit compose.override discovery to the primary file directory so monorepo subprojects cannot absorb a sibling override. Refuse case-only path collisions at discovery, scrub internal paths from compose validation errors, treat literal $ filenames as static, and heal stale manifest_state on read.
2026-08-10 17:12:55 -04:00

2126 lines
93 KiB
TypeScript

import path from 'path';
import os from 'os';
import crypto from 'crypto';
import { promises as fsPromises, createReadStream, createWriteStream } from 'fs';
import type { Dirent } from 'fs';
import { Readable } from 'stream';
import { pipeline } from 'stream/promises';
import { NodeRegistry } from './NodeRegistry';
import { DatabaseService } from './DatabaseService';
import { isPathWithinBase, isValidStackName } from '../utils/validation';
import { isBinaryBuffer } from '../utils/binaryDetect';
import { sanitizeForLog } from '../utils/safeLog';
import { sha256HexBuffer } from '../utils/hashing';
export interface FileEntry {
name: string;
// 'other' covers non-regular helper-volume entries (fifo/socket/device): they
// are unrepresentable on the fs backend but the helper can surface them, and
// they must stay distinct from 'file' so the archive guard can reject them.
type: 'file' | 'directory' | 'symlink' | 'other';
size: number;
mtime: number;
isProtected: boolean;
}
/**
* Optional scope for a file-explorer operation. When `rootAbsDir` is set, the
* operation resolves and is contained within that absolute directory instead of
* the stack source dir, so the same primitives serve volume-aware bind-mount
* roots. `protectedEnabled` (compose/.env protection) defaults to true and is
* set false by the route for non-stack-source roots, where a file named
* compose.yaml/.env is just an ordinary editable file. The caller is
* responsible for pre-authorizing `rootAbsDir` (it may legitimately sit outside
* the compose base dir); this service only enforces containment within it.
*/
export interface FileRootScope {
rootAbsDir?: string;
protectedEnabled?: boolean;
}
/**
* Resolves the writable Sencho data directory (same one DatabaseService /
* CryptoService use). Recomputed lazily so test harnesses that override
* `process.env.DATA_DIR` after module load still take effect.
*/
function getBackupBaseDir(): string {
const dataDir = process.env.DATA_DIR || path.join(process.cwd(), 'data');
return path.join(dataDir, 'backups');
}
import { isDebugEnabled } from '../utils/debug';
const PROTECTED_STACK_FILES = new Set([
'compose.yaml',
'compose.yml',
'docker-compose.yaml',
'docker-compose.yml',
'.env',
]);
// Explorer-only protection: includes the blueprint ownership marker without
// putting it in PROTECTED_STACK_FILES (backup/rollback orphan removal).
const EXPLORER_PROTECTED_STACK_FILES = new Set([
...PROTECTED_STACK_FILES,
'.blueprint.json',
]);
// Bookkeeping markers Sencho writes into the backup slot. They are never copied
// back into the stack directory on restore: `.timestamp` records when the backup
// was taken; `.checksums` is the integrity manifest verified before a restore.
const BACKUP_MARKER_FILES = new Set(['.timestamp', '.checksums']);
// Compose filenames Sencho recognizes as a managed stack, in resolution-priority
// order. Used by getStacks / hasComposeFile / firstComposeFilename.
const IMPORT_COMPOSE_FILENAMES = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'] as const;
const IMPORT_COMPOSE_FILENAME_SET = new Set<string>(IMPORT_COMPOSE_FILENAMES);
// Override filenames docker compose can auto-discover, listed in priority order (first
// match wins, not paired to the chosen base file's family). We resolve the first that
// exists, mirroring compose's default override resolution, to re-add it when an explicit
// -f list (mesh injection) would otherwise suppress that discovery.
const COMPOSE_OVERRIDE_FILENAMES = [
'compose.override.yaml',
'compose.override.yml',
'docker-compose.override.yaml',
'docker-compose.override.yml',
] as const;
const COMPOSE_OVERRIDE_FILENAME_SET = new Set<string>(COMPOSE_OVERRIDE_FILENAMES);
/** True for adopt-scan candidates: any .yml/.yaml that is not a compose override. */
function isAdoptYamlFilename(name: string): boolean {
return !COMPOSE_OVERRIDE_FILENAME_SET.has(name) && (name.endsWith('.yml') || name.endsWith('.yaml'));
}
function assertSafeComposeBasename(name: string): void {
// Basename only: reject path separators and the special entries . / .., but allow
// names that merely contain ".." as a substring (e.g. foo..bar.yml).
if (!name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
throw Object.assign(new Error('Invalid path'), { code: 'INVALID_PATH' });
}
}
/** Canonical names keep their basename; everything else lands as compose.yaml. */
function adoptDestComposeBasename(name: string): string {
return IMPORT_COMPOSE_FILENAME_SET.has(name) ? name : 'compose.yaml';
}
// Skip reading compose files larger than this into the import preview.
const IMPORT_MAX_PREVIEW_BYTES = 1_048_576; // 1 MiB
/**
* A compose file discovered on disk during the guided import scan that is not yet
* a stack. `status` records why: a compose file loose at the compose-dir root
* (`loose-root`) or one directory too deep (`nested`) will not auto-register and
* needs the user to move it. A top-level subdirectory with a compose file is
* already a stack (it shows in the sidebar), so the scan skips it and it never
* appears here. `content` is null when the file was oversized or unreadable.
*/
export interface ImportCandidateRaw {
name: string;
composeFile: string;
location: string;
status: 'loose-root' | 'nested';
content: string | null;
oversized: boolean;
}
/**
* The subset of an import candidate the move path needs: where the compose file
* sits and whether it is loose at the root or nested. The scan only surfaces
* these two placements, so any candidate it returns can be moved into place.
*/
export type MovableImportCandidate = Pick<ImportCandidateRaw, 'location' | 'composeFile' | 'status'>;
/** Placement metadata for a single import candidate (no file read). */
export interface ImportCandidateMeta {
name: string;
composeFile: string;
location: string;
status: 'loose-root' | 'nested';
}
// Strips at most one trailing slash. The upstream validator
// (isValidRelativeStackPath) rejects any '//' sequence, so a string reaching
// this helper can carry at most one trailing slash, and a single slice is
// sufficient. Avoids the polynomial regex /\/+$/ that CodeQL would flag for
// callers without the upstream length guarantee.
function stripTrailingSlash(s: string): string {
return s.endsWith('/') ? s.slice(0, -1) : s;
}
// On a case-insensitive filesystem (Windows, default macOS) two paths that differ
// only in case point at the same entry, so comparisons that gate filesystem
// mutations must fold case to stay authoritative. On Linux (where Sencho runs in
// production) paths are case-sensitive and this returns the input unchanged.
function fsCaseKey(s: string): string {
return process.platform === 'win32' || process.platform === 'darwin' ? s.toLowerCase() : s;
}
/**
* True when resolved absolute path `candidate` is `parent` itself or sits inside
* it, compared case-folded so the guard stays authoritative on a case-insensitive
* filesystem. Used to block moving/copying a directory into its own subtree.
*/
function isSameOrDescendantFsPath(parent: string, candidate: string): boolean {
const parentKey = fsCaseKey(parent);
const parentKeyWithSep = parentKey.endsWith(path.sep) ? parentKey : parentKey + path.sep;
const candidateKey = fsCaseKey(candidate);
return candidateKey === parentKey || candidateKey.startsWith(parentKeyWithSep);
}
function isProtectedRelPath(relPath: string): boolean {
if (!relPath) return false;
const normalized = stripTrailingSlash(relPath);
// Only files at the stack root are protected; compose CLI reads compose.yaml from
// the stack directory itself, so a subdirectory entry named compose.yaml is just
// an arbitrary file and the user may want to delete it.
if (normalized.includes('/')) return false;
// Fold case so e.g. a request for COMPOSE.YAML cannot dodge the gate on a
// case-insensitive filesystem where it resolves to the real compose.yaml.
return EXPLORER_PROTECTED_STACK_FILES.has(fsCaseKey(normalized));
}
function protectedFileError(relPath: string): Error & { code: string } {
const basename = stripTrailingSlash(relPath).split('/').pop() ?? relPath;
return Object.assign(
new Error(`${basename} is a protected stack file. Delete the stack itself via Stack Actions instead.`),
{ code: 'PROTECTED_FILE' as const },
);
}
const MIME_MAP: Record<string, string> = {
'.yaml': 'text/yaml',
'.yml': 'text/yaml',
'.json': 'application/json',
'.sh': 'text/x-sh',
'.env': 'text/plain',
};
/**
* FileSystemService - local-only file I/O for compose stack management.
*
* In the Distributed API model, remote node file operations are handled
* by the remote Sencho instance itself. This service only operates on
* the local filesystem.
*/
export class FileSystemService {
private baseDir: string;
private nodeId: number;
constructor(nodeId?: number) {
this.nodeId = nodeId ?? NodeRegistry.getInstance().getDefaultNodeId();
this.baseDir = NodeRegistry.getInstance().getComposeDir(this.nodeId);
}
public static getInstance(nodeId?: number): FileSystemService {
return new FileSystemService(nodeId);
}
private assertWithinBase(filePath: string): void {
if (!isPathWithinBase(filePath, this.baseDir)) {
throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' });
}
}
private resolveStackDir(stackName: string): string {
if (!isValidStackName(stackName)) {
throw Object.assign(new Error('Invalid stack name'), { code: 'INVALID_STACK_NAME' });
}
const stackDir = path.join(this.baseDir, stackName);
this.assertWithinBase(stackDir);
return stackDir;
}
private getBackupDir(stackName: string): string {
if (!isValidStackName(stackName)) {
throw Object.assign(new Error('Invalid stack name'), { code: 'INVALID_STACK_NAME' });
}
return path.join(getBackupBaseDir(), String(this.nodeId), stackName);
}
async hasComposeFile(dir: string): Promise<boolean> {
this.assertWithinBase(dir);
const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
for (const file of composeFiles) {
try {
await fsPromises.access(path.join(dir, file));
return true;
} catch {
// continue
}
}
return false;
}
private async getComposeFilePath(stackName: string): Promise<string> {
const stackDir = this.resolveStackDir(stackName);
await this.assertRealWithinBase(stackDir);
const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
for (const file of composeFiles) {
const filePath = path.join(stackDir, file);
try {
await fsPromises.access(filePath);
if (isDebugEnabled()) console.debug('[FileSystemService:debug] Resolved compose file', { stackName, file });
return filePath;
} catch {
// continue
}
}
throw new Error(`No compose file found for stack: ${stackName}`);
}
async getComposeFilename(stackName: string): Promise<string> {
return path.basename(await this.getComposeFilePath(stackName));
}
/**
* The stack's hand-authored compose override filename (bare basename, e.g.
* `compose.override.yml`), or `null` when none exists. Mirrors how docker compose
* itself resolves the default override: the first existing variant in priority order.
* Callers building an explicit `-f` list (which suppresses compose's built-in override
* discovery) use this to re-add the implicit override. Applies the same stack-name and
* symlink-containment guards as `getComposeFilePath`.
*/
async getOverrideFilename(stackName: string): Promise<string | null> {
const stackDir = this.resolveStackDir(stackName);
await this.assertRealWithinBase(stackDir);
// Canonical js/path-injection barrier inline with the access sink (same pattern as
// envExists): stackName is already validated by resolveStackDir and assertRealWithinBase
// above, but static analysis only credits the containment check when it sits at the sink.
const baseResolved = path.resolve(this.baseDir);
for (const file of COMPOSE_OVERRIDE_FILENAMES) {
const target = path.resolve(stackDir, file);
if (!target.startsWith(baseResolved + path.sep)) continue;
try {
await fsPromises.access(target);
return file;
} catch {
// continue
}
}
return null;
}
private async listStacksRaw(): Promise<string[]> {
const items = await fsPromises.readdir(this.baseDir, { withFileTypes: true });
const stackNames: string[] = [];
for (const item of items) {
if (!item.isDirectory()) continue;
if (!item.name || typeof item.name !== 'string') continue;
const stackDir = path.join(this.baseDir, item.name);
if (await this.hasComposeFile(stackDir)) {
stackNames.push(item.name);
}
}
return stackNames;
}
async getStacks(): Promise<string[]> {
try {
return await this.listStacksRaw();
} catch (error: any) {
if (error?.code === 'ENOMEM') {
const freeMiB = Math.round(os.freemem() / (1024 * 1024));
console.warn(`[FileSystemService] Failed to list stacks: ENOMEM (host free memory: ${freeMiB} MiB). Returning empty list.`);
} else {
console.warn(`[FileSystemService] Failed to list stacks: ${error.message}`);
}
return [];
}
}
/**
* Like getStacks(), but PROPAGATES listing errors instead of returning an
* empty list. Callers that must distinguish "no stacks" from "could not
* list stacks" (the boot orphan sweep) use this variant: a swallowed read
* failure must never look like every stack disappeared.
*/
async getStacksStrict(): Promise<string[]> {
return this.listStacksRaw();
}
async getStackContent(stackName: string): Promise<string> {
try {
const filePath = await this.getComposeFilePath(stackName);
return await fsPromises.readFile(filePath, 'utf-8');
} catch (error) {
console.error('Error reading stack content:', sanitizeForLog((error as Error)?.message ?? String(error)));
throw new Error(`Failed to read stack: ${stackName}`);
}
}
/**
* Read the resolved compose file along with its mtimeMs, which the route
* layer surfaces as an ETag for optimistic-concurrency on PUT. The stat
* and read share a single file descriptor so they observe the same inode
* state, even if the file is replaced (rename) between the two calls.
*/
async getStackContentWithMtime(stackName: string): Promise<{ content: string; mtimeMs: number }> {
const untrustedFilePath = await this.getComposeFilePath(stackName);
// Canonical js/path-injection barrier: resolve against a known-safe root
// then check the result is contained in that root. The form mirrors
// CodeQL's documented sanitizer exactly so taint flow recognizes it.
const baseResolved = path.resolve(this.baseDir);
const safePath = path.resolve(baseResolved, untrustedFilePath);
if (!safePath.startsWith(baseResolved + path.sep)) {
throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' });
}
const fh = await fsPromises.open(safePath, 'r');
try {
const stat = await fh.stat();
const content = await fh.readFile('utf-8');
return { content, mtimeMs: stat.mtimeMs };
} finally {
await fh.close();
}
}
async saveStackContent(stackName: string, content: string | Buffer): Promise<void> {
const stackDir = this.resolveStackDir(stackName);
const filePath = path.join(stackDir, 'compose.yaml');
await this.assertRealWithinBase(filePath);
try {
// Buffer input is written byte-exact (the encoding option is ignored for
// Buffers); string input keeps the utf-8 write. Byte-exactness matters to
// the Git materializer, whose content hashes are computed over raw bytes.
await fsPromises.writeFile(filePath, content, 'utf-8');
} catch (error) {
console.error('Error writing file:', error);
throw new Error(`Failed to save stack: ${stackName}`);
}
}
/**
* Optimistic-concurrency write: if `expectedMtimeMs` is provided, stat the
* write target first and return `{ok: false}` with the current content and
* mtime when they don't match. The route maps that to 412. Mtime comparison
* uses Math.floor to absorb sub-millisecond jitter from different file
* systems / Node versions.
*
* If the file doesn't exist yet, the write proceeds (no mtime to compare).
* Returns the new mtimeMs so the route can emit a fresh ETag.
*/
async saveStackContentIfUnchanged(
stackName: string,
content: string,
expectedMtimeMs: number | null,
): Promise<
| { ok: true; mtimeMs: number }
| { ok: false; currentMtimeMs: number; currentContent: string }
> {
const stackDir = this.resolveStackDir(stackName);
const untrustedFilePath = path.join(stackDir, 'compose.yaml');
// Canonical js/path-injection barrier: path.resolve(SAFE_ROOT, untrusted)
// followed by a single startsWith check, both inline with the sink.
const baseResolved = path.resolve(this.baseDir);
const safePath = path.resolve(baseResolved, untrustedFilePath);
if (!safePath.startsWith(baseResolved + path.sep)) {
throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' });
}
await this.assertRealWithinBase(safePath);
if (expectedMtimeMs !== null) {
let fh: import('fs/promises').FileHandle | null = null;
try {
fh = await fsPromises.open(safePath, 'r');
const stat = await fh.stat();
if (Math.floor(stat.mtimeMs) !== Math.floor(expectedMtimeMs)) {
const currentContent = await fh.readFile('utf-8');
return { ok: false, currentMtimeMs: stat.mtimeMs, currentContent };
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
// File doesn't exist yet, treat as fresh write.
} finally {
if (fh) await fh.close();
}
}
await fsPromises.writeFile(safePath, content, 'utf-8');
const newStat = await fsPromises.stat(safePath);
return { ok: true, mtimeMs: newStat.mtimeMs };
}
/**
* Optimistic-concurrency write for arbitrary paths under the stack dir
* (used for .env files; the path was already validated by the caller).
*/
async writeFileIfUnchanged(
untrustedTargetPath: string,
content: string,
expectedMtimeMs: number | null,
): Promise<
| { ok: true; mtimeMs: number }
| { ok: false; currentMtimeMs: number; currentContent: string }
> {
// Canonical js/path-injection barrier: path.resolve(SAFE_ROOT, untrusted)
// followed by a single startsWith check, both inline with the sink.
const baseResolved = path.resolve(this.baseDir);
const safePath = path.resolve(baseResolved, untrustedTargetPath);
if (!safePath.startsWith(baseResolved + path.sep)) {
throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' });
}
await this.assertRealWithinBase(safePath);
if (expectedMtimeMs !== null) {
let fh: import('fs/promises').FileHandle | null = null;
try {
fh = await fsPromises.open(safePath, 'r');
const stat = await fh.stat();
if (Math.floor(stat.mtimeMs) !== Math.floor(expectedMtimeMs)) {
const currentContent = await fh.readFile('utf-8');
return { ok: false, currentMtimeMs: stat.mtimeMs, currentContent };
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
} finally {
if (fh) await fh.close();
}
}
await fsPromises.writeFile(safePath, content, 'utf-8');
const newStat = await fsPromises.stat(safePath);
return { ok: true, mtimeMs: newStat.mtimeMs };
}
async statMtime(untrustedTargetPath: string): Promise<number | null> {
const baseResolved = path.resolve(this.baseDir);
const safePath = path.resolve(baseResolved, untrustedTargetPath);
if (!safePath.startsWith(baseResolved + path.sep)) {
throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' });
}
await this.assertRealWithinBase(safePath);
try {
const stat = await fsPromises.stat(safePath);
return stat.mtimeMs;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw err;
}
}
async envExists(stackName: string): Promise<boolean> {
const stackDir = this.resolveStackDir(stackName);
// Canonical js/path-injection barrier inline with the access sink, the
// same pattern backupStackFiles/restoreStackFiles use: stackName is
// already validated by resolveStackDir above, but static analysis only
// credits the containment check when it sits at the sink itself.
const baseResolved = path.resolve(this.baseDir);
const target = path.resolve(stackDir, '.env');
if (!target.startsWith(baseResolved + path.sep)) {
return false;
}
try {
await this.assertRealWithinBase(target);
await fsPromises.access(target);
return true;
} catch {
return false;
}
}
async readFile(filePath: string, encoding: BufferEncoding = 'utf-8'): Promise<string> {
this.assertWithinBase(filePath);
await this.assertRealWithinBase(filePath);
return fsPromises.readFile(filePath, encoding);
}
async writeFile(filePath: string, content: string, encoding: BufferEncoding = 'utf-8'): Promise<void> {
this.assertWithinBase(filePath);
await this.assertRealWithinBase(filePath);
return fsPromises.writeFile(filePath, content, encoding);
}
async access(filePath: string): Promise<void> {
this.assertWithinBase(filePath);
await this.assertRealWithinBase(filePath);
return fsPromises.access(filePath);
}
async getEnvContent(stackName: string): Promise<string> {
const base = path.resolve(this.baseDir);
const envPath = path.resolve(base, path.basename(stackName), '.env');
if (!isPathWithinBase(envPath, base)) {
throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' });
}
await this.assertRealWithinBase(envPath);
try {
return await fsPromises.readFile(envPath, 'utf-8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('Error reading env file:', error);
}
throw error;
}
}
async saveEnvContent(stackName: string, content: string): Promise<void> {
const stackDir = this.resolveStackDir(stackName);
const envPath = path.join(stackDir, '.env');
await this.assertRealWithinBase(envPath);
try {
await fsPromises.writeFile(envPath, content, 'utf-8');
} catch (error) {
console.error('Error writing env file:', error);
throw new Error(`Failed to save env file for stack: ${stackName}`);
}
}
async createStack(stackName: string): Promise<void> {
if (DatabaseService.getInstance().hasBlockingDeletionIntent(this.nodeId, stackName)) {
throw new Error(
`Stack "${stackName}" has a deletion in progress and cannot be created until cleanup finishes.`,
);
}
const stackDir = this.resolveStackDir(stackName);
await this.assertRealWithinBase(stackDir);
try {
await fsPromises.access(stackDir);
throw new Error(`Stack "${stackName}" already exists`);
} catch (error: any) {
if (error.message.includes('already exists')) throw error;
}
await fsPromises.mkdir(stackDir, { recursive: true });
const boilerplate = `services:
app:
image: nginx:latest
restart: always
# Uncomment to expose a host port:
# ports:
# - "8080:80"
`;
try {
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), boilerplate, 'utf-8');
} catch (error) {
console.error('Error creating stack:', error);
throw new Error(`Failed to create stack: ${stackName}`);
}
}
/**
* Remove non-canonical root Compose filenames so discovery cannot shadow
* compose.yaml. Filename set is fixed inside this method. ENOENT is success;
* other unlink failures are logged and skipped.
*/
async removeAlternateRootComposeFiles(stackName: string): Promise<void> {
const stackDir = this.resolveStackDir(stackName);
await this.assertRealWithinBase(stackDir);
const baseResolved = path.resolve(this.baseDir);
for (const file of IMPORT_COMPOSE_FILENAMES) {
if (file === 'compose.yaml') continue;
// Containment barrier at the unlink sink (same pattern as rollback orphan removal).
const target = path.resolve(baseResolved, path.join(stackDir, file));
if (!target.startsWith(baseResolved + path.sep)) {
throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' });
}
try {
await fsPromises.unlink(target);
} catch (e: unknown) {
if ((e as NodeJS.ErrnoException).code === 'ENOENT') continue;
console.warn(
`[FileSystemService] Could not remove alternate compose file ${sanitizeForLog(file)} in stack ${sanitizeForLog(stackName)}:`,
sanitizeForLog((e as Error)?.message ?? String(e)),
);
}
}
}
public async deleteStack(stackName: string): Promise<void> {
const stackDir = this.resolveStackDir(stackName);
await this.assertRealWithinBase(stackDir);
try {
await fsPromises.rm(stackDir, { recursive: true, force: true });
} catch (error: unknown) {
const fsError = error as NodeJS.ErrnoException;
if (fsError.code === 'ENOENT') return;
console.error('Error deleting stack directory:', fsError.message);
throw new Error(`Failed to delete stack directory: ${fsError.message}`);
}
}
getBaseDir(): string {
return this.baseDir;
}
private async firstComposeFilename(dir: string): Promise<string | null> {
this.assertWithinBase(dir);
for (const file of IMPORT_COMPOSE_FILENAMES) {
try {
await fsPromises.access(path.join(dir, file));
return file;
} catch {
// continue
}
}
return null;
}
/**
* Compose file to surface for adopt: prefer the four canonical names (same
* access probe as firstComposeFilename, including weird/unreadable entries),
* then any other regular .yml/.yaml file that is not a compose override.
*/
private async firstAdoptComposeFilename(dir: string): Promise<string | null> {
const canonical = await this.firstComposeFilename(dir);
if (canonical) return canonical;
this.assertWithinBase(dir);
let entries: Dirent[];
try {
entries = await fsPromises.readdir(dir, { withFileTypes: true });
} catch (error) {
console.warn(
'[FileSystemService] Failed to read directory during adopt scan:',
sanitizeForLog((error as Error)?.message ?? String(error)),
);
return null;
}
const yamlFiles = entries
.filter((e) => e.isFile() && typeof e.name === 'string' && isAdoptYamlFilename(e.name))
.map((e) => e.name)
.sort((a, b) => a.localeCompare(b));
return yamlFiles[0] ?? null;
}
private async readComposeCandidate(filePath: string): Promise<{ content: string | null; oversized: boolean }> {
this.assertWithinBase(filePath);
let fh: import('fs/promises').FileHandle | null = null;
try {
// Resolve symlinks and confirm the real target is still inside the compose
// directory before reading (matches resolveSafeStackPath). A symlinked
// compose file or symlinked parent must not expose a file outside the
// compose dir through the preview.
const realPath = await fsPromises.realpath(filePath);
if (!isPathWithinBase(realPath, this.baseDir)) {
console.warn('[FileSystemService] Skipping import candidate that escapes the compose directory:', sanitizeForLog(filePath));
return { content: null, oversized: false };
}
// Open the canonical path once and stat/read on the same descriptor so the
// size check and the read observe the same inode (no time-of-check/use race).
fh = await fsPromises.open(realPath, 'r');
const stat = await fh.stat();
if (!stat.isFile()) return { content: null, oversized: false };
if (stat.size > IMPORT_MAX_PREVIEW_BYTES) return { content: null, oversized: true };
// Read at most stat.size (<= cap) bytes so a file that grows after the
// stat cannot push this buffer past the cap.
const buffer = Buffer.alloc(stat.size);
const { bytesRead } = await fh.read(buffer, 0, stat.size, 0);
return { content: buffer.subarray(0, bytesRead).toString('utf-8'), oversized: false };
} catch (error) {
// The file existed at probe time, so a failure here (permission, I/O) is
// worth a server-side line even though the scan degrades gracefully and the
// route reports it to the user.
console.warn('[FileSystemService] Failed to read import candidate:', sanitizeForLog((error as Error)?.message ?? String(error)));
return { content: null, oversized: false };
} finally {
if (fh) await fh.close();
}
}
/**
* Shared placement walk for import discovery and counting. Yields the same
* candidates findImportCandidates surfaces (including weird/unreadable files
* that readComposeCandidate returns content:null for). Loose-root and nested
* candidates accept any .yml/.yaml except compose override filenames; a
* top-level subdirectory is still a stack only when it has a canonical
* compose filename. Stops after `limit` yields.
*/
private async *enumerateImportCandidates(limit: number): AsyncGenerator<ImportCandidateMeta> {
let yielded = 0;
let entries: Dirent[];
try {
entries = await fsPromises.readdir(this.baseDir, { withFileTypes: true });
} catch (error) {
console.warn('[FileSystemService] Failed to scan compose directory for import:', sanitizeForLog((error as Error)?.message ?? String(error)));
return;
}
for (const entry of entries) {
if (yielded >= limit) return;
if (!entry.name || typeof entry.name !== 'string') continue;
if (entry.isFile()) {
if (isAdoptYamlFilename(entry.name)) {
yielded++;
yield { name: '', composeFile: entry.name, location: entry.name, status: 'loose-root' };
}
continue;
}
if (!entry.isDirectory()) continue;
const dir = path.join(this.baseDir, entry.name);
// Canonical compose here means already a stack. Non-canonical yaml in this
// folder (e.g. plex/plex.yml) is neither a stack nor an adopt candidate:
// promoting with destName equal to the folder would conflict on disk.
const topCompose = await this.firstComposeFilename(dir);
if (topCompose) continue;
let children: Dirent[];
try {
children = await fsPromises.readdir(dir, { withFileTypes: true });
} catch (error) {
console.warn('[FileSystemService] Failed to read subdirectory during import scan:', sanitizeForLog((error as Error)?.message ?? String(error)));
continue;
}
for (const child of children) {
if (yielded >= limit) return;
if (!child.isDirectory() || !child.name || typeof child.name !== 'string') continue;
const childDir = path.join(dir, child.name);
const childCompose = await this.firstAdoptComposeFilename(childDir);
if (childCompose) {
yielded++;
yield {
name: child.name,
composeFile: childCompose,
location: `${entry.name}/${child.name}/${childCompose}`,
status: 'nested',
};
}
}
}
}
/**
* Count adopt candidates with 101st lookahead: truncated is true only when
* more than maxCandidates exist.
*/
async countImportCandidates(maxCandidates = 100): Promise<{ count: number; truncated: boolean }> {
let count = 0;
for await (const _ of this.enumerateImportCandidates(maxCandidates + 1)) {
count++;
}
if (count > maxCandidates) {
return { count: maxCandidates, truncated: true };
}
return { count, truncated: false };
}
/**
* Scan the compose directory for compose files that are not yet stacks: loose
* files at the root and compose files one directory too deep. A top-level
* subdirectory with a compose file is already a stack, so it is skipped, not
* surfaced. Read-only. Bounded by `maxCandidates` and by a single level of
* nesting so a deep tree cannot make this walk unbounded.
*/
async findImportCandidates(maxCandidates = 100): Promise<ImportCandidateRaw[]> {
const candidates: ImportCandidateRaw[] = [];
for await (const meta of this.enumerateImportCandidates(maxCandidates)) {
const filePath = meta.status === 'loose-root'
? path.join(this.baseDir, meta.composeFile)
: path.join(this.baseDir, ...meta.location.split('/'));
const loaded = await this.readComposeCandidate(filePath);
candidates.push({ ...meta, ...loaded });
}
return candidates;
}
/**
* Move a discovered import candidate into its own top-level stack directory so
* auto-discovery (getStacks) picks it up. This is the single write path of the
* guided import flow and only runs on an explicit, per-file user action.
*
* A `loose-root` file is moved into <base>/<destName>/: only the chosen
* compose file moves, so sibling files referenced by a relative path
* (e.g. a root .env) stay where they are. A `nested` stack directory
* (<parent>/<child>) is promoted whole to <base>/<destName>, preserving its
* .env and any other files. Non-canonical basenames (anything other than the
* four managed compose names) are renamed to compose.yaml so getStacks sees
* the new stack, matching migrateFlatToDirectory.
*
* Never overwrites: a pre-existing destination is a conflict. Source and
* destination are both confirmed to resolve inside the compose directory
* before the rename, mirroring readComposeCandidate / resolveSafeStackPath.
*/
async importCandidateIntoStack(
candidate: MovableImportCandidate,
destName: string,
): Promise<void> {
// Validate the name, then re-establish containment inline at the sinks below
// (path.resolve against the safe base + a single startsWith). resolveStackDir
// applies the same check, but only the inline form is credited by static
// analysis, matching the read and backup paths in this file.
if (!isValidStackName(destName)) {
throw Object.assign(new Error('Invalid stack name'), { code: 'INVALID_STACK_NAME' });
}
const baseResolved = path.resolve(this.baseDir);
const destDir = path.resolve(baseResolved, destName);
if (!destDir.startsWith(baseResolved + path.sep)) {
throw Object.assign(new Error('Invalid stack name'), { code: 'INVALID_STACK_NAME' });
}
// No overwrite: the destination stack must not already exist. ENOENT is the
// expected happy path; any other access error (e.g. EACCES) should surface.
try {
await fsPromises.access(destDir);
throw Object.assign(new Error(`A stack named "${destName}" already exists`), { code: 'DEST_EXISTS' });
} catch (error) {
const code = (error as NodeJS.ErrnoException)?.code;
if (code === 'DEST_EXISTS') throw error;
if (code !== 'ENOENT') throw error;
}
// The on-disk source the candidate points at, confirmed within the base.
const source = path.resolve(this.baseDir, candidate.location);
this.assertWithinBase(source);
if (candidate.status === 'loose-root') {
const realSource = await this.realPathWithinBase(source);
// Build the relocated file path through the same inline containment barrier so
// the rename target is a credited safe path. composeFile is a basename from
// the scan (any .yml/.yaml except overrides); containment is re-checked here.
assertSafeComposeBasename(candidate.composeFile);
// Non-canonical names (e.g. nginx.yml) become compose.yaml so getStacks picks
// them up, matching migrateFlatToDirectory. Canonical names keep their basename.
const destComposePath = path.resolve(baseResolved, destName, adoptDestComposeBasename(candidate.composeFile));
if (!destComposePath.startsWith(baseResolved + path.sep)) {
throw Object.assign(new Error('Invalid path'), { code: 'INVALID_PATH' });
}
// Non-recursive mkdir is the atomic no-overwrite guard: if the destination
// appeared between the access() precheck above and here, this throws
// EEXIST (mapped to a 409 conflict) instead of merging into the existing
// directory and letting the rename clobber a same-named file.
await fsPromises.mkdir(destDir);
try {
await fsPromises.rename(realSource, destComposePath);
} catch (error) {
// mkdir just created destDir empty; a failed rename would otherwise strand
// it, and a retry with the same name would then hit the access() precheck
// and 409 for a stack that was never created. Remove the empty dir we made
// (best-effort, only ever empty) and rethrow the original failure.
await fsPromises.rmdir(destDir).catch(() => undefined);
throw error;
}
return;
}
if (candidate.status === 'nested') {
// Promote the whole child directory (<parent>/<child>) one level up so the
// stack keeps its .env and any sibling files.
const sourceDir = path.dirname(source);
this.assertWithinBase(sourceDir);
const realSourceDir = await this.realPathWithinBase(sourceDir);
// The directory can be real and within the base while the compose file inside
// it symlinks out of the base. Confirm the compose file resolves within the
// (real) source directory, otherwise the symlink rides the directory move into
// a stack folder and the editor would later follow it to the out-of-base file.
const realCompose = await this.realPathWithinBase(source);
if (!isPathWithinBase(realCompose, realSourceDir)) {
throw Object.assign(new Error('Compose file escapes the import directory'), { code: 'INVALID_PATH' });
}
// Non-canonical basenames become compose.yaml after promotion. Preflight so a
// sibling compose.yaml cannot strand the folder after the directory move.
const destBasename = adoptDestComposeBasename(candidate.composeFile);
const needsComposeRename = destBasename !== candidate.composeFile;
if (needsComposeRename) {
assertSafeComposeBasename(candidate.composeFile);
try {
await fsPromises.access(path.join(realSourceDir, destBasename));
throw Object.assign(
new Error(`Cannot rename ${candidate.composeFile} to ${destBasename}: destination already exists`),
{ code: 'DEST_EXISTS' },
);
} catch (error) {
const code = (error as NodeJS.ErrnoException)?.code;
if (code === 'DEST_EXISTS') throw error;
if (code !== 'ENOENT') throw error;
}
}
await fsPromises.rename(realSourceDir, destDir);
if (needsComposeRename) {
const fromPath = path.resolve(destDir, candidate.composeFile);
const toPath = path.resolve(destDir, destBasename);
if (!fromPath.startsWith(destDir + path.sep) || !toPath.startsWith(destDir + path.sep)) {
// Directory already moved; roll it back before failing.
await fsPromises.rename(destDir, realSourceDir).catch(() => undefined);
throw Object.assign(new Error('Invalid path'), { code: 'INVALID_PATH' });
}
try {
await fsPromises.rename(fromPath, toPath);
} catch (error) {
// Roll the directory back to its nested path so the candidate stays
// adoptable and a retry is not blocked by DEST_EXISTS on destName.
await fsPromises.rename(destDir, realSourceDir).catch(() => undefined);
throw error;
}
}
return;
}
// Exhaustiveness guard: the union is loose-root | nested. A status added later
// fails to compile here until it is handled, rather than silently taking a move
// path that does not fit it.
const unhandled: never = candidate.status;
throw new Error(`Unhandled import candidate status: ${String(unhandled)}`);
}
/**
* Resolve symlinks and confirm the real target is still inside the compose
* directory before a write moves it, so a symlinked source cannot relocate a
* file from outside the base. Returns the canonical path to operate on.
*/
private async realPathWithinBase(p: string): Promise<string> {
const real = await fsPromises.realpath(p);
if (!isPathWithinBase(real, this.baseDir)) {
throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' });
}
return real;
}
/**
* Reject when `targetPath` (an absolute managed stack dir, or a managed file
* inside it) would let an operation escape the real compose root via a
* symlink/junction. Complements the lexical inline barrier at each sink, which
* cannot see symlinks: path.resolve does not follow links.
*
* Walks up to the deepest path component that actually exists and confirms its
* canonical (realpath'd) location is inside the canonical compose root. The
* base is realpath'd too, so a legitimately symlinked compose root is not a
* false positive (both canonicalize through the same root link). Two escape
* shapes are rejected: an existing path that resolves outside the root, and a
* dangling symlink (a link whose target does not exist) anywhere on the path,
* since a write/mkdir would follow it out of tree. Components that are simply
* absent are safe (they get created as real entries), so they are walked past.
*
* No-op when the compose root itself does not exist yet (first-run
* create/migrate): nothing can exist under it, so no link can be followed.
*
* `targetPath` must be absolute; realpath of a relative path would resolve
* against the process cwd.
*/
private async assertRealWithinBase(targetPath: string): Promise<void> {
// Canonical js/path-injection barrier (mirrors every other sink in this
// file): resolve the untrusted target against the compose root and confirm
// lexical containment before any filesystem probe, so static analysis
// credits the sanitizer for the realpath/lstat calls below. Callers already
// build targetPath under the base, so this never rejects a legitimate or a
// symlink-escaping path (both are lexically contained); the realpath walk
// below is what actually catches symlink/junction escapes.
const baseResolved = path.resolve(this.baseDir);
const safeTarget = path.resolve(baseResolved, targetPath);
if (!safeTarget.startsWith(baseResolved + path.sep)) {
throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' });
}
let realBase: string;
try {
realBase = await fsPromises.realpath(this.baseDir);
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e;
return;
}
const escape = () =>
Object.assign(new Error('Path escapes compose directory via symlink'), { code: 'SYMLINK_ESCAPE' });
let cursor = safeTarget;
for (;;) {
let realCursor: string;
try {
realCursor = await fsPromises.realpath(cursor);
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e;
// cursor did not resolve. A dangling symlink still lstat's (the link
// exists); a genuinely absent component does not. Reject the dangling
// link; walk up past an absent component to the nearest real ancestor.
let danglingLink = false;
try {
await fsPromises.lstat(cursor);
danglingLink = true;
} catch (le) {
if ((le as NodeJS.ErrnoException).code !== 'ENOENT') throw le;
}
if (danglingLink) throw escape();
const parent = path.dirname(cursor);
if (parent === cursor) throw escape();
cursor = parent;
continue;
}
if (realCursor !== realBase && !realCursor.startsWith(realBase + path.sep)) {
throw escape();
}
return;
}
}
async migrateFlatToDirectory(): Promise<void> {
try {
try {
await fsPromises.access(this.baseDir);
} catch {
await fsPromises.mkdir(this.baseDir, { recursive: true });
return;
}
const items = await fsPromises.readdir(this.baseDir, { withFileTypes: true });
for (const item of items) {
if (!item.isFile()) continue;
if (!item.name.endsWith('.yml') && !item.name.endsWith('.yaml')) continue;
const stackName = item.name.replace(/\.(yml|yaml)$/, '');
const stackDir = path.join(this.baseDir, stackName);
try {
await this.assertRealWithinBase(stackDir);
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== 'SYMLINK_ESCAPE') throw e;
// A symlinked entry escaping the compose root is hostile/anomalous;
// skip just it so the remaining flat stacks still migrate.
console.warn(`[FileSystemService] Skipping migration of ${stackName}: stack path escapes the compose directory`);
continue;
}
try {
await fsPromises.access(stackDir);
continue;
} catch {
// Directory doesn't exist, proceed
}
await fsPromises.mkdir(stackDir, { recursive: true });
const oldComposePath = path.join(this.baseDir, item.name);
const newComposePath = path.join(stackDir, 'compose.yaml');
await fsPromises.rename(oldComposePath, newComposePath);
const oldEnvPath = path.join(this.baseDir, `${stackName}.env`);
const newEnvPath = path.join(stackDir, '.env');
try {
await fsPromises.access(oldEnvPath);
await fsPromises.rename(oldEnvPath, newEnvPath);
} catch (e: unknown) {
const code = (e as NodeJS.ErrnoException)?.code;
if (code !== 'ENOENT') {
console.warn(`[FileSystemService] Could not migrate env file for ${stackName}:`, (e as Error).message);
}
}
}
} catch (error) {
console.error('Migration error:', error);
}
}
/**
* Backup stack files (compose.yaml + .env) into Sencho's data dir.
*
* Backups live at <DATA_DIR>/backups/<nodeId>/<stackName>/ (NOT inside the user's
* compose folder) so the operation always succeeds even when the stack
* folder is owned by another UID (e.g., a container running as root has
* chowned its bind mount). DATA_DIR is the same writable location that
* holds sencho.db and encryption.key.
*/
async backupStackFiles(stackName: string): Promise<void> {
const debug = isDebugEnabled();
const t0 = Date.now();
const stackDir = this.resolveStackDir(stackName);
await this.assertRealWithinBase(stackDir);
// Canonical js/path-injection barrier (mirrors restoreStackFiles): resolve the
// backup path against the backup root and confirm containment inline, so the
// mkdir/copy/write sinks below operate on a validated path. stackName is
// already validated by resolveStackDir above; this re-establishes containment
// at the backup sinks themselves so static analysis sees the barrier.
const backupRoot = path.resolve(getBackupBaseDir());
const backupDir = path.resolve(backupRoot, String(this.nodeId), stackName);
if (!backupDir.startsWith(backupRoot + path.sep)) {
throw Object.assign(new Error('Path escapes backup directory'), { code: 'INVALID_PATH' });
}
await fsPromises.mkdir(backupDir, { recursive: true });
// Clear ALL non-marker files from the backup slot before writing the current
// set. The slot is reused across runs, so a file removed from the stack since
// the last backup (e.g. a deleted .env, a switched compose variant, or a
// removed project env file like old.env) would otherwise linger here and a
// later restore would resurrect it, breaking the faithful-revert guarantee.
// Marker files (.timestamp, .checksums) are preserved until rewritten below.
// A clear failure is logged but not fatal: it only risks a stale future
// rollback, so it should not block an otherwise valid deploy.
try {
const existing = await fsPromises.readdir(backupDir);
for (const item of existing) {
if (BACKUP_MARKER_FILES.has(item)) continue;
const stale = path.resolve(backupRoot, path.join(backupDir, item));
if (!stale.startsWith(backupRoot + path.sep)) continue;
try {
await fsPromises.unlink(stale);
} catch (e: unknown) {
if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') {
console.warn(`[FileSystemService] Could not clear stale backup ${item}:`, (e as Error).message);
}
}
}
} catch (e: unknown) {
if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') {
console.warn('[FileSystemService] Could not read backup directory for stale cleanup:', (e as Error).message);
}
}
// Copy each managed file by reading it into memory, writing it to the backup
// slot, and recording the SHA-256 of the source bytes. Hashing the source
// (not the destination) means a truncated copy is caught when restore re-hashes
// the backup and finds it no longer matches. Managed files (compose, .env) are
// small; revisit this read-into-memory if a large file is ever added to
// PROTECTED_STACK_FILES.
// Canonical js/path-injection barrier inline with each source read sink:
// resolve against the compose base and confirm containment, mirroring
// snapshotStackFiles. stackDir is already validated by resolveStackDir, but
// re-establishing containment at the readFile sink itself lets static analysis
// credit the barrier, which it does not through the helper.
const baseResolved = path.resolve(this.baseDir);
const checksums: Record<string, string> = {};
const writeManagedBackupFile = async (file: string, src: string): Promise<void> => {
let buf: Buffer;
try {
buf = await fsPromises.readFile(src);
} catch (e: unknown) {
const code = (e as NodeJS.ErrnoException)?.code;
// ENOENT means the file genuinely is not present: most stacks use one
// compose variant and have no .env, so an absent managed file is expected
// and simply skipped. Any other code (EACCES on a chowned bind mount,
// EBUSY on a held file) means the file exists but could not be read.
// Skipping it would produce a backup that silently omits a live managed
// file, so a later rollback would delete that file as an orphan and
// restore nothing. Fail the backup so the caller aborts before mutating.
if (code === 'ENOENT') return;
throw new Error(`Could not read ${file} for backup: ${(e as Error).message}`, { cause: e });
}
const dest = path.join(backupDir, file);
try {
await fsPromises.writeFile(dest, buf);
} catch (e: unknown) {
try {
await fsPromises.unlink(dest);
} catch {
// Best-effort cleanup only. The write failure below is the actionable error.
}
throw new Error(`Could not write backup ${file}: ${(e as Error).message}`, { cause: e });
}
checksums[file] = sha256HexBuffer(buf);
};
const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
for (const file of composeFiles) {
const src = path.resolve(baseResolved, path.join(stackDir, file));
if (!src.startsWith(baseResolved + path.sep)) {
throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' });
}
await writeManagedBackupFile(file, src);
}
// Copy .env if it exists (same inline containment barrier as above).
const envSrc = path.resolve(baseResolved, path.join(stackDir, '.env'));
if (!envSrc.startsWith(baseResolved + path.sep)) {
throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' });
}
await writeManagedBackupFile('.env', envSrc);
// Backup configured project env files (e.g. stack.env, .env.production).
// Dedup against .env (already in PROTECTED_STACK_FILES) so it is not
// duplicated. Stale dynamic files from a prior backup are cleared below so a
// previously-backed-up old.env does not linger.
let projectEnvFiles: string[] = [];
try {
projectEnvFiles = DatabaseService.getInstance().getStackProjectEnvFiles(this.nodeId, stackName);
} catch {
// DB read failure is not fatal to the deploy; skip project env file backup.
}
for (const file of projectEnvFiles) {
if (file === '.env') continue; // already handled above
const src = path.resolve(baseResolved, path.join(stackDir, file));
if (!src.startsWith(baseResolved + path.sep)) continue;
await writeManagedBackupFile(file, src);
}
// Write the integrity manifest before the timestamp marker, so a crash
// between the two leaves the checksums present (a backup that restore can
// verify) rather than a timestamp with no integrity data. Only files that
// were actually written above appear here, so the manifest never claims a
// file the slot does not hold.
await fsPromises.writeFile(path.join(backupDir, '.checksums'), JSON.stringify(checksums), 'utf-8');
// Write timestamp marker
await fsPromises.writeFile(path.join(backupDir, '.timestamp'), Date.now().toString(), 'utf-8');
if (debug) console.debug(`[FileSystemService:debug] Backup completed in ${Date.now() - t0}ms`, { stackName });
}
async restoreStackFiles(stackName: string): Promise<void> {
const debug = isDebugEnabled();
const t0 = Date.now();
const stackDir = this.resolveStackDir(stackName);
await this.assertRealWithinBase(stackDir);
// Canonical js/path-injection barrier at the backup read sink: resolve the
// backup dir against its root and confirm containment inline, mirroring
// backupStackFiles. stackName is already validated by resolveStackDir above;
// re-establishing containment at the readdir sink itself lets static
// analysis see the barrier, which it does not credit through the
// getBackupDir helper.
const backupRoot = path.resolve(getBackupBaseDir());
const backupDir = path.resolve(backupRoot, String(this.nodeId), stackName);
if (!backupDir.startsWith(backupRoot + path.sep)) {
throw Object.assign(new Error('Path escapes backup directory'), { code: 'INVALID_PATH' });
}
const items = await fsPromises.readdir(backupDir);
const backedUp = new Set(items);
// Verify the backup's integrity before mutating the stack, so a corrupt or
// truncated backup is rejected rather than copied back silently. Each backed-up
// file is re-hashed and compared to the .checksums manifest written at backup
// time. This runs before the orphan removal and copy below, so a failed check
// leaves the live stack exactly as it was. A backup with no manifest (taken
// before this guard existed) or a file with no recorded checksum is left
// unverified: the check is never stricter than what the backup recorded, so it
// cannot block a rollback the backup can still serve.
let checksums: Record<string, unknown> | null = null;
try {
const parsed: unknown = JSON.parse(await fsPromises.readFile(path.join(backupDir, '.checksums'), 'utf-8'));
if (parsed !== null && typeof parsed === 'object') {
checksums = parsed as Record<string, unknown>;
}
} catch (e: unknown) {
// ENOENT is a pre-feature backup with no manifest: restore unverified. A
// present but unreadable or malformed manifest does not prove the data files
// are bad, and blocking would deny a needed rollback, so warn and proceed
// unverified rather than fail.
if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') {
console.warn('[FileSystemService] Backup checksum manifest unreadable, restoring without integrity check:', (e as Error).message);
}
}
if (checksums) {
// Iterate the recorded manifest, not the directory listing. A file the
// manifest records but the slot no longer holds (lost or deleted after the
// manifest was written) passes an items-only scan unchecked; the orphan
// removal below would then delete the live file and the copy would restore
// nothing, leaving the stack unrecoverable. Walking the manifest catches
// both a corrupt copy and a missing one before anything is mutated.
for (const [file, expected] of Object.entries(checksums)) {
// Every key the manifest records names a file the backup captured. If the
// slot no longer holds it, the backup is incomplete: the orphan removal
// below would delete the live file (for a managed name) and the copy would
// restore nothing, so abort before anything is mutated. This precedes the
// value-type skip so a missing file is caught even when its recorded
// checksum is malformed.
if (!backedUp.has(file)) {
throw new Error(`Rollback aborted: the backup is missing ${file} recorded in its integrity manifest; the stack files were not changed.`);
}
// A non-string entry (a tampered or malformed-but-parseable manifest) is
// treated as no recorded checksum: the file is present, so copy it back
// unverified rather than fail, so a manifest problem never blocks a
// rollback whose data files are intact.
if (typeof expected !== 'string') continue;
// Canonical js/path-injection barrier inline with the read sink: the file
// name comes from the manifest, so resolve it against the backup dir and
// confirm containment before re-hashing.
const member = path.resolve(backupDir, file);
if (!member.startsWith(backupDir + path.sep)) {
throw Object.assign(new Error('Path escapes backup directory'), { code: 'INVALID_PATH' });
}
const actual = sha256HexBuffer(await fsPromises.readFile(member));
if (actual !== expected) {
throw new Error(`Rollback aborted: the backup of ${file} is corrupt (integrity check failed); the stack files were not changed.`);
}
}
}
// Remove managed files the backup does not contain before copying, so a
// rollback is a faithful revert rather than an additive overlay. If the
// failed deploy switched compose variants (e.g. compose.yaml ->
// docker-compose.yml) or added a .env the backup predates, leaving the new
// file in place would re-deploy a hybrid of old and new configuration.
// Scope is strictly PROTECTED_STACK_FILES (the same set Sencho backs up);
// user data and bind-mounted content in the stack directory are untouched.
// Canonical js/path-injection barrier: path.resolve(SAFE_ROOT, untrusted)
// followed by a single startsWith check, both inline with the sink. stackDir
// is already validated by resolveStackDir; this re-establishes containment at
// the delete sink itself so static analysis sees the barrier.
const baseResolved = path.resolve(this.baseDir);
let removedOrphans = 0;
for (const file of PROTECTED_STACK_FILES) {
if (backedUp.has(file)) continue;
const target = path.resolve(baseResolved, path.join(stackDir, file));
if (!target.startsWith(baseResolved + path.sep)) {
throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' });
}
try {
await fsPromises.unlink(target);
removedOrphans++;
} catch (e: unknown) {
const code = (e as NodeJS.ErrnoException)?.code;
// ENOENT means the file is already absent, which is the desired end
// state. Any other code (EACCES on a chowned bind mount, EBUSY on a
// held file) means a managed file Sencho meant to remove is still on
// disk: completing the copy below would leave a hybrid config while
// reporting success. Abort so the caller surfaces a real failure and
// preserves the backup for manual recovery.
if (code !== 'ENOENT') {
throw new Error(`Rollback aborted: could not remove stale ${file} (${code ?? 'unknown error'}); the restore would leave a mix of old and new configuration.`);
}
}
}
for (const item of items) {
if (BACKUP_MARKER_FILES.has(item)) continue;
await fsPromises.copyFile(path.join(backupDir, item), path.join(stackDir, item));
}
if (debug) console.debug(`[FileSystemService:debug] Restore completed in ${Date.now() - t0}ms`, { stackName, restored: items.filter(i => !BACKUP_MARKER_FILES.has(i)).length, removedOrphans });
}
/**
* Capture the current managed stack files (PROTECTED_STACK_FILES plus configured
* project env files) in memory and return a function that puts them back,
* faithfully (writing the captured contents and removing any managed file that
* did not exist when captured).
*
* Used by the rollback route to undo a restored backup when the policy gate
* blocks before the deploy commits: restoreStackFiles has already overwritten
* the on-disk files, so without this a blocked rollback would leave disk holding
* the rolled-back configuration while the deployed containers are unchanged.
*/
async snapshotStackFiles(stackName: string): Promise<() => Promise<void>> {
const stackDir = this.resolveStackDir(stackName);
await this.assertRealWithinBase(stackDir);
const baseResolved = path.resolve(this.baseDir);
const snapshot = new Map<string, Buffer>();
// Collect the unified set: PROTECTED_STACK_FILES + configured project env files.
const files = new Set(PROTECTED_STACK_FILES);
try {
const projectEnvFiles = DatabaseService.getInstance().getStackProjectEnvFiles(this.nodeId, stackName);
for (const f of projectEnvFiles) {
if (f !== '.env') files.add(f); // .env already in PROTECTED_STACK_FILES
}
} catch {
// DB read failure: snapshot without project env files (safe fallback).
}
for (const file of files) {
const target = path.resolve(baseResolved, path.join(stackDir, file));
if (!target.startsWith(baseResolved + path.sep)) {
throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' });
}
try {
snapshot.set(file, await fsPromises.readFile(target));
} catch (e: unknown) {
if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') throw e;
}
}
return async () => {
for (const file of files) {
const target = path.resolve(baseResolved, path.join(stackDir, file));
if (!target.startsWith(baseResolved + path.sep)) continue;
const saved = snapshot.get(file);
if (saved !== undefined) {
await fsPromises.writeFile(target, saved);
} else {
try {
await fsPromises.unlink(target);
} catch (e: unknown) {
if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') throw e;
}
}
}
};
}
/**
* Names-only summary of the backup slot's env coverage for rollback
* readiness: whether a backup exists, whether it contains a .env, and the
* variable names defined in it. Values never leave this method.
*/
async getBackupEnvSummary(stackName: string): Promise<{ exists: boolean; envPresent: boolean; keys: string[] }> {
if (!isValidStackName(stackName)) {
return { exists: false, envPresent: false, keys: [] };
}
// Canonical js/path-injection barrier inline with the read sink, mirroring
// backupStackFiles/restoreStackFiles.
const backupRoot = path.resolve(getBackupBaseDir());
const backupDir = path.resolve(backupRoot, String(this.nodeId), stackName);
if (!backupDir.startsWith(backupRoot + path.sep)) {
throw Object.assign(new Error('Path escapes backup directory'), { code: 'INVALID_PATH' });
}
try {
await fsPromises.access(backupDir);
} catch (e: unknown) {
// Only a missing slot may report "no backup"; an unreadable one (EACCES
// on a root-created dir) must propagate so callers degrade to unknown
// instead of falsely promising the next update will create one.
if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') throw e;
return { exists: false, envPresent: false, keys: [] };
}
try {
const content = await fsPromises.readFile(path.join(backupDir, '.env'), 'utf-8');
const keys: string[] = [];
for (const line of content.split(/\r?\n/)) {
const match = /^\s*([A-Za-z_][A-Za-z0-9_]*)=/.exec(line);
if (match) keys.push(match[1]);
}
return { exists: true, envPresent: true, keys };
} catch (e: unknown) {
// ENOENT means the backup genuinely has no env file. Anything else
// (EACCES, EISDIR) must propagate: reporting it as "no env in backup"
// would falsely claim a rollback cannot restore env changes.
if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') throw e;
return { exists: true, envPresent: false, keys: [] };
}
}
async getBackupInfo(stackName: string): Promise<{ exists: boolean; timestamp: number | null }> {
const backupDir = this.getBackupDir(stackName);
try {
await fsPromises.access(backupDir);
const tsFile = path.join(backupDir, '.timestamp');
try {
const ts = await fsPromises.readFile(tsFile, 'utf-8');
return { exists: true, timestamp: parseInt(ts, 10) || null };
} catch (e) {
console.warn('[FileSystemService] Backup timestamp file unreadable:', (e as Error).message);
return { exists: true, timestamp: null };
}
} catch {
return { exists: false, timestamp: null };
}
}
// ---------------------------------------------------------------------------
// Stack-scoped file explorer methods
// ---------------------------------------------------------------------------
private guessMime(filePath: string): string {
if (path.basename(filePath) === '.env') return 'text/plain';
const ext = path.extname(filePath).toLowerCase();
return MIME_MAP[ext] ?? 'text/plain';
}
/**
* Resolve `relPath` within an arbitrary absolute root directory, applying the
* same containment + symlink-escape protection used for stack-source paths.
* Serves both the stack source dir (via resolveSafeStackPath) and volume-aware
* bind-mount roots, which may legitimately resolve outside the compose base dir
* (the caller pre-authorizes the root and passes its canonical realpath).
*
* KNOWN LIMITATION (TOCTOU): this realpath-validates the path, then the caller
* opens/streams/writes it by name, so a process that can write inside the root
* (e.g. a container writing its own bind-mounted config volume) could swap a
* validated regular file for a symlink between this check and the open and
* escape the root. Closing it fully requires per-component openat/O_RESOLVE
* traversal; plain O_NOFOLLOW is not viable because config volumes
* legitimately contain symlinks (e.g. nginx sites-enabled). This is a
* pre-existing property of every FileSystemService file op (not specific to
* volume roots); the bind root is contained to the compose dir and the op
* requires stack:edit, which already grants equivalent host access via
* compose. Tracked as a follow-up hardening, not a per-root regression.
*/
private async resolveSafePathWithin(rootAbsDir: string, relPath: string): Promise<string> {
// Canonical js/path-injection barrier inline with the realpath sinks below:
// isPathWithinBase performs the same containment check, but static analysis
// only credits the path.resolve + startsWith form when it sits at the sink.
// relPath === '' resolves to the (server-controlled) root itself and carries
// no user input, so it needs no containment check.
const baseResolved = path.resolve(rootAbsDir);
const target = path.resolve(baseResolved, relPath);
if (relPath !== '' && !target.startsWith(baseResolved + path.sep)) {
throw Object.assign(new Error('Path escapes root directory'), { code: 'INVALID_PATH' });
}
let realTarget: string;
try {
realTarget = await fsPromises.realpath(target);
} catch (err: unknown) {
const fsErr = err as NodeJS.ErrnoException;
if (fsErr.code !== 'ENOENT') throw err;
// Walk up to the deepest existing ancestor, then reattach the suffix.
let existing = target;
const suffix: string[] = [];
while (true) {
const parent = path.dirname(existing);
if (parent === existing) {
// Reached filesystem root without finding an existing path.
throw Object.assign(new Error('Path escapes root directory'), { code: 'INVALID_PATH' });
}
suffix.unshift(path.basename(existing));
existing = parent;
if (existing === baseResolved) {
// Reached the root: realpath the untainted base (never a tainted input)
// and reattach the not-yet-existing suffix.
const realBase = await fsPromises.realpath(baseResolved);
if (!isPathWithinBase(realBase, rootAbsDir)) {
throw Object.assign(new Error('Symlink escapes root directory'), { code: 'SYMLINK_ESCAPE' });
}
realTarget = path.join(realBase, ...suffix);
break;
}
// Inline js/path-injection barrier: existing is now strictly below the
// root, so the canonical path.resolve + startsWith form credits the sink.
if (!existing.startsWith(baseResolved + path.sep)) {
throw Object.assign(new Error('Path escapes root directory'), { code: 'INVALID_PATH' });
}
try {
const realExisting = await fsPromises.realpath(existing);
if (!isPathWithinBase(realExisting, rootAbsDir)) {
throw Object.assign(new Error('Symlink escapes root directory'), { code: 'SYMLINK_ESCAPE' });
}
realTarget = path.join(realExisting, ...suffix);
break;
} catch (innerErr: unknown) {
const innerFsErr = innerErr as NodeJS.ErrnoException;
if (innerFsErr.code !== 'ENOENT') throw innerErr;
// Continue walking up.
}
}
}
if (!isPathWithinBase(realTarget, rootAbsDir)) {
throw Object.assign(new Error('Symlink escapes root directory'), { code: 'SYMLINK_ESCAPE' });
}
// A dangling symlink leaf realpath()s to ENOENT (its target is missing), so
// the reattach branch above rebuilds the path with the link name intact and
// it passes the lexical containment check. lstat does not follow the final
// component, so it tells a not-yet-created file (ENOENT, allowed) apart from
// a dangling link (a symlink, rejected) that a follow-on writeFile/readFile
// would traverse out of the root. Mirrors assertRealWithinBase's guard.
let leafIsSymlink = false;
try {
leafIsSymlink = (await fsPromises.lstat(realTarget)).isSymbolicLink();
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
if (leafIsSymlink) {
throw Object.assign(new Error('Symlink escapes root directory'), { code: 'SYMLINK_ESCAPE' });
}
return realTarget;
}
private async resolveSafeStackPath(stackName: string, relPath: string): Promise<string> {
const stackDir = path.join(this.baseDir, stackName);
if (!isPathWithinBase(stackDir, this.baseDir)) {
throw Object.assign(new Error('Stack name escapes compose directory'), { code: 'INVALID_PATH' });
}
return this.resolveSafePathWithin(stackDir, relPath);
}
/**
* Resolve the effective path for an operation that may target the stack source
* dir (default) or a pre-authorized bind-mount root (`scope.rootAbsDir`).
*/
private async resolveScopedPath(stackName: string, relPath: string, scope?: FileRootScope): Promise<string> {
return scope?.rootAbsDir !== undefined
? this.resolveSafePathWithin(scope.rootAbsDir, relPath)
: this.resolveSafeStackPath(stackName, relPath);
}
/** Leaf-path variant of resolveScopedPath (does not follow a symlink leaf). */
private async resolveScopedLeafPath(stackName: string, relPath: string, scope?: FileRootScope): Promise<string> {
return scope?.rootAbsDir !== undefined
? this.resolveSafeLeafPathWithin(scope.rootAbsDir, relPath)
: this.resolveSafeStackLeafPath(stackName, relPath);
}
async listStackDirectory(stackName: string, relPath: string, scope?: FileRootScope): Promise<FileEntry[]> {
const page = await this.listStackDirectoryPage(stackName, relPath, { scope });
return page.entries;
}
/**
* Pagination-aware variant. Returns the sorted entries (optionally truncated
* to `limit`) along with the unfiltered `total` so the route can advertise
* how much was elided. Callers that just want the unbounded array should
* keep using listStackDirectory; the route uses this variant to cap the
* payload for unusually large directories without losing the count.
*/
async listStackDirectoryPage(
stackName: string,
relPath: string,
opts: { limit?: number; scope?: FileRootScope },
): Promise<{ entries: FileEntry[]; total: number; truncated: boolean }> {
const safePath = await this.resolveScopedPath(stackName, relPath, opts.scope);
const protectedEnabled = opts.scope?.protectedEnabled ?? true;
const dirents = await fsPromises.readdir(safePath, { withFileTypes: true });
const total = dirents.length;
const entries = await Promise.all(
dirents.map(async (dirent): Promise<FileEntry> => {
const entryPath = path.join(safePath, dirent.name);
let size = 0;
let mtime = 0;
try {
const st = await fsPromises.stat(entryPath);
size = dirent.isDirectory() ? 0 : st.size;
mtime = st.mtimeMs;
} catch {
// stat can fail for broken symlinks; use defaults.
}
const type: FileEntry['type'] = dirent.isDirectory()
? 'directory'
: dirent.isSymbolicLink()
? 'symlink'
: 'file';
return {
name: dirent.name,
type,
size,
mtime,
isProtected: protectedEnabled && EXPLORER_PROTECTED_STACK_FILES.has(dirent.name),
};
})
);
const sorted = entries.sort((a, b) => {
if (a.type === 'directory' && b.type !== 'directory') return -1;
if (a.type !== 'directory' && b.type === 'directory') return 1;
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
if (opts.limit !== undefined && sorted.length > opts.limit) {
return { entries: sorted.slice(0, opts.limit), total, truncated: true };
}
return { entries: sorted, total, truncated: false };
}
async readStackFile(
stackName: string,
relPath: string,
maxBytes: number = 2 * 1024 * 1024,
opts: { forceText?: boolean; scope?: FileRootScope } = {},
): Promise<{ content?: string; binary: boolean; oversized: boolean; size: number; mime: string; mtimeMs: number }> {
const safePath = await this.resolveScopedPath(stackName, relPath, opts.scope);
const mime = this.guessMime(safePath);
// Open once and stat+read through the same handle so the mtime returned to
// the client matches the bytes it received, even if the file is replaced
// (atomic rename) between the two operations.
const fh = await fsPromises.open(safePath, 'r');
try {
const stat = await fh.stat();
const mtimeMs = stat.mtimeMs;
if (stat.isDirectory()) {
throw Object.assign(new Error('Target is a directory'), { code: 'IS_DIRECTORY' });
}
if (stat.size > maxBytes) {
const probe = Buffer.allocUnsafe(8192);
const { bytesRead } = await fh.read(probe, 0, 8192, 0);
const binary = isBinaryBuffer(probe.subarray(0, bytesRead));
return { binary, oversized: true, size: stat.size, mime, mtimeMs };
}
const buf = await fh.readFile();
// forceText bypasses the binary-detection heuristic so callers can
// recover from false positives (a UTF-8 file that happens to carry a
// NUL or a high non-printable ratio in its first 8 KB). The oversized
// branch above still applies because returning a multi-megabyte file
// as JSON-encoded text is wasteful regardless of the heuristic.
if (!opts.forceText && isBinaryBuffer(buf)) {
return { binary: true, oversized: false, size: stat.size, mime, mtimeMs };
}
return { binary: false, oversized: false, size: stat.size, mime, mtimeMs, content: buf.toString('utf-8') };
} finally {
await fh.close();
}
}
async streamStackFile(
stackName: string,
relPath: string,
scope?: FileRootScope,
): Promise<{ stream: Readable; size: number; filename: string; mime: string }> {
const safePath = await this.resolveScopedPath(stackName, relPath, scope);
const stat = await fsPromises.stat(safePath);
if (stat.isDirectory()) {
throw Object.assign(new Error('Target is a directory'), { code: 'IS_DIRECTORY' });
}
return {
stream: createReadStream(safePath),
size: stat.size,
filename: path.basename(safePath),
mime: this.guessMime(safePath),
};
}
/**
* Atomic write: stages the content in a sibling .tmp file in the same
* directory, fsyncs, then promotes it to the final path. A crash between
* the open and the rename leaves either the original target intact or a
* leftover .tmp file (cleaned up on next failure path), never a truncated
* target.
*
* `exclusive: true` uses link+unlink instead of rename so the create is
* race-free atomic: link fails with EEXIST if the target already exists,
* giving the caller a definitive "did not exist when we wrote it" signal.
* The non-exclusive default uses rename, which is atomic against partial
* reads but clobbers any existing target.
*/
private async writeStackFileAtomic(
safePath: string,
data: string | Buffer | Readable,
opts: { exclusive?: boolean } = {},
): Promise<void> {
await fsPromises.mkdir(path.dirname(safePath), { recursive: true });
// crypto.randomBytes gives a guaranteed-length high-entropy suffix; Math.random
// can drop leading zeros which narrows entropy unpredictably.
const suffix = `${process.pid}-${Date.now()}-${crypto.randomBytes(6).toString('hex')}`;
const tmpPath = `${safePath}.sencho-tmp-${suffix}`;
let stagedTmp = false;
try {
if (data instanceof Readable) {
// Stream a temp-file source (an upload spooled to disk) into the staging
// file without buffering it in memory. 'wx' exclusively creates the
// staging file; the random suffix already guarantees a fresh name.
const ws = createWriteStream(tmpPath, { flags: 'wx' });
stagedTmp = true;
await pipeline(data, ws);
const synced = await fsPromises.open(tmpPath, 'r+');
try {
await synced.sync();
} finally {
await synced.close();
}
} else {
const fh = await fsPromises.open(tmpPath, 'wx');
stagedTmp = true;
try {
await fh.writeFile(data);
await fh.sync();
} finally {
await fh.close();
}
}
if (opts.exclusive) {
// link() is atomic against EEXIST. Tmp and target are guaranteed to live
// in the same directory (same filesystem); link works on NTFS and any
// POSIX FS without elevated privileges.
try {
await fsPromises.link(tmpPath, safePath);
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'EEXIST') {
throw Object.assign(new Error('File already exists'), { code: 'FILE_EXISTS' as const });
}
throw err;
}
} else {
await fsPromises.rename(tmpPath, safePath);
stagedTmp = false;
}
} finally {
if (stagedTmp) {
await fsPromises.unlink(tmpPath).catch(() => {});
}
}
}
async writeStackFile(
stackName: string,
relPath: string,
content: string | Buffer,
opts?: { exclusive?: boolean },
): Promise<void> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
await this.writeStackFileAtomic(safePath, content, opts);
}
/**
* Atomic, scoped write whose source is a temp file on disk (an upload spooled
* by multer's diskStorage). Streams the temp file into a staging sibling in the
* target's own directory, fsyncs, then links/renames into place, so a large
* upload is never buffered in memory and the temp file's filesystem can differ
* from the stack/volume filesystem (no cross-device rename). The caller owns
* deleting tempPath.
*/
async writeScopedFileFromTemp(
stackName: string,
relPath: string,
tempPath: string,
opts?: { exclusive?: boolean; scope?: FileRootScope },
): Promise<void> {
const safePath = await this.resolveScopedPath(stackName, relPath, opts?.scope);
await this.writeStackFileAtomic(safePath, createReadStream(tempPath), { exclusive: opts?.exclusive });
}
/**
* Returns 'file' or 'directory' if the resolved path exists, null if it
* does not. Path-resolution errors (INVALID_PATH, SYMLINK_ESCAPE) propagate
* so callers do not silently treat a malformed path as 'available for write'.
* Callers should validate inputs upstream before invoking this helper.
*/
async pathKind(stackName: string, relPath: string, scope?: FileRootScope): Promise<'file' | 'directory' | null> {
const safePath = await this.resolveScopedPath(stackName, relPath, scope);
try {
const stat = await fsPromises.lstat(safePath);
if (stat.isDirectory()) return 'directory';
return 'file';
} catch (err: unknown) {
const e = err as NodeJS.ErrnoException;
if (e.code === 'ENOENT') return null;
throw err;
}
}
/**
* Optimistic-concurrency write for arbitrary stack files (file-explorer
* editor save path). If `expectedMtimeMs` is provided, opens the target,
* stats it, and refuses the write (returning current content + mtime) when
* the stat does not match the caller's expectation. Mirrors the
* compose-file pattern in saveStackContentIfUnchanged.
*
* Mtime comparison uses Math.floor so sub-millisecond jitter between
* different filesystems and Node versions does not produce false 412s.
*
* If the file does not exist yet, the write proceeds (no mtime to compare).
* Returns the new mtimeMs so the route can emit a fresh ETag.
*/
async writeStackFileIfUnchanged(
stackName: string,
relPath: string,
content: string,
expectedMtimeMs: number | null,
scope?: FileRootScope,
): Promise<
| { ok: true; mtimeMs: number }
| { ok: false; currentMtimeMs: number; currentContent: string }
> {
const safePath = await this.resolveScopedPath(stackName, relPath, scope);
await fsPromises.mkdir(path.dirname(safePath), { recursive: true });
if (expectedMtimeMs !== null) {
let fh: import('fs/promises').FileHandle | null = null;
try {
fh = await fsPromises.open(safePath, 'r');
const stat = await fh.stat();
if (Math.floor(stat.mtimeMs) !== Math.floor(expectedMtimeMs)) {
const currentContent = await fh.readFile('utf-8');
return { ok: false, currentMtimeMs: stat.mtimeMs, currentContent };
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
// The caller expected an existing file but it has been deleted under
// the editor. That is itself a conflict (the file is gone, the user
// is editing into a void) so surface it the same way as a stale-mtime
// mismatch. An empty current snapshot tells the client "the live
// version is gone, you are starting from scratch".
return { ok: false, currentMtimeMs: 0, currentContent: '' };
} finally {
if (fh) await fh.close();
}
}
// Promote through the atomic stage-and-rename helper so a crash or write
// failure leaves the previous target intact rather than a truncated file.
// rename also replaces a (rejected upstream) symlink leaf in place instead
// of following it, so the write can never land outside the root.
await this.writeStackFileAtomic(safePath, content);
const newStat = await fsPromises.stat(safePath);
return { ok: true, mtimeMs: newStat.mtimeMs };
}
/**
* Like resolveSafeStackPath but does NOT follow a symlink at the leaf.
* Path-component symlinks are still resolved and validated (so a symlinked
* parent that escapes the stack dir still throws SYMLINK_ESCAPE), but the
* final entry stays as the link path the user sees in the tree. Callers
* use this to act on the link entry itself (unlink the link, not the
* target) for operations where following would mutate a file other than
* the one the user clicked on.
*/
private async resolveSafeLeafPathWithin(rootAbsDir: string, relPath: string): Promise<string> {
if (relPath === '' || relPath === '.') {
return this.resolveSafePathWithin(rootAbsDir, '');
}
const parentRel = path.dirname(relPath);
const baseName = path.basename(relPath);
if (!baseName || baseName === '.' || baseName === '..') {
throw Object.assign(new Error('Invalid path'), { code: 'INVALID_PATH' });
}
const safeParent = await this.resolveSafePathWithin(rootAbsDir, parentRel === '.' ? '' : parentRel);
return path.join(safeParent, baseName);
}
private async resolveSafeStackLeafPath(stackName: string, relPath: string): Promise<string> {
const stackDir = path.join(this.baseDir, stackName);
if (!isPathWithinBase(stackDir, this.baseDir)) {
throw Object.assign(new Error('Stack name escapes compose directory'), { code: 'INVALID_PATH' });
}
return this.resolveSafeLeafPathWithin(stackDir, relPath);
}
async deleteStackPath(stackName: string, relPath: string, recursive: boolean = false, scope?: FileRootScope): Promise<void> {
if ((scope?.protectedEnabled ?? true) && isProtectedRelPath(relPath)) throw protectedFileError(relPath);
const leafPath = await this.resolveScopedLeafPath(stackName, relPath, scope);
// Branch on whether the leaf is a symlink BEFORE following it. Deleting
// a symlink should remove the link entry the user clicked on; following
// through to the target would silently delete a file with a different
// name and leave the link entry dangling.
const leafStat = await fsPromises.lstat(leafPath);
if (leafStat.isSymbolicLink()) {
await fsPromises.unlink(leafPath);
return;
}
if (recursive) {
await fsPromises.rm(leafPath, { recursive: true, force: true });
return;
}
try {
await fsPromises.unlink(leafPath);
} catch (err: unknown) {
const e = err as NodeJS.ErrnoException;
if (e.code === 'EISDIR') {
try {
await fsPromises.rmdir(leafPath);
} catch (inner: unknown) {
const ie = inner as NodeJS.ErrnoException;
if (ie.code === 'ENOTEMPTY' || ie.code === 'EEXIST') {
throw Object.assign(new Error('Directory is not empty'), { code: 'NOT_EMPTY' });
}
throw inner;
}
} else {
throw err;
}
}
}
async mkdirStackPath(stackName: string, relPath: string, scope?: FileRootScope): Promise<void> {
const safePath = await this.resolveScopedPath(stackName, relPath, scope);
await fsPromises.mkdir(safePath, { recursive: true });
}
/**
* Renames or moves an entry within a stack. The source and destination may sit
* in different directories (a cross-directory move), since fs.rename relocates
* natively. Both paths resolve through the leaf helper so a symlink source is
* moved as the link entry itself rather than followed to its target, matching
* the delete/chmod policy. fs.rename fails with EXDEV across a filesystem
* boundary (e.g. a bind-mounted subdirectory); the route surfaces that as a 409.
*/
async renameStackPath(stackName: string, fromRel: string, toRel: string, scope?: FileRootScope): Promise<void> {
if (scope?.protectedEnabled ?? true) {
if (isProtectedRelPath(fromRel)) throw protectedFileError(fromRel);
if (isProtectedRelPath(toRel)) throw protectedFileError(toRel);
}
const fromPath = await this.resolveScopedLeafPath(stackName, fromRel, scope);
const toPath = await this.resolveScopedLeafPath(stackName, toRel, scope);
const toName = path.basename(toPath);
if (!toName || toName === '.' || toName === '..') {
throw Object.assign(new Error('Invalid destination name'), { code: 'INVALID_PATH' });
}
// Block moving a directory into itself or one of its own descendants; fs.rename
// would otherwise fail with an opaque EINVAL/EPERM.
const fromStat = await fsPromises.lstat(fromPath);
if (fromStat.isDirectory() && isSameOrDescendantFsPath(fromPath, toPath)) {
throw Object.assign(new Error('Cannot move a folder into itself'), { code: 'INVALID_PATH' });
}
// Prevent overwriting an existing path. lstat (not access) so a dangling
// symlink already at the destination still counts as occupied.
try {
await fsPromises.lstat(toPath);
throw Object.assign(new Error('A file or folder with that name already exists'), { code: 'EEXIST' });
} catch (e: unknown) {
const fe = e as NodeJS.ErrnoException;
if (fe.code !== 'ENOENT') throw e;
}
await fsPromises.rename(fromPath, toPath);
}
/**
* Copies a file or directory within a single root. The source resolves through
* the leaf helper and the copy does not dereference symlinks, so a symlink
* entry is copied as a link (matching the delete/rename leaf policy) rather
* than followed to its target. Only the destination is protection-checked:
* duplicating a protected file (e.g. compose.yaml) elsewhere is allowed, but a
* copy cannot create a reserved name at a protected root. An existing
* destination is rejected (surfaced as EEXIST, which the route maps to 409).
*/
async copyScopedPath(stackName: string, fromRel: string, toRel: string, scope?: FileRootScope): Promise<void> {
if ((scope?.protectedEnabled ?? true) && isProtectedRelPath(toRel)) throw protectedFileError(toRel);
const fromPath = await this.resolveScopedLeafPath(stackName, fromRel, scope);
const toPath = await this.resolveScopedLeafPath(stackName, toRel, scope);
const toName = path.basename(toPath);
if (!toName || toName === '.' || toName === '..') {
throw Object.assign(new Error('Invalid destination name'), { code: 'INVALID_PATH' });
}
// Block copying a directory into itself or one of its own descendants;
// fs.cp would otherwise recurse into the copy it is creating.
const fromStat = await fsPromises.lstat(fromPath);
if (fromStat.isDirectory() && isSameOrDescendantFsPath(fromPath, toPath)) {
throw Object.assign(new Error('Cannot copy a folder into itself'), { code: 'INVALID_PATH' });
}
try {
await fsPromises.cp(fromPath, toPath, {
recursive: fromStat.isDirectory(),
dereference: false,
errorOnExist: true,
force: false,
});
} catch (err: unknown) {
// fs.cp raises ERR_FS_CP_EEXIST when the destination already exists; remap
// to EEXIST so the route returns 409, matching rename's conflict handling.
if ((err as NodeJS.ErrnoException).code === 'ERR_FS_CP_EEXIST') {
throw Object.assign(new Error('A file or folder with that name already exists'), { code: 'EEXIST' });
}
throw err;
}
}
async getStackEntryMode(stackName: string, relPath: string, scope?: FileRootScope): Promise<{ mode: number; octal: string }> {
const safePath = await this.resolveScopedPath(stackName, relPath, scope);
const stat = await fsPromises.stat(safePath);
const mode = stat.mode & 0o777;
return { mode, octal: mode.toString(8).padStart(3, '0') };
}
async chmodStackPath(stackName: string, relPath: string, mode: number, scope?: FileRootScope): Promise<void> {
if (!Number.isInteger(mode) || mode < 0 || mode > 0o777) {
throw Object.assign(new Error('Invalid permission bits'), { code: 'INVALID_PATH' });
}
const leafPath = await this.resolveScopedLeafPath(stackName, relPath, scope);
// chmod on a symlink is rejected. Following the link would silently
// mutate permissions on a file with a different name than the entry the
// user clicked on. Node's fsPromises.lchmod is macOS-only, so for the
// common Linux/Windows case there is no safe in-place alternative; we
// surface a clear error so the user edits the target file directly.
const leafStat = await fsPromises.lstat(leafPath);
if (leafStat.isSymbolicLink()) {
throw Object.assign(
new Error('Cannot change permissions of a symlink. Edit the target file directly.'),
{ code: 'LINK_CHMOD_UNSUPPORTED' as const },
);
}
await fsPromises.chmod(leafPath, mode);
}
async statStackEntry(stackName: string, relPath: string, scope?: FileRootScope): Promise<FileEntry> {
const safePath = await this.resolveScopedPath(stackName, relPath, scope);
// Use lstat so symlinks are reported as 'symlink' rather than resolved to target type.
const stat = await fsPromises.lstat(safePath);
const name = path.basename(safePath);
const type: FileEntry['type'] = stat.isDirectory()
? 'directory'
: stat.isSymbolicLink()
? 'symlink'
: 'file';
return {
name,
type,
size: stat.isDirectory() ? 0 : stat.size,
mtime: stat.mtimeMs,
isProtected: (scope?.protectedEnabled ?? true) && EXPLORER_PROTECTED_STACK_FILES.has(name),
};
}
}