Skip to main content

aura_lang/vfs/
lockfile.rs

1//! aura.lock (SPEC §5.2, D8): pinning exact registry module versions + an integrity hash.
2
3use std::collections::BTreeMap;
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct LockEntry {
7    /// The exact version without the `v` prefix, e.g. "1.2.9".
8    pub version: String,
9    /// The integrity hash: `aura1-<hex>` over the module's token stream, or the
10    /// legacy `sha256-<hex>` over its raw bytes. See [`integrity_of`].
11    pub integrity: String,
12}
13
14#[derive(Debug, Default, Clone)]
15pub struct Lockfile {
16    /// BTreeMap - a deterministic write order.
17    pub entries: BTreeMap<String, LockEntry>,
18    /// New/updated entries appeared - the file needs rewriting (except under --frozen).
19    pub dirty: bool,
20}
21
22impl Lockfile {
23    pub fn parse(text: &str) -> Result<Self, String> {
24        let value: toml::Value = toml::from_str(text).map_err(|e| e.to_string())?;
25        let mut entries = BTreeMap::new();
26        if let Some(modules) = value.get("modules").and_then(|m| m.as_table()) {
27            for (path, entry) in modules {
28                let version = entry
29                    .get("version")
30                    .and_then(|v| v.as_str())
31                    .ok_or_else(|| format!("lock entry '{path}': missing version"))?;
32                let integrity = entry
33                    .get("integrity")
34                    .and_then(|v| v.as_str())
35                    .ok_or_else(|| format!("lock entry '{path}': missing integrity"))?;
36                entries.insert(
37                    path.clone(),
38                    LockEntry {
39                        version: version.to_string(),
40                        integrity: integrity.to_string(),
41                    },
42                );
43            }
44        }
45        Ok(Lockfile {
46            entries,
47            dirty: false,
48        })
49    }
50
51    pub fn to_toml_string(&self) -> String {
52        let mut out = String::from("# Generated by aura. Do not edit manually.\n");
53        for (path, e) in &self.entries {
54            out.push_str(&format!(
55                "\n[modules.\"{path}\"]\nversion = \"{}\"\nintegrity = \"{}\"\n",
56                e.version, e.integrity
57            ));
58        }
59        out
60    }
61}
62
63/// A module's integrity hash: SHA-256 over its **token stream**, prefixed `aura1-`.
64///
65/// Hashing bytes — which is what `sha256-` entries did — makes the check fire on
66/// changes that cannot alter behaviour: a reformat, a fixed typo in a comment, a
67/// changed line ending. That trains people to refresh the lock without looking,
68/// which is precisely the habit an integrity check exists to prevent. Dhall got
69/// this right first, hashing the normal form of an expression rather than its
70/// text.
71///
72/// Hashing the token stream is cheaper than a normal form and needs no
73/// evaluation, so it also works for a module whose exports include functions.
74/// The trade is honest: it is insensitive to layout and comments, but a renamed
75/// private variable still changes the hash even though behaviour is identical —
76/// a normal form would see through that, and this does not.
77///
78/// The encoding below **is a compatibility contract**: change it and every lock
79/// in existence stops matching. That is why it is written out explicitly rather
80/// than derived from `Debug`, which is not a stable format, and why
81/// `hash_is_pinned_by_a_golden_value` exists. A future change means a new prefix,
82/// not an edit here.
83pub fn integrity_of(text: &str) -> String {
84    // A file that does not lex has no token stream; fall back to its bytes so
85    // that a corrupt download still produces a stable, comparable hash.
86    match crate::lexer::Lexer::new(text, 0).tokenize() {
87        Ok(tokens) => sha256_hex("aura1", &encode_tokens(&tokens)),
88        Err(_) => sha256_hex("aura1", text.as_bytes()),
89    }
90}
91
92/// The legacy hash: SHA-256 over the raw bytes, prefixed `sha256-`. Kept so that
93/// a lock written by an older version still verifies instead of failing E0402.
94pub fn legacy_integrity_of(text: &str) -> String {
95    sha256_hex("sha256", text.as_bytes())
96}
97
98/// Which algorithm an existing lock entry used.
99pub fn recompute_like(existing: &str, text: &str) -> String {
100    if existing.starts_with("sha256-") {
101        legacy_integrity_of(text)
102    } else {
103        integrity_of(text)
104    }
105}
106
107fn sha256_hex(prefix: &str, bytes: &[u8]) -> String {
108    use sha2::{Digest, Sha256};
109    let digest = Sha256::digest(bytes);
110    let mut out = String::with_capacity(prefix.len() + 65);
111    out.push_str(prefix);
112    out.push('-');
113    for b in digest {
114        out.push_str(&format!("{b:02x}"));
115    }
116    out
117}
118
119/// A deterministic byte encoding of the token stream.
120///
121/// Every variant gets an explicit tag, and payloads are length-prefixed so that
122/// no concatenation of two tokens can be confused with a third. `Newline` and
123/// `Eof` are skipped: blank lines carry no meaning, and layout must not affect
124/// the hash. Comments never appear here at all — the lexer does not emit them.
125fn encode_tokens(tokens: &[crate::lexer::Token<'_>]) -> Vec<u8> {
126    use crate::lexer::token::{StrPart, TokenKind as T};
127
128    let mut out = Vec::with_capacity(tokens.len() * 4);
129    let put_str = |out: &mut Vec<u8>, s: &str| {
130        out.extend_from_slice(&(s.len() as u64).to_le_bytes());
131        out.extend_from_slice(s.as_bytes());
132    };
133
134    for t in tokens {
135        // Exhaustive on purpose: a new token variant must not silently inherit
136        // another one's tag, so adding one has to be a deliberate decision here.
137        let tag: u8 = match &t.kind {
138            T::Newline | T::Eof => continue,
139            T::Ident(_) => 1,
140            T::Int(_) => 2,
141            T::Float(_) => 3,
142            T::Str(_) => 4,
143            T::InterpStr(_) => 5,
144            T::ImportPath { .. } => 6,
145            T::True => 7,
146            T::False => 8,
147            T::Null => 9,
148            T::Import => 10,
149            T::As => 11,
150            T::Type => 12,
151            T::Enum => 13,
152            T::Def => 14,
153            T::End => 15,
154            T::Domain => 16,
155            T::New => 17,
156            T::Assert => 18,
157            T::Shadow => 19,
158            T::Pub => 20,
159            T::Cond => 21,
160            T::Else => 22,
161            T::LParen => 23,
162            T::RParen => 24,
163            T::LBracket => 25,
164            T::RBracket => 26,
165            T::Colon => 27,
166            T::Comma => 28,
167            T::Dot => 29,
168            T::Assign => 30,
169            T::Arrow => 31,
170            T::Question => 32,
171            T::Plus => 33,
172            T::Minus => 34,
173            T::Star => 35,
174            T::Slash => 36,
175            T::Percent => 37,
176            T::EqEq => 38,
177            T::NotEq => 39,
178            T::Lt => 40,
179            T::Gt => 41,
180            T::LtEq => 42,
181            T::GtEq => 43,
182            T::And => 44,
183            T::Or => 45,
184            T::Not => 46,
185        };
186        out.push(tag);
187
188        match &t.kind {
189            T::Ident(s) | T::Str(s) => put_str(&mut out, s),
190            T::Int(n) => out.extend_from_slice(&n.to_le_bytes()),
191            // Bit pattern, not the decimal rendering: `1.0` and `1.00` are the
192            // same value and must hash the same.
193            T::Float(f) => out.extend_from_slice(&f.to_bits().to_le_bytes()),
194            T::InterpStr(parts) => {
195                out.extend_from_slice(&(parts.len() as u64).to_le_bytes());
196                for p in parts {
197                    match p {
198                        StrPart::Lit(s) => {
199                            out.push(0);
200                            put_str(&mut out, s);
201                        }
202                        StrPart::Interp(s) => {
203                            out.push(1);
204                            put_str(&mut out, s);
205                        }
206                    }
207                }
208            }
209            T::ImportPath { path, version } => {
210                put_str(&mut out, path);
211                put_str(&mut out, version);
212            }
213            _ => {}
214        }
215    }
216    out
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    /// The whole point: a change that cannot alter behaviour must not fire the
224    /// integrity check, because a check that cries wolf gets refreshed blindly.
225    #[test]
226    fn layout_and_comments_do_not_change_the_hash() {
227        let original = "pub def f(a)\n  x: a\nend\n";
228        let same_meaning = [
229            "pub def f(a)\n\n\n  x: a\nend\n",          // blank lines
230            "pub def f(a)\n  x:   a\nend\n",            // spacing
231            "# a comment\npub def f(a)\n  x: a\nend\n", // a leading comment
232            "pub def f(a)\n  x: a # trailing\nend\n",   // a trailing comment
233            "pub def f(a)\r\n  x: a\r\nend\r\n",        // CRLF
234        ];
235        let base = integrity_of(original);
236        for variant in same_meaning {
237            assert_eq!(
238                integrity_of(variant),
239                base,
240                "hash changed for a behaviour-preserving edit:\n{variant:?}"
241            );
242        }
243        // The byte hash, by contrast, changes for every one of them — which is
244        // the behaviour being replaced.
245        for variant in same_meaning {
246            assert_ne!(legacy_integrity_of(variant), legacy_integrity_of(original));
247        }
248    }
249
250    /// And it must still fire on anything that can change behaviour.
251    #[test]
252    fn real_changes_do_change_the_hash() {
253        let base = integrity_of("port: 8080\n");
254        for changed in [
255            "port: 8081\n",     // a different number
256            "port: \"8080\"\n", // a string, not an int
257            "prt: 8080\n",      // a renamed key
258            "port = 8080\n",    // `=` instead of `:` — private, not exported
259            "port: 8080.0\n",   // Float, not Int
260        ] {
261            assert_ne!(
262                integrity_of(changed),
263                base,
264                "hash unchanged for {changed:?}"
265            );
266        }
267    }
268
269    /// `1.0` and `1.00` are the same value, so they must hash the same — the
270    /// encoding uses the bit pattern rather than the text.
271    #[test]
272    fn equal_floats_written_differently_hash_the_same() {
273        assert_eq!(integrity_of("x: 1.0\n"), integrity_of("x: 1.00\n"));
274        assert_ne!(integrity_of("x: 1.0\n"), integrity_of("x: 1.5\n"));
275    }
276
277    /// Length-prefixing means no two tokens can run together into a third.
278    #[test]
279    fn adjacent_payloads_cannot_be_confused() {
280        assert_ne!(
281            integrity_of("a: \"bc\"\n"),
282            integrity_of("a: \"b\"\nc: 1\n")
283        );
284        assert_ne!(integrity_of("ab: 1\n"), integrity_of("a: 1\nb: 1\n"));
285    }
286
287    /// Input that does not lex still gets a stable, comparable hash.
288    #[test]
289    fn unlexable_input_falls_back_to_bytes() {
290        let broken = "x: \"unterminated";
291        assert_eq!(integrity_of(broken), integrity_of(broken));
292        assert_ne!(integrity_of(broken), integrity_of("x: \"other"));
293    }
294
295    #[test]
296    fn recompute_like_follows_the_existing_prefix() {
297        let text = "x: 1\n";
298        let old = legacy_integrity_of(text);
299        let new = integrity_of(text);
300        assert_eq!(
301            recompute_like(&old, text),
302            old,
303            "a sha256- entry stays legacy"
304        );
305        assert_eq!(
306            recompute_like(&new, text),
307            new,
308            "an aura1- entry stays current"
309        );
310        assert_ne!(old, new, "the two algorithms must be distinguishable");
311    }
312
313    /// Every token kind must carry its own tag. A duplicated tag would be a
314    /// collision in the integrity hash — two different modules hashing the same,
315    /// which is exactly what an attacker substituting a package would want. The
316    /// tag table is a hand-written `match` of 46 arms, so nothing but a test
317    /// stops two of them from being given the same number.
318    ///
319    /// Each snippet below differs from its neighbours only in which token kinds
320    /// it contains, so any two sharing a tag would produce an equal hash.
321    #[test]
322    fn every_token_kind_hashes_distinctly() {
323        // Keyed by the kind under test, so a failure names it.
324        let cases: &[(&str, &str)] = &[
325            ("Ident", "x: a\n"),
326            ("Int", "x: 1\n"),
327            ("Float", "x: 1.5\n"),
328            ("Str", "x: \"s\"\n"),
329            ("InterpStr", "a = 1\nx: \"v#{a}\"\n"),
330            ("ImportPath", "import github/o/r@v1.0 as m\nx: m\n"),
331            ("True", "x: true\n"),
332            ("False", "x: false\n"),
333            ("Null", "x: null\n"),
334            ("Import/As", "import \"m.aura\" as m\nx: m\n"),
335            ("Type", "type T\n  a: Int\nend\n"),
336            ("Enum", "enum E\n  \"a\"\nend\n"),
337            ("Def/End", "def f()\n  x: 1\nend\n"),
338            ("Domain", "domain d\n  x: 1\nend\n"),
339            ("New", "type T\n  a: Int\nend\n\nx: new T\n  a: 1\nend\n"),
340            ("Assert", "assert 1 == 1, \"m\"\n"),
341            ("Shadow", "a = 1\n\ndef f()\n  shadow a = 2\n  x: a\nend\n"),
342            ("Pub", "pub def f()\n  x: 1\nend\n"),
343            ("Cond/Else", "x: cond\n  1 > 2 -> 1\n  else -> 2\nend\n"),
344            ("LParen/RParen", "x: (1)\n"),
345            ("LBracket/RBracket", "x: [1]\n"),
346            ("Comma", "x: [1, 2]\n"),
347            ("Dot", "x: \"s\".upper()\n"),
348            ("Assign", "a = 1\nx: a\n"),
349            ("Arrow", "f = (a) -> a\nx: f(1)\n"),
350            ("Question", "x: true ? 1 : 2\n"),
351            ("Plus", "x: 1 + 2\n"),
352            ("Minus", "x: 1 - 2\n"),
353            ("Star", "x: 1 * 2\n"),
354            ("Slash", "x: 1 / 2\n"),
355            ("Percent", "x: 1 % 2\n"),
356            ("EqEq", "x: 1 == 2\n"),
357            ("NotEq", "x: 1 != 2\n"),
358            ("Lt", "x: 1 < 2\n"),
359            ("Gt", "x: 1 > 2\n"),
360            ("LtEq", "x: 1 <= 2\n"),
361            ("GtEq", "x: 1 >= 2\n"),
362            ("And", "x: true && false\n"),
363            ("Or", "x: true || false\n"),
364            ("Not", "x: !true\n"),
365        ];
366
367        // Two things have to hold before distinctness means anything: the snippet
368        // must lex (otherwise `integrity_of` falls back to the byte hash and the
369        // test is comparing raw text), and it must actually contain the token kind
370        // it is named after — a mislabelled snippet would leave a tag untested
371        // while appearing to cover it.
372        for (label, src) in cases {
373            let tokens = crate::lexer::Lexer::new(src, 0)
374                .tokenize()
375                .unwrap_or_else(|d| {
376                    panic!(
377                        "the {label} snippet does not lex ({}), so it never reaches \
378                     encode_tokens: {src:?}",
379                        d.message
380                    )
381                });
382            let present: Vec<String> = tokens
383                .iter()
384                .map(|t| {
385                    let dbg = format!("{:?}", t.kind);
386                    dbg.split(['(', ' ']).next().unwrap_or_default().to_string()
387                })
388                .collect();
389            for kind in label.split('/') {
390                assert!(
391                    present.iter().any(|p| p == kind),
392                    "the {label} snippet contains no {kind} token, so that tag is \
393                     untested: {src:?} lexed as {present:?}"
394                );
395            }
396        }
397
398        let mut seen: std::collections::HashMap<String, &str> = std::collections::HashMap::new();
399        for (kind, src) in cases {
400            let hash = integrity_of(src);
401            if let Some(other) = seen.insert(hash, kind) {
402                panic!("{kind} and {other} hash identically — they share a tag byte");
403            }
404        }
405    }
406
407    /// The encoding is a compatibility contract: if this value moves, every lock
408    /// in existence stops matching. Changing the algorithm means a new prefix,
409    /// not a new expected value here.
410    #[test]
411    fn hash_is_pinned_by_a_golden_value() {
412        assert_eq!(
413            integrity_of("port: 8080\n"),
414            "aura1-f04ef7152b1131c8367cb4b832e7e5c5b9c5e8c7633abccf22e8b5572652ea8e"
415        );
416    }
417}