Skip to main content

heddle_object_model/object/source_target/
capture.rs

1//! Per-Thread derived source-target snapshot, committed by the signed capture.
2use std::collections::BTreeMap;
3
4use serde::{Deserialize, Serialize};
5
6use super::{SourceFileCore, SourceSelector, SourceTargetCore};
7use crate::{
8    error::{HeddleError, Result},
9    object::{
10        CollaborationScope, ContentHash, ObjectSource, StateId,
11        source_target_map::{MapBudget, SourceTargetMap, SourceTargetMapStore},
12    },
13};
14
15pub const MAX_REFERENCE_OBJECTS: usize = 65_536;
16pub const MAX_REFERENCE_BYTES: usize = 32 * 1024 * 1024;
17pub const MAX_REFERENCE_OBJECT_BYTES: usize = 64 * 1024;
18
19#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(deny_unknown_fields)]
21pub struct SourceTargetSnapshot {
22    pub version: u16,
23    pub scope: CollaborationScope,
24    pub state: StateId,
25    /// Commitment to the exact sorted collaboration heads at capture. Keeping
26    /// individual private operation IDs out of source-only publication avoids
27    /// revealing withheld discussion membership.
28    pub collaboration_frontier: ContentHash,
29    pub files: Option<ContentHash>,
30    pub targets: Option<ContentHash>,
31}
32/// Expected descriptor address/scope derived from an already verified signed
33/// capture. Supplying this tuple alone does not establish author authority.
34pub struct ReferenceProof {
35    pub descriptor: ContentHash,
36    pub scope: CollaborationScope,
37    pub state: StateId,
38}
39#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum ResolutionStatus {
42    Resolved,
43    Ambiguous,
44    Deleted,
45}
46#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(deny_unknown_fields)]
48pub struct FileResolution {
49    pub core: SourceFileCore,
50    pub path: String,
51    pub blob: Option<ContentHash>,
52    pub status: ResolutionStatus,
53}
54#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(deny_unknown_fields)]
56pub struct TargetResolution {
57    pub core: SourceTargetCore,
58    pub selector: SourceSelector,
59    pub status: ResolutionStatus,
60}
61#[derive(Clone, Debug, PartialEq, Eq)]
62pub struct ResolvedSourceTarget {
63    pub scope: CollaborationScope,
64    pub state: StateId,
65    pub file: FileResolution,
66    pub target: TargetResolution,
67}
68pub fn invalid(message: impl std::fmt::Display) -> HeddleError {
69    HeddleError::InvalidObject(message.to_string())
70}
71pub fn encode(value: &impl Serialize) -> Result<Vec<u8>> {
72    Ok(rmp_serde::to_vec_named(value)?)
73}
74pub fn decode<T: serde::de::DeserializeOwned + Serialize>(bytes: &[u8]) -> Result<T> {
75    if bytes.len() > MAX_REFERENCE_OBJECT_BYTES {
76        return Err(invalid("reference object exceeds budget"));
77    }
78    let value: T = rmp_serde::from_slice(bytes)?;
79    if encode(&value)? != bytes {
80        return Err(invalid("noncanonical reference object"));
81    }
82    Ok(value)
83}
84impl SourceTargetSnapshot {
85    pub fn validate(&self, scope: &CollaborationScope, state: StateId) -> Result<()> {
86        if self.version != 1
87            || &self.scope != scope
88            || self.scope.spool.is_nil()
89            || self.scope.thread.is_none()
90            || self.state != state
91        {
92            return Err(invalid(
93                "reference snapshot scope, revision, or frontier mismatch",
94            ));
95        }
96        Ok(())
97    }
98}
99/// Verified closure includes map nodes and resolution records, but does not
100/// follow original source revisions or named references into private history.
101pub struct ReferenceClosure {
102    pub snapshot: SourceTargetSnapshot,
103    pub blobs: BTreeMap<ContentHash, Vec<u8>>,
104    pub files: BTreeMap<ContentHash, FileResolution>,
105    pub targets: BTreeMap<ContentHash, TargetResolution>,
106}
107
108/// Read one target and its file binding from a signed descriptor. Unlike
109/// [`closure`], this walks only the two bounded trie routes and does not require
110/// unrelated targets to be retained locally. The caller supplies an admitted
111/// descriptor proof and independently authorizes any returned coordinates.
112pub fn resolve_target(
113    source: &impl ObjectSource,
114    descriptor: ContentHash,
115    scope: &CollaborationScope,
116    state: StateId,
117    target_id: ContentHash,
118) -> Result<Option<ResolvedSourceTarget>> {
119    let mut reader = Reader {
120        source,
121        blobs: BTreeMap::new(),
122        bytes: 0,
123    };
124    let snapshot: SourceTargetSnapshot =
125        decode(&reader.blob(descriptor, MAX_REFERENCE_OBJECT_BYTES)?)?;
126    snapshot.validate(scope, state)?;
127    // A 256-bit key uses at most 52 five-bit branches plus its leaf. Two
128    // independent maps bound work regardless of their total cardinality.
129    let mut budget = MapBudget::new(
130        106,
131        106 * crate::object::source_target_map::MAX_NODE_BYTES,
132        0,
133        0,
134    );
135    let Some(target_hash) =
136        SourceTargetMap::get(&mut reader, snapshot.targets, target_id, &mut budget)
137            .map_err(invalid)?
138    else {
139        return Ok(None);
140    };
141    let target: TargetResolution = decode(&reader.blob(target_hash, MAX_REFERENCE_OBJECT_BYTES)?)?;
142    if target.core.id().map_err(invalid)? != target_id {
143        return Err(invalid("source target core identity mismatch"));
144    }
145    let Some(file_hash) =
146        SourceTargetMap::get(&mut reader, snapshot.files, target.core.file, &mut budget)
147            .map_err(invalid)?
148    else {
149        return Err(invalid("source target file binding missing"));
150    };
151    let file: FileResolution = decode(&reader.blob(file_hash, MAX_REFERENCE_OBJECT_BYTES)?)?;
152    if file.core.id().map_err(invalid)? != target.core.file || file.core.scope.spool != scope.spool
153    {
154        return Err(invalid("source file core identity or Spool mismatch"));
155    }
156    let mut current_file = file.core.clone();
157    current_file.path = file.path.clone();
158    current_file.id().map_err(invalid)?;
159    let mut current_target = target.core.clone();
160    current_target.selector = target.selector.clone();
161    current_target.id().map_err(invalid)?;
162    Ok(Some(ResolvedSourceTarget {
163        scope: snapshot.scope,
164        state,
165        file,
166        target,
167    }))
168}
169struct Reader<'a, S> {
170    source: &'a S,
171    blobs: BTreeMap<ContentHash, Vec<u8>>,
172    bytes: usize,
173}
174impl<S: ObjectSource> Reader<'_, S> {
175    fn blob(&mut self, hash: ContentHash, limit: usize) -> Result<Vec<u8>> {
176        if let Some(bytes) = self.blobs.get(&hash) {
177            if bytes.len() > limit {
178                return Err(invalid("reference read budget"));
179            }
180            return Ok(bytes.clone());
181        }
182        if self.blobs.len() >= MAX_REFERENCE_OBJECTS {
183            return Err(invalid("reference object count budget"));
184        }
185        let allowed = limit
186            .min(MAX_REFERENCE_OBJECT_BYTES)
187            .min(MAX_REFERENCE_BYTES.saturating_sub(self.bytes));
188        if self
189            .source
190            .decoded_blob_len(&hash)?
191            .is_none_or(|len| len > allowed as u64)
192        {
193            return Err(invalid("reference blob missing or exceeds budget"));
194        }
195        let blob = self
196            .source
197            .get_blob(&hash)?
198            .ok_or_else(|| invalid("reference blob missing"))?;
199        let bytes = blob.into_content();
200        if bytes.len() > allowed || ContentHash::compute_typed("blob", &bytes) != hash {
201            return Err(invalid("reference blob identity or budget mismatch"));
202        }
203        self.bytes += bytes.len();
204        self.blobs.insert(hash, bytes.clone());
205        Ok(bytes)
206    }
207}
208impl<S: ObjectSource> SourceTargetMapStore for Reader<'_, S> {
209    type Error = HeddleError;
210    fn read(&mut self, hash: ContentHash, max: usize) -> Result<Option<Vec<u8>>> {
211        self.blob(hash, max).map(Some)
212    }
213    fn write(&mut self, _: ContentHash, _: Vec<u8>) -> Result<()> {
214        Err(invalid("read-only reference closure"))
215    }
216}
217pub fn closure(
218    source: &impl ObjectSource,
219    descriptor: ContentHash,
220    scope: &CollaborationScope,
221    state: StateId,
222) -> Result<ReferenceClosure> {
223    let mut reader = Reader {
224        source,
225        blobs: BTreeMap::new(),
226        bytes: 0,
227    };
228    let snapshot: SourceTargetSnapshot =
229        decode(&reader.blob(descriptor, MAX_REFERENCE_OBJECT_BYTES)?)?;
230    snapshot.validate(scope, state)?;
231    let mut budget = MapBudget::new(MAX_REFERENCE_OBJECTS, MAX_REFERENCE_BYTES, 0, 0);
232    let file_entries = SourceTargetMap::entries(
233        &mut reader,
234        snapshot.files,
235        MAX_REFERENCE_OBJECTS,
236        &mut budget,
237    )
238    .map_err(invalid)?;
239    let target_entries = SourceTargetMap::entries(
240        &mut reader,
241        snapshot.targets,
242        MAX_REFERENCE_OBJECTS,
243        &mut budget,
244    )
245    .map_err(invalid)?;
246    let mut files = BTreeMap::new();
247    for (id, hash) in file_entries {
248        let file: FileResolution = decode(&reader.blob(hash, MAX_REFERENCE_OBJECT_BYTES)?)?;
249        if file.core.id().map_err(invalid)? != id || file.core.scope.spool != scope.spool {
250            return Err(invalid("source file core identity or Spool mismatch"));
251        }
252        // Reuse the canonical path validator without claiming new coordinates
253        // change the original core's identity.
254        let mut current = file.core.clone();
255        current.path = file.path.clone();
256        current.id().map_err(invalid)?;
257        files.insert(id, file);
258    }
259    let mut targets = BTreeMap::new();
260    for (id, hash) in target_entries {
261        let target: TargetResolution = decode(&reader.blob(hash, MAX_REFERENCE_OBJECT_BYTES)?)?;
262        if target.core.id().map_err(invalid)? != id || !files.contains_key(&target.core.file) {
263            return Err(invalid("source target identity or file closure mismatch"));
264        }
265        let mut current = target.core.clone();
266        current.selector = target.selector.clone();
267        current.id().map_err(invalid)?;
268        targets.insert(id, target);
269    }
270    Ok(ReferenceClosure {
271        snapshot,
272        blobs: reader.blobs,
273        files,
274        targets,
275    })
276}