fix(credentials-local): one operation chain, read-modify-write under the shared writer lock, and a quote-aware line editor
Review round three, credentials half. dsh-atomic-write grows the cross-process writer-lock primitive (withFileLock: wx sentinel, bounded backoff, stale takeover via onStaleBreak, deadline failure) plus a dirMode option, and settings-local migrates its private copy to it; both providers now create harness-home directories 0700. credentials-local reuses the reviewed settings-local shape: watcher reloads and line edits share one settled operation chain; every write re-reads the document under the lock and publishes unobserved external entries before editing, so an edit inside the debounce window (or another process's write) can never be overwritten; the watcher's ready signal queues one reconcile closing the startup gap. The line editor is now physical-line aware: continuation lines of a quoted multi-line value are never mistaken for assignments, untouched lines keep their exact bytes (CRLF included), an edited line keeps its own terminator, and appends use the document's dominant ending. A multi-line entry reports writable: false, matching what set() would do. The Credentials base class owns a contained notifyUpdated fan-out: providers publish only after the commit, every listener runs, sync throws and async rejections are logged without failing the committed write, and INVARIANT-coded failures rethrow after the fan-out.
This commit is contained in:
@@ -3,19 +3,21 @@
|
||||
* a `$DSH_HOME/.env` document. The environment is authoritative and read-only
|
||||
* (a launch-time override must win, and must be visibly read-only rather than
|
||||
* silently shadow writes); the file is the provider-managed writable source:
|
||||
* `set`/`unset` rewrite only their own line and preserve every other byte,
|
||||
* external edits hot-publish through the seam, and each reload replaces the
|
||||
* snapshot wholesale so a deleted entry never lingers in memory.
|
||||
* every write re-reads the document under a cross-process writer lock before
|
||||
* rewriting only its own line — preserving every other byte, physical line
|
||||
* endings and quoted multi-line values included — external edits hot-publish
|
||||
* through the seam, and each reload replaces the snapshot wholesale so a
|
||||
* deleted entry never lingers in memory.
|
||||
* @module @deepseek-ai/dsh-credentials-local
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { watch as chokidarWatch } from 'chokidar'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { mkdir, readFile } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { parse } from 'dotenv'
|
||||
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
||||
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { Credentials, credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepseek-ai/dsh-credentials'
|
||||
@@ -58,11 +60,6 @@ function isENOENT(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
|
||||
}
|
||||
|
||||
/** Match the physical line(s) assigning one reference (ref chars need no escaping). */
|
||||
function refLinePattern(ref: CredentialRef): RegExp {
|
||||
return new RegExp(`^\\s*(?:export\\s+)?${ref}\\s*=`)
|
||||
}
|
||||
|
||||
/** Values that survive a dotenv round-trip without quoting. */
|
||||
const BARE_VALUE = /^[A-Za-z0-9_@%+:,./-]+$/
|
||||
|
||||
@@ -90,30 +87,98 @@ function renderLine(ref: CredentialRef, value: string): string {
|
||||
throw new Error(`credentials-local: the value for "${ref}" mixes quoting no .env style can represent; edit the file directly`)
|
||||
}
|
||||
|
||||
/** Split text into physical lines with their terminators attached. */
|
||||
function physicalLines(text: string): string[] {
|
||||
return text.length === 0 ? [] : text.split(/(?<=\n)/)
|
||||
}
|
||||
|
||||
/** One physical line's content without its terminator. */
|
||||
function lineContent(line: string): string {
|
||||
if (line.endsWith('\r\n')) return line.slice(0, -2)
|
||||
if (line.endsWith('\n')) return line.slice(0, -1)
|
||||
return line
|
||||
}
|
||||
|
||||
/** One physical line's terminator (empty on a final unterminated line). */
|
||||
function lineTerminator(line: string): string {
|
||||
return line.slice(lineContent(line).length)
|
||||
}
|
||||
|
||||
/** An assignment line: optional export, a POSIX identifier, `=`, the value part. */
|
||||
const ASSIGNMENT = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/
|
||||
|
||||
/** Quote characters dotenv reads across physical lines. */
|
||||
const MULTILINE_QUOTES = ['\'', '"', '`']
|
||||
|
||||
/**
|
||||
* Replace, insert, or delete one reference's assignment while preserving every
|
||||
* other byte. The first matching line is rewritten in place; further matches
|
||||
* are dropped (dotenv reads the last one, so duplicates are dead weight that
|
||||
* would otherwise override the edit).
|
||||
* The quote character an assignment's value part opens without closing on its
|
||||
* own line — the following physical lines are that value's continuation, not
|
||||
* assignments — or `undefined` for a single-line value.
|
||||
*/
|
||||
function upsertLine(text: string | undefined, ref: CredentialRef, line: string | undefined): string {
|
||||
const lines = text === undefined || text.length === 0 ? [] : text.split('\n')
|
||||
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
|
||||
const matcher = refLinePattern(ref)
|
||||
function opensMultiline(valuePart: string): string | undefined {
|
||||
const trimmed = valuePart.trimStart()
|
||||
const quote = trimmed[0]
|
||||
if (quote === undefined || !MULTILINE_QUOTES.includes(quote)) return undefined
|
||||
const rest = trimmed.slice(1)
|
||||
const body = quote === '"' ? rest.replaceAll('\\"', '') : rest
|
||||
return body.includes(quote) ? undefined : quote
|
||||
}
|
||||
|
||||
/** Whether a continuation line closes the given quote. */
|
||||
function closesQuote(content: string, quote: string): boolean {
|
||||
const body = quote === '"' ? content.replaceAll('\\"', '') : content
|
||||
return body.includes(quote)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace, insert, or delete one reference's assignment while preserving
|
||||
* every other byte: untouched lines keep their exact content and terminators
|
||||
* (CRLF included), and the physical lines inside another key's quoted
|
||||
* multi-line value are never mistaken for assignments. The first matching
|
||||
* assignment is rewritten in place with its own line ending; later duplicates
|
||||
* drop (dotenv reads the last one, so a surviving duplicate would override
|
||||
* the edit); an insert appends in the document's dominant ending style.
|
||||
*/
|
||||
function upsertLine(text: string | undefined, ref: CredentialRef, rendered: string | undefined): string {
|
||||
const lines = physicalLines(text ?? '')
|
||||
const dominant = lines.some(line => line.endsWith('\r\n')) ? '\r\n' : '\n'
|
||||
const out: string[] = []
|
||||
let placed = false
|
||||
for (const current of lines) {
|
||||
if (matcher.test(current)) {
|
||||
if (line !== undefined && !placed) {
|
||||
out.push(line)
|
||||
placed = true
|
||||
}
|
||||
let pendingQuote: string | undefined
|
||||
for (const line of lines) {
|
||||
const content = lineContent(line)
|
||||
if (pendingQuote !== undefined) {
|
||||
// Inside a quoted multi-line value: never an assignment, always kept.
|
||||
if (closesQuote(content, pendingQuote)) pendingQuote = undefined
|
||||
out.push(line)
|
||||
continue
|
||||
}
|
||||
out.push(current)
|
||||
const match = ASSIGNMENT.exec(content)
|
||||
if (match === null) {
|
||||
out.push(line)
|
||||
continue
|
||||
}
|
||||
const [, key, valuePart] = match
|
||||
if (key !== ref) {
|
||||
pendingQuote = opensMultiline(valuePart ?? '')
|
||||
out.push(line)
|
||||
continue
|
||||
}
|
||||
// The write path refuses multi-line targets before rendering, so the
|
||||
// matched assignment is single-line and drops or rewrites wholesale.
|
||||
if (rendered !== undefined && !placed) {
|
||||
out.push(`${rendered}${lineTerminator(line) === '' ? dominant : lineTerminator(line)}`)
|
||||
placed = true
|
||||
}
|
||||
}
|
||||
if (line !== undefined && !placed) out.push(line)
|
||||
return out.length === 0 ? '' : `${out.join('\n')}\n`
|
||||
if (rendered !== undefined && !placed) {
|
||||
const last = out[out.length - 1]
|
||||
if (last !== undefined && lineTerminator(last) === '') {
|
||||
out[out.length - 1] = `${last}${dominant}`
|
||||
}
|
||||
out.push(`${rendered}${dominant}`)
|
||||
}
|
||||
return out.join('')
|
||||
}
|
||||
|
||||
/** File-backed credentials provider (`$DSH_HOME/.env`). */
|
||||
@@ -137,10 +202,12 @@ export class CredentialsLocal extends Credentials {
|
||||
private text: string | undefined
|
||||
/** Parsed document snapshot; replaced wholesale on every reload. */
|
||||
private values = new Map<string, string>()
|
||||
/** Serializes watcher-triggered reloads so reads never interleave. */
|
||||
private refreshTask: Promise<void> = Promise.resolve()
|
||||
/** Serializes writes to the one document; settled tail. */
|
||||
private writeChain: Promise<unknown> = Promise.resolve()
|
||||
/**
|
||||
* Single exclusive operation chain: watcher reloads and line edits run one
|
||||
* at a time in queue order (settled tail), so an edit can never render from
|
||||
* text a concurrent reload is busy replacing.
|
||||
*/
|
||||
private operations: Promise<void> = Promise.resolve()
|
||||
/** Set at dispose: refuse new writes and let in-flight work no-op. */
|
||||
private closed = false
|
||||
|
||||
@@ -159,10 +226,10 @@ export class CredentialsLocal extends Credentials {
|
||||
|
||||
async* [Service.init](): AsyncGenerator<() => Promise<void> | void, void, void> {
|
||||
yield async () => {
|
||||
// Drain: refuse new writes, then settle the queued ones so disposal
|
||||
// Drain: refuse new operations, then settle the queued ones so disposal
|
||||
// completes only once storage is quiescent.
|
||||
this.closed = true
|
||||
await this.writeChain
|
||||
await this.operations
|
||||
}
|
||||
await this.loadInitial()
|
||||
if (!this.spec.watch) return
|
||||
@@ -178,26 +245,27 @@ export class CredentialsLocal extends Credentials {
|
||||
})
|
||||
watcher.on('all', () => {
|
||||
if (this.closed) return
|
||||
this.refreshTask = this.refreshTask.then(() => this.refresh()).catch((error: unknown) => {
|
||||
// Only an invariant violation escaping the update fan-out can reject a
|
||||
// refresh; keep the reload queue alive and surface it as an error so
|
||||
// one poisoned commit cannot silently end hot reloading forever.
|
||||
this.ctx.logger.error('credentials-local: reload commit failed at %s', this.spec.filename)
|
||||
this.ctx.logger.error(error)
|
||||
})
|
||||
this.queueRefresh()
|
||||
})
|
||||
watcher.on('ready', () => {
|
||||
// The initial load raced the watcher's own setup: a change written
|
||||
// between that read and the watcher becoming active never fires an
|
||||
// event. One reconcile at ready closes the gap.
|
||||
if (this.closed) return
|
||||
this.queueRefresh()
|
||||
})
|
||||
watcher.on('error', (error) => {
|
||||
this.ctx.logger.warn('credentials-local: watcher error on %s', this.spec.filename)
|
||||
this.ctx.logger.warn(error)
|
||||
})
|
||||
/* jscpd:ignore-end */
|
||||
yield async () => {
|
||||
// Quiesce: stop accepting events, close the watcher, then wait out any
|
||||
// queued or in-flight refresh so nothing publishes after disposal.
|
||||
// queued or in-flight operation so nothing publishes after disposal.
|
||||
this.closed = true
|
||||
await watcher.close()
|
||||
await this.refreshTask
|
||||
await this.operations
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
}
|
||||
|
||||
override resolve(ref: CredentialRef): Promise<ResolvedCredential | undefined> {
|
||||
@@ -215,7 +283,9 @@ export class CredentialsLocal extends Credentials {
|
||||
}
|
||||
const stored = this.values.get(ref)
|
||||
if (stored !== undefined && stored.length > 0) {
|
||||
return Promise.resolve({ configured: true, source: 'file', writable: true })
|
||||
// A quoted multi-line value resolves fine but the line editor refuses to
|
||||
// rewrite it, so writability must say what set() would actually do.
|
||||
return Promise.resolve({ configured: true, source: 'file', writable: !stored.includes('\n') })
|
||||
}
|
||||
return Promise.resolve({ configured: false, writable: true })
|
||||
}
|
||||
@@ -231,6 +301,24 @@ export class CredentialsLocal extends Credentials {
|
||||
await this.write(ref, undefined)
|
||||
}
|
||||
|
||||
/** Queue one exclusive document operation behind every earlier one. */
|
||||
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const task = this.operations.then(operation)
|
||||
this.operations = task.then(() => undefined, () => undefined)
|
||||
return task
|
||||
}
|
||||
|
||||
/** Queue a reload; only an invariant violation escaping the fan-out can reject it. */
|
||||
private queueRefresh(): void {
|
||||
void this.enqueue(() => this.refresh()).catch((error: unknown) => {
|
||||
// Only an invariant violation escaping the update fan-out can reject a
|
||||
// refresh; keep the operation queue alive and surface it as an error so
|
||||
// one poisoned commit cannot silently end hot reloading forever.
|
||||
this.ctx.logger.error('credentials-local: reload commit failed at %s', this.spec.filename)
|
||||
this.ctx.logger.error(error)
|
||||
})
|
||||
}
|
||||
|
||||
/** Queue one line edit; entry checks reject early, the queue re-judges them at run time. */
|
||||
private async write(ref: CredentialRef, value: string | undefined): Promise<void> {
|
||||
const verb = value === undefined ? 'unset' : 'set'
|
||||
@@ -238,32 +326,43 @@ export class CredentialsLocal extends Credentials {
|
||||
throw new Error(`credentials-local is disposed: cannot ${verb} "${ref}"`)
|
||||
}
|
||||
this.assertUnshadowed(ref, verb)
|
||||
// The stored tail is settled on both outcomes, so chaining needs no catch
|
||||
// and one rejected write can never poison the queue for later callers.
|
||||
const previous = this.writeChain
|
||||
const run = previous.then(async () => {
|
||||
return this.enqueue(async () => {
|
||||
if (this.isClosed()) {
|
||||
throw new Error(`credentials-local was disposed before the queued "${ref}" ${verb} ran`)
|
||||
}
|
||||
// Re-judged at run time: the environment may have changed while queued.
|
||||
this.assertUnshadowed(ref, verb)
|
||||
const existing = this.values.get(ref)
|
||||
if (value === undefined && existing === undefined) return
|
||||
if (existing !== undefined && existing.includes('\n')) {
|
||||
throw new Error(
|
||||
`credentials-local: "${ref}" is a multi-line entry this line editor would corrupt; edit ${this.spec.filename} directly`,
|
||||
)
|
||||
}
|
||||
const nextText = upsertLine(this.text, ref, value === undefined ? undefined : renderLine(ref, value))
|
||||
// 0600: a document holding secrets is never world-readable.
|
||||
await writeFileAtomic(this.spec.filename, nextText, { mode: 0o600 })
|
||||
this.text = nextText
|
||||
if (value === undefined) this.values.delete(ref)
|
||||
else this.values.set(ref, value)
|
||||
this.ctx.emit('credentials/updated', ref)
|
||||
// The writer lock's exclusive create needs the parent to exist; 0700
|
||||
// because the harness home holds user-private data.
|
||||
await mkdir(dirname(this.spec.filename), { recursive: true, mode: 0o700 })
|
||||
await withFileLock(this.spec.filename, async () => {
|
||||
// Read-modify-write: fold in any on-disk state this process has not
|
||||
// observed yet — an external edit still inside the watcher debounce
|
||||
// window, a change the watcher missed, or another process's write —
|
||||
// so the line edit below can never resurrect a stale document.
|
||||
await this.reconcileFromDisk()
|
||||
const existing = this.values.get(ref)
|
||||
if (value === undefined && existing === undefined) return
|
||||
if (existing !== undefined && existing.includes('\n')) {
|
||||
throw new Error(
|
||||
`credentials-local: "${ref}" is a multi-line entry this line editor would corrupt; edit ${this.spec.filename} directly`,
|
||||
)
|
||||
}
|
||||
const nextText = upsertLine(this.text, ref, value === undefined ? undefined : renderLine(ref, value))
|
||||
// 0600: a document holding secrets is never world-readable.
|
||||
await writeFileAtomic(this.spec.filename, nextText, { mode: 0o600, dirMode: 0o700 })
|
||||
this.text = nextText
|
||||
if (value === undefined) this.values.delete(ref)
|
||||
else this.values.set(ref, value)
|
||||
// After the commit: a broken observer must never make the durable
|
||||
// write look failed (an INVARIANT failure still rethrows).
|
||||
this.notifyUpdated(ref)
|
||||
}, {
|
||||
onStaleBreak: (lockPath) => {
|
||||
this.ctx.logger.warn('credentials-local: breaking a stale writer lock at %s', lockPath)
|
||||
},
|
||||
})
|
||||
})
|
||||
this.writeChain = run.then(() => undefined, () => undefined)
|
||||
return run
|
||||
}
|
||||
|
||||
/** Reject a write the live environment would shadow into apparent no-effect. */
|
||||
@@ -294,19 +393,33 @@ export class CredentialsLocal extends Credentials {
|
||||
* Re-read the document after a watcher event. Unchanged content (including
|
||||
* this provider's own writes) is a no-op; an unreadable document keeps the
|
||||
* last good snapshot and warns — a live hot-reload must never take the
|
||||
* process down. dotenv parsing is lenient by design and cannot fail.
|
||||
* process down. An invariant violation escaping the fan-out is not a reload
|
||||
* failure and propagates to the queue's error surface.
|
||||
*/
|
||||
private async refresh(): Promise<void> {
|
||||
if (this.closed) return
|
||||
try {
|
||||
await this.reconcileFromDisk()
|
||||
} catch (error) {
|
||||
if ((error as { code?: unknown } | null)?.code === 'INVARIANT') throw error
|
||||
this.ctx.logger.warn('credentials-local: reload failed at %s; keeping the last good document', this.spec.filename)
|
||||
this.ctx.logger.warn(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare the on-disk text against the cache and publish any difference
|
||||
* into the seam. Absence publishes the empty store; an unreadable file
|
||||
* throws, so each caller picks its policy — a reload warns and keeps the
|
||||
* last good snapshot, a write fails loud. dotenv parsing is lenient by
|
||||
* design and cannot fail.
|
||||
*/
|
||||
private async reconcileFromDisk(): Promise<void> {
|
||||
let text: string | undefined
|
||||
try {
|
||||
text = await readFile(this.spec.filename, 'utf8')
|
||||
} catch (error) {
|
||||
if (!isENOENT(error)) {
|
||||
this.ctx.logger.warn('credentials-local: reload failed at %s; keeping the last good document', this.spec.filename)
|
||||
this.ctx.logger.warn(error)
|
||||
return
|
||||
}
|
||||
if (!isENOENT(error)) throw error
|
||||
text = undefined
|
||||
}
|
||||
if (text === this.text || this.isClosed()) return
|
||||
@@ -314,7 +427,7 @@ export class CredentialsLocal extends Credentials {
|
||||
const changed = this.changedRefs(this.values, next)
|
||||
this.text = text
|
||||
this.values = next
|
||||
for (const ref of changed) this.ctx.emit('credentials/updated', ref)
|
||||
for (const ref of changed) this.notifyUpdated(ref)
|
||||
}
|
||||
|
||||
/** Seam-addressable entries whose effective (non-empty) value changed. */
|
||||
|
||||
Reference in New Issue
Block a user