bynk_registry_core/
archive.rs1use std::collections::{BTreeMap, BTreeSet};
16
17use sha2::{Digest, Sha256};
18
19pub const BAD_ARCHIVE: &str = "bynk.registry.bad_archive";
20
21const MAGIC: &[u8] = b"bynk-tree-v1\n";
22
23pub const MAX_UNPACKED: u64 = 500 * 1024 * 1024;
25const MAX_FILES: usize = 100_000;
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct ArchiveError {
31 pub message: String,
32}
33
34impl std::fmt::Display for ArchiveError {
35 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36 f.write_str(&self.message)
37 }
38}
39
40#[derive(Debug, Clone, Default, PartialEq, Eq)]
43pub struct Tree {
44 files: BTreeMap<String, Vec<u8>>,
45}
46
47impl Tree {
48 pub fn new() -> Self {
49 Self::default()
50 }
51
52 pub fn insert(&mut self, path: impl Into<String>, bytes: Vec<u8>) {
53 self.files.insert(path.into(), bytes);
54 }
55
56 pub fn get(&self, path: &str) -> Option<&[u8]> {
57 self.files.get(path).map(Vec::as_slice)
58 }
59
60 pub fn iter(&self) -> impl Iterator<Item = (&str, &[u8])> {
62 self.files.iter().map(|(p, b)| (p.as_str(), b.as_slice()))
63 }
64
65 pub fn len(&self) -> usize {
66 self.files.len()
67 }
68
69 pub fn is_empty(&self) -> bool {
70 self.files.is_empty()
71 }
72}
73
74impl FromIterator<(String, Vec<u8>)> for Tree {
75 fn from_iter<I: IntoIterator<Item = (String, Vec<u8>)>>(iter: I) -> Self {
76 Tree {
77 files: iter.into_iter().collect(),
78 }
79 }
80}
81
82pub fn is_excluded(rel: &str) -> bool {
86 let first = rel.split('/').next().unwrap_or_default();
87 first == "out" || first == "node_modules" || rel.split('/').any(|p| p.starts_with('.'))
88}
89
90pub fn pack(tree: &Tree) -> (Vec<u8>, String) {
93 let mut out = MAGIC.to_vec();
94 let mut hasher = Sha256::new();
95 for (rel, bytes) in tree.iter().filter(|(rel, _)| !is_excluded(rel)) {
96 for chunk in [
97 rel.as_bytes(),
98 b"\0",
99 bytes.len().to_string().as_bytes(),
100 b"\0",
101 bytes,
102 ] {
103 out.extend_from_slice(chunk);
104 hasher.update(chunk);
105 }
106 }
107 let digest: [u8; 32] = hasher.finalize().into();
108 (out, bynk_project::lock::checksum_of(&digest))
109}
110
111pub fn unpack(archive: &[u8]) -> Result<Tree, ArchiveError> {
113 let bad = |why: &str| ArchiveError {
114 message: format!("a registry archive is malformed: {why}"),
115 };
116 let mut rest = archive
117 .strip_prefix(MAGIC)
118 .ok_or_else(|| bad("it isn't a `bynk-tree-v1` archive"))?;
119 let mut seen: BTreeSet<String> = BTreeSet::new();
120 let mut tree = Tree::new();
121 let mut total: u64 = 0;
122 let mut previous: Option<String> = None;
123 while !rest.is_empty() {
124 let nul = rest
125 .iter()
126 .position(|&b| b == 0)
127 .ok_or_else(|| bad("a path isn't terminated"))?;
128 let rel = std::str::from_utf8(&rest[..nul]).map_err(|_| bad("a path isn't UTF-8"))?;
129 rest = &rest[nul + 1..];
130 let nul = rest
131 .iter()
132 .position(|&b| b == 0)
133 .ok_or_else(|| bad("a length isn't terminated"))?;
134 let len: u64 = std::str::from_utf8(&rest[..nul])
135 .ok()
136 .filter(|s| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()))
137 .and_then(|s| s.parse().ok())
138 .ok_or_else(|| bad("a length isn't a number"))?;
139 rest = &rest[nul + 1..];
140 if len > rest.len() as u64 {
141 return Err(bad("a file is shorter than its length"));
142 }
143 let (bytes, tail) = rest.split_at(len as usize);
144 rest = tail;
145 check_path(rel).map_err(|why| bad(&format!("`{rel}` {why}")))?;
146 if previous.as_deref().is_some_and(|p| p >= rel) || !seen.insert(rel.to_owned()) {
147 return Err(bad(&format!("`{rel}` is out of order or repeated")));
148 }
149 previous = Some(rel.to_owned());
150 total += len;
151 if total > MAX_UNPACKED || seen.len() > MAX_FILES {
152 return Err(bad("it unpacks past the size or file-count limit"));
153 }
154 tree.insert(rel, bytes.to_vec());
155 }
156 Ok(tree)
157}
158
159fn check_path(rel: &str) -> Result<(), &'static str> {
162 if rel.is_empty() {
163 return Err("is empty");
164 }
165 if rel.contains('\\') || rel.contains(':') {
166 return Err("isn't a plain `/`-separated path");
167 }
168 if rel.starts_with('/') {
169 return Err("is absolute");
170 }
171 if rel
172 .split('/')
173 .any(|p| p.is_empty() || p == "." || p == "..")
174 {
175 return Err("climbs out of the package (`.`, `..` or a root)");
176 }
177 Ok(())
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 #[test]
185 fn a_tree_round_trips_and_leaves_out_what_a_package_never_ships() {
186 let mut tree = Tree::new();
187 tree.insert("bynk.toml", b"[package]\n".to_vec());
188 tree.insert("src/x.bynk", b"commons x\n".to_vec());
189 tree.insert("out/x.ts", b"built".to_vec());
190 tree.insert(".env", b"SECRET=1".to_vec());
191 tree.insert("node_modules/a/b.js", b"x".to_vec());
192 let (archive, checksum) = pack(&tree);
193 let back = unpack(&archive).unwrap();
194 let paths: Vec<_> = back.iter().map(|(p, _)| p).collect();
195 assert_eq!(paths, ["bynk.toml", "src/x.bynk"]);
196 assert_eq!(pack(&back), (archive, checksum));
197 }
198
199 #[test]
200 fn hostile_archives_are_refused() {
201 let cases: &[&[u8]] = &[
202 b"tar\0",
203 b"bynk-tree-v1\n/etc/passwd\x001\0x",
204 b"bynk-tree-v1\n../x\x001\0x",
205 b"bynk-tree-v1\na/../../x\x001\0x",
206 b"bynk-tree-v1\nC:\\x\x001\0x",
207 b"bynk-tree-v1\nb\x001\0xa\x001\0x",
208 b"bynk-tree-v1\na\x0099\0x",
209 b"bynk-tree-v1\na\x00-1\0x",
210 b"bynk-tree-v1\n\x001\0x",
211 b"bynk-tree-v1\na//b\x001\0x",
212 ];
213 for (i, case) in cases.iter().enumerate() {
214 assert!(unpack(case).is_err(), "case {i}");
215 }
216 }
217}