Skip to content

Commit 87b0153

Browse files
backup plugin: pod container → .tar.gz download (data portability)
Walks the named container over loopback with the caller's own bearer (real WAC decides what's readable; unreadables are skipped and counted in an in-band MANIFEST.json — headers are already committed once the stream starts, so a header can't carry the count). Hand-rolled POSIX ustar + node:zlib gzip, streamed, one resource buffered at a time. Headline findings: no api.events → incremental/scheduled backup is unbuildable, pull-on-demand only (5th consumer of the #2 seam); no authorized-read seam → O(N) loopback round-trips; no snapshot semantics → a busy pod yields an honest but mixed-state archive.
1 parent 067b647 commit 87b0153

3 files changed

Lines changed: 747 additions & 0 deletions

File tree

backup/README.md

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
# backup — download a pod container as a .tar.gz
2+
3+
Data portability in one request: `GET /backup/<container>/` walks the
4+
container recursively and streams everything the caller can read as a
5+
gzipped POSIX ustar archive. Read-only, no external fetches, no writes —
6+
built on the [#206 plugin loader](https://jss.live/docs/features/plugins)
7+
with the loopback container-walk that `rss/`, `sparql/`, `search/` and
8+
`webdav/` established.
9+
10+
```js
11+
plugins: [{
12+
module: 'backup/plugin.js',
13+
prefix: '/backup',
14+
config: {
15+
loopbackUrl: 'http://127.0.0.1:3000', // where the plugin reaches its own host (required)
16+
// maxResources: 10000, // loopback fetches per archive (walk cap)
17+
// maxDepth: 32, // container nesting cap
18+
},
19+
}]
20+
```
21+
22+
```sh
23+
# Everything under /alice/ you can read, as alice.tar.gz:
24+
curl -H "Authorization: Bearer $TOKEN" -OJ http://localhost:3000/backup/alice/
25+
26+
# A sub-container:
27+
curl -H "Authorization: Bearer $TOKEN" -OJ http://localhost:3000/backup/alice/photos/
28+
29+
# Restore is just tar:
30+
tar -xzf alice.tar.gz
31+
```
32+
33+
## Routes
34+
35+
| Route | Returns |
36+
|---|---|
37+
| `GET /backup/<pod-path>/` | `application/gzip` tar of the container tree, `Content-Disposition: attachment` |
38+
| `GET /backup/<pod-path>` | same — a missing trailing slash is treated as the container |
39+
| `GET /backup/`, `GET /backup` | `400` with usage — whole-server export is deliberately not offered |
40+
41+
An unreadable or missing **root** container is the caller's error status
42+
(`401`/`403`/`404`), decided *before* the `200` is committed. Everything
43+
below the root is best-effort: a member the caller cannot GET is **skipped,
44+
not fatal**.
45+
46+
## What's in the archive
47+
48+
- A **directory entry** (typeflag `5`) per container, a **file entry** per
49+
readable resource, byte-for-byte as the pod serves it, `mtime` from the
50+
listing's `dcterms:modified` when present.
51+
- **`MANIFEST.json` at the archive root** (written last, once the walk is
52+
complete): the requested container, generation time, `included` (path,
53+
entry name, size, content-type), `skipped` (path + reason
54+
`unauthorized` / `error` / `name-too-long` / `max-depth`, plus HTTP status
55+
where there was one), counts, and a `truncated` flag if `maxResources` /
56+
`maxDepth` cut the walk short.
57+
- Names longer than tar's 100-byte field ride the ustar **prefix field**
58+
(155 more bytes, split at a `/`); a name that still doesn't fit is skipped
59+
and counted. Percent-encoded segments are decoded for readability unless
60+
decoding would change the path shape (`.`, `..`, a `/` or NUL) — a hostile
61+
resource name cannot traverse out of the extraction directory.
62+
- `.acl` / `.meta` companions are **not** exported (see Findings).
63+
64+
## Auth
65+
66+
The walk happens over loopback HTTP **forwarding the caller's
67+
`Authorization` header verbatim**, so the host's real WAC decides every
68+
single GET. The plugin has no authority of its own:
69+
70+
- with a pod bearer you get everything that token can read;
71+
- **with no credentials the request still works** — you get an archive of
72+
exactly the publicly-readable subset, and every private resource shows up
73+
in `MANIFEST.json` as `{ "reason": "unauthorized" }`.
74+
75+
## What maps / what doesn't
76+
77+
| Solid | tar | note |
78+
|---|---|---|
79+
| container | directory entry | typeflag `5`, mode 755 |
80+
| resource bytes + `Content-Type` | file entry + manifest `contentType` | tar has no MIME field; the manifest carries it |
81+
| `dcterms:modified` | entry `mtime` | best-effort from the container listing |
82+
| WAC (`.acl`) || not exported; permissions don't round-trip |
83+
| resource metadata (`.meta`) || not exported |
84+
| a consistent snapshot || the walk is not atomic; see Findings |
85+
86+
## Findings
87+
88+
- **No `api.events` → pull-on-demand only.** There is no
89+
`onResourceChange` hook, so incremental backup ("archive what changed
90+
since last time") and scheduled/server-initiated backup are impossible to
91+
build well: the plugin can't observe writes, and it has no credentials of
92+
its own to walk with anyway. Every backup is a full read-time crawl,
93+
initiated and authorized by the caller. Same wall as `sparql/`, `rss/`,
94+
`search/`, `matrix/` — the #2 seam in NOTES.md, now with a
95+
backup-flavoured consumer.
96+
97+
- **No `api.authorize` / no direct read API → O(N) loopback round-trips.**
98+
Confirmed in the shape of the code: one HTTP GET per container listing
99+
plus one HTTP GET per resource, each carrying the requester's own
100+
credentials so WAC is answered correctly by construction. For a
101+
data-exfiltration-safe *export* feature this is definitionally right —
102+
but it means an N-resource pod costs N+containers loopback requests where
103+
a host-side seam (an authorized read API, or `api.authorize` + direct
104+
storage access) would make this a filesystem walk with one WAC check per
105+
entry. The dominant cost of a backup is HTTP overhead to yourself.
106+
107+
- **Memory profile: streamed archive, one buffered resource at a time —
108+
and the buffering is structural.** The gzip stream goes to the client as
109+
the walk runs (backpressure respected), so the whole archive is never in
110+
memory; but each resource *is* fully buffered before its tar entry is
111+
written, because a tar header must state the byte size **before** the
112+
data. Trusting a HEAD `Content-Length` instead would corrupt the archive
113+
if the resource changed between HEAD and GET — there is no ETag-pinned
114+
read to prevent that race. Fine for pod-sized resources; a multi-GB single
115+
resource would hurt. (ustar's 11-octal-digit size field caps an entry at
116+
8 GiB regardless.)
117+
118+
- **No snapshot semantics.** The walk is not atomic: a pod written during
119+
the walk yields a mixed-state archive (old bytes for already-archived
120+
resources, new for later ones), and nothing in the api offers a lock,
121+
snapshot, or even a conditional multi-read. A backup of a quiet pod is
122+
exact; a backup of a busy pod is honest but fuzzy. Worth knowing for
123+
anyone treating this as disaster recovery.
124+
125+
- **Skip counts can't go in a response header.** Streaming means the
126+
headers are committed before the walk starts, and fetch/Node expose no
127+
HTTP trailers usefully — so the bookkeeping *must* travel in-band. That's
128+
why `MANIFEST.json` is a tar entry (the last one) rather than an
129+
`X-Backup-Skipped` header.
130+
131+
- **Permissions don't survive export — and can't, cleanly.** `.acl`/`.meta`
132+
are filtered from the walk (the same member filter `rss/`/`s3/` use).
133+
Including them would only need the caller to hold `acl:Control` per
134+
resource (the loopback GET would enforce that for free), but a tarball of
135+
`.acl` files full of absolute URLs doesn't restore meaningfully onto
136+
another server anyway. Real portability of *policy*, not just bytes, is a
137+
spec-level gap, not a plugin-api gap.
138+
139+
- **`config.loopbackUrl` repetition — the `api.serverInfo` seam again.**
140+
The plugin must be told where its own host lives to make loopback calls;
141+
it fails loudly at `activate` when the config is missing. Same finding as
142+
~10 other plugins in this repo.

0 commit comments

Comments
 (0)