use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LockEntry {
pub version: String,
pub integrity: String,
}
#[derive(Debug, Default, Clone)]
pub struct Lockfile {
pub entries: BTreeMap<String, LockEntry>,
pub dirty: bool,
}
impl Lockfile {
pub fn parse(text: &str) -> Result<Self, String> {
let value: toml::Value = toml::from_str(text).map_err(|e| e.to_string())?;
let mut entries = BTreeMap::new();
if let Some(modules) = value.get("modules").and_then(|m| m.as_table()) {
for (path, entry) in modules {
let version = entry
.get("version")
.and_then(|v| v.as_str())
.ok_or_else(|| format!("lock entry '{path}': missing version"))?;
let integrity = entry
.get("integrity")
.and_then(|v| v.as_str())
.ok_or_else(|| format!("lock entry '{path}': missing integrity"))?;
entries.insert(
path.clone(),
LockEntry {
version: version.to_string(),
integrity: integrity.to_string(),
},
);
}
}
Ok(Lockfile {
entries,
dirty: false,
})
}
pub fn to_toml_string(&self) -> String {
let mut out = String::from("# Generated by aura. Do not edit manually.\n");
for (path, e) in &self.entries {
out.push_str(&format!(
"\n[modules.\"{path}\"]\nversion = \"{}\"\nintegrity = \"{}\"\n",
e.version, e.integrity
));
}
out
}
}
pub fn integrity_of(text: &str) -> String {
match crate::lexer::Lexer::new(text, 0).tokenize() {
Ok(tokens) => sha256_hex("aura1", &encode_tokens(&tokens)),
Err(_) => sha256_hex("aura1", text.as_bytes()),
}
}
pub fn legacy_integrity_of(text: &str) -> String {
sha256_hex("sha256", text.as_bytes())
}
pub fn recompute_like(existing: &str, text: &str) -> String {
if existing.starts_with("sha256-") {
legacy_integrity_of(text)
} else {
integrity_of(text)
}
}
fn sha256_hex(prefix: &str, bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(bytes);
let mut out = String::with_capacity(prefix.len() + 65);
out.push_str(prefix);
out.push('-');
for b in digest {
out.push_str(&format!("{b:02x}"));
}
out
}
fn encode_tokens(tokens: &[crate::lexer::Token<'_>]) -> Vec<u8> {
use crate::lexer::token::{StrPart, TokenKind as T};
let mut out = Vec::with_capacity(tokens.len() * 4);
let put_str = |out: &mut Vec<u8>, s: &str| {
out.extend_from_slice(&(s.len() as u64).to_le_bytes());
out.extend_from_slice(s.as_bytes());
};
for t in tokens {
let tag: u8 = match &t.kind {
T::Newline | T::Eof => continue,
T::Ident(_) => 1,
T::Int(_) => 2,
T::Float(_) => 3,
T::Str(_) => 4,
T::InterpStr(_) => 5,
T::ImportPath { .. } => 6,
T::True => 7,
T::False => 8,
T::Null => 9,
T::Import => 10,
T::As => 11,
T::Type => 12,
T::Enum => 13,
T::Def => 14,
T::End => 15,
T::Domain => 16,
T::New => 17,
T::Assert => 18,
T::Shadow => 19,
T::Pub => 20,
T::Cond => 21,
T::Else => 22,
T::LParen => 23,
T::RParen => 24,
T::LBracket => 25,
T::RBracket => 26,
T::Colon => 27,
T::Comma => 28,
T::Dot => 29,
T::Assign => 30,
T::Arrow => 31,
T::Question => 32,
T::Plus => 33,
T::Minus => 34,
T::Star => 35,
T::Slash => 36,
T::Percent => 37,
T::EqEq => 38,
T::NotEq => 39,
T::Lt => 40,
T::Gt => 41,
T::LtEq => 42,
T::GtEq => 43,
T::And => 44,
T::Or => 45,
T::Not => 46,
};
out.push(tag);
match &t.kind {
T::Ident(s) | T::Str(s) => put_str(&mut out, s),
T::Int(n) => out.extend_from_slice(&n.to_le_bytes()),
T::Float(f) => out.extend_from_slice(&f.to_bits().to_le_bytes()),
T::InterpStr(parts) => {
out.extend_from_slice(&(parts.len() as u64).to_le_bytes());
for p in parts {
match p {
StrPart::Lit(s) => {
out.push(0);
put_str(&mut out, s);
}
StrPart::Interp(s) => {
out.push(1);
put_str(&mut out, s);
}
}
}
}
T::ImportPath { path, version } => {
put_str(&mut out, path);
put_str(&mut out, version);
}
_ => {}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn layout_and_comments_do_not_change_the_hash() {
let original = "pub def f(a)\n x: a\nend\n";
let same_meaning = [
"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", ];
let base = integrity_of(original);
for variant in same_meaning {
assert_eq!(
integrity_of(variant),
base,
"hash changed for a behaviour-preserving edit:\n{variant:?}"
);
}
for variant in same_meaning {
assert_ne!(legacy_integrity_of(variant), legacy_integrity_of(original));
}
}
#[test]
fn real_changes_do_change_the_hash() {
let base = integrity_of("port: 8080\n");
for changed in [
"port: 8081\n", "port: \"8080\"\n", "prt: 8080\n", "port = 8080\n", "port: 8080.0\n", ] {
assert_ne!(
integrity_of(changed),
base,
"hash unchanged for {changed:?}"
);
}
}
#[test]
fn equal_floats_written_differently_hash_the_same() {
assert_eq!(integrity_of("x: 1.0\n"), integrity_of("x: 1.00\n"));
assert_ne!(integrity_of("x: 1.0\n"), integrity_of("x: 1.5\n"));
}
#[test]
fn adjacent_payloads_cannot_be_confused() {
assert_ne!(
integrity_of("a: \"bc\"\n"),
integrity_of("a: \"b\"\nc: 1\n")
);
assert_ne!(integrity_of("ab: 1\n"), integrity_of("a: 1\nb: 1\n"));
}
#[test]
fn unlexable_input_falls_back_to_bytes() {
let broken = "x: \"unterminated";
assert_eq!(integrity_of(broken), integrity_of(broken));
assert_ne!(integrity_of(broken), integrity_of("x: \"other"));
}
#[test]
fn recompute_like_follows_the_existing_prefix() {
let text = "x: 1\n";
let old = legacy_integrity_of(text);
let new = integrity_of(text);
assert_eq!(
recompute_like(&old, text),
old,
"a sha256- entry stays legacy"
);
assert_eq!(
recompute_like(&new, text),
new,
"an aura1- entry stays current"
);
assert_ne!(old, new, "the two algorithms must be distinguishable");
}
#[test]
fn every_token_kind_hashes_distinctly() {
let cases: &[(&str, &str)] = &[
("Ident", "x: a\n"),
("Int", "x: 1\n"),
("Float", "x: 1.5\n"),
("Str", "x: \"s\"\n"),
("InterpStr", "a = 1\nx: \"v#{a}\"\n"),
("ImportPath", "import github/o/r@v1.0 as m\nx: m\n"),
("True", "x: true\n"),
("False", "x: false\n"),
("Null", "x: null\n"),
("Import/As", "import \"m.aura\" as m\nx: m\n"),
("Type", "type T\n a: Int\nend\n"),
("Enum", "enum E\n \"a\"\nend\n"),
("Def/End", "def f()\n x: 1\nend\n"),
("Domain", "domain d\n x: 1\nend\n"),
("New", "type T\n a: Int\nend\n\nx: new T\n a: 1\nend\n"),
("Assert", "assert 1 == 1, \"m\"\n"),
("Shadow", "a = 1\n\ndef f()\n shadow a = 2\n x: a\nend\n"),
("Pub", "pub def f()\n x: 1\nend\n"),
("Cond/Else", "x: cond\n 1 > 2 -> 1\n else -> 2\nend\n"),
("LParen/RParen", "x: (1)\n"),
("LBracket/RBracket", "x: [1]\n"),
("Comma", "x: [1, 2]\n"),
("Dot", "x: \"s\".upper()\n"),
("Assign", "a = 1\nx: a\n"),
("Arrow", "f = (a) -> a\nx: f(1)\n"),
("Question", "x: true ? 1 : 2\n"),
("Plus", "x: 1 + 2\n"),
("Minus", "x: 1 - 2\n"),
("Star", "x: 1 * 2\n"),
("Slash", "x: 1 / 2\n"),
("Percent", "x: 1 % 2\n"),
("EqEq", "x: 1 == 2\n"),
("NotEq", "x: 1 != 2\n"),
("Lt", "x: 1 < 2\n"),
("Gt", "x: 1 > 2\n"),
("LtEq", "x: 1 <= 2\n"),
("GtEq", "x: 1 >= 2\n"),
("And", "x: true && false\n"),
("Or", "x: true || false\n"),
("Not", "x: !true\n"),
];
for (label, src) in cases {
let tokens = crate::lexer::Lexer::new(src, 0)
.tokenize()
.unwrap_or_else(|d| {
panic!(
"the {label} snippet does not lex ({}), so it never reaches \
encode_tokens: {src:?}",
d.message
)
});
let present: Vec<String> = tokens
.iter()
.map(|t| {
let dbg = format!("{:?}", t.kind);
dbg.split(['(', ' ']).next().unwrap_or_default().to_string()
})
.collect();
for kind in label.split('/') {
assert!(
present.iter().any(|p| p == kind),
"the {label} snippet contains no {kind} token, so that tag is \
untested: {src:?} lexed as {present:?}"
);
}
}
let mut seen: std::collections::HashMap<String, &str> = std::collections::HashMap::new();
for (kind, src) in cases {
let hash = integrity_of(src);
if let Some(other) = seen.insert(hash, kind) {
panic!("{kind} and {other} hash identically — they share a tag byte");
}
}
}
#[test]
fn hash_is_pinned_by_a_golden_value() {
assert_eq!(
integrity_of("port: 8080\n"),
"aura1-f04ef7152b1131c8367cb4b832e7e5c5b9c5e8c7633abccf22e8b5572652ea8e"
);
}
}