Skip to main content

max / makenotwork

9.9 KB · 293 lines History Blame Raw
1 //! What bytes exist: the per-file manifest and the digest that names it.
2
3 use crate::ContractError;
4 use sha2::{Digest, Sha256};
5
6 /// One file in a bundle: its sha256 and its path relative to the bundle root.
7 #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
8 pub struct ManifestEntry {
9 pub sha256: String,
10 pub path: String,
11 }
12
13 /// Every file in a bundle, sorted by path.
14 ///
15 /// Sorted, and constructible only through checks: a manifest is what the digest
16 /// is computed over, so two producers listing the same files must produce the
17 /// same text or the digest stops being an identity. Sorting is the ordering
18 /// half of that. Rejecting duplicate paths is the other half, since a repeated
19 /// path with two different hashes is a manifest that describes two bundles.
20 #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
21 #[serde(transparent)]
22 pub struct Manifest {
23 entries: Vec<ManifestEntry>,
24 }
25
26 impl Manifest {
27 /// Build a manifest from `(path, sha256)` pairs in any order.
28 pub fn new<I, P, S>(files: I) -> Result<Self, ContractError>
29 where
30 I: IntoIterator<Item = (P, S)>,
31 P: Into<String>,
32 S: Into<String>,
33 {
34 let mut entries: Vec<ManifestEntry> = files
35 .into_iter()
36 .map(|(path, sha256)| ManifestEntry {
37 path: path.into(),
38 sha256: sha256.into(),
39 })
40 .collect();
41 for e in &entries {
42 check_sha256(&e.sha256)?;
43 check_path(&e.path)?;
44 }
45 entries.sort_by(|a, b| a.path.cmp(&b.path));
46 if let Some(dup) = entries.windows(2).find(|w| w[0].path == w[1].path) {
47 return Err(ContractError::DuplicatePath(dup[0].path.clone()));
48 }
49 if entries.is_empty() {
50 return Err(ContractError::EmptyManifest);
51 }
52 Ok(Self { entries })
53 }
54
55 pub fn entries(&self) -> &[ManifestEntry] {
56 &self.entries
57 }
58
59 /// The `MANIFEST` file's contents: `<sha256> <path>`, one per line,
60 /// trailing newline. Deliberately the shape `sha256sum -c` reads, so the
61 /// file is checkable on a node with no tooling of ours installed.
62 pub fn to_text(&self) -> String {
63 let mut s = String::new();
64 for e in &self.entries {
65 s.push_str(&e.sha256);
66 s.push_str(" ");
67 s.push_str(&e.path);
68 s.push('\n');
69 }
70 s
71 }
72
73 /// Parse a `MANIFEST` back. Round-trips [`Manifest::to_text`].
74 pub fn parse(text: &str) -> Result<Self, ContractError> {
75 let mut files = Vec::new();
76 for (n, line) in text.lines().enumerate() {
77 if line.trim().is_empty() {
78 continue;
79 }
80 // Split on the two-space separator rather than on whitespace: a path
81 // may contain single spaces, and splitting greedily would silently
82 // truncate one. `sha256sum` has the same convention.
83 let (sha, path) = line
84 .split_once(" ")
85 .ok_or(ContractError::MalformedManifestLine(n + 1))?;
86 files.push((path.to_string(), sha.to_string()));
87 }
88 Self::new(files)
89 }
90
91 /// The bundle digest: sha256 of the manifest text.
92 ///
93 /// Hashing the manifest rather than the primary binary is the point. Two
94 /// bundles whose binaries are identical and whose assets differ hash the
95 /// same if you hash only the binary, so an asset-only change ships
96 /// unnoticed.
97 pub fn digest(&self) -> BundleDigest {
98 let mut hasher = Sha256::new();
99 hasher.update(self.to_text().as_bytes());
100 BundleDigest(hex_lower(&hasher.finalize()))
101 }
102 }
103
104 /// The identity of a set of bytes: 64 lowercase hex, the sha256 of a manifest.
105 ///
106 /// Not a substitute for the git sha, and neither is a substitute for it. The
107 /// sha says what source; the digest says what bytes. Cargo builds are not
108 /// bit-reproducible by default, so one commit built twice gives two digests:
109 /// a digest match across hosts proves more than a sha match, and a digest
110 /// cannot answer "have we already built this commit".
111 #[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
112 #[serde(transparent)]
113 pub struct BundleDigest(String);
114
115 impl BundleDigest {
116 pub fn parse(s: &str) -> Result<Self, ContractError> {
117 check_sha256(s)?;
118 Ok(Self(s.to_string()))
119 }
120
121 pub fn as_str(&self) -> &str {
122 &self.0
123 }
124
125 /// The first 16 hex characters, which is what a content-addressed directory
126 /// is named. The full 64 is what gets stored and compared.
127 pub fn short(&self) -> &str {
128 &self.0[..16]
129 }
130 }
131
132 impl std::fmt::Display for BundleDigest {
133 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134 self.0.fmt(f)
135 }
136 }
137
138 fn check_sha256(s: &str) -> Result<(), ContractError> {
139 if s.len() == 64
140 && s.bytes()
141 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
142 {
143 Ok(())
144 } else {
145 Err(ContractError::NotASha256(s.to_string()))
146 }
147 }
148
149 /// A manifest path is relative, forward-slashed, and does not walk upward.
150 ///
151 /// The node verifies a bundle by joining these onto a release directory, so a
152 /// path that escapes it would have the verifier hash a file the bundle does not
153 /// contain, and a deploy write one there.
154 fn check_path(p: &str) -> Result<(), ContractError> {
155 let bad = p.is_empty()
156 || p.starts_with('/')
157 || p.contains('\\')
158 || p.split('/').any(|c| c == ".." || c == ".")
159 || p.contains('\n');
160 if bad {
161 return Err(ContractError::UnsafePath(p.to_string()));
162 }
163 Ok(())
164 }
165
166 fn hex_lower(bytes: &[u8]) -> String {
167 use std::fmt::Write as _;
168 let mut s = String::with_capacity(bytes.len() * 2);
169 for b in bytes {
170 let _ = write!(s, "{b:02x}");
171 }
172 s
173 }
174
175 #[cfg(test)]
176 mod tests {
177 use super::*;
178
179 fn sha(byte: u8) -> String {
180 std::iter::repeat_n(format!("{byte:02x}"), 32).collect()
181 }
182
183 #[test]
184 fn entries_sort_by_path_so_input_order_cannot_change_the_digest() {
185 let a = Manifest::new([
186 ("static/app.css", sha(1)),
187 ("makenotwork", sha(2)),
188 ("companions/mnw-cli", sha(3)),
189 ])
190 .unwrap();
191 let b = Manifest::new([
192 ("companions/mnw-cli", sha(3)),
193 ("static/app.css", sha(1)),
194 ("makenotwork", sha(2)),
195 ])
196 .unwrap();
197 assert_eq!(a.to_text(), b.to_text());
198 assert_eq!(a.digest(), b.digest());
199 let paths: Vec<&str> = a.entries().iter().map(|e| e.path.as_str()).collect();
200 assert_eq!(
201 paths,
202 ["companions/mnw-cli", "makenotwork", "static/app.css"]
203 );
204 }
205
206 #[test]
207 fn text_round_trips() {
208 let m = Manifest::new([("docs/index.html", sha(9)), ("makenotwork", sha(10))]).unwrap();
209 let back = Manifest::parse(&m.to_text()).unwrap();
210 assert_eq!(m, back);
211 assert_eq!(m.digest(), back.digest());
212 }
213
214 #[test]
215 fn a_path_containing_a_space_survives_the_round_trip() {
216 // Splitting on whitespace rather than on the two-space separator would
217 // truncate this to "Release" and quietly describe a different file.
218 let m = Manifest::new([("docs/Release Notes.html", sha(4))]).unwrap();
219 let back = Manifest::parse(&m.to_text()).unwrap();
220 assert_eq!(back.entries()[0].path, "docs/Release Notes.html");
221 }
222
223 #[test]
224 fn the_digest_is_the_sha256_of_the_manifest_text() {
225 let m = Manifest::new([("a", sha(0))]).unwrap();
226 let mut hasher = Sha256::new();
227 hasher.update(m.to_text().as_bytes());
228 assert_eq!(m.digest().as_str(), hex_lower(&hasher.finalize()));
229 assert_eq!(m.digest().short().len(), 16);
230 }
231
232 #[test]
233 fn a_changed_asset_changes_the_digest_even_with_the_binary_untouched() {
234 // Hashing only the binary would call these two bundles the same thing.
235 let before = Manifest::new([("makenotwork", sha(7)), ("static/app.css", sha(1))]).unwrap();
236 let after = Manifest::new([("makenotwork", sha(7)), ("static/app.css", sha(2))]).unwrap();
237 assert_ne!(before.digest(), after.digest());
238 }
239
240 #[test]
241 fn a_duplicate_path_is_refused() {
242 let err = Manifest::new([("makenotwork", sha(1)), ("makenotwork", sha(2))]).unwrap_err();
243 assert!(matches!(err, ContractError::DuplicatePath(p) if p == "makenotwork"));
244 }
245
246 #[test]
247 fn an_empty_manifest_is_refused() {
248 // A bundle with no files has a perfectly good digest (the hash of the
249 // empty string), which is the problem: it is a stable identity for
250 // nothing at all, and every empty bundle shares it.
251 let files: Vec<(String, String)> = Vec::new();
252 assert!(matches!(
253 Manifest::new(files).unwrap_err(),
254 ContractError::EmptyManifest
255 ));
256 }
257
258 #[test]
259 fn paths_that_escape_the_bundle_are_refused() {
260 for p in ["/etc/passwd", "../secrets", "a/../../b", "c:\\windows"] {
261 assert!(
262 matches!(
263 Manifest::new([(p, sha(1))]).unwrap_err(),
264 ContractError::UnsafePath(_)
265 ),
266 "{p} should be refused"
267 );
268 }
269 }
270
271 #[test]
272 fn a_hash_that_is_not_a_sha256_is_refused() {
273 for h in ["", "abc", &"g".repeat(64), &"AB".repeat(32)] {
274 assert!(
275 matches!(
276 Manifest::new([("a", h)]).unwrap_err(),
277 ContractError::NotASha256(_)
278 ),
279 "{h} should be refused"
280 );
281 }
282 }
283
284 #[test]
285 fn a_malformed_line_names_its_line_number() {
286 let text = format!("{} ok\nnot-a-manifest-line\n", sha(1));
287 assert!(matches!(
288 Manifest::parse(&text).unwrap_err(),
289 ContractError::MalformedManifestLine(2)
290 ));
291 }
292 }
293