lds_pack/manifest.rs
1//! `pack.toml` — the manifest carried at the head of every `.pack` archive.
2//!
3//! The manifest is what makes a pack *inspectable without unpacking*: it is
4//! written as the first entry of the tar stream, so [`crate::inspect`] can stop
5//! reading as soon as it has been decoded.
6//!
7//! It records three classes of information:
8//!
9//! 1. **provenance** — where the pack came from and when.
10//! 2. **what was deliberately left out** — cache directories (regenerable) and
11//! secrets (never packed; carrying those is the operator's own business).
12//! 3. **what needs attention on restore** — symlinks that point outside the
13//! project, and worktrees whose absolute `gitdir` pointers were rewritten.
14
15use serde::{Deserialize, Serialize};
16
17/// File name of the manifest inside the archive.
18pub const MANIFEST_NAME: &str = "pack.toml";
19
20/// Current pack format version.
21///
22/// Bumped when the on-disk layout changes in a way older readers cannot
23/// interpret. [`crate::restore`] refuses archives newer than this.
24///
25/// Version 2 dropped the `[claude]` section, which described one hard-coded
26/// directory. Readers at version 1 declared `claude` as a required field, so
27/// they cannot decode a version 2 manifest; the bump makes them say so instead
28/// of failing on a missing key. Version 1 archives are still readable here —
29/// their `[claude]` section is ignored, since nothing on it has a counterpart
30/// in this shape.
31pub const PACK_FORMAT_VERSION: u32 = 2;
32
33/// Top-level manifest written to `pack.toml`.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct Manifest {
36 /// Pack format version (see [`PACK_FORMAT_VERSION`]).
37 pub format_version: u32,
38 /// RFC 3339 timestamp of when the pack was created.
39 pub created_at: String,
40 /// Absolute path of the project root the pack was created from.
41 ///
42 /// Restoring into a different path is expected and supported; this field
43 /// exists so worktree pointer rewriting can compute the delta, and so a
44 /// human reading the manifest can tell where the pack came from.
45 pub source_root: String,
46 /// Directory name of the source root (used as the default restore name).
47 pub project_name: String,
48 /// Version of the `lds` binary that produced the pack.
49 pub lds_version: String,
50 /// Aggregate counts for the payload.
51 pub stats: Stats,
52 /// `no_link_report` globs that actually suppressed at least one link here.
53 ///
54 /// Without this a reader cannot tell a project with no symlinks from one
55 /// whose links were deliberately left out of the report, and would act on
56 /// the wrong assumption. A rule that matched nothing is absent, so this
57 /// names the processing that happened rather than the configuration that
58 /// existed.
59 #[serde(default)]
60 pub no_link_report_applied: Vec<String>,
61 /// Files a `keep` rule carried past a secret rule.
62 #[serde(default)]
63 pub kept_over_secret: Vec<KeptOverSecret>,
64 /// Cache directories that were skipped because they can be regenerated,
65 /// each with the size of what it dropped.
66 #[serde(default)]
67 pub skipped_cache: Vec<CacheRecord>,
68 /// Secret-looking files that were skipped and reported instead of packed.
69 #[serde(default)]
70 pub skipped_secret: Vec<SkipRecord>,
71 /// OS debris (`.DS_Store`, `Thumbs.db`) that was dropped.
72 ///
73 /// Nothing here needs acting on, which is why it used to be dropped in
74 /// silence. It is listed because "dropped without a record" is not a
75 /// property worth having anywhere in this format: a reader comparing the
76 /// source tree against the payload should never find a file that the
77 /// manifest cannot account for.
78 #[serde(default)]
79 pub skipped_noise: Vec<SkipRecord>,
80 /// Symlinks recorded verbatim, one entry each — the complete list, so it
81 /// can be redirected to a file and processed.
82 ///
83 /// Excludes only links covered by a `no_link_report` glob, and every such
84 /// glob is named in [`Self::no_link_report_applied`].
85 #[serde(default)]
86 pub symlinks: Vec<SymlinkRecord>,
87 /// Registered git worktrees whose pointer files need rewriting on restore.
88 #[serde(default)]
89 pub worktrees: Vec<WorktreeRecord>,
90 /// Set when the packed root is *itself* a worktree of a repository that
91 /// lives elsewhere.
92 ///
93 /// Such a root has no `.git/worktrees/` of its own — its `.git` is a file
94 /// naming the parent's admin directory — so without this the pack carries
95 /// no trace of the other half of the wiring. Absent in packs written before
96 /// this field existed, which read back as `None`.
97 #[serde(default)]
98 pub worktree_of: Option<WorktreeOrigin>,
99}
100
101/// Aggregate payload counts, filled in as the archive is written.
102#[derive(Debug, Clone, Default, Serialize, Deserialize)]
103pub struct Stats {
104 /// Number of regular files packed.
105 pub file_count: u64,
106 /// Number of symlinks packed (stored as links, never dereferenced).
107 pub symlink_count: u64,
108 /// Sum of regular-file sizes in bytes, before compression.
109 pub total_bytes: u64,
110}
111
112/// A file that matched a secret rule but was packed anyway, because a `keep`
113/// rule outranked it.
114///
115/// `keep` is the only subtractive list, and the only way a file the secret
116/// rules named can end up inside the archive. That is a legitimate thing to
117/// ask for — `.env.example` matches `.env.*` and holds placeholders — but it
118/// is also how a real credential gets carried by accident, when a glob written
119/// for one file turns out to match another. Recording each one keeps the
120/// override reviewable instead of silent.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct KeptOverSecret {
123 /// Path relative to the project root.
124 pub path: String,
125 /// The `keep` glob that rescued it.
126 pub keep_pattern: String,
127 /// The secret glob that would otherwise have excluded it.
128 pub secret_pattern: String,
129}
130
131/// A path that was left out of the pack, with the reason why.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct SkipRecord {
134 /// Path relative to the project root.
135 pub path: String,
136 /// Why it was skipped (matched pattern or rule name).
137 pub reason: String,
138}
139
140/// A cache directory that was dropped, and the size of what went with it.
141///
142/// One record stands in for a whole subtree, which is the right granularity
143/// for something regenerable — nobody acts on the individual files inside
144/// `target/`. The size is what makes the record checkable: a `cache_dirs` entry
145/// aimed at the wrong directory drops hand-written source, and `dist → 412
146/// files, 2.1 MB` reads nothing like `target → 38104 files, 4.2 GB`. Without
147/// it, a misconfiguration is one indistinguishable line.
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct CacheRecord {
150 /// Path relative to the project root.
151 pub path: String,
152 /// Which rule named it a cache.
153 pub reason: String,
154 /// Regular files below it that were not packed.
155 pub file_count: u64,
156 /// Sum of those files' sizes in bytes.
157 pub total_bytes: u64,
158 /// Credential-looking files found inside it, attributed to the rule that
159 /// swallowed them.
160 ///
161 /// A cache is pruned before the classification pass, so these were
162 /// previously neither packed nor reported: safe, but the operator never
163 /// learned that `node_modules/.npmrc` was holding a token. They are still
164 /// dropped with the rest of the cache — this only says they were there.
165 #[serde(default)]
166 pub secrets: Vec<SkipRecord>,
167}
168
169/// A symlink recorded individually.
170///
171/// Symlinks are packed as links, never dereferenced — following them would
172/// drag unrelated trees (and whatever they contain) into the archive. The
173/// record exists so restore can report what is dangling rather than silently
174/// producing a broken tree.
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct SymlinkRecord {
177 /// Path of the link itself, relative to the project root.
178 pub path: String,
179 /// Raw link target, exactly as stored on disk.
180 pub target: String,
181 /// Whether the target resolves outside the project root.
182 ///
183 /// Links that stay inside the root travel fine; only the ones pointing out
184 /// of it can dangle after a restore somewhere else.
185 pub outside_root: bool,
186}
187
188/// A registered git worktree carried in the pack.
189///
190/// Both halves of a worktree's wiring are absolute paths — `.git/worktrees/<name>/gitdir`
191/// points at the worktree's `.git` file, and that `.git` file points back at the
192/// admin directory. A plain extract leaves both aimed at the machine the pack
193/// came from, so restore rewrites them.
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct WorktreeRecord {
196 /// Worktree name (the directory name under `.git/worktrees/`).
197 pub name: String,
198 /// Worktree location relative to the project root.
199 ///
200 /// `None` when the worktree lives outside the root, in which case its
201 /// contents are not in the pack and restore only reports it.
202 pub path: Option<String>,
203 /// Original absolute path of the worktree, as recorded at pack time.
204 pub source_path: String,
205 /// Whether the worktree's contents were included in the payload.
206 pub included: bool,
207}
208
209/// The repository a packed worktree checkout belongs to.
210///
211/// This is the mirror image of [`WorktreeRecord`]: that one is written by the
212/// repository about its worktrees, this one by a worktree about its repository.
213/// Both halves of the wiring are absolute, so both packs need to know about the
214/// other end to be restorable somewhere new.
215#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct WorktreeOrigin {
217 /// This worktree's name under the parent's `.git/worktrees/`.
218 pub name: String,
219 /// Original absolute path of the parent's admin directory
220 /// (`<parent_root>/.git/worktrees/<name>`), as recorded at pack time.
221 pub admin_path: String,
222 /// Original absolute path of the parent repository root.
223 pub parent_root: String,
224}
225
226impl Manifest {
227 /// Serialize to TOML for embedding as the archive's first entry.
228 ///
229 /// # Errors
230 ///
231 /// Returns [`toml::ser::Error`] if serialization fails, which for this
232 /// schema means a bug rather than bad input.
233 pub fn to_toml(&self) -> Result<String, toml::ser::Error> {
234 toml::to_string_pretty(self)
235 }
236
237 /// Parse a manifest from TOML text.
238 ///
239 /// # Errors
240 ///
241 /// Returns [`toml::de::Error`] if the text is not a valid manifest.
242 pub fn from_toml(text: &str) -> Result<Self, toml::de::Error> {
243 toml::from_str(text)
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 fn sample() -> Manifest {
252 Manifest {
253 format_version: PACK_FORMAT_VERSION,
254 created_at: "2026-08-10T00:00:00Z".to_string(),
255 source_root: "/tmp/proj".to_string(),
256 project_name: "proj".to_string(),
257 lds_version: "0.13.3".to_string(),
258 stats: Stats {
259 file_count: 3,
260 symlink_count: 1,
261 total_bytes: 42,
262 },
263 no_link_report_applied: vec![".zsh/**".to_string()],
264 kept_over_secret: vec![KeptOverSecret {
265 path: ".env.example".to_string(),
266 keep_pattern: ".env.example".to_string(),
267 secret_pattern: ".env.*".to_string(),
268 }],
269 skipped_cache: vec![CacheRecord {
270 path: "target".to_string(),
271 reason: "cache directory: target".to_string(),
272 file_count: 38104,
273 total_bytes: 4_200_000_000,
274 secrets: vec![SkipRecord {
275 path: "target/tmp/.npmrc".to_string(),
276 reason: "secret pattern: .npmrc".to_string(),
277 }],
278 }],
279 skipped_secret: vec![SkipRecord {
280 path: ".env".to_string(),
281 reason: "secret pattern: .env".to_string(),
282 }],
283 skipped_noise: vec![SkipRecord {
284 path: "sub/.DS_Store".to_string(),
285 reason: "os debris: .DS_Store".to_string(),
286 }],
287 symlinks: vec![SymlinkRecord {
288 path: "link".to_string(),
289 target: "/elsewhere".to_string(),
290 outside_root: true,
291 }],
292 worktrees: vec![WorktreeRecord {
293 name: "wt".to_string(),
294 path: Some(".worktrees/wt".to_string()),
295 source_path: "/tmp/proj/.worktrees/wt".to_string(),
296 included: true,
297 }],
298 worktree_of: None,
299 }
300 }
301
302 /// A manifest survives a serialize / parse round trip with every field intact.
303 #[test]
304 fn test_manifest_round_trip() {
305 let original = sample();
306 let text = original.to_toml().expect("serialize should succeed");
307 let parsed = Manifest::from_toml(&text).expect("parse should succeed");
308
309 assert_eq!(parsed.format_version, original.format_version);
310 assert_eq!(parsed.source_root, original.source_root);
311 assert_eq!(parsed.stats.file_count, 3);
312 assert_eq!(parsed.no_link_report_applied, vec![".zsh/**".to_string()]);
313 assert_eq!(parsed.kept_over_secret[0].path, ".env.example");
314 assert_eq!(parsed.kept_over_secret[0].secret_pattern, ".env.*");
315 assert_eq!(parsed.skipped_cache[0].file_count, 38104);
316 assert_eq!(parsed.skipped_cache[0].total_bytes, 4_200_000_000);
317 assert_eq!(parsed.skipped_cache[0].secrets[0].path, "target/tmp/.npmrc");
318 assert_eq!(parsed.skipped_noise[0].path, "sub/.DS_Store");
319 assert_eq!(parsed.skipped_secret.len(), 1);
320 assert_eq!(parsed.symlinks[0].target, "/elsewhere");
321 assert_eq!(parsed.worktrees[0].name, "wt");
322 }
323
324 /// Optional list sections may be absent entirely; they default to empty.
325 #[test]
326 fn test_manifest_parses_without_optional_sections() {
327 let text = r#"
328format_version = 1
329created_at = "2026-08-10T00:00:00Z"
330source_root = "/tmp/proj"
331project_name = "proj"
332lds_version = "0.13.3"
333
334[stats]
335file_count = 0
336symlink_count = 0
337total_bytes = 0
338"#;
339 let parsed = Manifest::from_toml(text).expect("minimal manifest should parse");
340 assert!(parsed.skipped_cache.is_empty());
341 assert!(parsed.skipped_noise.is_empty());
342 assert!(parsed.skipped_secret.is_empty());
343 assert!(parsed.symlinks.is_empty());
344 assert!(parsed.worktrees.is_empty());
345 assert!(
346 parsed.no_link_report_applied.is_empty(),
347 "an operator who suppressed nothing gets no suppression record"
348 );
349 assert!(parsed.kept_over_secret.is_empty());
350 }
351
352 /// A version 1 manifest still decodes here, `[claude]` section and all.
353 ///
354 /// That section described one hard-coded directory and has no successor, so
355 /// it is ignored rather than translated — but it must not make the archive
356 /// unreadable, since packs written by 0.14.0 are still out there.
357 #[test]
358 fn test_manifest_reads_legacy_v1_claude_section() {
359 let text = r#"
360format_version = 1
361created_at = "2026-08-10T00:00:00Z"
362source_root = "/tmp/proj"
363project_name = "proj"
364lds_version = "0.14.0"
365
366[stats]
367file_count = 0
368symlink_count = 0
369total_bytes = 0
370
371[claude]
372present = true
373symlink_count = 154
374link_roots = ["/mnt/links/shared"]
375"#;
376 let parsed = Manifest::from_toml(text).expect("a v1 manifest must still parse");
377
378 assert_eq!(parsed.format_version, 1);
379 assert!(
380 parsed.no_link_report_applied.is_empty(),
381 "the legacy section is dropped, not translated into a suppression"
382 );
383 }
384
385 /// A worktree living outside the root carries no relative path.
386 #[test]
387 fn test_worktree_record_allows_absent_path() {
388 let mut m = sample();
389 m.worktrees[0].path = None;
390 m.worktrees[0].included = false;
391 let text = m.to_toml().expect("serialize should succeed");
392 let parsed = Manifest::from_toml(&text).expect("parse should succeed");
393 assert!(parsed.worktrees[0].path.is_none());
394 assert!(!parsed.worktrees[0].included);
395 }
396}