1use std::{collections::BTreeSet, fmt};
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use sha2::{Digest, Sha256};
7use unicode_normalization::UnicodeNormalization;
8
9use crate::{
10 artifact::{
11 ArtifactKind, ArtifactManifest, IosDeviceProductExpectation, IpaExpectation,
12 UnsignedNestedBundleKind,
13 },
14 error::{RemoteBuildError, RemoteBuildResult},
15 signing::{SigningMode, SigningPlan, SigningTargetKind},
16 source::{SourceLimits, SourceManifest, SourceMode, validate_source_manifest},
17};
18
19pub const IOS_DEVICE_RUST_TARGET: &str = "aarch64-apple-ios";
21
22pub const IOS_DEVICE_SDK: &str = "iphoneos";
24
25pub const MAX_EVENT_LINE_BYTES: usize = 1024 * 1024;
27
28pub const CURRENT_PROTOCOL_VERSION: ProtocolVersion = ProtocolVersion::new(1, 0);
30
31pub const REMOTE_BUILD_EVENT_TYPES: &[&str] = &[
33 "operation_started",
34 "job_created",
35 "job_queued",
36 "worker_assigned",
37 "source_prepared",
38 "source_upload_started",
39 "source_upload_progress",
40 "source_verified",
41 "phase_started",
42 "progress",
43 "command_started",
44 "diagnostic",
45 "signing_started",
46 "artifact_created",
47 "artifact_validated",
48 "artifact_upload_started",
49 "artifact_download_started",
50 "artifact_download_progress",
51 "artifact_downloaded",
52 "warning",
53 "cleanup_started",
54 "cleanup_finished",
55 "operation_finished",
56 "operation_cancelled",
57];
58
59#[derive(
64 Clone, Copy, Debug, Deserialize, Eq, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
65)]
66pub struct ProtocolVersion {
67 pub major: u16,
69 pub minor: u16,
71}
72
73impl ProtocolVersion {
74 pub const fn new(major: u16, minor: u16) -> Self {
76 Self { major, minor }
77 }
78
79 pub const fn is_compatible_with(self, other: Self) -> bool {
81 self.major == other.major && (self.major != 0 || self.minor == other.minor)
82 }
83
84 pub fn negotiate(self, other: Self) -> RemoteBuildResult<Self> {
90 if self.is_compatible_with(other) {
91 Ok(Self::new(self.major, self.minor.min(other.minor)))
92 } else {
93 Err(RemoteBuildError::IncompatibleProtocolVersion {
94 supported: self,
95 received: other,
96 })
97 }
98 }
99}
100
101impl fmt::Display for ProtocolVersion {
102 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
103 write!(formatter, "{}.{}", self.major, self.minor)
104 }
105}
106
107#[derive(
109 Clone, Copy, Debug, Deserialize, Eq, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
110)]
111#[serde(rename_all = "snake_case")]
112pub enum BuildProfile {
113 Debug,
115 Release,
117}
118
119#[derive(
121 Clone, Copy, Debug, Deserialize, Eq, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
122)]
123#[serde(rename_all = "snake_case")]
124pub enum IosArtifactType {
125 Ipa,
127 Xcarchive,
129 AppBundle,
131 Dsym,
133 SigningReport,
135 ProvisioningReport,
137}
138
139impl fmt::Display for IosArtifactType {
140 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
141 let name = match self {
142 Self::Ipa => "ipa",
143 Self::Xcarchive => "xcarchive",
144 Self::AppBundle => "app_bundle",
145 Self::Dsym => "dsym",
146 Self::SigningReport => "signing_report",
147 Self::ProvisioningReport => "provisioning_report",
148 };
149 formatter.write_str(name)
150 }
151}
152
153impl IosArtifactType {
154 #[must_use]
156 pub const fn artifact_kind(self) -> ArtifactKind {
157 match self {
158 Self::Ipa => ArtifactKind::Ipa,
159 Self::Xcarchive => ArtifactKind::Xcarchive,
160 Self::AppBundle => ArtifactKind::App,
161 Self::Dsym => ArtifactKind::Dsym,
162 Self::SigningReport => ArtifactKind::SigningReport,
163 Self::ProvisioningReport => ArtifactKind::ValidationReport,
164 }
165 }
166}
167
168#[derive(
170 Clone, Copy, Debug, Deserialize, Eq, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
171)]
172#[serde(rename_all = "snake_case")]
173pub enum ProtocolPathSemantics {
174 ProjectRelative,
176 WorkerRelative,
178 ClientAbsolute,
180 ProviderUri,
182}
183
184#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
186pub struct ProtocolPath {
187 pub semantics: ProtocolPathSemantics,
189 pub value: String,
191}
192
193impl ProtocolPath {
194 pub fn new(
200 semantics: ProtocolPathSemantics,
201 value: impl Into<String>,
202 ) -> RemoteBuildResult<Self> {
203 let path = Self {
204 semantics,
205 value: value.into(),
206 };
207 path.validate()?;
208 Ok(path)
209 }
210
211 pub fn validate(&self) -> RemoteBuildResult<()> {
217 validate_safe_text("path", &self.value, 4096)?;
218 match self.semantics {
219 ProtocolPathSemantics::ProjectRelative | ProtocolPathSemantics::WorkerRelative => {
220 if is_absolute_path_text(&self.value) {
221 return Err(RemoteBuildError::InvalidIdentifier {
222 field: "path",
223 reason: "relative path must not be absolute",
224 });
225 }
226 if self
227 .value
228 .split(['/', '\\'])
229 .any(|component| component.is_empty() || component == "." || component == "..")
230 {
231 return Err(RemoteBuildError::InvalidIdentifier {
232 field: "path",
233 reason: "relative path contains an empty, current, or parent component",
234 });
235 }
236 }
237 ProtocolPathSemantics::ClientAbsolute => {
238 if !is_absolute_path_text(&self.value) {
239 return Err(RemoteBuildError::InvalidIdentifier {
240 field: "path",
241 reason: "client path must be absolute",
242 });
243 }
244 }
245 ProtocolPathSemantics::ProviderUri => {
246 let uri = url::Url::parse(&self.value).map_err(|_| {
247 RemoteBuildError::InvalidIdentifier {
248 field: "path",
249 reason: "provider URI is invalid",
250 }
251 })?;
252 if !uri.username().is_empty() || uri.password().is_some() {
253 return Err(RemoteBuildError::InvalidIdentifier {
254 field: "path",
255 reason: "provider URI must not contain credentials",
256 });
257 }
258 }
259 }
260 Ok(())
261 }
262}
263
264#[derive(
266 Clone, Copy, Debug, Deserialize, Eq, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
267)]
268#[serde(rename_all = "snake_case")]
269pub enum JobState {
270 Created,
272 Queued,
274 Running,
276 Cancelling,
278 Succeeded,
280 Failed,
282 Cancelled,
284 Cleaning,
286 Cleaned,
288 CleanupFailed,
290}
291
292impl JobState {
293 pub const fn can_transition_to(self, next: Self) -> bool {
295 matches!(
296 (self, next),
297 (
298 Self::Created,
299 Self::Queued | Self::Running | Self::Cancelling | Self::Cancelled | Self::Failed
300 ) | (
301 Self::Queued,
302 Self::Running | Self::Cancelling | Self::Cancelled | Self::Failed
303 ) | (
304 Self::Running,
305 Self::Succeeded | Self::Failed | Self::Cancelling | Self::Cancelled
306 ) | (Self::Cancelling, Self::Cancelled | Self::Failed)
307 | (
308 Self::Succeeded | Self::Failed | Self::Cancelled,
309 Self::Cleaning
310 )
311 | (Self::Cleaning, Self::Cleaned | Self::CleanupFailed)
312 )
313 }
314
315 pub fn transition_to(self, next: Self) -> RemoteBuildResult<Self> {
321 if self.can_transition_to(next) {
322 Ok(next)
323 } else {
324 Err(RemoteBuildError::InvalidJobTransition {
325 from: self,
326 to: next,
327 })
328 }
329 }
330
331 pub const fn is_build_terminal(self) -> bool {
333 matches!(self, Self::Succeeded | Self::Failed | Self::Cancelled)
334 }
335
336 pub const fn is_terminal(self) -> bool {
338 matches!(self, Self::Cleaned | Self::CleanupFailed)
339 }
340}
341
342#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
347pub struct IosDeviceBuildRequest {
348 pub protocol_version: ProtocolVersion,
350 pub operation_id: String,
352 pub product_name: String,
354 pub bundle_identifier: String,
356 pub minimum_ios_version: String,
358 pub product: IosDeviceProductExpectation,
360 pub profile: BuildProfile,
362 pub source_mode: SourceMode,
364 #[serde(skip_serializing_if = "Option::is_none")]
366 pub source_repository: Option<String>,
367 #[serde(skip_serializing_if = "Option::is_none")]
369 pub source_revision: Option<String>,
370 pub source: SourceManifest,
372 pub signing: SigningPlan,
374 pub requested_artifacts: BTreeSet<IosArtifactType>,
376}
377
378impl IosDeviceBuildRequest {
379 pub fn validate(&self) -> RemoteBuildResult<()> {
386 CURRENT_PROTOCOL_VERSION.negotiate(self.protocol_version)?;
387 validate_identifier("operation_id", &self.operation_id)?;
388 validate_safe_text("product_name", &self.product_name, 255)?;
389 validate_bundle_identifier(&self.bundle_identifier)?;
390 validate_ios_version(&self.minimum_ios_version)?;
391 validate_product_expectation(&self.product, &self.bundle_identifier, &self.signing)?;
392 validate_source_manifest(&self.source, SourceLimits::default()).map_err(|error| {
393 RemoteBuildError::InvalidSourceManifest {
394 message: error.to_string(),
395 }
396 })?;
397 self.signing
398 .validate()
399 .map_err(|_| RemoteBuildError::InvalidEventPayload {
400 event: "ios_device_build_request",
401 reason: "signing plan is invalid",
402 })?;
403 let application_bundle_identifier = self
404 .signing
405 .targets
406 .iter()
407 .find(|target| target.kind == crate::signing::SigningTargetKind::Application)
408 .map(|target| target.bundle_identifier.as_str());
409 if application_bundle_identifier != Some(self.bundle_identifier.as_str()) {
410 return Err(RemoteBuildError::InvalidEventPayload {
411 event: "ios_device_build_request",
412 reason: "signing plan application bundle identifier does not match request",
413 });
414 }
415 validate_requested_artifact_selection(self.signing.mode, &self.requested_artifacts)?;
416 match self.source_mode {
417 SourceMode::Git | SourceMode::GitSnapshot => {
418 let repository = self.source_repository.as_deref().ok_or(
419 RemoteBuildError::InvalidEventPayload {
420 event: "ios_device_build_request",
421 reason: "Git source requires a normalized HTTPS GitHub repository URL",
422 },
423 )?;
424 let revision = self.source_revision.as_deref().ok_or(
425 RemoteBuildError::InvalidEventPayload {
426 event: "ios_device_build_request",
427 reason: "Git source requires an exact commit SHA",
428 },
429 )?;
430 validate_github_repository(repository)?;
431 validate_git_revision(revision)?;
432 }
433 SourceMode::Snapshot => {
434 if self.source_repository.is_some() || self.source_revision.is_some() {
435 return Err(RemoteBuildError::InvalidEventPayload {
436 event: "ios_device_build_request",
437 reason: "snapshot source forbids repository and revision fields",
438 });
439 }
440 }
441 }
442 if self.requested_artifacts.is_empty() {
443 return Err(RemoteBuildError::InvalidEventPayload {
444 event: "ios_device_build_request",
445 reason: "at least one artifact type is required",
446 });
447 }
448 Ok(())
449 }
450
451 pub fn ipa_expectation(&self) -> RemoteBuildResult<IpaExpectation> {
457 self.validate()?;
458 Ok(IpaExpectation {
459 app_directory_name: self.product.app_directory_name.clone(),
460 bundle_identifier: self.bundle_identifier.clone(),
461 executable: self.product.executable.clone(),
462 app_version: Some(self.product.app_version.clone()),
463 build_number: Some(self.product.build_number.clone()),
464 minimum_os: self.minimum_ios_version.clone(),
465 nested_bundles: self.product.nested_bundles.clone(),
466 provisioning_required: self.signing.mode.is_signed(),
467 })
468 }
469}
470
471fn validate_requested_artifact_selection(
472 signing_mode: SigningMode,
473 requested_artifacts: &BTreeSet<IosArtifactType>,
474) -> RemoteBuildResult<()> {
475 if signing_mode == SigningMode::UnsignedCompileOnly
476 && requested_artifacts.contains(&IosArtifactType::Ipa)
477 {
478 return Err(RemoteBuildError::InvalidEventPayload {
479 event: "ios_device_build_request",
480 reason: "unsigned compile-only mode cannot request an installable IPA",
481 });
482 }
483 if signing_mode.is_signed() && !requested_artifacts.contains(&IosArtifactType::Ipa) {
484 return Err(RemoteBuildError::InvalidEventPayload {
485 event: "ios_device_build_request",
486 reason: "signed builds must request the installable IPA",
487 });
488 }
489 if signing_mode.is_signed() && !requested_artifacts.contains(&IosArtifactType::SigningReport) {
490 return Err(RemoteBuildError::InvalidEventPayload {
491 event: "ios_device_build_request",
492 reason: "signed builds must request the signing report",
493 });
494 }
495 Ok(())
496}
497
498pub fn canonical_request_bytes(request: &IosDeviceBuildRequest) -> RemoteBuildResult<Vec<u8>> {
507 request.validate()?;
508 serde_json::to_vec(request).map_err(|error| RemoteBuildError::Serialization {
509 message: error.to_string(),
510 })
511}
512
513pub fn canonical_request_sha256(request: &IosDeviceBuildRequest) -> RemoteBuildResult<String> {
519 Ok(hex::encode(Sha256::digest(canonical_request_bytes(
520 request,
521 )?)))
522}
523
524pub fn canonical_git_snapshot_request_template_sha256(
536 request: &IosDeviceBuildRequest,
537) -> RemoteBuildResult<String> {
538 if request.source_mode != SourceMode::GitSnapshot {
539 return Err(RemoteBuildError::InvalidEventPayload {
540 event: "ios_device_build_request",
541 reason: "Git snapshot template requires Git snapshot source mode",
542 });
543 }
544
545 let mut validated = request.clone();
546 if request.source_revision.is_none() {
547 validated.source_revision = Some("0".repeat(40));
548 }
549 validated.validate()?;
550
551 validated.source_revision = None;
552 let bytes =
553 serde_json::to_vec(&validated).map_err(|error| RemoteBuildError::Serialization {
554 message: error.to_string(),
555 })?;
556 Ok(hex::encode(Sha256::digest(bytes)))
557}
558
559pub fn canonical_retry_template_sha256_v1(
569 request: &IosDeviceBuildRequest,
570) -> RemoteBuildResult<String> {
571 const DOMAIN: &[u8] = b"rustferry-retry-template-v1\0";
572 const OPERATION_ID_PLACEHOLDER: &str = "semantic-retry-template-v1";
573
574 request.validate()?;
575 let mut template = request.clone();
576 OPERATION_ID_PLACEHOLDER.clone_into(&mut template.operation_id);
577 let bytes = canonical_request_bytes(&template)?;
578 let mut digest = Sha256::new();
579 digest.update(DOMAIN);
580 digest.update(bytes);
581 Ok(hex::encode(digest.finalize()))
582}
583
584#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
586pub struct IosDeviceBuildResult {
587 pub protocol_version: ProtocolVersion,
589 pub operation_id: String,
591 pub job_id: String,
593 pub state: JobState,
595 pub artifacts: Vec<ArtifactManifest>,
597 #[serde(skip_serializing_if = "Option::is_none")]
599 pub cleanup: Option<CleanupConfirmation>,
600}
601
602impl IosDeviceBuildResult {
603 pub fn validate(&self) -> RemoteBuildResult<()> {
610 CURRENT_PROTOCOL_VERSION.negotiate(self.protocol_version)?;
611 validate_identifier("operation_id", &self.operation_id)?;
612 validate_identifier("job_id", &self.job_id)?;
613 if !self.state.is_build_terminal() && !self.state.is_terminal() {
614 return Err(RemoteBuildError::InvalidEventPayload {
615 event: "ios_device_build_result",
616 reason: "result state is not terminal",
617 });
618 }
619 if self.state == JobState::Succeeded && self.artifacts.is_empty() {
620 return Err(RemoteBuildError::InvalidEventPayload {
621 event: "ios_device_build_result",
622 reason: "successful result has no artifact manifest",
623 });
624 }
625 Ok(())
626 }
627}
628
629#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
631pub struct CleanupConfirmation {
632 pub job_id: String,
634 pub completed_at_ms: u64,
636 pub workspace_removed: bool,
638 pub signing_material_removed: bool,
640 pub artifacts_retained: bool,
642}
643
644#[derive(
646 Clone, Copy, Debug, Deserialize, Eq, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
647)]
648#[serde(rename_all = "snake_case")]
649pub enum DiagnosticSeverity {
650 Information,
652 Warning,
654 Error,
656}
657
658#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
660pub struct RemoteDiagnostic {
661 pub code: String,
663 pub severity: DiagnosticSeverity,
665 pub message: String,
667 #[serde(skip_serializing_if = "Option::is_none")]
669 pub help: Option<String>,
670 #[serde(skip_serializing_if = "Option::is_none")]
672 pub path: Option<ProtocolPath>,
673 #[serde(skip_serializing_if = "Option::is_none")]
675 pub line: Option<u32>,
676 #[serde(skip_serializing_if = "Option::is_none")]
678 pub column: Option<u32>,
679}
680
681#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
683pub struct RemoteErrorInfo {
684 pub code: String,
686 pub message: String,
688 #[serde(skip_serializing_if = "Option::is_none")]
690 pub help: Option<String>,
691 pub retryable: bool,
693}
694
695#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
697#[serde(tag = "event", rename_all = "snake_case")]
698pub enum RemoteBuildEventKind {
699 OperationStarted {
701 command: String,
703 },
704 JobCreated {
706 state: JobState,
708 },
709 JobQueued {
711 #[serde(skip_serializing_if = "Option::is_none")]
713 position: Option<u64>,
714 },
715 WorkerAssigned {
717 worker_id: String,
719 },
720 SourcePrepared {
722 file_count: u64,
724 total_bytes: u64,
726 },
727 SourceUploadStarted {
729 total_bytes: u64,
731 },
732 SourceUploadProgress {
734 uploaded_bytes: u64,
736 total_bytes: u64,
738 },
739 SourceVerified {
741 sha256: String,
743 },
744 PhaseStarted {
746 #[serde(skip_serializing_if = "Option::is_none")]
748 message: Option<String>,
749 },
750 Progress {
752 message: String,
754 #[serde(skip_serializing_if = "Option::is_none")]
756 current: Option<u64>,
757 #[serde(skip_serializing_if = "Option::is_none")]
759 total: Option<u64>,
760 },
761 CommandStarted {
763 tool: String,
765 arguments: Vec<String>,
767 },
768 Diagnostic {
770 diagnostic: RemoteDiagnostic,
772 },
773 SigningStarted {
775 mode: SigningMode,
777 },
778 ArtifactCreated {
780 artifact_id: String,
782 artifact_type: IosArtifactType,
784 },
785 ArtifactValidated {
787 artifact: ArtifactManifest,
789 },
790 ArtifactUploadStarted {
792 artifact_id: String,
794 total_bytes: u64,
796 },
797 ArtifactDownloadStarted {
799 artifact_id: String,
801 total_bytes: u64,
803 },
804 ArtifactDownloadProgress {
806 artifact_id: String,
808 downloaded_bytes: u64,
810 total_bytes: u64,
812 },
813 ArtifactDownloaded {
815 artifact: ArtifactManifest,
817 local_path: ProtocolPath,
819 },
820 Warning {
822 code: String,
824 message: String,
826 #[serde(skip_serializing_if = "Option::is_none")]
828 help: Option<String>,
829 },
830 CleanupStarted,
832 CleanupFinished {
834 confirmation: CleanupConfirmation,
836 },
837 OperationFinished {
839 success: bool,
841 duration_ms: u64,
843 #[serde(skip_serializing_if = "Option::is_none")]
845 result: Option<IosDeviceBuildResult>,
846 #[serde(skip_serializing_if = "Option::is_none")]
848 error: Option<RemoteErrorInfo>,
849 },
850 OperationCancelled {
852 reason: String,
854 duration_ms: u64,
856 },
857 #[serde(other)]
859 Unknown,
860}
861
862impl RemoteBuildEventKind {
863 pub const fn event_name(&self) -> &'static str {
865 match self {
866 Self::OperationStarted { .. } => "operation_started",
867 Self::JobCreated { .. } => "job_created",
868 Self::JobQueued { .. } => "job_queued",
869 Self::WorkerAssigned { .. } => "worker_assigned",
870 Self::SourcePrepared { .. } => "source_prepared",
871 Self::SourceUploadStarted { .. } => "source_upload_started",
872 Self::SourceUploadProgress { .. } => "source_upload_progress",
873 Self::SourceVerified { .. } => "source_verified",
874 Self::PhaseStarted { .. } => "phase_started",
875 Self::Progress { .. } => "progress",
876 Self::CommandStarted { .. } => "command_started",
877 Self::Diagnostic { .. } => "diagnostic",
878 Self::SigningStarted { .. } => "signing_started",
879 Self::ArtifactCreated { .. } => "artifact_created",
880 Self::ArtifactValidated { .. } => "artifact_validated",
881 Self::ArtifactUploadStarted { .. } => "artifact_upload_started",
882 Self::ArtifactDownloadStarted { .. } => "artifact_download_started",
883 Self::ArtifactDownloadProgress { .. } => "artifact_download_progress",
884 Self::ArtifactDownloaded { .. } => "artifact_downloaded",
885 Self::Warning { .. } => "warning",
886 Self::CleanupStarted => "cleanup_started",
887 Self::CleanupFinished { .. } => "cleanup_finished",
888 Self::OperationFinished { .. } => "operation_finished",
889 Self::OperationCancelled { .. } => "operation_cancelled",
890 Self::Unknown => "unknown",
891 }
892 }
893}
894
895#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
897pub struct RemoteBuildEvent {
898 pub protocol_version: ProtocolVersion,
900 pub operation_id: String,
902 pub job_id: String,
904 pub timestamp_ms: u64,
906 pub provider: String,
908 pub phase: String,
910 pub sequence: u64,
912 #[serde(flatten)]
914 pub kind: RemoteBuildEventKind,
915}
916
917impl RemoteBuildEvent {
918 #[allow(clippy::too_many_arguments)]
924 pub fn new(
925 operation_id: impl Into<String>,
926 job_id: impl Into<String>,
927 timestamp_ms: u64,
928 provider: impl Into<String>,
929 phase: impl Into<String>,
930 sequence: u64,
931 kind: RemoteBuildEventKind,
932 ) -> RemoteBuildResult<Self> {
933 let event = Self {
934 protocol_version: CURRENT_PROTOCOL_VERSION,
935 operation_id: operation_id.into(),
936 job_id: job_id.into(),
937 timestamp_ms,
938 provider: provider.into(),
939 phase: phase.into(),
940 sequence,
941 kind,
942 };
943 event.validate()?;
944 Ok(event)
945 }
946
947 pub fn encode_line(&self) -> RemoteBuildResult<String> {
953 self.validate()?;
954 let mut encoded =
955 serde_json::to_string(self).map_err(|error| RemoteBuildError::Serialization {
956 message: error.to_string(),
957 })?;
958 encoded.push('\n');
959 Ok(encoded)
960 }
961
962 pub fn decode_line(encoded: &str) -> RemoteBuildResult<Self> {
968 if encoded.len() > MAX_EVENT_LINE_BYTES {
969 return Err(RemoteBuildError::EventTooLarge {
970 bytes: encoded.len(),
971 maximum: MAX_EVENT_LINE_BYTES,
972 });
973 }
974 let event = serde_json::from_str::<Self>(encoded).map_err(|error| {
975 if error.is_eof() {
976 RemoteBuildError::TruncatedEvent
977 } else {
978 RemoteBuildError::MalformedEvent {
979 message: error.to_string(),
980 }
981 }
982 })?;
983 event.validate()?;
984 Ok(event)
985 }
986
987 pub fn decode_line_bytes(encoded: &[u8]) -> RemoteBuildResult<Self> {
993 let encoded = std::str::from_utf8(encoded).map_err(|_| RemoteBuildError::InvalidUtf8)?;
994 Self::decode_line(encoded)
995 }
996
997 pub fn validate(&self) -> RemoteBuildResult<()> {
1003 CURRENT_PROTOCOL_VERSION.negotiate(self.protocol_version)?;
1004 validate_identifier("operation_id", &self.operation_id)?;
1005 validate_identifier("job_id", &self.job_id)?;
1006 validate_identifier("provider", &self.provider)?;
1007 validate_identifier("phase", &self.phase)?;
1008
1009 match &self.kind {
1010 RemoteBuildEventKind::JobCreated { state } if *state != JobState::Created => {
1011 return Err(RemoteBuildError::InvalidEventPayload {
1012 event: "job_created",
1013 reason: "initial state must be created",
1014 });
1015 }
1016 RemoteBuildEventKind::SourceUploadProgress {
1017 uploaded_bytes,
1018 total_bytes,
1019 } if uploaded_bytes > total_bytes => {
1020 return Err(RemoteBuildError::InvalidEventPayload {
1021 event: "source_upload_progress",
1022 reason: "uploaded bytes exceed total bytes",
1023 });
1024 }
1025 RemoteBuildEventKind::Progress {
1026 current: Some(current),
1027 total: Some(total),
1028 ..
1029 } if current > total => {
1030 return Err(RemoteBuildError::InvalidEventPayload {
1031 event: "progress",
1032 reason: "completed units exceed total units",
1033 });
1034 }
1035 RemoteBuildEventKind::ArtifactDownloadProgress {
1036 downloaded_bytes,
1037 total_bytes,
1038 ..
1039 } if downloaded_bytes > total_bytes => {
1040 return Err(RemoteBuildError::InvalidEventPayload {
1041 event: "artifact_download_progress",
1042 reason: "downloaded bytes exceed total bytes",
1043 });
1044 }
1045 RemoteBuildEventKind::ArtifactDownloaded { local_path, .. } => {
1046 local_path.validate()?;
1047 if local_path.semantics != ProtocolPathSemantics::ClientAbsolute {
1048 return Err(RemoteBuildError::InvalidEventPayload {
1049 event: "artifact_downloaded",
1050 reason: "download destination must be client_absolute",
1051 });
1052 }
1053 }
1054 RemoteBuildEventKind::Diagnostic { diagnostic } => {
1055 if let Some(path) = &diagnostic.path {
1056 path.validate()?;
1057 }
1058 }
1059 RemoteBuildEventKind::OperationFinished {
1060 success: true,
1061 error: Some(_),
1062 ..
1063 } => {
1064 return Err(RemoteBuildError::InvalidEventPayload {
1065 event: "operation_finished",
1066 reason: "successful operation must not contain an error",
1067 });
1068 }
1069 RemoteBuildEventKind::OperationFinished {
1070 success: false,
1071 error: None,
1072 ..
1073 } => {
1074 return Err(RemoteBuildError::InvalidEventPayload {
1075 event: "operation_finished",
1076 reason: "failed operation requires a typed error",
1077 });
1078 }
1079 _ => {}
1080 }
1081
1082 let value =
1083 serde_json::to_value(self).map_err(|error| RemoteBuildError::Serialization {
1084 message: error.to_string(),
1085 })?;
1086 validate_no_terminal_escape(&value)?;
1087 Ok(())
1088 }
1089}
1090
1091pub(crate) fn validate_identifier(field: &'static str, value: &str) -> RemoteBuildResult<()> {
1092 if value.is_empty() {
1093 return Err(RemoteBuildError::InvalidIdentifier {
1094 field,
1095 reason: "value is empty",
1096 });
1097 }
1098 if value.len() > 160 {
1099 return Err(RemoteBuildError::InvalidIdentifier {
1100 field,
1101 reason: "value exceeds 160 bytes",
1102 });
1103 }
1104 if !value
1105 .bytes()
1106 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
1107 {
1108 return Err(RemoteBuildError::InvalidIdentifier {
1109 field,
1110 reason: "value contains unsupported characters",
1111 });
1112 }
1113 Ok(())
1114}
1115
1116fn validate_safe_text(
1117 field: &'static str,
1118 value: &str,
1119 maximum_bytes: usize,
1120) -> RemoteBuildResult<()> {
1121 if value.is_empty() {
1122 return Err(RemoteBuildError::InvalidIdentifier {
1123 field,
1124 reason: "value is empty",
1125 });
1126 }
1127 if value.len() > maximum_bytes {
1128 return Err(RemoteBuildError::InvalidIdentifier {
1129 field,
1130 reason: "value exceeds its protocol limit",
1131 });
1132 }
1133 if value.chars().any(char::is_control) {
1134 return Err(RemoteBuildError::InvalidIdentifier {
1135 field,
1136 reason: "value contains control characters",
1137 });
1138 }
1139 Ok(())
1140}
1141
1142fn validate_bundle_identifier(value: &str) -> RemoteBuildResult<()> {
1143 validate_safe_text("bundle_identifier", value, 255)?;
1144 let mut segments = value.split('.');
1145 let valid_segment = |segment: &str| {
1146 !segment.is_empty()
1147 && !segment.starts_with('-')
1148 && !segment.ends_with('-')
1149 && segment
1150 .bytes()
1151 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
1152 };
1153 let first = segments.next();
1154 let second = segments.next();
1155 if first.is_none_or(|segment| !valid_segment(segment))
1156 || second.is_none_or(|segment| !valid_segment(segment))
1157 || segments.any(|segment| !valid_segment(segment))
1158 {
1159 return Err(RemoteBuildError::InvalidIdentifier {
1160 field: "bundle_identifier",
1161 reason: "value must contain at least two valid dot-separated segments",
1162 });
1163 }
1164 Ok(())
1165}
1166
1167fn validate_ios_version(value: &str) -> RemoteBuildResult<()> {
1168 validate_safe_text("minimum_ios_version", value, 32)?;
1169 let components = value.split('.').collect::<Vec<_>>();
1170 if components.is_empty()
1171 || components.len() > 3
1172 || components.iter().any(|component| {
1173 component.is_empty()
1174 || (component.len() > 1 && component.starts_with('0'))
1175 || !component.bytes().all(|byte| byte.is_ascii_digit())
1176 || component.parse::<u16>().is_err()
1177 })
1178 {
1179 return Err(RemoteBuildError::InvalidIdentifier {
1180 field: "minimum_ios_version",
1181 reason: "value must contain one to three canonical numeric components",
1182 });
1183 }
1184 Ok(())
1185}
1186
1187fn validate_product_expectation(
1188 product: &IosDeviceProductExpectation,
1189 main_bundle_identifier: &str,
1190 signing: &SigningPlan,
1191) -> RemoteBuildResult<()> {
1192 validate_portable_component("app_directory_name", &product.app_directory_name)?;
1193 if !has_exact_extension(&product.app_directory_name, "app") {
1194 return Err(RemoteBuildError::InvalidIdentifier {
1195 field: "app_directory_name",
1196 reason: "value must end with .app",
1197 });
1198 }
1199 validate_portable_component("executable", &product.executable)?;
1200 validate_bundle_version("app_version", &product.app_version)?;
1201 validate_bundle_version("build_number", &product.build_number)?;
1202 if product.nested_bundles.len() > 512 {
1203 return Err(RemoteBuildError::InvalidEventPayload {
1204 event: "ios_device_build_request",
1205 reason: "product contains too many nested bundles",
1206 });
1207 }
1208
1209 let mut paths = BTreeSet::new();
1210 let mut portable_paths = BTreeSet::new();
1211 let mut bundle_identifiers = BTreeSet::from([main_bundle_identifier.to_owned()]);
1212 let mut product_targets = BTreeSet::new();
1213 let mut previous_path: Option<&str> = None;
1214 for nested in &product.nested_bundles {
1215 if previous_path.is_some_and(|previous| previous >= nested.relative_path.as_str()) {
1216 return Err(RemoteBuildError::InvalidEventPayload {
1217 event: "ios_device_build_request",
1218 reason: "nested product bundles must be strictly sorted by path",
1219 });
1220 }
1221 previous_path = Some(&nested.relative_path);
1222 validate_nested_bundle_path(&nested.relative_path, nested.kind)?;
1223 validate_portable_component("nested_executable", &nested.executable)?;
1224 validate_bundle_identifier(&nested.bundle_identifier)?;
1225 if !paths.insert(nested.relative_path.as_str())
1226 || !portable_paths.insert(portable_name_key(&nested.relative_path))
1227 || !bundle_identifiers.insert(nested.bundle_identifier.clone())
1228 {
1229 return Err(RemoteBuildError::InvalidEventPayload {
1230 event: "ios_device_build_request",
1231 reason: "nested product bundles contain duplicate or portable-colliding identities",
1232 });
1233 }
1234 let signing_kind = match nested.kind {
1235 UnsignedNestedBundleKind::AppExtension => SigningTargetKind::Extension,
1236 UnsignedNestedBundleKind::Framework => SigningTargetKind::Framework,
1237 };
1238 product_targets.insert((nested.bundle_identifier.as_str(), signing_kind));
1239 }
1240
1241 let signing_targets = signing
1242 .targets
1243 .iter()
1244 .filter(|target| {
1245 matches!(
1246 target.kind,
1247 SigningTargetKind::Extension | SigningTargetKind::Framework
1248 )
1249 })
1250 .map(|target| (target.bundle_identifier.as_str(), target.kind))
1251 .collect::<BTreeSet<_>>();
1252 if product_targets != signing_targets {
1253 return Err(RemoteBuildError::InvalidEventPayload {
1254 event: "ios_device_build_request",
1255 reason: "product nested bundle graph does not match signing targets",
1256 });
1257 }
1258 Ok(())
1259}
1260
1261fn validate_portable_component(field: &'static str, value: &str) -> RemoteBuildResult<()> {
1262 validate_safe_text(field, value, 255)?;
1263 if matches!(value, "." | "..")
1264 || value.contains(['/', '\\', '\0', ':', '*', '?', '"', '<', '>', '|'])
1265 || value.ends_with(['.', ' '])
1266 {
1267 return Err(RemoteBuildError::InvalidIdentifier {
1268 field,
1269 reason: "value is not a portable filename component",
1270 });
1271 }
1272 let basename = value
1273 .split('.')
1274 .next()
1275 .unwrap_or_default()
1276 .to_ascii_uppercase();
1277 let reserved = matches!(basename.as_str(), "CON" | "PRN" | "AUX" | "NUL")
1278 || basename
1279 .strip_prefix("COM")
1280 .or_else(|| basename.strip_prefix("LPT"))
1281 .is_some_and(|suffix| {
1282 matches!(suffix, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")
1283 });
1284 if reserved {
1285 return Err(RemoteBuildError::InvalidIdentifier {
1286 field,
1287 reason: "value is reserved on a supported client filesystem",
1288 });
1289 }
1290 Ok(())
1291}
1292
1293fn validate_nested_bundle_path(
1294 path: &str,
1295 kind: UnsignedNestedBundleKind,
1296) -> RemoteBuildResult<()> {
1297 validate_safe_text("nested_bundle_path", path, 1024)?;
1298 let components = path.split('/').collect::<Vec<_>>();
1299 if components.len() != 2 {
1300 return Err(RemoteBuildError::InvalidIdentifier {
1301 field: "nested_bundle_path",
1302 reason: "value must be directly below PlugIns or Frameworks",
1303 });
1304 }
1305 validate_portable_component("nested_bundle_directory", components[1])?;
1306 let valid = match kind {
1307 UnsignedNestedBundleKind::AppExtension => {
1308 components[0] == "PlugIns" && has_exact_extension(components[1], "appex")
1309 }
1310 UnsignedNestedBundleKind::Framework => {
1311 components[0] == "Frameworks" && has_exact_extension(components[1], "framework")
1312 }
1313 };
1314 if !valid {
1315 return Err(RemoteBuildError::InvalidIdentifier {
1316 field: "nested_bundle_path",
1317 reason: "value does not match its nested bundle kind",
1318 });
1319 }
1320 Ok(())
1321}
1322
1323fn validate_bundle_version(field: &'static str, value: &str) -> RemoteBuildResult<()> {
1324 validate_safe_text(field, value, 64)?;
1325 let components = value.split('.').collect::<Vec<_>>();
1326 if components.is_empty()
1327 || components.len() > 3
1328 || components.iter().any(|component| {
1329 component.is_empty()
1330 || (component.len() > 1 && component.starts_with('0'))
1331 || !component.bytes().all(|byte| byte.is_ascii_digit())
1332 || component.parse::<u32>().is_err()
1333 })
1334 {
1335 return Err(RemoteBuildError::InvalidIdentifier {
1336 field,
1337 reason: "value must contain one to three canonical numeric components",
1338 });
1339 }
1340 Ok(())
1341}
1342
1343fn portable_name_key(value: &str) -> String {
1344 value.nfkc().flat_map(char::to_lowercase).collect()
1345}
1346
1347fn has_exact_extension(value: &str, expected: &str) -> bool {
1348 value
1349 .rsplit_once('.')
1350 .is_some_and(|(_, extension)| extension == expected)
1351}
1352
1353pub(crate) fn validate_github_repository(value: &str) -> RemoteBuildResult<()> {
1354 validate_safe_text("source_repository", value, 2048)?;
1355 let repository = url::Url::parse(value).map_err(|_| RemoteBuildError::InvalidIdentifier {
1356 field: "source_repository",
1357 reason: "value is not a valid URL",
1358 })?;
1359 if repository.scheme() != "https"
1360 || repository.host_str() != Some("github.com")
1361 || repository.port().is_some()
1362 || !repository.username().is_empty()
1363 || repository.password().is_some()
1364 || repository.query().is_some()
1365 || repository.fragment().is_some()
1366 {
1367 return Err(RemoteBuildError::InvalidIdentifier {
1368 field: "source_repository",
1369 reason: "value must be a credential-free HTTPS GitHub repository URL",
1370 });
1371 }
1372
1373 let segments = repository
1374 .path_segments()
1375 .map(Iterator::collect::<Vec<_>>)
1376 .unwrap_or_default();
1377 if segments.len() != 2 || !segments.iter().copied().all(is_github_name) {
1378 return Err(RemoteBuildError::InvalidIdentifier {
1379 field: "source_repository",
1380 reason: "value must contain exactly one GitHub owner and repository",
1381 });
1382 }
1383 let canonical = format!("https://github.com/{}/{}", segments[0], segments[1]);
1384 if value != canonical {
1385 return Err(RemoteBuildError::InvalidIdentifier {
1386 field: "source_repository",
1387 reason: "value is not normalized",
1388 });
1389 }
1390 Ok(())
1391}
1392
1393fn is_github_name(value: &str) -> bool {
1394 !value.is_empty()
1395 && value != "."
1396 && value != ".."
1397 && value
1398 .bytes()
1399 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
1400}
1401
1402fn validate_git_revision(value: &str) -> RemoteBuildResult<()> {
1403 if value.len() != 40
1404 || !value
1405 .bytes()
1406 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1407 {
1408 return Err(RemoteBuildError::InvalidIdentifier {
1409 field: "source_revision",
1410 reason: "value must be an exact lowercase 40-hex commit SHA",
1411 });
1412 }
1413 Ok(())
1414}
1415
1416fn validate_no_terminal_escape(value: &Value) -> RemoteBuildResult<()> {
1417 match value {
1418 Value::String(text) if text.contains('\u{1b}') => {
1419 Err(RemoteBuildError::UnsafeEventText { field: "event" })
1420 }
1421 Value::Array(values) => {
1422 for value in values {
1423 validate_no_terminal_escape(value)?;
1424 }
1425 Ok(())
1426 }
1427 Value::Object(values) => {
1428 for value in values.values() {
1429 validate_no_terminal_escape(value)?;
1430 }
1431 Ok(())
1432 }
1433 _ => Ok(()),
1434 }
1435}
1436
1437fn is_absolute_path_text(value: &str) -> bool {
1438 let bytes = value.as_bytes();
1439 value.starts_with('/')
1440 || value.starts_with("\\\\")
1441 || (bytes.len() >= 3
1442 && bytes[0].is_ascii_alphabetic()
1443 && bytes[1] == b':'
1444 && matches!(bytes[2], b'/' | b'\\'))
1445}
1446
1447#[cfg(test)]
1448mod tests {
1449 use super::*;
1450
1451 #[test]
1452 fn signed_artifact_selection_requires_ipa_and_signing_report() {
1453 let valid = BTreeSet::from([IosArtifactType::Ipa, IosArtifactType::SigningReport]);
1454 assert!(
1455 validate_requested_artifact_selection(SigningMode::ManualDevelopment, &valid).is_ok()
1456 );
1457
1458 for (requested, expected_reason) in [
1459 (
1460 BTreeSet::from([IosArtifactType::SigningReport]),
1461 "signed builds must request the installable IPA",
1462 ),
1463 (
1464 BTreeSet::from([IosArtifactType::Ipa]),
1465 "signed builds must request the signing report",
1466 ),
1467 ] {
1468 assert!(matches!(
1469 validate_requested_artifact_selection(
1470 SigningMode::ManualDevelopment,
1471 &requested
1472 ),
1473 Err(RemoteBuildError::InvalidEventPayload { reason, .. })
1474 if reason == expected_reason
1475 ));
1476 }
1477 }
1478}