1use std::collections::BTreeMap;
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct LockEntry {
7 pub version: String,
9 pub integrity: String,
12}
13
14#[derive(Debug, Default, Clone)]
15pub struct Lockfile {
16 pub entries: BTreeMap<String, LockEntry>,
18 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
63pub fn integrity_of(text: &str) -> String {
84 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
92pub fn legacy_integrity_of(text: &str) -> String {
95 sha256_hex("sha256", text.as_bytes())
96}
97
98pub 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
119fn 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 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 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 #[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", "pub def f(a)\n x: a\nend\n", "# a comment\npub def f(a)\n x: a\nend\n", "pub def f(a)\n x: a # trailing\nend\n", "pub def f(a)\r\n x: a\r\nend\r\n", ];
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 for variant in same_meaning {
246 assert_ne!(legacy_integrity_of(variant), legacy_integrity_of(original));
247 }
248 }
249
250 #[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", "port: \"8080\"\n", "prt: 8080\n", "port = 8080\n", "port: 8080.0\n", ] {
261 assert_ne!(
262 integrity_of(changed),
263 base,
264 "hash unchanged for {changed:?}"
265 );
266 }
267 }
268
269 #[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 #[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 #[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 #[test]
322 fn every_token_kind_hashes_distinctly() {
323 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 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 #[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}