lds_pack/inspect.rs
1//! Read a pack's manifest without unpacking it.
2//!
3//! `pack.toml` is written as the archive's first entry, so answering "what is
4//! in this pack, and what did it leave behind?" costs one small read rather
5//! than a full decompression.
6
7use std::fs::File;
8use std::io::Read;
9use std::path::Path;
10
11use crate::create::PAYLOAD_PREFIX;
12use crate::error::PackError;
13use crate::manifest::{MANIFEST_NAME, Manifest, PACK_FORMAT_VERSION};
14
15/// How much manifest this build will read into memory.
16///
17/// The manifest is decompressed before it can be parsed, and an archive decides
18/// both how much it declares and how much it actually carries. Compressible
19/// filler expands enormously — a few megabytes of zeroes become gigabytes — so
20/// reading "the whole first entry" hands the archive control of how much memory
21/// the process allocates, and one small file can end it.
22///
23/// The manifest records what was left behind rather than every packed file:
24/// skipped secrets and caches, symlinks, worktrees. Sixty-four mebibytes is far
25/// past what a real project produces — a hundred thousand records is a few tens
26/// of megabytes — and still a bound.
27const MAX_MANIFEST_BYTES: u64 = 64 * 1024 * 1024;
28
29/// Read the manifest from a pack.
30///
31/// # Arguments
32///
33/// * `archive` — Path to a `.pack` file.
34///
35/// # Returns
36///
37/// The embedded [`Manifest`].
38///
39/// # Errors
40///
41/// - [`PackError::Io`] if the file cannot be read or is not a valid zstd/tar stream.
42/// - [`PackError::MissingManifest`] if the archive contains no `pack.toml`.
43/// - [`PackError::ManifestTooLarge`] if the manifest exceeds
44/// [`MAX_MANIFEST_BYTES`] — the archive is read before it is trusted, so how
45/// much of it reaches memory cannot be the archive's decision.
46/// - [`PackError::ManifestParse`] if the manifest is malformed.
47pub fn inspect(archive: &Path) -> Result<Manifest, PackError> {
48 let file = File::open(archive)?;
49 let decoder = zstd::stream::Decoder::new(file)?;
50 let mut tar = tar::Archive::new(decoder);
51
52 // Only the first entry is examined: the manifest is written first, so
53 // anything else there means this is not one of our archives and scanning
54 // the rest of the payload would tell us nothing.
55 if let Some(first) = tar.entries()?.next() {
56 let mut entry = first?;
57 let path = entry.path()?.to_path_buf();
58 if path.as_os_str() == MANIFEST_NAME {
59 let mut text = String::new();
60 // One byte past the limit, so an over-long manifest is detected by
61 // having read it rather than by believing the declared size.
62 let read = entry
63 .by_ref()
64 .take(MAX_MANIFEST_BYTES + 1)
65 .read_to_string(&mut text)? as u64;
66 if read > MAX_MANIFEST_BYTES {
67 return Err(PackError::ManifestTooLarge {
68 limit: MAX_MANIFEST_BYTES,
69 });
70 }
71 return Ok(Manifest::from_toml(&text)?);
72 }
73 }
74
75 Err(PackError::MissingManifest)
76}
77
78/// Verify that a pack can be read by this build.
79///
80/// # Errors
81///
82/// - [`PackError::UnsupportedFormat`] if the archive was written by a newer
83/// pack format than this build understands.
84/// - Any error from [`inspect`].
85pub fn verify(archive: &Path) -> Result<Manifest, PackError> {
86 let manifest = inspect(archive)?;
87 if manifest.format_version > PACK_FORMAT_VERSION {
88 return Err(PackError::UnsupportedFormat {
89 found: manifest.format_version,
90 supported: PACK_FORMAT_VERSION,
91 });
92 }
93 Ok(manifest)
94}
95
96/// List every payload path in a pack, relative to the project root.
97///
98/// Unlike [`inspect`] this walks the whole archive, so it costs a full
99/// decompression pass.
100///
101/// # Errors
102///
103/// - [`PackError::UnusableArchiveEntry`] if the archive carries an entry a
104/// restore would refuse; listing a pack that cannot be restored would
105/// describe an operation that is not going to happen.
106/// - [`PackError::Io`] if the archive cannot be read.
107pub fn list_payload_paths(archive: &Path) -> Result<Vec<String>, PackError> {
108 Ok(scan_payload(archive)?.paths)
109}
110
111/// What one decompression pass over the payload found.
112pub(crate) struct Payload {
113 /// Every payload path, relative to the project root.
114 pub(crate) paths: Vec<String>,
115 /// Hard links the archive carries, as `(path, target)` pairs. Restore does
116 /// not create these, so a prediction has to name them too.
117 pub(crate) hard_links: Vec<(String, String)>,
118}
119
120/// Walk the payload once, applying the same entry-type policy the restore uses.
121///
122/// The policy lives in [`crate::restore::entry_plan`] so that a prediction and
123/// the restore it predicts cannot drift apart: an entry type the restore would
124/// refuse fails here as well, and one it would decline to create is collected
125/// rather than counted as a file that is going to appear.
126///
127/// # Errors
128///
129/// - [`PackError::UnusableArchiveEntry`] for an entry the restore would refuse.
130/// - [`PackError::Io`] if the archive cannot be read.
131pub(crate) fn scan_payload(archive: &Path) -> Result<Payload, PackError> {
132 use crate::restore::EntryPlan;
133
134 let file = File::open(archive)?;
135 let decoder = zstd::stream::Decoder::new(file)?;
136 let mut tar = tar::Archive::new(decoder);
137
138 let prefix = format!("{PAYLOAD_PREFIX}/");
139 let mut payload = Payload {
140 paths: Vec::new(),
141 hard_links: Vec::new(),
142 };
143 for entry in tar.entries()? {
144 let entry = entry?;
145 let path = entry.path()?;
146 let s = path.to_string_lossy();
147 let Some(rest) = s.strip_prefix(&prefix) else {
148 continue;
149 };
150 if rest.is_empty() {
151 continue;
152 }
153 let rel = rest.trim_end_matches('/').to_string();
154
155 match crate::restore::entry_plan(entry.header().entry_type()) {
156 EntryPlan::Extract => payload.paths.push(rel),
157 EntryPlan::HardLink => {
158 let target = entry
159 .link_name()?
160 .map(|t| t.display().to_string())
161 .unwrap_or_default();
162 payload.hard_links.push((rel, target));
163 }
164 EntryPlan::Refuse(kind) => {
165 return Err(PackError::UnusableArchiveEntry {
166 path: rel,
167 kind: kind.to_string(),
168 });
169 }
170 }
171 }
172 Ok(payload)
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178 use crate::create::{CreateOptions, create};
179 use std::fs;
180 use tempfile::TempDir;
181
182 /// Inspecting something that is not a pack fails cleanly rather than panicking.
183 #[test]
184 fn test_inspect_rejects_non_archive() {
185 let dir = TempDir::new().expect("tempdir");
186 let bogus = dir.path().join("not.pack");
187 fs::write(&bogus, b"definitely not zstd").expect("write");
188 assert!(inspect(&bogus).is_err());
189 }
190
191 /// A pack whose first entry is not the manifest is rejected.
192 #[test]
193 fn test_inspect_requires_manifest_first() {
194 let dir = TempDir::new().expect("tempdir");
195 let out = dir.path().join("hand.pack");
196
197 let file = fs::File::create(&out).expect("create");
198 let encoder = zstd::stream::Encoder::new(file, 1).expect("encoder");
199 let mut builder = tar::Builder::new(encoder);
200 let body = b"x";
201 let mut header = tar::Header::new_gnu();
202 header.set_size(body.len() as u64);
203 header.set_mode(0o644);
204 header.set_cksum();
205 builder
206 .append_data(&mut header, "payload/a.txt", &body[..])
207 .expect("append");
208 builder
209 .into_inner()
210 .expect("into_inner")
211 .finish()
212 .expect("finish");
213
214 assert!(matches!(inspect(&out), Err(PackError::MissingManifest)));
215 }
216
217 /// `verify` accepts a pack this build wrote.
218 #[test]
219 fn test_verify_accepts_current_format() {
220 let dir = TempDir::new().expect("tempdir");
221 let root = dir.path().join("proj");
222 fs::create_dir_all(root.join("src")).expect("mkdir");
223 fs::write(root.join("src/main.rs"), "fn main() {}").expect("write");
224
225 let out = dir.path().join("proj.pack");
226 create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
227
228 let manifest = verify(&out).expect("verify");
229 assert_eq!(manifest.format_version, PACK_FORMAT_VERSION);
230 }
231}