mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 15:46:43 +00:00
578ce7684d
* 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.
1082 lines
59 KiB
TypeScript
1082 lines
59 KiB
TypeScript
import { describe, it, expect, afterEach, vi } from 'vitest';
|
|
import fs from 'fs';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import { ComposeInputDiscoveryService } from '../services/ComposeInputDiscoveryService';
|
|
import type { ManifestBounds } from '../types/gitProjectManifest';
|
|
|
|
const BOUNDS: ManifestBounds = {
|
|
maxFiles: 10_000,
|
|
maxBytes: 512 * 1024 * 1024,
|
|
maxContextBytes: 256 * 1024 * 1024,
|
|
maxPathDepth: 64,
|
|
maxFileBytes: 10 * 1024 * 1024,
|
|
};
|
|
|
|
let tmpRoots: string[] = [];
|
|
|
|
function makeClone(files: Record<string, string>): string {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-disc-'));
|
|
tmpRoots.push(dir);
|
|
for (const [rel, content] of Object.entries(files)) {
|
|
const abs = path.join(dir, rel);
|
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
fs.writeFileSync(abs, content);
|
|
}
|
|
return dir;
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const dir of tmpRoots) {
|
|
try {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
} catch {
|
|
// best-effort cleanup
|
|
}
|
|
}
|
|
tmpRoots = [];
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
function discovery() {
|
|
return ComposeInputDiscoveryService.getInstance();
|
|
}
|
|
|
|
describe('discoverFromClone', () => {
|
|
it('classifies include, env_file and config inputs as managed', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': 'include:\n - common/redis.yaml\nservices:\n web:\n image: nginx\n env_file: web.env\n configs: [cfg]\nconfigs:\n cfg:\n file: nginx/nginx.conf\n',
|
|
// Included files resolve their own relative paths against their
|
|
// own directory (each included file is its own project).
|
|
'common/redis.yaml': 'services:\n redis:\n image: redis\n env_file: redis.env\n',
|
|
'web.env': 'FOO=bar\n',
|
|
'common/redis.env': 'REDIS=1\n',
|
|
'nginx/nginx.conf': 'server {}\n',
|
|
'.env': 'PROJECT=1\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals).toEqual([]);
|
|
const byKind = (k: string) => result.inputs.filter((i) => i.dependencyKind === k);
|
|
expect(byKind('explicit').map((i) => i.sourcePath)).toEqual(['compose.yaml']);
|
|
expect(byKind('explicit')[0].materializedPath).toBe('compose.yaml');
|
|
expect(byKind('include')[0].sourcePath).toBe('common/redis.yaml');
|
|
const envFiles = byKind('env_file');
|
|
expect(envFiles.some((i) => i.sourcePath === 'web.env')).toBe(true);
|
|
// The included file's env_file resolves against its own directory.
|
|
expect(envFiles.some((i) => i.sourcePath === 'common/redis.env')).toBe(true);
|
|
expect(byKind('config')[0].sourcePath).toBe('nginx/nginx.conf');
|
|
// The included project's default interpolation .env (common/.env) is
|
|
// absent here, so it is recorded unmanaged and tolerated; every file
|
|
// actually present in the repository is managed.
|
|
const missingDefaultEnv = result.inputs.find((i) => i.dependencyKind === 'interpolation-env' && i.sourcePath === 'common/.env');
|
|
expect(missingDefaultEnv?.ownership).toBe('unmanaged');
|
|
expect(missingDefaultEnv?.note).toContain('No project .env');
|
|
expect(result.inputs.filter((i) => i.ownership === 'unmanaged')).toHaveLength(1);
|
|
expect(result.inputs.filter((i) => i.ownership === 'managed').every((i) => i.state === 'present')).toBe(true);
|
|
// content hashes are computed for file-backed inputs.
|
|
expect(envFiles[0].contentSha256).toBeTruthy();
|
|
});
|
|
|
|
it('refuses out-of-bound ../ include targets', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': 'include:\n - ../outside.yaml\nservices: {}\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals.some((r) => r.kind === 'out-of-bounds' && r.actionable)).toBe(true);
|
|
});
|
|
|
|
it('refuses URL includes', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': 'include:\n - https://example.com/remote.yaml\nservices: {}\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals.some((r) => r.kind === 'url-include')).toBe(true);
|
|
});
|
|
|
|
it('refuses symlink inputs as unsafe-symlink', async () => {
|
|
const clone = makeClone({ 'compose.yaml': 'services:\n web:\n image: nginx\n env_file: linked.env\n' });
|
|
// Create a real target and a symlink pointing at it.
|
|
fs.writeFileSync(path.join(clone, 'target.env'), 'X=1\n');
|
|
try {
|
|
fs.symlinkSync('target.env', path.join(clone, 'linked.env'));
|
|
} catch {
|
|
// Symlinks unavailable (Windows perms); skip.
|
|
return;
|
|
}
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals.some((r) => r.kind === 'unsafe-symlink')).toBe(true);
|
|
});
|
|
|
|
it('refuses special files as special-file', async () => {
|
|
const clone = makeClone({ 'compose.yaml': 'services:\n web:\n image: nginx\n env_file: fifo.env\n' });
|
|
// FIFOs cannot be created on Windows; mock the lstat result instead.
|
|
fs.writeFileSync(path.join(clone, 'fifo.env'), 'X=1\n');
|
|
const originalLstat = fs.promises.lstat.bind(fs.promises);
|
|
vi.spyOn(fs.promises, 'lstat').mockImplementation(async (p) => {
|
|
if (String(p).endsWith('fifo.env')) {
|
|
return {
|
|
isSymbolicLink: () => false,
|
|
isDirectory: () => false,
|
|
isFile: () => true,
|
|
isCharacterDevice: () => true,
|
|
isBlockDevice: () => false,
|
|
isSocket: () => false,
|
|
isFIFO: () => false,
|
|
size: 4,
|
|
} as unknown as fs.Stats;
|
|
}
|
|
return originalLstat(p);
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals.some((r) => r.kind === 'special-file')).toBe(true);
|
|
});
|
|
|
|
it('refuses paths inside submodules', async () => {
|
|
const clone = makeClone({
|
|
'.gitmodules': '[submodule "vendor"]\n\tpath = vendor/lib\n',
|
|
'compose.yaml': 'services:\n web:\n image: nginx\n env_file: vendor/lib/settings.env\n',
|
|
'vendor/lib/settings.env': 'X=1\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals.some((r) => r.kind === 'submodule' && r.sourcePath === 'vendor/lib/settings.env')).toBe(true);
|
|
});
|
|
|
|
it('refuses LFS pointer content for file-backed inputs', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': 'services:\n web:\n image: nginx\n env_file: lfs.env\n',
|
|
'lfs.env': 'version https://git-lfs.github.com/spec/v1\noid sha256:abcd\ntype file\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals.some((r) => r.kind === 'lfs-pointer')).toBe(true);
|
|
});
|
|
|
|
it('records host binds and external resources as unmanaged', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': `services:
|
|
web:
|
|
image: nginx
|
|
volumes:
|
|
- /var/run/docker.sock:/var/run/docker.sock
|
|
- ./data:/data
|
|
configs:
|
|
ext:
|
|
external: true
|
|
`,
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
|
|
const unmanaged = result.inputs.filter((i) => i.ownership === 'unmanaged');
|
|
expect(unmanaged.some((i) => i.dependencyKind === 'bind-mount')).toBe(true);
|
|
expect(unmanaged.some((i) => i.dependencyKind === 'config' && i.sourcePath === null)).toBe(true);
|
|
expect(unmanaged.every((i) => i.deletionAuthority === 'none')).toBe(true);
|
|
});
|
|
|
|
it('materializes build contexts with dockerignore filtering and stable byte accounting', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': 'services:\n web:\n build:\n context: web\n',
|
|
'web/.dockerignore': 'node_modules\n*.log\n',
|
|
'web/Dockerfile': 'FROM node\n',
|
|
'web/index.js': 'console.log(1)\n',
|
|
'web/node_modules/pkg/index.js': 'big\n',
|
|
'web/debug.log': 'trace\n',
|
|
});
|
|
const first = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(first.refusals).toEqual([]);
|
|
expect(first.buildContexts).toHaveLength(1);
|
|
const ctx = first.buildContexts[0];
|
|
expect(ctx.repoPath).toBe('web');
|
|
expect(ctx.dockerignoreApplied).toBe(true);
|
|
// node_modules + *.log ignored: only Dockerfile + index.js + .dockerignore counted.
|
|
expect(ctx.ignoredCount).toBeGreaterThanOrEqual(2);
|
|
expect(ctx.contextBytes).toBeGreaterThan(0);
|
|
|
|
// Context byte accounting is stable across pulls of the same revision.
|
|
const second = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(second.buildContexts[0].contextBytes).toBe(first.buildContexts[0].contextBytes);
|
|
});
|
|
|
|
it('refuses a repo-root build context that exceeds the context cap', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': 'services:\n web:\n build: .\n',
|
|
'a.bin': 'a'.repeat(500),
|
|
'b.bin': 'b'.repeat(500),
|
|
'c.bin': 'c'.repeat(500),
|
|
});
|
|
const bounds = { ...BOUNDS, maxContextBytes: 1000 };
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds });
|
|
expect(result.refusals.some((r) => r.kind === 'context-unbounded')).toBe(true);
|
|
});
|
|
|
|
it('counts a shared-context union once per unique file', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': `services:
|
|
one:
|
|
build:
|
|
context: web
|
|
dockerfile: Dockerfile.one
|
|
two:
|
|
build:
|
|
context: web
|
|
dockerfile: Dockerfile.two
|
|
`,
|
|
'web/Dockerfile.one': 'FROM scratch\n',
|
|
'web/Dockerfile.two': 'FROM scratch\n',
|
|
'web/Dockerfile.one.dockerignore': 'b.bin\n',
|
|
'web/Dockerfile.two.dockerignore': 'a.bin\n',
|
|
'web/common.bin': 'c'.repeat(100),
|
|
'web/a.bin': 'a'.repeat(100),
|
|
'web/b.bin': 'b'.repeat(100),
|
|
});
|
|
const result = await discovery().discoverFromClone({
|
|
cloneDir: clone,
|
|
composePaths: ['compose.yaml'],
|
|
contextDir: null,
|
|
bounds: { ...BOUNDS, maxContextBytes: 350 },
|
|
});
|
|
|
|
expect(result.refusals.some((r) => r.kind === 'context-unbounded')).toBe(false);
|
|
expect(result.buildContexts).toHaveLength(1);
|
|
expect(result.buildContexts[0].contextBytes).toBe(338);
|
|
expect(result.buildContexts[0].files).toHaveLength(7);
|
|
|
|
const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-context-union-'));
|
|
tmpRoots.push(dest);
|
|
const managedFiles = result.inputs
|
|
.filter((input) => input.ownership === 'managed'
|
|
&& input.materializedPath !== null
|
|
&& input.dependencyKind !== 'build-context'
|
|
&& input.dependencyKind !== 'build-additional-context')
|
|
.map((input) => ({ srcRel: input.sourcePath!, destRel: input.materializedPath! }));
|
|
await discovery().walkAndCopy(clone, dest, managedFiles, result.contextCopyPlans, BOUNDS);
|
|
expect(fs.existsSync(path.join(dest, 'web/a.bin'))).toBe(true);
|
|
expect(fs.existsSync(path.join(dest, 'web/b.bin'))).toBe(true);
|
|
});
|
|
|
|
it('refuses LFS pointers inside a build context', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': 'services:\n web:\n build: web\n',
|
|
'web/Dockerfile': 'FROM node\n',
|
|
'web/model.bin': 'version https://git-lfs.github.com/spec/v1\noid sha256:abcd\ntype file\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals.some((r) => r.kind === 'lfs-in-context')).toBe(true);
|
|
});
|
|
|
|
it('discovers the implicit compose override for single-file stacks only', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': 'services:\n web:\n image: nginx\n',
|
|
'compose.override.yaml': 'services:\n web:\n environment:\n A: b\n',
|
|
});
|
|
const single = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(single.inputs.some((i) => i.dependencyKind === 'implicit-override' && i.sourcePath === 'compose.override.yaml')).toBe(true);
|
|
|
|
const multi = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml', 'compose.override.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(multi.inputs.some((i) => i.dependencyKind === 'implicit-override')).toBe(false);
|
|
});
|
|
|
|
it('scopes implicit override discovery to the primary file directory (monorepo isolation)', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': 'services:\n app:\n image: nginx\n ports: ["8099:80"]\n',
|
|
'compose.override.yaml': 'services:\n app:\n environment:\n FROM: root\n',
|
|
'monorepo/project-a/compose.yaml': 'services:\n a:\n image: alpine\n',
|
|
'monorepo/project-a/compose.override.yaml': 'services:\n a:\n environment:\n FROM: nested\n',
|
|
'monorepo/project-b/compose.yaml': 'services:\n b:\n image: busybox\n',
|
|
});
|
|
|
|
const nestedWithLocal = await discovery().discoverFromClone({
|
|
cloneDir: clone,
|
|
composePaths: ['monorepo/project-a/compose.yaml'],
|
|
contextDir: null,
|
|
bounds: BOUNDS,
|
|
});
|
|
const overrideA = nestedWithLocal.inputs.find((i) => i.dependencyKind === 'implicit-override');
|
|
expect(overrideA?.sourcePath).toBe('monorepo/project-a/compose.override.yaml');
|
|
expect(overrideA?.materializedPath).toBe('compose.override.yaml');
|
|
expect(nestedWithLocal.inputs.some((i) => i.sourcePath === 'compose.override.yaml')).toBe(false);
|
|
|
|
const nestedWithoutLocal = await discovery().discoverFromClone({
|
|
cloneDir: clone,
|
|
composePaths: ['monorepo/project-b/compose.yaml'],
|
|
contextDir: null,
|
|
bounds: BOUNDS,
|
|
});
|
|
expect(nestedWithoutLocal.inputs.some((i) => i.dependencyKind === 'implicit-override')).toBe(false);
|
|
});
|
|
|
|
it('refuses case-only materialized path collisions instead of dropping one file', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': 'include:\n - case/Config.yml\n - case/config.yml\nservices: {}\n',
|
|
'case/Config.yml': 'services:\n upper:\n image: alpine\n',
|
|
'case/config.yml': 'services:\n lower:\n image: busybox\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({
|
|
cloneDir: clone,
|
|
composePaths: ['compose.yaml'],
|
|
contextDir: null,
|
|
bounds: BOUNDS,
|
|
});
|
|
expect(result.refusals.some((r) => r.kind === 'case-collision' && r.actionable)).toBe(true);
|
|
const collision = result.refusals.find((r) => r.kind === 'case-collision');
|
|
expect(collision?.reason).toMatch(/Config\.yml/i);
|
|
expect(collision?.reason).toMatch(/config\.yml/i);
|
|
const caseInputs = result.inputs.filter(
|
|
(i) => i.materializedPath !== null && /^case\/config\.yml$/i.test(i.materializedPath),
|
|
);
|
|
expect(caseInputs).toHaveLength(1);
|
|
});
|
|
|
|
it('materializes env_file paths that contain a literal $ but no Compose variable', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': 'services:\n web:\n image: nginx\n env_file: config$.env\n',
|
|
'config$.env': 'A=1\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({
|
|
cloneDir: clone,
|
|
composePaths: ['compose.yaml'],
|
|
contextDir: null,
|
|
bounds: BOUNDS,
|
|
});
|
|
const env = result.inputs.find((i) => i.dependencyKind === 'env_file' && i.sourcePath === 'config$.env');
|
|
expect(env?.ownership).toBe('managed');
|
|
expect(env?.materializedPath).toBe('config$.env');
|
|
expect(result.dynamic.some((d) => d.sourcePath === 'config$.env')).toBe(false);
|
|
});
|
|
|
|
it('enforces the aggregate file and byte caps during classification', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': 'services:\n web:\n image: nginx\n env_file:\n - a.env\n - b.env\n - c.env\n',
|
|
'a.env': 'A=1\n',
|
|
'b.env': 'B=1\n',
|
|
'c.env': 'C=1\n',
|
|
});
|
|
const fileBounds = { ...BOUNDS, maxFiles: 3 }; // compose.yaml + a.env + b.env fit; c.env crosses
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: fileBounds });
|
|
expect(result.refusals.some((r) => r.kind === 'too-many-files')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('walkAndCopy', () => {
|
|
it('copies managed files preserving the nested layout, skipping .git', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': 'services: {}\n',
|
|
'deploy/prod.yaml': 'services: {}\n',
|
|
'.git/config': '[remote]\n',
|
|
});
|
|
const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-cand-'));
|
|
tmpRoots.push(dest);
|
|
const result = await discovery().walkAndCopy(clone, dest, [
|
|
{ srcRel: 'compose.yaml', destRel: 'compose.yaml' },
|
|
{ srcRel: 'deploy/prod.yaml', destRel: 'deploy/prod.yaml' },
|
|
], [], BOUNDS);
|
|
expect(result.copiedFiles).toBe(2);
|
|
expect(fs.existsSync(path.join(dest, 'compose.yaml'))).toBe(true);
|
|
expect(fs.existsSync(path.join(dest, 'deploy/prod.yaml'))).toBe(true);
|
|
expect(fs.existsSync(path.join(dest, '.git'))).toBe(false);
|
|
});
|
|
|
|
it('copies build contexts with dockerignore filtering', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': 'services:\n web:\n build:\n context: web\n',
|
|
'web/.dockerignore': 'node_modules\n',
|
|
'web/Dockerfile': 'FROM node\n',
|
|
'web/index.js': 'console.log(1)\n',
|
|
'web/node_modules/pkg/index.js': 'big\n',
|
|
});
|
|
const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-cand-'));
|
|
tmpRoots.push(dest);
|
|
const discovery = ComposeInputDiscoveryService.getInstance();
|
|
const inventory = await discovery.discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
|
|
await discovery.walkAndCopy(clone, dest, [], inventory.contextCopyPlans, BOUNDS);
|
|
expect(fs.existsSync(path.join(dest, 'web/Dockerfile'))).toBe(true);
|
|
expect(fs.existsSync(path.join(dest, 'web/index.js'))).toBe(true);
|
|
expect(fs.existsSync(path.join(dest, 'web/node_modules'))).toBe(false);
|
|
});
|
|
|
|
it('throws with running counts when the byte cap is crossed mid-copy', async () => {
|
|
const clone = makeClone({
|
|
'a.bin': 'a'.repeat(1024),
|
|
'b.bin': 'b'.repeat(1024),
|
|
});
|
|
const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-cand-'));
|
|
tmpRoots.push(dest);
|
|
const bounds = { ...BOUNDS, maxBytes: 1500 };
|
|
await expect(
|
|
discovery().walkAndCopy(clone, dest, [
|
|
{ srcRel: 'a.bin', destRel: 'a.bin' },
|
|
{ srcRel: 'b.bin', destRel: 'b.bin' },
|
|
], [], bounds),
|
|
).rejects.toThrow(/exceeds 1500 bytes/);
|
|
});
|
|
|
|
it('rejects case-colliding destination paths', async () => {
|
|
const clone = makeClone({ 'A.TXT': '1\n', 'a.txt': '2\n' });
|
|
const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-cand-'));
|
|
tmpRoots.push(dest);
|
|
await expect(
|
|
discovery().walkAndCopy(clone, dest, [
|
|
{ srcRel: 'A.TXT', destRel: 'A.TXT' },
|
|
{ srcRel: 'a.txt', destRel: 'a.txt' },
|
|
], [], BOUNDS),
|
|
).rejects.toThrow(/case-insensitive/);
|
|
});
|
|
});
|
|
|
|
describe('syncEnv ownership (audit C-2)', () => {
|
|
it('marks the repo-root .env unmanaged and unhashed when syncEnv owns the path', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': 'services:\n web:\n image: nginx\n',
|
|
'.env': 'REPO=1\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, syncEnv: true, bounds: BOUNDS });
|
|
const envEntries = result.inputs.filter((i) => i.materializedPath === '.env' || i.dependencyKind === 'interpolation-env');
|
|
expect(envEntries).toHaveLength(1);
|
|
expect(envEntries[0].ownership).toBe('unmanaged');
|
|
expect(envEntries[0].contentSha256).toBeNull();
|
|
expect(envEntries[0].deletionAuthority).toBe('none');
|
|
});
|
|
|
|
it('keeps the repo .env managed when syncEnv is off', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': 'services:\n web:\n image: nginx\n',
|
|
'.env': 'REPO=1\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, syncEnv: false, bounds: BOUNDS });
|
|
const envEntries = result.inputs.filter((i) => i.dependencyKind === 'interpolation-env');
|
|
expect(envEntries).toHaveLength(1);
|
|
expect(envEntries[0].ownership).toBe('managed');
|
|
expect(envEntries[0].contentSha256).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe('explicit dockerfile resolution (audit round 2 C-3)', () => {
|
|
it('rebases the dockerfile against its build context', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': 'services:\n web:\n build:\n context: web\n dockerfile: Dockerfile.dev\n',
|
|
'web/Dockerfile.dev': 'FROM nginx\n',
|
|
'web/app.js': 'console.log(1)\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals).toEqual([]);
|
|
expect(result.buildContexts).toHaveLength(1);
|
|
expect(result.buildContexts[0].repoPath).toBe('web');
|
|
expect(result.buildContexts[0].dockerfile).toBe('web/Dockerfile.dev');
|
|
// The dockerfile's files are part of the context inventory.
|
|
expect(result.buildContexts[0].files.some((f) => f.path === 'Dockerfile.dev')).toBe(true);
|
|
});
|
|
|
|
it('allows a ../ dockerfile inside the repository and materializes it separately', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': 'services:\n web:\n build:\n context: web\n dockerfile: ../shared/Dockerfile\n',
|
|
'web/app.js': 'x\n',
|
|
'shared/Dockerfile': 'FROM nginx\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals).toEqual([]);
|
|
const dockerfileEntry = result.inputs.find((i) => i.dependencyKind === 'dockerfile' && i.materializedPath === 'shared/Dockerfile');
|
|
expect(dockerfileEntry).toBeTruthy();
|
|
expect(dockerfileEntry?.ownership).toBe('managed');
|
|
expect(dockerfileEntry?.contentSha256).toBeTruthy();
|
|
});
|
|
|
|
it('refuses a dockerfile that escapes the repository', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': 'services:\n web:\n build:\n context: web\n dockerfile: ../../outside/Dockerfile\n',
|
|
'web/app.js': 'x\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals.some((r) => r.kind === 'out-of-bounds' && String(r.sourcePath).includes('Dockerfile'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('dynamic and submodule-backed inputs (audit round 8 B-2)', () => {
|
|
it('persists dynamic ${VAR} paths as explicit unmanaged entries', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': 'services:\n web:\n image: nginx\n env_file: ${ENV_FILE:-default.env}\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals).toEqual([]);
|
|
const dyn = result.inputs.filter((i) => i.dependencyKind === 'env_file' && i.sourcePath?.includes('${ENV_FILE'));
|
|
expect(dyn).toHaveLength(1);
|
|
expect(dyn[0].ownership).toBe('unmanaged');
|
|
expect(dyn[0].materializedPath).toBeNull();
|
|
expect(dyn[0].deletionAuthority).toBe('none');
|
|
expect(dyn[0].note).toContain('resolved by Compose at deploy time');
|
|
// The dynamic path is never searched for as a file in the clone.
|
|
expect(result.refusals.some((r) => r.kind === 'missing-file')).toBe(false);
|
|
});
|
|
|
|
it('refuses a build context rooted inside a submodule', async () => {
|
|
const clone = makeClone({
|
|
'.gitmodules': '[submodule "vendor"]\n\tpath = vendor/lib\n',
|
|
'compose.yaml': 'services:\n web:\n build: vendor/lib\n',
|
|
'vendor/lib/Dockerfile': 'FROM nginx\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals.some((r) => r.kind === 'submodule' && String(r.sourcePath).includes('vendor/lib'))).toBe(true);
|
|
});
|
|
|
|
it('refuses a repo-root context containing a submodule directory', async () => {
|
|
const clone = makeClone({
|
|
'.gitmodules': '[submodule "vendor"]\n\tpath = vendor/lib\n',
|
|
'compose.yaml': 'services:\n web:\n build: .\n',
|
|
'Dockerfile': 'FROM nginx\n',
|
|
'vendor/lib/Dockerfile': 'FROM nginx\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals.some((r) => r.kind === 'submodule' && String(r.reason).includes('vendor/lib'))).toBe(true);
|
|
});
|
|
|
|
it('allows a submodule directory excluded by the context dockerignore', async () => {
|
|
const clone = makeClone({
|
|
'.gitmodules': '[submodule "vendor"]\n\tpath = vendor/lib\n',
|
|
'compose.yaml': 'services:\n web:\n build: .\n',
|
|
'Dockerfile': 'FROM nginx\n',
|
|
'.dockerignore': 'vendor\n',
|
|
'vendor/lib/Dockerfile': 'FROM nginx\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('effective invocation path resolution (audit round 8 B-3)', () => {
|
|
it('resolves merged-file relative paths against the base file directory, never the declaring file', async () => {
|
|
const clone = makeClone({
|
|
'deploy/base.yaml': 'services:\n web:\n image: nginx\n configs: [cfg]\nconfigs:\n cfg:\n file: nginx.conf\n',
|
|
// The second -f file lives in a DIFFERENT directory: its relative
|
|
// paths still resolve against the base file's directory (deploy/),
|
|
// not its own.
|
|
'configs/prod.yaml': 'services:\n web:\n env_file: prod.env\n',
|
|
'deploy/nginx.conf': 'server {}\n',
|
|
'deploy/prod.env': 'A=1\n',
|
|
// Same-named files at the repo root must NOT be picked up.
|
|
'nginx.conf': 'WRONG server {}\n',
|
|
'prod.env': 'WRONG=1\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['deploy/base.yaml', 'configs/prod.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals).toEqual([]);
|
|
const prodEnv = result.inputs.find((i) => i.dependencyKind === 'env_file');
|
|
expect(prodEnv?.sourcePath).toBe('deploy/prod.env');
|
|
// Materialized at the stack root (the runtime project dir).
|
|
expect(prodEnv?.materializedPath).toBe('prod.env');
|
|
const cfg = result.inputs.find((i) => i.dependencyKind === 'config');
|
|
expect(cfg?.sourcePath).toBe('deploy/nginx.conf');
|
|
expect(cfg?.materializedPath).toBe('nginx.conf');
|
|
});
|
|
|
|
it('refuses a base-dir-relative file that is missing instead of falling back to a same-named root file', async () => {
|
|
const clone = makeClone({
|
|
'deploy/base.yaml': 'services:\n web:\n image: nginx\n env_file: app.env\n',
|
|
// No deploy/app.env: the old declaring-dir-first + repo-root
|
|
// fallback would silently materialize the root decoy.
|
|
'app.env': 'WRONG=1\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['deploy/base.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals.some((r) => r.kind === 'missing-file' && r.sourcePath === 'deploy/app.env')).toBe(true);
|
|
expect(result.inputs.some((i) => i.sourcePath === 'app.env' && i.ownership === 'managed')).toBe(false);
|
|
});
|
|
|
|
it('resolves the primary\'s includes against the context dir but keeps the included file\'s own paths on its own project directory', async () => {
|
|
const clone = makeClone({
|
|
// With --project-directory deploy, the primary's include path
|
|
// resolves against deploy/ (the top-level project base).
|
|
'compose.yaml': 'include:\n - common/redis.yaml\nservices: {}\n',
|
|
'deploy/common/redis.yaml': 'services:\n redis:\n image: redis\n env_file: redis.env\n',
|
|
// The included file's own env_file resolves against ITS project
|
|
// directory (deploy/common), not the context dir.
|
|
'deploy/common/redis.env': 'REDIS=1\n',
|
|
'deploy/redis.env': 'WRONG=1\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: 'deploy', bounds: BOUNDS });
|
|
expect(result.refusals).toEqual([]);
|
|
const include = result.inputs.find((i) => i.dependencyKind === 'include');
|
|
expect(include?.sourcePath).toBe('deploy/common/redis.yaml');
|
|
expect(include?.materializedPath).toBe('deploy/common/redis.yaml');
|
|
const env = result.inputs.find((i) => i.dependencyKind === 'env_file');
|
|
expect(env?.sourcePath).toBe('deploy/common/redis.env');
|
|
expect(env?.materializedPath).toBe('deploy/common/redis.env');
|
|
});
|
|
|
|
it('materializes base-file-relative paths at the stack root, stripping the base directory prefix', async () => {
|
|
const clone = makeClone({
|
|
'deploy/base.yaml': 'services:\n web:\n image: nginx\n env_file: prod.env\n',
|
|
'deploy/prod.env': 'A=1\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['deploy/base.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals).toEqual([]);
|
|
const env = result.inputs.find((i) => i.dependencyKind === 'env_file');
|
|
// The primary file lands at the stack root, so the runtime project dir
|
|
// is the stack root: prod.env must live at the stack root, not under
|
|
// deploy/.
|
|
expect(env?.sourcePath).toBe('deploy/prod.env');
|
|
expect(env?.materializedPath).toBe('prod.env');
|
|
});
|
|
|
|
it('resolves project-relative paths against the configured project directory', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': 'services:\n web:\n image: nginx\n env_file: app.env\n',
|
|
'deploy/app.env': 'A=1\n',
|
|
'app.env': 'WRONG=1\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: 'deploy', bounds: BOUNDS });
|
|
expect(result.refusals).toEqual([]);
|
|
const env = result.inputs.find((i) => i.dependencyKind === 'env_file');
|
|
expect(env?.sourcePath).toBe('deploy/app.env');
|
|
expect(env?.materializedPath).toBe('deploy/app.env');
|
|
});
|
|
|
|
it('does not auto-discover an override when the project directory makes the invocation explicit', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': 'services:\n web:\n image: nginx\n',
|
|
'compose.override.yaml': 'services:\n web:\n environment:\n A: b\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: 'deploy', bounds: BOUNDS });
|
|
// A context dir forces explicit -f at runtime, which suppresses
|
|
// auto-discovery; the override must not enter the inventory.
|
|
expect(result.inputs.some((i) => i.dependencyKind === 'implicit-override')).toBe(false);
|
|
});
|
|
|
|
it('rebases a subdir build context to the stack root for the materialized layout', async () => {
|
|
const clone = makeClone({
|
|
'deploy/base.yaml': 'services:\n web:\n build:\n context: web\n dockerfile: Dockerfile\n',
|
|
'deploy/web/Dockerfile': 'FROM nginx\n',
|
|
'deploy/web/app.js': 'console.log(1)\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['deploy/base.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals).toEqual([]);
|
|
expect(result.buildContexts).toHaveLength(1);
|
|
const ctx = result.buildContexts[0];
|
|
expect(ctx.repoPath).toBe('web');
|
|
expect(ctx.dockerfile).toBe('deploy/web/Dockerfile');
|
|
const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-b3-ctx-'));
|
|
tmpRoots.push(dest);
|
|
const managed = result.inputs.filter((i) => i.ownership === 'managed' && i.materializedPath !== null && i.dependencyKind !== 'build-context')
|
|
.map((i) => ({ srcRel: i.sourcePath!, destRel: i.materializedPath! }));
|
|
await discovery().walkAndCopy(clone, dest, managed, result.contextCopyPlans, BOUNDS);
|
|
expect(fs.existsSync(path.join(dest, 'web/Dockerfile'))).toBe(true);
|
|
expect(fs.existsSync(path.join(dest, 'web/app.js'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('nested-primary runtime path equivalence (audit round 9 B-3)', () => {
|
|
it('materializes a nested primary\'s include graph at the stack root', async () => {
|
|
const clone = makeClone({
|
|
'deploy/base.yaml': 'include:\n - common.yaml\nservices: {}\n',
|
|
'deploy/common.yaml': 'services:\n web:\n image: nginx\n env_file: web.env\n',
|
|
'deploy/web.env': 'A=1\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['deploy/base.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals).toEqual([]);
|
|
const include = result.inputs.find((i) => i.dependencyKind === 'include');
|
|
expect(include?.sourcePath).toBe('deploy/common.yaml');
|
|
expect(include?.materializedPath).toBe('common.yaml');
|
|
// The included file's own project-relative input also moves to the
|
|
// stack root: at runtime compose.yaml includes ./common.yaml, whose
|
|
// env_file resolves against its own (relocated) directory.
|
|
const env = result.inputs.find((i) => i.dependencyKind === 'env_file');
|
|
expect(env?.sourcePath).toBe('deploy/web.env');
|
|
expect(env?.materializedPath).toBe('web.env');
|
|
|
|
// The candidate mirrors the runtime layout.
|
|
const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-b3-graph-'));
|
|
tmpRoots.push(dest);
|
|
const managed = result.inputs.filter((i) => i.ownership === 'managed' && i.materializedPath !== null && i.dependencyKind !== 'build-context')
|
|
.map((i) => ({ srcRel: i.sourcePath!, destRel: i.materializedPath! }));
|
|
await discovery().walkAndCopy(clone, dest, managed, result.contextCopyPlans, BOUNDS);
|
|
expect(fs.existsSync(path.join(dest, 'common.yaml'))).toBe(true);
|
|
expect(fs.existsSync(path.join(dest, 'web.env'))).toBe(true);
|
|
expect(fs.existsSync(path.join(dest, 'deploy/common.yaml'))).toBe(false);
|
|
});
|
|
|
|
it('recurses through a nested primary\'s include graph, stripping the prefix at every level', async () => {
|
|
const clone = makeClone({
|
|
'deploy/base.yaml': 'include:\n - common.yaml\nservices: {}\n',
|
|
'deploy/common.yaml': 'include:\n - nested/inner.yaml\nservices: {}\n',
|
|
'deploy/nested/inner.yaml': 'services:\n web:\n image: nginx\n env_file: inner.env\n',
|
|
'deploy/nested/inner.env': 'A=1\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['deploy/base.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals).toEqual([]);
|
|
const inner = result.inputs.find((i) => i.dependencyKind === 'include' && i.sourcePath === 'deploy/nested/inner.yaml');
|
|
expect(inner?.materializedPath).toBe('nested/inner.yaml');
|
|
const env = result.inputs.find((i) => i.dependencyKind === 'env_file');
|
|
expect(env?.sourcePath).toBe('deploy/nested/inner.env');
|
|
expect(env?.materializedPath).toBe('nested/inner.env');
|
|
});
|
|
|
|
it('resolves an additional -f file\'s includes against the base file\'s directory', async () => {
|
|
const clone = makeClone({
|
|
'deploy/base.yaml': 'services: {}\n',
|
|
// The merged project's base is deploy/ (the first -f file); an
|
|
// include declared in the second file resolves against it.
|
|
'infra/prod.yaml': 'include:\n - prod-common.yaml\nservices: {}\n',
|
|
'deploy/prod-common.yaml': 'services:\n web:\n image: nginx\n',
|
|
'infra/prod-common.yaml': 'services:\n WRONG:\n image: nginx\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['deploy/base.yaml', 'infra/prod.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals).toEqual([]);
|
|
const include = result.inputs.find((i) => i.dependencyKind === 'include');
|
|
expect(include?.sourcePath).toBe('deploy/prod-common.yaml');
|
|
// At runtime the project dir is the stack root, so the included file
|
|
// moves there too.
|
|
expect(include?.materializedPath).toBe('prod-common.yaml');
|
|
});
|
|
|
|
it('applies a divergent include project_directory to the subtree project base', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': 'include:\n - path: app/compose.yaml\n project_directory: other\nservices: {}\n',
|
|
'app/compose.yaml': 'services:\n web:\n image: nginx\n env_file: x.env\n',
|
|
'other/x.env': 'A=1\n',
|
|
'app/x.env': 'WRONG=1\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals).toEqual([]);
|
|
const env = result.inputs.find((i) => i.dependencyKind === 'env_file');
|
|
// project_directory re-bases the subtree: the env file lives under
|
|
// other/, not next to the included file.
|
|
expect(env?.sourcePath).toBe('other/x.env');
|
|
expect(env?.materializedPath).toBe('other/x.env');
|
|
});
|
|
|
|
it('materializes a nested primary\'s extends.file target at the stack root with its own inputs', async () => {
|
|
const clone = makeClone({
|
|
'deploy/base.yaml': 'services:\n web:\n extends:\n file: web-base.yaml\n service: web-base\n',
|
|
'deploy/web-base.yaml': 'services:\n web-base:\n image: nginx\n label_file: labels.txt\n',
|
|
'deploy/labels.txt': 'a=b\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['deploy/base.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals).toEqual([]);
|
|
const ext = result.inputs.find((i) => i.dependencyKind === 'extends');
|
|
expect(ext?.sourcePath).toBe('deploy/web-base.yaml');
|
|
expect(ext?.materializedPath).toBe('web-base.yaml');
|
|
const label = result.inputs.find((i) => i.dependencyKind === 'label_file');
|
|
expect(label?.sourcePath).toBe('deploy/labels.txt');
|
|
expect(label?.materializedPath).toBe('labels.txt');
|
|
});
|
|
|
|
it('refuses a nested-primary include that escapes the stack root', async () => {
|
|
const clone = makeClone({
|
|
'deploy/base.yaml': 'include:\n - ../shared.yaml\nservices: {}\n',
|
|
'shared.yaml': 'services: {}\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['deploy/base.yaml'], contextDir: null, bounds: BOUNDS });
|
|
// ../shared.yaml stays inside the repository (deploy/../shared.yaml)
|
|
// but escapes the stack root at runtime; it must be refused, never
|
|
// silently dropped or adopted from the repo root.
|
|
expect(result.refusals.some((r) => r.actionable && r.kind === 'out-of-bounds' && String(r.sourcePath).includes('../shared.yaml'))).toBe(true);
|
|
expect(result.inputs.some((i) => i.sourcePath === 'shared.yaml' && i.ownership === 'managed')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('included-project default env and project-base includes (audit round 10 B-1/B-2)', () => {
|
|
it('materializes an included project\'s default .env as a managed sensitive input', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': 'include:\n - modules/app/compose.yaml\nservices: {}\n',
|
|
'modules/app/compose.yaml': 'services:\n app:\n image: app:${TAG:-latest}\n',
|
|
'modules/app/.env': 'TAG=production\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals).toEqual([]);
|
|
const defaultEnv = result.inputs.find((i) => i.dependencyKind === 'interpolation-env' && i.sourcePath === 'modules/app/.env');
|
|
expect(defaultEnv?.ownership).toBe('managed');
|
|
expect(defaultEnv?.materializedPath).toBe('modules/app/.env');
|
|
expect(defaultEnv?.contentSha256).toBeTruthy();
|
|
expect(defaultEnv?.sensitivity).toBe('high');
|
|
});
|
|
|
|
it('tolerates a missing included-project default .env without refusing', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': 'include:\n - modules/app/compose.yaml\nservices: {}\n',
|
|
'modules/app/compose.yaml': 'services:\n app:\n image: app:${TAG:-latest}\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals).toEqual([]);
|
|
const defaultEnv = result.inputs.find((i) => i.dependencyKind === 'interpolation-env' && i.sourcePath === 'modules/app/.env');
|
|
expect(defaultEnv?.ownership).toBe('unmanaged');
|
|
expect(defaultEnv?.note).toContain('No project .env');
|
|
});
|
|
|
|
it('resolves extends.file in an additional -f file against the base file\'s directory', async () => {
|
|
const clone = makeClone({
|
|
'deploy/base.yaml': 'services: {}\n',
|
|
'infra/prod.yaml': 'services:\n web:\n extends:\n file: web-base.yaml\n service: web-base\n',
|
|
'deploy/web-base.yaml': 'services:\n web-base:\n image: nginx\n',
|
|
'infra/web-base.yaml': 'services:\n WRONG:\n image: nginx\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['deploy/base.yaml', 'infra/prod.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals).toEqual([]);
|
|
const ext = result.inputs.find((i) => i.dependencyKind === 'extends');
|
|
expect(ext?.sourcePath).toBe('deploy/web-base.yaml');
|
|
expect(ext?.materializedPath).toBe('web-base.yaml');
|
|
});
|
|
|
|
it('resolves extends.file against the context dir with the subtree on its own project directory', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': 'services:\n web:\n extends:\n file: web-base.yaml\n service: web-base\n',
|
|
'deploy/web-base.yaml': 'services:\n web-base:\n image: nginx\n label_file: labels.txt\n',
|
|
'deploy/labels.txt': 'a=b\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: 'deploy', bounds: BOUNDS });
|
|
expect(result.refusals).toEqual([]);
|
|
const ext = result.inputs.find((i) => i.dependencyKind === 'extends');
|
|
expect(ext?.sourcePath).toBe('deploy/web-base.yaml');
|
|
const label = result.inputs.find((i) => i.dependencyKind === 'label_file');
|
|
expect(label?.sourcePath).toBe('deploy/labels.txt');
|
|
});
|
|
|
|
it('uses the first path as the included project\'s main file for a multi-directory path list', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': 'include:\n - path:\n - modules/base/compose.yaml\n - overrides/compose.yaml\nservices: {}\n',
|
|
'modules/base/compose.yaml': 'services:\n base:\n image: nginx\n',
|
|
'overrides/compose.yaml': 'services:\n base:\n env_file: shared.env\n',
|
|
// The included project's base is modules/base (the first path);
|
|
// the override's relative inputs resolve against it, never the
|
|
// override's own directory.
|
|
'modules/base/shared.env': 'A=1\n',
|
|
'overrides/shared.env': 'WRONG=1\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals).toEqual([]);
|
|
const env = result.inputs.find((i) => i.dependencyKind === 'env_file');
|
|
expect(env?.sourcePath).toBe('modules/base/shared.env');
|
|
expect(env?.materializedPath).toBe('modules/base/shared.env');
|
|
});
|
|
});
|
|
|
|
describe('optional env_file and external: false semantics (audit round 10 S-1)', () => {
|
|
it('does not refuse a missing optional env_file', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': 'services:\n web:\n image: nginx\n env_file:\n - path: optional.env\n required: false\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals).toEqual([]);
|
|
const env = result.inputs.find((i) => i.dependencyKind === 'env_file');
|
|
expect(env?.ownership).toBe('unmanaged');
|
|
expect(env?.note).toContain('Optional env file');
|
|
});
|
|
|
|
it('materializes a present optional env_file as managed', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': 'services:\n web:\n image: nginx\n env_file:\n - path: optional.env\n required: false\n',
|
|
'optional.env': 'A=1\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals).toEqual([]);
|
|
const env = result.inputs.find((i) => i.dependencyKind === 'env_file');
|
|
expect(env?.ownership).toBe('managed');
|
|
expect(env?.contentSha256).toBeTruthy();
|
|
});
|
|
|
|
it('treats only external: true as external for file-backed configs', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': 'configs:\n app:\n file: configs/app.conf\n external: false\nservices:\n web:\n image: nginx\n configs: [app]\n',
|
|
'configs/app.conf': 'server {}\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals).toEqual([]);
|
|
const cfg = result.inputs.find((i) => i.dependencyKind === 'config');
|
|
expect(cfg?.sourcePath).toBe('configs/app.conf');
|
|
expect(cfg?.ownership).toBe('managed');
|
|
expect(cfg?.contentSha256).toBeTruthy();
|
|
});
|
|
|
|
it('treats only external: true as external for file-backed secrets', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': 'secrets:\n app-key:\n file: secrets/app.key\n external: false\nservices:\n web:\n image: nginx\n secrets: [app-key]\n',
|
|
'secrets/app.key': 'secret\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals).toEqual([]);
|
|
const sec = result.inputs.find((i) => i.dependencyKind === 'secret');
|
|
expect(sec?.sourcePath).toBe('secrets/app.key');
|
|
expect(sec?.ownership).toBe('managed');
|
|
expect(sec?.sensitivity).toBe('high');
|
|
});
|
|
});
|
|
|
|
describe('absolute and home-relative path classification (audit round 9 B-4)', () => {
|
|
it('records absolute env/config paths as unmanaged host entries, never adopting repo decoys', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': `services:
|
|
web:
|
|
image: nginx
|
|
env_file: /etc/secrets/web.env
|
|
configs: [cfg]
|
|
configs:
|
|
cfg:
|
|
file: /etc/nginx/app.conf
|
|
`,
|
|
// Repo decoys with the same basenames must never be adopted.
|
|
'etc/secrets/web.env': 'WRONG=1\n',
|
|
'etc/nginx/app.conf': 'WRONG server {}\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals).toEqual([]);
|
|
const env = result.inputs.find((i) => i.dependencyKind === 'env_file');
|
|
expect(env?.ownership).toBe('unmanaged');
|
|
expect(env?.materializedPath).toBeNull();
|
|
expect(env?.note).toContain('Host path');
|
|
const cfg = result.inputs.find((i) => i.dependencyKind === 'config');
|
|
expect(cfg?.ownership).toBe('unmanaged');
|
|
expect(result.inputs.some((i) => i.ownership === 'managed' && i.materializedPath?.includes('etc/'))).toBe(false);
|
|
});
|
|
|
|
it('refuses absolute includes without adopting a repo decoy', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': 'include:\n - /etc/compose/extra.yaml\nservices: {}\n',
|
|
'etc/compose/extra.yaml': 'services:\n web:\n image: nginx\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals.some((r) => r.actionable && r.kind === 'out-of-bounds' && String(r.sourcePath).includes('/etc/compose/extra.yaml'))).toBe(true);
|
|
expect(result.inputs.some((i) => i.materializedPath === 'etc/compose/extra.yaml')).toBe(false);
|
|
});
|
|
|
|
it('records absolute build contexts and dockerfiles as unmanaged host entries', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': `services:
|
|
web:
|
|
build:
|
|
context: /opt/build/web
|
|
dockerfile: /opt/build/Dockerfile
|
|
`,
|
|
'opt/build/web/Dockerfile': 'FROM nginx\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals).toEqual([]);
|
|
expect(result.buildContexts).toHaveLength(0);
|
|
const ctx = result.inputs.find((i) => i.dependencyKind === 'build-context');
|
|
expect(ctx?.ownership).toBe('unmanaged');
|
|
expect(ctx?.note).toContain('Host path');
|
|
const df = result.inputs.find((i) => i.dependencyKind === 'dockerfile');
|
|
expect(df?.ownership).toBe('unmanaged');
|
|
});
|
|
|
|
it('records Windows drive, UNC, and home-relative paths as host inputs', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': `services:
|
|
web:
|
|
image: nginx
|
|
env_file:
|
|
- C:\\\\config\\\\web.env
|
|
- \\\\\\\\server\\\\share\\\\x.env
|
|
- ~/web.env
|
|
`,
|
|
'config/web.env': 'WRONG=1\n',
|
|
'web.env': 'WRONG=1\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals).toEqual([]);
|
|
const envFiles = result.inputs.filter((i) => i.dependencyKind === 'env_file');
|
|
expect(envFiles).toHaveLength(3);
|
|
expect(envFiles.every((e) => e.ownership === 'unmanaged' && e.materializedPath === null)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('default build context and build-secret grammar (audit round 8 B-1)', () => {
|
|
it('materializes an omitted build context at the repo root', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': 'services:\n web:\n build:\n dockerfile: Dockerfile\n',
|
|
'Dockerfile': 'FROM nginx\n',
|
|
'src/app.js': 'console.log(1)\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals).toEqual([]);
|
|
expect(result.buildContexts).toHaveLength(1);
|
|
const ctx = result.buildContexts[0];
|
|
expect(ctx.repoPath).toBe('');
|
|
expect(ctx.dockerfile).toBe('Dockerfile');
|
|
expect(ctx.files.some((f) => f.path === 'Dockerfile')).toBe(true);
|
|
expect(ctx.files.some((f) => f.path === 'src/app.js')).toBe(true);
|
|
});
|
|
|
|
it('resolves an omitted build context against the configured project directory', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': 'services:\n web:\n build:\n dockerfile: Dockerfile\n',
|
|
'deploy/Dockerfile': 'FROM nginx\n',
|
|
'deploy/app.js': 'console.log(1)\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: 'deploy', bounds: BOUNDS });
|
|
expect(result.refusals).toEqual([]);
|
|
expect(result.buildContexts).toHaveLength(1);
|
|
const ctx = result.buildContexts[0];
|
|
expect(ctx.repoPath).toBe('deploy');
|
|
expect(ctx.dockerfile).toBe('deploy/Dockerfile');
|
|
expect(ctx.files.some((f) => f.path === 'app.js')).toBe(true);
|
|
});
|
|
|
|
it('resolves an omitted build context in an included file against the included file\'s directory', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': 'include:\n - services/app/compose.yaml\nservices: {}\n',
|
|
'services/app/compose.yaml': 'services:\n app:\n build:\n dockerfile: Dockerfile\n',
|
|
'services/app/Dockerfile': 'FROM nginx\n',
|
|
'services/app/app.js': 'console.log(1)\n',
|
|
'root-decoy.txt': 'must not be in the context\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals).toEqual([]);
|
|
expect(result.buildContexts).toHaveLength(1);
|
|
const ctx = result.buildContexts[0];
|
|
expect(ctx.repoPath).toBe('services/app');
|
|
expect(ctx.dockerfile).toBe('services/app/Dockerfile');
|
|
expect(ctx.files.some((f) => f.path === 'app.js')).toBe(true);
|
|
expect(ctx.files.some((f) => f.path === 'root-decoy.txt')).toBe(false);
|
|
});
|
|
|
|
it('does not double-prefix an include map-form env_file from a subdirectory base file', async () => {
|
|
const clone = makeClone({
|
|
'deploy/base.yaml': 'include:\n - path: web.yaml\n env_file: e.env\nservices: {}\n',
|
|
'deploy/web.yaml': 'services:\n web:\n image: nginx\n',
|
|
'deploy/e.env': 'A=1\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['deploy/base.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals).toEqual([]);
|
|
const includeEnv = result.inputs.find((i) => i.dependencyKind === 'include-env');
|
|
// Repo-side the env file sits next to the base file; at runtime the
|
|
// primary lands at the stack root, so the env file lives there too.
|
|
expect(includeEnv?.sourcePath).toBe('deploy/e.env');
|
|
expect(includeEnv?.materializedPath).toBe('e.env');
|
|
});
|
|
|
|
it('does not search for a file named after a build-secret source', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': `secrets:
|
|
build-key:
|
|
file: keys/build.env
|
|
services:
|
|
web:
|
|
build:
|
|
context: web
|
|
secrets:
|
|
- id: build-key
|
|
source: build-key
|
|
`,
|
|
'web/Dockerfile': 'FROM nginx\n',
|
|
'keys/build.env': 'TOKEN=x\n',
|
|
});
|
|
const result = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
|
|
expect(result.refusals).toEqual([]);
|
|
const secretEntry = result.inputs.find((i) => i.dependencyKind === 'secret' && i.sourcePath === 'keys/build.env');
|
|
expect(secretEntry).toBeTruthy();
|
|
expect(secretEntry?.ownership).toBe('managed');
|
|
const buildSecret = result.inputs.find((i) => i.dependencyKind === 'build-secret');
|
|
expect(buildSecret?.sourcePath).toBeNull();
|
|
expect(buildSecret?.ownership).toBe('unmanaged');
|
|
});
|
|
});
|
|
|
|
describe('repo-root build context overlap (audit round 2)', () => {
|
|
it('copies a repo-root context without duplicating managed files', async () => {
|
|
const clone = makeClone({
|
|
'compose.yaml': 'services:\n web:\n build: .\n',
|
|
'src/main.go': 'package main\n',
|
|
});
|
|
const inv = await discovery().discoverFromClone({ cloneDir: clone, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
|
|
const managed = inv.inputs.filter((i) => i.ownership === 'managed' && i.materializedPath !== null && i.dependencyKind !== 'build-context');
|
|
const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-rootctx-'));
|
|
tmpRoots.push(dest);
|
|
const result = await discovery().walkAndCopy(clone, dest, managed.map((i) => ({ srcRel: i.sourcePath!, destRel: i.materializedPath! })), inv.contextCopyPlans, BOUNDS);
|
|
// compose.yaml + src/main.go: no duplicate-path failure, no double copy.
|
|
expect(result.copiedFiles).toBe(2);
|
|
expect(fs.readFileSync(path.join(dest, 'src/main.go'), 'utf8')).toBe('package main\n');
|
|
});
|
|
});
|