Skip to main content

rustferry_remote/
protocol.rs

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
19/// Rust target used for physical iPhone builds.
20pub const IOS_DEVICE_RUST_TARGET: &str = "aarch64-apple-ios";
21
22/// Xcode SDK used for physical iPhone builds.
23pub const IOS_DEVICE_SDK: &str = "iphoneos";
24
25/// Largest single NDJSON event accepted by the decoder.
26pub const MAX_EVENT_LINE_BYTES: usize = 1024 * 1024;
27
28/// Current Ferry Remote Build Protocol version.
29pub const CURRENT_PROTOCOL_VERSION: ProtocolVersion = ProtocolVersion::new(1, 0);
30
31/// Required Ferry Remote Build Protocol v1 event names.
32pub 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/// Semantic version of the remote-build wire protocol.
60///
61/// Peers with the same nonzero major version are compatible. A handshake selects the lower minor
62/// version. A future pre-1 protocol must match both components exactly.
63#[derive(
64    Clone, Copy, Debug, Deserialize, Eq, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
65)]
66pub struct ProtocolVersion {
67    /// Breaking-change version.
68    pub major: u16,
69    /// Backward-compatible feature version.
70    pub minor: u16,
71}
72
73impl ProtocolVersion {
74    /// Construct a protocol version.
75    pub const fn new(major: u16, minor: u16) -> Self {
76        Self { major, minor }
77    }
78
79    /// Whether two protocol versions can exchange v1 envelopes.
80    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    /// Select the common version or return a typed incompatibility error.
85    ///
86    /// # Errors
87    ///
88    /// Returns [`RemoteBuildError::IncompatibleProtocolVersion`] for different major versions.
89    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/// Optimization profile requested from the remote worker.
108#[derive(
109    Clone, Copy, Debug, Deserialize, Eq, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
110)]
111#[serde(rename_all = "snake_case")]
112pub enum BuildProfile {
113    /// Fast development build.
114    Debug,
115    /// Optimized build.
116    Release,
117}
118
119/// Physical-iPhone artifact type requested or advertised by a provider.
120#[derive(
121    Clone, Copy, Debug, Deserialize, Eq, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
122)]
123#[serde(rename_all = "snake_case")]
124pub enum IosArtifactType {
125    /// Signed installable iOS application archive.
126    Ipa,
127    /// Xcode archive retained for inspection or later export.
128    Xcarchive,
129    /// Unarchived application bundle.
130    AppBundle,
131    /// Compressed debug symbols for the main application executable.
132    Dsym,
133    /// Sanitized signing validation report.
134    SigningReport,
135    /// Sanitized provisioning validation report.
136    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    /// Return the manifest kind that must represent this requested artifact.
155    #[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/// Explicit interpretation of a path carried by the wire protocol.
169#[derive(
170    Clone, Copy, Debug, Deserialize, Eq, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
171)]
172#[serde(rename_all = "snake_case")]
173pub enum ProtocolPathSemantics {
174    /// Path relative to the submitted `RustFerry` project root.
175    ProjectRelative,
176    /// Path relative to the worker's isolated job root.
177    WorkerRelative,
178    /// Absolute path on the client that submitted the request.
179    ClientAbsolute,
180    /// Provider-owned URI with no embedded credentials.
181    ProviderUri,
182}
183
184/// Path string paired with explicit machine and root semantics.
185#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
186pub struct ProtocolPath {
187    /// How the path must be resolved.
188    pub semantics: ProtocolPathSemantics,
189    /// UTF-8 path or URI text.
190    pub value: String,
191}
192
193impl ProtocolPath {
194    /// Construct and validate a protocol path.
195    ///
196    /// # Errors
197    ///
198    /// Returns a typed validation error for unsafe or inconsistent path text.
199    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    /// Validate lexical path safety without touching the filesystem.
212    ///
213    /// # Errors
214    ///
215    /// Returns a typed validation error for traversal, credentials, or incorrect semantics.
216    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/// Lifecycle state of one provider job.
265#[derive(
266    Clone, Copy, Debug, Deserialize, Eq, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
267)]
268#[serde(rename_all = "snake_case")]
269pub enum JobState {
270    /// Provider accepted the request and allocated an identifier.
271    Created,
272    /// Job awaits a worker.
273    Queued,
274    /// Worker is preparing or building the project.
275    Running,
276    /// Cancellation was accepted and cleanup is pending.
277    Cancelling,
278    /// Build completed successfully.
279    Succeeded,
280    /// Build failed.
281    Failed,
282    /// Build stopped after cancellation.
283    Cancelled,
284    /// Provider is deleting remote job material.
285    Cleaning,
286    /// Required cleanup completed.
287    Cleaned,
288    /// Cleanup was attempted but did not complete.
289    CleanupFailed,
290}
291
292impl JobState {
293    /// Whether a direct state transition is valid.
294    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    /// Apply a checked state transition.
316    ///
317    /// # Errors
318    ///
319    /// Returns [`RemoteBuildError::InvalidJobTransition`] for a forbidden transition.
320    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    /// Whether the build phase has completed, before optional cleanup.
332    pub const fn is_build_terminal(self) -> bool {
333        matches!(self, Self::Succeeded | Self::Failed | Self::Cancelled)
334    }
335
336    /// Whether no further lifecycle transition is possible.
337    pub const fn is_terminal(self) -> bool {
338        matches!(self, Self::Cleaned | Self::CleanupFailed)
339    }
340}
341
342/// Declarative request for an official physical-iPhone build.
343///
344/// The request intentionally contains no executable, argument, environment, or secret-value
345/// fields. Workers derive their fixed command plans from these validated inputs.
346#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
347pub struct IosDeviceBuildRequest {
348    /// Requested wire-protocol version.
349    pub protocol_version: ProtocolVersion,
350    /// Caller-owned operation identifier copied into every event.
351    pub operation_id: String,
352    /// Human-readable product name.
353    pub product_name: String,
354    /// Exact Apple application bundle identifier.
355    pub bundle_identifier: String,
356    /// Minimum supported iOS version.
357    pub minimum_ios_version: String,
358    /// Client-derived product identity used for independent output validation.
359    pub product: IosDeviceProductExpectation,
360    /// Rust/Xcode optimization profile.
361    pub profile: BuildProfile,
362    /// How the provider obtains the source.
363    pub source_mode: SourceMode,
364    /// Normalized HTTPS GitHub repository URL in Git-backed source modes.
365    #[serde(skip_serializing_if = "Option::is_none")]
366    pub source_repository: Option<String>,
367    /// Exact lowercase 40-hex commit SHA in Git-backed source modes.
368    #[serde(skip_serializing_if = "Option::is_none")]
369    pub source_revision: Option<String>,
370    /// Required deterministic integrity and shape manifest for every source mode.
371    pub source: SourceManifest,
372    /// Complete declarative signing plan containing opaque references, never secret bytes.
373    pub signing: SigningPlan,
374    /// Requested output kinds in deterministic order.
375    pub requested_artifacts: BTreeSet<IosArtifactType>,
376}
377
378impl IosDeviceBuildRequest {
379    /// Validate protocol and identifier fields before provider submission.
380    ///
381    /// # Errors
382    ///
383    /// Returns a typed error for incompatible versions, unsafe identifiers, ambiguous Git input,
384    /// invalid source manifests, or missing artifact requests.
385    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    /// Derive a complete client-side IPA expectation without worker-provided metadata.
452    ///
453    /// # Errors
454    ///
455    /// Returns a typed request-validation error when any product or signing field is invalid.
456    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
498/// Serialize one validated iPhone build request using the protocol's deterministic field order.
499///
500/// Maps and sets nested in the request use ordered representations. Keeping this function in the
501/// protocol crate prevents providers and workers from hashing subtly different request encodings.
502///
503/// # Errors
504///
505/// Returns a typed validation or serialization failure.
506pub 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
513/// Return lowercase SHA-256 of [`canonical_request_bytes`].
514///
515/// # Errors
516///
517/// Returns the same validation or serialization failure as [`canonical_request_bytes`].
518pub fn canonical_request_sha256(request: &IosDeviceBuildRequest) -> RemoteBuildResult<String> {
519    Ok(hex::encode(Sha256::digest(canonical_request_bytes(
520        request,
521    )?)))
522}
523
524/// Return the canonical request-template SHA-256 embedded in a Git snapshot descriptor.
525///
526/// The template removes only `source_revision`, whose orphan commit SHA cannot exist until after
527/// the descriptor itself has been written into that commit. Every other request field remains
528/// covered. Callers may pass either the pre-publication request with no revision or the final
529/// request containing the exact published commit SHA.
530///
531/// # Errors
532///
533/// Returns a typed validation or serialization failure for a non-Git-snapshot request or an
534/// otherwise invalid request template.
535pub 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
559/// Return the version-1 semantic retry SHA-256 for one validated request.
560///
561/// The retry identity deliberately excludes only the caller-owned operation identifier. A retry
562/// therefore receives a fresh wire-request hash while remaining cryptographically bound to the
563/// same source, product, target, profile, signing plan, and artifact selection.
564///
565/// # Errors
566///
567/// Returns the same validation or serialization failure as [`canonical_request_bytes`].
568pub 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/// Provider result after a physical-iPhone build reaches a build-terminal state.
585#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
586pub struct IosDeviceBuildResult {
587    /// Negotiated wire-protocol version.
588    pub protocol_version: ProtocolVersion,
589    /// Operation identifier from the request.
590    pub operation_id: String,
591    /// Provider-owned stable job identifier.
592    pub job_id: String,
593    /// Terminal build state.
594    pub state: JobState,
595    /// Independently verifiable artifact manifests.
596    pub artifacts: Vec<ArtifactManifest>,
597    /// Cleanup proof when cleanup already ran.
598    #[serde(skip_serializing_if = "Option::is_none")]
599    pub cleanup: Option<CleanupConfirmation>,
600}
601
602impl IosDeviceBuildResult {
603    /// Validate result identity and terminal-state invariants.
604    ///
605    /// # Errors
606    ///
607    /// Returns a typed error for incompatible versions, invalid IDs, nonterminal states, or a
608    /// successful build without artifacts.
609    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/// Proof that provider-owned job material was removed or deliberately retained.
630#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
631pub struct CleanupConfirmation {
632    /// Stable job identifier.
633    pub job_id: String,
634    /// Milliseconds since Unix epoch in UTC.
635    pub completed_at_ms: u64,
636    /// Whether the isolated source/build workspace was removed.
637    pub workspace_removed: bool,
638    /// Whether decoded signing material and temporary keychains were removed.
639    pub signing_material_removed: bool,
640    /// Whether provider retention policy deliberately kept artifact bytes.
641    pub artifacts_retained: bool,
642}
643
644/// Normalized diagnostic severity.
645#[derive(
646    Clone, Copy, Debug, Deserialize, Eq, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
647)]
648#[serde(rename_all = "snake_case")]
649pub enum DiagnosticSeverity {
650    /// Informational message.
651    Information,
652    /// Non-fatal warning.
653    Warning,
654    /// Build-blocking error.
655    Error,
656}
657
658/// Sanitized, optionally file-bound worker diagnostic.
659#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
660pub struct RemoteDiagnostic {
661    /// Stable diagnostic category.
662    pub code: String,
663    /// Severity consumed by CLI and IDE adapters.
664    pub severity: DiagnosticSeverity,
665    /// Sanitized UTF-8 summary.
666    pub message: String,
667    /// Optional concrete remediation.
668    #[serde(skip_serializing_if = "Option::is_none")]
669    pub help: Option<String>,
670    /// Optional path with explicit resolution semantics.
671    #[serde(skip_serializing_if = "Option::is_none")]
672    pub path: Option<ProtocolPath>,
673    /// Optional one-based source line.
674    #[serde(skip_serializing_if = "Option::is_none")]
675    pub line: Option<u32>,
676    /// Optional one-based source column.
677    #[serde(skip_serializing_if = "Option::is_none")]
678    pub column: Option<u32>,
679}
680
681/// Sanitized error embedded in an operation-finished event.
682#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
683pub struct RemoteErrorInfo {
684    /// Stable namespaced error code.
685    pub code: String,
686    /// Safe error summary.
687    pub message: String,
688    /// Optional recovery action.
689    #[serde(skip_serializing_if = "Option::is_none")]
690    pub help: Option<String>,
691    /// Whether retrying may succeed without changing the request.
692    pub retryable: bool,
693}
694
695/// Typed Ferry Remote Build Protocol v1 event payload.
696#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
697#[serde(tag = "event", rename_all = "snake_case")]
698pub enum RemoteBuildEventKind {
699    /// Operation boundary opened.
700    OperationStarted {
701        /// Stable command name.
702        command: String,
703    },
704    /// Provider allocated a job.
705    JobCreated {
706        /// Initial job state; must be `created`.
707        state: JobState,
708    },
709    /// Job entered the provider queue.
710    JobQueued {
711        /// One-based queue position when known.
712        #[serde(skip_serializing_if = "Option::is_none")]
713        position: Option<u64>,
714    },
715    /// A concrete worker accepted the job.
716    WorkerAssigned {
717        /// Stable, non-secret worker identifier.
718        worker_id: String,
719    },
720    /// Source manifest was converted into a bounded upload.
721    SourcePrepared {
722        /// Included file count.
723        file_count: u64,
724        /// Total uncompressed source bytes.
725        total_bytes: u64,
726    },
727    /// Source upload opened.
728    SourceUploadStarted {
729        /// Expected upload byte count.
730        total_bytes: u64,
731    },
732    /// Source upload advanced.
733    SourceUploadProgress {
734        /// Uploaded byte count.
735        uploaded_bytes: u64,
736        /// Expected upload byte count.
737        total_bytes: u64,
738    },
739    /// Worker verified the exact submitted source.
740    SourceVerified {
741        /// Lowercase SHA-256 digest of the source bundle or Git manifest.
742        sha256: String,
743    },
744    /// Named build phase opened.
745    PhaseStarted {
746        /// Optional safe UI text.
747        #[serde(skip_serializing_if = "Option::is_none")]
748        message: Option<String>,
749    },
750    /// Bounded progress update.
751    Progress {
752        /// Human-readable safe progress summary.
753        message: String,
754        /// Completed unit count.
755        #[serde(skip_serializing_if = "Option::is_none")]
756        current: Option<u64>,
757        /// Total unit count.
758        #[serde(skip_serializing_if = "Option::is_none")]
759        total: Option<u64>,
760    },
761    /// Sanitized external-tool boundary.
762    CommandStarted {
763        /// Executable name, never a shell command string.
764        tool: String,
765        /// Tokenized arguments after provider redaction.
766        arguments: Vec<String>,
767    },
768    /// Structured compiler, signing, or validation diagnostic.
769    Diagnostic {
770        /// Diagnostic payload.
771        diagnostic: RemoteDiagnostic,
772    },
773    /// Signing phase opened without exposing secret values.
774    SigningStarted {
775        /// Selected signing mode.
776        mode: SigningMode,
777    },
778    /// Worker created a candidate artifact.
779    ArtifactCreated {
780        /// Stable artifact identifier.
781        artifact_id: String,
782        /// Candidate artifact type.
783        artifact_type: IosArtifactType,
784    },
785    /// Worker independently validated an artifact.
786    ArtifactValidated {
787        /// Full integrity and provenance manifest.
788        artifact: ArtifactManifest,
789    },
790    /// Provider artifact upload opened.
791    ArtifactUploadStarted {
792        /// Stable artifact identifier.
793        artifact_id: String,
794        /// Expected upload byte count.
795        total_bytes: u64,
796    },
797    /// Client artifact download opened.
798    ArtifactDownloadStarted {
799        /// Stable artifact identifier.
800        artifact_id: String,
801        /// Expected download byte count.
802        total_bytes: u64,
803    },
804    /// Client artifact download advanced.
805    ArtifactDownloadProgress {
806        /// Stable artifact identifier.
807        artifact_id: String,
808        /// Downloaded byte count.
809        downloaded_bytes: u64,
810        /// Expected download byte count.
811        total_bytes: u64,
812    },
813    /// Client downloaded and hash-verified an artifact.
814    ArtifactDownloaded {
815        /// Verified artifact manifest.
816        artifact: ArtifactManifest,
817        /// Absolute destination on the client.
818        local_path: ProtocolPath,
819    },
820    /// Non-fatal provider warning.
821    Warning {
822        /// Stable namespaced warning code.
823        code: String,
824        /// Sanitized summary.
825        message: String,
826        /// Optional concrete remediation.
827        #[serde(skip_serializing_if = "Option::is_none")]
828        help: Option<String>,
829    },
830    /// Provider cleanup opened.
831    CleanupStarted,
832    /// Provider cleanup completed.
833    CleanupFinished {
834        /// Cleanup proof.
835        confirmation: CleanupConfirmation,
836    },
837    /// Operation completed, including typed failure.
838    OperationFinished {
839        /// Whether every required phase succeeded.
840        success: bool,
841        /// Monotonic operation duration.
842        duration_ms: u64,
843        /// Build result when this was a build operation.
844        #[serde(skip_serializing_if = "Option::is_none")]
845        result: Option<IosDeviceBuildResult>,
846        /// Typed failure when `success` is false.
847        #[serde(skip_serializing_if = "Option::is_none")]
848        error: Option<RemoteErrorInfo>,
849    },
850    /// Operation stopped after cancellation.
851    OperationCancelled {
852        /// Stable cancellation reason.
853        reason: String,
854        /// Monotonic operation duration.
855        duration_ms: u64,
856    },
857    /// A same-major future event ignored by this reader.
858    #[serde(other)]
859    Unknown,
860}
861
862impl RemoteBuildEventKind {
863    /// Stable serialized event name.
864    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/// One self-contained NDJSON event envelope.
896#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
897pub struct RemoteBuildEvent {
898    /// Wire-protocol version.
899    pub protocol_version: ProtocolVersion,
900    /// Caller-owned operation identifier.
901    pub operation_id: String,
902    /// Provider-owned job identifier.
903    pub job_id: String,
904    /// Milliseconds since Unix epoch in UTC.
905    pub timestamp_ms: u64,
906    /// Stable provider identifier.
907    pub provider: String,
908    /// Stable build-phase identifier.
909    pub phase: String,
910    /// Strictly increasing event sequence within one job.
911    pub sequence: u64,
912    /// Typed event-specific fields.
913    #[serde(flatten)]
914    pub kind: RemoteBuildEventKind,
915}
916
917impl RemoteBuildEvent {
918    /// Construct and validate an event envelope.
919    ///
920    /// # Errors
921    ///
922    /// Returns a typed validation error when envelope or payload invariants fail.
923    #[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    /// Encode one compact JSON object followed by a newline.
948    ///
949    /// # Errors
950    ///
951    /// Returns a typed validation or serialization error.
952    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    /// Decode exactly one UTF-8 JSON object, tolerating trailing whitespace.
963    ///
964    /// # Errors
965    ///
966    /// Returns a typed size, syntax, truncation, version, or validation error.
967    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    /// Decode one event from untrusted bytes and reject non-UTF-8 input.
988    ///
989    /// # Errors
990    ///
991    /// Returns [`RemoteBuildError::InvalidUtf8`] or any error from [`Self::decode_line`].
992    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    /// Validate envelope, progress, path, and terminal-event invariants.
998    ///
999    /// # Errors
1000    ///
1001    /// Returns a typed version, identifier, path, payload, or serialization error.
1002    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}