Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions src/services/checkpoints/ShadowCheckpointService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,21 @@ function createSanitizedGit(baseDir: string): SimpleGit {
return git
}

/**
* Whether the symlink-resolved (real) `target` path is `root` itself or
* nested below it.
*
* `path.relative` reports an outside target as a path that starts with `..`
* (or, for a target on another drive on Windows, an absolute path). That
* cannot be fooled by a sibling directory whose name extends the root's name
* (e.g. `/ws2` vs `/ws`), the one case a trailing-separator `startsWith`
* prefix check accepts.
*/
export function isRealPathContained(root: string, target: string): boolean {
const rel = path.relative(root, target)
return !rel.startsWith("..") && !path.isAbsolute(rel)
}

export abstract class ShadowCheckpointService extends EventEmitter {
public readonly taskId: string
public readonly checkpointsDir: string
Expand Down Expand Up @@ -407,6 +422,105 @@ export abstract class ShadowCheckpointService extends EventEmitter {
}
}

/**
* Restore a single file to its state at `commitHash` without moving the
* branch or truncating the checkpoint list (unlike
* {@link restoreCheckpoint}).
*
* If the file did not exist at `commitHash`, it is removed from the
* working tree instead — rolling a file back to before it was created.
*/
public async restoreFile(commitHash: string, filePath: string): Promise<void> {
try {
this.log(`[${this.constructor.name}#restoreFile] restoring ${filePath} from ${commitHash}`)

if (!this.git) {
throw new Error("Shadow git repo not initialized")
}

// Git pathspecs are always POSIX: normalize a native (Windows
// backslash) path before the git calls. Without this, `cat-file -e` on
// a backslashed path never matches, the file is treated as absent at
// the checkpoint, and the delete branch below would remove a file the
// checkpoint actually contains. The local fs.rm join keeps the native
// form, since the OS treats both separators interchangeably there.
const gitPath = filePath.toPosix()

// Constrain the path to the workspace before either branch: `..`
// segments would otherwise normalize (path.join / git pathspec) to a
// location outside `this.workspaceDir`, and the delete branch could
// remove an unrelated file. `path.resolve` normalizes the segments;
// the trailing-separator prefix check is the containment guard.
const resolvedTarget = path.resolve(this.workspaceDir, filePath)
const workspaceRoot = this.workspaceDir.endsWith(path.sep)
? this.workspaceDir
: this.workspaceDir + path.sep
if (resolvedTarget !== this.workspaceDir && !resolvedTarget.startsWith(workspaceRoot)) {
throw new Error(`restoreFile target is outside the workspace: ${filePath}`)
}

// The lexical check cannot see through a symlinked ancestor: a link
// inside the workspace pointing outside it passes the prefix check
// while the real target resolves elsewhere. Re-check containment on
// the resolved (real) paths whenever the target file currently
// exists — that is the destructive case. A target that does not exist
// cannot be deleted, and the checkout branch only writes files the
// task-owned shadow repo recorded. Resolving both sides keeps a
// legitimate symlinked workspace root working.
if (await fileExistsAtPath(resolvedTarget)) {
const realWorkspaceRoot = await fs.realpath(this.workspaceDir)
const realTarget = await fs.realpath(resolvedTarget)
if (!isRealPathContained(realWorkspaceRoot, realTarget)) {
throw new Error(`restoreFile target resolves outside the workspace: ${filePath}`)
}
}

const start = Date.now()
const existed = await this.fileExistsInCommit(commitHash, gitPath)

if (existed) {
await this.git.checkout([commitHash, "--", gitPath])
} else {
await fs.rm(path.join(this.workspaceDir, filePath), { force: true })
}

const duration = Date.now() - start
this.emit("restore", { type: "restore", commitHash, duration })
this.log(`[${this.constructor.name}#restoreFile] restored ${filePath} in ${duration}ms`)
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e))
this.log(`[${this.constructor.name}#restoreFile] failed to restore file: ${error.message}`)
this.emit("error", { type: "error", error })
throw error
}
}

/** Whether `filePath` exists in the tree of `commitHash`. */
private async fileExistsInCommit(commitHash: string, filePath: string): Promise<boolean> {
// A failed lookup is not evidence the file is absent. If the commit object
// itself cannot be read (invalid hash, corrupt or missing shadow repo),
// falling through to the restoreFile delete branch would remove a live
// file. Verify the object first and fail the restore loudly instead.
//
// Verification must be evidence-based: simple-git's raw() only rejects
// when git writes a fatal to stderr, and `git cat-file -e <bad-sha>`
// fails *silently* (exit 1, no output) — a silent resolution would be
// read as "valid commit". `rev-parse --verify` emits the resolved id on
// success and a stderr fatal on every failure mode, so the reject is
// reliable.
try {
await this.git!.raw(["rev-parse", "--verify", `${commitHash}^{commit}`])
} catch {
throw new Error(`Checkpoint unavailable: ${commitHash}`)
}
try {
await this.git!.raw(["cat-file", "-e", `${commitHash}:${filePath}`])
return true
} catch {
return false
}
}

public async getDiff({ from, to }: { from?: string; to?: string }): Promise<CheckpointDiff[]> {
if (!this.git) {
throw new Error("Shadow git repo not initialized")
Expand Down
Loading
Loading