1use 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
21pub const SNAPSHOT_SESSION_SCHEMA_VERSION: u32 = 1;
23pub 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#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
39#[serde(deny_unknown_fields)]
40pub struct SnapshotBuildParameters {
41 #[serde(deserialize_with = "deserialize_protocol_version")]
43 pub protocol_version: ProtocolVersion,
44 pub operation_id: String,
46 pub product_name: String,
48 pub bundle_identifier: String,
50 pub minimum_ios_version: String,
52 pub product: IosDeviceProductExpectation,
54 pub profile: BuildProfile,
56 pub signing: SigningPlan,
58 pub requested_artifacts: BTreeSet<IosArtifactType>,
60}
61
62impl SnapshotBuildParameters {
63 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 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#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
159#[serde(deny_unknown_fields)]
160pub struct SnapshotBuildStart {
161 pub schema_version: u32,
163 pub parameters: SnapshotBuildParameters,
165 pub source_descriptor_size: u64,
167 pub source_descriptor_sha256: String,
169 pub source_archive: SourceArchive,
171}
172
173impl SnapshotBuildStart {
174 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 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 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#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
249#[serde(deny_unknown_fields)]
250pub struct SnapshotJobAccepted {
251 pub schema_version: u32,
253 #[serde(deserialize_with = "deserialize_protocol_version")]
255 pub protocol_version: ProtocolVersion,
256 pub operation_id: String,
258 pub job_id: String,
260 pub state: JobState,
262 pub created_at_ms: u64,
264}
265
266impl SnapshotJobAccepted {
267 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 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 #[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#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
321#[serde(deny_unknown_fields)]
322pub struct SnapshotArtifactDescriptor {
323 pub schema_version: u32,
325 #[serde(deserialize_with = "deserialize_protocol_version")]
327 pub protocol_version: ProtocolVersion,
328 pub operation_id: String,
330 pub job_id: String,
332 pub artifact: ArtifactRecord,
334 pub compile: CompilePhaseEvidence,
336}
337
338impl SnapshotArtifactDescriptor {
339 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 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#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
389#[serde(deny_unknown_fields)]
390pub struct SnapshotArtifactReceipt {
391 pub schema_version: u32,
393 #[serde(deserialize_with = "deserialize_protocol_version")]
395 pub protocol_version: ProtocolVersion,
396 pub operation_id: String,
398 pub job_id: String,
400 pub artifact_id: String,
402 pub size: u64,
404 pub sha256: String,
406 pub local_path: ProtocolPath,
408}
409
410impl SnapshotArtifactReceipt {
411 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 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#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
473#[serde(deny_unknown_fields)]
474pub struct SnapshotBuildComplete {
475 pub schema_version: u32,
477 #[serde(deserialize_with = "deserialize_protocol_version")]
479 pub protocol_version: ProtocolVersion,
480 pub operation_id: String,
482 pub job_id: String,
484 #[serde(deserialize_with = "deserialize_cleanup_confirmation")]
486 pub cleanup: CleanupConfirmation,
487}
488
489impl SnapshotBuildComplete {
490 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 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#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
536#[serde(deny_unknown_fields)]
537pub struct SnapshotSessionError {
538 pub schema_version: u32,
540 #[serde(deserialize_with = "deserialize_protocol_version")]
542 pub protocol_version: ProtocolVersion,
543 pub operation_id: Option<String>,
545 pub job_id: Option<String>,
547 pub code: String,
549 pub message: String,
551 pub retryable: bool,
553 #[serde(
555 default,
556 deserialize_with = "deserialize_optional_cleanup_confirmation"
557 )]
558 pub cleanup: Option<CleanupConfirmation>,
559}
560
561impl SnapshotSessionError {
562 #[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 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}