1use sha2::{Digest, Sha256};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum HeaderStyle {
24 Shell,
26 Toml,
28 YamlFrontmatter,
31 HtmlComment,
33 None,
35}
36
37fn generated_line(version: &str) -> String {
39 format!("generated by ctx v{version} — regenerate with 'ctx harness init'")
40}
41
42fn is_checksum_line(line: &str) -> bool {
45 let trimmed = line.trim();
46 let body = trimmed
47 .strip_prefix("<!--")
48 .or_else(|| trimmed.strip_prefix("//"))
49 .or_else(|| trimmed.strip_prefix('#'))
50 .unwrap_or(trimmed);
51 body.trim_start().starts_with("ctx:checksum")
52}
53
54pub fn content_checksum(bytes: &[u8]) -> String {
58 let mut hasher = Sha256::new();
59 match std::str::from_utf8(bytes) {
60 Ok(text) => {
61 for line in text.split_inclusive('\n') {
62 if !is_checksum_line(line) {
63 hasher.update(line.as_bytes());
64 }
65 }
66 }
67 Err(_) => hasher.update(bytes),
68 }
69 format!("{:x}", hasher.finalize())
70}
71
72pub fn recorded_checksum(content: &str) -> Option<String> {
74 for line in content.lines() {
75 if is_checksum_line(line) {
76 let after = line.split("ctx:checksum").nth(1)?;
77 let token = after.split_whitespace().next()?;
78 let hex = token.strip_prefix("sha256:").unwrap_or(token);
79 let hex = hex.trim_end_matches("-->").trim();
80 if !hex.is_empty() {
81 return Some(hex.to_string());
82 }
83 }
84 }
85 None
86}
87
88pub fn generated_version(content: &str) -> Option<String> {
91 for line in content.lines() {
92 if let Some(after) = line.split("generated by ctx v").nth(1) {
93 let version: String = after
94 .chars()
95 .take_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-' || c.is_alphanumeric())
96 .collect();
97 if !version.is_empty() {
98 return Some(version);
99 }
100 }
101 }
102 None
103}
104
105pub fn finalize(content: &str, style: HeaderStyle, version: &str) -> String {
109 const PLACEHOLDER: &str = "{{CHECKSUM}}";
110
111 let headered = match style {
112 HeaderStyle::None => return content.to_string(),
113 HeaderStyle::Shell => {
114 let header = format!(
115 "# {}\n# ctx:checksum sha256:{}\n",
116 generated_line(version),
117 PLACEHOLDER
118 );
119 match content.split_once('\n') {
120 Some((first, rest)) if first.starts_with("#!") => {
121 format!("{first}\n{header}{rest}")
122 }
123 _ => format!("{header}{content}"),
124 }
125 }
126 HeaderStyle::Toml => format!(
127 "# {}\n# ctx:checksum sha256:{}\n{}",
128 generated_line(version),
129 PLACEHOLDER,
130 content
131 ),
132 HeaderStyle::YamlFrontmatter => {
133 let header = format!(
134 "# {}\n# ctx:checksum sha256:{}\n",
135 generated_line(version),
136 PLACEHOLDER
137 );
138 match content.split_once('\n') {
139 Some((first, rest)) if first.trim_end() == "---" => {
140 format!("{first}\n{header}{rest}")
141 }
142 _ => format!("---\n{header}---\n{content}"),
143 }
144 }
145 HeaderStyle::HtmlComment => format!(
146 "<!-- {} -->\n<!-- ctx:checksum sha256:{} -->\n{}",
147 generated_line(version),
148 PLACEHOLDER,
149 content
150 ),
151 };
152
153 let checksum = content_checksum(headered.as_bytes());
156 headered.replace(PLACEHOLDER, &checksum)
157}
158
159pub fn style_for_path(rel_path: &str) -> HeaderStyle {
161 let name = rel_path.rsplit('/').next().unwrap_or(rel_path);
162 if name.ends_with(".sh") {
163 HeaderStyle::Shell
164 } else if name.ends_with(".toml") || name.ends_with(".lock") {
165 HeaderStyle::Toml
166 } else if name == "SKILL.md" {
167 HeaderStyle::YamlFrontmatter
168 } else if name.ends_with(".md") {
169 HeaderStyle::HtmlComment
170 } else {
171 HeaderStyle::None
173 }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 #[test]
181 fn test_checksum_roundtrip_shell() {
182 let raw = "#!/bin/sh\nset -u\necho hello\n";
183 let finalized = finalize(raw, HeaderStyle::Shell, "1.2.3");
184
185 assert!(finalized.starts_with("#!/bin/sh\n# generated by ctx v1.2.3"));
187 assert!(finalized.contains("regenerate with 'ctx harness init'"));
188
189 let recorded = recorded_checksum(&finalized).unwrap();
191 assert_eq!(recorded, content_checksum(finalized.as_bytes()));
192 assert_eq!(generated_version(&finalized).as_deref(), Some("1.2.3"));
193 }
194
195 #[test]
196 fn test_one_byte_tamper_changes_checksum() {
197 let finalized = finalize("#!/bin/sh\necho hi\n", HeaderStyle::Shell, "1.2.3");
198 let recorded = recorded_checksum(&finalized).unwrap();
199
200 let tampered = finalized.replace("echo hi", "echo ho");
201 assert_ne!(recorded, content_checksum(tampered.as_bytes()));
202 assert_eq!(recorded_checksum(&tampered).unwrap(), recorded);
204 }
205
206 #[test]
207 fn test_checksum_line_is_leader_agnostic() {
208 let body = "some content\nmore content\n";
211 let hash = |c: &str| content_checksum(c.as_bytes());
212 assert_eq!(
213 hash(&format!("# ctx:checksum sha256:abc\n{body}")),
214 hash(&format!("// ctx:checksum sha256:abc\n{body}")),
215 );
216 assert_eq!(
217 hash(&format!("<!-- ctx:checksum sha256:abc -->\n{body}")),
218 hash(body),
219 );
220 assert_eq!(
222 hash(&format!("# ctx:checksum sha256:abc\n{body}")),
223 hash(&format!("# ctx:checksum sha256:def\n{body}")),
224 );
225 }
226
227 #[test]
228 fn test_json_degenerates_to_full_content_hash() {
229 let json = "{\n \"name\": \"ctx\"\n}\n";
232 assert_eq!(finalize(json, HeaderStyle::None, "1.2.3"), json);
233
234 let mut hasher = Sha256::new();
235 hasher.update(json.as_bytes());
236 assert_eq!(
237 content_checksum(json.as_bytes()),
238 format!("{:x}", hasher.finalize())
239 );
240 }
241
242 #[test]
243 fn test_yaml_frontmatter_header_stays_inside_frontmatter() {
244 let raw = "---\nname: ctx\ndescription: test\n---\n\n# Body\n";
245 let finalized = finalize(raw, HeaderStyle::YamlFrontmatter, "1.2.3");
246 assert!(finalized.starts_with("---\n# generated by ctx v1.2.3"));
247 assert_eq!(finalized.matches("---\n").count(), 2);
249 let recorded = recorded_checksum(&finalized).unwrap();
250 assert_eq!(recorded, content_checksum(finalized.as_bytes()));
251 }
252
253 #[test]
254 fn test_html_comment_header() {
255 let raw = "# Title\n\nBody text.\n";
256 let finalized = finalize(raw, HeaderStyle::HtmlComment, "1.2.3");
257 assert!(finalized.starts_with("<!-- generated by ctx v1.2.3"));
258 let recorded = recorded_checksum(&finalized).unwrap();
259 assert_eq!(recorded, content_checksum(finalized.as_bytes()));
260 }
261
262 #[test]
263 fn test_style_for_path() {
264 assert_eq!(
265 style_for_path(".claude/hooks/ctx/stop.sh"),
266 HeaderStyle::Shell
267 );
268 assert_eq!(style_for_path(".ctx/rules.toml"), HeaderStyle::Toml);
269 assert_eq!(style_for_path(".ctx/harness.lock"), HeaderStyle::Toml);
270 assert_eq!(
271 style_for_path("skills/ctx/SKILL.md"),
272 HeaderStyle::YamlFrontmatter
273 );
274 assert_eq!(style_for_path("README.md"), HeaderStyle::HtmlComment);
275 assert_eq!(
276 style_for_path(".claude-plugin/plugin.json"),
277 HeaderStyle::None
278 );
279 }
280}