heddle_object_model/object/thread_replication/
capture_visibility.rs1use 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(deny_unknown_fields)]
17pub struct CaptureVisibility {
18 pub state: Option<VisibilityTier>,
20 pub embargo_until: Option<DateTime<Utc>>,
22 pub entries: Vec<EntryVisibilityEntry>,
24}
25
26impl CaptureVisibility {
27 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 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 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}