heddle_object_model/object/collaboration/
requirements.rs1use 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case", tag = "kind", deny_unknown_fields)]
22pub enum AuthoredSourceRequirement {
23 Thread { scope: CollaborationScope },
25 Revision {
27 scope: CollaborationScope,
28 revision: CollaborationRevision,
29 path: String,
30 },
31 Change {
34 scope: CollaborationScope,
35 change_id: ChangeId,
36 },
37 WholeSource {
41 scope: CollaborationScope,
42 state_id: StateId,
43 },
44}
45
46impl AuthoredSourceRequirement {
47 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
58pub 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
117pub 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}