Skip to main content

scc_core/
handles.rs

1//! Stable content handles for lazy exact-source retrieval.
2//!
3//! Handles identify a repository object at a ModelEpoch. They are
4//! root-aware, collision-resistant (path + name, never basename-only),
5//! and carry a content hash so a stale target can be refused instead of
6//! silently served.
7
8use serde::{Deserialize, Serialize};
9use std::fmt;
10use thiserror::Error;
11
12/// Kind of object a handle names. Heterogeneous on purpose: SCC entities
13/// are not only symbols.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
16// trace:exempt reason=internal-detail
17pub enum HandleKind {
18    Symbol,
19    Component,
20    Flow,
21    Contract,
22    State,
23    Route,
24    File,
25    Span,
26}
27
28impl HandleKind {
29    pub fn as_str(self) -> &'static str {
30        match self {
31            HandleKind::Symbol => "symbol",
32            HandleKind::Component => "component",
33            HandleKind::Flow => "flow",
34            HandleKind::Contract => "contract",
35            HandleKind::State => "state",
36            HandleKind::Route => "route",
37            HandleKind::File => "file",
38            HandleKind::Span => "span",
39        }
40    }
41
42    pub fn parse(s: &str) -> Option<Self> {
43        Some(match s {
44            "symbol" => HandleKind::Symbol,
45            "component" => HandleKind::Component,
46            "flow" => HandleKind::Flow,
47            "contract" => HandleKind::Contract,
48            "state" => HandleKind::State,
49            "route" => HandleKind::Route,
50            "file" => HandleKind::File,
51            "span" => HandleKind::Span,
52            _ => return None,
53        })
54    }
55}
56
57/// Deterministic, epoch-scoped content identifier.
58///
59/// Format (not locked against future collision/freshness changes):
60/// `scc://{repo}/{epoch}/{kind}/{encoded_key}@{content_hash16}`
61///
62/// `encoded_key` is percent-encoded so paths and `::` scopes stay portable
63/// across MCP calls. `content_hash16` is FNV-1a-64 of the referent file
64/// bytes (hex); empty hash means identity-only (no freshness gate).
65#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
66// trace:v1 id=impl.scc.core.content-handle work=WORK-ripwire-lessons-phase1 satisfies=REQ-stable-content-handles
67pub struct ContentHandle {
68    pub repo: String,
69    pub epoch: String,
70    pub kind: HandleKind,
71    pub key: String,
72    /// 16-char hex FNV-1a of file bytes; empty if unknown.
73    pub content_hash: String,
74}
75
76#[derive(Debug, Error, PartialEq, Eq)]
77pub enum HandleError {
78    #[error("malformed handle")]
79    Malformed,
80    #[error("stale handle: content hash mismatch")]
81    Stale,
82    #[error("ambiguous handle")]
83    Ambiguous,
84    #[error("unknown handle kind")]
85    UnknownKind,
86}
87
88// trace:exempt reason=internal-detail
89impl ContentHandle {
90    pub fn new(
91        repo: impl Into<String>,
92        epoch: impl Into<String>,
93        kind: HandleKind,
94        key: impl Into<String>,
95        content_hash: impl Into<String>,
96    ) -> Self {
97        ContentHandle {
98            repo: repo.into(),
99            epoch: epoch.into(),
100            kind,
101            key: key.into(),
102            content_hash: content_hash.into(),
103        }
104    }
105
106    /// Symbol handle: `path::qualified_name` (never basename-only).
107    pub fn for_symbol(
108        repo: &str,
109        epoch: &str,
110        path: &str,
111        name: &str,
112        content_hash: &str,
113    ) -> Self {
114        ContentHandle::new(
115            repo,
116            epoch,
117            HandleKind::Symbol,
118            format!("{path}::{name}"),
119            content_hash,
120        )
121    }
122
123    pub fn for_file(repo: &str, epoch: &str, path: &str, content_hash: &str) -> Self {
124        ContentHandle::new(repo, epoch, HandleKind::File, path, content_hash)
125    }
126
127    pub fn for_span(
128        repo: &str,
129        epoch: &str,
130        path: &str,
131        start_line: u32,
132        end_line: u32,
133        content_hash: &str,
134    ) -> Self {
135        ContentHandle::new(
136            repo,
137            epoch,
138            HandleKind::Span,
139            format!("{path}:L{start_line}-L{end_line}"),
140            content_hash,
141        )
142    }
143
144    pub fn parse(s: &str) -> Result<Self, HandleError> {
145        let rest = s.strip_prefix("scc://").ok_or(HandleError::Malformed)?;
146        let (repo, rest) = rest.split_once('/').ok_or(HandleError::Malformed)?;
147        let (epoch, rest) = rest.split_once('/').ok_or(HandleError::Malformed)?;
148        let (kind_s, rest) = rest.split_once('/').ok_or(HandleError::Malformed)?;
149        let kind = HandleKind::parse(kind_s).ok_or(HandleError::UnknownKind)?;
150        let (key_enc, hash) = match rest.rsplit_once('@') {
151            Some((k, h)) => (k, h.to_string()),
152            None => (rest, String::new()),
153        };
154        if repo.is_empty() || epoch.is_empty() || key_enc.is_empty() {
155            return Err(HandleError::Malformed);
156        }
157        Ok(ContentHandle {
158            repo: percent_decode(repo),
159            epoch: epoch.to_string(),
160            kind,
161            key: percent_decode(key_enc),
162            content_hash: hash,
163        })
164    }
165
166    /// True when `actual_hash` matches the handle's freshness pin.
167    /// Identity-only handles (empty hash) never fail this check.
168    pub fn matches_content(&self, actual_hash: &str) -> bool {
169        self.content_hash.is_empty() || self.content_hash == actual_hash
170    }
171
172    pub fn refuse_if_stale(&self, actual_hash: &str) -> Result<(), HandleError> {
173        if self.matches_content(actual_hash) {
174            Ok(())
175        } else {
176            Err(HandleError::Stale)
177        }
178    }
179}
180
181impl fmt::Display for ContentHandle {
182    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
183        write!(
184            f,
185            "scc://{}/{}/{}/{}",
186            percent_encode(&self.repo),
187            self.epoch,
188            self.kind.as_str(),
189            percent_encode(&self.key)
190        )?;
191        if !self.content_hash.is_empty() {
192            write!(f, "@{}", self.content_hash)?;
193        }
194        Ok(())
195    }
196}
197
198/// FNV-1a 64-bit, hex (16 chars). Stable across processes; used for
199/// handle freshness, not cryptographic integrity.
200pub fn fnv1a64_hex(bytes: &[u8]) -> String {
201    const OFFSET: u64 = 0xcbf29ce484222325;
202    const PRIME: u64 = 0x100000001b3;
203    let mut h = OFFSET;
204    for b in bytes {
205        h ^= u64::from(*b);
206        h = h.wrapping_mul(PRIME);
207    }
208    format!("{h:016x}")
209}
210
211fn percent_encode(s: &str) -> String {
212    let mut out = String::with_capacity(s.len());
213    for b in s.bytes() {
214        match b {
215            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
216                out.push(b as char);
217            }
218            _ => out.push_str(&format!("%{b:02X}")),
219        }
220    }
221    out
222}
223
224fn percent_decode(s: &str) -> String {
225    let bytes = s.as_bytes();
226    let mut out = Vec::with_capacity(bytes.len());
227    let mut i = 0;
228    while i < bytes.len() {
229        if bytes[i] == b'%' && i + 2 < bytes.len() {
230            if let (Some(hi), Some(lo)) = (from_hex(bytes[i + 1]), from_hex(bytes[i + 2])) {
231                out.push((hi << 4) | lo);
232                i += 3;
233                continue;
234            }
235        }
236        out.push(bytes[i]);
237        i += 1;
238    }
239    String::from_utf8_lossy(&out).into_owned()
240}
241
242fn from_hex(b: u8) -> Option<u8> {
243    match b {
244        b'0'..=b'9' => Some(b - b'0'),
245        b'a'..=b'f' => Some(b - b'a' + 10),
246        b'A'..=b'F' => Some(b - b'A' + 10),
247        _ => None,
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    // trace:v1 id=test.scc.core.handle-roundtrip verifies=REQ-stable-content-handles exercises=impl.scc.core.content-handle
257    fn roundtrip_is_stable_and_path_aware() {
258        let h = ContentHandle::for_symbol(
259            "repo",
260            "epoch1",
261            "src/foo.ts",
262            "Class::method",
263            "deadbeefcafebabe",
264        );
265        let s = h.to_string();
266        assert!(s.starts_with("scc://"), "{s}");
267        assert!(!s.contains("foo.ts::Class") || s.contains("src"), "{s}");
268        let parsed = ContentHandle::parse(&s).unwrap();
269        assert_eq!(parsed, h);
270        assert_eq!(parsed.key, "src/foo.ts::Class::method");
271    }
272
273    #[test]
274    fn basename_only_is_not_the_identity() {
275        let a = ContentHandle::for_symbol("r", "e", "a/foo.rs", "n", "1");
276        let b = ContentHandle::for_symbol("r", "e", "b/foo.rs", "n", "1");
277        assert_ne!(a.to_string(), b.to_string());
278    }
279
280    #[test]
281    fn stale_hash_refuses() {
282        let h = ContentHandle::for_file("r", "e", "x.rs", "aaaaaaaaaaaaaaaa");
283        assert_eq!(h.refuse_if_stale("bbbbbbbbbbbbbbbb"), Err(HandleError::Stale));
284        assert!(h.refuse_if_stale("aaaaaaaaaaaaaaaa").is_ok());
285        let open = ContentHandle::for_file("r", "e", "x.rs", "");
286        assert!(open.refuse_if_stale("anything").is_ok());
287    }
288
289    #[test]
290    fn malformed_refuses() {
291        assert_eq!(ContentHandle::parse("not-a-handle"), Err(HandleError::Malformed));
292        assert_eq!(ContentHandle::parse("scc://"), Err(HandleError::Malformed));
293    }
294
295    #[test]
296    fn fnv_is_deterministic() {
297        assert_eq!(fnv1a64_hex(b"abc"), fnv1a64_hex(b"abc"));
298        assert_ne!(fnv1a64_hex(b"abc"), fnv1a64_hex(b"abd"));
299    }
300}