1use std::{
4 fs::{self, File, OpenOptions},
5 io::{Read, Seek, SeekFrom, Write},
6};
7
8use camino::{Utf8Path, Utf8PathBuf};
9use rustferry_remote::{
10 CURRENT_PROTOCOL_VERSION, ProtocolPath, ProtocolPathSemantics, RemoteBuildEvent,
11 SnapshotArtifactDescriptor, SnapshotArtifactReceipt, SnapshotBuildComplete, SnapshotBuildStart,
12 SnapshotJobAccepted, SnapshotSessionError, SourceArchiveLimits, SourceBundleDescriptor,
13 WorkerDataPlaneFrameError, WorkerDataPlaneFrameHeader, WorkerDataPlaneFrameKind,
14 WorkerDataPlaneSequence, canonical_request_sha256, read_worker_data_plane_header,
15 read_worker_data_plane_payload, write_worker_data_plane_frame, write_worker_data_plane_header,
16};
17use serde::{Serialize, de::DeserializeOwned};
18use sha2::{Digest as _, Sha256};
19use thiserror::Error;
20
21use crate::provider::SSH_PROVIDER_ID;
22
23pub struct SnapshotSessionRequest<'a> {
25 pub start: &'a SnapshotBuildStart,
27 pub source_descriptor: &'a mut File,
29 pub source_archive: &'a mut File,
31 pub source_limits: SourceArchiveLimits,
33}
34
35impl<'a> SnapshotSessionRequest<'a> {
36 #[must_use]
38 pub const fn new(
39 start: &'a SnapshotBuildStart,
40 source_descriptor: &'a mut File,
41 source_archive: &'a mut File,
42 source_limits: SourceArchiveLimits,
43 ) -> Self {
44 Self {
45 start,
46 source_descriptor,
47 source_archive,
48 source_limits,
49 }
50 }
51}
52
53#[derive(Debug)]
55pub struct CreateOnlyArtifactSpool {
56 path: Utf8PathBuf,
57 file: File,
58 publication: Option<PublishedArtifact>,
59 commit_authorized: bool,
60 armed: bool,
61}
62
63#[derive(Debug)]
64struct PublishedArtifact {
65 path: Utf8PathBuf,
66 file: File,
67 is_staging_link: bool,
68 size: u64,
69 sha256: String,
70}
71
72impl CreateOnlyArtifactSpool {
73 pub fn create(path: impl Into<Utf8PathBuf>) -> Result<Self, SnapshotSessionClientError> {
80 let path = path.into();
81 if !path.is_absolute() || path.as_str().chars().any(char::is_control) {
82 return Err(SnapshotSessionClientError::ArtifactSpoolPathInvalid);
83 }
84 let mut options = OpenOptions::new();
85 options.read(true).write(true).create_new(true);
86 #[cfg(unix)]
87 {
88 use std::os::unix::fs::OpenOptionsExt as _;
89 options
90 .mode(0o600)
91 .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW);
92 }
93 #[cfg(windows)]
94 {
95 use std::os::windows::fs::OpenOptionsExt as _;
96 const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
97 options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
98 }
99 let file = options.open(&path).map_err(|error| {
100 if error.kind() == std::io::ErrorKind::AlreadyExists {
101 SnapshotSessionClientError::ArtifactSpoolExists
102 } else {
103 SnapshotSessionClientError::LocalIo
104 }
105 })?;
106 let spool = Self {
107 path,
108 file,
109 publication: None,
110 commit_authorized: false,
111 armed: true,
112 };
113 spool.validate_new_link()?;
114 Ok(spool)
115 }
116
117 #[must_use]
119 pub fn path(&self) -> &Utf8Path {
120 &self.path
121 }
122
123 pub fn abort(&mut self) -> Result<(), SnapshotSessionClientError> {
133 self.cleanup_failure()
134 }
135
136 pub fn commit(&mut self) -> Result<(), SnapshotSessionClientError> {
148 self.commit_with(|_| {})
149 }
150
151 fn commit_with(
152 &mut self,
153 after_staging_quarantine: impl FnOnce(&Utf8Path),
154 ) -> Result<(), SnapshotSessionClientError> {
155 let result = self.commit_inner_with(after_staging_quarantine);
156 if let Err(error) = result {
157 self.cleanup_failure()?;
158 return Err(error);
159 }
160 Ok(())
161 }
162
163 fn commit_inner_with(
164 &mut self,
165 after_staging_quarantine: impl FnOnce(&Utf8Path),
166 ) -> Result<(), SnapshotSessionClientError> {
167 if !self.commit_authorized {
168 return Err(SnapshotSessionClientError::ArtifactCommitNotAuthorized);
169 }
170 let publication = self
171 .publication
172 .as_ref()
173 .ok_or(SnapshotSessionClientError::ArtifactCommitNotAuthorized)?;
174 let path = publication.path.clone();
175 let is_staging_link = publication.is_staging_link;
176 let size = publication.size;
177 let sha256 = publication.sha256.clone();
178 validate_published_artifact(&self.file, &publication.file, &path, size, &sha256)?;
179 if !is_staging_link {
180 self.remove_owned_link_with(&self.path, after_staging_quarantine)?;
181 let publication = self
182 .publication
183 .as_ref()
184 .ok_or(SnapshotSessionClientError::ArtifactCommitNotAuthorized)?;
185 validate_published_artifact(&self.file, &publication.file, &path, size, &sha256)?;
186 }
187 self.armed = false;
188 Ok(())
189 }
190
191 fn validate_new_link(&self) -> Result<(), SnapshotSessionClientError> {
192 let opened = self
193 .file
194 .metadata()
195 .map_err(|_| SnapshotSessionClientError::LocalIo)?;
196 let linked = fs::symlink_metadata(&self.path)
197 .map_err(|_| SnapshotSessionClientError::ArtifactSpoolInvalid)?;
198 let linked_file = open_file_no_follow(&self.path)
199 .map_err(|_| SnapshotSessionClientError::ArtifactSpoolInvalid)?;
200 if !opened.file_type().is_file()
201 || linked.file_type().is_symlink()
202 || !linked.file_type().is_file()
203 || opened.len() != 0
204 || linked.len() != 0
205 || !same_file_identity(&self.file, &linked_file)
206 {
207 return Err(SnapshotSessionClientError::ArtifactSpoolInvalid);
208 }
209 #[cfg(unix)]
210 {
211 use std::os::unix::fs::MetadataExt as _;
212 if opened.mode() & 0o077 != 0 || opened.nlink() != 1 {
213 return Err(SnapshotSessionClientError::ArtifactSpoolInvalid);
214 }
215 }
216 Ok(())
217 }
218
219 fn register_publication(
220 &mut self,
221 local_path: &ProtocolPath,
222 expected_size: u64,
223 expected_sha256: &str,
224 ) -> Result<(), SnapshotSessionClientError> {
225 if local_path.semantics != ProtocolPathSemantics::ClientAbsolute {
226 return Err(SnapshotSessionClientError::ArtifactPublicationFailed);
227 }
228 let path = Utf8PathBuf::from(&local_path.value);
229 if !path.is_absolute() || path.as_str().chars().any(char::is_control) {
230 return Err(SnapshotSessionClientError::ArtifactPublicationFailed);
231 }
232 let is_staging_link = same_directory_entry(&self.path, &path);
233 let file = open_publication_identity(&self.file, &path)?;
234 self.publication = Some(PublishedArtifact {
235 path: path.clone(),
236 file,
237 is_staging_link,
238 size: expected_size,
239 sha256: expected_sha256.to_owned(),
240 });
241 let publication = self
242 .publication
243 .as_ref()
244 .ok_or(SnapshotSessionClientError::ArtifactPublicationFailed)?;
245 validate_published_artifact(
246 &self.file,
247 &publication.file,
248 &path,
249 expected_size,
250 expected_sha256,
251 )
252 }
253
254 pub(crate) fn authorize_commit(&mut self) -> Result<(), SnapshotSessionClientError> {
255 if self.publication.is_none() || !self.armed {
256 return Err(SnapshotSessionClientError::ArtifactCommitNotAuthorized);
257 }
258 self.commit_authorized = true;
259 Ok(())
260 }
261
262 pub(crate) fn cleanup_failure(&mut self) -> Result<(), SnapshotSessionClientError> {
263 if !self.armed {
264 return Ok(());
265 }
266 let publication = self
267 .publication
268 .as_ref()
269 .map(|publication| (publication.path.clone(), publication.is_staging_link));
270 let mut failed = false;
271 if let Some((path, _)) = &publication
272 && self.remove_owned_link(path).is_err()
273 {
274 failed = true;
275 }
276 if !publication
277 .as_ref()
278 .is_some_and(|(_, is_staging_link)| *is_staging_link)
279 && self.remove_owned_link(&self.path).is_err()
280 {
281 failed = true;
282 }
283 if failed {
284 Err(SnapshotSessionClientError::ArtifactCleanupFailed)
285 } else {
286 self.armed = false;
287 self.publication = None;
288 self.commit_authorized = false;
289 Ok(())
290 }
291 }
292
293 fn remove_owned_link(&self, path: &Utf8Path) -> Result<(), SnapshotSessionClientError> {
294 self.remove_owned_link_with(path, |_| {})
295 }
296
297 fn remove_owned_link_with(
298 &self,
299 path: &Utf8Path,
300 after_quarantine: impl FnOnce(&Utf8Path),
301 ) -> Result<(), SnapshotSessionClientError> {
302 let parent = path
303 .parent()
304 .ok_or(SnapshotSessionClientError::ArtifactCleanupFailed)?;
305 let quarantine = tempfile::Builder::new()
306 .prefix(".rustferry-artifact-cleanup-")
307 .tempdir_in(parent)
308 .map_err(|_| SnapshotSessionClientError::ArtifactCleanupFailed)?;
309 let quarantine_root = Utf8PathBuf::from_path_buf(quarantine.path().to_path_buf())
310 .map_err(|_| SnapshotSessionClientError::ArtifactCleanupFailed)?;
311 let quarantined = quarantine_root.join("artifact");
312 match fs::rename(path, &quarantined) {
313 Ok(()) => {}
314 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
315 quarantine
316 .close()
317 .map_err(|_| SnapshotSessionClientError::ArtifactCleanupFailed)?;
318 return Ok(());
319 }
320 Err(_) => return Err(SnapshotSessionClientError::ArtifactCleanupFailed),
321 }
322 after_quarantine(&quarantined);
323
324 let owned = fs::symlink_metadata(&quarantined).is_ok_and(|linked| {
325 !linked.file_type().is_symlink()
326 && linked.file_type().is_file()
327 && open_file_no_follow(&quarantined)
328 .is_ok_and(|linked| same_file_identity(&self.file, &linked))
329 });
330 let Ok(temporary_path) = tempfile::TempPath::try_from_path(quarantined.to_path_buf())
331 else {
332 let _ = quarantine.keep();
333 return Err(SnapshotSessionClientError::ArtifactCleanupFailed);
334 };
335 if owned {
336 temporary_path
337 .close()
338 .map_err(|_| SnapshotSessionClientError::ArtifactCleanupFailed)?;
339 quarantine
340 .close()
341 .map_err(|_| SnapshotSessionClientError::ArtifactCleanupFailed)?;
342 return sync_parent_directory(path)
343 .map_err(|_| SnapshotSessionClientError::ArtifactCleanupFailed);
344 }
345
346 match temporary_path.persist_noclobber(path) {
347 Ok(()) => {
348 let _ = quarantine.close();
349 }
350 Err(mut error) => {
351 error.path.disable_cleanup(true);
352 drop(error);
353 let _ = quarantine.keep();
354 }
355 }
356 Err(SnapshotSessionClientError::ArtifactCleanupFailed)
357 }
358}
359
360impl Drop for CreateOnlyArtifactSpool {
361 fn drop(&mut self) {
362 let _ = self.cleanup_failure();
363 }
364}
365
366#[derive(Clone, Debug, Eq, PartialEq)]
368pub struct SnapshotSessionOutcome {
369 pub accepted: SnapshotJobAccepted,
371 pub artifact: SnapshotArtifactDescriptor,
373 pub receipt: SnapshotArtifactReceipt,
375 pub complete: SnapshotBuildComplete,
377}
378
379#[derive(Clone, Debug, Eq, Error, PartialEq)]
381pub enum SnapshotSessionClientError {
382 #[error(transparent)]
384 DataPlane(#[from] WorkerDataPlaneFrameError),
385 #[error("SSH snapshot build start is invalid")]
387 InvalidBuildStart,
388 #[error("SSH snapshot source descriptor is invalid")]
390 InvalidSourceDescriptor,
391 #[error("SSH snapshot source descriptor bytes do not match the build start")]
393 SourceDescriptorMismatch,
394 #[error("SSH snapshot source archive bytes do not match the build start")]
396 SourceArchiveMismatch,
397 #[error("SSH snapshot source file changed while it was streamed")]
399 SourceFileChanged,
400 #[error("SSH snapshot control frame is invalid")]
402 InvalidControlFrame,
403 #[error("unexpected SSH snapshot server frame {received:?}; expected {expected}")]
405 UnexpectedServerFrame {
406 expected: &'static str,
408 received: WorkerDataPlaneFrameKind,
410 },
411 #[error("SSH snapshot job acknowledgement identity is invalid")]
413 JobIdentityMismatch,
414 #[error("SSH snapshot event identity is invalid")]
416 EventIdentityMismatch,
417 #[error("SSH snapshot event sequence is invalid")]
419 EventSequenceMismatch,
420 #[error("SSH snapshot artifact arrived before any validated progress event")]
422 MissingProgressEvent,
423 #[error("SSH snapshot artifact identity is invalid")]
425 ArtifactIdentityMismatch,
426 #[error("SSH snapshot compile evidence does not match the submitted request")]
428 CompileEvidenceMismatch,
429 #[error("SSH snapshot artifact bytes do not match the descriptor")]
431 ArtifactPayloadMismatch,
432 #[error("SSH snapshot artifact verification failed")]
434 ArtifactVerificationFailed,
435 #[error("SSH snapshot artifact publication could not be proven")]
437 ArtifactPublicationFailed,
438 #[error("SSH snapshot artifact commit is not authorized")]
440 ArtifactCommitNotAuthorized,
441 #[error("SSH snapshot uncommitted artifact cleanup failed")]
443 ArtifactCleanupFailed,
444 #[error("SSH snapshot completion identity is invalid")]
446 CompletionIdentityMismatch,
447 #[error("SSH snapshot worker returned an error")]
449 Server(Box<SnapshotSessionError>),
450 #[error("SSH snapshot artifact spool path is invalid")]
452 ArtifactSpoolPathInvalid,
453 #[error("SSH snapshot artifact spool already exists")]
455 ArtifactSpoolExists,
456 #[error("SSH snapshot artifact spool is invalid")]
458 ArtifactSpoolInvalid,
459 #[error("SSH snapshot local file operation failed")]
461 LocalIo,
462}
463
464pub fn run_snapshot_session<Reader, Writer, EventSink, Verifier, VerifyError>(
481 server_reader: &mut Reader,
482 client_writer: &mut Writer,
483 request: SnapshotSessionRequest<'_>,
484 artifact_spool: &mut CreateOnlyArtifactSpool,
485 on_event: EventSink,
486 verify_artifact: Verifier,
487) -> Result<SnapshotSessionOutcome, SnapshotSessionClientError>
488where
489 Reader: Read,
490 Writer: Write,
491 EventSink: FnMut(RemoteBuildEvent),
492 Verifier: FnOnce(
493 &mut File,
494 &Utf8Path,
495 &SnapshotArtifactDescriptor,
496 ) -> Result<ProtocolPath, VerifyError>,
497{
498 let mut progress = SnapshotSessionProgress::default();
499 let outcome = run_snapshot_session_deferred(
500 server_reader,
501 client_writer,
502 request,
503 artifact_spool,
504 on_event,
505 verify_artifact,
506 &mut progress,
507 )?;
508 if let Err(error) = artifact_spool.authorize_commit() {
509 return cleanup_session_error(artifact_spool, error);
510 }
511 Ok(outcome)
512}
513
514#[derive(Clone, Copy, Debug, Eq, PartialEq)]
515pub(crate) struct SnapshotSessionProgress {
516 client_output: ClientOutputState,
517}
518
519#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
520enum ClientOutputState {
521 #[default]
522 Uploading,
523 Cancelable,
524 ReceiptStarted,
525 ReceiptSent,
526 CancelSent,
527}
528
529impl Default for SnapshotSessionProgress {
530 fn default() -> Self {
531 Self {
532 client_output: ClientOutputState::Uploading,
533 }
534 }
535}
536
537impl SnapshotSessionProgress {
538 pub(crate) const fn can_send_cancel(self) -> bool {
539 matches!(self.client_output, ClientOutputState::Cancelable)
540 }
541
542 pub(crate) fn mark_cancel_sent(&mut self) {
543 self.client_output = ClientOutputState::CancelSent;
544 }
545}
546
547pub(crate) fn run_snapshot_session_deferred<Reader, Writer, EventSink, Verifier, VerifyError>(
548 server_reader: &mut Reader,
549 client_writer: &mut Writer,
550 request: SnapshotSessionRequest<'_>,
551 artifact_spool: &mut CreateOnlyArtifactSpool,
552 on_event: EventSink,
553 verify_artifact: Verifier,
554 progress: &mut SnapshotSessionProgress,
555) -> Result<SnapshotSessionOutcome, SnapshotSessionClientError>
556where
557 Reader: Read,
558 Writer: Write,
559 EventSink: FnMut(RemoteBuildEvent),
560 Verifier: FnOnce(
561 &mut File,
562 &Utf8Path,
563 &SnapshotArtifactDescriptor,
564 ) -> Result<ProtocolPath, VerifyError>,
565{
566 let result = run_snapshot_session_uncommitted(
567 server_reader,
568 client_writer,
569 request,
570 artifact_spool,
571 on_event,
572 verify_artifact,
573 progress,
574 );
575 match result {
576 Ok(outcome) => Ok(outcome),
577 Err(error) => cleanup_session_error(artifact_spool, error),
578 }
579}
580
581#[allow(clippy::too_many_arguments)]
582fn run_snapshot_session_uncommitted<Reader, Writer, EventSink, Verifier, VerifyError>(
583 server_reader: &mut Reader,
584 client_writer: &mut Writer,
585 request: SnapshotSessionRequest<'_>,
586 artifact_spool: &mut CreateOnlyArtifactSpool,
587 mut on_event: EventSink,
588 verify_artifact: Verifier,
589 progress: &mut SnapshotSessionProgress,
590) -> Result<SnapshotSessionOutcome, SnapshotSessionClientError>
591where
592 Reader: Read,
593 Writer: Write,
594 EventSink: FnMut(RemoteBuildEvent),
595 Verifier: FnOnce(
596 &mut File,
597 &Utf8Path,
598 &SnapshotArtifactDescriptor,
599 ) -> Result<ProtocolPath, VerifyError>,
600{
601 let mut prepared = prepare_request(request)?;
602 artifact_spool.validate_new_link()?;
603 write_request_frames(client_writer, &mut prepared)?;
604 client_writer
605 .flush()
606 .map_err(|_| SnapshotSessionClientError::DataPlane(WorkerDataPlaneFrameError::Io))?;
607 progress.client_output = ClientOutputState::Cancelable;
608 let mut cancel_guard = PreReceiptCancelGuard::new(client_writer, progress);
609
610 let mut server_sequence = WorkerDataPlaneSequence::new();
611 let mut next_event_sequence = 0_u64;
612 let accepted = read_job_accepted(
613 server_reader,
614 &mut server_sequence,
615 &prepared.operation_id,
616 prepared.protocol_version,
617 )?;
618 let artifact = read_until_artifact_descriptor(
619 server_reader,
620 &mut server_sequence,
621 &prepared,
622 &accepted,
623 &mut next_event_sequence,
624 &mut on_event,
625 )?;
626 read_artifact_payload(
627 server_reader,
628 &mut server_sequence,
629 &prepared,
630 &accepted,
631 &artifact,
632 artifact_spool,
633 )?;
634 if next_event_sequence == 0 {
635 return Err(SnapshotSessionClientError::MissingProgressEvent);
636 }
637
638 artifact_spool
639 .file
640 .seek(SeekFrom::Start(0))
641 .map_err(|_| SnapshotSessionClientError::LocalIo)?;
642 let local_path = verify_artifact(
643 &mut artifact_spool.file,
644 artifact_spool.path.as_path(),
645 &artifact,
646 )
647 .map_err(|_| SnapshotSessionClientError::ArtifactVerificationFailed)?;
648 validate_open_file_bytes(
649 &mut artifact_spool.file,
650 artifact.artifact.size,
651 &artifact.artifact.sha256,
652 SnapshotSessionClientError::ArtifactVerificationFailed,
653 )?;
654 let receipt = SnapshotArtifactReceipt::new(&artifact, local_path)
655 .map_err(|_| SnapshotSessionClientError::ArtifactVerificationFailed)?;
656 artifact_spool.register_publication(
657 &receipt.local_path,
658 artifact.artifact.size,
659 &artifact.artifact.sha256,
660 )?;
661 cancel_guard.begin_receipt();
662 write_json_frame(
663 &mut cancel_guard,
664 WorkerDataPlaneFrameKind::ArtifactReceipt,
665 3,
666 &receipt,
667 )?;
668 cancel_guard
669 .flush()
670 .map_err(|_| SnapshotSessionClientError::DataPlane(WorkerDataPlaneFrameError::Io))?;
671 cancel_guard.receipt_sent();
672
673 let complete = read_until_complete(
674 server_reader,
675 &mut server_sequence,
676 &prepared,
677 &accepted,
678 &mut next_event_sequence,
679 &mut on_event,
680 )?;
681 Ok(SnapshotSessionOutcome {
682 accepted,
683 artifact,
684 receipt,
685 complete,
686 })
687}
688
689fn cleanup_session_error<T>(
690 artifact_spool: &mut CreateOnlyArtifactSpool,
691 error: SnapshotSessionClientError,
692) -> Result<T, SnapshotSessionClientError> {
693 artifact_spool.cleanup_failure()?;
694 Err(error)
695}
696
697struct PreReceiptCancelGuard<'a, Writer: Write> {
698 writer: &'a mut Writer,
699 progress: &'a mut SnapshotSessionProgress,
700 armed: bool,
701}
702
703impl<'a, Writer: Write> PreReceiptCancelGuard<'a, Writer> {
704 fn new(writer: &'a mut Writer, progress: &'a mut SnapshotSessionProgress) -> Self {
705 Self {
706 writer,
707 progress,
708 armed: true,
709 }
710 }
711
712 fn begin_receipt(&mut self) {
713 self.progress.client_output = ClientOutputState::ReceiptStarted;
714 self.armed = false;
715 }
716
717 fn receipt_sent(&mut self) {
718 self.progress.client_output = ClientOutputState::ReceiptSent;
719 }
720}
721
722impl<Writer: Write> Write for PreReceiptCancelGuard<'_, Writer> {
723 fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
724 self.writer.write(buffer)
725 }
726
727 fn flush(&mut self) -> std::io::Result<()> {
728 self.writer.flush()
729 }
730}
731
732impl<Writer: Write> Drop for PreReceiptCancelGuard<'_, Writer> {
733 fn drop(&mut self) {
734 if self.armed && self.progress.can_send_cancel() {
735 let sent = write_worker_data_plane_frame(
736 self.writer,
737 WorkerDataPlaneFrameKind::Cancel,
738 3,
739 &[],
740 )
741 .and_then(|()| {
742 self.writer
743 .flush()
744 .map_err(|_| WorkerDataPlaneFrameError::Io)
745 });
746 if sent.is_ok() {
747 self.progress.mark_cancel_sent();
748 }
749 }
750 }
751}
752
753struct PreparedRequest<'a> {
754 start: &'a SnapshotBuildStart,
755 source_descriptor: &'a mut File,
756 source_archive: &'a mut File,
757 operation_id: String,
758 protocol_version: rustferry_remote::ProtocolVersion,
759 request_sha256: String,
760 source_sha256: String,
761}
762
763fn prepare_request(
764 request: SnapshotSessionRequest<'_>,
765) -> Result<PreparedRequest<'_>, SnapshotSessionClientError> {
766 let SnapshotSessionRequest {
767 start,
768 source_descriptor,
769 source_archive,
770 source_limits,
771 } = request;
772 start
773 .validate()
774 .map_err(|_| SnapshotSessionClientError::InvalidBuildStart)?;
775 validate_open_file_bytes(
776 source_descriptor,
777 start.source_descriptor_size,
778 &start.source_descriptor_sha256,
779 SnapshotSessionClientError::SourceDescriptorMismatch,
780 )?;
781 validate_open_file_bytes(
782 source_archive,
783 start.source_archive.size,
784 &start.source_archive.sha256,
785 SnapshotSessionClientError::SourceArchiveMismatch,
786 )?;
787
788 let descriptor = parse_validated_descriptor(
789 source_descriptor,
790 start.source_descriptor_size,
791 &start.source_descriptor_sha256,
792 )?;
793 let reconstructed = start
794 .reconstruct_request(&descriptor, source_limits)
795 .map_err(|_| SnapshotSessionClientError::InvalidSourceDescriptor)?;
796 let request_sha256 = canonical_request_sha256(&reconstructed)
797 .map_err(|_| SnapshotSessionClientError::InvalidBuildStart)?;
798 let protocol_version = CURRENT_PROTOCOL_VERSION
799 .negotiate(reconstructed.protocol_version)
800 .map_err(|_| SnapshotSessionClientError::InvalidBuildStart)?;
801 source_descriptor
802 .seek(SeekFrom::Start(0))
803 .map_err(|_| SnapshotSessionClientError::LocalIo)?;
804 source_archive
805 .seek(SeekFrom::Start(0))
806 .map_err(|_| SnapshotSessionClientError::LocalIo)?;
807
808 Ok(PreparedRequest {
809 start,
810 source_descriptor,
811 source_archive,
812 operation_id: reconstructed.operation_id,
813 protocol_version,
814 request_sha256,
815 source_sha256: reconstructed.source.sha256,
816 })
817}
818
819fn write_request_frames(
820 writer: &mut impl Write,
821 prepared: &mut PreparedRequest<'_>,
822) -> Result<(), SnapshotSessionClientError> {
823 write_json_frame(
824 writer,
825 WorkerDataPlaneFrameKind::BuildRequest,
826 0,
827 prepared.start,
828 )?;
829 stream_validated_file(
830 writer,
831 WorkerDataPlaneFrameKind::SourceDescriptor,
832 1,
833 prepared.source_descriptor,
834 prepared.start.source_descriptor_size,
835 &prepared.start.source_descriptor_sha256,
836 &SnapshotSessionClientError::SourceDescriptorMismatch,
837 )?;
838 stream_validated_file(
839 writer,
840 WorkerDataPlaneFrameKind::SourceArchive,
841 2,
842 prepared.source_archive,
843 prepared.start.source_archive.size,
844 &prepared.start.source_archive.sha256,
845 &SnapshotSessionClientError::SourceArchiveMismatch,
846 )
847}
848
849fn read_job_accepted(
850 reader: &mut impl Read,
851 sequence: &mut WorkerDataPlaneSequence,
852 operation_id: &str,
853 protocol_version: rustferry_remote::ProtocolVersion,
854) -> Result<SnapshotJobAccepted, SnapshotSessionClientError> {
855 let header = read_sequenced_header(reader, sequence)?;
856 match header.kind() {
857 WorkerDataPlaneFrameKind::JobAccepted => {
858 let accepted = read_json_frame::<SnapshotJobAccepted>(reader, header)?;
859 accepted
860 .validate()
861 .map_err(|_| SnapshotSessionClientError::InvalidControlFrame)?;
862 if accepted.operation_id != operation_id
863 || accepted.protocol_version != protocol_version
864 {
865 return Err(SnapshotSessionClientError::JobIdentityMismatch);
866 }
867 Ok(accepted)
868 }
869 WorkerDataPlaneFrameKind::Error => Err(SnapshotSessionClientError::Server(Box::new(
870 read_server_error(reader, header, operation_id, protocol_version, None)?,
871 ))),
872 received => Err(SnapshotSessionClientError::UnexpectedServerFrame {
873 expected: "job_accepted or error",
874 received,
875 }),
876 }
877}
878
879fn read_until_artifact_descriptor(
880 reader: &mut impl Read,
881 sequence: &mut WorkerDataPlaneSequence,
882 prepared: &PreparedRequest<'_>,
883 accepted: &SnapshotJobAccepted,
884 next_event_sequence: &mut u64,
885 on_event: &mut impl FnMut(RemoteBuildEvent),
886) -> Result<SnapshotArtifactDescriptor, SnapshotSessionClientError> {
887 loop {
888 let header = read_sequenced_header(reader, sequence)?;
889 match header.kind() {
890 WorkerDataPlaneFrameKind::Event => {
891 let event = read_event(reader, header, prepared, accepted, next_event_sequence)?;
892 on_event(event);
893 }
894 WorkerDataPlaneFrameKind::ArtifactDescriptor => {
895 let artifact = read_json_frame::<SnapshotArtifactDescriptor>(reader, header)?;
896 artifact
897 .validate()
898 .map_err(|_| SnapshotSessionClientError::InvalidControlFrame)?;
899 if artifact.operation_id != prepared.operation_id
900 || artifact.job_id != accepted.job_id
901 || artifact.protocol_version != prepared.protocol_version
902 {
903 return Err(SnapshotSessionClientError::ArtifactIdentityMismatch);
904 }
905 if artifact.compile.request_sha256 != prepared.request_sha256
906 || artifact.compile.source_sha256 != prepared.source_sha256
907 || artifact.compile.provider != SSH_PROVIDER_ID
908 {
909 return Err(SnapshotSessionClientError::CompileEvidenceMismatch);
910 }
911 return Ok(artifact);
912 }
913 WorkerDataPlaneFrameKind::Error => {
914 return Err(SnapshotSessionClientError::Server(Box::new(
915 read_server_error(
916 reader,
917 header,
918 &prepared.operation_id,
919 prepared.protocol_version,
920 Some(accepted),
921 )?,
922 )));
923 }
924 received => {
925 return Err(SnapshotSessionClientError::UnexpectedServerFrame {
926 expected: "event, artifact_descriptor, or error",
927 received,
928 });
929 }
930 }
931 }
932}
933
934fn read_artifact_payload(
935 reader: &mut impl Read,
936 sequence: &mut WorkerDataPlaneSequence,
937 prepared: &PreparedRequest<'_>,
938 accepted: &SnapshotJobAccepted,
939 artifact: &SnapshotArtifactDescriptor,
940 spool: &mut CreateOnlyArtifactSpool,
941) -> Result<(), SnapshotSessionClientError> {
942 let header = read_sequenced_header(reader, sequence)?;
943 if header.kind() == WorkerDataPlaneFrameKind::Error {
944 return Err(SnapshotSessionClientError::Server(Box::new(
945 read_server_error(
946 reader,
947 header,
948 &prepared.operation_id,
949 prepared.protocol_version,
950 Some(accepted),
951 )?,
952 )));
953 }
954 if header.kind() != WorkerDataPlaneFrameKind::Artifact {
955 return Err(SnapshotSessionClientError::UnexpectedServerFrame {
956 expected: "artifact immediately after artifact_descriptor",
957 received: header.kind(),
958 });
959 }
960 if header.payload_bytes() != artifact.artifact.size {
961 return Err(SnapshotSessionClientError::ArtifactPayloadMismatch);
962 }
963 spool.validate_new_link()?;
964 spool
965 .file
966 .seek(SeekFrom::Start(0))
967 .map_err(|_| SnapshotSessionClientError::LocalIo)?;
968 let digest = copy_exact_hashed(reader, &mut spool.file, header.payload_bytes())?;
969 spool
970 .file
971 .flush()
972 .and_then(|()| spool.file.sync_all())
973 .map_err(|_| SnapshotSessionClientError::LocalIo)?;
974 if digest != artifact.artifact.sha256
975 || spool
976 .file
977 .metadata()
978 .map_err(|_| SnapshotSessionClientError::LocalIo)?
979 .len()
980 != artifact.artifact.size
981 {
982 return Err(SnapshotSessionClientError::ArtifactPayloadMismatch);
983 }
984 Ok(())
985}
986
987fn read_until_complete(
988 reader: &mut impl Read,
989 sequence: &mut WorkerDataPlaneSequence,
990 prepared: &PreparedRequest<'_>,
991 accepted: &SnapshotJobAccepted,
992 next_event_sequence: &mut u64,
993 on_event: &mut impl FnMut(RemoteBuildEvent),
994) -> Result<SnapshotBuildComplete, SnapshotSessionClientError> {
995 loop {
996 let header = read_sequenced_header(reader, sequence)?;
997 match header.kind() {
998 WorkerDataPlaneFrameKind::Event => {
999 let event = read_event(reader, header, prepared, accepted, next_event_sequence)?;
1000 on_event(event);
1001 }
1002 WorkerDataPlaneFrameKind::Complete => {
1003 let complete = read_json_frame::<SnapshotBuildComplete>(reader, header)?;
1004 complete
1005 .validate()
1006 .map_err(|_| SnapshotSessionClientError::InvalidControlFrame)?;
1007 if complete.operation_id != prepared.operation_id
1008 || complete.job_id != accepted.job_id
1009 || complete.protocol_version != prepared.protocol_version
1010 {
1011 return Err(SnapshotSessionClientError::CompletionIdentityMismatch);
1012 }
1013 return Ok(complete);
1014 }
1015 WorkerDataPlaneFrameKind::Error => {
1016 return Err(SnapshotSessionClientError::Server(Box::new(
1017 read_server_error(
1018 reader,
1019 header,
1020 &prepared.operation_id,
1021 prepared.protocol_version,
1022 Some(accepted),
1023 )?,
1024 )));
1025 }
1026 received => {
1027 return Err(SnapshotSessionClientError::UnexpectedServerFrame {
1028 expected: "event, complete, or error after artifact receipt",
1029 received,
1030 });
1031 }
1032 }
1033 }
1034}
1035
1036fn read_event(
1037 reader: &mut impl Read,
1038 header: WorkerDataPlaneFrameHeader,
1039 prepared: &PreparedRequest<'_>,
1040 accepted: &SnapshotJobAccepted,
1041 next_event_sequence: &mut u64,
1042) -> Result<RemoteBuildEvent, SnapshotSessionClientError> {
1043 let payload = read_worker_data_plane_payload(reader, header)?;
1044 let event = RemoteBuildEvent::decode_line_bytes(&payload)
1045 .map_err(|_| SnapshotSessionClientError::InvalidControlFrame)?;
1046 if event.protocol_version != prepared.protocol_version
1047 || event.operation_id != prepared.operation_id
1048 || event.job_id != accepted.job_id
1049 || event.provider != SSH_PROVIDER_ID
1050 {
1051 return Err(SnapshotSessionClientError::EventIdentityMismatch);
1052 }
1053 if event.sequence != *next_event_sequence {
1054 return Err(SnapshotSessionClientError::EventSequenceMismatch);
1055 }
1056 *next_event_sequence = next_event_sequence
1057 .checked_add(1)
1058 .ok_or(SnapshotSessionClientError::EventSequenceMismatch)?;
1059 Ok(event)
1060}
1061
1062fn read_server_error(
1063 reader: &mut impl Read,
1064 header: WorkerDataPlaneFrameHeader,
1065 operation_id: &str,
1066 protocol_version: rustferry_remote::ProtocolVersion,
1067 accepted: Option<&SnapshotJobAccepted>,
1068) -> Result<SnapshotSessionError, SnapshotSessionClientError> {
1069 let error = read_json_frame::<SnapshotSessionError>(reader, header)?;
1070 error
1071 .validate()
1072 .map_err(|_| SnapshotSessionClientError::InvalidControlFrame)?;
1073 if error.protocol_version != protocol_version
1074 || error
1075 .operation_id
1076 .as_deref()
1077 .is_some_and(|received| received != operation_id)
1078 {
1079 return Err(SnapshotSessionClientError::JobIdentityMismatch);
1080 }
1081 if let Some(accepted) = accepted {
1082 if error.operation_id.as_deref() != Some(operation_id)
1083 || error.job_id.as_deref() != Some(accepted.job_id.as_str())
1084 {
1085 return Err(SnapshotSessionClientError::JobIdentityMismatch);
1086 }
1087 } else if error.job_id.is_some() {
1088 return Err(SnapshotSessionClientError::JobIdentityMismatch);
1089 }
1090 Ok(error)
1091}
1092
1093fn read_sequenced_header(
1094 reader: &mut impl Read,
1095 sequence: &mut WorkerDataPlaneSequence,
1096) -> Result<WorkerDataPlaneFrameHeader, SnapshotSessionClientError> {
1097 let header = read_worker_data_plane_header(reader)?;
1098 sequence.accept(header)?;
1099 Ok(header)
1100}
1101
1102fn read_json_frame<T: DeserializeOwned>(
1103 reader: &mut impl Read,
1104 header: WorkerDataPlaneFrameHeader,
1105) -> Result<T, SnapshotSessionClientError> {
1106 let payload = read_worker_data_plane_payload(reader, header)?;
1107 serde_json::from_slice(&payload).map_err(|_| SnapshotSessionClientError::InvalidControlFrame)
1108}
1109
1110fn write_json_frame<T: Serialize>(
1111 writer: &mut impl Write,
1112 kind: WorkerDataPlaneFrameKind,
1113 sequence: u64,
1114 value: &T,
1115) -> Result<(), SnapshotSessionClientError> {
1116 let payload =
1117 serde_json::to_vec(value).map_err(|_| SnapshotSessionClientError::InvalidControlFrame)?;
1118 write_worker_data_plane_frame(writer, kind, sequence, &payload)?;
1119 Ok(())
1120}
1121
1122#[allow(clippy::too_many_arguments)]
1123fn stream_validated_file(
1124 writer: &mut impl Write,
1125 kind: WorkerDataPlaneFrameKind,
1126 sequence: u64,
1127 file: &mut File,
1128 expected_size: u64,
1129 expected_sha256: &str,
1130 mismatch: &SnapshotSessionClientError,
1131) -> Result<(), SnapshotSessionClientError> {
1132 validate_open_file_bytes(file, expected_size, expected_sha256, mismatch.clone())?;
1133 let before = FileState::from_metadata(
1134 &file
1135 .metadata()
1136 .map_err(|_| SnapshotSessionClientError::LocalIo)?,
1137 );
1138 file.seek(SeekFrom::Start(0))
1139 .map_err(|_| SnapshotSessionClientError::LocalIo)?;
1140 let header = WorkerDataPlaneFrameHeader::new(kind, sequence, expected_size)?;
1141 write_worker_data_plane_header(writer, header)?;
1142 let digest = copy_exact_hashed(file, writer, expected_size)?;
1143 let mut extra = [0_u8; 1];
1144 let has_extra = file
1145 .read(&mut extra)
1146 .map_err(|_| SnapshotSessionClientError::LocalIo)?
1147 != 0;
1148 let after = FileState::from_metadata(
1149 &file
1150 .metadata()
1151 .map_err(|_| SnapshotSessionClientError::LocalIo)?,
1152 );
1153 file.seek(SeekFrom::Start(0))
1154 .map_err(|_| SnapshotSessionClientError::LocalIo)?;
1155 if has_extra || before != after || digest != expected_sha256 {
1156 return Err(SnapshotSessionClientError::SourceFileChanged);
1157 }
1158 Ok(())
1159}
1160
1161fn validate_open_file_bytes(
1162 file: &mut File,
1163 expected_size: u64,
1164 expected_sha256: &str,
1165 mismatch: SnapshotSessionClientError,
1166) -> Result<(), SnapshotSessionClientError> {
1167 let before_metadata = file
1168 .metadata()
1169 .map_err(|_| SnapshotSessionClientError::LocalIo)?;
1170 if !before_metadata.file_type().is_file() || before_metadata.len() != expected_size {
1171 return Err(mismatch);
1172 }
1173 let before = FileState::from_metadata(&before_metadata);
1174 file.seek(SeekFrom::Start(0))
1175 .map_err(|_| SnapshotSessionClientError::LocalIo)?;
1176 let (size, digest) = hash_to_end(file, expected_size)?;
1177 let after = FileState::from_metadata(
1178 &file
1179 .metadata()
1180 .map_err(|_| SnapshotSessionClientError::LocalIo)?,
1181 );
1182 file.seek(SeekFrom::Start(0))
1183 .map_err(|_| SnapshotSessionClientError::LocalIo)?;
1184 if before != after || size != expected_size || digest != expected_sha256 {
1185 return Err(mismatch);
1186 }
1187 Ok(())
1188}
1189
1190fn parse_validated_descriptor(
1191 file: &mut File,
1192 expected_size: u64,
1193 expected_sha256: &str,
1194) -> Result<SourceBundleDescriptor, SnapshotSessionClientError> {
1195 let before = FileState::from_metadata(
1196 &file
1197 .metadata()
1198 .map_err(|_| SnapshotSessionClientError::LocalIo)?,
1199 );
1200 file.seek(SeekFrom::Start(0))
1201 .map_err(|_| SnapshotSessionClientError::LocalIo)?;
1202 let mut reader = HashingReader::new(file);
1203 let descriptor = serde_json::from_reader::<_, SourceBundleDescriptor>(&mut reader)
1204 .map_err(|_| SnapshotSessionClientError::InvalidSourceDescriptor)?;
1205 let (size, digest) = reader.finish();
1206 let after = FileState::from_metadata(
1207 &file
1208 .metadata()
1209 .map_err(|_| SnapshotSessionClientError::LocalIo)?,
1210 );
1211 file.seek(SeekFrom::Start(0))
1212 .map_err(|_| SnapshotSessionClientError::LocalIo)?;
1213 if before != after || size != expected_size || digest != expected_sha256 {
1214 return Err(SnapshotSessionClientError::SourceDescriptorMismatch);
1215 }
1216 Ok(descriptor)
1217}
1218
1219fn hash_to_end(
1220 reader: &mut impl Read,
1221 expected_size: u64,
1222) -> Result<(u64, String), SnapshotSessionClientError> {
1223 let mut hasher = Sha256::new();
1224 let mut total = 0_u64;
1225 let mut buffer = [0_u8; 16 * 1024];
1226 loop {
1227 let count = reader
1228 .read(&mut buffer)
1229 .map_err(|_| SnapshotSessionClientError::LocalIo)?;
1230 if count == 0 {
1231 break;
1232 }
1233 total = total
1234 .checked_add(count as u64)
1235 .ok_or(SnapshotSessionClientError::LocalIo)?;
1236 if total > expected_size {
1237 return Ok((total, String::new()));
1238 }
1239 hasher.update(&buffer[..count]);
1240 }
1241 Ok((total, lowercase_hex(&hasher.finalize())))
1242}
1243
1244fn copy_exact_hashed(
1245 reader: &mut impl Read,
1246 writer: &mut impl Write,
1247 payload_bytes: u64,
1248) -> Result<String, SnapshotSessionClientError> {
1249 let mut remaining = payload_bytes;
1250 let mut hasher = Sha256::new();
1251 let mut buffer = [0_u8; 16 * 1024];
1252 while remaining != 0 {
1253 let chunk = usize::try_from(remaining.min(buffer.len() as u64))
1254 .map_err(|_| WorkerDataPlaneFrameError::PayloadSizeUnsupported)?;
1255 let count = reader
1256 .read(&mut buffer[..chunk])
1257 .map_err(|_| WorkerDataPlaneFrameError::Io)?;
1258 if count == 0 {
1259 return Err(WorkerDataPlaneFrameError::TruncatedPayload.into());
1260 }
1261 writer
1262 .write_all(&buffer[..count])
1263 .map_err(|_| WorkerDataPlaneFrameError::Io)?;
1264 hasher.update(&buffer[..count]);
1265 remaining -= count as u64;
1266 }
1267 Ok(lowercase_hex(&hasher.finalize()))
1268}
1269
1270fn lowercase_hex(bytes: &[u8]) -> String {
1271 const DIGITS: &[u8; 16] = b"0123456789abcdef";
1272 let mut encoded = String::with_capacity(bytes.len().saturating_mul(2));
1273 for byte in bytes {
1274 encoded.push(DIGITS[usize::from(byte >> 4)] as char);
1275 encoded.push(DIGITS[usize::from(byte & 0x0f)] as char);
1276 }
1277 encoded
1278}
1279
1280struct HashingReader<'a> {
1281 inner: &'a mut File,
1282 hasher: Sha256,
1283 total: u64,
1284}
1285
1286impl<'a> HashingReader<'a> {
1287 fn new(inner: &'a mut File) -> Self {
1288 Self {
1289 inner,
1290 hasher: Sha256::new(),
1291 total: 0,
1292 }
1293 }
1294
1295 fn finish(self) -> (u64, String) {
1296 (self.total, lowercase_hex(&self.hasher.finalize()))
1297 }
1298}
1299
1300impl Read for HashingReader<'_> {
1301 fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
1302 let count = self.inner.read(buffer)?;
1303 self.total = self.total.saturating_add(count as u64);
1304 self.hasher.update(&buffer[..count]);
1305 Ok(count)
1306 }
1307}
1308
1309#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1310struct FileState {
1311 length: u64,
1312 modified: Option<std::time::SystemTime>,
1313 #[cfg(unix)]
1314 device: u64,
1315 #[cfg(unix)]
1316 inode: u64,
1317}
1318
1319impl FileState {
1320 fn from_metadata(metadata: &fs::Metadata) -> Self {
1321 #[cfg(unix)]
1322 use std::os::unix::fs::MetadataExt as _;
1323 Self {
1324 length: metadata.len(),
1325 modified: metadata.modified().ok(),
1326 #[cfg(unix)]
1327 device: metadata.dev(),
1328 #[cfg(unix)]
1329 inode: metadata.ino(),
1330 }
1331 }
1332}
1333
1334fn same_file_identity(left: &File, right: &File) -> bool {
1335 let Ok(left) = left.try_clone().and_then(same_file::Handle::from_file) else {
1336 return false;
1337 };
1338 let Ok(right) = right.try_clone().and_then(same_file::Handle::from_file) else {
1339 return false;
1340 };
1341 left == right
1342}
1343
1344fn same_directory_entry(left: &Utf8Path, right: &Utf8Path) -> bool {
1345 left == right
1346 || fs::canonicalize(left)
1347 .ok()
1348 .zip(fs::canonicalize(right).ok())
1349 .is_some_and(|(left, right)| left == right)
1350}
1351
1352fn open_publication_identity(
1353 spool: &File,
1354 path: &Utf8Path,
1355) -> Result<File, SnapshotSessionClientError> {
1356 let published = open_publication_file(path)?;
1357 let published_metadata = published
1358 .metadata()
1359 .map_err(|_| SnapshotSessionClientError::ArtifactPublicationFailed)?;
1360 if !published_metadata.file_type().is_file() || !same_file_identity(spool, &published) {
1361 return Err(SnapshotSessionClientError::ArtifactPublicationFailed);
1362 }
1363 Ok(published)
1364}
1365
1366fn validate_published_artifact(
1367 spool: &File,
1368 retained: &File,
1369 path: &Utf8Path,
1370 expected_size: u64,
1371 expected_sha256: &str,
1372) -> Result<(), SnapshotSessionClientError> {
1373 let mut published = open_publication_file(path)?;
1374 let published_metadata = published
1375 .metadata()
1376 .map_err(|_| SnapshotSessionClientError::ArtifactPublicationFailed)?;
1377 if !published_metadata.file_type().is_file()
1378 || published_metadata.len() != expected_size
1379 || !same_file_identity(spool, &published)
1380 || !same_file_identity(retained, &published)
1381 {
1382 return Err(SnapshotSessionClientError::ArtifactPublicationFailed);
1383 }
1384 published
1385 .seek(SeekFrom::Start(0))
1386 .map_err(|_| SnapshotSessionClientError::ArtifactPublicationFailed)?;
1387 let (size, digest) = hash_to_end(&mut published, expected_size)
1388 .map_err(|_| SnapshotSessionClientError::ArtifactPublicationFailed)?;
1389 if size != expected_size || digest != expected_sha256 {
1390 return Err(SnapshotSessionClientError::ArtifactPublicationFailed);
1391 }
1392 published
1393 .sync_all()
1394 .map_err(|_| SnapshotSessionClientError::ArtifactPublicationFailed)?;
1395 sync_parent_directory(path).map_err(|_| SnapshotSessionClientError::ArtifactPublicationFailed)
1396}
1397
1398fn open_publication_file(path: &Utf8Path) -> Result<File, SnapshotSessionClientError> {
1399 let linked = fs::symlink_metadata(path)
1400 .map_err(|_| SnapshotSessionClientError::ArtifactPublicationFailed)?;
1401 if linked.file_type().is_symlink() || !linked.file_type().is_file() {
1402 return Err(SnapshotSessionClientError::ArtifactPublicationFailed);
1403 }
1404 open_file_no_follow(path).map_err(|_| SnapshotSessionClientError::ArtifactPublicationFailed)
1405}
1406
1407fn open_file_no_follow(path: &Utf8Path) -> std::io::Result<File> {
1408 let mut options = OpenOptions::new();
1409 options.read(true).write(true);
1410 #[cfg(unix)]
1411 {
1412 use std::os::unix::fs::OpenOptionsExt as _;
1413 options.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW);
1414 }
1415 #[cfg(windows)]
1416 {
1417 use std::os::windows::fs::OpenOptionsExt as _;
1418 const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
1419 options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
1420 }
1421 options.open(path)
1422}
1423
1424fn sync_parent_directory(path: &Utf8Path) -> std::io::Result<()> {
1425 let parent = path.parent().ok_or_else(|| {
1426 std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no parent")
1427 })?;
1428 #[cfg(unix)]
1429 return File::open(parent)?.sync_all();
1430 #[cfg(not(unix))]
1431 {
1432 let _ = parent;
1433 Ok(())
1434 }
1435}
1436
1437#[cfg(test)]
1438mod tests {
1439 use std::{
1440 collections::{BTreeMap, BTreeSet},
1441 io::Cursor,
1442 };
1443 #[cfg(unix)]
1444 use std::{
1445 ffi::OsString,
1446 panic::{AssertUnwindSafe, catch_unwind},
1447 process::{Command, Stdio},
1448 thread,
1449 time::{Duration, Instant},
1450 };
1451
1452 #[cfg(unix)]
1453 use rustferry_remote::CancellationToken;
1454 use rustferry_remote::{
1455 ApplePlatform, ArtifactKind, ArtifactRecord, BuildProfile, BundleIdentifier,
1456 COMPILE_PHASE_EVIDENCE_SCHEMA_VERSION, CURRENT_PROTOCOL_VERSION, CleanupConfirmation,
1457 CompilePhaseEvidence, CompileToolchainEvidence, IOS_DEVICE_RUST_TARGET, IosArtifactType,
1458 IosDeviceBuildRequest, IosDeviceProductExpectation, JobState, MachOSliceEvidence,
1459 ProtocolPathSemantics, ProtocolVersion, RemoteBuildEventKind,
1460 SEALED_UNSIGNED_ARCHIVE_SCHEMA_VERSION, SealedUnsignedArchive, SigningMode, SigningPlan,
1461 SigningTarget, SigningTargetKind, SnapshotBuildParameters, SourceArchive,
1462 SourceBundleRequest, SourceMode, UnsignedAppInspection, UnsignedXcarchiveExpectation,
1463 UnsignedXcarchiveInspection, WorkerDataPlaneFrameError, copy_worker_data_plane_payload,
1464 plan_source_bundle, write_worker_data_plane_frame,
1465 };
1466
1467 use super::*;
1468 #[cfg(unix)]
1469 use crate::{
1470 ProcessSshRunner, SshInvocation, SshSnapshotSessionError, SshTransportError,
1471 transport::test_invocation,
1472 };
1473
1474 struct Fixture {
1475 _directory: tempfile::TempDir,
1476 descriptor_path: Utf8PathBuf,
1477 archive_path: Utf8PathBuf,
1478 descriptor_bytes: Vec<u8>,
1479 archive_bytes: Vec<u8>,
1480 artifact_bytes: Vec<u8>,
1481 start: SnapshotBuildStart,
1482 accepted: SnapshotJobAccepted,
1483 artifact: SnapshotArtifactDescriptor,
1484 complete: SnapshotBuildComplete,
1485 }
1486
1487 impl Fixture {
1488 fn source_handles(&self) -> (File, File) {
1489 (
1490 File::open(&self.descriptor_path).expect("descriptor handle"),
1491 File::open(&self.archive_path).expect("archive handle"),
1492 )
1493 }
1494
1495 fn spool_path(&self, label: &str) -> Utf8PathBuf {
1496 self.descriptor_path
1497 .parent()
1498 .expect("fixture directory")
1499 .join(format!("artifact-{label}.zip"))
1500 }
1501 }
1502
1503 #[allow(clippy::too_many_lines)]
1504 fn fixture() -> Fixture {
1505 let directory = tempfile::tempdir().expect("temporary directory");
1506 let root = Utf8PathBuf::from_path_buf(directory.path().join("app")).expect("UTF-8 path");
1507 fs::create_dir_all(root.join("src")).expect("source directory");
1508 fs::write(
1509 root.join("Cargo.toml"),
1510 "[package]\nname='app'\nversion='0.1.0'\nedition='2024'\n",
1511 )
1512 .expect("Cargo.toml");
1513 fs::write(root.join("Cargo.lock"), "# fixture\n").expect("Cargo.lock");
1514 fs::write(root.join("ferry.toml"), "[app]\nname='App'\n").expect("ferry.toml");
1515 fs::write(root.join("src/main.rs"), "fn main() {}\n").expect("main.rs");
1516 let plan =
1517 plan_source_bundle(&SourceBundleRequest::new(&root, &root)).expect("source plan");
1518
1519 let archive_bytes = b"deterministic source archive fixture".repeat(4);
1520 let source_archive = SourceArchive {
1521 size: archive_bytes.len() as u64,
1522 sha256: sha256(&archive_bytes),
1523 };
1524 let descriptor =
1525 SourceBundleDescriptor::new(source_archive.clone(), plan.manifest().clone());
1526 descriptor
1527 .validate(SourceArchiveLimits::default())
1528 .expect("source descriptor");
1529 let descriptor_bytes = serde_json::to_vec(&descriptor).expect("descriptor JSON");
1530 let descriptor_path = root
1531 .parent()
1532 .expect("fixture directory")
1533 .join("source.json");
1534 let archive_path = root.parent().expect("fixture directory").join("source.zip");
1535 fs::write(&descriptor_path, &descriptor_bytes).expect("descriptor file");
1536 fs::write(&archive_path, &archive_bytes).expect("archive file");
1537
1538 let signing = SigningPlan {
1539 mode: SigningMode::UnsignedCompileOnly,
1540 signing: None,
1541 team: None,
1542 device: None,
1543 targets: vec![SigningTarget {
1544 name: "App".to_owned(),
1545 bundle_identifier: BundleIdentifier::new("com.example.app")
1546 .expect("bundle identifier"),
1547 kind: SigningTargetKind::Application,
1548 }],
1549 provisioning: Vec::new(),
1550 entitlements: Vec::new(),
1551 allow_provisioning_updates: false,
1552 };
1553 let request = IosDeviceBuildRequest {
1554 protocol_version: CURRENT_PROTOCOL_VERSION,
1555 operation_id: "operation-1".to_owned(),
1556 product_name: "App".to_owned(),
1557 bundle_identifier: "com.example.app".to_owned(),
1558 minimum_ios_version: "16.0".to_owned(),
1559 product: IosDeviceProductExpectation {
1560 app_directory_name: "App.app".to_owned(),
1561 executable: "App".to_owned(),
1562 app_version: "1.0.0".to_owned(),
1563 build_number: "1".to_owned(),
1564 nested_bundles: Vec::new(),
1565 },
1566 profile: BuildProfile::Debug,
1567 source_mode: SourceMode::Snapshot,
1568 source_repository: None,
1569 source_revision: None,
1570 source: descriptor.manifest.clone(),
1571 signing,
1572 requested_artifacts: BTreeSet::from([IosArtifactType::Xcarchive]),
1573 };
1574 request.validate().expect("request");
1575 let start = SnapshotBuildStart::new(
1576 SnapshotBuildParameters::from_request(&request).expect("parameters"),
1577 descriptor_bytes.len() as u64,
1578 sha256(&descriptor_bytes),
1579 source_archive,
1580 )
1581 .expect("build start");
1582 let accepted =
1583 SnapshotJobAccepted::new("operation-1", "job-1", 10).expect("job acknowledgement");
1584
1585 let artifact_bytes = vec![0x5a; 456];
1586 let artifact_archive = SourceArchive {
1587 size: artifact_bytes.len() as u64,
1588 sha256: sha256(&artifact_bytes),
1589 };
1590 let expectation = UnsignedXcarchiveExpectation {
1591 app_directory_name: "App.app".to_owned(),
1592 bundle_identifier: "com.example.app".to_owned(),
1593 executable: "App".to_owned(),
1594 app_version: "1.0.0".to_owned(),
1595 build_number: "1".to_owned(),
1596 minimum_os: "16.0".to_owned(),
1597 sdk_version: "18.5".to_owned(),
1598 sdk_build_version: "22F76".to_owned(),
1599 nested_bundles: Vec::new(),
1600 required_resources: BTreeMap::new(),
1601 };
1602 let slice = MachOSliceEvidence {
1603 architecture: "arm64".to_owned(),
1604 platform: ApplePlatform::Ios,
1605 minimum_os: Some("16.0.0".to_owned()),
1606 sdk: Some("18.5.0".to_owned()),
1607 };
1608 let inspection = UnsignedXcarchiveInspection {
1609 application_path: "Applications/App.app".to_owned(),
1610 architectures: vec!["arm64".to_owned()],
1611 app: UnsignedAppInspection {
1612 app_directory_name: "App.app".to_owned(),
1613 bundle_identifier: "com.example.app".to_owned(),
1614 executable: "App".to_owned(),
1615 main_executable: vec![slice],
1616 nested_executables: BTreeMap::new(),
1617 extensions: Vec::new(),
1618 resources: BTreeMap::new(),
1619 entries: vec!["App".to_owned()],
1620 },
1621 entries: vec!["Products/Applications/App.app/App".to_owned()],
1622 };
1623 let compile = CompilePhaseEvidence {
1624 schema_version: COMPILE_PHASE_EVIDENCE_SCHEMA_VERSION,
1625 job_id: accepted.job_id.clone(),
1626 provider: "ssh-macos".to_owned(),
1627 request_sha256: canonical_request_sha256(&request).expect("request hash"),
1628 source_sha256: request.source.sha256.clone(),
1629 cargo_lock_sha256: "d".repeat(64),
1630 config_sha256: "e".repeat(64),
1631 rustferry_version: "0.1.0".to_owned(),
1632 worker_version: "0.1.0".to_owned(),
1633 toolchain: CompileToolchainEvidence {
1634 worker_os: "macOS 15.0".to_owned(),
1635 worker_architecture: "arm64".to_owned(),
1636 xcode_version: "16.4".to_owned(),
1637 iphoneos_sdk_version: "18.5".to_owned(),
1638 iphoneos_sdk_build_version: "22F76".to_owned(),
1639 developer_directory_sha256: "f".repeat(64),
1640 rust_version: "rustc 1.92.0".to_owned(),
1641 rust_target: IOS_DEVICE_RUST_TARGET.to_owned(),
1642 },
1643 sealed_archive: SealedUnsignedArchive {
1644 schema_version: SEALED_UNSIGNED_ARCHIVE_SCHEMA_VERSION,
1645 transport: artifact_archive.clone(),
1646 contents: descriptor.manifest,
1647 expectation,
1648 },
1649 archive_inspection: inspection,
1650 started_at_unix_seconds: 100,
1651 finished_at_unix_seconds: 200,
1652 };
1653 let artifact = SnapshotArtifactDescriptor::new(
1654 request.operation_id.clone(),
1655 ArtifactRecord {
1656 artifact_id: "archive-1".to_owned(),
1657 kind: ArtifactKind::Xcarchive,
1658 file_name: "App-unsigned.xcarchive.zip".to_owned(),
1659 size: artifact_archive.size,
1660 sha256: artifact_archive.sha256,
1661 media_type: Some("application/zip".to_owned()),
1662 },
1663 compile,
1664 )
1665 .expect("artifact descriptor");
1666 let complete = SnapshotBuildComplete::new(
1667 request.operation_id,
1668 CleanupConfirmation {
1669 job_id: accepted.job_id.clone(),
1670 completed_at_ms: 300,
1671 workspace_removed: true,
1672 signing_material_removed: true,
1673 artifacts_retained: false,
1674 },
1675 )
1676 .expect("completion");
1677
1678 Fixture {
1679 _directory: directory,
1680 descriptor_path,
1681 archive_path,
1682 descriptor_bytes,
1683 archive_bytes,
1684 artifact_bytes,
1685 start,
1686 accepted,
1687 artifact,
1688 complete,
1689 }
1690 }
1691
1692 fn event(fixture: &Fixture, sequence: u64, kind: RemoteBuildEventKind) -> RemoteBuildEvent {
1693 RemoteBuildEvent::new(
1694 fixture.start.parameters.operation_id.clone(),
1695 fixture.accepted.job_id.clone(),
1696 20 + sequence,
1697 "ssh-macos",
1698 "compile",
1699 sequence,
1700 kind,
1701 )
1702 .expect("event")
1703 }
1704
1705 fn success_transcript(fixture: &Fixture) -> Vec<u8> {
1706 let first = event(
1707 fixture,
1708 0,
1709 RemoteBuildEventKind::JobCreated {
1710 state: JobState::Created,
1711 },
1712 );
1713 let second = event(fixture, 1, RemoteBuildEventKind::CleanupStarted);
1714 let mut transcript = Vec::new();
1715 write_json_frame(
1716 &mut transcript,
1717 WorkerDataPlaneFrameKind::JobAccepted,
1718 0,
1719 &fixture.accepted,
1720 )
1721 .expect("accepted frame");
1722 write_json_frame(&mut transcript, WorkerDataPlaneFrameKind::Event, 1, &first)
1723 .expect("first event");
1724 write_json_frame(
1725 &mut transcript,
1726 WorkerDataPlaneFrameKind::ArtifactDescriptor,
1727 2,
1728 &fixture.artifact,
1729 )
1730 .expect("artifact descriptor");
1731 write_worker_data_plane_frame(
1732 &mut transcript,
1733 WorkerDataPlaneFrameKind::Artifact,
1734 3,
1735 &fixture.artifact_bytes,
1736 )
1737 .expect("artifact frame");
1738 write_json_frame(&mut transcript, WorkerDataPlaneFrameKind::Event, 4, &second)
1739 .expect("second event");
1740 write_json_frame(
1741 &mut transcript,
1742 WorkerDataPlaneFrameKind::Complete,
1743 5,
1744 &fixture.complete,
1745 )
1746 .expect("complete frame");
1747 transcript
1748 }
1749
1750 #[test]
1751 fn success_transcript_streams_exact_files_and_receipts_verified_artifact() {
1752 let fixture = fixture();
1753 let mut server = Cursor::new(success_transcript(&fixture));
1754 let mut client = Vec::new();
1755 let (mut descriptor, mut archive) = fixture.source_handles();
1756 let spool_path = fixture.spool_path("success");
1757 let mut spool = CreateOnlyArtifactSpool::create(&spool_path).expect("create-only spool");
1758 let mut events = Vec::new();
1759 let outcome = run_snapshot_session(
1760 &mut server,
1761 &mut client,
1762 SnapshotSessionRequest::new(
1763 &fixture.start,
1764 &mut descriptor,
1765 &mut archive,
1766 SourceArchiveLimits::default(),
1767 ),
1768 &mut spool,
1769 |event| events.push(event),
1770 |file, path, offered| {
1771 let mut bytes = Vec::new();
1772 file.read_to_end(&mut bytes).expect("verify spool bytes");
1773 assert_eq!(bytes, fixture.artifact_bytes);
1774 assert_eq!(offered, &fixture.artifact);
1775 ProtocolPath::new(ProtocolPathSemantics::ClientAbsolute, path.to_string())
1776 },
1777 )
1778 .expect("successful session");
1779
1780 assert_eq!(outcome.accepted, fixture.accepted);
1781 assert_eq!(outcome.artifact, fixture.artifact);
1782 assert_eq!(outcome.complete, fixture.complete);
1783 assert_eq!(outcome.receipt.local_path.value, spool_path);
1784 assert_eq!(events.len(), 2);
1785 assert_eq!(
1786 fs::read(&spool_path).expect("spooled artifact"),
1787 fixture.artifact_bytes
1788 );
1789 spool.commit().expect("commit verified publication");
1790
1791 let mut client = Cursor::new(client);
1792 let mut sequence = WorkerDataPlaneSequence::new();
1793 let start_header = read_sequenced_header(&mut client, &mut sequence).expect("start header");
1794 assert_eq!(start_header.kind(), WorkerDataPlaneFrameKind::BuildRequest);
1795 assert_eq!(
1796 read_json_frame::<SnapshotBuildStart>(&mut client, start_header).expect("start JSON"),
1797 fixture.start
1798 );
1799 let descriptor_header =
1800 read_sequenced_header(&mut client, &mut sequence).expect("descriptor header");
1801 assert_eq!(
1802 descriptor_header.kind(),
1803 WorkerDataPlaneFrameKind::SourceDescriptor
1804 );
1805 assert_eq!(
1806 read_worker_data_plane_payload(&mut client, descriptor_header)
1807 .expect("descriptor payload"),
1808 fixture.descriptor_bytes
1809 );
1810 let archive_header =
1811 read_sequenced_header(&mut client, &mut sequence).expect("archive header");
1812 assert_eq!(
1813 archive_header.kind(),
1814 WorkerDataPlaneFrameKind::SourceArchive
1815 );
1816 let mut archive_bytes = Vec::new();
1817 copy_worker_data_plane_payload(&mut client, &mut archive_bytes, archive_header)
1818 .expect("archive payload");
1819 assert_eq!(archive_bytes, fixture.archive_bytes);
1820 let receipt_header =
1821 read_sequenced_header(&mut client, &mut sequence).expect("receipt header");
1822 assert_eq!(
1823 receipt_header.kind(),
1824 WorkerDataPlaneFrameKind::ArtifactReceipt
1825 );
1826 assert_eq!(
1827 read_json_frame::<SnapshotArtifactReceipt>(&mut client, receipt_header)
1828 .expect("receipt JSON"),
1829 outcome.receipt
1830 );
1831 assert_eq!(
1832 read_worker_data_plane_header(&mut client),
1833 Err(WorkerDataPlaneFrameError::EmptyInput)
1834 );
1835 }
1836
1837 #[test]
1838 fn bad_server_sequence_and_kind_fail_before_payload_interpretation() {
1839 let fixture = fixture();
1840 let mut wrong_sequence = Vec::new();
1841 write_json_frame(
1842 &mut wrong_sequence,
1843 WorkerDataPlaneFrameKind::JobAccepted,
1844 1,
1845 &fixture.accepted,
1846 )
1847 .expect("wrong sequence frame");
1848 let (error, _) = run_failure(&fixture, wrong_sequence, "bad-sequence");
1849 assert_eq!(
1850 error,
1851 SnapshotSessionClientError::DataPlane(WorkerDataPlaneFrameError::UnexpectedSequence {
1852 expected: 0,
1853 received: 1,
1854 })
1855 );
1856
1857 let mut wrong_kind = Vec::new();
1858 write_worker_data_plane_frame(&mut wrong_kind, WorkerDataPlaneFrameKind::Event, 0, b"{}")
1859 .expect("wrong kind frame");
1860 let (error, _) = run_failure(&fixture, wrong_kind, "bad-kind");
1861 assert_eq!(
1862 error,
1863 SnapshotSessionClientError::UnexpectedServerFrame {
1864 expected: "job_accepted or error",
1865 received: WorkerDataPlaneFrameKind::Event,
1866 }
1867 );
1868 }
1869
1870 #[test]
1871 fn truncated_or_hash_mismatched_artifact_never_emits_receipt() {
1872 let fixture = fixture();
1873 let mut truncated = artifact_prefix(&fixture);
1874 let header = WorkerDataPlaneFrameHeader::new(
1875 WorkerDataPlaneFrameKind::Artifact,
1876 2,
1877 fixture.artifact_bytes.len() as u64,
1878 )
1879 .expect("artifact header");
1880 write_worker_data_plane_header(&mut truncated, header).expect("artifact header bytes");
1881 truncated.extend_from_slice(&fixture.artifact_bytes[..32]);
1882 let (error, client) = run_failure(&fixture, truncated, "truncated");
1883 assert_eq!(
1884 error,
1885 SnapshotSessionClientError::DataPlane(WorkerDataPlaneFrameError::TruncatedPayload)
1886 );
1887 assert!(!client_kinds(&client).contains(&WorkerDataPlaneFrameKind::ArtifactReceipt));
1888
1889 let mut wrong_hash = artifact_prefix(&fixture);
1890 let mismatched = vec![0x3c; fixture.artifact_bytes.len()];
1891 write_worker_data_plane_frame(
1892 &mut wrong_hash,
1893 WorkerDataPlaneFrameKind::Artifact,
1894 2,
1895 &mismatched,
1896 )
1897 .expect("mismatched artifact");
1898 let (error, client) = run_failure(&fixture, wrong_hash, "hash-mismatch");
1899 assert_eq!(error, SnapshotSessionClientError::ArtifactPayloadMismatch);
1900 assert!(!client_kinds(&client).contains(&WorkerDataPlaneFrameKind::ArtifactReceipt));
1901 }
1902
1903 #[test]
1904 fn artifact_without_a_validated_progress_event_never_reaches_verifier_or_receipt() {
1905 let fixture = fixture();
1906 let mut transcript = artifact_prefix(&fixture);
1907 write_worker_data_plane_frame(
1908 &mut transcript,
1909 WorkerDataPlaneFrameKind::Artifact,
1910 2,
1911 &fixture.artifact_bytes,
1912 )
1913 .expect("artifact frame");
1914 let mut server = Cursor::new(transcript);
1915 let mut client = Vec::new();
1916 let (mut descriptor, mut archive) = fixture.source_handles();
1917 let spool_path = fixture.spool_path("missing-progress-event");
1918 let mut spool = CreateOnlyArtifactSpool::create(&spool_path).expect("create-only spool");
1919 let mut verifier_called = false;
1920 let error = run_snapshot_session(
1921 &mut server,
1922 &mut client,
1923 SnapshotSessionRequest::new(
1924 &fixture.start,
1925 &mut descriptor,
1926 &mut archive,
1927 SourceArchiveLimits::default(),
1928 ),
1929 &mut spool,
1930 |_| {},
1931 |_, path, _| {
1932 verifier_called = true;
1933 ProtocolPath::new(ProtocolPathSemantics::ClientAbsolute, path.to_string())
1934 },
1935 )
1936 .expect_err("missing progress event");
1937 assert_eq!(error, SnapshotSessionClientError::MissingProgressEvent);
1938 assert!(!verifier_called);
1939 assert!(!client_kinds(&client).contains(&WorkerDataPlaneFrameKind::ArtifactReceipt));
1940 assert!(!spool_path.exists());
1941 }
1942
1943 #[test]
1944 fn source_hash_mismatch_is_rejected_before_any_wire_write() {
1945 let fixture = fixture();
1946 fs::write(&fixture.archive_path, b"substituted source archive")
1947 .expect("replace source archive");
1948 let (error, client) = run_failure(&fixture, Vec::new(), "source-mismatch");
1949 assert_eq!(error, SnapshotSessionClientError::SourceArchiveMismatch);
1950 assert!(client.is_empty());
1951 }
1952
1953 #[test]
1954 fn compile_evidence_must_bind_the_reconstructed_request() {
1955 let fixture = fixture();
1956 let mut substituted = fixture.artifact.clone();
1957 substituted.compile.request_sha256 = "0".repeat(64);
1958 substituted
1959 .validate()
1960 .expect("intrinsically valid descriptor");
1961 let mut transcript = Vec::new();
1962 write_json_frame(
1963 &mut transcript,
1964 WorkerDataPlaneFrameKind::JobAccepted,
1965 0,
1966 &fixture.accepted,
1967 )
1968 .expect("accepted frame");
1969 write_json_frame(
1970 &mut transcript,
1971 WorkerDataPlaneFrameKind::ArtifactDescriptor,
1972 1,
1973 &substituted,
1974 )
1975 .expect("substituted descriptor");
1976 let (error, client) = run_failure(&fixture, transcript, "evidence-mismatch");
1977 assert_eq!(error, SnapshotSessionClientError::CompileEvidenceMismatch);
1978 assert!(!client_kinds(&client).contains(&WorkerDataPlaneFrameKind::ArtifactReceipt));
1979 }
1980
1981 #[test]
1982 fn verifier_rejection_never_emits_artifact_receipt() {
1983 let fixture = fixture();
1984 let mut server = Cursor::new(success_transcript(&fixture));
1985 let mut client = Vec::new();
1986 let (mut descriptor, mut archive) = fixture.source_handles();
1987 let spool_path = fixture.spool_path("verifier-rejected");
1988 let mut spool = CreateOnlyArtifactSpool::create(&spool_path).expect("create-only spool");
1989 let error = run_snapshot_session(
1990 &mut server,
1991 &mut client,
1992 SnapshotSessionRequest::new(
1993 &fixture.start,
1994 &mut descriptor,
1995 &mut archive,
1996 SourceArchiveLimits::default(),
1997 ),
1998 &mut spool,
1999 |_| {},
2000 |_, _, _| Err::<ProtocolPath, ()>(()),
2001 )
2002 .expect_err("verifier rejection");
2003 assert_eq!(
2004 error,
2005 SnapshotSessionClientError::ArtifactVerificationFailed
2006 );
2007 assert!(!client_kinds(&client).contains(&WorkerDataPlaneFrameKind::ArtifactReceipt));
2008 assert!(!spool_path.exists());
2009 }
2010
2011 #[test]
2012 fn validated_server_error_is_returned_without_artifact_receipt() {
2013 let fixture = fixture();
2014 let failure = SnapshotSessionError::new(
2015 Some(fixture.start.parameters.operation_id.clone()),
2016 Some(fixture.accepted.job_id.clone()),
2017 "worker.build_failed",
2018 "Unsigned build failed",
2019 false,
2020 None,
2021 )
2022 .expect("server error");
2023 let mut transcript = Vec::new();
2024 write_json_frame(
2025 &mut transcript,
2026 WorkerDataPlaneFrameKind::JobAccepted,
2027 0,
2028 &fixture.accepted,
2029 )
2030 .expect("accepted frame");
2031 write_json_frame(
2032 &mut transcript,
2033 WorkerDataPlaneFrameKind::Error,
2034 1,
2035 &failure,
2036 )
2037 .expect("error frame");
2038 let (error, client) = run_failure(&fixture, transcript, "server-error");
2039 assert_eq!(error, SnapshotSessionClientError::Server(Box::new(failure)));
2040 assert!(!client_kinds(&client).contains(&WorkerDataPlaneFrameKind::ArtifactReceipt));
2041 }
2042
2043 #[test]
2044 fn publication_requires_an_existing_same_inode_final_path() {
2045 let fixture = fixture();
2046 let missing = fixture.spool_path("missing-publication");
2047 let (error, client) = run_with_publication_path(
2048 &fixture,
2049 success_transcript(&fixture),
2050 "missing-publication-spool",
2051 &missing,
2052 );
2053 assert_eq!(error, SnapshotSessionClientError::ArtifactPublicationFailed);
2054 assert!(!client_kinds(&client).contains(&WorkerDataPlaneFrameKind::ArtifactReceipt));
2055
2056 let unrelated = fixture.spool_path("unrelated-publication");
2057 fs::write(&unrelated, &fixture.artifact_bytes).expect("unrelated artifact");
2058 let (error, client) = run_with_publication_path(
2059 &fixture,
2060 success_transcript(&fixture),
2061 "unrelated-publication-spool",
2062 &unrelated,
2063 );
2064 assert_eq!(error, SnapshotSessionClientError::ArtifactPublicationFailed);
2065 assert!(!client_kinds(&client).contains(&WorkerDataPlaneFrameKind::ArtifactReceipt));
2066 assert_eq!(
2067 fs::read(&unrelated).expect("preserved unrelated artifact"),
2068 fixture.artifact_bytes
2069 );
2070 }
2071
2072 #[cfg(unix)]
2073 #[test]
2074 fn hardlink_publication_commits_only_after_explicit_authorization() {
2075 let fixture = fixture();
2076 let final_path = fixture.spool_path("hardlink-final");
2077 let spool_path = fixture.spool_path("hardlink-staging");
2078 let mut server = Cursor::new(success_transcript(&fixture));
2079 let mut client = Vec::new();
2080 let (mut descriptor, mut archive) = fixture.source_handles();
2081 let mut spool = CreateOnlyArtifactSpool::create(&spool_path).expect("create-only spool");
2082 run_snapshot_session(
2083 &mut server,
2084 &mut client,
2085 SnapshotSessionRequest::new(
2086 &fixture.start,
2087 &mut descriptor,
2088 &mut archive,
2089 SourceArchiveLimits::default(),
2090 ),
2091 &mut spool,
2092 |_| {},
2093 |_, staging, _| {
2094 fs::hard_link(staging, &final_path).expect("publish hard link");
2095 ProtocolPath::new(
2096 ProtocolPathSemantics::ClientAbsolute,
2097 final_path.to_string(),
2098 )
2099 },
2100 )
2101 .expect("successful hardlink session");
2102 assert!(spool_path.exists());
2103 assert!(final_path.exists());
2104 spool.commit().expect("commit hardlink publication");
2105 assert!(!spool_path.exists());
2106 assert_eq!(
2107 fs::read(final_path).expect("committed final artifact"),
2108 fixture.artifact_bytes
2109 );
2110 }
2111
2112 #[test]
2113 fn explicit_abort_removes_staging_and_is_idempotent() {
2114 let fixture = fixture();
2115 let spool_path = fixture.spool_path("explicit-abort");
2116 let mut spool = CreateOnlyArtifactSpool::create(&spool_path).expect("create-only spool");
2117 assert!(spool_path.exists());
2118 spool.abort().expect("abort staging spool");
2119 assert!(!spool_path.exists());
2120 spool.abort().expect("repeat abort");
2121 }
2122
2123 #[cfg(unix)]
2124 #[test]
2125 fn commit_revalidates_publication_after_removing_staging_link() {
2126 let fixture = fixture();
2127 let final_path = fixture.spool_path("commit-race-final");
2128 let spool_path = fixture.spool_path("commit-race-staging");
2129 let mut server = Cursor::new(success_transcript(&fixture));
2130 let mut client = Vec::new();
2131 let (mut descriptor, mut archive) = fixture.source_handles();
2132 let mut spool = CreateOnlyArtifactSpool::create(&spool_path).expect("create-only spool");
2133 run_snapshot_session(
2134 &mut server,
2135 &mut client,
2136 SnapshotSessionRequest::new(
2137 &fixture.start,
2138 &mut descriptor,
2139 &mut archive,
2140 SourceArchiveLimits::default(),
2141 ),
2142 &mut spool,
2143 |_| {},
2144 |_, staging, _| {
2145 fs::hard_link(staging, &final_path).expect("publish hard link");
2146 ProtocolPath::new(
2147 ProtocolPathSemantics::ClientAbsolute,
2148 final_path.to_string(),
2149 )
2150 },
2151 )
2152 .expect("successful hardlink session");
2153
2154 let error = spool
2155 .commit_with(|_| {
2156 fs::remove_file(&final_path).expect("remove verified final link");
2157 fs::write(&final_path, b"concurrent replacement").expect("replace final path");
2158 })
2159 .expect_err("replaced publication cannot commit");
2160 assert_eq!(error, SnapshotSessionClientError::ArtifactCleanupFailed);
2161 assert!(!spool_path.exists());
2162 assert_eq!(
2163 fs::read(final_path).expect("preserved concurrent replacement"),
2164 b"concurrent replacement"
2165 );
2166 }
2167
2168 #[cfg(unix)]
2169 #[test]
2170 fn explicit_abort_preserves_replaced_publication_and_reports_cleanup_failure() {
2171 let fixture = fixture();
2172 let final_path = fixture.spool_path("abort-race-final");
2173 let spool_path = fixture.spool_path("abort-race-staging");
2174 let mut server = Cursor::new(success_transcript(&fixture));
2175 let mut client = Vec::new();
2176 let (mut descriptor, mut archive) = fixture.source_handles();
2177 let mut spool = CreateOnlyArtifactSpool::create(&spool_path).expect("create-only spool");
2178 run_snapshot_session(
2179 &mut server,
2180 &mut client,
2181 SnapshotSessionRequest::new(
2182 &fixture.start,
2183 &mut descriptor,
2184 &mut archive,
2185 SourceArchiveLimits::default(),
2186 ),
2187 &mut spool,
2188 |_| {},
2189 |_, staging, _| {
2190 fs::hard_link(staging, &final_path).expect("publish hard link");
2191 ProtocolPath::new(
2192 ProtocolPathSemantics::ClientAbsolute,
2193 final_path.to_string(),
2194 )
2195 },
2196 )
2197 .expect("successful hardlink session");
2198 fs::remove_file(&final_path).expect("remove verified final link");
2199 fs::write(&final_path, b"concurrent replacement").expect("replace final path");
2200
2201 let error = spool
2202 .abort()
2203 .expect_err("replacement makes cleanup uncertain");
2204 assert_eq!(error, SnapshotSessionClientError::ArtifactCleanupFailed);
2205 assert!(!spool_path.exists());
2206 assert_eq!(
2207 fs::read(final_path).expect("preserved concurrent replacement"),
2208 b"concurrent replacement"
2209 );
2210 }
2211
2212 #[cfg(unix)]
2213 #[test]
2214 fn uncommitted_success_and_post_receipt_error_remove_all_owned_links() {
2215 let fixture = fixture();
2216 let final_path = fixture.spool_path("uncommitted-final");
2217 let spool_path = fixture.spool_path("uncommitted-staging");
2218 {
2219 let mut server = Cursor::new(success_transcript(&fixture));
2220 let mut client = Vec::new();
2221 let (mut descriptor, mut archive) = fixture.source_handles();
2222 let mut spool =
2223 CreateOnlyArtifactSpool::create(&spool_path).expect("create-only spool");
2224 run_snapshot_session(
2225 &mut server,
2226 &mut client,
2227 SnapshotSessionRequest::new(
2228 &fixture.start,
2229 &mut descriptor,
2230 &mut archive,
2231 SourceArchiveLimits::default(),
2232 ),
2233 &mut spool,
2234 |_| {},
2235 |_, staging, _| {
2236 fs::hard_link(staging, &final_path).expect("publish hard link");
2237 ProtocolPath::new(
2238 ProtocolPathSemantics::ClientAbsolute,
2239 final_path.to_string(),
2240 )
2241 },
2242 )
2243 .expect("successful but uncommitted session");
2244 }
2245 assert!(!spool_path.exists());
2246 assert!(!final_path.exists());
2247
2248 let final_path = fixture.spool_path("terminal-error-final");
2249 let spool_path = fixture.spool_path("terminal-error-staging");
2250 let failure = SnapshotSessionError::new(
2251 Some(fixture.start.parameters.operation_id.clone()),
2252 Some(fixture.accepted.job_id.clone()),
2253 "worker.cleanup_failed",
2254 "Cleanup failed",
2255 false,
2256 None,
2257 )
2258 .expect("server error");
2259 let mut transcript = artifact_prefix_with_event(&fixture);
2260 write_worker_data_plane_frame(
2261 &mut transcript,
2262 WorkerDataPlaneFrameKind::Artifact,
2263 3,
2264 &fixture.artifact_bytes,
2265 )
2266 .expect("artifact frame");
2267 write_json_frame(
2268 &mut transcript,
2269 WorkerDataPlaneFrameKind::Error,
2270 4,
2271 &failure,
2272 )
2273 .expect("terminal error");
2274 let mut server = Cursor::new(transcript);
2275 let mut client = Vec::new();
2276 let (mut descriptor, mut archive) = fixture.source_handles();
2277 let mut spool = CreateOnlyArtifactSpool::create(&spool_path).expect("create-only spool");
2278 let error = run_snapshot_session(
2279 &mut server,
2280 &mut client,
2281 SnapshotSessionRequest::new(
2282 &fixture.start,
2283 &mut descriptor,
2284 &mut archive,
2285 SourceArchiveLimits::default(),
2286 ),
2287 &mut spool,
2288 |_| {},
2289 |_, staging, _| {
2290 fs::hard_link(staging, &final_path).expect("publish hard link");
2291 ProtocolPath::new(
2292 ProtocolPathSemantics::ClientAbsolute,
2293 final_path.to_string(),
2294 )
2295 },
2296 )
2297 .expect_err("post-receipt server error");
2298 assert_eq!(error, SnapshotSessionClientError::Server(Box::new(failure)));
2299 assert!(client_kinds(&client).contains(&WorkerDataPlaneFrameKind::ArtifactReceipt));
2300 assert!(!spool_path.exists());
2301 assert!(!final_path.exists());
2302 }
2303
2304 #[test]
2305 fn events_require_ssh_provider_and_one_sequence_across_both_phases() {
2306 let fixture = fixture();
2307 let mut wrong_sequence = event(
2308 &fixture,
2309 1,
2310 RemoteBuildEventKind::JobCreated {
2311 state: JobState::Created,
2312 },
2313 );
2314 wrong_sequence.sequence = 1;
2315 let mut transcript = Vec::new();
2316 write_json_frame(
2317 &mut transcript,
2318 WorkerDataPlaneFrameKind::JobAccepted,
2319 0,
2320 &fixture.accepted,
2321 )
2322 .expect("accepted frame");
2323 write_json_frame(
2324 &mut transcript,
2325 WorkerDataPlaneFrameKind::Event,
2326 1,
2327 &wrong_sequence,
2328 )
2329 .expect("wrong event sequence");
2330 let (error, _) = run_failure(&fixture, transcript, "event-sequence");
2331 assert_eq!(error, SnapshotSessionClientError::EventSequenceMismatch);
2332
2333 let mut wrong_provider = event(
2334 &fixture,
2335 0,
2336 RemoteBuildEventKind::JobCreated {
2337 state: JobState::Created,
2338 },
2339 );
2340 wrong_provider.provider = "github-actions".to_owned();
2341 let mut transcript = Vec::new();
2342 write_json_frame(
2343 &mut transcript,
2344 WorkerDataPlaneFrameKind::JobAccepted,
2345 0,
2346 &fixture.accepted,
2347 )
2348 .expect("accepted frame");
2349 write_json_frame(
2350 &mut transcript,
2351 WorkerDataPlaneFrameKind::Event,
2352 1,
2353 &wrong_provider,
2354 )
2355 .expect("wrong provider event");
2356 let (error, _) = run_failure(&fixture, transcript, "event-provider");
2357 assert_eq!(error, SnapshotSessionClientError::EventIdentityMismatch);
2358
2359 let first = event(
2360 &fixture,
2361 0,
2362 RemoteBuildEventKind::JobCreated {
2363 state: JobState::Created,
2364 },
2365 );
2366 let repeated = event(&fixture, 0, RemoteBuildEventKind::CleanupStarted);
2367 let mut transcript = Vec::new();
2368 write_json_frame(
2369 &mut transcript,
2370 WorkerDataPlaneFrameKind::JobAccepted,
2371 0,
2372 &fixture.accepted,
2373 )
2374 .expect("accepted frame");
2375 write_json_frame(&mut transcript, WorkerDataPlaneFrameKind::Event, 1, &first)
2376 .expect("first event");
2377 write_json_frame(
2378 &mut transcript,
2379 WorkerDataPlaneFrameKind::ArtifactDescriptor,
2380 2,
2381 &fixture.artifact,
2382 )
2383 .expect("artifact descriptor");
2384 write_worker_data_plane_frame(
2385 &mut transcript,
2386 WorkerDataPlaneFrameKind::Artifact,
2387 3,
2388 &fixture.artifact_bytes,
2389 )
2390 .expect("artifact frame");
2391 write_json_frame(
2392 &mut transcript,
2393 WorkerDataPlaneFrameKind::Event,
2394 4,
2395 &repeated,
2396 )
2397 .expect("repeated event");
2398 let (error, client) = run_failure(&fixture, transcript, "event-repeated");
2399 assert_eq!(error, SnapshotSessionClientError::EventSequenceMismatch);
2400 assert!(client_kinds(&client).contains(&WorkerDataPlaneFrameKind::ArtifactReceipt));
2401 }
2402
2403 #[test]
2404 fn compile_provider_and_post_accept_error_ids_are_exact() {
2405 let fixture = fixture();
2406 let mut artifact = fixture.artifact.clone();
2407 artifact.compile.provider = "github-actions".to_owned();
2408 let mut transcript = Vec::new();
2409 write_json_frame(
2410 &mut transcript,
2411 WorkerDataPlaneFrameKind::JobAccepted,
2412 0,
2413 &fixture.accepted,
2414 )
2415 .expect("accepted frame");
2416 write_json_frame(
2417 &mut transcript,
2418 WorkerDataPlaneFrameKind::Event,
2419 1,
2420 &event(
2421 &fixture,
2422 0,
2423 RemoteBuildEventKind::JobCreated {
2424 state: JobState::Created,
2425 },
2426 ),
2427 )
2428 .expect("progress event");
2429 write_json_frame(
2430 &mut transcript,
2431 WorkerDataPlaneFrameKind::ArtifactDescriptor,
2432 2,
2433 &artifact,
2434 )
2435 .expect("artifact descriptor");
2436 let (error, _) = run_failure(&fixture, transcript, "compile-provider");
2437 assert_eq!(error, SnapshotSessionClientError::CompileEvidenceMismatch);
2438
2439 let missing_ids =
2440 SnapshotSessionError::new(None, None, "worker.failed", "Build failed", false, None)
2441 .expect("server error");
2442 let mut transcript = Vec::new();
2443 write_json_frame(
2444 &mut transcript,
2445 WorkerDataPlaneFrameKind::JobAccepted,
2446 0,
2447 &fixture.accepted,
2448 )
2449 .expect("accepted frame");
2450 write_json_frame(
2451 &mut transcript,
2452 WorkerDataPlaneFrameKind::Error,
2453 1,
2454 &missing_ids,
2455 )
2456 .expect("missing-id error");
2457 let (error, _) = run_failure(&fixture, transcript, "missing-error-ids");
2458 assert_eq!(error, SnapshotSessionClientError::JobIdentityMismatch);
2459 }
2460
2461 #[test]
2462 fn replies_bind_to_negotiated_version_while_request_hash_keeps_requested_minor() {
2463 let fixture = fixture();
2464 let mut start = fixture.start.clone();
2465 start.parameters.protocol_version = ProtocolVersion::new(1, 1);
2466 start.validate().expect("future compatible start");
2467 let descriptor: SourceBundleDescriptor =
2468 serde_json::from_slice(&fixture.descriptor_bytes).expect("source descriptor");
2469 let request = start
2470 .reconstruct_request(&descriptor, SourceArchiveLimits::default())
2471 .expect("future compatible request");
2472 let mut artifact = fixture.artifact.clone();
2473 artifact.compile.request_sha256 =
2474 canonical_request_sha256(&request).expect("future request hash");
2475 artifact.validate().expect("future-bound artifact");
2476 let mut transcript = Vec::new();
2477 write_json_frame(
2478 &mut transcript,
2479 WorkerDataPlaneFrameKind::JobAccepted,
2480 0,
2481 &fixture.accepted,
2482 )
2483 .expect("accepted frame");
2484 write_json_frame(
2485 &mut transcript,
2486 WorkerDataPlaneFrameKind::Event,
2487 1,
2488 &event(
2489 &fixture,
2490 0,
2491 RemoteBuildEventKind::JobCreated {
2492 state: JobState::Created,
2493 },
2494 ),
2495 )
2496 .expect("progress event");
2497 write_json_frame(
2498 &mut transcript,
2499 WorkerDataPlaneFrameKind::ArtifactDescriptor,
2500 2,
2501 &artifact,
2502 )
2503 .expect("artifact descriptor");
2504 write_worker_data_plane_frame(
2505 &mut transcript,
2506 WorkerDataPlaneFrameKind::Artifact,
2507 3,
2508 &fixture.artifact_bytes,
2509 )
2510 .expect("artifact frame");
2511 write_json_frame(
2512 &mut transcript,
2513 WorkerDataPlaneFrameKind::Complete,
2514 4,
2515 &fixture.complete,
2516 )
2517 .expect("complete frame");
2518 let mut server = Cursor::new(transcript);
2519 let mut client = Vec::new();
2520 let (mut source_descriptor, mut source_archive) = fixture.source_handles();
2521 let spool_path = fixture.spool_path("future-minor");
2522 let mut spool = CreateOnlyArtifactSpool::create(&spool_path).expect("create-only spool");
2523 let outcome = run_snapshot_session(
2524 &mut server,
2525 &mut client,
2526 SnapshotSessionRequest::new(
2527 &start,
2528 &mut source_descriptor,
2529 &mut source_archive,
2530 SourceArchiveLimits::default(),
2531 ),
2532 &mut spool,
2533 |_| {},
2534 |_, path, _| ProtocolPath::new(ProtocolPathSemantics::ClientAbsolute, path.to_string()),
2535 )
2536 .expect("negotiated future-minor session");
2537 assert_eq!(outcome.accepted.protocol_version, CURRENT_PROTOCOL_VERSION);
2538 assert_eq!(
2539 outcome.artifact.compile.request_sha256,
2540 artifact.compile.request_sha256
2541 );
2542 spool.commit().expect("commit future-minor artifact");
2543 }
2544
2545 #[test]
2546 fn cleanup_quarantine_never_unlinks_a_replacement_path_occupant() {
2547 let fixture = fixture();
2548 let spool_path = fixture.spool_path("cleanup-race");
2549 {
2550 let spool = CreateOnlyArtifactSpool::create(&spool_path).expect("create-only spool");
2551 spool
2552 .remove_owned_link_with(&spool_path, |_| {
2553 fs::write(&spool_path, b"concurrent replacement").expect("install replacement");
2554 })
2555 .expect("remove only quarantined spool inode");
2556 }
2557 assert_eq!(
2558 fs::read(spool_path).expect("preserved replacement"),
2559 b"concurrent replacement"
2560 );
2561 }
2562
2563 #[cfg(unix)]
2564 #[test]
2565 fn process_snapshot_session_requires_protocol_and_zero_exit() {
2566 let fixture = fixture();
2567 let helper = ProcessHelper::new(&fixture);
2568 let invocation = helper.invocation("success", Duration::from_secs(5));
2569 let spool_path = fixture.spool_path("process-success");
2570 let mut spool = CreateOnlyArtifactSpool::create(&spool_path).expect("create-only spool");
2571 let outcome =
2572 run_process_fixture(&fixture, &invocation, &CancellationToken::new(), &mut spool)
2573 .expect("successful process session");
2574 assert_eq!(outcome.complete, fixture.complete);
2575 assert!(spool_path.exists());
2576 spool.commit().expect("commit process artifact");
2577 assert_eq!(
2578 fs::read(spool_path).expect("committed process artifact"),
2579 fixture.artifact_bytes
2580 );
2581
2582 let invocation = helper.invocation("nonzero", Duration::from_secs(5));
2583 let spool_path = fixture.spool_path("process-nonzero");
2584 let mut spool = CreateOnlyArtifactSpool::create(&spool_path).expect("create-only spool");
2585 let error =
2586 run_process_fixture(&fixture, &invocation, &CancellationToken::new(), &mut spool)
2587 .expect_err("nonzero OpenSSH status");
2588 assert_eq!(
2589 error,
2590 SshSnapshotSessionError::Transport(SshTransportError::ProcessFailed {
2591 status: Some(7)
2592 })
2593 );
2594 assert!(!spool_path.exists());
2595 }
2596
2597 #[cfg(unix)]
2598 #[test]
2599 fn process_snapshot_session_preserves_validated_server_error_on_nonzero_exit() {
2600 let fixture = fixture();
2601 let helper = ProcessHelper::new(&fixture);
2602 let failure = SnapshotSessionError::new(
2603 Some(fixture.start.parameters.operation_id.clone()),
2604 Some(fixture.accepted.job_id.clone()),
2605 "worker.cleanup_failed",
2606 "Cleanup failed",
2607 false,
2608 None,
2609 )
2610 .expect("server error");
2611 let mut transcript = artifact_prefix_with_event(&fixture);
2612 write_worker_data_plane_frame(
2613 &mut transcript,
2614 WorkerDataPlaneFrameKind::Artifact,
2615 3,
2616 &fixture.artifact_bytes,
2617 )
2618 .expect("artifact frame");
2619 write_json_frame(
2620 &mut transcript,
2621 WorkerDataPlaneFrameKind::Error,
2622 4,
2623 &failure,
2624 )
2625 .expect("terminal error");
2626 helper.write_response(&transcript);
2627 let invocation = helper.invocation("semantic-nonzero", Duration::from_secs(5));
2628 let spool_path = fixture.spool_path("process-semantic-error");
2629 let mut spool = CreateOnlyArtifactSpool::create(&spool_path).expect("create-only spool");
2630 let error =
2631 run_process_fixture(&fixture, &invocation, &CancellationToken::new(), &mut spool)
2632 .expect_err("validated server error");
2633 assert_eq!(
2634 error,
2635 SshSnapshotSessionError::Session(SnapshotSessionClientError::Server(Box::new(failure)))
2636 );
2637 assert!(!spool_path.exists());
2638 }
2639
2640 #[cfg(unix)]
2641 #[test]
2642 fn process_snapshot_session_reaps_child_when_event_callback_panics() {
2643 let fixture = fixture();
2644 let helper = ProcessHelper::new(&fixture);
2645 let invocation = helper.invocation("panic", Duration::from_secs(5));
2646 let spool_path = fixture.spool_path("process-panic");
2647 let mut spool = CreateOnlyArtifactSpool::create(&spool_path).expect("create-only spool");
2648 let started = Instant::now();
2649 let panic = catch_unwind(AssertUnwindSafe(|| {
2650 let (mut descriptor, mut archive) = fixture.source_handles();
2651 let _ = ProcessSshRunner.run_snapshot_session(
2652 &invocation,
2653 SnapshotSessionRequest::new(
2654 &fixture.start,
2655 &mut descriptor,
2656 &mut archive,
2657 SourceArchiveLimits::default(),
2658 ),
2659 &mut spool,
2660 &CancellationToken::new(),
2661 |_| panic!("event callback panic"),
2662 |_, path, _| {
2663 ProtocolPath::new(ProtocolPathSemantics::ClientAbsolute, path.to_string())
2664 },
2665 );
2666 }));
2667 assert!(panic.is_err());
2668 assert!(started.elapsed() < Duration::from_secs(1));
2669 assert!(!spool_path.exists());
2670 let pid = fs::read_to_string(&helper.pid).expect("helper pid");
2671 let mut process_exists = true;
2672 for _ in 0..20 {
2673 process_exists = Command::new("kill")
2674 .args(["-0", pid.trim()])
2675 .stdout(Stdio::null())
2676 .stderr(Stdio::null())
2677 .status()
2678 .is_ok_and(|status| status.success());
2679 if !process_exists {
2680 break;
2681 }
2682 thread::sleep(Duration::from_millis(10));
2683 }
2684 assert!(!process_exists, "panicking session left its child alive");
2685 assert!(!helper.marker.exists());
2686 }
2687
2688 #[cfg(unix)]
2689 #[test]
2690 fn process_snapshot_session_timeout_and_cancellation_are_bounded() {
2691 let fixture = fixture();
2692 let helper = ProcessHelper::new(&fixture);
2693 let invocation = helper.invocation("timeout", Duration::from_millis(120));
2694 let spool_path = fixture.spool_path("process-timeout");
2695 let mut spool = CreateOnlyArtifactSpool::create(&spool_path).expect("create-only spool");
2696 let started = Instant::now();
2697 let error =
2698 run_process_fixture(&fixture, &invocation, &CancellationToken::new(), &mut spool)
2699 .expect_err("session timeout");
2700 assert_eq!(
2701 error,
2702 SshSnapshotSessionError::Transport(SshTransportError::TimedOut)
2703 );
2704 assert!(started.elapsed() < Duration::from_secs(1));
2705 assert!(!spool_path.exists());
2706
2707 let invocation = helper.invocation("timeout", Duration::from_secs(5));
2708 let cancellation = CancellationToken::new();
2709 let request = cancellation.clone();
2710 let cancel_thread = thread::spawn(move || {
2711 thread::sleep(Duration::from_millis(50));
2712 let _ = request.cancel();
2713 });
2714 let spool_path = fixture.spool_path("process-cancelled");
2715 let mut spool = CreateOnlyArtifactSpool::create(&spool_path).expect("create-only spool");
2716 let started = Instant::now();
2717 let error = run_process_fixture(&fixture, &invocation, &cancellation, &mut spool)
2718 .expect_err("session cancellation");
2719 cancel_thread.join().expect("cancellation thread");
2720 assert_eq!(
2721 error,
2722 SshSnapshotSessionError::Transport(SshTransportError::Cancelled)
2723 );
2724 assert!(started.elapsed() < Duration::from_secs(1));
2725 assert!(!spool_path.exists());
2726 }
2727
2728 #[cfg(unix)]
2729 #[test]
2730 fn process_snapshot_session_does_not_wait_for_descendant_held_pipes() {
2731 let fixture = fixture();
2732 let helper = ProcessHelper::new(&fixture);
2733 let invocation = helper.invocation("descendant", Duration::from_secs(5));
2734 let spool_path = fixture.spool_path("process-descendant");
2735 let mut spool = CreateOnlyArtifactSpool::create(&spool_path).expect("create-only spool");
2736 let started = Instant::now();
2737 run_process_fixture(&fixture, &invocation, &CancellationToken::new(), &mut spool)
2738 .expect("session with descendant-held pipes");
2739 assert!(started.elapsed() < Duration::from_secs(1));
2740 spool.commit().expect("commit descendant fixture artifact");
2741 }
2742
2743 #[cfg(unix)]
2744 struct ProcessHelper {
2745 _directory: tempfile::TempDir,
2746 script: Utf8PathBuf,
2747 response: Utf8PathBuf,
2748 marker: Utf8PathBuf,
2749 pid: Utf8PathBuf,
2750 }
2751
2752 #[cfg(unix)]
2753 impl ProcessHelper {
2754 fn new(fixture: &Fixture) -> Self {
2755 use std::os::unix::fs::PermissionsExt as _;
2756
2757 let directory = tempfile::tempdir().expect("process helper directory");
2758 let root = Utf8PathBuf::from_path_buf(directory.path().to_path_buf())
2759 .expect("UTF-8 helper path");
2760 let script = root.join("session-helper.sh");
2761 let response = root.join("response.bin");
2762 let marker = root.join("completed");
2763 let pid = root.join("pid");
2764 fs::write(&response, success_transcript(fixture)).expect("helper response");
2765 fs::write(
2766 &script,
2767 concat!(
2768 "#!/bin/sh\n",
2769 "mode=$1\n",
2770 "response=$2\n",
2771 "marker=$3\n",
2772 "pid=$4\n",
2773 "printf '%s' \"$$\" > \"$pid\"\n",
2774 "printf '%s' 'private-worker-stderr' >&2\n",
2775 "case \"$mode\" in\n",
2776 " success) dd if=\"$response\" bs=16384 2>/dev/null; cat >/dev/null; exit 0 ;;\n",
2777 " descendant) sleep 2 & dd if=\"$response\" bs=16384 2>/dev/null; cat >/dev/null; exit 0 ;;\n",
2778 " timeout) sleep 2; exit 0 ;;\n",
2779 " nonzero) exit 7 ;;\n",
2780 " semantic-nonzero) dd if=\"$response\" bs=16384 2>/dev/null; cat >/dev/null; exit 1 ;;\n",
2781 " panic) dd if=\"$response\" bs=16384 2>/dev/null; sleep 2; printf '%s' completed > \"$marker\"; exit 0 ;;\n",
2782 " *) exit 9 ;;\n",
2783 "esac\n",
2784 ),
2785 )
2786 .expect("helper script");
2787 fs::set_permissions(&script, fs::Permissions::from_mode(0o700))
2788 .expect("helper permissions");
2789 Self {
2790 _directory: directory,
2791 script,
2792 response,
2793 marker,
2794 pid,
2795 }
2796 }
2797
2798 fn write_response(&self, bytes: &[u8]) {
2799 fs::write(&self.response, bytes).expect("replace helper response");
2800 }
2801
2802 fn invocation(&self, mode: &str, timeout: Duration) -> SshInvocation {
2803 test_invocation(
2804 "/bin/sh",
2805 [
2806 OsString::from(self.script.as_str()),
2807 OsString::from(mode),
2808 OsString::from(self.response.as_str()),
2809 OsString::from(self.marker.as_str()),
2810 OsString::from(self.pid.as_str()),
2811 ],
2812 timeout,
2813 )
2814 }
2815 }
2816
2817 #[cfg(unix)]
2818 fn run_process_fixture(
2819 fixture: &Fixture,
2820 invocation: &SshInvocation,
2821 cancellation: &CancellationToken,
2822 spool: &mut CreateOnlyArtifactSpool,
2823 ) -> Result<SnapshotSessionOutcome, SshSnapshotSessionError> {
2824 let (mut descriptor, mut archive) = fixture.source_handles();
2825 ProcessSshRunner.run_snapshot_session(
2826 invocation,
2827 SnapshotSessionRequest::new(
2828 &fixture.start,
2829 &mut descriptor,
2830 &mut archive,
2831 SourceArchiveLimits::default(),
2832 ),
2833 spool,
2834 cancellation,
2835 |_| {},
2836 |_, path, _| ProtocolPath::new(ProtocolPathSemantics::ClientAbsolute, path.to_string()),
2837 )
2838 }
2839
2840 fn artifact_prefix(fixture: &Fixture) -> Vec<u8> {
2841 let mut transcript = Vec::new();
2842 write_json_frame(
2843 &mut transcript,
2844 WorkerDataPlaneFrameKind::JobAccepted,
2845 0,
2846 &fixture.accepted,
2847 )
2848 .expect("accepted frame");
2849 write_json_frame(
2850 &mut transcript,
2851 WorkerDataPlaneFrameKind::ArtifactDescriptor,
2852 1,
2853 &fixture.artifact,
2854 )
2855 .expect("artifact descriptor");
2856 transcript
2857 }
2858
2859 #[cfg(unix)]
2860 fn artifact_prefix_with_event(fixture: &Fixture) -> Vec<u8> {
2861 let mut transcript = Vec::new();
2862 write_json_frame(
2863 &mut transcript,
2864 WorkerDataPlaneFrameKind::JobAccepted,
2865 0,
2866 &fixture.accepted,
2867 )
2868 .expect("accepted frame");
2869 write_json_frame(
2870 &mut transcript,
2871 WorkerDataPlaneFrameKind::Event,
2872 1,
2873 &event(
2874 fixture,
2875 0,
2876 RemoteBuildEventKind::JobCreated {
2877 state: JobState::Created,
2878 },
2879 ),
2880 )
2881 .expect("progress event");
2882 write_json_frame(
2883 &mut transcript,
2884 WorkerDataPlaneFrameKind::ArtifactDescriptor,
2885 2,
2886 &fixture.artifact,
2887 )
2888 .expect("artifact descriptor");
2889 transcript
2890 }
2891
2892 fn run_with_publication_path(
2893 fixture: &Fixture,
2894 transcript: Vec<u8>,
2895 label: &str,
2896 publication_path: &Utf8Path,
2897 ) -> (SnapshotSessionClientError, Vec<u8>) {
2898 let mut server = Cursor::new(transcript);
2899 let mut client = Vec::new();
2900 let (mut descriptor, mut archive) = fixture.source_handles();
2901 let spool_path = fixture.spool_path(label);
2902 let mut spool = CreateOnlyArtifactSpool::create(&spool_path).expect("create-only spool");
2903 let error = run_snapshot_session(
2904 &mut server,
2905 &mut client,
2906 SnapshotSessionRequest::new(
2907 &fixture.start,
2908 &mut descriptor,
2909 &mut archive,
2910 SourceArchiveLimits::default(),
2911 ),
2912 &mut spool,
2913 |_| {},
2914 |_, _, _| {
2915 ProtocolPath::new(
2916 ProtocolPathSemantics::ClientAbsolute,
2917 publication_path.to_string(),
2918 )
2919 },
2920 )
2921 .expect_err("publication failure");
2922 assert!(!spool_path.exists());
2923 (error, client)
2924 }
2925
2926 fn run_failure(
2927 fixture: &Fixture,
2928 transcript: Vec<u8>,
2929 label: &str,
2930 ) -> (SnapshotSessionClientError, Vec<u8>) {
2931 let mut server = Cursor::new(transcript);
2932 let mut client = Vec::new();
2933 let (mut descriptor, mut archive) = fixture.source_handles();
2934 let spool_path = fixture.spool_path(label);
2935 let mut spool = CreateOnlyArtifactSpool::create(&spool_path).expect("create-only spool");
2936 let error = run_snapshot_session(
2937 &mut server,
2938 &mut client,
2939 SnapshotSessionRequest::new(
2940 &fixture.start,
2941 &mut descriptor,
2942 &mut archive,
2943 SourceArchiveLimits::default(),
2944 ),
2945 &mut spool,
2946 |_| {},
2947 |_, path, _| ProtocolPath::new(ProtocolPathSemantics::ClientAbsolute, path.to_string()),
2948 )
2949 .expect_err("session failure");
2950 assert!(!spool_path.exists(), "failed session retained its spool");
2951 (error, client)
2952 }
2953
2954 fn client_kinds(encoded: &[u8]) -> Vec<WorkerDataPlaneFrameKind> {
2955 let mut reader = Cursor::new(encoded);
2956 let mut kinds = Vec::new();
2957 loop {
2958 let header = match read_worker_data_plane_header(&mut reader) {
2959 Ok(header) => header,
2960 Err(WorkerDataPlaneFrameError::EmptyInput) => break,
2961 Err(error) => panic!("invalid client transcript: {error}"),
2962 };
2963 kinds.push(header.kind());
2964 if matches!(
2965 header.kind(),
2966 WorkerDataPlaneFrameKind::SourceArchive | WorkerDataPlaneFrameKind::Artifact
2967 ) {
2968 copy_worker_data_plane_payload(&mut reader, &mut std::io::sink(), header)
2969 .expect("raw client payload");
2970 } else {
2971 read_worker_data_plane_payload(&mut reader, header).expect("client payload");
2972 }
2973 }
2974 kinds
2975 }
2976
2977 fn sha256(bytes: &[u8]) -> String {
2978 lowercase_hex(&Sha256::digest(bytes))
2979 }
2980}