mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
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:
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()
|
||||
|
||||
Reference in new issue
Block a user