fix(dev-infra): pin replay log path identities

This commit is contained in:
Tianyi Cui
2026-07-27 22:54:58 +08:00
parent 7b4eff137e
commit 2e210c369b
6 changed files with 226 additions and 38 deletions

View File

@@ -89,6 +89,22 @@ async function assertPinnedRepository(repository) {
throw new Error('invalid repository-relative gate-log path')
}
assertIdentity(repository.identity, 'repository')
const names = repository.relative.split(sep)
if (!Array.isArray(repository.components) || repository.components.length !== names.length) {
throw new Error('invalid gate-log path-component plan')
}
for (let index = 0; index < names.length; index += 1) {
const component = repository.components[index]
if (
typeof component !== 'object'
|| component === null
|| component.name !== names[index]
|| !('identity' in component)
) {
throw new Error('invalid gate-log path-component plan')
}
if (component.identity !== null) assertIdentity(component.identity, `path component ${component.name}`)
}
const pinnedMetadata = await stat('.', { bigint: true })
if (!pinnedMetadata.isDirectory() || !sameIdentity(pinnedMetadata, repository.identity)) {
throw new Error('gate-log repository identity changed before the helper started')
@@ -101,34 +117,49 @@ async function assertPinnedRepository(repository) {
) {
throw new Error('gate-log repository root is not a real directory')
}
return repository.components
}
async function enterLogDirectory(relativePath, create) {
async function enterLogDirectory(components, create) {
const traversed = []
for (const component of relativePath.split(sep)) {
if (component === '' || component === '.' || component === '..') {
throw new Error(`invalid gate-log path component ${JSON.stringify(component)}`)
for (const component of components) {
if (component.name === '' || component.name === '.' || component.name === '..') {
throw new Error(`invalid gate-log path component ${JSON.stringify(component.name)}`)
}
traversed.push(component)
traversed.push(component.name)
let componentMetadata
let created = false
try {
componentMetadata = await lstat(component, { bigint: true })
componentMetadata = await lstat(component.name, { bigint: true })
} catch (error) {
if (errorCode(error) !== 'ENOENT') throw error
if (component.identity !== null) {
throw new Error(`gate-log path component disappeared after validation: ${traversed.join('/')}`)
}
if (!create) return undefined
try {
await mkdir(component, { mode: 0o700 })
await mkdir(component.name, { mode: 0o700 })
} catch (mkdirError) {
if (errorCode(mkdirError) !== 'EEXIST') throw mkdirError
if (errorCode(mkdirError) === 'EEXIST') {
throw new Error(`gate-log path component appeared after validation: ${traversed.join('/')}`)
}
throw mkdirError
}
componentMetadata = await lstat(component, { bigint: true })
componentMetadata = await lstat(component.name, { bigint: true })
created = true
}
if (component.identity === null && !created) {
throw new Error(`gate-log path component appeared after validation: ${traversed.join('/')}`)
}
if (component.identity !== null && !sameIdentity(componentMetadata, component.identity)) {
throw new Error(`gate-log path component identity changed after validation: ${traversed.join('/')}`)
}
const shown = traversed.join('/')
if (!componentMetadata.isDirectory() || componentMetadata.isSymbolicLink()) {
throw new Error(`gate-log path component is not a real directory: ${shown}`)
}
const expected = identityOf(componentMetadata)
process.chdir(component)
const expected = component.identity ?? identityOf(componentMetadata)
process.chdir(component.name)
const pinnedMetadata = await stat('.', { bigint: true })
if (!pinnedMetadata.isDirectory() || !sameIdentity(pinnedMetadata, expected)) {
throw new Error(`gate-log path component identity changed before pinning: ${shown}`)
@@ -195,8 +226,8 @@ async function writeLog(request) {
async function main() {
const request = await readRequest()
assertRequest(request)
await assertPinnedRepository(request.repository)
const directory = await enterLogDirectory(request.repository.relative, request.operation === 'write')
const components = await assertPinnedRepository(request.repository)
const directory = await enterLogDirectory(components, request.operation === 'write')
if (directory === undefined) return { removed: [] }
switch (request.operation) {
case 'write': {

View File

@@ -25,6 +25,7 @@ import {
formatOnlyNotice,
gateDependencyClosure,
gatePlanForMode,
isMainModule,
listedGatePlan,
limitGateFailureLog,
parseCliRequest,
@@ -240,6 +241,15 @@ describe('gate plan inspection and replay', () => {
})
})
it.skipIf(process.platform === 'win32')('recognizes a symlinked script entry path', () => {
const temporary = temporaryRoot('dsh-run-gates-entry-')
const entry = join(temporary, 'run-gates.ts')
symlinkSync(join(repositoryRoot, 'scripts/run-gates.ts'), entry)
expect(isMainModule(entry)).toBe(true)
expect(isMainModule(join(temporary, 'missing.ts'))).toBe(false)
})
it('renders a cross-platform scheduler replay and labels focused evidence', () => {
const subject = plan([gate('snapshot')])
expect(replayCommand(subject, 'snapshot')).toBe('pnpm run check:all -- --only snapshot')
@@ -465,6 +475,103 @@ describe('gate failure logs', () => {
}
})
it.skipIf(process.platform === 'win32')('rejects a real-directory ancestor moved into place after validation', async () => {
const subjectGate = gate('subject')
const subject = plan([subjectGate])
for (const operation of ['write', 'prune', 'clean'] as const) {
const auditRoot = temporaryRoot(`dsh-gate-${operation}-real-swap-`)
const repositoryRoot = join(auditRoot, 'repository')
const external = join(auditRoot, 'external')
const cache = join(repositoryRoot, '.cache')
const directory = join(cache, 'gates')
const displacedCache = join(repositoryRoot, '.cache-pinned')
const externalCache = join(external, 'cache')
mkdirSync(directory, { recursive: true })
mkdirSync(join(externalCache, 'gates'), { recursive: true })
writeFileSync(join(directory, 'old.log'), 'old private log\n')
const victim = operation === 'write' ? undefined : join(externalCache, 'gates/victim.log')
if (victim !== undefined) writeFileSync(victim, 'keep\n')
const swapAncestor = (): void => {
renameSync(cache, displacedCache)
renameSync(externalCache, cache)
}
let invocation: Promise<unknown>
if (operation === 'write') {
invocation = writeGateFailureLog(subject, resultFor(subjectGate, 'failed'), {
directory,
repositoryRoot,
retention: 1,
unique: operation,
platform: 'linux',
beforeHelper: swapAncestor,
})
} else if (operation === 'prune') {
invocation = pruneGateLogs(directory, 0, repositoryRoot, swapAncestor)
} else {
invocation = cleanGateFailureLogs(directory, repositoryRoot, swapAncestor)
}
await expect(invocation).rejects.toThrow('gate-log helper')
if (victim === undefined) {
expect(readdirSync(join(cache, 'gates'))).toEqual([])
} else {
expect(readFileSync(join(cache, 'gates/victim.log'), 'utf8')).toBe('keep\n')
}
expect(readFileSync(join(displacedCache, 'gates/old.log'), 'utf8')).toBe('old private log\n')
}
})
it.skipIf(process.platform === 'win32')('rejects a real directory introduced at a previously missing component', async () => {
const auditRoot = temporaryRoot('dsh-gate-missing-real-swap-')
const repositoryRoot = join(auditRoot, 'repository')
const externalCache = join(auditRoot, 'external-cache')
const cache = join(repositoryRoot, '.cache')
const directory = join(cache, 'gates')
mkdirSync(repositoryRoot)
mkdirSync(join(externalCache, 'gates'), { recursive: true })
const victim = join(externalCache, 'gates/victim.log')
writeFileSync(victim, 'keep\n')
const subjectGate = gate('subject')
const invocation = writeGateFailureLog(plan([subjectGate]), resultFor(subjectGate, 'failed'), {
directory,
repositoryRoot,
retention: 1,
unique: 'missing-swap',
platform: 'linux',
beforeHelper: () => {
renameSync(externalCache, cache)
},
})
await expect(invocation).rejects.toThrow('gate-log helper')
expect(readFileSync(join(cache, 'gates/victim.log'), 'utf8')).toBe('keep\n')
expect(readdirSync(join(cache, 'gates'))).toEqual(['victim.log'])
})
it.skipIf(process.platform === 'win32')('rejects a repository root replaced after validation', async () => {
const auditRoot = temporaryRoot('dsh-gate-root-swap-')
const repositoryRoot = join(auditRoot, 'repository')
const externalRoot = join(auditRoot, 'external-repository')
const displacedRoot = join(auditRoot, 'repository-pinned')
const directory = join(repositoryRoot, '.cache/gates')
mkdirSync(directory, { recursive: true })
mkdirSync(join(externalRoot, '.cache/gates'), { recursive: true })
writeFileSync(join(directory, 'old.log'), 'old private log\n')
writeFileSync(join(externalRoot, '.cache/gates/victim.log'), 'keep\n')
const invocation = cleanGateFailureLogs(directory, repositoryRoot, () => {
renameSync(repositoryRoot, displacedRoot)
renameSync(externalRoot, repositoryRoot)
})
await expect(invocation).rejects.toThrow('gate-log helper')
expect(readFileSync(join(repositoryRoot, '.cache/gates/victim.log'), 'utf8')).toBe('keep\n')
expect(readFileSync(join(displacedRoot, '.cache/gates/old.log'), 'utf8')).toBe('old private log\n')
})
it('uses a console-only fallback on Windows before creating a retention directory', async () => {
const repositoryRoot = temporaryRoot()
const directory = join(repositoryRoot, '.cache/gates')

View File

@@ -7,6 +7,7 @@
*/
import { spawn } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { realpathSync } from 'node:fs'
import { lstat } from 'node:fs/promises'
import { availableParallelism } from 'node:os'
import { isAbsolute, relative, resolve, sep } from 'node:path'
@@ -136,6 +137,16 @@ interface GateLogDirectoryIdentity {
ino: string
}
interface GateLogPathComponent {
name: string
identity: GateLogDirectoryIdentity | null
}
interface GateLogPathPlan {
repositoryIdentity: GateLogDirectoryIdentity
pathComponents: GateLogPathComponent[]
}
type GateLogHelperRequest =
| { operation: 'write'; filename: string; content: string; retention: number }
| { operation: 'prune'; retain: number }
@@ -211,9 +222,19 @@ async function main(args: string[]): Promise<number> {
: 0
}
function isMainModule(): boolean {
const entry = process.argv[1]
return entry !== undefined && import.meta.url === pathToFileURL(resolve(entry)).href
/**
* Decide whether this module is the process entry, including through a symlinked path.
* @param entry - process entry path to compare with this module.
* @returns Whether the entry resolves to this module.
*/
export function isMainModule(entry: string | undefined = process.argv[1]): boolean {
if (entry === undefined) return false
if (import.meta.url === pathToFileURL(resolve(entry)).href) return true
try {
return import.meta.url === pathToFileURL(realpathSync(entry)).href
} catch {
return false
}
}
/**
@@ -1058,9 +1079,7 @@ export async function writeGateFailureLog(
if (!Number.isSafeInteger(retention) || retention < 1) {
throw new Error(`run-gates: log retention must be a positive integer, got ${JSON.stringify(retention)}.`)
}
await assertRepoLocalLogPath(repositoryRoot, directory)
const repositoryIdentity = await readDirectoryIdentity(repositoryRoot)
if (repositoryIdentity === undefined) throw new Error(`run-gates: repository root disappeared: ${repositoryRoot}`)
const { pathComponents, repositoryIdentity } = await inspectRepoLocalLogPath(repositoryRoot, directory)
const timestamp = now.toISOString().replaceAll(/[:.]/g, '-')
const safeUnique = unique.replaceAll(/[^a-zA-Z0-9-]/g, '')
if (safeUnique === '') throw new Error('run-gates: failure-log unique suffix is empty after sanitization.')
@@ -1070,6 +1089,7 @@ export async function writeGateFailureLog(
directory,
repositoryRoot,
repositoryIdentity,
pathComponents,
{
operation: 'write',
filename,
@@ -1082,24 +1102,37 @@ export async function writeGateFailureLog(
return resolve(directory, filename)
}
async function assertRepoLocalLogPath(repositoryRoot: string, target: string): Promise<void> {
async function inspectRepoLocalLogPath(
repositoryRoot: string,
target: string,
): Promise<GateLogPathPlan> {
const relativeTarget = relative(repositoryRoot, target)
if (relativeTarget === '' || relativeTarget === '..' || relativeTarget.startsWith(`..${sep}`) || isAbsolute(relativeTarget)) {
throw new Error(`run-gates: gate-log path must be below the repository root: ${target}`)
}
const rootMetadata = await lstat(repositoryRoot)
const rootMetadata = await lstat(repositoryRoot, { bigint: true })
if (!rootMetadata.isDirectory() || rootMetadata.isSymbolicLink()) {
throw new Error(`run-gates: repository root is not a real directory: ${repositoryRoot}`)
}
const components: GateLogPathComponent[] = []
let current = repositoryRoot
let missing = false
for (const component of relativeTarget.split(sep)) {
current = resolve(current, component)
if (missing) {
components.push({ name: component, identity: null })
continue
}
let metadata
try {
metadata = await lstat(current)
metadata = await lstat(current, { bigint: true })
} catch (error: unknown) {
if (hasErrorCode(error, 'ENOENT')) return
if (hasErrorCode(error, 'ENOENT')) {
missing = true
components.push({ name: component, identity: null })
continue
}
throw error
}
const shown = relative(repositoryRoot, current).split(sep).join('/')
@@ -1109,6 +1142,11 @@ async function assertRepoLocalLogPath(repositoryRoot: string, target: string): P
if (!metadata.isDirectory()) {
throw new Error(`run-gates: gate-log path component is not a directory: ${shown}`)
}
components.push({ name: component, identity: { dev: String(metadata.dev), ino: String(metadata.ino) } })
}
return {
repositoryIdentity: { dev: String(rootMetadata.dev), ino: String(rootMetadata.ino) },
pathComponents: components,
}
}
@@ -1134,6 +1172,7 @@ async function runGateLogHelper(
directory: string,
repositoryRoot: string,
repositoryIdentity: GateLogDirectoryIdentity,
pathComponents: GateLogPathComponent[],
request: GateLogHelperRequest,
beforeHelper: (() => Promise<void> | void) | undefined,
): Promise<GateLogHelperResult> {
@@ -1144,6 +1183,7 @@ async function runGateLogHelper(
root: repositoryRoot,
relative: relative(repositoryRoot, directory),
identity: repositoryIdentity,
components: pathComponents,
},
})
const result = await new Promise<{ status: number | null; stdout: string; stderr: string }>((resolveResult, reject) => {
@@ -1181,7 +1221,7 @@ async function runGateLogHelper(
throw new Error(`run-gates: gate-log helper returned invalid JSON: ${JSON.stringify(result.stdout)}`)
}
if (!isGateLogHelperResult(parsed)) throw new Error('run-gates: gate-log helper returned an invalid result.')
await assertRepoLocalLogPath(repositoryRoot, directory)
await inspectRepoLocalLogPath(repositoryRoot, directory)
const currentRepositoryIdentity = await readDirectoryIdentity(repositoryRoot)
if (
currentRepositoryIdentity === undefined
@@ -1229,10 +1269,15 @@ export async function cleanGateFailureLogs(
repositoryRoot = root,
beforeHelper?: () => Promise<void> | void,
): Promise<void> {
await assertRepoLocalLogPath(repositoryRoot, directory)
const repositoryIdentity = await readDirectoryIdentity(repositoryRoot)
if (repositoryIdentity === undefined) return
await runGateLogHelper(directory, repositoryRoot, repositoryIdentity, { operation: 'clean' }, beforeHelper)
const { pathComponents, repositoryIdentity } = await inspectRepoLocalLogPath(repositoryRoot, directory)
await runGateLogHelper(
directory,
repositoryRoot,
repositoryIdentity,
pathComponents,
{ operation: 'clean' },
beforeHelper,
)
}
/**
@@ -1251,10 +1296,15 @@ export async function pruneGateLogs(
if (!Number.isSafeInteger(retain) || retain < 0) {
throw new Error(`run-gates: retained log count must be a non-negative integer, got ${JSON.stringify(retain)}.`)
}
await assertRepoLocalLogPath(repositoryRoot, directory)
const repositoryIdentity = await readDirectoryIdentity(repositoryRoot)
if (repositoryIdentity === undefined) return
await runGateLogHelper(directory, repositoryRoot, repositoryIdentity, { operation: 'prune', retain }, beforeHelper)
const { pathComponents, repositoryIdentity } = await inspectRepoLocalLogPath(repositoryRoot, directory)
await runGateLogHelper(
directory,
repositoryRoot,
repositoryIdentity,
pathComponents,
{ operation: 'prune', retain },
beforeHelper,
)
}
async function attachFailureLog(plan: GatePlan, result: GateResult): Promise<void> {