Skip to main content

rustferry_remote/
snapshot_session.rs

1//! Strict control messages for one framed SSH snapshot-build session.
2
3use std::collections::BTreeSet;
4
5use schemars::JsonSchema;
6use serde::{Deserialize, Deserializer, Serialize};
7
8use crate::{
9    COMPILE_PHASE_EVIDENCE_SCHEMA_VERSION, CURRENT_PROTOCOL_VERSION, CompilePhaseEvidence,
10    IosArtifactType, IosDeviceBuildRequest, IosDeviceProductExpectation, JobHandle, JobState,
11    ProtocolPath, ProtocolPathSemantics, ProtocolVersion, RemoteBuildError, RemoteBuildResult,
12    SEALED_UNSIGNED_ARCHIVE_SCHEMA_VERSION, SigningMode, SigningPlan, SourceArchive,
13    SourceArchiveLimits, SourceBundleDescriptor, SourceLimits, SourceMode,
14    artifact::ArtifactKind,
15    artifact::ArtifactRecord,
16    data_plane::{MAX_WORKER_DATA_PLANE_ARTIFACT_BYTES, MAX_WORKER_DATA_PLANE_SOURCE_BYTES},
17    protocol::{BuildProfile, CleanupConfirmation, IOS_DEVICE_RUST_TARGET, validate_identifier},
18    source::validate_source_manifest,
19};
20
21/// Current schema for snapshot-session control messages.
22pub const SNAPSHOT_SESSION_SCHEMA_VERSION: u32 = 1;
23/// Maximum source-descriptor JSON size accepted by the interactive snapshot session.
24pub const MAX_SNAPSHOT_SESSION_DESCRIPTOR_BYTES: u64 = 8 * 1024 * 1024;
25
26const MAX_PUBLIC_ERROR_CODE_BYTES: usize = 96;
27const MAX_PUBLIC_ERROR_MESSAGE_BYTES: usize = 512;
28const MAX_PUBLIC_VERSION_BYTES: usize = 128;
29const MAX_PUBLIC_TOOLCHAIN_TEXT_BYTES: usize = 512;
30const MAX_ARTIFACT_FILE_NAME_BYTES: usize = 255;
31const MAX_MEDIA_TYPE_BYTES: usize = 128;
32
33/// Compact build fields sent before the separately framed source descriptor.
34///
35/// This deliberately excludes `source`, `source_mode`, `source_repository`, and
36/// `source_revision`. The worker reconstructs the complete request only after
37/// validating the separately transported [`SourceBundleDescriptor`].
38#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
39#[serde(deny_unknown_fields)]
40pub struct SnapshotBuildParameters {
41    /// Requested Ferry Remote Build Protocol version.
42    #[serde(deserialize_with = "deserialize_protocol_version")]
43    pub protocol_version: ProtocolVersion,
44    /// Caller-owned operation identifier.
45    pub operation_id: String,
46    /// Human-readable product name.
47    pub product_name: String,
48    /// Exact Apple application bundle identifier.
49    pub bundle_identifier: String,
50    /// Minimum supported iOS version.
51    pub minimum_ios_version: String,
52    /// Client-derived product identity.
53    pub product: IosDeviceProductExpectation,
54    /// Rust/Xcode optimization profile.
55    pub profile: BuildProfile,
56    /// Unsigned compile-only plan.
57    pub signing: SigningPlan,
58    /// Exact requested artifact set; snapshot session v1 accepts only `xcarchive`.
59    pub requested_artifacts: BTreeSet<IosArtifactType>,
60}
61
62impl SnapshotBuildParameters {
63    /// Convert one complete request into compact snapshot-session parameters.
64    ///
65    /// # Errors
66    ///
67    /// Rejects an invalid request, non-snapshot source, signed build, or any
68    /// artifact set other than exactly one unsigned Xcode archive.
69    pub fn from_request(request: &IosDeviceBuildRequest) -> RemoteBuildResult<Self> {
70        request.validate()?;
71        if request.source_mode != SourceMode::Snapshot {
72            return Err(invalid(
73                "snapshot_build_parameters",
74                "snapshot session requires snapshot source mode",
75            ));
76        }
77        require_unsigned_xcarchive(&request.signing, &request.requested_artifacts)?;
78        let parameters = Self {
79            protocol_version: request.protocol_version,
80            operation_id: request.operation_id.clone(),
81            product_name: request.product_name.clone(),
82            bundle_identifier: request.bundle_identifier.clone(),
83            minimum_ios_version: request.minimum_ios_version.clone(),
84            product: request.product.clone(),
85            profile: request.profile,
86            signing: request.signing.clone(),
87            requested_artifacts: request.requested_artifacts.clone(),
88        };
89        parameters.validate_shape()?;
90        Ok(parameters)
91    }
92
93    /// Reconstruct and fully validate a request using one separately received descriptor.
94    ///
95    /// # Errors
96    ///
97    /// Rejects malformed descriptor metadata, invalid compact fields, or any
98    /// complete request that violates the physical-iPhone build contract.
99    pub fn reconstruct_request(
100        &self,
101        descriptor: &SourceBundleDescriptor,
102        limits: SourceArchiveLimits,
103    ) -> RemoteBuildResult<IosDeviceBuildRequest> {
104        self.validate_shape()?;
105        descriptor.validate(limits).map_err(|_| {
106            invalid(
107                "snapshot_build_parameters",
108                "source bundle descriptor is invalid",
109            )
110        })?;
111        let request = IosDeviceBuildRequest {
112            protocol_version: self.protocol_version,
113            operation_id: self.operation_id.clone(),
114            product_name: self.product_name.clone(),
115            bundle_identifier: self.bundle_identifier.clone(),
116            minimum_ios_version: self.minimum_ios_version.clone(),
117            product: self.product.clone(),
118            profile: self.profile,
119            source_mode: SourceMode::Snapshot,
120            source_repository: None,
121            source_revision: None,
122            source: descriptor.manifest.clone(),
123            signing: self.signing.clone(),
124            requested_artifacts: self.requested_artifacts.clone(),
125        };
126        request.validate()?;
127        require_unsigned_xcarchive(&request.signing, &request.requested_artifacts)?;
128        Ok(request)
129    }
130
131    fn validate_shape(&self) -> RemoteBuildResult<()> {
132        CURRENT_PROTOCOL_VERSION.negotiate(self.protocol_version)?;
133        validate_identifier("operation_id", &self.operation_id)?;
134        validate_public_text(&self.product_name, 255, "snapshot_build_parameters")?;
135        validate_bundle_identifier(&self.bundle_identifier)?;
136        validate_ios_version(&self.minimum_ios_version)?;
137        validate_portable_component(&self.product.app_directory_name)?;
138        validate_portable_component(&self.product.executable)?;
139        self.signing.validate().map_err(|_| {
140            invalid(
141                "snapshot_build_parameters",
142                "unsigned signing plan is invalid",
143            )
144        })?;
145        require_unsigned_xcarchive(&self.signing, &self.requested_artifacts)
146    }
147}
148
149impl TryFrom<&IosDeviceBuildRequest> for SnapshotBuildParameters {
150    type Error = RemoteBuildError;
151
152    fn try_from(request: &IosDeviceBuildRequest) -> Result<Self, Self::Error> {
153        Self::from_request(request)
154    }
155}
156
157/// Initial control frame for one snapshot-build session.
158#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
159#[serde(deny_unknown_fields)]
160pub struct SnapshotBuildStart {
161    /// Snapshot-session message schema.
162    pub schema_version: u32,
163    /// Compact request fields.
164    pub parameters: SnapshotBuildParameters,
165    /// Exact separately framed descriptor byte length.
166    pub source_descriptor_size: u64,
167    /// SHA-256 of the exact separately framed descriptor JSON bytes.
168    pub source_descriptor_sha256: String,
169    /// Exact separately framed deterministic ZIP size and SHA-256.
170    pub source_archive: SourceArchive,
171}
172
173impl SnapshotBuildStart {
174    /// Construct one bounded source-transfer declaration.
175    ///
176    /// # Errors
177    ///
178    /// Rejects invalid compact parameters, empty/oversized transfers, or malformed hashes.
179    pub fn new(
180        parameters: SnapshotBuildParameters,
181        source_descriptor_size: u64,
182        source_descriptor_sha256: impl Into<String>,
183        source_archive: SourceArchive,
184    ) -> RemoteBuildResult<Self> {
185        let start = Self {
186            schema_version: SNAPSHOT_SESSION_SCHEMA_VERSION,
187            parameters,
188            source_descriptor_size,
189            source_descriptor_sha256: source_descriptor_sha256.into(),
190            source_archive,
191        };
192        start.validate()?;
193        Ok(start)
194    }
195
196    /// Validate the bounded transfer declaration before reading large payloads.
197    ///
198    /// # Errors
199    ///
200    /// Rejects unsupported schemas, invalid parameters, size overflow, or malformed hashes.
201    pub fn validate(&self) -> RemoteBuildResult<()> {
202        require_schema(self.schema_version, "snapshot_build_start")?;
203        self.parameters.validate_shape()?;
204        if self.source_descriptor_size == 0
205            || self.source_descriptor_size > MAX_SNAPSHOT_SESSION_DESCRIPTOR_BYTES
206        {
207            return Err(invalid(
208                "snapshot_build_start",
209                "source descriptor size is outside the fixed limit",
210            ));
211        }
212        validate_sha256(&self.source_descriptor_sha256, "snapshot_build_start")?;
213        validate_transfer_archive(
214            &self.source_archive,
215            MAX_WORKER_DATA_PLANE_SOURCE_BYTES,
216            "snapshot_build_start",
217        )
218    }
219
220    /// Bind the received descriptor to the declared archive and reconstruct the full request.
221    ///
222    /// # Errors
223    ///
224    /// Rejects descriptor/archive substitution or any invalid reconstructed request.
225    pub fn reconstruct_request(
226        &self,
227        descriptor: &SourceBundleDescriptor,
228        limits: SourceArchiveLimits,
229    ) -> RemoteBuildResult<IosDeviceBuildRequest> {
230        self.validate()?;
231        descriptor.validate(limits).map_err(|_| {
232            invalid(
233                "snapshot_build_start",
234                "source bundle descriptor is invalid",
235            )
236        })?;
237        if descriptor.archive != self.source_archive {
238            return Err(invalid(
239                "snapshot_build_start",
240                "source archive declaration does not match the descriptor",
241            ));
242        }
243        self.parameters.reconstruct_request(descriptor, limits)
244    }
245}
246
247/// Worker acknowledgement after allocating one snapshot job.
248#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
249#[serde(deny_unknown_fields)]
250pub struct SnapshotJobAccepted {
251    /// Snapshot-session message schema.
252    pub schema_version: u32,
253    /// Negotiated Ferry Remote Build Protocol version.
254    #[serde(deserialize_with = "deserialize_protocol_version")]
255    pub protocol_version: ProtocolVersion,
256    /// Operation identifier copied from the build start.
257    pub operation_id: String,
258    /// Provider-owned job identifier.
259    pub job_id: String,
260    /// Initial state; always `created`.
261    pub state: JobState,
262    /// Milliseconds since Unix epoch in UTC.
263    pub created_at_ms: u64,
264}
265
266impl SnapshotJobAccepted {
267    /// Construct one initial job acknowledgement.
268    ///
269    /// # Errors
270    ///
271    /// Rejects unsafe operation or job identifiers.
272    pub fn new(
273        operation_id: impl Into<String>,
274        job_id: impl Into<String>,
275        created_at_ms: u64,
276    ) -> RemoteBuildResult<Self> {
277        let accepted = Self {
278            schema_version: SNAPSHOT_SESSION_SCHEMA_VERSION,
279            protocol_version: CURRENT_PROTOCOL_VERSION,
280            operation_id: operation_id.into(),
281            job_id: job_id.into(),
282            state: JobState::Created,
283            created_at_ms,
284        };
285        accepted.validate()?;
286        Ok(accepted)
287    }
288
289    /// Validate schema, protocol, identity, and initial-state invariants.
290    ///
291    /// # Errors
292    ///
293    /// Rejects unsupported versions, unsafe identifiers, or a non-created state.
294    pub fn validate(&self) -> RemoteBuildResult<()> {
295        require_schema(self.schema_version, "snapshot_job_accepted")?;
296        CURRENT_PROTOCOL_VERSION.negotiate(self.protocol_version)?;
297        validate_identifier("operation_id", &self.operation_id)?;
298        validate_identifier("job_id", &self.job_id)?;
299        if self.state != JobState::Created {
300            return Err(invalid(
301                "snapshot_job_accepted",
302                "accepted job state must be created",
303            ));
304        }
305        Ok(())
306    }
307
308    /// Convert the acknowledgement into the provider-neutral job handle.
309    #[must_use]
310    pub fn job_handle(&self) -> JobHandle {
311        JobHandle {
312            job_id: self.job_id.clone(),
313            state: self.state,
314            created_at_ms: self.created_at_ms,
315        }
316    }
317}
318
319/// Exact unsigned archive record and compile evidence preceding artifact bytes.
320#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
321#[serde(deny_unknown_fields)]
322pub struct SnapshotArtifactDescriptor {
323    /// Snapshot-session message schema.
324    pub schema_version: u32,
325    /// Negotiated Ferry Remote Build Protocol version.
326    #[serde(deserialize_with = "deserialize_protocol_version")]
327    pub protocol_version: ProtocolVersion,
328    /// Operation identifier from the submitted request.
329    pub operation_id: String,
330    /// Provider-owned job identifier.
331    pub job_id: String,
332    /// Exact returned file name, size, and SHA-256.
333    pub artifact: ArtifactRecord,
334    /// Credential-free physical-iPhone compile evidence.
335    pub compile: CompilePhaseEvidence,
336}
337
338impl SnapshotArtifactDescriptor {
339    /// Construct an artifact descriptor bound to compile evidence.
340    ///
341    /// # Errors
342    ///
343    /// Rejects unsafe identity, malformed evidence, or artifact/evidence mismatch.
344    pub fn new(
345        operation_id: impl Into<String>,
346        artifact: ArtifactRecord,
347        compile: CompilePhaseEvidence,
348    ) -> RemoteBuildResult<Self> {
349        let descriptor = Self {
350            schema_version: SNAPSHOT_SESSION_SCHEMA_VERSION,
351            protocol_version: CURRENT_PROTOCOL_VERSION,
352            operation_id: operation_id.into(),
353            job_id: compile.job_id.clone(),
354            artifact,
355            compile,
356        };
357        descriptor.validate()?;
358        Ok(descriptor)
359    }
360
361    /// Validate exact unsigned-XCArchive artifact and public compile evidence.
362    ///
363    /// # Errors
364    ///
365    /// Rejects unsupported schemas, unsafe public text, invalid hashes, oversized
366    /// artifacts, or evidence that does not bind the returned file.
367    pub fn validate(&self) -> RemoteBuildResult<()> {
368        require_schema(self.schema_version, "snapshot_artifact_descriptor")?;
369        CURRENT_PROTOCOL_VERSION.negotiate(self.protocol_version)?;
370        validate_identifier("operation_id", &self.operation_id)?;
371        validate_identifier("job_id", &self.job_id)?;
372        validate_artifact_record(&self.artifact)?;
373        validate_compile_evidence(&self.compile)?;
374        if self.compile.job_id != self.job_id
375            || self.compile.sealed_archive.transport.size != self.artifact.size
376            || self.compile.sealed_archive.transport.sha256 != self.artifact.sha256
377        {
378            return Err(invalid(
379                "snapshot_artifact_descriptor",
380                "artifact record does not match compile evidence",
381            ));
382        }
383        Ok(())
384    }
385}
386
387/// Client acknowledgement after exact artifact bytes are safely verified.
388#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
389#[serde(deny_unknown_fields)]
390pub struct SnapshotArtifactReceipt {
391    /// Snapshot-session message schema.
392    pub schema_version: u32,
393    /// Negotiated Ferry Remote Build Protocol version.
394    #[serde(deserialize_with = "deserialize_protocol_version")]
395    pub protocol_version: ProtocolVersion,
396    /// Operation identifier from the submitted request.
397    pub operation_id: String,
398    /// Provider-owned job identifier.
399    pub job_id: String,
400    /// Exact artifact identifier.
401    pub artifact_id: String,
402    /// Verified local byte length.
403    pub size: u64,
404    /// Verified local lowercase SHA-256.
405    pub sha256: String,
406    /// Absolute create-only client destination containing the verified bytes.
407    pub local_path: ProtocolPath,
408}
409
410impl SnapshotArtifactReceipt {
411    /// Construct a receipt from a validated descriptor and client path.
412    ///
413    /// # Errors
414    ///
415    /// Rejects invalid descriptor evidence or a non-client-absolute destination.
416    pub fn new(
417        descriptor: &SnapshotArtifactDescriptor,
418        local_path: ProtocolPath,
419    ) -> RemoteBuildResult<Self> {
420        descriptor.validate()?;
421        let receipt = Self {
422            schema_version: SNAPSHOT_SESSION_SCHEMA_VERSION,
423            protocol_version: descriptor.protocol_version,
424            operation_id: descriptor.operation_id.clone(),
425            job_id: descriptor.job_id.clone(),
426            artifact_id: descriptor.artifact.artifact_id.clone(),
427            size: descriptor.artifact.size,
428            sha256: descriptor.artifact.sha256.clone(),
429            local_path,
430        };
431        receipt.validate_for(descriptor)?;
432        Ok(receipt)
433    }
434
435    /// Validate the receipt against the exact offered artifact.
436    ///
437    /// # Errors
438    ///
439    /// Rejects identity, size, digest, or path mismatches.
440    pub fn validate_for(&self, descriptor: &SnapshotArtifactDescriptor) -> RemoteBuildResult<()> {
441        require_schema(self.schema_version, "snapshot_artifact_receipt")?;
442        CURRENT_PROTOCOL_VERSION.negotiate(self.protocol_version)?;
443        validate_identifier("operation_id", &self.operation_id)?;
444        validate_identifier("job_id", &self.job_id)?;
445        validate_identifier("artifact_id", &self.artifact_id)?;
446        validate_sha256(&self.sha256, "snapshot_artifact_receipt")?;
447        self.local_path.validate()?;
448        if self.local_path.semantics != ProtocolPathSemantics::ClientAbsolute {
449            return Err(invalid(
450                "snapshot_artifact_receipt",
451                "receipt path must be client_absolute",
452            ));
453        }
454        descriptor.validate()?;
455        if self.protocol_version != descriptor.protocol_version
456            || self.operation_id != descriptor.operation_id
457            || self.job_id != descriptor.job_id
458            || self.artifact_id != descriptor.artifact.artifact_id
459            || self.size != descriptor.artifact.size
460            || self.sha256 != descriptor.artifact.sha256
461        {
462            return Err(invalid(
463                "snapshot_artifact_receipt",
464                "receipt does not match the offered artifact",
465            ));
466        }
467        Ok(())
468    }
469}
470
471/// Successful terminal message emitted only after verified client receipt and cleanup.
472#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
473#[serde(deny_unknown_fields)]
474pub struct SnapshotBuildComplete {
475    /// Snapshot-session message schema.
476    pub schema_version: u32,
477    /// Negotiated Ferry Remote Build Protocol version.
478    #[serde(deserialize_with = "deserialize_protocol_version")]
479    pub protocol_version: ProtocolVersion,
480    /// Operation identifier from the submitted request.
481    pub operation_id: String,
482    /// Provider-owned job identifier.
483    pub job_id: String,
484    /// Exact remote cleanup proof.
485    #[serde(deserialize_with = "deserialize_cleanup_confirmation")]
486    pub cleanup: CleanupConfirmation,
487}
488
489impl SnapshotBuildComplete {
490    /// Construct one successful terminal message.
491    ///
492    /// # Errors
493    ///
494    /// Rejects incomplete cleanup or unsafe identifiers.
495    pub fn new(
496        operation_id: impl Into<String>,
497        cleanup: CleanupConfirmation,
498    ) -> RemoteBuildResult<Self> {
499        let complete = Self {
500            schema_version: SNAPSHOT_SESSION_SCHEMA_VERSION,
501            protocol_version: CURRENT_PROTOCOL_VERSION,
502            operation_id: operation_id.into(),
503            job_id: cleanup.job_id.clone(),
504            cleanup,
505        };
506        complete.validate()?;
507        Ok(complete)
508    }
509
510    /// Validate that terminal success includes complete non-retaining cleanup.
511    ///
512    /// # Errors
513    ///
514    /// Rejects mismatched identity or incomplete cleanup evidence.
515    pub fn validate(&self) -> RemoteBuildResult<()> {
516        require_schema(self.schema_version, "snapshot_build_complete")?;
517        CURRENT_PROTOCOL_VERSION.negotiate(self.protocol_version)?;
518        validate_identifier("operation_id", &self.operation_id)?;
519        validate_identifier("job_id", &self.job_id)?;
520        if self.cleanup.job_id != self.job_id
521            || !self.cleanup.workspace_removed
522            || !self.cleanup.signing_material_removed
523            || self.cleanup.artifacts_retained
524        {
525            return Err(invalid(
526                "snapshot_build_complete",
527                "successful completion requires non-retaining cleanup proof",
528            ));
529        }
530        Ok(())
531    }
532}
533
534/// Stable, secret-free terminal or pre-job session failure.
535#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
536#[serde(deny_unknown_fields)]
537pub struct SnapshotSessionError {
538    /// Snapshot-session message schema.
539    pub schema_version: u32,
540    /// Ferry Remote Build Protocol version understood by the sender.
541    #[serde(deserialize_with = "deserialize_protocol_version")]
542    pub protocol_version: ProtocolVersion,
543    /// Operation identifier when a build start was decoded.
544    pub operation_id: Option<String>,
545    /// Provider job identifier when one was allocated.
546    pub job_id: Option<String>,
547    /// Stable namespaced error code.
548    pub code: String,
549    /// Bounded public summary, never raw subprocess or parser text.
550    pub message: String,
551    /// Whether retrying unchanged inputs may succeed.
552    pub retryable: bool,
553    /// Cleanup proof when cleanup completed after the failure.
554    #[serde(
555        default,
556        deserialize_with = "deserialize_optional_cleanup_confirmation"
557    )]
558    pub cleanup: Option<CleanupConfirmation>,
559}
560
561impl SnapshotSessionError {
562    /// Construct one bounded, public session failure.
563    ///
564    /// # Errors
565    ///
566    /// Rejects unsafe identifiers, control text, oversized text, or mismatched cleanup proof.
567    #[allow(clippy::too_many_arguments)]
568    pub fn new(
569        operation_id: Option<String>,
570        job_id: Option<String>,
571        code: impl Into<String>,
572        message: impl Into<String>,
573        retryable: bool,
574        cleanup: Option<CleanupConfirmation>,
575    ) -> RemoteBuildResult<Self> {
576        let error = Self {
577            schema_version: SNAPSHOT_SESSION_SCHEMA_VERSION,
578            protocol_version: CURRENT_PROTOCOL_VERSION,
579            operation_id,
580            job_id,
581            code: code.into(),
582            message: message.into(),
583            retryable,
584            cleanup,
585        };
586        error.validate()?;
587        Ok(error)
588    }
589
590    /// Validate bounded public failure fields and optional cleanup identity.
591    ///
592    /// # Errors
593    ///
594    /// Rejects unsupported versions, unsafe identifiers/text, or mismatched cleanup proof.
595    pub fn validate(&self) -> RemoteBuildResult<()> {
596        require_schema(self.schema_version, "snapshot_session_error")?;
597        CURRENT_PROTOCOL_VERSION.negotiate(self.protocol_version)?;
598        if let Some(operation_id) = &self.operation_id {
599            validate_identifier("operation_id", operation_id)?;
600        }
601        if let Some(job_id) = &self.job_id {
602            validate_identifier("job_id", job_id)?;
603        }
604        validate_public_identifier(
605            &self.code,
606            MAX_PUBLIC_ERROR_CODE_BYTES,
607            "snapshot_session_error",
608        )?;
609        validate_public_text(
610            &self.message,
611            MAX_PUBLIC_ERROR_MESSAGE_BYTES,
612            "snapshot_session_error",
613        )?;
614        if let Some(cleanup) = &self.cleanup
615            && self.job_id.as_deref() != Some(cleanup.job_id.as_str())
616        {
617            return Err(invalid(
618                "snapshot_session_error",
619                "cleanup proof does not match the failed job",
620            ));
621        }
622        Ok(())
623    }
624}
625
626fn require_unsigned_xcarchive(
627    signing: &SigningPlan,
628    requested_artifacts: &BTreeSet<IosArtifactType>,
629) -> RemoteBuildResult<()> {
630    if signing.mode != SigningMode::UnsignedCompileOnly {
631        return Err(invalid(
632            "snapshot_build_parameters",
633            "snapshot session v1 supports unsigned compile-only builds",
634        ));
635    }
636    if requested_artifacts != &BTreeSet::from([IosArtifactType::Xcarchive]) {
637        return Err(invalid(
638            "snapshot_build_parameters",
639            "snapshot session v1 returns exactly one Xcode archive",
640        ));
641    }
642    Ok(())
643}
644
645fn validate_artifact_record(record: &ArtifactRecord) -> RemoteBuildResult<()> {
646    validate_identifier("artifact_id", &record.artifact_id)?;
647    if record.kind != ArtifactKind::Xcarchive {
648        return Err(invalid(
649            "snapshot_artifact_descriptor",
650            "artifact kind must be xcarchive",
651        ));
652    }
653    validate_portable_component_with_limit(&record.file_name, MAX_ARTIFACT_FILE_NAME_BYTES)?;
654    if !record.file_name.to_ascii_lowercase().ends_with(".zip") {
655        return Err(invalid(
656            "snapshot_artifact_descriptor",
657            "xcarchive transport filename must end with .zip",
658        ));
659    }
660    if record.size == 0 || record.size > MAX_WORKER_DATA_PLANE_ARTIFACT_BYTES {
661        return Err(invalid(
662            "snapshot_artifact_descriptor",
663            "artifact size is outside the fixed limit",
664        ));
665    }
666    validate_sha256(&record.sha256, "snapshot_artifact_descriptor")?;
667    if let Some(media_type) = &record.media_type {
668        validate_public_text(
669            media_type,
670            MAX_MEDIA_TYPE_BYTES,
671            "snapshot_artifact_descriptor",
672        )?;
673    }
674    Ok(())
675}
676
677fn validate_compile_evidence(compile: &CompilePhaseEvidence) -> RemoteBuildResult<()> {
678    if compile.schema_version != COMPILE_PHASE_EVIDENCE_SCHEMA_VERSION
679        || compile.sealed_archive.schema_version != SEALED_UNSIGNED_ARCHIVE_SCHEMA_VERSION
680    {
681        return Err(invalid(
682            "snapshot_artifact_descriptor",
683            "compile evidence schema is unsupported",
684        ));
685    }
686    validate_identifier("job_id", &compile.job_id)?;
687    validate_identifier("provider", &compile.provider)?;
688    for digest in [
689        &compile.request_sha256,
690        &compile.source_sha256,
691        &compile.cargo_lock_sha256,
692        &compile.config_sha256,
693        &compile.toolchain.developer_directory_sha256,
694        &compile.sealed_archive.transport.sha256,
695    ] {
696        validate_sha256(digest, "snapshot_artifact_descriptor")?;
697    }
698    for version in [&compile.rustferry_version, &compile.worker_version] {
699        validate_public_text(
700            version,
701            MAX_PUBLIC_VERSION_BYTES,
702            "snapshot_artifact_descriptor",
703        )?;
704    }
705    for text in [
706        &compile.toolchain.worker_os,
707        &compile.toolchain.worker_architecture,
708        &compile.toolchain.xcode_version,
709        &compile.toolchain.iphoneos_sdk_version,
710        &compile.toolchain.iphoneos_sdk_build_version,
711        &compile.toolchain.rust_version,
712    ] {
713        validate_public_text(
714            text,
715            MAX_PUBLIC_TOOLCHAIN_TEXT_BYTES,
716            "snapshot_artifact_descriptor",
717        )?;
718    }
719    if compile.toolchain.rust_target != IOS_DEVICE_RUST_TARGET
720        || compile.started_at_unix_seconds > compile.finished_at_unix_seconds
721        || compile.sealed_archive.contents.project_path != "."
722    {
723        return Err(invalid(
724            "snapshot_artifact_descriptor",
725            "compile evidence does not prove a bounded physical-iPhone archive",
726        ));
727    }
728    validate_transfer_archive(
729        &compile.sealed_archive.transport,
730        MAX_WORKER_DATA_PLANE_ARTIFACT_BYTES,
731        "snapshot_artifact_descriptor",
732    )?;
733    validate_source_manifest(
734        &compile.sealed_archive.contents,
735        sealed_archive_source_limits(),
736    )
737    .map_err(|_| {
738        invalid(
739            "snapshot_artifact_descriptor",
740            "sealed archive content manifest is invalid",
741        )
742    })
743}
744
745const fn sealed_archive_source_limits() -> SourceLimits {
746    SourceLimits {
747        max_file_count: 50_000,
748        max_file_size: 512 * 1024 * 1024,
749        max_total_size: MAX_WORKER_DATA_PLANE_ARTIFACT_BYTES,
750        max_depth: 128,
751        max_ignore_file_size: 64 * 1024,
752        max_ignore_rules: 1,
753    }
754}
755
756fn validate_transfer_archive(
757    archive: &SourceArchive,
758    maximum: u64,
759    event: &'static str,
760) -> RemoteBuildResult<()> {
761    if archive.size == 0 || archive.size > maximum {
762        return Err(invalid(event, "transfer size is outside the fixed limit"));
763    }
764    validate_sha256(&archive.sha256, event)
765}
766
767fn require_schema(version: u32, event: &'static str) -> RemoteBuildResult<()> {
768    if version == SNAPSHOT_SESSION_SCHEMA_VERSION {
769        Ok(())
770    } else {
771        Err(invalid(event, "snapshot session schema is unsupported"))
772    }
773}
774
775fn validate_sha256(value: &str, event: &'static str) -> RemoteBuildResult<()> {
776    if value.len() == 64
777        && value
778            .bytes()
779            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
780    {
781        Ok(())
782    } else {
783        Err(invalid(event, "SHA-256 must be lowercase hexadecimal"))
784    }
785}
786
787fn validate_public_identifier(
788    value: &str,
789    maximum: usize,
790    event: &'static str,
791) -> RemoteBuildResult<()> {
792    if !value.is_empty()
793        && value.len() <= maximum
794        && value
795            .bytes()
796            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
797    {
798        Ok(())
799    } else {
800        Err(invalid(event, "public identifier is invalid"))
801    }
802}
803
804fn validate_public_text(value: &str, maximum: usize, event: &'static str) -> RemoteBuildResult<()> {
805    if !value.is_empty() && value.len() <= maximum && !value.chars().any(char::is_control) {
806        Ok(())
807    } else {
808        Err(invalid(
809            event,
810            "public text is empty, oversized, or contains controls",
811        ))
812    }
813}
814
815fn validate_bundle_identifier(value: &str) -> RemoteBuildResult<()> {
816    if value.len() > 255
817        || value.split('.').count() < 2
818        || value.split('.').any(|segment| {
819            segment.is_empty()
820                || segment.starts_with('-')
821                || segment.ends_with('-')
822                || !segment
823                    .bytes()
824                    .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
825        })
826    {
827        Err(invalid(
828            "snapshot_build_parameters",
829            "bundle identifier is invalid",
830        ))
831    } else {
832        Ok(())
833    }
834}
835
836fn validate_ios_version(value: &str) -> RemoteBuildResult<()> {
837    let components = value.split('.').collect::<Vec<_>>();
838    if components.is_empty()
839        || components.len() > 3
840        || components.iter().any(|component| {
841            component.is_empty()
842                || (component.len() > 1 && component.starts_with('0'))
843                || !component.bytes().all(|byte| byte.is_ascii_digit())
844                || component.parse::<u16>().is_err()
845        })
846    {
847        Err(invalid(
848            "snapshot_build_parameters",
849            "minimum iOS version is invalid",
850        ))
851    } else {
852        Ok(())
853    }
854}
855
856fn validate_portable_component(value: &str) -> RemoteBuildResult<()> {
857    validate_portable_component_with_limit(value, 255)
858}
859
860fn validate_portable_component_with_limit(value: &str, maximum: usize) -> RemoteBuildResult<()> {
861    if value.is_empty()
862        || value.len() > maximum
863        || value == "."
864        || value == ".."
865        || value.contains(['/', '\\', '\0', ':', '*', '?', '"', '<', '>', '|'])
866        || value.ends_with(['.', ' '])
867        || value.chars().any(char::is_control)
868    {
869        Err(invalid(
870            "snapshot_artifact_descriptor",
871            "filename component is not portable",
872        ))
873    } else {
874        Ok(())
875    }
876}
877
878const fn invalid(event: &'static str, reason: &'static str) -> RemoteBuildError {
879    RemoteBuildError::InvalidEventPayload { event, reason }
880}
881
882#[derive(Deserialize)]
883#[serde(deny_unknown_fields)]
884struct StrictProtocolVersion {
885    major: u16,
886    minor: u16,
887}
888
889fn deserialize_protocol_version<'de, D>(deserializer: D) -> Result<ProtocolVersion, D::Error>
890where
891    D: Deserializer<'de>,
892{
893    let version = StrictProtocolVersion::deserialize(deserializer)?;
894    Ok(ProtocolVersion::new(version.major, version.minor))
895}
896
897#[derive(Deserialize)]
898#[serde(deny_unknown_fields)]
899struct StrictCleanupConfirmation {
900    job_id: String,
901    completed_at_ms: u64,
902    workspace_removed: bool,
903    signing_material_removed: bool,
904    artifacts_retained: bool,
905}
906
907impl From<StrictCleanupConfirmation> for CleanupConfirmation {
908    fn from(cleanup: StrictCleanupConfirmation) -> Self {
909        Self {
910            job_id: cleanup.job_id,
911            completed_at_ms: cleanup.completed_at_ms,
912            workspace_removed: cleanup.workspace_removed,
913            signing_material_removed: cleanup.signing_material_removed,
914            artifacts_retained: cleanup.artifacts_retained,
915        }
916    }
917}
918
919fn deserialize_cleanup_confirmation<'de, D>(
920    deserializer: D,
921) -> Result<CleanupConfirmation, D::Error>
922where
923    D: Deserializer<'de>,
924{
925    StrictCleanupConfirmation::deserialize(deserializer).map(Into::into)
926}
927
928fn deserialize_optional_cleanup_confirmation<'de, D>(
929    deserializer: D,
930) -> Result<Option<CleanupConfirmation>, D::Error>
931where
932    D: Deserializer<'de>,
933{
934    Option::<StrictCleanupConfirmation>::deserialize(deserializer)
935        .map(|cleanup| cleanup.map(Into::into))
936}
937
938#[cfg(test)]
939mod tests {
940    use std::{collections::BTreeMap, fs};
941
942    use camino::Utf8PathBuf;
943
944    use crate::{
945        ApplePlatform, BundleIdentifier, COMPILE_PHASE_EVIDENCE_SCHEMA_VERSION,
946        CompileToolchainEvidence, IosDeviceProductExpectation, MachOSliceEvidence,
947        SEALED_UNSIGNED_ARCHIVE_SCHEMA_VERSION, SealedUnsignedArchive, SigningTarget,
948        SigningTargetKind, SourceBundleRequest, UnsignedAppInspection,
949        UnsignedXcarchiveExpectation, UnsignedXcarchiveInspection, plan_source_bundle,
950    };
951
952    use super::*;
953
954    struct Fixture {
955        _directory: tempfile::TempDir,
956        descriptor: SourceBundleDescriptor,
957        request: IosDeviceBuildRequest,
958    }
959
960    fn fixture() -> Fixture {
961        let directory = tempfile::tempdir().expect("temporary directory");
962        let root = Utf8PathBuf::from_path_buf(directory.path().to_path_buf()).expect("UTF-8 path");
963        fs::create_dir(root.join("src")).expect("source directory");
964        fs::write(
965            root.join("Cargo.toml"),
966            "[package]\nname='app'\nversion='0.1.0'\nedition='2024'\n",
967        )
968        .expect("Cargo.toml");
969        fs::write(root.join("Cargo.lock"), "# fixture\n").expect("Cargo.lock");
970        fs::write(root.join("ferry.toml"), "[app]\nname='App'\n").expect("ferry.toml");
971        fs::write(root.join("src/main.rs"), "fn main() {}\n").expect("main.rs");
972        let plan =
973            plan_source_bundle(&SourceBundleRequest::new(&root, &root)).expect("source plan");
974        let archive = SourceArchive {
975            size: 123,
976            sha256: "a".repeat(64),
977        };
978        let descriptor = SourceBundleDescriptor::new(archive, plan.manifest().clone());
979        let signing = SigningPlan {
980            mode: SigningMode::UnsignedCompileOnly,
981            signing: None,
982            team: None,
983            device: None,
984            targets: vec![SigningTarget {
985                name: "App".to_owned(),
986                bundle_identifier: BundleIdentifier::new("com.example.app")
987                    .expect("bundle identifier"),
988                kind: SigningTargetKind::Application,
989            }],
990            provisioning: Vec::new(),
991            entitlements: Vec::new(),
992            allow_provisioning_updates: false,
993        };
994        let request = IosDeviceBuildRequest {
995            protocol_version: CURRENT_PROTOCOL_VERSION,
996            operation_id: "operation-1".to_owned(),
997            product_name: "App".to_owned(),
998            bundle_identifier: "com.example.app".to_owned(),
999            minimum_ios_version: "16.0".to_owned(),
1000            product: IosDeviceProductExpectation {
1001                app_directory_name: "App.app".to_owned(),
1002                executable: "App".to_owned(),
1003                app_version: "1.0.0".to_owned(),
1004                build_number: "1".to_owned(),
1005                nested_bundles: Vec::new(),
1006            },
1007            profile: BuildProfile::Debug,
1008            source_mode: SourceMode::Snapshot,
1009            source_repository: None,
1010            source_revision: None,
1011            source: descriptor.manifest.clone(),
1012            signing,
1013            requested_artifacts: BTreeSet::from([IosArtifactType::Xcarchive]),
1014        };
1015        request.validate().expect("request");
1016        Fixture {
1017            _directory: directory,
1018            descriptor,
1019            request,
1020        }
1021    }
1022
1023    fn artifact_descriptor(fixture: &Fixture) -> SnapshotArtifactDescriptor {
1024        let expectation = UnsignedXcarchiveExpectation {
1025            app_directory_name: "App.app".to_owned(),
1026            bundle_identifier: "com.example.app".to_owned(),
1027            executable: "App".to_owned(),
1028            app_version: "1.0.0".to_owned(),
1029            build_number: "1".to_owned(),
1030            minimum_os: "16.0".to_owned(),
1031            sdk_version: "18.5".to_owned(),
1032            sdk_build_version: "22F76".to_owned(),
1033            nested_bundles: Vec::new(),
1034            required_resources: BTreeMap::new(),
1035        };
1036        let slice = MachOSliceEvidence {
1037            architecture: "arm64".to_owned(),
1038            platform: ApplePlatform::Ios,
1039            minimum_os: Some("16.0.0".to_owned()),
1040            sdk: Some("18.5.0".to_owned()),
1041        };
1042        let inspection = UnsignedXcarchiveInspection {
1043            application_path: "Applications/App.app".to_owned(),
1044            architectures: vec!["arm64".to_owned()],
1045            app: UnsignedAppInspection {
1046                app_directory_name: "App.app".to_owned(),
1047                bundle_identifier: "com.example.app".to_owned(),
1048                executable: "App".to_owned(),
1049                main_executable: vec![slice],
1050                nested_executables: BTreeMap::new(),
1051                extensions: Vec::new(),
1052                resources: BTreeMap::new(),
1053                entries: vec!["App".to_owned()],
1054            },
1055            entries: vec!["Products/Applications/App.app/App".to_owned()],
1056        };
1057        let transport = SourceArchive {
1058            size: 456,
1059            sha256: "b".repeat(64),
1060        };
1061        let compile = CompilePhaseEvidence {
1062            schema_version: COMPILE_PHASE_EVIDENCE_SCHEMA_VERSION,
1063            job_id: "job-1".to_owned(),
1064            provider: "ssh-macos".to_owned(),
1065            request_sha256: "c".repeat(64),
1066            source_sha256: fixture.request.source.sha256.clone(),
1067            cargo_lock_sha256: "d".repeat(64),
1068            config_sha256: "e".repeat(64),
1069            rustferry_version: "0.1.0".to_owned(),
1070            worker_version: "0.1.0".to_owned(),
1071            toolchain: CompileToolchainEvidence {
1072                worker_os: "macOS 15.0".to_owned(),
1073                worker_architecture: "arm64".to_owned(),
1074                xcode_version: "16.4".to_owned(),
1075                iphoneos_sdk_version: "18.5".to_owned(),
1076                iphoneos_sdk_build_version: "22F76".to_owned(),
1077                developer_directory_sha256: "f".repeat(64),
1078                rust_version: "rustc 1.92.0".to_owned(),
1079                rust_target: IOS_DEVICE_RUST_TARGET.to_owned(),
1080            },
1081            sealed_archive: SealedUnsignedArchive {
1082                schema_version: SEALED_UNSIGNED_ARCHIVE_SCHEMA_VERSION,
1083                transport: transport.clone(),
1084                contents: fixture.descriptor.manifest.clone(),
1085                expectation,
1086            },
1087            archive_inspection: inspection,
1088            started_at_unix_seconds: 100,
1089            finished_at_unix_seconds: 200,
1090        };
1091        SnapshotArtifactDescriptor::new(
1092            "operation-1",
1093            ArtifactRecord {
1094                artifact_id: "archive-1".to_owned(),
1095                kind: ArtifactKind::Xcarchive,
1096                file_name: "App-unsigned.xcarchive.zip".to_owned(),
1097                size: transport.size,
1098                sha256: transport.sha256,
1099                media_type: Some("application/zip".to_owned()),
1100            },
1101            compile,
1102        )
1103        .expect("artifact descriptor")
1104    }
1105
1106    #[test]
1107    fn parameters_roundtrip_only_snapshot_unsigned_xcarchive() {
1108        let fixture = fixture();
1109        let parameters =
1110            SnapshotBuildParameters::from_request(&fixture.request).expect("snapshot parameters");
1111        let reconstructed = parameters
1112            .reconstruct_request(&fixture.descriptor, SourceArchiveLimits::default())
1113            .expect("reconstructed request");
1114        assert_eq!(reconstructed, fixture.request);
1115
1116        let mut wrong_mode = fixture.request.clone();
1117        wrong_mode.source_mode = SourceMode::Git;
1118        wrong_mode.source_repository = Some("https://github.com/example/app".to_owned());
1119        wrong_mode.source_revision = Some("a".repeat(40));
1120        assert!(SnapshotBuildParameters::from_request(&wrong_mode).is_err());
1121
1122        let mut wrong_artifacts = fixture.request.clone();
1123        wrong_artifacts.requested_artifacts = BTreeSet::from([IosArtifactType::AppBundle]);
1124        assert!(SnapshotBuildParameters::from_request(&wrong_artifacts).is_err());
1125    }
1126
1127    #[test]
1128    fn build_start_binds_archive_to_separate_descriptor() {
1129        let fixture = fixture();
1130        let parameters =
1131            SnapshotBuildParameters::from_request(&fixture.request).expect("snapshot parameters");
1132        let start = SnapshotBuildStart::new(
1133            parameters,
1134            100,
1135            "9".repeat(64),
1136            fixture.descriptor.archive.clone(),
1137        )
1138        .expect("build start");
1139        assert_eq!(
1140            start
1141                .reconstruct_request(&fixture.descriptor, SourceArchiveLimits::default())
1142                .expect("bound request"),
1143            fixture.request
1144        );
1145
1146        let mut substituted = fixture.descriptor.clone();
1147        substituted.archive.sha256 = "8".repeat(64);
1148        assert!(
1149            start
1150                .reconstruct_request(&substituted, SourceArchiveLimits::default())
1151                .is_err()
1152        );
1153    }
1154
1155    #[test]
1156    fn build_start_enforces_the_interactive_descriptor_limit_boundary() {
1157        let fixture = fixture();
1158        let parameters =
1159            SnapshotBuildParameters::from_request(&fixture.request).expect("snapshot parameters");
1160        assert!(
1161            SnapshotBuildStart::new(
1162                parameters.clone(),
1163                MAX_SNAPSHOT_SESSION_DESCRIPTOR_BYTES,
1164                "9".repeat(64),
1165                fixture.descriptor.archive.clone(),
1166            )
1167            .is_ok()
1168        );
1169        assert!(
1170            SnapshotBuildStart::new(
1171                parameters,
1172                MAX_SNAPSHOT_SESSION_DESCRIPTOR_BYTES + 1,
1173                "9".repeat(64),
1174                fixture.descriptor.archive.clone(),
1175            )
1176            .is_err()
1177        );
1178    }
1179
1180    #[test]
1181    fn artifact_receipt_and_completion_require_exact_proof() {
1182        let fixture = fixture();
1183        let descriptor = artifact_descriptor(&fixture);
1184        let receipt = SnapshotArtifactReceipt::new(
1185            &descriptor,
1186            ProtocolPath::new(
1187                ProtocolPathSemantics::ClientAbsolute,
1188                "/tmp/App-unsigned.xcarchive.zip",
1189            )
1190            .expect("client path"),
1191        )
1192        .expect("receipt");
1193        receipt.validate_for(&descriptor).expect("exact receipt");
1194        let mut changed = receipt.clone();
1195        changed.sha256 = "0".repeat(64);
1196        assert!(changed.validate_for(&descriptor).is_err());
1197
1198        let cleanup = CleanupConfirmation {
1199            job_id: descriptor.job_id.clone(),
1200            completed_at_ms: 300,
1201            workspace_removed: true,
1202            signing_material_removed: true,
1203            artifacts_retained: false,
1204        };
1205        SnapshotBuildComplete::new(descriptor.operation_id.clone(), cleanup.clone())
1206            .expect("complete cleanup");
1207        let mut retained = cleanup;
1208        retained.artifacts_retained = true;
1209        assert!(SnapshotBuildComplete::new("operation-1", retained).is_err());
1210    }
1211
1212    #[test]
1213    fn strict_json_rejects_unknown_outer_nested_version_and_cleanup_fields() {
1214        let accepted = SnapshotJobAccepted::new("operation-1", "job-1", 10).expect("job accepted");
1215        let mut accepted_json = serde_json::to_value(accepted).expect("accepted JSON");
1216        accepted_json
1217            .as_object_mut()
1218            .expect("accepted object")
1219            .insert("future".to_owned(), serde_json::Value::Bool(true));
1220        assert!(serde_json::from_value::<SnapshotJobAccepted>(accepted_json).is_err());
1221
1222        let version_json = serde_json::json!({
1223            "schema_version": 1,
1224            "protocol_version": {"major": 1, "minor": 0, "future": true},
1225            "operation_id": "operation-1",
1226            "job_id": "job-1",
1227            "state": "created",
1228            "created_at_ms": 10
1229        });
1230        assert!(serde_json::from_value::<SnapshotJobAccepted>(version_json).is_err());
1231
1232        let complete_json = serde_json::json!({
1233            "schema_version": 1,
1234            "protocol_version": {"major": 1, "minor": 0},
1235            "operation_id": "operation-1",
1236            "job_id": "job-1",
1237            "cleanup": {
1238                "job_id": "job-1",
1239                "completed_at_ms": 10,
1240                "workspace_removed": true,
1241                "signing_material_removed": true,
1242                "artifacts_retained": false,
1243                "future": true
1244            }
1245        });
1246        assert!(serde_json::from_value::<SnapshotBuildComplete>(complete_json).is_err());
1247    }
1248
1249    #[test]
1250    fn public_session_errors_are_bounded_and_cleanup_bound() {
1251        let cleanup = CleanupConfirmation {
1252            job_id: "job-1".to_owned(),
1253            completed_at_ms: 20,
1254            workspace_removed: true,
1255            signing_material_removed: true,
1256            artifacts_retained: false,
1257        };
1258        SnapshotSessionError::new(
1259            Some("operation-1".to_owned()),
1260            Some("job-1".to_owned()),
1261            "worker.build_failed",
1262            "Unsigned build failed",
1263            false,
1264            Some(cleanup.clone()),
1265        )
1266        .expect("safe error");
1267        assert!(
1268            SnapshotSessionError::new(
1269                Some("operation-1".to_owned()),
1270                Some("job-2".to_owned()),
1271                "worker.build_failed",
1272                "Unsigned build failed",
1273                false,
1274                Some(cleanup),
1275            )
1276            .is_err()
1277        );
1278        assert!(
1279            SnapshotSessionError::new(None, None, "worker.failed", "unsafe\u{1b}[2J", false, None,)
1280                .is_err()
1281        );
1282    }
1283}