1use 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
13pub 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
23pub fn file_digest(fafb_bytes: &[u8]) -> String {
25 hex_sha256(fafb_bytes)
26}
27
28pub fn content_id(fafb_bytes: &[u8]) -> FafbResult<String> {
34 let decoded = super::compile::decompile(fafb_bytes)?;
35 Ok(content_id_of(&decoded))
36}
37
38pub 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
75pub 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
87pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
106pub enum TruncationTier {
107 None,
109 P64,
111 P128,
113 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}