Skip to main content

heddle_object_model/object/thread_replication/
capture_visibility.rs

1//! Privacy declarations travel inside the original signed source result.
2//! A courier cannot drop local declarations while retaining its author proof.
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6use super::Capture;
7use crate::{
8    error::{HeddleError, Result},
9    object::{EntryVisibility, EntryVisibilityEntry, State, VisibilityTier},
10};
11
12/// An authored visibility floor, independent of Thread sync and audience policy.
13/// Entry commitments bind to this capture's tree; unchanged commitments also
14/// inherit ancestor overrides when a reader evaluates the lineage.
15#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(deny_unknown_fields)]
17pub struct CaptureVisibility {
18    /// No explicit tier uses the Spool's applicable default.
19    pub state: Option<VisibilityTier>,
20    /// Advisory only. A later authorized transition releases an embargo.
21    pub embargo_until: Option<DateTime<Utc>>,
22    /// Sorted, unique `(tree_id, leaf_hash)` overrides for this source tree.
23    pub entries: Vec<EntryVisibilityEntry>,
24}
25
26impl CaptureVisibility {
27    /// Validate bounded canonical declarations. Source publication additionally
28    /// proves each named tree and salted leaf belongs to the selected closure.
29    pub fn validate(&self, source: &State) -> Result<()> {
30        if (self.state.is_none() && self.entries.is_empty())
31            || (self.embargo_until.is_some() && self.state.is_none())
32            || self.entries.len() > 4096
33        {
34            return Err(invalid("empty or oversized capture visibility"));
35        }
36        for tier in self
37            .state
38            .iter()
39            .chain(self.entries.iter().map(|entry| &entry.tier))
40        {
41            match tier {
42                VisibilityTier::TeamScoped { team_id: label }
43                | VisibilityTier::Restricted { scope_label: label }
44                | VisibilityTier::Private { scope_label: label }
45                    if label.trim().is_empty()
46                        || label.len() > 256
47                        || label.chars().any(char::is_control) =>
48                {
49                    return Err(invalid("invalid capture visibility label"));
50                }
51                _ => {}
52            }
53        }
54        let canonical = EntryVisibility::new(source.change_id, source.tree, self.entries.clone())
55            .map_err(invalid)?;
56        if canonical.entries != self.entries {
57            return Err(invalid("non-canonical capture entry visibility"));
58        }
59        Ok(())
60    }
61
62    /// Reconstruct the existing sidecar from the source identity covered by the
63    /// same signature. No caller-supplied change or root can substitute for it.
64    pub fn entry_sidecar(&self, source: &State) -> Result<Option<EntryVisibility>> {
65        self.validate(source)?;
66        if self.entries.is_empty() {
67            return Ok(None);
68        }
69        EntryVisibility::new(source.change_id, source.tree, self.entries.clone())
70            .map(Some)
71            .map_err(invalid)
72    }
73}
74
75impl Capture {
76    /// Decode canonical source and validate every signed source-result binding.
77    pub fn validated_state(&self) -> Result<State> {
78        let state = State::decode_current_msgpack(&self.state)?;
79        if state.encode_current_msgpack()? != self.state {
80            return Err(invalid("non-canonical capture"));
81        }
82        if let Some(visibility) = &self.visibility {
83            visibility.validate(&state)?;
84        }
85        Ok(state)
86    }
87}
88
89fn invalid(error: impl std::fmt::Display) -> HeddleError {
90    HeddleError::InvalidObject(error.to_string())
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use crate::object::{Attribution, ContentHash, Principal, Tree};
97
98    fn fixture() -> Capture {
99        let state = State::new_snapshot(
100            Tree::new().hash(),
101            vec![],
102            Attribution::human(Principal::new("owner", "")),
103        );
104        let mut capture: Capture = state.encode_current_msgpack().expect("state").into();
105        capture.visibility = Some(CaptureVisibility {
106            state: Some(VisibilityTier::Private {
107                scope_label: "security".into(),
108            }),
109            embargo_until: None,
110            entries: vec![EntryVisibilityEntry {
111                tree_id: state.tree,
112                leaf_hash: ContentHash::from_bytes([3; 32]),
113                tier: VisibilityTier::Internal,
114            }],
115        });
116        capture
117    }
118
119    #[test]
120    fn signed_capture_visibility_is_canonical_bounded_and_subject_derived() {
121        let original = fixture();
122        let source = original.validated_state().expect("valid capture");
123        let sidecar = original
124            .visibility
125            .as_ref()
126            .expect("privacy")
127            .entry_sidecar(&source)
128            .expect("validate")
129            .expect("entries");
130        assert_eq!(sidecar.change_id, source.change_id);
131        assert_eq!(sidecar.tree_root, source.tree);
132        let bytes = rmp_serde::to_vec_named(&original).expect("encode");
133        let decoded: Capture = rmp_serde::from_slice(&bytes).expect("decode");
134        assert_eq!(decoded, original);
135        for bad in 0..4 {
136            let mut changed = original.clone();
137            let privacy = changed.visibility.as_mut().expect("privacy");
138            match bad {
139                0 => privacy.entries.push(privacy.entries[0].clone()),
140                1 => {
141                    privacy.state = Some(VisibilityTier::Private {
142                        scope_label: "".into(),
143                    })
144                }
145                2 => privacy.entries = vec![privacy.entries[0].clone(); 4097],
146                _ => {
147                    privacy.entries.clear();
148                    privacy.state = None;
149                }
150            }
151            assert!(changed.validated_state().is_err(), "invalid case {bad}");
152        }
153    }
154}