1use super::*;
3use crate::git::observation::ObservationRecord;
4const OUTPUT: &[u8; 8] = b"BTOUTP01";
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct OwnedObservation {
7 pub id: [u8; 16],
8 pub worktree: WorktreeIdentity,
9 pub state: GitExpectedState,
10 pub alias_digest: [u8; 32],
11 pub retained_bytes: u64,
12}
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14#[repr(u8)]
15pub enum EntryGroup {
16 Staged = 1,
17 Unstaged = 2,
18 Untracked = 3,
19 Conflict = 4,
20}
21#[derive(Debug, Clone, PartialEq, Eq)]
22#[allow(clippy::large_enum_variant)]
24pub enum ObservationEvent {
25 Entry { group: EntryGroup, entry: GitEntry },
26 Alias(ObservationRecord),
27}
28fn aliases_hash() -> Sha256 {
29 let mut hash = Sha256::new();
30 hash.update(b"buntrail.original-aliases.v1\0");
31 hash
32}
33fn checked_path(path: &TargetPath, limit: TargetPathLimit) -> io::Result<&[u8]> {
34 let b = path_bytes(path)?;
35 if b.len() > limit.0 as usize || b.first() != Some(&b'/') || b.contains(&0) {
36 return Err(malformed());
37 }
38 Ok(b)
39}
40pub struct OutputWriter<W> {
41 out: W,
42 limit: TargetPathLimit,
43 counts: [u64; 5],
44 phase: u8,
45 hash: Sha256,
46 expected: [u8; 32],
47 failed: bool,
48}
49impl<W: Write> OutputWriter<W> {
50 pub fn new(
51 mut out: W,
52 summary: &OwnedObservation,
53 limit: TargetPathLimit,
54 ) -> Result<Self, CoreError> {
55 (|| -> io::Result<()> {
56 out.write_all(OUTPUT)?;
57 out.write_all(&limit.0.to_be_bytes())?;
58 out.write_all(&summary.id)?;
59 out.write_all(&summary.retained_bytes.to_be_bytes())?;
60 bytes(
61 &mut out,
62 checked_path(&summary.worktree.canonical_path, limit)?,
63 )?;
64 bytes(&mut out, &summary.worktree.common_git_directory_identity)?;
65 match &summary.state.head {
66 GitHead::Branch { name, oid } => {
67 tag(&mut out, 0)?;
68 bytes(&mut out, name)?;
69 bytes(&mut out, oid.as_bytes())?;
70 }
71 GitHead::Detached(oid) => {
72 tag(&mut out, 1)?;
73 bytes(&mut out, oid.as_bytes())?;
74 }
75 GitHead::Unborn(name) => {
76 tag(&mut out, 2)?;
77 bytes(&mut out, name)?;
78 }
79 }
80 out.write_all(&summary.state.index_fingerprint)?;
81 out.write_all(&summary.state.worktree_fingerprint)?;
82 out.write_all(&summary.alias_digest)
83 })()
84 .map_err(codec_error)?;
85 Ok(Self {
86 out,
87 limit,
88 counts: [0; 5],
89 phase: 0,
90 hash: aliases_hash(),
91 expected: summary.alias_digest,
92 failed: false,
93 })
94 }
95 pub fn event(&mut self, event: &ObservationEvent) -> Result<(), CoreError> {
96 if self.failed {
97 return Err(invalid());
98 }
99 self.failed = true;
100 let group = match event {
101 ObservationEvent::Entry { group, .. } => *group as u8,
102 ObservationEvent::Alias(_) => 5,
103 };
104 if group < self.phase {
105 return Err(invalid());
106 }
107 self.phase = group;
108 self.counts[group as usize - 1] = self.counts[group as usize - 1]
109 .checked_add(1)
110 .ok_or_else(invalid)?;
111 (|| -> io::Result<()> {
112 tag(&mut self.out, group)?;
113 match event {
114 ObservationEvent::Entry { entry, .. } => {
115 bytes(&mut self.out, checked_path(&entry.path, self.limit)?)?;
116 match &entry.previous_path {
117 None => tag(&mut self.out, 0)?,
118 Some(path) => {
119 tag(&mut self.out, 1)?;
120 bytes(&mut self.out, checked_path(path, self.limit)?)?;
121 }
122 }
123 bytes(&mut self.out, entry.status.as_bytes())?;
124 }
125 ObservationEvent::Alias(record) => {
126 if record.membership.is_none() {
127 return Err(malformed());
128 }
129 checked_path(&record.path, self.limit)?;
130 if let Some(path) = &record.resolved {
131 checked_path(path, self.limit)?;
132 }
133 let digest = record.file_reference.ok_or_else(malformed)?;
134 self.out
135 .write_all(&record.encode().map_err(|_| malformed())?)?;
136 self.hash.update(digest);
137 }
138 }
139 Ok(())
140 })()
141 .map_err(codec_error)?;
142 self.failed = false;
143 Ok(())
144 }
145 pub fn finish(mut self) -> Result<W, CoreError> {
146 if self.failed || <[u8; 32]>::from(self.hash.finalize()) != self.expected {
147 return Err(invalid());
148 }
149 tag(&mut self.out, 0).map_err(codec_error)?;
150 for count in self.counts {
151 self.out.write_all(&count.to_be_bytes()).map_err(io_error)?;
152 }
153 self.out.write_all(&self.expected).map_err(io_error)?;
154 Ok(self.out)
155 }
156}
157pub struct OutputReader<R> {
158 input: Option<Input<R>>,
159 summary: OwnedObservation,
160 limit: TargetPathLimit,
161 counts: [u64; 5],
162 phase: u8,
163 hash: Sha256,
164 complete: bool,
165 wire_digest: Option<[u8; 32]>,
166}
167impl<R: Read> OutputReader<R> {
168 pub fn new(reader: R, length: u64, target: &TargetIdentity) -> Result<Self, CoreError> {
169 let mut input = Input::new(reader, length);
170 let (summary, limit) = (|| -> Result<_, CoreError> {
171 if &input.fixed::<8>().map_err(codec_error)? != OUTPUT {
172 return Err(invalid());
173 }
174 let limit =
175 TargetPathLimit::declared(u32::from_be_bytes(input.fixed().map_err(codec_error)?))?;
176 let id = input.fixed().map_err(codec_error)?;
177 let retained_bytes = input.number().map_err(codec_error)?;
178 let worktree = WorktreeIdentity {
179 target: target.clone(),
180 canonical_path: TargetPath::posix(
181 input.bounded(limit.0 as u64).map_err(codec_error)?,
182 )?,
183 common_git_directory_identity: input.bounded(SCALAR).map_err(codec_error)?,
184 };
185 let head = match input.tag().map_err(codec_error)? {
186 0 => GitHead::Branch {
187 name: input.bounded(SCALAR).map_err(codec_error)?,
188 oid: input.string().map_err(codec_error)?,
189 },
190 1 => GitHead::Detached(input.string().map_err(codec_error)?),
191 2 => GitHead::Unborn(input.bounded(SCALAR).map_err(codec_error)?),
192 _ => return Err(invalid()),
193 };
194 let state = GitExpectedState {
195 head,
196 index_fingerprint: input.fixed().map_err(codec_error)?,
197 worktree_fingerprint: input.fixed().map_err(codec_error)?,
198 };
199 let alias_digest = input.fixed().map_err(codec_error)?;
200 Ok((
201 OwnedObservation {
202 id,
203 worktree,
204 state,
205 alias_digest,
206 retained_bytes,
207 },
208 limit,
209 ))
210 })()?;
211 Ok(Self {
212 input: Some(input),
213 summary,
214 limit,
215 counts: [0; 5],
216 phase: 0,
217 hash: aliases_hash(),
218 complete: false,
219 wire_digest: None,
220 })
221 }
222 pub fn next_event(&mut self) -> Result<Option<ObservationEvent>, CoreError> {
224 if self.complete {
225 return Ok(None);
226 }
227 let result = self.event();
228 if result.is_err() {
229 self.input = None;
230 }
231 result
232 }
233 fn event(&mut self) -> Result<Option<ObservationEvent>, CoreError> {
234 let input = self.input.as_mut().ok_or_else(invalid)?;
235 let kind = input.tag().map_err(codec_error)?;
236 if kind == 0 {
237 for count in self.counts {
238 if input.number().map_err(codec_error)? != count {
239 return Err(invalid());
240 }
241 }
242 let digest = input.fixed::<32>().map_err(codec_error)?;
243 if digest != self.summary.alias_digest
244 || digest != <[u8; 32]>::from(self.hash.clone().finalize())
245 {
246 return Err(invalid());
247 }
248 self.wire_digest = Some(self.input.take().unwrap().finish().map_err(codec_error)?);
249 self.complete = true;
250 return Ok(None);
251 }
252 if kind < self.phase || kind > 5 {
253 return Err(invalid());
254 }
255 self.phase = kind;
256 self.counts[kind as usize - 1] = self.counts[kind as usize - 1]
257 .checked_add(1)
258 .ok_or_else(invalid)?;
259 if kind == 5 {
260 let record = ObservationRecord::read_with_path_limit(input, self.limit.0 as usize)?
261 .ok_or_else(invalid)?;
262 if record.membership.is_none() {
263 return Err(invalid());
264 }
265 self.hash.update(record.file_reference.ok_or_else(invalid)?);
266 return Ok(Some(ObservationEvent::Alias(record)));
267 }
268 let path = TargetPath::posix(input.bounded(self.limit.0 as u64).map_err(codec_error)?)?;
269 let previous_path = match input.tag().map_err(codec_error)? {
270 0 => None,
271 1 => Some(TargetPath::posix(
272 input.bounded(self.limit.0 as u64).map_err(codec_error)?,
273 )?),
274 _ => return Err(invalid()),
275 };
276 let status = input.string().map_err(codec_error)?;
277 let group = match kind {
278 1 => EntryGroup::Staged,
279 2 => EntryGroup::Unstaged,
280 3 => EntryGroup::Untracked,
281 4 => EntryGroup::Conflict,
282 _ => return Err(invalid()),
283 };
284 Ok(Some(ObservationEvent::Entry {
285 group,
286 entry: GitEntry {
287 path,
288 previous_path,
289 status,
290 },
291 }))
292 }
293 pub fn observation_id(&self) -> [u8; 16] {
295 self.summary.id
296 }
297 pub fn wire_digest(&self) -> Option<[u8; 32]> {
299 self.wire_digest
300 }
301 pub fn finish(self) -> Result<OwnedObservation, CoreError> {
302 if !self.complete {
303 return Err(invalid());
304 }
305 Ok(self.summary)
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312 use crate::git::observation::ObservationMembership;
313 fn fixture() -> (OwnedObservation, ObservationRecord) {
314 let r = super::super::tests::original();
315 let record = ObservationRecord {
316 path: TargetPath::PosixBytes([b"/".as_slice(), &vec![b'x'; 2999]].concat()),
317 structural_token: [1; 32],
318 resolved: None,
319 regular: None,
320 membership: Some(ObservationMembership {
321 requested_repository: Some([2; 32]),
322 resolved_repository: None,
323 affected: true,
324 }),
325 file_reference: Some([3; 32]),
326 };
327 let mut hash = Sha256::new();
328 hash.update(b"buntrail.original-aliases.v1\0");
329 hash.update([3; 32]);
330 (
331 OwnedObservation {
332 id: [4; 16],
333 worktree: r.worktree,
334 state: r.expected,
335 alias_digest: hash.finalize().into(),
336 retained_bytes: 999,
337 },
338 record,
339 )
340 }
341 #[test]
342 fn output_streams_all_status_groups_and_linux_paths_without_host_path_limit() {
343 let (summary, record) = fixture();
344 let mut bytes = Vec::new();
345 let mut writer = OutputWriter::new(
346 &mut bytes,
347 &summary,
348 TargetPathLimit::declared(4096).unwrap(),
349 )
350 .expect("owned output must stream");
351 for group in [
352 EntryGroup::Staged,
353 EntryGroup::Unstaged,
354 EntryGroup::Untracked,
355 EntryGroup::Conflict,
356 ] {
357 writer
358 .event(&ObservationEvent::Entry {
359 group,
360 entry: GitEntry {
361 path: record.path.clone(),
362 previous_path: Some(TargetPath::PosixBytes(b"/old\xff".to_vec())),
363 status: "R ".into(),
364 },
365 })
366 .unwrap();
367 }
368 writer
369 .event(&ObservationEvent::Alias(record.clone()))
370 .unwrap();
371 writer.finish().unwrap();
372 let mut reader = OutputReader::new(
373 super::super::tests::Chunks(&bytes, 7),
374 bytes.len() as u64,
375 &summary.worktree.target,
376 )
377 .unwrap();
378 for group in [
379 EntryGroup::Staged,
380 EntryGroup::Unstaged,
381 EntryGroup::Untracked,
382 EntryGroup::Conflict,
383 ] {
384 assert!(
385 matches!(reader.next_event().unwrap(),Some(ObservationEvent::Entry{group:actual,entry}) if actual==group&&entry.path==record.path)
386 );
387 }
388 assert_eq!(
389 reader.next_event().unwrap(),
390 Some(ObservationEvent::Alias(record))
391 );
392 assert_eq!(reader.next_event().unwrap(), None);
393 assert_eq!(reader.finish().unwrap(), summary);
394 bytes[8..12].copy_from_slice(&1024u32.to_be_bytes());
395 let mut reader = OutputReader::new(
396 bytes.as_slice(),
397 bytes.len() as u64,
398 &summary.worktree.target,
399 )
400 .unwrap();
401 assert!(reader.next_event().is_err());
402 }
403 #[test]
404 fn output_exceeds_legacy_32_mib_without_an_aggregate_buffer() {
405 use std::io::{Seek, SeekFrom};
406 let (summary, record) = fixture();
407 let mut file = tempfile::tempfile().unwrap();
408 let mut writer = OutputWriter::new(
409 &mut file,
410 &summary,
411 TargetPathLimit::declared(4096).unwrap(),
412 )
413 .unwrap();
414 let event = ObservationEvent::Entry {
415 group: EntryGroup::Untracked,
416 entry: GitEntry {
417 path: record.path.clone(),
418 previous_path: None,
419 status: "??".into(),
420 },
421 };
422 for _ in 0..12000 {
423 writer.event(&event).unwrap();
424 }
425 writer.event(&ObservationEvent::Alias(record)).unwrap();
426 writer.finish().unwrap();
427 let length = file.stream_position().unwrap();
428 assert!(length > 32 * 1024 * 1024);
429 file.seek(SeekFrom::Start(0)).unwrap();
430 let mut reader = OutputReader::new(file, length, &summary.worktree.target).unwrap();
431 let mut count = 0;
432 while reader.next_event().unwrap().is_some() {
433 count += 1;
434 }
435 assert_eq!(count, 12001);
436 assert_eq!(reader.finish().unwrap(), summary);
437 eprintln!(
438 "synthetic owned output: {length} bytes, {count} events; no Git scalability claim"
439 );
440 }
441 #[test]
442 fn output_rejects_bad_trailer_truncation_order_type_and_unsupported_path_bound() {
443 let (summary, record) = fixture();
444 let mut bytes = Vec::new();
445 let mut writer = OutputWriter::new(
446 &mut bytes,
447 &summary,
448 TargetPathLimit::declared(4096).unwrap(),
449 )
450 .unwrap();
451 writer
452 .event(&ObservationEvent::Alias(record.clone()))
453 .unwrap();
454 writer.finish().unwrap();
455 let decode = |b: &[u8]| -> Result<OwnedObservation, CoreError> {
456 let mut r = OutputReader::new(
457 super::super::tests::Chunks(b, 1),
458 b.len() as u64,
459 &summary.worktree.target,
460 )?;
461 while r.next_event()?.is_some() {}
462 r.finish()
463 };
464 for n in [0, 8, 12, 28, bytes.len() - 1] {
465 assert!(decode(&bytes[..n]).is_err());
466 }
467 let mut changed = bytes.clone();
468 let end = changed.len() - 1;
469 changed[end] ^= 1;
470 assert!(decode(&changed).is_err());
471 let mut changed = bytes.clone();
472 changed[8..12].copy_from_slice(&u32::MAX.to_be_bytes());
473 assert!(decode(&changed).is_err());
474 let mut changed = bytes.clone();
475 changed.push(0);
476 assert!(decode(&changed).is_err());
477 let mut writer = OutputWriter::new(
478 Vec::new(),
479 &summary,
480 TargetPathLimit::declared(4096).unwrap(),
481 )
482 .unwrap();
483 writer
484 .event(&ObservationEvent::Alias(record.clone()))
485 .unwrap();
486 assert!(
487 writer
488 .event(&ObservationEvent::Entry {
489 group: EntryGroup::Staged,
490 entry: GitEntry {
491 path: record.path.clone(),
492 previous_path: None,
493 status: "M".into()
494 }
495 })
496 .is_err()
497 );
498 assert!(writer.finish().is_err());
499 let mut writer = OutputWriter::new(
500 Vec::new(),
501 &summary,
502 TargetPathLimit::declared(4096).unwrap(),
503 )
504 .unwrap();
505 let mut wrong = record;
506 wrong.membership = None;
507 assert!(writer.event(&ObservationEvent::Alias(wrong)).is_err());
508 assert!(
509 OutputReader::new(
510 bytes.as_slice(),
511 bytes.len() as u64,
512 &summary.worktree.target
513 )
514 .unwrap()
515 .finish()
516 .is_err(),
517 "partial output granted summary"
518 );
519 }
520 #[test]
521 fn owned_output_uses_proof_store_bytes_and_releases_its_input_slot() {
522 let dir = tempfile::tempdir().unwrap();
523 let local =
524 crate::connections::local::LocalConnection::open(dir.path().join("state")).unwrap();
525 let lease = WorkspaceLeaseID([92; 16]);
526 local.register(WorkspaceID([92; 16]), lease).unwrap();
527 let owner = local.observation_owner(lease).unwrap();
528 let (mut summary, mut record) = fixture();
529 summary.worktree.target = local.identity.clone();
530 record.path = TargetPath::PosixBytes(b"/alias".to_vec());
531 let spool = local.git.observation_input(&owner).unwrap();
532 let mut output = OutputWriter::new(
533 std::io::BufWriter::with_capacity(65536, spool),
534 &summary,
535 TargetPathLimit::declared(libc::PATH_MAX as u32).unwrap(),
536 )
537 .unwrap();
538 output.event(&ObservationEvent::Alias(record)).unwrap();
539 let buffered = output.finish().unwrap();
540 let input = buffered
541 .into_inner()
542 .map_err(|e| e.into_error())
543 .unwrap()
544 .seal()
545 .unwrap();
546 assert_eq!(
547 local.git.observation_storage_stats().unwrap().bytes,
548 input.len()
549 );
550 assert!(local.git.observation_input(&owner).is_err());
551 let mut reader =
552 OutputReader::new(input.reader().unwrap(), input.len(), &local.identity).unwrap();
553 while reader.next_event().unwrap().is_some() {}
554 assert_eq!(reader.wire_digest(), Some(input.digest));
555 assert_eq!(reader.finish().unwrap(), summary);
556 drop(input);
557 assert_eq!(local.git.observation_storage_stats().unwrap().bytes, 0);
558 local.git.observation_input(&owner).unwrap();
559 }
560}