Skip to main content

heddle_object_model/object/collaboration/
requirements.rs

1//! Immutable source dependencies of an authored record. These are identities
2//! to authorize, never authority. Current target locations are a separate view.
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7use super::{
8    AnnotationTag, CollaborationAnchor, CollaborationCodecError, CollaborationRevision,
9    CollaborationScope, CollaborationSourceAnchor,
10};
11use crate::object::{
12    ChangeId, ContentHash, StateId,
13    source_target::SourceTargetBinding,
14    thread_replication::metadata::{Review, ReviewCoverage},
15};
16
17/// A distinct source permission required to emit an authored anchor or tag.
18/// Revision requirements include the original path, but not line coordinates:
19/// entry visibility is a property of the file, independent of its referrers.
20#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case", tag = "kind", deny_unknown_fields)]
22pub enum AuthoredSourceRequirement {
23    /// An explicitly named Thread must itself be readable.
24    Thread { scope: CollaborationScope },
25    /// An exact authored revision and, if nonempty, its file must be readable.
26    Revision {
27        scope: CollaborationScope,
28        revision: CollaborationRevision,
29        path: String,
30    },
31    /// A logical identity needs a separately authorized revision selection.
32    /// Absence of such a selection must not become an empty permission set.
33    Change {
34        scope: CollaborationScope,
35        change_id: ChangeId,
36    },
37    /// A Read attestation over the complete exact State requires that no
38    /// currently admitted entry in that source is withheld. An empty Revision
39    /// path checks only State visibility and must never represent this rule.
40    WholeSource {
41        scope: CollaborationScope,
42        state_id: StateId,
43    },
44}
45
46impl AuthoredSourceRequirement {
47    /// Stable derived index key. This does not alter any signed record bytes.
48    pub fn id(&self) -> Result<ContentHash, CollaborationCodecError> {
49        let bytes = rmp_serde::to_vec_named(self)
50            .map_err(|error| CollaborationCodecError::Encoding(error.to_string()))?;
51        Ok(ContentHash::compute_typed(
52            "heddle-authored-source-requirement-v1",
53            &bytes,
54        ))
55    }
56}
57
58/// Normalize the source dependencies of a signed anchor and its tags.
59///
60/// Repeated references to a file share a single predicate. ViewedThread targets
61/// retain their original source predicate; movement/deletion of the derived
62/// current target cannot silently remove readable history. Named and pinned
63/// bindings add their explicit Thread/revision, independently of that original.
64///
65/// This function covers source references only. Record audience, extracted
66/// discussion visibility, and other entity authorization remain separate gates.
67pub fn authored_source_requirements(
68    scope: &CollaborationScope,
69    anchor: &CollaborationAnchor,
70    tags: &[AnnotationTag],
71) -> Result<Vec<AuthoredSourceRequirement>, CollaborationCodecError> {
72    super::operation::validate_anchor(anchor)?;
73    super::validate_annotation_tags(tags)?;
74    let mut requirements = BTreeMap::new();
75    let mut insert = |value: AuthoredSourceRequirement| {
76        requirements.insert(value.id()?, value);
77        Ok::<_, CollaborationCodecError>(())
78    };
79    match anchor {
80        CollaborationAnchor::Repository => {}
81        CollaborationAnchor::Source { source } => source_requirements(scope, source, &mut insert)?,
82        CollaborationAnchor::State { state_id } => insert(AuthoredSourceRequirement::Revision {
83            scope: scope.clone(),
84            revision: CollaborationRevision::State {
85                state_id: *state_id,
86            },
87            path: String::new(),
88        })?,
89        CollaborationAnchor::Path { state_id, path }
90        | CollaborationAnchor::Symbol { state_id, path, .. } => {
91            insert(AuthoredSourceRequirement::Revision {
92                scope: scope.clone(),
93                revision: CollaborationRevision::State {
94                    state_id: *state_id,
95                },
96                path: path.clone(),
97            })?;
98        }
99        CollaborationAnchor::Change { change_id } => insert(AuthoredSourceRequirement::Change {
100            scope: scope.clone(),
101            change_id: *change_id,
102        })?,
103    }
104    for tag in tags {
105        if let AnnotationTag::Source { target }
106        | AnnotationTag::Symbol {
107            target: Some(target),
108            ..
109        } = tag
110        {
111            source_requirements(&target.scope, &target.source, &mut insert)?;
112        }
113    }
114    Ok(requirements.into_values().collect())
115}
116
117/// Source predicates of a signed review, independent of its current display
118/// location. The source and target scopes are explicit because a fork review
119/// can compare a source Thread with its independently shared parent Thread.
120pub fn authored_review_requirements(
121    source_scope: &CollaborationScope,
122    target_scope: &CollaborationScope,
123    review: &Review,
124) -> Result<Vec<AuthoredSourceRequirement>, CollaborationCodecError> {
125    if source_scope.spool != target_scope.spool
126        || source_scope.thread.is_none()
127        || target_scope.thread.is_none()
128    {
129        return Err(CollaborationCodecError::Invalid(
130            "review source and target require exact Threads in one Spool".into(),
131        ));
132    }
133    let mut requirements = BTreeMap::new();
134    let mut insert = |value: AuthoredSourceRequirement| {
135        requirements.insert(value.id()?, value);
136        Ok::<_, CollaborationCodecError>(())
137    };
138    insert(AuthoredSourceRequirement::Revision {
139        scope: source_scope.clone(),
140        revision: CollaborationRevision::State {
141            state_id: review.source,
142        },
143        path: String::new(),
144    })?;
145    insert(AuthoredSourceRequirement::Revision {
146        scope: target_scope.clone(),
147        revision: CollaborationRevision::State {
148            state_id: review.target,
149        },
150        path: String::new(),
151    })?;
152    match &review.coverage {
153        Some(ReviewCoverage::WholeSource) => insert(AuthoredSourceRequirement::WholeSource {
154            scope: source_scope.clone(),
155            state_id: review.source,
156        })?,
157        Some(ReviewCoverage::Symbols(anchors)) => {
158            if anchors.is_empty() || anchors.len() > 128 {
159                return Err(CollaborationCodecError::Invalid(
160                    "review symbol coverage exceeds bounds".into(),
161                ));
162            }
163            for anchor in anchors {
164                if anchor.file.is_empty()
165                    || anchor.file.len() > 4096
166                    || anchor.file.starts_with('/')
167                    || anchor
168                        .file
169                        .split('/')
170                        .any(|part| part.is_empty() || part == "." || part == "..")
171                {
172                    return Err(CollaborationCodecError::Invalid(
173                        "review symbol path must be relative and canonical".into(),
174                    ));
175                }
176                insert(AuthoredSourceRequirement::Revision {
177                    scope: source_scope.clone(),
178                    revision: CollaborationRevision::State {
179                        state_id: review.source,
180                    },
181                    path: anchor.file.clone(),
182                })?;
183            }
184        }
185        None => {}
186    }
187    Ok(requirements.into_values().collect())
188}
189
190fn source_requirements(
191    scope: &CollaborationScope,
192    source: &CollaborationSourceAnchor,
193    insert: &mut impl FnMut(AuthoredSourceRequirement) -> Result<(), CollaborationCodecError>,
194) -> Result<(), CollaborationCodecError> {
195    insert(AuthoredSourceRequirement::Revision {
196        scope: scope.clone(),
197        revision: source.revision.clone(),
198        path: source.path.clone(),
199    })?;
200    match source.target.as_ref().map(|target| &target.binding) {
201        None | Some(SourceTargetBinding::ViewedThread) => Ok(()),
202        Some(SourceTargetBinding::NamedThread { scope }) => {
203            insert(AuthoredSourceRequirement::Thread {
204                scope: scope.clone(),
205            })
206        }
207        Some(SourceTargetBinding::PinnedRevision { scope, revision }) => {
208            insert(AuthoredSourceRequirement::Revision {
209                scope: scope.clone(),
210                revision: revision.clone(),
211                path: String::new(),
212            })
213        }
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use crate::object::thread_replication::metadata::{ReviewKind, ReviewSymbolAnchor};
221
222    fn review(coverage: Option<ReviewCoverage>) -> Review {
223        Review {
224            id: uuid::Uuid::from_u128(1),
225            source: StateId::from_bytes([11; 32]),
226            target: StateId::from_bytes([12; 32]),
227            policy_version: ContentHash::from_bytes([13; 32]),
228            kind: ReviewKind::Read,
229            explanation: String::new(),
230            revokes: None,
231            expires_at_unix_seconds: None,
232            coverage,
233        }
234    }
235
236    #[test]
237    fn complete_read_requires_whole_source_in_addition_to_both_exact_revisions() {
238        let source = CollaborationScope {
239            spool: uuid::Uuid::from_u128(1),
240            thread: Some(ContentHash::from_bytes([1; 32])),
241        };
242        let target = CollaborationScope {
243            thread: Some(ContentHash::from_bytes([2; 32])),
244            ..source.clone()
245        };
246        let value = review(Some(ReviewCoverage::WholeSource));
247        let requirements = authored_review_requirements(&source, &target, &value)
248            .expect("exact review requirements");
249        assert_eq!(requirements.len(), 3);
250        assert!(
251            requirements.contains(&AuthoredSourceRequirement::WholeSource {
252                scope: source.clone(),
253                state_id: value.source,
254            })
255        );
256        assert!(requirements.contains(&AuthoredSourceRequirement::Revision {
257            scope: target.clone(),
258            revision: CollaborationRevision::State {
259                state_id: value.target
260            },
261            path: String::new(),
262        }));
263        assert_ne!(
264            AuthoredSourceRequirement::WholeSource {
265                scope: source.clone(),
266                state_id: value.source
267            }
268            .id()
269            .expect("whole source key"),
270            AuthoredSourceRequirement::Revision {
271                scope: source,
272                revision: CollaborationRevision::State {
273                    state_id: value.source
274                },
275                path: String::new(),
276            }
277            .id()
278            .expect("state key"),
279        );
280    }
281
282    #[test]
283    fn symbol_read_requires_each_original_path_and_rejects_unscoped_reviews() {
284        let scope = CollaborationScope {
285            spool: uuid::Uuid::from_u128(1),
286            thread: Some(ContentHash::from_bytes([1; 32])),
287        };
288        let value = review(Some(ReviewCoverage::Symbols(vec![
289            ReviewSymbolAnchor {
290                file: "src/main.rs".into(),
291                symbol: "run".into(),
292            },
293            ReviewSymbolAnchor {
294                file: "src/main.rs".into(),
295                symbol: "main".into(),
296            },
297            ReviewSymbolAnchor {
298                file: "src/lib.rs".into(),
299                symbol: "parse".into(),
300            },
301        ])));
302        let requirements = authored_review_requirements(&scope, &scope, &value)
303            .expect("exact symbol requirements");
304        assert_eq!(
305            requirements.len(),
306            4,
307            "two revisions and two distinct paths"
308        );
309        assert!(requirements.contains(&AuthoredSourceRequirement::Revision {
310            scope: scope.clone(),
311            revision: CollaborationRevision::State {
312                state_id: value.source
313            },
314            path: "src/main.rs".into(),
315        }));
316        assert!(
317            authored_review_requirements(
318                &CollaborationScope {
319                    thread: None,
320                    ..scope.clone()
321                },
322                &scope,
323                &value
324            )
325            .is_err()
326        );
327        assert!(
328            authored_review_requirements(
329                &scope,
330                &CollaborationScope {
331                    spool: uuid::Uuid::from_u128(2),
332                    ..scope.clone()
333                },
334                &value
335            )
336            .is_err()
337        );
338    }
339}