Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 94 additions & 38 deletions stasis-core/src/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -388,16 +388,36 @@ export class State {
// the explicit one), then substitute the explicit content once root-detection has
// committed to a rootDir below.
const explicitLockPath = this.config.lockFile
// A construction-time `bundleFile` (a --bundle-file flag or EXODUS_STASIS_BUNDLE_FILE) is
// rootDir-INDEPENDENT: reading it yields the same file at every candidate root. So, exactly
// like explicitLockPath, it must NOT act as a per-rootDir root-detection signal -- if it did,
// the innermost package.json dir would always match first and win, diverging load's root from
// the (outer) root the bundle was captured with. Because every bundle key is stored relative
// to the capture root, that divergence makes every bundled dependency unreachable at load: the
// load hook throws "outside project root" / "not attested", and Node's ESM->CJS translator --
// which re-resolves a required dependency through Module._load(absPath, /* no parent */) -- hits
// native disk resolution for a file the bundle carries but disk (post-prune / never-shipped)
// does not, i.e. `Cannot find module '<abs node_modules path>'`. Root is instead chosen from
// rootDir-DEPENDENT signals (stasis.config.json, the default lockfile/bundle at the dir), then
// the explicit bundle is loaded at the committed root -- or, when nothing else committed, at the
// outermost root post-loop (mirroring explicitLockPath). A `bundleFile` that comes only from
// stasis.config.json is NOT suppressed: it lives at a dir already carrying the config signal, so
// it selects that dir consistently at capture and load alike.
const explicitBundlePath = this.config.bundleFile
for (const rootDir of potentialRoots) {
const config = readFileSyncMaybe(rootDir, FILE_CONFIG, 'utf-8')
const lockProbe = explicitLockPath ? null : readFileSyncMaybe(rootDir, FILE_LOCK, 'utf-8')
// Probe for a bundle at the construction-time bundleFile (a --bundle-file flag or
// EXODUS_STASIS_BUNDLE_FILE) or, absent that, the default <rootDir>/stasis.code.br.
// This is only a root-detection signal -- the authoritative path can still change when
// Read the bundle at the construction-time bundleFile (a --bundle-file flag or
// EXODUS_STASIS_BUNDLE_FILE) or, absent that, the default <rootDir>/stasis.code.br -- used
// to LOAD the bundle once a root is committed. The authoritative path can still change when
// loadConfig() applies a `bundleFile` from stasis.config.json, so it's re-read below.
let sourcesPath = this.config.bundleFile || join(rootDir, FILE_CODE)
let sources = readFileSyncMaybe(dirname(sourcesPath), basename(sourcesPath))
if (config !== null || lockProbe !== null || sources !== null) {
// Root-SELECTION signal: only the DEFAULT <rootDir>/stasis.code.br counts. An explicit
// (rootDir-independent) bundleFile is suppressed here (see explicitBundlePath above) so it
// can't bias selection to the innermost dir; it is still loaded via `sources` once committed.
const bundleProbe = explicitBundlePath ? null : sources
if (config !== null || lockProbe !== null || bundleProbe !== null) {
if (loaded) throw new Error('Stasis config already loaded')
loaded = true
this.root = rootDir
Expand Down Expand Up @@ -473,40 +493,7 @@ export class State {
}

if (sources && (this.config.writeBundle || this.config.loadBundle || this.config.frozenBundle) && !this.config.replaceBundle) {
// One unified bundle carries both code and resources. Split the flat file
// view into this.sources (code) and this.resources (resource payloads --
// raw UTF-8 for 'resource', base64 for 'resource:base64') by format.
const bundle = Bundle.parse(brotliDecompressSync(sources).toString('utf-8'))
// `Bundle.parse` accepts v0 for offline tooling (`stasis extract`, `diff`,
// `audit`, `sbom`) -- it has the metadata those commands need. The runtime
// path is stricter: a v0 bundle has no per-file `formats` attestation
// (resources can't be distinguished from code, the loader can't pick
// module-vs-commonjs) and no import map (resolution edges go unchecked),
// so serving / verifying one under `stasis run` would silently widen the
// trust boundary. Refuse it explicitly and point at the upgrade path.
assert.equal(bundle.version, Bundle.VERSION,
`stasis run requires a v1 bundle; ${sourcesPath} is v${bundle.version}. ` +
`Re-bundle with the current stasis (\`stasis bundle\`) or \`stasis run --bundle=replace\` ` +
`against a v0-free starting point to upgrade.`)
assert.equal(bundle.config.scope, this.config.scope)
this.#mergeBundleMetadata(bundle, { lockfileLoaded })
for (const [file, content] of bundle.sources) {
if (Bundle.isResourceFormat(bundle.formats.get(file))) this.resources.set(file, content)
else this.sources.set(file, content)
}
this.formats = bundle.formats
this.imports = bundle.imports
if (this.config.frozenBundle) {
// Snapshot the attested sets before addFile/addImport mutate the live maps.
// imports is deep-cloned (this.imports shares bundle.imports's nested Maps,
// which addImport extends); formats is a flat file->string map, so a shallow
// copy suffices. Code and resource file sets are tracked separately to match
// addFile's per-kind frozen-bundle membership check.
this.#bundleSources = new Set(this.sources.keys())
this.#bundleResources = new Set(this.resources.keys())
this.#bundleImports = objectToMaps(fileMapToObject(bundle.imports))
this.#bundleFormats = new Map(bundle.formats)
}
this.#absorbCodeBundle(sources, sourcesPath, lockfileLoaded)
}

// Split-bundle layout: when `resourcesBundleFile` is configured, resources
Expand Down Expand Up @@ -569,6 +556,35 @@ export class State {
}
}

// Explicit (flag/env) bundleFile with no rootDir-dependent indicator at any rootDir: like the
// explicit lockfile above, it was suppressed as a root-detection signal so it couldn't pull the
// root down to the innermost package, so the loop never loaded it. Load it here against
// `this.root` (the outermost package.json) -- the same root capture commits to when no signal
// exists -- so its capture-root-relative keys line up with how they're looked up. Inlines the
// same gates as the in-loop bundle branch.
if (!loaded && explicitBundlePath) {
const sourcesPath = this.config.bundleFile
const sources = readFileSyncMaybe(dirname(sourcesPath), basename(sourcesPath))
if (sources && !this.config.writeBundle && !this.config.loadBundle && !this.config.ignoreBundle && !this.config.frozenBundle) {
throw new Error(`Unexpected ${sourcesPath} with config.bundle = 'none'`)
}
if (sources && !lockfileLoaded && this.config.useLockfile && !this.config.replaceLockfile && !this.config.frozenBundle) {
throw new Error('stasis.lock.json missing, can not use sources')
}
if (sources && (this.config.writeBundle || this.config.loadBundle || this.config.frozenBundle) && !this.config.replaceBundle) {
this.#absorbCodeBundle(sources, sourcesPath, lockfileLoaded)
}
// Split-bundle resources: same post-loop treatment (resourcesBundleFile is likewise an
// explicit, rootDir-independent path). Mirrors the in-loop resources branch.
if (this.config.resourcesBundleFile) {
const resourcesPath = this.config.resourcesBundleFile
const resourcesData = readFileSyncMaybe(dirname(resourcesPath), basename(resourcesPath))
if (resourcesData && (this.config.writeBundle || this.config.loadBundle || this.config.frozenBundle) && !this.config.replaceBundle) {
this.#absorbResourcesBundle(resourcesData, { lockfileLoaded, resourcesPath })
}
}
}

// Frozen modes must have actually loaded their attestation. Asserting here --
// after the discovery loop -- rather than only inside it closes a fail-open: with
// no stasis files on disk at all the loop body never runs, so a per-rootDir guard
Expand Down Expand Up @@ -608,6 +624,46 @@ export class State {
liveStates().add(this)
}

// Absorb a unified code+resource bundle's metadata and payloads into this State. Shared by the
// discovery loop (a committed rootDir carrying a stasis signal) and the post-loop fallback that
// loads an explicit (rootDir-independent) bundleFile against the outermost root.
#absorbCodeBundle(sources, sourcesPath, lockfileLoaded) {
// One unified bundle carries both code and resources. Split the flat file
// view into this.sources (code) and this.resources (resource payloads --
// raw UTF-8 for 'resource', base64 for 'resource:base64') by format.
const bundle = Bundle.parse(brotliDecompressSync(sources).toString('utf-8'))
// `Bundle.parse` accepts v0 for offline tooling (`stasis extract`, `diff`,
// `audit`, `sbom`) -- it has the metadata those commands need. The runtime
// path is stricter: a v0 bundle has no per-file `formats` attestation
// (resources can't be distinguished from code, the loader can't pick
// module-vs-commonjs) and no import map (resolution edges go unchecked),
// so serving / verifying one under `stasis run` would silently widen the
// trust boundary. Refuse it explicitly and point at the upgrade path.
assert.equal(bundle.version, Bundle.VERSION,
`stasis run requires a v1 bundle; ${sourcesPath} is v${bundle.version}. ` +
`Re-bundle with the current stasis (\`stasis bundle\`) or \`stasis run --bundle=replace\` ` +
`against a v0-free starting point to upgrade.`)
assert.equal(bundle.config.scope, this.config.scope)
this.#mergeBundleMetadata(bundle, { lockfileLoaded })
for (const [file, content] of bundle.sources) {
if (Bundle.isResourceFormat(bundle.formats.get(file))) this.resources.set(file, content)
else this.sources.set(file, content)
}
this.formats = bundle.formats
this.imports = bundle.imports
if (this.config.frozenBundle) {
// Snapshot the attested sets before addFile/addImport mutate the live maps.
// imports is deep-cloned (this.imports shares bundle.imports's nested Maps,
// which addImport extends); formats is a flat file->string map, so a shallow
// copy suffices. Code and resource file sets are tracked separately to match
// addFile's per-kind frozen-bundle membership check.
this.#bundleSources = new Set(this.sources.keys())
this.#bundleResources = new Set(this.resources.keys())
this.#bundleImports = objectToMaps(fileMapToObject(bundle.imports))
this.#bundleFormats = new Map(bundle.formats)
}
}

// Cross-check bundle metadata (entries/modules) with what the lockfile already
// loaded, or absorb it as the source of truth when no lockfile is present.
// v0 bundles infer `name` from path for node_modules buckets but carry no
Expand Down
44 changes: 44 additions & 0 deletions tests/cli.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,50 @@ test('run honors a config-only bundleFile in a nested-package (monorepo) layout'
t.assert.doesNotMatch(r.stderr, /Stasis config already loaded/, 'outer root must not re-detect the bundle')
}))

