diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts index a5024353c8..ea77458d12 100644 --- a/packages/credentials/credentials-local/src/index.ts +++ b/packages/credentials/credentials-local/src/index.ts @@ -101,6 +101,7 @@ const GROUP_OTHER_BITS = 0o077 * @throws when the file exists with group or other permission bits set. */ async function assertOwnerOnly(filename: string): Promise { + /* v8 ignore next -- native Windows coverage exercises the skip; POSIX covers the check */ if (process.platform === 'win32') return let mode: number try { @@ -130,6 +131,7 @@ function isENOENT(error: unknown): boolean { */ function describeYamlError(error: YAMLError): string { const at = error.linePos?.[0] + /* v8 ignore next -- `prettyErrors` populates linePos on every error; the guard answers its optional type */ const where = at === undefined ? '' : ` at line ${String(at.line)}, column ${String(at.col)}` return `${error.code}${where}` } diff --git a/packages/credentials/credentials-local/tests/local.spec.ts b/packages/credentials/credentials-local/tests/local.spec.ts index 7a8b8fdc17..43e42cd53f 100644 --- a/packages/credentials/credentials-local/tests/local.spec.ts +++ b/packages/credentials/credentials-local/tests/local.spec.ts @@ -179,6 +179,29 @@ describe('layer ladder', () => { .rejects.toThrow(/readable beyond its owner \(mode 644\)/) }) + it('propagates a permission check that fails for a reason other than absence', async () => { + const dir = await tempDir() + const notADirectory = join(dir, 'occupied') + await writeFile(notADirectory, 'a regular file\n') + // An absent document is an empty store, but a path that cannot be + // reached at all is a misconfiguration: the parent is a file, so the + // check fails with ENOTDIR rather than concluding "no credentials yet". + const ctx = new Context() + await expect(ctx.plugin(CredentialsLocal, { path: join(notADirectory, '.credentials.yaml'), watch: false })) + .rejects.toThrow(/ENOTDIR/) + }) + + it('propagates a read that fails for a reason other than absence', async () => { + const dir = await tempDir() + const path = join(dir, '.credentials.yaml') + // Owner-only, so the permission check passes, and unreadable as a file: + // the store is present but cannot be parsed, which must fail the launch + // rather than silently serve nothing. + await mkdir(path, { mode: 0o700 }) + const ctx = new Context() + await expect(ctx.plugin(CredentialsLocal, { path, watch: false })).rejects.toThrow(/EISDIR/) + }) + it('lets only the inherited environment shadow the store, read-only', async () => { const dir = await tempDir() const path = join(dir, '.credentials.yaml') diff --git a/packages/settings/settings-local/src/index.ts b/packages/settings/settings-local/src/index.ts index d713083c20..142d7935bd 100644 --- a/packages/settings/settings-local/src/index.ts +++ b/packages/settings/settings-local/src/index.ts @@ -250,6 +250,7 @@ export class SettingsLocal extends Settings { throw new Error(`settings-local: invalid document at ${this.spec.filename}: ${ document.errors.map((error) => { const at = error.linePos?.[0] + /* v8 ignore next -- `prettyErrors` populates linePos on every error; the guard answers its optional type */ return `${error.code}${at === undefined ? '' : ` at line ${String(at.line)}, column ${String(at.col)}`}` }).join('; ')}`) } diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index fba1ad1993..4d44c780c7 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -190,6 +190,113 @@ describe('loadLayeredEnv', () => { vi.unstubAllEnvs() } }) + + it('warns and continues when a layer exists but cannot be read', () => { + const home = tmp() + const project = tmp() + // A directory named `.env` is present-but-unreadable (EISDIR): unlike an + // absent file, it is a real misconfiguration, so it is reported rather + // than passed over in silence — and the other layers still load. + mkdirSync(join(home, '.env')) + writeFileSync(join(project, '.env'), `${NAMES[2]}=project-only\n`) + clear() + vi.stubEnv('DSH_HOME', home) + const warn = vi.fn() + try { + const snapshot = loadLayeredEnv(NAME, project, warn) + expect(warn).toHaveBeenCalledWith(expect.stringContaining(`${NAME}: failed to load .env`)) + expect(snapshot.layers).toEqual([ + { source: 'process' }, + { source: 'project-env', path: join(project, '.env') }, + ]) + expect(process.env[NAMES[2]]).toBe('project-only') + } finally { + clear() + vi.unstubAllEnvs() + } + }) + + it('reports to stderr when the caller supplies no reporter', () => { + const home = tmp() + const project = tmp() + mkdirSync(join(home, '.env')) + writeFileSync(join(project, '.env'), `${NAMES[2]}=project-only\n`) + clear() + vi.stubEnv('DSH_HOME', home) + const write = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + const snapshot = loadLayeredEnv(NAME, project) + expect(write).toHaveBeenCalledWith(expect.stringContaining(`${NAME}: failed to load .env`)) + expect(snapshot.layers).toEqual([ + { source: 'process' }, + { source: 'project-env', path: join(project, '.env') }, + ]) + expect(process.env[NAMES[2]]).toBe('project-only') + } finally { + write.mockRestore() + clear() + vi.unstubAllEnvs() + } + }) + + it('passes over an absent layer without reporting it', () => { + const home = tmp() + const project = tmp() + writeFileSync(join(project, '.env'), `${NAMES[2]}=project-only\n`) + clear() + vi.stubEnv('DSH_HOME', home) + const warn = vi.fn() + try { + // No user `.env` exists, which is ordinary rather than a fault: the + // layer is simply absent, and nothing is reported. + const snapshot = loadLayeredEnv(NAME, project, warn) + expect(warn).not.toHaveBeenCalled() + expect(snapshot.layers).toEqual([ + { source: 'process' }, + { source: 'project-env', path: join(project, '.env') }, + ]) + } finally { + clear() + vi.unstubAllEnvs() + } + }) + + it('carries only the inherited environment when neither file exists', () => { + const home = tmp() + const project = tmp() + clear() + vi.stubEnv('DSH_HOME', home) + vi.stubEnv('APP_BOOT_LAYERED_INHERITED', 'inherited') + try { + const snapshot = loadLayeredEnv(NAME, project, vi.fn()) + expect(snapshot.layers).toEqual([{ source: 'process' }]) + expect(snapshot.get('APP_BOOT_LAYERED_INHERITED')).toEqual({ value: 'inherited', source: 'process' }) + } finally { + clear() + vi.unstubAllEnvs() + } + }) + + it('reads a harness home that is also the invocation directory exactly once', () => { + const both = tmp() + writeFileSync(join(both, '.env'), `${NAMES[2]}=one-file\n`) + clear() + vi.stubEnv('DSH_HOME', both) + try { + // One file cannot be two layers. It is the project layer, because that + // is the more trusted of the two — reading it twice would otherwise + // put the same path at two different ranks. + const snapshot = loadLayeredEnv(NAME, both, vi.fn()) + expect(snapshot.layers).toEqual([ + { source: 'process' }, + { source: 'project-env', path: join(both, '.env') }, + ]) + expect(snapshot.get(NAMES[2])).toEqual({ value: 'one-file', source: 'project-env', path: join(both, '.env') }) + } finally { + clear() + vi.unstubAllEnvs() + } + }) }) describe('installFailLoud', () => { diff --git a/packages/util/environment/src/index.ts b/packages/util/environment/src/index.ts index 11014f64b5..f35e32f9c5 100644 --- a/packages/util/environment/src/index.ts +++ b/packages/util/environment/src/index.ts @@ -76,6 +76,7 @@ export interface EnvironmentSnapshot { * @returns the key to store and look up by. */ function lookupKey(name: string): string { + /* v8 ignore next -- native Windows coverage exercises the folding arm; POSIX covers the exact one */ return process.platform === 'win32' ? name.toUpperCase() : name }