lean_ctx_protocol/hive/
anchor.rs1use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18
19use crate::ValidationError;
20
21pub 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
53pub 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
70#[serde(deny_unknown_fields)]
71pub struct CodeAnchorV1 {
72 pub path: String,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub symbol: Option<String>,
80 pub digest: String,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub lang: Option<String>,
86}
87
88fn invisible(c: char) -> bool {
91 super::invisible(c) || matches!(c, '\u{200C}' | '\u{200D}')
92}
93
94pub 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
150pub 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}