Skip to main content

faf_fafb/
identity.rs

1//! Content ID and file digest — computed, never stored.
2//!
3//! See BINARY-FORMAT.md § Identities. Stamps and `__`-prefixed chunks are
4//! excluded from the Content ID.
5
6use sha2::{Digest, Sha256};
7
8use super::canon::{CANONICAL_CHUNKS, is_structural_name};
9use super::compile::DecompiledFafb;
10use super::error::FafbResult;
11use super::section::SectionEntry;
12
13/// SHA-256 as 64 lowercase hex characters, no `0x` prefix.
14pub fn hex_sha256(bytes: &[u8]) -> String {
15    let digest = Sha256::digest(bytes);
16    let mut out = String::with_capacity(64);
17    for b in digest {
18        out.push_str(&format!("{b:02x}"));
19    }
20    out
21}
22
23/// File digest: SHA-256 over every byte of the file.
24pub fn file_digest(fafb_bytes: &[u8]) -> String {
25    hex_sha256(fafb_bytes)
26}
27
28/// Content ID of a compiled `.fafb`.
29///
30/// Concatenation, in canonical table order, of each present non-structural
31/// chunk encoded as:
32/// `name_len (u8) ‖ name (UTF-8) ‖ class (u8) ‖ priority (u8) ‖ payload_len (u32 LE) ‖ payload`
33pub fn content_id(fafb_bytes: &[u8]) -> FafbResult<String> {
34    let decoded = super::compile::decompile(fafb_bytes)?;
35    Ok(content_id_of(&decoded))
36}
37
38/// Content ID from an already-decompiled file.
39pub fn content_id_of(decoded: &DecompiledFafb) -> String {
40    hex_sha256(&content_id_preimage(decoded))
41}
42
43fn content_id_preimage(decoded: &DecompiledFafb) -> Vec<u8> {
44    let mut buf = Vec::new();
45    for chunk in CANONICAL_CHUNKS {
46        let Some(entry) = find_named(decoded, chunk.name) else {
47            continue;
48        };
49        let Some(payload) = decoded.section_data(entry) else {
50            continue;
51        };
52        encode_chunk(&mut buf, chunk.name, entry, payload);
53    }
54    buf
55}
56
57fn find_named<'a>(decoded: &'a DecompiledFafb, name: &str) -> Option<&'a SectionEntry> {
58    decoded
59        .section_table
60        .entries()
61        .iter()
62        .find(|e| decoded.section_name(e) == name && !is_structural_name(name))
63}
64
65fn encode_chunk(buf: &mut Vec<u8>, name: &str, entry: &SectionEntry, payload: &[u8]) {
66    let name_bytes = name.as_bytes();
67    buf.push(name_bytes.len() as u8);
68    buf.extend_from_slice(name_bytes);
69    buf.push(entry.classification().bits() as u8);
70    buf.push(entry.priority.value());
71    buf.extend_from_slice(&(payload.len() as u32).to_le_bytes());
72    buf.extend_from_slice(payload);
73}
74
75/// Canonical text rendering: concatenation of content-chunk payloads in
76/// canonical table order. Each payload already begins with `name:\n`.
77pub fn canonical_rendering(decoded: &DecompiledFafb) -> String {
78    let mut out = String::new();
79    for chunk in CANONICAL_CHUNKS {
80        if let Some(text) = decoded.get_section_string_by_name(chunk.name) {
81            out.push_str(&text);
82        }
83    }
84    out
85}
86
87/// Canonical rendering after dropping whole priority tiers from the tail.
88///
89/// `drop_up_to` is the last tier to drop, in spec order: 64, then 128, then
90/// 150–200. Critical (255) chunks always remain.
91pub fn truncated_rendering(decoded: &DecompiledFafb, drop_up_to: TruncationTier) -> String {
92    let mut out = String::new();
93    for chunk in CANONICAL_CHUNKS {
94        if drop_up_to.drops(chunk.priority) {
95            continue;
96        }
97        if let Some(text) = decoded.get_section_string_by_name(chunk.name) {
98            out.push_str(&text);
99        }
100    }
101    out
102}
103
104/// Spec truncation tiers, dropped from the tail in this order.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
106pub enum TruncationTier {
107    /// Drop nothing.
108    None,
109    /// Drop priority 64 (scores, context).
110    P64,
111    /// Also drop priority 128 (architecture).
112    P128,
113    /// Also drop priorities 150–200.
114    P150_200,
115}
116
117impl TruncationTier {
118    fn drops(self, priority: u8) -> bool {
119        if priority == 255 {
120            return false;
121        }
122        match self {
123            Self::None => false,
124            Self::P64 => priority == 64,
125            Self::P128 => priority == 64 || priority == 128,
126            Self::P150_200 => (150..=200).contains(&priority) || priority == 64 || priority == 128,
127        }
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crate::{CompileOptions, compile};
135
136    fn det() -> CompileOptions {
137        CompileOptions {
138            use_timestamp: false,
139        }
140    }
141
142    #[test]
143    fn hex_is_64_lowercase() {
144        let h = hex_sha256(b"abc");
145        assert_eq!(h.len(), 64);
146        assert!(
147            h.chars()
148                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
149        );
150        assert!(!h.starts_with("0x"));
151    }
152
153    #[test]
154    fn comment_does_not_change_content_id() {
155        let a = "faf_version: 2.5.0\nproject:\n  name: x\n";
156        let b = "# a comment the AI never sees\nfaf_version: 2.5.0\nproject:\n  name: x\n";
157        let ba = compile(a, &det()).unwrap();
158        let bb = compile(b, &det()).unwrap();
159        assert_eq!(content_id(&ba).unwrap(), content_id(&bb).unwrap());
160        assert_ne!(file_digest(&ba), file_digest(&bb));
161    }
162}