test('run --bundle=load with an explicit --bundle-file resolves the root consistently in a monorepo', withTmp((t, tmp) => {
// Regression: an explicit --bundle-file (or EXODUS_STASIS_BUNDLE_FILE) is a rootDir-INDEPENDENT
// path -- it reads the same file at every candidate root. As a root-detection signal it therefore
// matched the INNERMOST package.json (packages/app) and committed that as the root at load time,
// even though capture -- run before the bundle existed, so with no such signal -- committed the
// OUTER repo root. Bundle keys are stored relative to the capture root, so a dependency hoisted to
// <repo>/node_modules then landed OUTSIDE the (leaf) load root: the load hook could not serve it,
// and Node's ESM->CJS translator re-resolved it through Module._load(absPath, /* no parent */),
// which fell through hooks.js's `typeof parent?.filename === 'string'` guard to native disk
// resolution -- `Cannot find module '<abs>/node_modules/dep/index.js'` once node_modules was
// pruned/not shipped. Choosing the root without the explicit bundleFile biasing it keeps capture
// and load agreed, so the hoisted dep stays in-root and is served from the bundle.
//
// Layout yields potentialRoots = [packages/app, <tmp>] (the .git at <tmp> stops the upward walk).
mkdirSync(join(tmp, '.git'))
writeFileSync(join(tmp, 'package.json'), JSON.stringify({ name: 'root', version: '1.0.0', private: true }))
// A CJS dependency hoisted to the repo-root node_modules (the npm/pnpm dedup shape).
const dep = join(tmp, 'node_modules', 'dep')
mkdirSync(dep, { recursive: true })
writeFileSync(join(dep, 'package.json'), JSON.stringify({ name: 'dep', version: '1.0.0', main: 'index.js' }))
writeFileSync(join(dep, 'index.js'), "module.exports = 'DEP-FROM-BUNDLE'\n")
// Leaf package with NO stasis.config.json; its ESM entry imports the hoisted CJS dep (an
// ESM->CJS edge, so Node's translator drives the resolution that tripped the guard).
const app = join(tmp, 'packages', 'app')
mkdirSync(app, { recursive: true })
writeFileSync(join(app, 'package.json'), JSON.stringify({ name: 'app', version: '1.0.0', private: true }))
writeFileSync(join(app, 'index.mjs'), "import dep from 'dep'\nconsole.log(dep)\n")
const bundlePath = join(tmp, 'snapshot.br')

const save = run(['run', '--lock=none', '--dependencies', '--bundle=add', `--bundle-file=${bundlePath}`, 'index.mjs'], { cwd: app })
t.assert.equal(save.status, 0, `save stderr: ${save.stderr}`)
t.assert.match(save.stdout, /DEP-FROM-BUNDLE/)

// Tamper the dep's on-disk bytes: the node_modules layout stays on disk (node_modules scope
// resolves the bare specifier through it), but the CONTENT must come from the bundle. Pre-fix,
// the leaf-root misclassification served (or failed to find) the on-disk copy instead.
writeFileSync(join(dep, 'index.js'), "module.exports = 'DEP-FROM-DISK-TAMPERED'\n")

const load = run(['run', '--lock=none', '--dependencies', '--bundle=load', `--bundle-file=${bundlePath}`, 'index.mjs'], { cwd: app })
t.assert.equal(load.status, 0, `load stderr: ${load.stderr}`)
t.assert.doesNotMatch(load.stdout, /TAMPERED/, 'the hoisted dep must be served from the bundle, not the on-disk copy')
t.assert.match(load.stdout, /DEP-FROM-BUNDLE/, 'bundle bytes win for a dependency resolved above the leaf package')
}))

test('run --lock=replace --bundle=add rejects when disk disagrees with the pre-loaded bundle', withTmp((t, tmp) => {
cpSync(runFixture, tmp, { recursive: true })
const bundlePath = join(tmp, 'snapshot.br')
Expand Down