Skip to main content

bynk_registry_core/
archive.rs

1//! Registry archives (packaging slice 4a, #2274, decision B): a package's
2//! files in transit, held in memory.
3//!
4//! An archive is the header `bynk-tree-v1\n` followed by the package's files,
5//! each `path \0 length \0 bytes`, in byte-wise path order — the very stream
6//! the canonical-tree checksum hashes. The checksum a registry signs is that
7//! tree checksum, and the client admits an unpacked tree only when its own tree
8//! checksum matches, so the archive is transport alone: no tar, no links, no
9//! modes, nothing to canonicalise (and so no compression backend to carry).
10//!
11//! Unpacking takes only regular files at plain relative paths: an absolute
12//! path, a `..`, a `\`, an empty or repeated path, or a size past the caps is
13//! refused (`bynk.registry.bad_archive`).
14
15use 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
23/// The most an archive may unpack to.
24pub const MAX_UNPACKED: u64 = 500 * 1024 * 1024;
25/// The most files an archive may hold.
26const MAX_FILES: usize = 100_000;
27
28/// Why an archive was refused: always `bynk.registry.bad_archive`.
29#[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/// A package's files in memory: `/`-separated relative path to bytes, ordered
41/// byte-wise by path — the order an archive and its checksum use.
42#[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    /// The files in path order.
61    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
82/// What a package archive leaves out: build output, npm's cache, and every
83/// hidden file or directory (`.git`, `.env`, editor state) — a published
84/// package carries its sources, never a secret beside them.
85pub 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
90/// Pack `tree` canonically: the archive bytes and the tree checksum a consumer
91/// will compute from them. Excluded files ([`is_excluded`]) are left out.
92pub 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
111/// Unpack `archive` into a [`Tree`], applying the hardening rules above.
112pub 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
159/// Why `rel` isn't a plain relative file path, if it isn't. Lexical only: no
160/// `std::path`, so the answer is the same on every platform.
161fn 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}