Skip to main content

ctx/harness/
checksum.rs

1//! Generated-file headers and content checksums for `ctx harness`.
2//!
3//! Every file that `ctx harness init` writes carries a two-line header:
4//!
5//! ```text
6//! # generated by ctx v0.2.1 — regenerate with 'ctx harness init'
7//! # ctx:checksum sha256:<hex>
8//! ```
9//!
10//! The checksum is a SHA-256 over the file content *minus* every
11//! `ctx:checksum` line itself (so the recorded value does not depend on
12//! itself), which lets `init` distinguish files it owns and the user has not
13//! touched (safe to regenerate) from user-modified or foreign files (never
14//! overwritten without `--force`).
15//!
16//! JSON files cannot carry comments; they get no in-file header and are
17//! tracked exclusively through the `.ctx/harness.lock` manifest instead.
18
19use sha2::{Digest, Sha256};
20
21/// Where (and how) the generated-file header is written.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum HeaderStyle {
24    /// `#` comments right after the `#!` shebang line.
25    Shell,
26    /// `#` comments at the top of the file.
27    Toml,
28    /// `#` comment lines inserted *inside* the YAML frontmatter (after the
29    /// opening `---`), so frontmatter parsers still see valid YAML.
30    YamlFrontmatter,
31    /// HTML comments at the top of the file (plain Markdown).
32    HtmlComment,
33    /// No in-file header (JSON); tracked via `.ctx/harness.lock` only.
34    None,
35}
36
37/// The `generated by ...` header line (without comment leader).
38fn generated_line(version: &str) -> String {
39    format!("generated by ctx v{version} — regenerate with 'ctx harness init'")
40}
41
42/// True if `line` is a `ctx:checksum` line under any comment leader
43/// (`#`, `//`, or `<!--`).
44fn 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
54/// SHA-256 (hex) of `bytes` with every `ctx:checksum` line removed.
55///
56/// Non-UTF-8 content has no checksum lines to strip and is hashed as-is.
57pub 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
72/// Extract the checksum recorded in a file's `ctx:checksum` line, if any.
73pub 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
88/// Extract the ctx version recorded in a file's `generated by ctx v...`
89/// header line, if any.
90pub 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
105/// Insert the generated-file header (with a real checksum) into `content`.
106///
107/// For [`HeaderStyle::None`] the content is returned unchanged.
108pub 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    // The checksum line is excluded from the hash, so hashing with the
154    // placeholder still present yields the final value.
155    let checksum = content_checksum(headered.as_bytes());
156    headered.replace(PLACEHOLDER, &checksum)
157}
158
159/// The header style implied by a generated file's relative path.
160pub 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        // JSON and anything else that cannot carry comments.
172        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        // Header sits right after the shebang and carries the version.
186        assert!(finalized.starts_with("#!/bin/sh\n# generated by ctx v1.2.3"));
187        assert!(finalized.contains("regenerate with 'ctx harness init'"));
188
189        // Recorded checksum matches a recomputation over the final bytes.
190        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        // The recorded line itself is untouched, so it still parses.
203        assert_eq!(recorded_checksum(&tampered).unwrap(), recorded);
204    }
205
206    #[test]
207    fn test_checksum_line_is_leader_agnostic() {
208        // The same body hashes identically regardless of the comment leader
209        // used for the ctx:checksum line.
210        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        // Different recorded values also do not change the hash.
221        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        // HeaderStyle::None leaves content untouched; the checksum is over
230        // the full content (no lines are stripped from JSON).
231        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        // Frontmatter fence count is unchanged (header is inside it).
248        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}