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 Some(target) = tag.source() {
106            source_requirements(&target.scope, &target.source, &mut insert)?;
107        }
108    }
109    Ok(requirements.into_values().collect())
110}
111
112/// Source predicates of a signed review, independent of its current display
113/// location. The source and target scopes are explicit because a fork review
114/// can compare a source Thread with its independently shared parent Thread.
115pub fn authored_review_requirements(
116    source_scope: &CollaborationScope,
117    target_scope: &CollaborationScope,
118    review: &Review,
119) -> Result<Vec<AuthoredSourceRequirement>, CollaborationCodecError> {
120    if source_scope.spool != target_scope.spool
121        || source_scope.thread.is_none()
122        || target_scope.thread.is_none()
123    {
124        return Err(CollaborationCodecError::Invalid(
125            "review source and target require exact Threads in one Spool".into(),
126        ));
127    }
128    let mut requirements = BTreeMap::new();
129    let mut insert = |value: AuthoredSourceRequirement| {
130        requirements.insert(value.id()?, value);
131        Ok::<_, CollaborationCodecError>(())
132    };
133    insert(AuthoredSourceRequirement::Revision {
134        scope: source_scope.clone(),
135        revision: CollaborationRevision::State {
136            state_id: review.source,
137        },
138        path: String::new(),
139    })?;
140    insert(AuthoredSourceRequirement::Revision {
141        scope: target_scope.clone(),
142        revision: CollaborationRevision::State {
143            state_id: review.target,
144        },
145        path: String::new(),
146    })?;
147    match &review.coverage {
148        Some(ReviewCoverage::WholeSource) => insert(AuthoredSourceRequirement::WholeSource {
149            scope: source_scope.clone(),
150            state_id: review.source,
151        })?,
152        Some(ReviewCoverage::Symbols(anchors)) => {
153            if anchors.is_empty() || anchors.len() > 128 {
154                return Err(CollaborationCodecError::Invalid(
155                    "review symbol coverage exceeds bounds".into(),
156                ));
157            }
158            for anchor in anchors {
159                if anchor.file.is_empty()
160                    || anchor.file.len() > 4096
161                    || anchor.file.starts_with('/')
162                    || anchor
163                        .file
164                        .split('/')
165                        .any(|part| part.is_empty() || part == "." || part == "..")
166                {
167                    return Err(CollaborationCodecError::Invalid(
168                        "review symbol path must be relative and canonical".into(),
169                    ));
170                }
171                insert(AuthoredSourceRequirement::Revision {
172                    scope: source_scope.clone(),
173                    revision: CollaborationRevision::State {
174                        state_id: review.source,
175                    },
176                    path: anchor.file.clone(),
177                })?;
178            }
179        }
180        None => {}
181    }
182    Ok(requirements.into_values().collect())
183}
184
185fn source_requirements(
186    scope: &CollaborationScope,
187    source: &CollaborationSourceAnchor,
188    insert: &mut impl FnMut(AuthoredSourceRequirement) -> Result<(), CollaborationCodecError>,
189) -> Result<(), CollaborationCodecError> {
190    insert(AuthoredSourceRequirement::Revision {
191        scope: scope.clone(),
192        revision: source.revision.clone(),
193        path: source.path.clone(),
194    })?;
195    match source.target.as_ref().map(|target| &target.binding) {
196        None | Some(SourceTargetBinding::ViewedThread) => Ok(()),
197        Some(SourceTargetBinding::NamedThread { scope }) => {
198            insert(AuthoredSourceRequirement::Thread {
199                scope: scope.clone(),
200            })
201        }
202        Some(SourceTargetBinding::PinnedRevision { scope, revision }) => {
203            insert(AuthoredSourceRequirement::Revision {
204                scope: scope.clone(),
205                revision: revision.clone(),
206                path: String::new(),
207            })
208        }
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use crate::object::thread_replication::metadata::{ReviewKind, ReviewSymbolAnchor};
216
217    fn review(coverage: Option<ReviewCoverage>) -> Review {
218        Review {
219            id: uuid::Uuid::from_u128(1),
220            source: StateId::from_bytes([11; 32]),
221            target: StateId::from_bytes([12; 32]),
222            policy_version: ContentHash::from_bytes([13; 32]),
223            kind: ReviewKind::Read,
224            explanation: String::new(),
225            revokes: None,
226            expires_at_unix_seconds: None,
227            coverage,
228        }
229    }
230
231    #[test]
232    fn complete_read_requires_whole_source_in_addition_to_both_exact_revisions() {
233        let source = CollaborationScope {
234            spool: uuid::Uuid::from_u128(1),
235            thread: Some(ContentHash::from_bytes([1; 32])),
236        };
237        let target = CollaborationScope {
238            thread: Some(ContentHash::from_bytes([2; 32])),
239            ..source.clone()
240        };
241        let value = review(Some(ReviewCoverage::WholeSource));
242        let requirements = authored_review_requirements(&source, &target, &value)
243            .expect("exact review requirements");
244        assert_eq!(requirements.len(), 3);
245        assert!(
246            requirements.contains(&AuthoredSourceRequirement::WholeSource {
247                scope: source.clone(),
248                state_id: value.source,
249            })
250        );
251        assert!(requirements.contains(&AuthoredSourceRequirement::Revision {
252            scope: target.clone(),
253            revision: CollaborationRevision::State {
254                state_id: value.target
255            },
256            path: String::new(),
257        }));
258        assert_ne!(
259            AuthoredSourceRequirement::WholeSource {
260                scope: source.clone(),
261                state_id: value.source
262            }
263            .id()
264            .expect("whole source key"),
265            AuthoredSourceRequirement::Revision {
266                scope: source,
267                revision: CollaborationRevision::State {
268                    state_id: value.source
269                },
270                path: String::new(),
271            }
272            .id()
273            .expect("state key"),
274        );
275    }
276
277    #[test]
278    fn symbol_read_requires_each_original_path_and_rejects_unscoped_reviews() {
279        let scope = CollaborationScope {
280            spool: uuid::Uuid::from_u128(1),
281            thread: Some(ContentHash::from_bytes([1; 32])),
282        };
283        let value = review(Some(ReviewCoverage::Symbols(vec![
284            ReviewSymbolAnchor {
285                file: "src/main.rs".into(),
286                symbol: "run".into(),
287            },
288            ReviewSymbolAnchor {
289                file: "src/main.rs".into(),
290                symbol: "main".into(),
291            },
292            ReviewSymbolAnchor {
293                file: "src/lib.rs".into(),
294                symbol: "parse".into(),
295            },
296        ])));
297        let requirements = authored_review_requirements(&scope, &scope, &value)
298            .expect("exact symbol requirements");
299        assert_eq!(
300            requirements.len(),
301            4,
302            "two revisions and two distinct paths"
303        );
304        assert!(requirements.contains(&AuthoredSourceRequirement::Revision {
305            scope: scope.clone(),
306            revision: CollaborationRevision::State {
307                state_id: value.source
308            },
309            path: "src/main.rs".into(),
310        }));
311        assert!(
312            authored_review_requirements(
313                &CollaborationScope {
314                    thread: None,
315                    ..scope.clone()
316                },
317                &scope,
318                &value
319            )
320            .is_err()
321        );
322        assert!(
323            authored_review_requirements(
324                &scope,
325                &CollaborationScope {
326                    spool: uuid::Uuid::from_u128(2),
327                    ..scope.clone()
328                },
329                &value
330            )
331            .is_err()
332        );
333    }
334}