Skip to main content

rustferry_ssh/
session.rs

1//! Pure framed client for one SSH snapshot-build session.
2
3use 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
23/// Source handles and limits for one snapshot request.
24pub struct SnapshotSessionRequest<'a> {
25    /// Validated control declaration sent as client frame zero.
26    pub start: &'a SnapshotBuildStart,
27    /// Exact strict JSON descriptor declared by `start`.
28    pub source_descriptor: &'a mut File,
29    /// Exact deterministic source archive declared by `start` and the descriptor.
30    pub source_archive: &'a mut File,
31    /// Limits used while reconstructing the complete request.
32    pub source_limits: SourceArchiveLimits,
33}
34
35impl<'a> SnapshotSessionRequest<'a> {
36    /// Bind one build declaration to already-open source handles.
37    #[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/// Create-only local file receiving untrusted artifact bytes.
54#[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    /// Create one new private artifact spool without following an existing path.
74    ///
75    /// # Errors
76    ///
77    /// Rejects non-absolute or unsafe paths, existing destinations, non-files,
78    /// and failures to prove that the opened handle is the newly linked file.
79    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    /// Caller-selected local spool path.
118    #[must_use]
119    pub fn path(&self) -> &Utf8Path {
120        &self.path
121    }
122
123    /// Remove every still-uncommitted identity-matching artifact link.
124    ///
125    /// Calls after successful cleanup are idempotent. A replaced or otherwise
126    /// unprovable path is preserved and reported instead of being unlinked.
127    ///
128    /// # Errors
129    ///
130    /// Returns [`SnapshotSessionClientError::ArtifactCleanupFailed`] when all
131    /// operation-owned links cannot be proven removed.
132    pub fn abort(&mut self) -> Result<(), SnapshotSessionClientError> {
133        self.cleanup_failure()
134    }
135
136    /// Commit the already validated publication after all caller-owned output
137    /// checks have succeeded.
138    ///
139    /// When the verifier published through a second hard link, this removes
140    /// only the private staging link. Until this method succeeds, dropping the
141    /// spool removes both identity-matching links.
142    ///
143    /// # Errors
144    ///
145    /// Rejects calls before a complete session, a replaced publication, byte
146    /// changes, or a failure to durably remove the private staging link.
147    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/// Validated terminal result of one snapshot session.
367#[derive(Clone, Debug, Eq, PartialEq)]
368pub struct SnapshotSessionOutcome {
369    /// Worker job allocation.
370    pub accepted: SnapshotJobAccepted,
371    /// Exact artifact and compile evidence verified by the client.
372    pub artifact: SnapshotArtifactDescriptor,
373    /// Client receipt sent only after local verification succeeded.
374    pub receipt: SnapshotArtifactReceipt,
375    /// Terminal non-retaining worker cleanup proof.
376    pub complete: SnapshotBuildComplete,
377}
378
379/// Stable fail-closed snapshot-session client failure.
380#[derive(Clone, Debug, Eq, Error, PartialEq)]
381pub enum SnapshotSessionClientError {
382    /// Binary framing, sequence, size, or stream I/O failed.
383    #[error(transparent)]
384    DataPlane(#[from] WorkerDataPlaneFrameError),
385    /// Build-start fields or their deterministic encoding are invalid.
386    #[error("SSH snapshot build start is invalid")]
387    InvalidBuildStart,
388    /// Source descriptor JSON or reconstructed request is invalid.
389    #[error("SSH snapshot source descriptor is invalid")]
390    InvalidSourceDescriptor,
391    /// Descriptor bytes do not match the size and digest declared by the start.
392    #[error("SSH snapshot source descriptor bytes do not match the build start")]
393    SourceDescriptorMismatch,
394    /// Archive bytes do not match the descriptor and build-start declaration.
395    #[error("SSH snapshot source archive bytes do not match the build start")]
396    SourceArchiveMismatch,
397    /// A validated source handle changed while its bytes were streamed.
398    #[error("SSH snapshot source file changed while it was streamed")]
399    SourceFileChanged,
400    /// A bounded control payload was not strict valid JSON for its frame kind.
401    #[error("SSH snapshot control frame is invalid")]
402    InvalidControlFrame,
403    /// A server frame appeared outside the only accepted session state.
404    #[error("unexpected SSH snapshot server frame {received:?}; expected {expected}")]
405    UnexpectedServerFrame {
406        /// Required frame or terminal alternatives.
407        expected: &'static str,
408        /// Received typed frame.
409        received: WorkerDataPlaneFrameKind,
410    },
411    /// Job acknowledgement identity does not match the reconstructed request.
412    #[error("SSH snapshot job acknowledgement identity is invalid")]
413    JobIdentityMismatch,
414    /// Event identity does not match the acknowledged job.
415    #[error("SSH snapshot event identity is invalid")]
416    EventIdentityMismatch,
417    /// Event sequence did not start at zero and increase by exactly one.
418    #[error("SSH snapshot event sequence is invalid")]
419    EventSequenceMismatch,
420    /// No validated progress event preceded artifact verification and receipt.
421    #[error("SSH snapshot artifact arrived before any validated progress event")]
422    MissingProgressEvent,
423    /// Artifact descriptor identity does not match the acknowledged job.
424    #[error("SSH snapshot artifact identity is invalid")]
425    ArtifactIdentityMismatch,
426    /// Compile evidence is not bound to the reconstructed request and source.
427    #[error("SSH snapshot compile evidence does not match the submitted request")]
428    CompileEvidenceMismatch,
429    /// Streamed artifact bytes do not match their descriptor.
430    #[error("SSH snapshot artifact bytes do not match the descriptor")]
431    ArtifactPayloadMismatch,
432    /// The caller's independent artifact verifier rejected bytes or final path.
433    #[error("SSH snapshot artifact verification failed")]
434    ArtifactVerificationFailed,
435    /// Final artifact path was absent, replaced, unrelated, changed, or not durable.
436    #[error("SSH snapshot artifact publication could not be proven")]
437    ArtifactPublicationFailed,
438    /// Publication cannot be committed before protocol and process success.
439    #[error("SSH snapshot artifact commit is not authorized")]
440    ArtifactCommitNotAuthorized,
441    /// Identity-bound removal of an uncommitted artifact failed.
442    #[error("SSH snapshot uncommitted artifact cleanup failed")]
443    ArtifactCleanupFailed,
444    /// Completion identity or cleanup proof does not match the acknowledged job.
445    #[error("SSH snapshot completion identity is invalid")]
446    CompletionIdentityMismatch,
447    /// The worker returned a validated public session error.
448    #[error("SSH snapshot worker returned an error")]
449    Server(Box<SnapshotSessionError>),
450    /// Artifact spool path must be absolute UTF-8 without control bytes.
451    #[error("SSH snapshot artifact spool path is invalid")]
452    ArtifactSpoolPathInvalid,
453    /// Create-only artifact spool destination already exists.
454    #[error("SSH snapshot artifact spool already exists")]
455    ArtifactSpoolExists,
456    /// Artifact spool is not one empty, private, create-only regular file.
457    #[error("SSH snapshot artifact spool is invalid")]
458    ArtifactSpoolInvalid,
459    /// A local file operation failed without exposing path or contents.
460    #[error("SSH snapshot local file operation failed")]
461    LocalIo,
462}
463
464/// Run one pure framed snapshot session over caller-owned duplex streams.
465///
466/// Source and artifact payloads are streamed with fixed memory. `verify_artifact`
467/// runs after the artifact's size and SHA-256 match its descriptor and must
468/// return the final client-absolute path. Client receipt frame three is written
469/// only after the final path is opened without following a final symlink,
470/// proven to be the verified inode, and durably synchronized.
471///
472/// A successful return authorizes, but does not commit, the artifact. The
473/// caller must invoke [`CreateOnlyArtifactSpool::commit`] after its own
474/// supporting-output checks; otherwise `Drop` removes the uncommitted output.
475///
476/// # Errors
477///
478/// Fails closed on any framing, order, identity, digest, file-stability,
479/// verifier, server-error, or cleanup mismatch.
480pub 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}