mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-04-28 06:59:37 +00:00
fix: tighten source cache validation
This commit is contained in:
parent
0d4d103627
commit
ac5dd8c3e9
2 changed files with 103 additions and 1 deletions
|
|
@ -44,10 +44,44 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|||
function isManifestEntry(value: unknown): value is { file: string; provider: string; logicalPath: string } {
|
||||
return isPlainObject(value)
|
||||
&& typeof value.file === 'string'
|
||||
&& /^[a-f0-9]{40}\.json$/.test(value.file)
|
||||
&& typeof value.provider === 'string'
|
||||
&& typeof value.logicalPath === 'string'
|
||||
}
|
||||
|
||||
function isSessionSummary(value: unknown): value is SessionSummary {
|
||||
return isPlainObject(value)
|
||||
&& typeof value.sessionId === 'string'
|
||||
&& typeof value.project === 'string'
|
||||
&& typeof value.firstTimestamp === 'string'
|
||||
&& typeof value.lastTimestamp === 'string'
|
||||
&& typeof value.totalCostUSD === 'number'
|
||||
&& Number.isFinite(value.totalCostUSD)
|
||||
&& typeof value.totalInputTokens === 'number'
|
||||
&& Number.isFinite(value.totalInputTokens)
|
||||
&& typeof value.totalOutputTokens === 'number'
|
||||
&& Number.isFinite(value.totalOutputTokens)
|
||||
&& typeof value.totalCacheReadTokens === 'number'
|
||||
&& Number.isFinite(value.totalCacheReadTokens)
|
||||
&& typeof value.totalCacheWriteTokens === 'number'
|
||||
&& Number.isFinite(value.totalCacheWriteTokens)
|
||||
&& typeof value.apiCalls === 'number'
|
||||
&& Number.isFinite(value.apiCalls)
|
||||
&& Array.isArray(value.turns)
|
||||
&& isPlainObject(value.modelBreakdown)
|
||||
&& isPlainObject(value.toolBreakdown)
|
||||
&& isPlainObject(value.mcpBreakdown)
|
||||
&& isPlainObject(value.bashBreakdown)
|
||||
&& isPlainObject(value.categoryBreakdown)
|
||||
}
|
||||
|
||||
function isAppendState(value: unknown): value is AppendState {
|
||||
return isPlainObject(value)
|
||||
&& typeof value.endOffset === 'number'
|
||||
&& Number.isFinite(value.endOffset)
|
||||
&& typeof value.tailHash === 'string'
|
||||
}
|
||||
|
||||
function isSourceCacheEntry(value: unknown): value is SourceCacheEntry {
|
||||
return isPlainObject(value)
|
||||
&& typeof value.version === 'number'
|
||||
|
|
@ -57,9 +91,13 @@ function isSourceCacheEntry(value: unknown): value is SourceCacheEntry {
|
|||
&& (value.cacheStrategy === 'full-reparse' || value.cacheStrategy === 'append-jsonl')
|
||||
&& typeof value.parserVersion === 'string'
|
||||
&& isPlainObject(value.fingerprint)
|
||||
&& Number.isFinite(value.fingerprint.mtimeMs)
|
||||
&& typeof value.fingerprint.mtimeMs === 'number'
|
||||
&& Number.isFinite(value.fingerprint.sizeBytes)
|
||||
&& typeof value.fingerprint.sizeBytes === 'number'
|
||||
&& Array.isArray(value.sessions)
|
||||
&& value.sessions.every(isSessionSummary)
|
||||
&& (value.appendState === undefined || isAppendState(value.appendState))
|
||||
}
|
||||
|
||||
function cacheRoot(): string {
|
||||
|
|
@ -172,10 +210,10 @@ export async function readSourceCacheEntry(
|
|||
export async function writeSourceCacheEntry(manifest: SourceCacheManifest, entry: SourceCacheEntry): Promise<void> {
|
||||
await mkdir(entryDir(), { recursive: true })
|
||||
const file = entryFilename(entry.provider, entry.logicalPath)
|
||||
await atomicWriteJson(join(entryDir(), file), entry)
|
||||
manifest.entries[sourceKey(entry.provider, entry.logicalPath)] = {
|
||||
file,
|
||||
provider: entry.provider,
|
||||
logicalPath: entry.logicalPath,
|
||||
}
|
||||
await atomicWriteJson(join(entryDir(), file), entry)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createHash } from 'crypto'
|
||||
import { existsSync } from 'fs'
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
|
|
@ -42,6 +43,22 @@ describe('source cache manifest', () => {
|
|||
await expect(loadSourceCacheManifest()).resolves.toEqual(emptySourceCacheManifest())
|
||||
})
|
||||
|
||||
it('returns an empty manifest when an entry filename is unsafe', async () => {
|
||||
await mkdir(join(root, 'source-cache-v1'), { recursive: true })
|
||||
await writeFile(join(root, 'source-cache-v1', 'manifest.json'), JSON.stringify({
|
||||
version: SOURCE_CACHE_VERSION,
|
||||
entries: {
|
||||
bad: {
|
||||
file: '../escape.json',
|
||||
provider: 'fake',
|
||||
logicalPath: join(root, 'source.jsonl'),
|
||||
},
|
||||
},
|
||||
}), 'utf-8')
|
||||
|
||||
await expect(loadSourceCacheManifest()).resolves.toEqual(emptySourceCacheManifest())
|
||||
})
|
||||
|
||||
it('round-trips a manifest and entry', async () => {
|
||||
const sourcePath = join(root, 'source.jsonl')
|
||||
await writeFile(sourcePath, '{"ok":true}\n', 'utf-8')
|
||||
|
|
@ -113,6 +130,30 @@ describe('source cache manifest', () => {
|
|||
expect(loaded).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when append state is malformed', async () => {
|
||||
const sourcePath = join(root, 'source.jsonl')
|
||||
await writeFile(sourcePath, 'one\n', 'utf-8')
|
||||
const fingerprint = await computeFileFingerprint(sourcePath)
|
||||
const entry = {
|
||||
version: SOURCE_CACHE_VERSION,
|
||||
provider: 'fake',
|
||||
logicalPath: sourcePath,
|
||||
fingerprintPath: sourcePath,
|
||||
cacheStrategy: 'append-jsonl' as const,
|
||||
parserVersion: 'fake-v1',
|
||||
fingerprint,
|
||||
sessions: [],
|
||||
appendState: { endOffset: 'bad', tailHash: 'abc' },
|
||||
}
|
||||
|
||||
const manifest = await loadSourceCacheManifest()
|
||||
await writeSourceCacheEntry(manifest, entry as SourceCacheEntry)
|
||||
await saveSourceCacheManifest(manifest)
|
||||
|
||||
const loaded = await readSourceCacheEntry(await loadSourceCacheManifest(), 'fake', sourcePath)
|
||||
expect(loaded).toBeNull()
|
||||
})
|
||||
|
||||
it('writes atomically without leaving temp files behind', async () => {
|
||||
const sourcePath = join(root, 'source.jsonl')
|
||||
await writeFile(sourcePath, 'x\n', 'utf-8')
|
||||
|
|
@ -137,4 +178,27 @@ describe('source cache manifest', () => {
|
|||
expect(cacheFiles.some(f => f.endsWith('.tmp'))).toBe(false)
|
||||
expect(entryFiles.some(f => f.endsWith('.tmp'))).toBe(false)
|
||||
})
|
||||
|
||||
it('does not mutate the manifest when the entry write fails', async () => {
|
||||
const sourcePath = join(root, 'source.jsonl')
|
||||
await writeFile(sourcePath, 'x\n', 'utf-8')
|
||||
const manifest = await loadSourceCacheManifest()
|
||||
const provider = 'fake'
|
||||
const logicalPath = sourcePath
|
||||
const file = `${createHash('sha1').update(`${provider}:${logicalPath}`).digest('hex')}.json`
|
||||
await mkdir(join(root, 'source-cache-v1', 'entries', file), { recursive: true })
|
||||
|
||||
await expect(writeSourceCacheEntry(manifest, {
|
||||
version: SOURCE_CACHE_VERSION,
|
||||
provider,
|
||||
logicalPath,
|
||||
fingerprintPath: sourcePath,
|
||||
cacheStrategy: 'full-reparse',
|
||||
parserVersion: 'fake-v1',
|
||||
fingerprint: await computeFileFingerprint(sourcePath),
|
||||
sessions: [],
|
||||
})).rejects.toBeTruthy()
|
||||
|
||||
expect(manifest.entries[`fake:${sourcePath}`]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue