fix: discard inaccessible computer write receipts safely

Preserve removed, replaced, or permission-restricted temp parents while allowing unrelated file operations to continue. Keep unknown I/O failures visible.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-10 14:22:43 -05:00
1 parent d2235865bb
commit 5e5fe7a708
2 files changed
+35 -2

No files matched your search

@@ -10,6 +10,7 @@ import {
writeFileSync,
mkdirSync,
renameSync,
chmodSync,
} from "node:fs";
import { createHash, randomUUID } from "node:crypto";
import { tmpdir } from "node:os";
@@ -288,6 +289,35 @@ describe("confined computer files", () => {
expect(readFileSync(outside, "utf8")).toBe("preserve");
expect(existsSync(temp)).toBe(true);
});
it.each(["missing", "file", "symlink"])("discards an unusable receipt when its parent is %s", (replacement) => {
const f = interruptedWrite();
renameSync(join(f.root, "nested"), join(f.root, "moved-by-user"));
const outside = join(f.temp, "outside"); mkdirSync(outside);
writeFileSync(join(outside, "notes"), "outside user content");
if (replacement === "file") writeFileSync(join(f.root, "nested"), "replacement parent");
if (replacement === "symlink") symlinkSync(outside, join(f.root, "nested"));
writeFileSync(join(f.root, "unrelated"), "still readable");
expect(f.call({ action: "list", path: "" }).entries).toEqual(expect.any(Array));
expect(Buffer.from(f.call({ action: "read", path: "unrelated" }).base64, "base64").toString()).toBe("still readable");
expect(f.call({ action: "write", path: "new-file", expectedSha256: null, base64: Buffer.from("new content").toString("base64") }).sha256).toBeTruthy();
expect(readFileSync(join(f.root, ".paperclip-editor.lock"), "utf8")).toBe("");
expect(readFileSync(join(f.root, "moved-by-user/notes"), "utf8")).toBe("original");
expect(readFileSync(join(f.root, "moved-by-user", f.receipt.path.split("/").at(-1)), "utf8")).toBe("interrupted content");
expect(readFileSync(join(outside, "notes"), "utf8")).toBe("outside user content");
if (replacement === "file") expect(readFileSync(join(f.root, "nested"), "utf8")).toBe("replacement parent");
if (replacement === "symlink") expect(existsSync(join(f.root, "nested/notes"))).toBe(true);
});
it.skipIf(process.getuid?.() === 0)("preserves an inaccessible temp parent without blocking unrelated reads", () => {
const f = interruptedWrite();
writeFileSync(join(f.root, "unrelated"), "still readable");
chmodSync(join(f.root, "nested"), 0);
try {
expect(Buffer.from(f.call({ action: "read", path: "unrelated" }).base64, "base64").toString()).toBe("still readable");
expect(readFileSync(join(f.root, ".paperclip-editor.lock"), "utf8")).toBe("");
} finally { chmodSync(join(f.root, "nested"), 0o700); }
expect(readFileSync(join(f.root, f.receipt.path), "utf8")).toBe("interrupted content");
expect(readFileSync(join(f.root, "nested/notes"), "utf8")).toBe("original");
});
it("leaves only an empty temp when killed before its ownership receipt", () => {
const f = fixture(); f.call({ action: "seed", files: {} });
const result = spawnSync("python3", ["-c", remoteProgram
@@ -1,6 +1,6 @@
/** Fixed remote program. Inputs travel as JSON, never interpolated into Python or shell. */
export const remoteProgram = String.raw`
import os,sys,json,hashlib,base64,tempfile,subprocess,fcntl,stat,shutil
import os,sys,json,hashlib,base64,tempfile,subprocess,fcntl,stat,shutil,errno
p=json.load(sys.stdin)
def fail(code):
print(json.dumps({'error':code}));sys.exit(0)
@@ -273,7 +273,10 @@ def recover_write():
info=os.stat(name,dir_fd=parentfd,follow_symlinks=False)
if stat.S_ISREG(info.st_mode) and info.st_nlink==1 and info.st_dev==receipt.get('device') and info.st_ino==receipt.get('inode'):
os.unlink(name,dir_fd=parentfd)
except FileNotFoundError:pass
except OSError as error:
# A removed/replaced/inaccessible ancestor makes this receipt unusable. Preserve
# whatever occupies that path and allow unrelated file operations to proceed.
if error.errno not in (errno.ENOENT,errno.ENOTDIR,errno.ELOOP,errno.EACCES,errno.EPERM):raise
finally:
if parentfd is not None:os.close(parentfd)
clear_write_receipt()