Skip to main content

heddle_pack/store/pack/
source_pack.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Exact source closure validation, shared by device and hosted publication.
3use std::{
4    collections::{BTreeMap, BTreeSet},
5    io::{Read, Seek, Write},
6};
7
8use super::{ObjectType, PackObjectId, PackReader, PackStats, StreamingPackBuilder, SyncData};
9use crate::{
10    object::{
11        ContentHash, EntryRedactions, ObjectSource, PartialTree, State, Tree, TreeEntryTarget,
12    },
13    store::{Result, StoreError},
14};
15
16pub(super) fn validate(
17    reader: &PackReader<'_>,
18    selected: &State,
19    max_decoded_bytes: u64,
20    references: &[crate::object::source_target::capture::ReferenceProof],
21    visibility: Option<&crate::object::thread_replication::CaptureVisibility>,
22) -> Result<Vec<PackObjectId>> {
23    Ok(validate_disclosure(
24        reader,
25        selected,
26        max_decoded_bytes,
27        references,
28        visibility,
29        false,
30    )?
31    .objects)
32}
33
34/// Verified visible closure. Partial trees retain their original Merkle root
35/// and must be installed in the partial-tree cache, never the full tree store.
36pub struct VisibleSourceClosure {
37    /// Exact selected State and all visible tree/blob object identities.
38    pub objects: Vec<PackObjectId>,
39    /// Verified Merkle proofs whose hidden leaves remain unavailable.
40    pub partial_trees: Vec<PartialTree>,
41}
42
43pub(super) fn validate_disclosure(
44    reader: &PackReader<'_>,
45    selected: &State,
46    max_decoded_bytes: u64,
47    references: &[crate::object::source_target::capture::ReferenceProof],
48    visibility: Option<&crate::object::thread_replication::CaptureVisibility>,
49    allow_partial: bool,
50) -> Result<VisibleSourceClosure> {
51    let canonical = selected.encode_current_msgpack()?;
52    let mut available = BTreeMap::new();
53    let mut trees = BTreeMap::new();
54    let mut partial_trees = Vec::new();
55    let mut decoded = 0_u64;
56    reader.visit_objects(|id, kind, data| {
57        decoded = decoded
58            .checked_add(data.len() as u64)
59            .ok_or_else(|| invalid("source pack size overflow"))?;
60        if decoded > max_decoded_bytes {
61            return Err(invalid("source pack decoded byte budget exceeded"));
62        }
63        if available.insert(id, kind).is_some() {
64            return Err(invalid("duplicate source pack object"));
65        }
66        match (id, kind) {
67            (PackObjectId::StateId(id), ObjectType::State)
68                if id == selected.id() && data == canonical => {}
69            (PackObjectId::Hash(hash), ObjectType::Blob)
70                if ContentHash::compute_typed("blob", data) == hash => {}
71            (PackObjectId::Hash(hash), ObjectType::Tree) => {
72                if allow_partial && crate::object::is_redacted_tree(data) {
73                    let partial = crate::object::decode_redacted_projection(data)?;
74                    partial.verify()?;
75                    if partial.declared_root() != hash || !partial.has_redactions() {
76                        return Err(invalid(
77                            "partial source tree differs from its address or is complete",
78                        ));
79                    }
80                    trees.insert(hash, partial.visible_tree()?);
81                    partial_trees.push(partial);
82                    return Ok(());
83                }
84                // Full tree anchors avoid pulling a private historical delta
85                // base into a selected revision's disclosure closure.
86                let tree = Tree::decode_canonical(data)
87                    .map_err(|_| invalid("source pack requires complete canonical tree anchors"))?;
88                if tree.hash() != hash {
89                    return Err(invalid("source tree hash differs from its address"));
90                }
91                trees.insert(hash, tree);
92            }
93            _ => {
94                return Err(invalid(
95                    "source pack contains an unselected or incorrectly addressed object",
96                ));
97            }
98        }
99        Ok(())
100    })?;
101    let mut visited = BTreeSet::new();
102    let mut pending = vec![
103        (PackObjectId::StateId(selected.id()), ObjectType::State),
104        (PackObjectId::Hash(selected.tree), ObjectType::Tree),
105    ];
106    while let Some((id, kind)) = pending.pop() {
107        if available.get(&id) != Some(&kind) {
108            return Err(invalid("source closure is incomplete"));
109        }
110        if !visited.insert(id) {
111            continue;
112        }
113        if kind == ObjectType::Tree {
114            let PackObjectId::Hash(hash) = id else {
115                return Err(invalid("tree address is not a content hash"));
116            };
117            let tree = trees
118                .get(&hash)
119                .ok_or_else(|| invalid("source tree unavailable"))?;
120            for (hash, kind) in children(tree) {
121                pending.push((PackObjectId::Hash(hash), kind));
122            }
123        }
124    }
125    if let Some(visibility) = visibility {
126        visibility.validate(selected)?;
127        // Reuse the decoded, closure-checked trees. Compute leaves once per
128        // mentioned tree, not once per override, and never consult ambient storage.
129        let mut leaves = BTreeMap::<ContentHash, BTreeSet<ContentHash>>::new();
130        for entry in &visibility.entries {
131            if !visited.contains(&PackObjectId::Hash(entry.tree_id)) {
132                return Err(invalid("entry visibility tree is outside selected source"));
133            }
134            let tree = trees
135                .get(&entry.tree_id)
136                .ok_or_else(|| invalid("entry visibility subject is not a tree"))?;
137            let actual = leaves.entry(entry.tree_id).or_insert_with(|| {
138                (0..tree.entries().len())
139                    .filter_map(|index| tree.v4_leaf_hash_at(index))
140                    .collect()
141            });
142            if !actual.contains(&entry.leaf_hash) {
143                return Err(invalid(
144                    "entry visibility leaf is absent from selected salted tree",
145                ));
146            }
147        }
148    }
149    for reference in references {
150        let closure = crate::object::source_target::capture::closure(
151            &ReferenceReader(reader),
152            reference.descriptor,
153            &reference.scope,
154            reference.state,
155        )?;
156        for hash in closure.blobs.keys() {
157            let id = PackObjectId::Hash(*hash);
158            if available.get(&id) != Some(&ObjectType::Blob) {
159                return Err(invalid("reference closure is incomplete"));
160            }
161            visited.insert(id);
162        }
163    }
164    if visited.len() != available.len() {
165        return Err(invalid(
166            "source pack contains objects outside the selected revision",
167        ));
168    }
169    Ok(VisibleSourceClosure {
170        objects: visited.into_iter().collect(),
171        partial_trees,
172    })
173}
174fn invalid(message: &str) -> StoreError {
175    StoreError::InvalidObject(message.into())
176}
177
178/// Build an exact selected-source pack without consulting State history,
179/// provenance, attachments, or linked spools. Full canonical trees keep private
180/// delta bases out of the pack. The caller owns revision authorization and the
181/// temporary output directory; discard that directory on failure.
182///
183/// Object bytes stream into the builder one object at a time. Blob header
184/// lengths are checked before reading where the source supports that probe.
185/// No object repository, CLI, network, or async runtime dependency is required.
186pub fn build_source_pack<W: Write + Read + Seek + SyncData>(
187    builder: StreamingPackBuilder<W>,
188    source: &impl ObjectSource,
189    selected: &State,
190    max_objects: usize,
191    max_decoded_bytes: u64,
192) -> Result<(W, PackStats)> {
193    build_source_pack_with_references(
194        builder,
195        source,
196        selected,
197        &[],
198        max_objects,
199        max_decoded_bytes,
200    )
201}
202
203pub fn build_source_pack_with_references<W: Write + Read + Seek + SyncData>(
204    builder: StreamingPackBuilder<W>,
205    source: &impl ObjectSource,
206    selected: &State,
207    references: &[crate::object::source_target::capture::ReferenceProof],
208    max_objects: usize,
209    max_decoded_bytes: u64,
210) -> Result<(W, PackStats)> {
211    build_disclosure(
212        builder,
213        source,
214        selected,
215        references,
216        None,
217        max_objects,
218        max_decoded_bytes,
219    )
220    .map(|(output, stats, _)| (output, stats))
221}
222
223/// Build only source bytes visible under an already-admitted entry projection.
224/// Hidden directories are not traversed; their salted commitments prove the
225/// original tree root without names or targets. Reference descriptor closures
226/// are included only when no selected entry is hidden. The final boolean is
227/// true when the selected source and reference closure is complete.
228pub fn build_visible_source_pack<W: Write + Read + Seek + SyncData>(
229    builder: StreamingPackBuilder<W>,
230    source: &impl ObjectSource,
231    selected: &State,
232    references: &[crate::object::source_target::capture::ReferenceProof],
233    redactions: &EntryRedactions,
234    max_objects: usize,
235    max_decoded_bytes: u64,
236) -> Result<(W, PackStats, bool)> {
237    build_disclosure(
238        builder,
239        source,
240        selected,
241        references,
242        Some(redactions),
243        max_objects,
244        max_decoded_bytes,
245    )
246}
247
248fn build_disclosure<W: Write + Read + Seek + SyncData>(
249    mut builder: StreamingPackBuilder<W>,
250    source: &impl ObjectSource,
251    selected: &State,
252    references: &[crate::object::source_target::capture::ReferenceProof],
253    redactions: Option<&EntryRedactions>,
254    max_objects: usize,
255    max_decoded_bytes: u64,
256) -> Result<(W, PackStats, bool)> {
257    if max_objects < 2 {
258        return Err(invalid("source pack object budget exceeded"));
259    }
260    let canonical = selected.encode_current_msgpack()?;
261    let mut decoded = 0_u64;
262    charge_bytes(&mut decoded, canonical.len() as u64, max_decoded_bytes)?;
263    builder.add_id(
264        PackObjectId::StateId(selected.id()),
265        ObjectType::State,
266        &canonical,
267    )?;
268    let mut discovered = BTreeMap::from([(selected.tree, ObjectType::Tree)]);
269    let mut partial = false;
270    let mut pending = discovered.clone();
271    while let Some((hash, kind)) = pending.pop_first() {
272        match kind {
273            ObjectType::Tree => {
274                let tree = source
275                    .get_tree(&hash)?
276                    .ok_or_else(|| invalid("selected source tree is missing"))?;
277                if tree.hash() != hash {
278                    return Err(invalid("source tree differs from its address"));
279                }
280                let full = tree.encode_canonical()?;
281                // Charge examined bytes, including hidden entries, so redaction
282                // cannot turn a bounded disclosure into an unbounded tree walk.
283                charge_bytes(&mut decoded, full.len() as u64, max_decoded_bytes)?;
284                let (canonical, visible_tree) = match redactions {
285                    Some(redactions)
286                        if (0..tree.entries().len())
287                            .any(|index| !redactions.entry_visible(&tree, index)) =>
288                    {
289                        partial = true;
290                        let partial = PartialTree::project(&tree, redactions.leaves())?;
291                        (
292                            crate::object::encode_redacted_projection(&partial)?,
293                            partial.visible_tree()?,
294                        )
295                    }
296                    _ => (full, tree),
297                };
298                for (hash, kind) in children(&visible_tree) {
299                    if let Some(expected) = discovered.get(&hash) {
300                        if *expected != kind {
301                            return Err(invalid(
302                                "source object is referenced with conflicting types",
303                            ));
304                        }
305                        continue;
306                    }
307                    // The selected State occupies one slot beyond this set.
308                    // Check before queuing or reading the excess object.
309                    if discovered.len().saturating_add(1) >= max_objects {
310                        return Err(invalid("source pack object budget exceeded"));
311                    }
312                    discovered.insert(hash, kind);
313                    pending.insert(hash, kind);
314                }
315                builder.add_id(PackObjectId::Hash(hash), ObjectType::Tree, &canonical)?;
316            }
317            ObjectType::Blob => {
318                let length = source
319                    .decoded_blob_len(&hash)?
320                    .ok_or_else(|| invalid("selected source blob is missing"))?;
321                if length > max_decoded_bytes.saturating_sub(decoded) {
322                    return Err(invalid("source pack decoded byte budget exceeded"));
323                }
324                let bytes = source
325                    .get_blob_bytes(&hash)?
326                    .ok_or_else(|| invalid("selected source blob is missing"))?;
327                if bytes.len() as u64 != length
328                    || ContentHash::compute_typed("blob", &bytes) != hash
329                {
330                    return Err(invalid(
331                        "source blob differs from its address or declared size",
332                    ));
333                }
334                charge_bytes(&mut decoded, length, max_decoded_bytes)?;
335                builder.add_id(PackObjectId::Hash(hash), ObjectType::Blob, bytes)?;
336            }
337            _ => return Err(invalid("unexpected source object type")),
338        }
339    }
340    if !partial {
341        for reference in references {
342            let closure = crate::object::source_target::capture::closure(
343                source,
344                reference.descriptor,
345                &reference.scope,
346                reference.state,
347            )?;
348            for (hash, bytes) in closure.blobs {
349                if let Some(kind) = discovered.get(&hash) {
350                    if *kind != ObjectType::Blob {
351                        return Err(invalid("reference object type conflict"));
352                    }
353                    continue;
354                }
355                if discovered.len().saturating_add(1) >= max_objects {
356                    return Err(invalid("reference pack object budget exceeded"));
357                }
358                charge_bytes(&mut decoded, bytes.len() as u64, max_decoded_bytes)?;
359                discovered.insert(hash, ObjectType::Blob);
360                builder.add_id(PackObjectId::Hash(hash), ObjectType::Blob, bytes)?;
361            }
362        }
363    }
364    let (output, stats) = builder.finalize()?;
365    Ok((output, stats, !partial))
366}
367
368fn charge_bytes(decoded: &mut u64, length: u64, limit: u64) -> Result<()> {
369    *decoded = decoded
370        .checked_add(length)
371        .ok_or_else(|| invalid("source pack size overflow"))?;
372    if *decoded > limit {
373        return Err(invalid("source pack decoded byte budget exceeded"));
374    }
375    Ok(())
376}
377
378fn children(tree: &Tree) -> impl Iterator<Item = (ContentHash, ObjectType)> + '_ {
379    tree.entries()
380        .iter()
381        .filter_map(|entry| match entry.target() {
382            TreeEntryTarget::Tree { hash } => Some((*hash, ObjectType::Tree)),
383            TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => {
384                Some((*hash, ObjectType::Blob))
385            }
386            TreeEntryTarget::Gitlink { .. } | TreeEntryTarget::Spoollink { .. } => None,
387        })
388}
389
390struct ReferenceReader<'a, 'b>(&'a PackReader<'b>);
391impl ObjectSource for ReferenceReader<'_, '_> {
392    fn get_tree(&self, _: &ContentHash) -> Result<Option<Tree>> {
393        Err(invalid("reference closure must not read source trees"))
394    }
395    fn get_state(&self, _: &crate::object::StateId) -> Result<Option<State>> {
396        Err(invalid("reference closure must not read source history"))
397    }
398    fn get_blob(&self, hash: &ContentHash) -> Result<Option<crate::object::Blob>> {
399        match self.0.get_hashed_object(hash)? {
400            Some((ObjectType::Blob, bytes)) => Ok(Some(crate::object::Blob::new(bytes))),
401            None => Ok(None),
402            _ => Err(invalid("reference object must be a blob")),
403        }
404    }
405    fn decoded_blob_len(&self, hash: &ContentHash) -> Result<Option<u64>> {
406        self.0.get_hashed_object_size(hash)
407    }
408}