fix(jsonl): handle filesystem path aliases

This commit is contained in:
Turtle
2026-07-24 21:50:11 +08:00
parent c14f488b00
commit 1ffdacb2c4
8 changed files with 60 additions and 11 deletions

View File

@@ -9,7 +9,7 @@
import { Context } from 'cordis'
import z from 'schemastery'
import { readdirSync } from 'node:fs'
import { open, mkdir, readFile, readdir, link, rm, stat, truncate } from 'node:fs/promises'
import { open, mkdir, readFile, readdir, realpath, link, rm, stat, truncate } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { randomBytes } from 'node:crypto'
import {
@@ -168,7 +168,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
: {},
}
}
this.assertStoredIdentity(path, prefix.meta, expectedId)
await this.assertStoredIdentity(path, prefix.meta, expectedId)
return prefix
}
@@ -291,7 +291,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
if (first === undefined) continue // empty/half-written file
const meta = parseHeaderMeta(first)
if (meta === undefined) continue // not a session header
this.assertStoredIdentity(path, meta)
await this.assertStoredIdentity(path, meta)
if (ids.has(meta.id)) {
throw new Error(`duplicate JSONL session id "${meta.id}" appears in multiple project directories`)
}
@@ -578,7 +578,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
}
/** Reject metadata that does not identify the selected physical log. */
private assertStoredIdentity(path: string, meta: SessionHeader, expectedId?: SessionId): void {
private async assertStoredIdentity(path: string, meta: SessionHeader, expectedId?: SessionId): Promise<void> {
if (expectedId !== undefined && meta.id !== expectedId) {
throw new Error(`corrupt session log "${path}": requested id "${expectedId}" does not match header id "${meta.id}"`)
}
@@ -588,11 +588,28 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
} catch (error) {
throw new Error(`corrupt session log "${path}": header id cannot name a storage path`, { cause: error })
}
if (path !== expectedPath) {
if (path !== expectedPath && !await this.sameFile(path, expectedPath)) {
throw new Error(`corrupt session log "${path}": header id "${meta.id}" and cwd identify "${expectedPath}"`)
}
}
/**
* Whether two path spellings resolve to the same physical file. This admits
* case aliases on case-insensitive filesystems without weakening identity
* checks on case-sensitive stores.
*/
private async sameFile(path: string, expectedPath: string): Promise<boolean> {
try {
const [actual, expected] = await Promise.all([realpath(path), realpath(expectedPath)])
return actual === expected
} catch (error) {
/* v8 ignore else -- non-ENOENT realpath failures require an external permission or I/O fault */
if (isENOENT(error)) return false
/* v8 ignore next -- non-ENOENT realpath failures are external I/O faults, propagated unchanged */
throw error
}
}
/** The human-readable project directories under the configured root. */
private async listProjectDirs(): Promise<string[]> {
try {

View File

@@ -12,7 +12,7 @@
*/
import { mkdtemp, rm, stat } from 'node:fs/promises'
import { basename, join, parse, resolve, toNamespacedPath } from 'node:path'
import { join, parse, resolve, toNamespacedPath } from 'node:path'
type MoveFileExW = (existing: string, replacement: string, flags: number) => number
type GetLastError = () => number
@@ -139,7 +139,9 @@ export async function ensureDurableDirectoryWin32(target: string): Promise<void>
}
async function createLeafDirectoryWin32(parent: string, target: string): Promise<void> {
const staging = await mkdtemp(join(parent, `.dsh-mkdir-${basename(target)}-`))
// Keep the staging component independent of the target basename so a legal
// 255-byte target component does not make mkdtemp's sibling name too long.
const staging = await mkdtemp(join(parent, '.dsh-mkdir-'))
try {
await publishNewFileWin32(staging, target)
} catch (error) {