Skip to main content

lean_ctx_protocol/hive/
anchor.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Code anchors (C0 1.9): where in the code a fact, or a piece of task
4//! evidence, applies.
5//!
6//! An anchor names a repo-relative `path`, optionally a `symbol` in it, and
7//! the `digest` of what it covered when it was written. The engine resolves
8//! anchors against its local code graph; Hive only stores them.
9//!
10//! The digest (section 3) is [`anchor_digest`] of the covered bytes: the
11//! exact bytes of the symbol's syntax node (without the comments before it),
12//! or of the whole file without a symbol, normalized by
13//! [`normalize_anchor_content`]. `vectors.json` → `anchor_digest` holds the
14//! reference cases every implementation reproduces.
15
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18
19use crate::ValidationError;
20
21/// The covered content as it is hashed: a leading UTF-8 byte-order mark
22/// dropped, every `\r\n` and lone `\r` turned into `\n`, and trailing spaces
23/// and tabs removed from every line. Nothing else changes: indentation,
24/// blank lines and the final newline (or its absence) stay.
25pub fn normalize_anchor_content(content: &[u8]) -> Vec<u8> {
26    let content = content.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(content);
27    let mut lf = Vec::with_capacity(content.len());
28    let mut bytes = content.iter().copied().peekable();
29    while let Some(byte) = bytes.next() {
30        if byte == b'\r' {
31            if bytes.peek() == Some(&b'\n') {
32                bytes.next();
33            }
34            lf.push(b'\n');
35        } else {
36            lf.push(byte);
37        }
38    }
39    let mut out = Vec::with_capacity(lf.len());
40    for (i, line) in lf.split(|b| *b == b'\n').enumerate() {
41        if i > 0 {
42            out.push(b'\n');
43        }
44        let end = line
45            .iter()
46            .rposition(|b| *b != b' ' && *b != b'\t')
47            .map_or(0, |last| last + 1);
48        out.extend_from_slice(&line[..end]);
49    }
50    out
51}
52
53/// `sha256:` + lowercase hex of the normalized content.
54pub fn anchor_digest(content: &[u8]) -> String {
55    let digest = Sha256::digest(normalize_anchor_content(content));
56    let mut out = String::with_capacity(71);
57    out.push_str("sha256:");
58    for byte in digest {
59        out.push_str(&format!("{byte:02x}"));
60    }
61    out
62}
63
64pub const MAX_ANCHOR_PATH_BYTES: usize = 512;
65pub const MAX_ANCHOR_SYMBOL_CHARS: usize = 256;
66pub const MAX_ANCHORS: usize = 16;
67
68/// One code anchor.
69#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
70#[serde(deny_unknown_fields)]
71pub struct CodeAnchorV1 {
72    /// Repo-relative, `/`-separated, NFC; no `.` or `..` segment, no leading
73    /// or trailing `/`.
74    pub path: String,
75    /// Language-neutral qualified name from the code graph
76    /// (`hive::policy::verify_bundle`, `AuthService.verify`); the whole file
77    /// when absent.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub symbol: Option<String>,
80    /// `sha256:` + 64 lowercase hex over the normalized symbol span (or the
81    /// whole file without a symbol).
82    pub digest: String,
83    /// Tree-sitter language id, a hint only.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub lang: Option<String>,
86}
87
88/// Characters that could make two paths look alike: every one a text field
89/// refuses ([`super::invisible`]) and, in a path, the joiners as well.
90fn invisible(c: char) -> bool {
91    super::invisible(c) || matches!(c, '\u{200C}' | '\u{200D}')
92}
93
94/// A repo-relative, `/`-separated path without `.` or `..` segments (the
95/// rule for anchors and, 1.9 section 8, the code graph). 1.9 R3: no `:`
96/// (drive letters, alternate data streams) and no invisible format
97/// characters.
98pub fn validate_anchor_path(path: &str) -> Result<(), ValidationError> {
99    if path.is_empty()
100        || path.len() > MAX_ANCHOR_PATH_BYTES
101        || path.starts_with('/')
102        || path.ends_with('/')
103        || path.contains('\\')
104        || path.contains(':')
105        || path.chars().any(|c| c.is_control() || invisible(c))
106        || path
107            .split('/')
108            .any(|part| part.is_empty() || part == "." || part == "..")
109    {
110        return Err(ValidationError::new(
111            "anchor path must be a normalized repo-relative path",
112        ));
113    }
114    Ok(())
115}
116
117impl CodeAnchorV1 {
118    pub fn validate(&self) -> Result<(), ValidationError> {
119        validate_anchor_path(&self.path)?;
120        if let Some(symbol) = &self.symbol
121            && (symbol.is_empty()
122                || symbol.chars().count() > MAX_ANCHOR_SYMBOL_CHARS
123                || symbol.trim() != symbol
124                || symbol.chars().any(char::is_control))
125        {
126            return Err(ValidationError::new(
127                "anchor symbol must be 1-256 characters without surrounding spaces",
128            ));
129        }
130        let hex = self
131            .digest
132            .strip_prefix("sha256:")
133            .ok_or_else(|| ValidationError::new("anchor digest must start with sha256:"))?;
134        super::validate_hex64(hex, "anchor digest")?;
135        if let Some(lang) = &self.lang
136            && (lang.is_empty()
137                || lang.len() > 32
138                || !lang
139                    .bytes()
140                    .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_'))
141        {
142            return Err(ValidationError::new(
143                "anchor lang must be 1-32 characters of [a-z0-9_]",
144            ));
145        }
146        Ok(())
147    }
148}
149
150/// A list of anchors: at most [`MAX_ANCHORS`], sorted by `(path, symbol)`
151/// (no symbol first), without repeats.
152pub fn validate_anchors(anchors: &[CodeAnchorV1]) -> Result<(), ValidationError> {
153    if anchors.len() > MAX_ANCHORS {
154        return Err(ValidationError::new(format!(
155            "at most {MAX_ANCHORS} anchors"
156        )));
157    }
158    for (i, anchor) in anchors.iter().enumerate() {
159        anchor.validate()?;
160        if i > 0 {
161            let prev = &anchors[i - 1];
162            if (prev.path.as_str(), prev.symbol.as_deref())
163                >= (anchor.path.as_str(), anchor.symbol.as_deref())
164            {
165                return Err(ValidationError::new(
166                    "anchors must be sorted by path and symbol without repeats",
167                ));
168            }
169        }
170    }
171    Ok(())
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    fn anchor(path: &str, symbol: Option<&str>) -> CodeAnchorV1 {
179        CodeAnchorV1 {
180            path: path.into(),
181            symbol: symbol.map(Into::into),
182            digest: format!("sha256:{}", "a".repeat(64)),
183            lang: None,
184        }
185    }
186
187    #[test]
188    fn paths_are_normalized_and_repo_relative() {
189        assert!(
190            anchor("src/retry.rs", Some("retry::budget"))
191                .validate()
192                .is_ok()
193        );
194        for bad in [
195            "/etc/x",
196            "src/",
197            "a//b",
198            "./a",
199            "a/../b",
200            "a\\b",
201            "",
202            "C:/x",
203            "a.txt:stream",
204            "a\u{202E}b",
205        ] {
206            assert!(anchor(bad, None).validate().is_err(), "{bad}");
207        }
208    }
209
210    #[test]
211    fn anchor_lists_are_sorted_without_repeats() {
212        let a = anchor("a.rs", None);
213        let a_sym = anchor("a.rs", Some("f"));
214        let b = anchor("b.rs", None);
215        assert!(validate_anchors(&[a.clone(), a_sym.clone(), b.clone()]).is_ok());
216        assert!(validate_anchors(&[a_sym.clone(), a.clone()]).is_err());
217        assert!(validate_anchors(&[b.clone(), b]).is_err());
218    }
219}