mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 20:46:28 +01:00
Preserve empty directories during persistent home seeding
Stage directory entries under the existing exact upload token and no-follow directory handles before atomic publication. Preserve adoption of existing homes and bounded file streaming. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
cdde159de3
commit
54deda1baa
6 files changed
+54
-5
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
existsSync,
|
||||
unlinkSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
statSync,
|
||||
@@ -511,6 +512,24 @@ describe("confined computer files", () => {
|
||||
error: "invalid",
|
||||
});
|
||||
});
|
||||
it("stages empty seed directories with exact upload ownership and no-follow paths", () => {
|
||||
const f = fixture(); const seedId = randomUUID();
|
||||
f.call({ action: "seed-begin", seedId });
|
||||
expect(f.call({ action: "seed-directory", seedId, path: "empty/nested" })).toEqual({});
|
||||
expect(existsSync(f.root)).toBe(false);
|
||||
expect(f.call({ action: "seed-directory", seedId: randomUUID(), path: "foreign" })).toEqual({ error: "conflict" });
|
||||
expect(f.call({ action: "seed-directory", seedId, path: "../escape" })).toEqual({ error: "invalid" });
|
||||
const staging = join(f.temp, `.paperclip-seed-home-${seedId}`, "tree");
|
||||
const outside = join(f.temp, "outside"); mkdirSync(outside);
|
||||
symlinkSync(outside, join(staging, "alias"));
|
||||
expect(f.call({ action: "seed-directory", seedId, path: "alias/escape" })).toEqual({ error: "invalid" });
|
||||
expect(readdirSync(outside)).toEqual([]);
|
||||
// A malicious link must not be included in the successful fixture publication.
|
||||
unlinkSync(join(staging, "alias"));
|
||||
expect(f.call({ action: "seed-commit", seedId })).toEqual({ seeded: true });
|
||||
expect(statSync(join(f.root, "empty/nested")).isDirectory()).toBe(true);
|
||||
expect(statSync(join(f.root, "empty/nested")).mode & 0o777).toBe(0o700);
|
||||
});
|
||||
it("publishes bounded seed chunks atomically and aborts partial homes", () => {
|
||||
const f = fixture(); const seedId = randomUUID();
|
||||
expect(f.call({ action: "seed-begin", seedId })).toEqual({ started: true });
|
||||
|
||||
@@ -124,7 +124,7 @@ def directory(parts,create=False,start=None):
|
||||
except BaseException:os.close(fd);raise
|
||||
# Initial uploads stay in a private sibling until a no-replace rename publishes them.
|
||||
# Each request carries at most one bounded chunk, never the whole home.
|
||||
seed_actions=('seed-begin','seed-chunk','seed-commit','seed-abort')
|
||||
seed_actions=('seed-begin','seed-directory','seed-chunk','seed-commit','seed-abort')
|
||||
# The durable receipt names one exact upload. Root listings (including home
|
||||
# admission) reclaim expired uploads even after a controller crash or publish.
|
||||
if act in seed_actions or (act=='list' and not p.get('path') and os.path.lexists(os.path.join(os.path.dirname(root),'.paperclip-seed-'+os.path.basename(root)+'.json'))):
|
||||
@@ -209,7 +209,14 @@ if act in seed_actions or (act=='list' and not p.get('path') and os.path.lexists
|
||||
if receipt is None or receipt['seedId']!=token:fail('conflict')
|
||||
stagefd=os.open(staging,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW,dir_fd=parentfd)
|
||||
if read_metadata(stagefd,'owner.json')!=identity(token):fail('invalid')
|
||||
if act=='seed-chunk':
|
||||
if act=='seed-directory':
|
||||
relative=p.get('path','');safe(relative)
|
||||
parts=[part for part in relative.split('/') if part not in ('','.')]
|
||||
if not parts:fail('invalid')
|
||||
fd=directory(['tree']+parts,create=True,start=stagefd);os.close(fd)
|
||||
receipt['updatedAt']=time.time();save_receipt(receipt)
|
||||
print('{}')
|
||||
elif act=='seed-chunk':
|
||||
relative=p.get('path','');safe(relative)
|
||||
parts=[part for part in relative.split('/') if part not in ('','.')]
|
||||
if not parts:fail('invalid')
|
||||
|
||||
Reference in new issue
Block a user