Skip to main content

rustferry_github/
workflow.rs

1//! Deterministic rendering of a two-phase GitHub-hosted macOS workflow.
2
3use std::{
4    collections::{BTreeMap, BTreeSet},
5    error::Error,
6    fmt,
7    fmt::Write as _,
8};
9
10use rustferry_remote::{SigningTarget, SigningTargetKind, canonical_signing_target_graph_sha256};
11use sha2::{Digest, Sha256};
12
13const CHECKOUT_ACTION_SHA: &str = "3d3c42e5aac5ba805825da76410c181273ba90b1";
14const CACHE_ACTION_SHA: &str = "caa296126883cff596d87d8935842f9db880ef25";
15const DOWNLOAD_ARTIFACT_ACTION_SHA: &str = "3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c";
16const UPLOAD_ARTIFACT_ACTION_SHA: &str = "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a";
17const DEFAULT_DEVELOPER_DIRECTORY: &str = "/Applications/Xcode.app/Contents/Developer";
18const DEFAULT_RUNNER: &str = "macos-15";
19const DEFAULT_WORKER_BUILD_TIMEOUT_MINUTES: u16 = 30;
20const REQUEST_MANIFEST_PATH: &str = ".rustferry/goal3/request.json";
21const RUST_TOOLCHAIN: &str = "1.92.0";
22const GOAL3_CERTIFICATE_P12_SECRET: &str = "RUSTFERRY_GOAL3_IOS_CERTIFICATE_P12";
23const GOAL3_CERTIFICATE_PASSWORD_SECRET: &str = "RUSTFERRY_GOAL3_IOS_CERTIFICATE_PASSWORD";
24const GOAL3_APPLICATION_PROFILE_SECRET: &str = "RUSTFERRY_GOAL3_IOS_PROVISIONING_PROFILE";
25const GOAL3_EXTENSION_PROFILE_SECRET_PREFIX: &str = "RUSTFERRY_GOAL3_IOS_PROFILE_";
26
27/// Maximum number of application and extension provisioning profiles exposed
28/// to one protected GitHub signing job.
29pub const MAX_SIGNING_PROFILES: usize = 3;
30
31/// A rejected workflow configuration field.
32///
33/// Errors name the field and failure class but never echo rejected input.
34#[derive(Clone, Copy, Debug, Eq, PartialEq)]
35pub enum WorkflowConfigError {
36    /// A required field was empty.
37    Empty {
38        /// Stable field name.
39        field: &'static str,
40    },
41    /// A field exceeded its byte limit.
42    TooLong {
43        /// Stable field name.
44        field: &'static str,
45        /// Maximum accepted byte count.
46        maximum: usize,
47    },
48    /// A byte fell outside the field allowlist.
49    InvalidCharacter {
50        /// Stable field name.
51        field: &'static str,
52        /// Byte offset of the rejected input, without the byte itself.
53        index: usize,
54    },
55    /// A field did not match its required structure.
56    InvalidFormat {
57        /// Stable field name.
58        field: &'static str,
59    },
60    /// A reserved GitHub name or namespace was selected.
61    Reserved {
62        /// Stable field name.
63        field: &'static str,
64    },
65    /// Two signing roles referenced the same GitHub secret.
66    DuplicateSecretName,
67    /// Signing targets could not form one unambiguous application/profile map.
68    InvalidSigningTargets,
69    /// More profile-bearing targets were configured than the protected worker accepts.
70    TooManySigningProfiles {
71        /// Maximum accepted application plus extension profile count.
72        maximum: usize,
73    },
74    /// The temporary dispatch namespace overlaps the trusted source ref.
75    RefNamespaceOverlap,
76    /// A numeric policy value was outside its bounded range.
77    PolicyOutOfRange {
78        /// Stable field name.
79        field: &'static str,
80        /// Inclusive minimum.
81        minimum: u16,
82        /// Inclusive maximum.
83        maximum: u16,
84    },
85}
86
87impl fmt::Display for WorkflowConfigError {
88    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
89        match self {
90            Self::Empty { field } => write!(formatter, "{field} is empty"),
91            Self::TooLong { field, maximum } => {
92                write!(formatter, "{field} exceeds {maximum} bytes")
93            }
94            Self::InvalidCharacter { field, index } => {
95                write!(
96                    formatter,
97                    "{field} contains an unsafe byte at offset {index}"
98                )
99            }
100            Self::InvalidFormat { field } => write!(formatter, "{field} has an invalid format"),
101            Self::Reserved { field } => write!(formatter, "{field} uses a reserved value"),
102            Self::DuplicateSecretName => {
103                formatter.write_str("signing secret names must be distinct")
104            }
105            Self::InvalidSigningTargets => formatter.write_str(
106                "signing targets must contain exactly one application and unique target identities",
107            ),
108            Self::TooManySigningProfiles { maximum } => {
109                write!(
110                    formatter,
111                    "signing target graph exceeds {maximum} provisioning profiles"
112                )
113            }
114            Self::RefNamespaceOverlap => formatter
115                .write_str("temporary dispatch namespace must not overlap the trusted source ref"),
116            Self::PolicyOutOfRange {
117                field,
118                minimum,
119                maximum,
120            } => write!(
121                formatter,
122                "{field} must be between {minimum} and {maximum} inclusive"
123            ),
124        }
125    }
126}
127
128impl Error for WorkflowConfigError {}
129
130/// Validated basename for a generated file below `.github/workflows/`.
131#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
132pub struct WorkflowFileName(String);
133
134impl WorkflowFileName {
135    /// Validate a workflow basename ending in `.yml` or `.yaml`.
136    ///
137    /// # Errors
138    ///
139    /// Rejects paths, control characters, YAML syntax, dot sequences, and
140    /// names longer than 128 bytes.
141    #[allow(clippy::case_sensitive_file_extension_comparisons)]
142    pub fn new(value: impl Into<String>) -> Result<Self, WorkflowConfigError> {
143        let value = value.into();
144        validate_nonempty_length("workflow filename", &value, 128)?;
145        validate_ascii_allowlist("workflow filename", &value, |byte| {
146            byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')
147        })?;
148        if !value.as_bytes()[0].is_ascii_alphanumeric()
149            || value.contains("..")
150            || !(value.ends_with(".yml") || value.ends_with(".yaml"))
151        {
152            return Err(WorkflowConfigError::InvalidFormat {
153                field: "workflow filename",
154            });
155        }
156        Ok(Self(value))
157    }
158
159    /// Return the validated basename.
160    pub fn as_str(&self) -> &str {
161        &self.0
162    }
163
164    /// Return the repository-relative workflow path.
165    pub fn repository_path(&self) -> String {
166        format!(".github/workflows/{}", self.0)
167    }
168}
169
170/// Validated GitHub Environment name used only by the signing job.
171#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
172pub struct ProtectedEnvironment(String);
173
174impl ProtectedEnvironment {
175    /// Validate a conservative GitHub Environment identifier.
176    ///
177    /// # Errors
178    ///
179    /// Rejects spaces, expression syntax, path syntax, and values longer than
180    /// 64 bytes.
181    pub fn new(value: impl Into<String>) -> Result<Self, WorkflowConfigError> {
182        let value = value.into();
183        validate_identifier("protected environment", &value, 64, b"._-")?;
184        if value.contains("..") {
185            return Err(WorkflowConfigError::InvalidFormat {
186                field: "protected environment",
187            });
188        }
189        Ok(Self(value))
190    }
191
192    /// Return the validated environment name.
193    pub fn as_str(&self) -> &str {
194        &self.0
195    }
196}
197
198/// Validated name of a GitHub Actions secret, never a secret value.
199#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
200pub struct SecretName(String);
201
202impl SecretName {
203    /// Validate an uppercase GitHub Actions secret name.
204    ///
205    /// # Errors
206    ///
207    /// Rejects names beginning with a number or `GITHUB_`, lowercase names,
208    /// expression syntax, and values longer than 128 bytes.
209    pub fn new(value: impl Into<String>) -> Result<Self, WorkflowConfigError> {
210        let value = value.into();
211        validate_nonempty_length("secret name", &value, 128)?;
212        let first = value.as_bytes()[0];
213        if !(first.is_ascii_uppercase() || first == b'_') {
214            return Err(WorkflowConfigError::InvalidFormat {
215                field: "secret name",
216            });
217        }
218        validate_ascii_allowlist("secret name", &value, |byte| {
219            byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_'
220        })?;
221        if value.starts_with("GITHUB_") {
222            return Err(WorkflowConfigError::Reserved {
223                field: "secret name",
224            });
225        }
226        Ok(Self(value))
227    }
228
229    /// Return the validated reference name.
230    pub fn as_str(&self) -> &str {
231        &self.0
232    }
233}
234
235#[derive(Clone, Debug, Eq, PartialEq)]
236struct SigningProfileSecretName {
237    target: Option<String>,
238    name: SecretName,
239}
240
241#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
242struct SigningTargetIdentity {
243    name: String,
244    bundle_identifier: String,
245    kind: SigningTargetKind,
246}
247
248/// Opaque GitHub secret references needed for manual development signing.
249///
250/// Legacy configurations retain one target-independent application profile.
251/// New configurations bind one deterministic static secret name to each
252/// application or extension target before the workflow is installed.
253#[derive(Clone, Debug, Eq, PartialEq)]
254pub struct SigningSecretNames {
255    certificate_p12: SecretName,
256    certificate_password: SecretName,
257    provisioning_profiles: Vec<SigningProfileSecretName>,
258    target_graph: Option<Vec<SigningTargetIdentity>>,
259    target_graph_sha256: Option<String>,
260}
261
262impl SigningSecretNames {
263    /// Bind distinct certificate, password, and provisioning-profile names.
264    ///
265    /// # Errors
266    ///
267    /// Returns [`WorkflowConfigError::DuplicateSecretName`] when one secret is
268    /// reused for multiple roles.
269    pub fn new(
270        certificate_p12: SecretName,
271        certificate_password: SecretName,
272        provisioning_profile: SecretName,
273    ) -> Result<Self, WorkflowConfigError> {
274        if certificate_p12 == certificate_password
275            || certificate_p12 == provisioning_profile
276            || certificate_password == provisioning_profile
277        {
278            return Err(WorkflowConfigError::DuplicateSecretName);
279        }
280        Ok(Self {
281            certificate_p12,
282            certificate_password,
283            provisioning_profiles: vec![SigningProfileSecretName {
284                target: None,
285                name: provisioning_profile,
286            }],
287            target_graph: None,
288            target_graph_sha256: None,
289        })
290    }
291
292    /// Derive the exact static profile-secret map for one target graph.
293    ///
294    /// The application retains the legacy Goal 3 profile name. Extension
295    /// names contain the first 128 bits of SHA-256 over the target name, a NUL
296    /// separator, and its bundle identifier. Frameworks and dynamic libraries
297    /// do not receive provisioning profiles.
298    ///
299    /// # Errors
300    ///
301    /// Rejects target graphs without exactly one application, duplicate target
302    /// names or bundle identifiers, or more than [`MAX_SIGNING_PROFILES`]
303    /// application and extension targets.
304    ///
305    /// # Panics
306    ///
307    /// This cannot panic because the fixed certificate and password names
308    /// satisfy [`SecretName`] invariants.
309    pub fn for_targets(targets: &[SigningTarget]) -> Result<Self, WorkflowConfigError> {
310        let target_graph = canonical_signing_target_graph(targets)?;
311        let target_graph_sha256 = canonical_signing_target_graph_sha256(targets);
312        let mut profiles = BTreeMap::new();
313
314        for target in &target_graph {
315            let secret_name = match target.kind {
316                SigningTargetKind::Application => GOAL3_APPLICATION_PROFILE_SECRET.to_owned(),
317                SigningTargetKind::Extension => extension_profile_secret_name(target),
318                SigningTargetKind::DynamicLibrary | SigningTargetKind::Framework => continue,
319            };
320            profiles.insert(target.name.clone(), SecretName::new(secret_name)?);
321        }
322
323        if profiles.len() > MAX_SIGNING_PROFILES {
324            return Err(WorkflowConfigError::TooManySigningProfiles {
325                maximum: MAX_SIGNING_PROFILES,
326            });
327        }
328
329        let names = Self {
330            certificate_p12: SecretName::new(GOAL3_CERTIFICATE_P12_SECRET)
331                .expect("constant secret name is valid"),
332            certificate_password: SecretName::new(GOAL3_CERTIFICATE_PASSWORD_SECRET)
333                .expect("constant secret name is valid"),
334            provisioning_profiles: profiles
335                .into_iter()
336                .map(|(target, name)| SigningProfileSecretName {
337                    target: Some(target),
338                    name,
339                })
340                .collect(),
341            target_graph: Some(target_graph),
342            target_graph_sha256: Some(target_graph_sha256),
343        };
344        if names.all_names().collect::<BTreeSet<_>>().len() != names.all_names().count() {
345            return Err(WorkflowConfigError::DuplicateSecretName);
346        }
347        Ok(names)
348    }
349
350    /// Goal 3 development secret namespace from the project specification.
351    ///
352    /// # Panics
353    ///
354    /// This cannot panic because all three constant names satisfy the
355    /// constructor invariants and are distinct.
356    pub fn goal3_defaults() -> Self {
357        Self::new(
358            SecretName::new(GOAL3_CERTIFICATE_P12_SECRET).expect("constant secret name is valid"),
359            SecretName::new(GOAL3_CERTIFICATE_PASSWORD_SECRET)
360                .expect("constant secret name is valid"),
361            SecretName::new(GOAL3_APPLICATION_PROFILE_SECRET)
362                .expect("constant secret name is valid"),
363        )
364        .expect("constant secret names are distinct")
365    }
366
367    /// GitHub secret containing the encoded PKCS#12 certificate.
368    pub fn certificate_p12(&self) -> &SecretName {
369        &self.certificate_p12
370    }
371
372    /// GitHub secret containing the PKCS#12 password.
373    pub fn certificate_password(&self) -> &SecretName {
374        &self.certificate_password
375    }
376
377    /// GitHub secret containing the encoded application provisioning profile.
378    ///
379    /// # Panics
380    ///
381    /// This cannot panic because every constructor retains at least one
382    /// application profile name.
383    pub fn provisioning_profile(&self) -> &SecretName {
384        let profile = self
385            .provisioning_profiles
386            .iter()
387            .find(|profile| profile.name.as_str() == GOAL3_APPLICATION_PROFILE_SECRET)
388            .unwrap_or_else(|| {
389                self.provisioning_profiles
390                    .first()
391                    .expect("signing secret names always contain an application profile")
392            });
393        &profile.name
394    }
395
396    /// Static provisioning-profile secret bound to `target`.
397    ///
398    /// A legacy one-profile configuration returns its sole profile for any
399    /// target. The provider permits that fallback only for one-profile plans.
400    pub fn profile_for_target(&self, target: &str) -> Option<&SecretName> {
401        self.provisioning_profiles
402            .iter()
403            .find(|profile| {
404                profile
405                    .target
406                    .as_deref()
407                    .is_none_or(|configured| configured == target)
408            })
409            .map(|profile| &profile.name)
410    }
411
412    /// Deterministically ordered provisioning-profile secret names.
413    pub fn profile_names(&self) -> impl ExactSizeIterator<Item = &SecretName> {
414        self.provisioning_profiles
415            .iter()
416            .map(|profile| &profile.name)
417    }
418
419    /// Exact certificate, password, and provisioning-profile secret set.
420    pub fn all_names(&self) -> impl Iterator<Item = &SecretName> {
421        std::iter::once(&self.certificate_p12)
422            .chain(std::iter::once(&self.certificate_password))
423            .chain(self.profile_names())
424    }
425
426    /// Canonical full signing-target graph policy digest for modern workflows.
427    ///
428    /// Legacy target-independent configurations return `None`.
429    pub fn target_graph_sha256(&self) -> Option<&str> {
430        self.target_graph_sha256.as_deref()
431    }
432
433    /// Whether `targets` exactly matches the graph bound during workflow setup.
434    ///
435    /// Ordering is ignored. Target names, bundle identifiers, and kinds are
436    /// compared for every application, extension, framework, and dynamic library.
437    /// Legacy target-independent configurations never match a target graph.
438    pub fn matches_target_graph(&self, targets: &[SigningTarget]) -> bool {
439        self.target_graph.as_ref().is_some_and(|expected| {
440            canonical_signing_target_graph(targets).is_ok_and(|actual| actual == *expected)
441                && self.target_graph_sha256()
442                    == Some(canonical_signing_target_graph_sha256(targets).as_str())
443        })
444    }
445
446    pub(crate) fn uses_legacy_profile_binding(&self) -> bool {
447        self.provisioning_profiles.len() == 1 && self.provisioning_profiles[0].target.is_none()
448    }
449}
450
451fn canonical_signing_target_graph(
452    targets: &[SigningTarget],
453) -> Result<Vec<SigningTargetIdentity>, WorkflowConfigError> {
454    let mut target_names = BTreeSet::new();
455    let mut bundle_identifiers = BTreeSet::new();
456    let mut application_count = 0_usize;
457    let mut target_graph = Vec::with_capacity(targets.len());
458
459    for target in targets {
460        if !valid_signing_target_name(&target.name)
461            || !target_names.insert(target.name.as_str())
462            || !bundle_identifiers.insert(target.bundle_identifier.as_str())
463        {
464            return Err(WorkflowConfigError::InvalidSigningTargets);
465        }
466        if target.kind == SigningTargetKind::Application {
467            application_count += 1;
468        }
469        target_graph.push(SigningTargetIdentity {
470            name: target.name.clone(),
471            bundle_identifier: target.bundle_identifier.as_str().to_owned(),
472            kind: target.kind,
473        });
474    }
475
476    if application_count != 1 {
477        return Err(WorkflowConfigError::InvalidSigningTargets);
478    }
479    target_graph.sort_unstable();
480    Ok(target_graph)
481}
482
483fn extension_profile_secret_name(target: &SigningTargetIdentity) -> String {
484    let mut digest = Sha256::new();
485    digest.update(target.name.as_bytes());
486    digest.update([0]);
487    digest.update(target.bundle_identifier.as_bytes());
488    let digest = digest.finalize();
489    format!(
490        "{GOAL3_EXTENSION_PROFILE_SECRET_PREFIX}{}",
491        hex::encode_upper(&digest[..16])
492    )
493}
494
495fn valid_signing_target_name(value: &str) -> bool {
496    !value.is_empty()
497        && value.len() <= 128
498        && value
499            .bytes()
500            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
501        && !value.contains("..")
502}
503
504/// Validated public GitHub repository containing the project source.
505///
506/// The constructor validates and normalizes repository identity. The caller
507/// must separately prove that GitHub reports the repository as public before
508/// using it with a private execution repository.
509#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
510pub struct PublicSourceRepository {
511    url: String,
512    slug: String,
513}
514
515impl PublicSourceRepository {
516    /// Normalize an `OWNER/REPOSITORY` slug or HTTPS GitHub repository URL.
517    ///
518    /// # Errors
519    ///
520    /// Rejects non-GitHub URLs, credentials, extra path components, expression
521    /// syntax, and values longer than 256 bytes.
522    pub fn new(value: impl Into<String>) -> Result<Self, WorkflowConfigError> {
523        let value = value.into();
524        let (url, slug) = normalize_github_repository("public source repository", &value)?;
525        Ok(Self { url, slug })
526    }
527
528    /// Normalized lowercase HTTPS repository URL.
529    pub fn url(&self) -> &str {
530        &self.url
531    }
532
533    /// Normalized lowercase `owner/repository` slug for `actions/checkout`.
534    pub fn slug(&self) -> &str {
535        &self.slug
536    }
537}
538
539/// Exact trusted branch or tag from which source revisions may be selected.
540#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
541pub struct TrustedSourceRef(String);
542
543impl TrustedSourceRef {
544    /// Validate a full `refs/heads/...` or `refs/tags/...` name.
545    ///
546    /// # Errors
547    ///
548    /// Rejects pull-request refs, wildcards, ref traversal, expression syntax,
549    /// and values longer than 255 bytes.
550    pub fn new(value: impl Into<String>) -> Result<Self, WorkflowConfigError> {
551        let value = value.into();
552        validate_nonempty_length("trusted source ref", &value, 255)?;
553        let tail = value
554            .strip_prefix("refs/heads/")
555            .or_else(|| value.strip_prefix("refs/tags/"))
556            .ok_or(WorkflowConfigError::InvalidFormat {
557                field: "trusted source ref",
558            })?;
559        validate_ref_tail("trusted source ref", tail)?;
560        Ok(Self(value))
561    }
562
563    /// Return the validated full ref.
564    pub fn as_str(&self) -> &str {
565        &self.0
566    }
567}
568
569/// Branch namespace used only for temporary provider job refs.
570#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
571pub struct TemporaryBranchNamespace(String);
572
573impl TemporaryBranchNamespace {
574    /// Validate a branch prefix such as `rustferry/goal3/builds`.
575    ///
576    /// The namespace excludes `refs/heads/`; the generator adds it where a
577    /// full ref is required and appends `/**` only in the trigger filter.
578    ///
579    /// # Errors
580    ///
581    /// Rejects ref syntax, wildcard characters, reserved GitHub namespaces,
582    /// and values longer than 192 bytes.
583    pub fn new(value: impl Into<String>) -> Result<Self, WorkflowConfigError> {
584        let value = value.into();
585        validate_nonempty_length("temporary branch namespace", &value, 192)?;
586        if value.starts_with("refs/") || value.starts_with("pull/") {
587            return Err(WorkflowConfigError::Reserved {
588                field: "temporary branch namespace",
589            });
590        }
591        validate_ref_tail("temporary branch namespace", &value)?;
592        Ok(Self(value))
593    }
594
595    /// Return the namespace without `refs/heads/`.
596    pub fn as_str(&self) -> &str {
597        &self.0
598    }
599
600    fn trigger_pattern(&self) -> String {
601        format!("{}/**", self.0)
602    }
603
604    fn full_ref_prefix(&self) -> String {
605        format!("refs/heads/{}", self.0)
606    }
607}
608
609/// Absolute Xcode developer directory selected per job through `DEVELOPER_DIR`.
610#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
611pub struct DeveloperDirectory(String);
612
613impl DeveloperDirectory {
614    /// Validate an Xcode developer directory below `/Applications`.
615    ///
616    /// # Errors
617    ///
618    /// Rejects relative paths, traversal, shell/YAML syntax, and values longer
619    /// than 256 bytes.
620    pub fn new(value: impl Into<String>) -> Result<Self, WorkflowConfigError> {
621        let value = value.into();
622        validate_nonempty_length("developer directory", &value, 256)?;
623        validate_ascii_allowlist("developer directory", &value, |byte| {
624            byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'.' | b'_' | b'-')
625        })?;
626        if !value.starts_with("/Applications/Xcode")
627            || !value.ends_with(".app/Contents/Developer")
628            || value.contains("..")
629            || value.contains("//")
630        {
631            return Err(WorkflowConfigError::InvalidFormat {
632                field: "developer directory",
633            });
634        }
635        Ok(Self(value))
636    }
637
638    /// Standard GitHub-hosted runner Xcode selection.
639    ///
640    /// # Panics
641    ///
642    /// This cannot panic because the constant path satisfies all invariants.
643    pub fn github_default() -> Self {
644        Self::new(DEFAULT_DEVELOPER_DIRECTORY).expect("constant developer directory is valid")
645    }
646
647    /// Return the validated absolute directory.
648    pub fn as_str(&self) -> &str {
649        &self.0
650    }
651}
652
653/// Trusted macOS worker provisioning policy.
654#[derive(Clone, Debug, Eq, PartialEq)]
655pub struct WorkerDistribution {
656    provisioning: WorkerProvisioning,
657    version: String,
658}
659
660#[derive(Clone, Debug, Eq, PartialEq)]
661enum WorkerProvisioning {
662    Prebuilt {
663        download_url: String,
664        sha256: String,
665    },
666    SourceBuild {
667        repository: String,
668        repository_slug: String,
669        revision: String,
670    },
671}
672
673impl WorkerDistribution {
674    /// Validate one public GitHub release asset, SHA-256, and worker version.
675    ///
676    /// The URL must be a simple HTTPS GitHub release URL without credentials,
677    /// a query, a fragment, percent escapes, or shell/YAML metacharacters.
678    ///
679    /// # Errors
680    ///
681    /// Rejects a non-GitHub/non-HTTPS URL, malformed lowercase SHA-256, or an
682    /// unsafe version label.
683    pub fn new(
684        download_url: impl Into<String>,
685        sha256: impl Into<String>,
686        version: impl Into<String>,
687    ) -> Result<Self, WorkflowConfigError> {
688        let download_url = download_url.into();
689        let sha256 = sha256.into();
690        let version = version.into();
691        validate_worker_url(&download_url)?;
692        validate_sha256("worker sha256", &sha256)?;
693        validate_nonempty_length("worker version", &version, 64)?;
694        validate_ascii_allowlist("worker version", &version, |byte| {
695            byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'+' | b'-')
696        })?;
697        if !version.as_bytes()[0].is_ascii_alphanumeric() {
698            return Err(WorkflowConfigError::InvalidFormat {
699                field: "worker version",
700            });
701        }
702        Ok(Self {
703            provisioning: WorkerProvisioning::Prebuilt {
704                download_url,
705                sha256,
706            },
707            version,
708        })
709    }
710
711    /// Bind a source build to one normalized GitHub repository, exact commit,
712    /// and expected semantic version.
713    ///
714    /// Repository inputs may be `OWNER/REPOSITORY` or an HTTPS GitHub URL,
715    /// optionally ending in `.git` or `/`. The stored URL and slug are
716    /// lowercase and contain exactly two path components.
717    ///
718    /// # Errors
719    ///
720    /// Rejects non-GitHub repositories, malformed 40-character lowercase
721    /// commit IDs, and non-canonical semantic versions.
722    pub fn from_source(
723        repository: impl Into<String>,
724        revision: impl Into<String>,
725        expected_version: impl Into<String>,
726    ) -> Result<Self, WorkflowConfigError> {
727        let repository = repository.into();
728        let revision = revision.into();
729        let version = expected_version.into();
730        let (repository, repository_slug) =
731            normalize_github_repository("worker source repository", &repository)?;
732        validate_commit_sha("worker source revision", &revision)?;
733        validate_semver("worker version", &version)?;
734        Ok(Self {
735            provisioning: WorkerProvisioning::SourceBuild {
736                repository,
737                repository_slug,
738                revision,
739            },
740            version,
741        })
742    }
743
744    /// Validated public download URL for prebuilt provisioning.
745    ///
746    /// # Panics
747    ///
748    /// Panics when source-build provisioning is selected. Use
749    /// [`Self::source_repository`] to inspect that mode.
750    pub fn download_url(&self) -> &str {
751        match &self.provisioning {
752            WorkerProvisioning::Prebuilt { download_url, .. } => download_url,
753            WorkerProvisioning::SourceBuild { .. } => {
754                panic!("source-built workers do not have a download URL")
755            }
756        }
757    }
758
759    /// Expected lowercase SHA-256 for prebuilt provisioning.
760    ///
761    /// # Panics
762    ///
763    /// Panics when source-build provisioning is selected. Its SHA-256 is
764    /// derived by the isolated worker job and propagated as a job output.
765    pub fn sha256(&self) -> &str {
766        match &self.provisioning {
767            WorkerProvisioning::Prebuilt { sha256, .. } => sha256,
768            WorkerProvisioning::SourceBuild { .. } => {
769                panic!("source-built worker SHA-256 is derived by the workflow")
770            }
771        }
772    }
773
774    /// Whether the worker is built from an exact trusted source revision.
775    pub fn is_source_build(&self) -> bool {
776        matches!(&self.provisioning, WorkerProvisioning::SourceBuild { .. })
777    }
778
779    /// Exact worker version expected by the workflow contract.
780    pub fn version(&self) -> &str {
781        &self.version
782    }
783
784    /// Normalized HTTPS GitHub repository when source provisioning is selected.
785    pub fn source_repository(&self) -> Option<&str> {
786        match &self.provisioning {
787            WorkerProvisioning::Prebuilt { .. } => None,
788            WorkerProvisioning::SourceBuild { repository, .. } => Some(repository),
789        }
790    }
791
792    /// Exact lowercase commit ID when source provisioning is selected.
793    pub fn source_revision(&self) -> Option<&str> {
794        match &self.provisioning {
795            WorkerProvisioning::Prebuilt { .. } => None,
796            WorkerProvisioning::SourceBuild { revision, .. } => Some(revision),
797        }
798    }
799
800    fn source_build(&self) -> Option<(&str, &str, &str)> {
801        match &self.provisioning {
802            WorkerProvisioning::Prebuilt { .. } => None,
803            WorkerProvisioning::SourceBuild {
804                repository,
805                repository_slug,
806                revision,
807            } => Some((repository, repository_slug, revision)),
808        }
809    }
810}
811
812/// Bounded execution and artifact-retention policy.
813#[derive(Clone, Copy, Debug, Eq, PartialEq)]
814pub struct WorkflowLimits {
815    compile_timeout_minutes: u16,
816    signing_timeout_minutes: u16,
817    retention_days: u16,
818}
819
820/// GitHub event that starts one generated worker workflow.
821#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
822pub enum WorkflowRunTrigger {
823    /// A push below the temporary operation branch namespace.
824    #[default]
825    Push,
826    /// One explicitly authenticated API dispatch with exact public inputs.
827    WorkflowDispatch,
828}
829
830impl WorkflowLimits {
831    /// Construct bounded compile/signing timeouts and artifact retention.
832    ///
833    /// # Errors
834    ///
835    /// Compile must be 5–180 minutes, signing 5–60 minutes, and retention
836    /// 1–14 days.
837    pub fn new(
838        compile_timeout_minutes: u16,
839        signing_timeout_minutes: u16,
840        retention_days: u16,
841    ) -> Result<Self, WorkflowConfigError> {
842        validate_policy("compile timeout", compile_timeout_minutes, 5, 180)?;
843        validate_policy("signing timeout", signing_timeout_minutes, 5, 60)?;
844        validate_policy("artifact retention", retention_days, 1, 14)?;
845        Ok(Self {
846            compile_timeout_minutes,
847            signing_timeout_minutes,
848            retention_days,
849        })
850    }
851
852    /// Default 90-minute compile, 30-minute signing, and three-day retention.
853    ///
854    /// # Panics
855    ///
856    /// This cannot panic because the constants satisfy the documented bounds.
857    pub fn secure_defaults() -> Self {
858        Self::new(90, 30, 3).expect("constant workflow limits are valid")
859    }
860
861    /// Compile-job timeout in minutes.
862    pub const fn compile_timeout_minutes(self) -> u16 {
863        self.compile_timeout_minutes
864    }
865
866    /// Signing-job timeout in minutes.
867    pub const fn signing_timeout_minutes(self) -> u16 {
868        self.signing_timeout_minutes
869    }
870
871    /// Artifact retention in days.
872    pub const fn retention_days(self) -> u16 {
873        self.retention_days
874    }
875}
876
877/// Fully validated inputs for the signed physical-iPhone workflow.
878#[derive(Clone, Debug, Eq, PartialEq)]
879pub struct WorkflowConfig {
880    filename: WorkflowFileName,
881    protected_environment: ProtectedEnvironment,
882    secret_names: SigningSecretNames,
883    worker: WorkerDistribution,
884    public_source_repository: PublicSourceRepository,
885    trusted_source_ref: TrustedSourceRef,
886    temporary_branch_namespace: TemporaryBranchNamespace,
887    developer_directory: DeveloperDirectory,
888    limits: WorkflowLimits,
889    run_trigger: WorkflowRunTrigger,
890}
891
892impl WorkflowConfig {
893    /// Bind the security-sensitive workflow configuration.
894    ///
895    /// Defaults to push dispatch through the temporary branch namespace.
896    ///
897    /// # Errors
898    ///
899    /// Rejects a trusted branch that lives inside the temporary job namespace.
900    pub fn new(
901        filename: WorkflowFileName,
902        protected_environment: ProtectedEnvironment,
903        secret_names: SigningSecretNames,
904        worker: WorkerDistribution,
905        public_source_repository: PublicSourceRepository,
906        trusted_source_ref: TrustedSourceRef,
907        temporary_branch_namespace: TemporaryBranchNamespace,
908    ) -> Result<Self, WorkflowConfigError> {
909        Self::new_with_run_trigger(
910            filename,
911            protected_environment,
912            secret_names,
913            worker,
914            public_source_repository,
915            trusted_source_ref,
916            temporary_branch_namespace,
917            WorkflowRunTrigger::default(),
918        )
919    }
920
921    /// Bind the security-sensitive workflow configuration and one exact run trigger.
922    ///
923    /// # Errors
924    ///
925    /// Rejects a trusted branch that lives inside the temporary job namespace.
926    #[allow(
927        clippy::too_many_arguments,
928        reason = "the trigger is an additive part of the already-typed workflow configuration"
929    )]
930    pub fn new_with_run_trigger(
931        filename: WorkflowFileName,
932        protected_environment: ProtectedEnvironment,
933        secret_names: SigningSecretNames,
934        worker: WorkerDistribution,
935        public_source_repository: PublicSourceRepository,
936        trusted_source_ref: TrustedSourceRef,
937        temporary_branch_namespace: TemporaryBranchNamespace,
938        run_trigger: WorkflowRunTrigger,
939    ) -> Result<Self, WorkflowConfigError> {
940        let temporary_prefix = temporary_branch_namespace.full_ref_prefix();
941        let trusted_source = trusted_source_ref.as_str();
942        if trusted_source == temporary_prefix
943            || trusted_source
944                .strip_prefix(&temporary_prefix)
945                .is_some_and(|suffix| suffix.starts_with('/'))
946        {
947            return Err(WorkflowConfigError::RefNamespaceOverlap);
948        }
949        Ok(Self {
950            filename,
951            protected_environment,
952            secret_names,
953            worker,
954            public_source_repository,
955            trusted_source_ref,
956            temporary_branch_namespace,
957            developer_directory: DeveloperDirectory::github_default(),
958            limits: WorkflowLimits::secure_defaults(),
959            run_trigger,
960        })
961    }
962
963    /// Select a validated per-job Xcode developer directory.
964    #[must_use]
965    pub fn with_developer_directory(mut self, developer_directory: DeveloperDirectory) -> Self {
966        self.developer_directory = developer_directory;
967        self
968    }
969
970    /// Apply bounded timeout and retention policy.
971    #[must_use]
972    pub fn with_limits(mut self, limits: WorkflowLimits) -> Self {
973        self.limits = limits;
974        self
975    }
976
977    /// Generated workflow basename.
978    pub fn filename(&self) -> &WorkflowFileName {
979        &self.filename
980    }
981
982    /// Protected signing environment.
983    pub fn protected_environment(&self) -> &ProtectedEnvironment {
984        &self.protected_environment
985    }
986
987    /// Opaque signing secret reference names.
988    pub fn secret_names(&self) -> &SigningSecretNames {
989        &self.secret_names
990    }
991
992    /// Trusted worker provisioning policy.
993    pub fn worker(&self) -> &WorkerDistribution {
994        &self.worker
995    }
996
997    /// Public GitHub repository containing trusted and requested source.
998    pub fn public_source_repository(&self) -> &PublicSourceRepository {
999        &self.public_source_repository
1000    }
1001
1002    /// Allowlisted source branch or tag.
1003    pub fn trusted_source_ref(&self) -> &TrustedSourceRef {
1004        &self.trusted_source_ref
1005    }
1006
1007    /// Temporary provider branch namespace.
1008    pub fn temporary_branch_namespace(&self) -> &TemporaryBranchNamespace {
1009        &self.temporary_branch_namespace
1010    }
1011
1012    /// Per-job Xcode selection.
1013    pub fn developer_directory(&self) -> &DeveloperDirectory {
1014        &self.developer_directory
1015    }
1016
1017    /// Bounded execution policy.
1018    pub const fn limits(&self) -> WorkflowLimits {
1019        self.limits
1020    }
1021
1022    /// Exact GitHub event admitted by the generated workflow.
1023    pub const fn run_trigger(&self) -> WorkflowRunTrigger {
1024        self.run_trigger
1025    }
1026}
1027
1028/// Deterministically generated repository-relative path and YAML bytes.
1029#[derive(Clone, Debug, Eq, PartialEq)]
1030pub struct GeneratedWorkflow {
1031    path: String,
1032    yaml: String,
1033}
1034
1035impl GeneratedWorkflow {
1036    /// Repository-relative `.github/workflows/...` path.
1037    pub fn path(&self) -> &str {
1038        &self.path
1039    }
1040
1041    /// Complete UTF-8 YAML document with one trailing newline.
1042    pub fn yaml(&self) -> &str {
1043        &self.yaml
1044    }
1045
1046    /// Consume the result into its path and YAML.
1047    pub fn into_parts(self) -> (String, String) {
1048        (self.path, self.yaml)
1049    }
1050}
1051
1052/// Generate a deterministic GitHub Actions workflow.
1053///
1054/// The compile job has no environment and no `secrets` expressions. The sign
1055/// job receives the bounded exact configured signing-secret set at its worker
1056/// invocation, after the sealed archive is downloaded and independently
1057/// hash-checked. It never checks out or builds project source and always
1058/// invokes worker cleanup.
1059pub fn generate_workflow(config: &WorkflowConfig) -> GeneratedWorkflow {
1060    let mut yaml = String::with_capacity(12 * 1024);
1061    render_header(&mut yaml, config);
1062    push(&mut yaml, "\njobs:\n");
1063    if config.worker.source_build().is_some() {
1064        render_worker_build_job(&mut yaml, config);
1065    }
1066    render_compile_job(&mut yaml, config);
1067    render_sign_job(&mut yaml, config);
1068    // Source checkout repository insertion is limited to two unique validated
1069    // paths. `hashFiles` needs doubled quotes inside its YAML scalar.
1070    let yaml = yaml
1071        .replace(
1072            "          path: .rustferry-trusted-source\n",
1073            &format!(
1074                "          path: .rustferry-trusted-source\n          repository: '{}'\n",
1075                config.public_source_repository.slug()
1076            ),
1077        )
1078        .replace(
1079            "          path: source\n",
1080            &format!(
1081                "          path: source\n          repository: '{}'\n",
1082                config.public_source_repository.slug()
1083            ),
1084        )
1085        .replace(
1086            "            --workflow-path \"",
1087            "            --source-repository \"$RUSTFERRY_SOURCE_REPOSITORY\" \\\n            --workflow-path \"",
1088        )
1089        .replace(
1090            "hashFiles('source/Cargo.lock')",
1091            "hashFiles(''source/Cargo.lock'')",
1092        )
1093        .replace(
1094            "$RUSTFERRY_WORKER_PATH",
1095            "$RUNNER_TEMP/rustferry-worker-runtime/ferry-worker-macos",
1096        );
1097    debug_assert!(yaml.ends_with('\n'));
1098    GeneratedWorkflow {
1099        path: config.filename.repository_path(),
1100        yaml,
1101    }
1102}
1103
1104fn render_header(yaml: &mut String, config: &WorkflowConfig) {
1105    match config.run_trigger {
1106        WorkflowRunTrigger::Push => {
1107            push(yaml, "name: RustFerry physical iPhone\n\non:\n");
1108            push(yaml, "  push:\n    branches:\n");
1109            line(
1110                yaml,
1111                format_args!(
1112                    "      - '{}'",
1113                    config.temporary_branch_namespace.trigger_pattern()
1114                ),
1115            );
1116        }
1117        WorkflowRunTrigger::WorkflowDispatch => push(
1118            yaml,
1119            "name: RustFerry physical iPhone\n\nrun-name: 'rustferry-v1|${{ inputs.operation_id }}|${{ inputs.request_sha256 }}|${{ inputs.source_revision }}|${{ inputs.dispatch_revision }}'\n\non:\n  workflow_dispatch:\n    inputs:\n      operation_id:\n        description: 'Exact provider operation identifier'\n        required: true\n        type: string\n      request_sha256:\n        description: 'Canonical request SHA-256'\n        required: true\n        type: string\n      source_revision:\n        description: 'Exact trusted source revision'\n        required: true\n        type: string\n      dispatch_revision:\n        description: 'Exact temporary ref revision'\n        required: true\n        type: string\n",
1120        ),
1121    }
1122    push(
1123        yaml,
1124        "\npermissions: {}\n\nconcurrency:\n  group: 'rustferry-iphone-${{ github.repository_id }}'\n  cancel-in-progress: false\n\nenv:\n",
1125    );
1126    line(
1127        yaml,
1128        format_args!("  DEVELOPER_DIR: '{}'", config.developer_directory.as_str()),
1129    );
1130    push(yaml, "  CARGO_TERM_COLOR: 'never'\n  RUST_BACKTRACE: '0'\n");
1131    line(yaml, format_args!("  RUSTUP_TOOLCHAIN: '{RUST_TOOLCHAIN}'"));
1132    match &config.worker.provisioning {
1133        WorkerProvisioning::Prebuilt {
1134            download_url,
1135            sha256,
1136        } => {
1137            line(
1138                yaml,
1139                format_args!("  RUSTFERRY_WORKER_URL: '{download_url}'"),
1140            );
1141            line(yaml, format_args!("  RUSTFERRY_WORKER_SHA256: '{sha256}'"));
1142        }
1143        WorkerProvisioning::SourceBuild {
1144            repository,
1145            revision,
1146            ..
1147        } => {
1148            line(
1149                yaml,
1150                format_args!("  RUSTFERRY_WORKER_REPOSITORY: '{repository}'"),
1151            );
1152            line(
1153                yaml,
1154                format_args!("  RUSTFERRY_WORKER_REVISION: '{revision}'"),
1155            );
1156        }
1157    }
1158    line(
1159        yaml,
1160        format_args!("  RUSTFERRY_WORKER_VERSION: '{}'", config.worker.version()),
1161    );
1162    line(
1163        yaml,
1164        format_args!(
1165            "  RUSTFERRY_SOURCE_REPOSITORY: '{}'",
1166            config.public_source_repository.url()
1167        ),
1168    );
1169    line(
1170        yaml,
1171        format_args!(
1172            "  RUSTFERRY_TRUSTED_SOURCE_REF: '{}'",
1173            config.trusted_source_ref.as_str()
1174        ),
1175    );
1176    line(
1177        yaml,
1178        format_args!(
1179            "  RUSTFERRY_TEMPORARY_REF_PREFIX: '{}'",
1180            config.temporary_branch_namespace.full_ref_prefix()
1181        ),
1182    );
1183    if let Some(digest) = config.secret_names.target_graph_sha256() {
1184        line(
1185            yaml,
1186            format_args!("  RUSTFERRY_SIGNING_TARGET_GRAPH_SHA256: '{digest}'"),
1187        );
1188    }
1189}
1190
1191fn render_worker_build_job(yaml: &mut String, config: &WorkflowConfig) {
1192    let (_, repository_slug, revision) = config
1193        .worker
1194        .source_build()
1195        .expect("source-build job requires source provisioning");
1196    push(
1197        yaml,
1198        "  worker:\n    name: Build trusted macOS worker from exact source revision\n",
1199    );
1200    line(yaml, format_args!("    runs-on: {DEFAULT_RUNNER}"));
1201    line(
1202        yaml,
1203        format_args!("    timeout-minutes: {DEFAULT_WORKER_BUILD_TIMEOUT_MINUTES}"),
1204    );
1205    push(
1206        yaml,
1207        "    permissions:\n      contents: read\n    outputs:\n      sha256: '${{ steps.worker.outputs.sha256 }}'\n    env:\n      CARGO_HOME: '${{ github.workspace }}/.rustferry-worker-cargo-home'\n      CARGO_TARGET_DIR: '${{ github.workspace }}/.rustferry-worker-target'\n    steps:\n      - name: Checkout exact trusted worker revision\n        uses: actions/checkout@",
1208    );
1209    push(yaml, CHECKOUT_ACTION_SHA);
1210    push(yaml, " # v7.0.1\n        with:\n          repository: '");
1211    push(yaml, repository_slug);
1212    push(yaml, "'\n          ref: '");
1213    push(yaml, revision);
1214    push(
1215        yaml,
1216        "'\n          fetch-depth: 1\n          persist-credentials: false\n          path: .rustferry-worker-source\n",
1217    );
1218    render_toolchain_setup(yaml, 6, false);
1219    push(
1220        yaml,
1221        "      - name: Build only the trusted RustFerry worker binary\n        shell: bash\n        run: |\n          set -euo pipefail\n          source_root=\"$GITHUB_WORKSPACE/.rustferry-worker-source\"\n          actual_revision=\"$(/usr/bin/git -C \"$source_root\" rev-parse --verify 'HEAD^{commit}')\"\n          if [[ \"$actual_revision\" != \"$RUSTFERRY_WORKER_REVISION\" ]]; then\n            echo 'Trusted worker checkout revision mismatch.' >&2\n            exit 1\n          fi\n          cargo build --locked --release \\\n            --manifest-path \"$source_root/Cargo.toml\" \\\n            --package rustferry-worker-macos \\\n            --bin ferry-worker-macos\n      - name: Verify and package trusted RustFerry worker\n        id: worker\n        shell: bash\n        run: |\n          set -euo pipefail\n          candidate=\"$CARGO_TARGET_DIR/release/ferry-worker-macos\"\n          artifact=\"$RUNNER_TEMP/rustferry-worker-artifact/ferry-worker-macos\"\n          test -f \"$candidate\" -a ! -L \"$candidate\"\n          \"$candidate\" version --expect \"$RUSTFERRY_WORKER_VERSION\"\n          install -d -m 0700 \"$(dirname \"$artifact\")\"\n          install -m 0500 \"$candidate\" \"$artifact\"\n          actual=\"$(shasum -a 256 \"$artifact\" | awk '{print $1}')\"\n          [[ \"$actual\" =~ ^[0-9a-f]{64}$ ]]\n          \"$artifact\" version --expect \"$RUSTFERRY_WORKER_VERSION\"\n          printf 'sha256=%s\\n' \"$actual\" >> \"$GITHUB_OUTPUT\"\n      - name: Upload immutable trusted worker artifact\n        uses: actions/upload-artifact@",
1222    );
1223    push(yaml, UPLOAD_ARTIFACT_ACTION_SHA);
1224    push(
1225        yaml,
1226        " # v7\n        with:\n          name: 'rustferry-worker-${{ github.run_id }}-${{ github.run_attempt }}'\n          path: '${{ runner.temp }}/rustferry-worker-artifact/ferry-worker-macos'\n          if-no-files-found: error\n          compression-level: 0\n",
1227    );
1228    line(
1229        yaml,
1230        format_args!(
1231            "          retention-days: {}",
1232            config.limits.retention_days()
1233        ),
1234    );
1235    push(yaml, "\n");
1236}
1237
1238fn render_compile_job(yaml: &mut String, config: &WorkflowConfig) {
1239    push(
1240        yaml,
1241        "  compile:\n    name: Phase A - compile without signing secrets\n",
1242    );
1243    if config.worker.source_build().is_some() {
1244        push(yaml, "    needs: worker\n");
1245    }
1246    line(yaml, format_args!("    runs-on: {DEFAULT_RUNNER}"));
1247    line(
1248        yaml,
1249        format_args!(
1250            "    timeout-minutes: {}",
1251            config.limits.compile_timeout_minutes()
1252        ),
1253    );
1254    push(yaml, "    permissions:\n");
1255    if config.worker.source_build().is_some() {
1256        push(yaml, "      actions: read\n");
1257    }
1258    push(
1259        yaml,
1260        "      contents: read\n    outputs:\n      operation_id: '${{ steps.request.outputs.operation_id }}'\n      project_path: '${{ steps.request.outputs.project_path }}'\n      sealed_sha256: '${{ steps.sealed.outputs.sha256 }}'\n      signing_mode: '${{ steps.request.outputs.signing_mode }}'\n      source_revision: '${{ steps.request.outputs.source_revision }}'\n    env:\n      CARGO_HOME: '${{ github.workspace }}/.rustferry-cargo-home'\n      CARGO_TARGET_DIR: '${{ github.workspace }}/.rustferry-cargo-target'\n    steps:\n",
1261    );
1262    render_worker_install(yaml, config, 6);
1263    render_toolchain_setup(yaml, 6, true);
1264    push(
1265        yaml,
1266        "      - name: Checkout immutable dispatch request\n        uses: actions/checkout@",
1267    );
1268    push(yaml, CHECKOUT_ACTION_SHA);
1269    push(
1270        yaml,
1271        " # v7.0.1\n        with:\n          ref: '${{ github.sha }}'\n          fetch-depth: 1\n          persist-credentials: false\n          path: .rustferry-dispatch\n      - name: Checkout trusted source policy ref\n        uses: actions/checkout@",
1272    );
1273    push(yaml, CHECKOUT_ACTION_SHA);
1274    push(
1275        yaml,
1276        " # v7.0.1\n        with:\n          ref: '${{ env.RUSTFERRY_TRUSTED_SOURCE_REF }}'\n          fetch-depth: 0\n          persist-credentials: false\n          path: .rustferry-trusted-source\n      - name: Validate dispatch request and workflow revision\n        id: request\n        shell: bash\n        run: |\n          set -euo pipefail\n          \"$RUSTFERRY_WORKER_PATH\" github-request \\\n            --event \"$GITHUB_EVENT_PATH\" \\\n            --dispatch-root \"$GITHUB_WORKSPACE/.rustferry-dispatch\" \\\n            --trusted-source-root \"$GITHUB_WORKSPACE/.rustferry-trusted-source\" \\\n            --workflow-path \"",
1277    );
1278    push(yaml, &config.filename.repository_path());
1279    push(
1280        yaml,
1281        "\" \\\n            --push-manifest \"$GITHUB_WORKSPACE/.rustferry-dispatch/",
1282    );
1283    push(yaml, REQUEST_MANIFEST_PATH);
1284    push(
1285        yaml,
1286        "\" \\\n            --trusted-source-ref \"$RUSTFERRY_TRUSTED_SOURCE_REF\" \\\n            --temporary-ref-prefix \"$RUSTFERRY_TEMPORARY_REF_PREFIX\"",
1287    );
1288    if config.secret_names.target_graph_sha256().is_some() {
1289        push(
1290            yaml,
1291            " \\\n            --expected-signing-target-graph-sha256 \"$RUSTFERRY_SIGNING_TARGET_GRAPH_SHA256\"",
1292        );
1293    }
1294    push(
1295        yaml,
1296        " \\\n            --output-manifest \"$RUNNER_TEMP/rustferry-request.json\" \\\n            --github-output \"$GITHUB_OUTPUT\"\n      - name: Checkout exact requested source revision\n        uses: actions/checkout@",
1297    );
1298    push(yaml, CHECKOUT_ACTION_SHA);
1299    push(
1300        yaml,
1301        " # v7.0.1\n        with:\n          ref: '${{ steps.request.outputs.source_revision }}'\n          fetch-depth: 1\n          persist-credentials: false\n          path: source\n      - name: Restore source-revision-scoped Cargo cache\n        uses: actions/cache@",
1302    );
1303    push(yaml, CACHE_ACTION_SHA);
1304    push(
1305        yaml,
1306        " # v5\n        with:\n          path: |\n            ${{ env.CARGO_HOME }}/registry/index\n            ${{ env.CARGO_HOME }}/registry/cache\n            ${{ env.CARGO_HOME }}/git/db\n            ${{ env.CARGO_TARGET_DIR }}\n          key: 'rustferry-ios-${{ runner.os }}-",
1307    );
1308    push(yaml, config.worker.version());
1309    push(
1310        yaml,
1311        "-${{ steps.request.outputs.source_revision }}-${{ hashFiles('source/Cargo.lock') }}'\n      - name: Compile and seal unsigned iPhone archive with worker\n        shell: bash\n        env:\n          RUSTFERRY_OPERATION_ID: '${{ steps.request.outputs.operation_id }}'\n          RUSTFERRY_PROJECT_PATH: '${{ steps.request.outputs.project_path }}'\n          RUSTFERRY_SOURCE_REVISION: '${{ steps.request.outputs.source_revision }}'\n        run: |\n          set -euo pipefail\n          \"$RUSTFERRY_WORKER_PATH\" run-job \\\n            --phase compile \\\n            --manifest \"$RUNNER_TEMP/rustferry-request.json\" \\\n            --source-root \"$GITHUB_WORKSPACE/source\" \\\n            --trusted-source-root \"$GITHUB_WORKSPACE/.rustferry-trusted-source\" \\\n            --job-root \"$RUNNER_TEMP/rustferry-compile-job\" \\\n            --output-directory \"$RUNNER_TEMP/rustferry-handoff\"\n      - name: Record sealed archive digest\n        id: sealed\n        shell: bash\n        run: |\n          set -euo pipefail\n          archive=\"$RUNNER_TEMP/rustferry-handoff/unsigned-archive.zip\"\n          test -f \"$archive\" -a ! -L \"$archive\"\n          digest=\"$(shasum -a 256 \"$archive\" | awk '{print $1}')\"\n          [[ \"$digest\" =~ ^[0-9a-f]{64}$ ]]\n          printf 'sha256=%s\\n' \"$digest\" >> \"$GITHUB_OUTPUT\"\n      - name: Upload sealed unsigned handoff\n        id: unsigned_artifact\n        uses: actions/upload-artifact@",
1312    );
1313    push(yaml, UPLOAD_ARTIFACT_ACTION_SHA);
1314    push(
1315        yaml,
1316        " # v7\n        with:\n          name: 'rustferry-unsigned-${{ github.run_id }}-${{ github.run_attempt }}'\n          path: |\n            ${{ runner.temp }}/rustferry-handoff/unsigned-archive.zip\n            ${{ runner.temp }}/rustferry-handoff/sealed-archive.json\n            ${{ runner.temp }}/rustferry-handoff/compile-report.json\n            ${{ runner.temp }}/rustferry-handoff/sanitized-compile-log.txt\n          if-no-files-found: error\n          compression-level: 0\n",
1317    );
1318    line(
1319        yaml,
1320        format_args!(
1321            "          retention-days: {}",
1322            config.limits.retention_days()
1323        ),
1324    );
1325    push(
1326        yaml,
1327        "      - name: Clean compile job workspace\n        if: '${{ always() }}'\n        shell: bash\n        run: |\n          set -euo pipefail\n          if [[ -x \"$RUSTFERRY_WORKER_PATH\" ]]; then\n            \"$RUSTFERRY_WORKER_PATH\" cleanup \\\n              --job-root \"$RUNNER_TEMP/rustferry-compile-job\" \\\n              --require-complete\n          elif [[ -d \"$RUNNER_TEMP/rustferry-compile-job\" ]]; then\n            echo 'Compile cleanup could not run because the trusted worker is unavailable.' >&2\n            exit 1\n          fi\n",
1328    );
1329}
1330
1331#[allow(clippy::too_many_lines)]
1332fn render_sign_job(yaml: &mut String, config: &WorkflowConfig) {
1333    push(
1334        yaml,
1335        "\n  sign:\n    name: Phase B - protected development signing\n",
1336    );
1337    if config.worker.source_build().is_some() {
1338        push(yaml, "    needs:\n      - worker\n      - compile\n");
1339    } else {
1340        push(yaml, "    needs: compile\n");
1341    }
1342    push(
1343        yaml,
1344        "    if: needs.compile.outputs.signing_mode == 'manual_development' && github.event.repository.private == true\n",
1345    );
1346    line(yaml, format_args!("    runs-on: {DEFAULT_RUNNER}"));
1347    line(
1348        yaml,
1349        format_args!(
1350            "    timeout-minutes: {}",
1351            config.limits.signing_timeout_minutes()
1352        ),
1353    );
1354    push(yaml, "    environment: '");
1355    push(yaml, config.protected_environment.as_str());
1356    push(
1357        yaml,
1358        "'\n    permissions:\n      actions: read\n    steps:\n",
1359    );
1360    render_worker_install(yaml, config, 6);
1361    render_toolchain_setup(yaml, 6, true);
1362    push(
1363        yaml,
1364        "      - name: Download exact sealed unsigned handoff\n        uses: actions/download-artifact@",
1365    );
1366    push(yaml, DOWNLOAD_ARTIFACT_ACTION_SHA);
1367    push(
1368        yaml,
1369        " # v8\n        with:\n          name: 'rustferry-unsigned-${{ github.run_id }}-${{ github.run_attempt }}'\n          path: '${{ runner.temp }}/rustferry-handoff'\n      - name: Verify sealed handoff digest before signing\n        shell: bash\n        env:\n          RUSTFERRY_EXPECTED_SHA256: '${{ needs.compile.outputs.sealed_sha256 }}'\n        run: |\n          set -euo pipefail\n          archive=\"$RUNNER_TEMP/rustferry-handoff/unsigned-archive.zip\"\n          [[ \"$RUSTFERRY_EXPECTED_SHA256\" =~ ^[0-9a-f]{64}$ ]]\n          test -f \"$archive\" -a ! -L \"$archive\"\n          actual=\"$(shasum -a 256 \"$archive\" | awk '{print $1}')\"\n          if [[ \"$actual\" != \"$RUSTFERRY_EXPECTED_SHA256\" ]]; then\n            echo 'Sealed unsigned archive failed integrity verification.' >&2\n            exit 1\n          fi\n      - name: Sign, export, and independently validate IPA with worker\n        id: signed\n        shell: bash\n        env:\n",
1370    );
1371    line(
1372        yaml,
1373        format_args!(
1374            "          RUSTFERRY_SIGNING_CERTIFICATE_P12: '${{{{ secrets.{} }}}}'",
1375            config.secret_names.certificate_p12.as_str()
1376        ),
1377    );
1378    line(
1379        yaml,
1380        format_args!(
1381            "          RUSTFERRY_SIGNING_CERTIFICATE_PASSWORD: '${{{{ secrets.{} }}}}'",
1382            config.secret_names.certificate_password.as_str()
1383        ),
1384    );
1385    let profile_names = config.secret_names.profile_names().collect::<Vec<_>>();
1386    if profile_names.len() == 1 {
1387        line(
1388            yaml,
1389            format_args!(
1390                "          RUSTFERRY_SIGNING_PROVISIONING_PROFILE: '${{{{ secrets.{} }}}}'",
1391                config.secret_names.provisioning_profile().as_str()
1392            ),
1393        );
1394    } else {
1395        for (index, profile) in profile_names.iter().enumerate() {
1396            line(
1397                yaml,
1398                format_args!(
1399                    "          RUSTFERRY_SIGNING_PROVISIONING_PROFILE_{}: '${{{{ secrets.{} }}}}'",
1400                    index + 1,
1401                    profile.as_str()
1402                ),
1403            );
1404        }
1405    }
1406    push(
1407        yaml,
1408        "          RUSTFERRY_EXPECTED_SHA256: '${{ needs.compile.outputs.sealed_sha256 }}'\n          RUSTFERRY_OPERATION_ID: '${{ needs.compile.outputs.operation_id }}'\n          RUSTFERRY_SOURCE_REVISION: '${{ needs.compile.outputs.source_revision }}'\n",
1409    );
1410    if profile_names.len() == 1 {
1411        push(
1412            yaml,
1413            "        run: |\n          set -euo pipefail\n          printf '%s\\0%s\\0%s' \\\n            \"$RUSTFERRY_SIGNING_CERTIFICATE_P12\" \\\n            \"$RUSTFERRY_SIGNING_CERTIFICATE_PASSWORD\" \\\n            \"$RUSTFERRY_SIGNING_PROVISIONING_PROFILE\" |\n",
1414        );
1415    } else {
1416        render_v2_signing_frame(yaml, config, &profile_names);
1417    }
1418    push(
1419        yaml,
1420        "            /usr/bin/env -i \\\n              \"DEVELOPER_DIR=$DEVELOPER_DIR\" \\\n              \"HOME=$HOME\" \\\n              'LC_ALL=C' \\\n              \"PATH=$HOME/.cargo/bin:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin\" \\\n              \"RUSTFERRY_WORKER_ROOT=$RUNNER_TEMP\" \\\n              \"RUSTUP_TOOLCHAIN=$RUSTUP_TOOLCHAIN\" \\\n              \"TMPDIR=$RUNNER_TEMP\" \\\n              \"$RUSTFERRY_WORKER_PATH\" run-job \\\n                --phase sign \\\n                --sealed-directory \"$RUNNER_TEMP/rustferry-handoff\" \\\n                --expected-sealed-sha256 \"$RUSTFERRY_EXPECTED_SHA256\" \\\n                --source-revision \"$RUSTFERRY_SOURCE_REVISION\" \\\n                --operation-id \"$RUSTFERRY_OPERATION_ID\" \\\n                --job-root \"$RUNNER_TEMP/rustferry-sign-job\" \\\n                --output-directory \"$RUNNER_TEMP/rustferry-signed\" \\\n",
1421    );
1422    line(
1423        yaml,
1424        format_args!(
1425            "                --certificate-p12-reference '{}' \\",
1426            config.secret_names.certificate_p12.as_str()
1427        ),
1428    );
1429    line(
1430        yaml,
1431        format_args!(
1432            "                --certificate-password-reference '{}' \\",
1433            config.secret_names.certificate_password.as_str()
1434        ),
1435    );
1436    for (index, profile) in profile_names.iter().enumerate() {
1437        let continuation = if index + 1 == profile_names.len() {
1438            ""
1439        } else {
1440            " \\"
1441        };
1442        line(
1443            yaml,
1444            format_args!(
1445                "                --provisioning-profile-reference '{}'{}",
1446                profile.as_str(),
1447                continuation
1448            ),
1449        );
1450    }
1451    push(
1452        yaml,
1453        "      - name: Remove signing material and temporary keychain\n        if: '${{ always() }}'\n        shell: bash\n        run: |\n          set -euo pipefail\n          if [[ -x \"$RUSTFERRY_WORKER_PATH\" ]]; then\n            \"$RUSTFERRY_WORKER_PATH\" cleanup \\\n              --job-root \"$RUNNER_TEMP/rustferry-sign-job\" \\\n              --require-complete\n          elif [[ -d \"$RUNNER_TEMP/rustferry-sign-job\" ]]; then\n            echo 'Signing cleanup could not run because the trusted worker is unavailable.' >&2\n            exit 1\n          fi\n      - name: Upload validated development IPA\n        if: '${{ success() }}'\n        uses: actions/upload-artifact@",
1454    );
1455    push(yaml, UPLOAD_ARTIFACT_ACTION_SHA);
1456    push(
1457        yaml,
1458        " # v7\n        with:\n          name: 'rustferry-iphone-${{ github.run_id }}-${{ github.run_attempt }}'\n          path: |\n            ${{ runner.temp }}/rustferry-signed/application-development.ipa\n            ${{ runner.temp }}/rustferry-signed/artifact-manifest.json\n            ${{ runner.temp }}/rustferry-signed/signing-report.json\n            ${{ runner.temp }}/rustferry-signed/validation-report.json\n            ${{ runner.temp }}/rustferry-signed/sanitized-build-log.txt\n            ${{ runner.temp }}/rustferry-signed/application.app.zip\n            ${{ runner.temp }}/rustferry-signed/application.xcarchive.zip\n            ${{ runner.temp }}/rustferry-signed/application.dSYM.zip\n          if-no-files-found: error\n          compression-level: 0\n",
1459    );
1460    line(
1461        yaml,
1462        format_args!(
1463            "          retention-days: {}",
1464            config.limits.retention_days()
1465        ),
1466    );
1467}
1468
1469fn render_v2_signing_frame(
1470    yaml: &mut String,
1471    config: &WorkflowConfig,
1472    profile_names: &[&SecretName],
1473) {
1474    push(
1475        yaml,
1476        "        run: |\n          set -euo pipefail\n          export LC_ALL=C\n          write_u16_be() {\n            local value=\"$1\"\n            printf '%b' \"$(printf '\\\\%03o\\\\%03o' \\\n              \"$(((value >> 8) & 255))\" \"$((value & 255))\")\"\n          }\n          write_u32_be() {\n            local value=\"$1\"\n            printf '%b' \"$(printf '\\\\%03o\\\\%03o\\\\%03o\\\\%03o' \\\n              \"$(((value >> 24) & 255))\" \"$(((value >> 16) & 255))\" \\\n              \"$(((value >> 8) & 255))\" \"$((value & 255))\")\"\n          }\n          write_record() {\n            local reference=\"$1\"\n            local secret_value=\"$2\"\n            write_u16_be \"${#reference}\"\n            write_u32_be \"${#secret_value}\"\n            printf '%s%s' \"$reference\" \"$secret_value\"\n          }\n          {\n            printf 'RFSIGNV2'\n",
1477    );
1478    line(
1479        yaml,
1480        format_args!("            write_u32_be '{}'", profile_names.len() + 2),
1481    );
1482    line(
1483        yaml,
1484        format_args!(
1485            "            write_record '{}' \"$RUSTFERRY_SIGNING_CERTIFICATE_P12\"",
1486            config.secret_names.certificate_p12.as_str()
1487        ),
1488    );
1489    line(
1490        yaml,
1491        format_args!(
1492            "            write_record '{}' \"$RUSTFERRY_SIGNING_CERTIFICATE_PASSWORD\"",
1493            config.secret_names.certificate_password.as_str()
1494        ),
1495    );
1496    for (index, profile) in profile_names.iter().enumerate() {
1497        line(
1498            yaml,
1499            format_args!(
1500                "            write_record '{}' \"$RUSTFERRY_SIGNING_PROVISIONING_PROFILE_{}\"",
1501                profile.as_str(),
1502                index + 1
1503            ),
1504        );
1505    }
1506    push(yaml, "          } |\n");
1507}
1508
1509fn render_worker_install(yaml: &mut String, config: &WorkflowConfig, indentation: usize) {
1510    match &config.worker.provisioning {
1511        WorkerProvisioning::Prebuilt { .. } => render_prebuilt_worker_install(yaml, indentation),
1512        WorkerProvisioning::SourceBuild { .. } => {
1513            render_source_worker_install(yaml, indentation);
1514        }
1515    }
1516}
1517
1518fn render_prebuilt_worker_install(yaml: &mut String, indentation: usize) {
1519    let spaces = " ".repeat(indentation);
1520    line(
1521        yaml,
1522        format_args!("{spaces}- name: Install hash-pinned RustFerry worker"),
1523    );
1524    line(yaml, format_args!("{spaces}  shell: bash"));
1525    line(yaml, format_args!("{spaces}  run: |"));
1526    line(yaml, format_args!("{spaces}    set -euo pipefail"));
1527    line(
1528        yaml,
1529        format_args!("{spaces}    install -d -m 0700 \"$(dirname \"$RUSTFERRY_WORKER_PATH\")\""),
1530    );
1531    line(
1532        yaml,
1533        format_args!(
1534            "{spaces}    curl --fail --location --proto '=https' --tlsv1.2 --retry 3 --silent --show-error \\\n{spaces}      \"$RUSTFERRY_WORKER_URL\" --output \"$RUSTFERRY_WORKER_PATH\""
1535        ),
1536    );
1537    line(
1538        yaml,
1539        format_args!(
1540            "{spaces}    actual=\"$(shasum -a 256 \"$RUSTFERRY_WORKER_PATH\" | awk '{{print $1}}')\""
1541        ),
1542    );
1543    line(
1544        yaml,
1545        format_args!("{spaces}    if [[ \"$actual\" != \"$RUSTFERRY_WORKER_SHA256\" ]]; then"),
1546    );
1547    line(
1548        yaml,
1549        format_args!("{spaces}      echo 'RustFerry worker integrity check failed.' >&2"),
1550    );
1551    line(yaml, format_args!("{spaces}      exit 1"));
1552    line(yaml, format_args!("{spaces}    fi"));
1553    line(
1554        yaml,
1555        format_args!("{spaces}    chmod 0500 \"$RUSTFERRY_WORKER_PATH\""),
1556    );
1557    line(
1558        yaml,
1559        format_args!(
1560            "{spaces}    \"$RUSTFERRY_WORKER_PATH\" version --expect \"$RUSTFERRY_WORKER_VERSION\""
1561        ),
1562    );
1563}
1564
1565fn render_source_worker_install(yaml: &mut String, indentation: usize) {
1566    let spaces = " ".repeat(indentation);
1567    line(
1568        yaml,
1569        format_args!("{spaces}- name: Download exact trusted worker artifact"),
1570    );
1571    line(
1572        yaml,
1573        format_args!(
1574            "{spaces}  uses: actions/download-artifact@{DOWNLOAD_ARTIFACT_ACTION_SHA} # v8"
1575        ),
1576    );
1577    line(yaml, format_args!("{spaces}  with:"));
1578    line(
1579        yaml,
1580        format_args!(
1581            "{spaces}    name: 'rustferry-worker-${{{{ github.run_id }}}}-${{{{ github.run_attempt }}}}'"
1582        ),
1583    );
1584    line(
1585        yaml,
1586        format_args!("{spaces}    path: '${{{{ runner.temp }}}}/rustferry-worker-download'"),
1587    );
1588    line(
1589        yaml,
1590        format_args!("{spaces}- name: Verify exact trusted worker artifact"),
1591    );
1592    line(yaml, format_args!("{spaces}  shell: bash"));
1593    line(yaml, format_args!("{spaces}  env:"));
1594    line(
1595        yaml,
1596        format_args!(
1597            "{spaces}    RUSTFERRY_EXPECTED_WORKER_SHA256: '${{{{ needs.worker.outputs.sha256 }}}}'"
1598        ),
1599    );
1600    line(yaml, format_args!("{spaces}  run: |"));
1601    line(yaml, format_args!("{spaces}    set -euo pipefail"));
1602    line(
1603        yaml,
1604        format_args!(
1605            "{spaces}    candidate=\"$RUNNER_TEMP/rustferry-worker-download/ferry-worker-macos\""
1606        ),
1607    );
1608    line(
1609        yaml,
1610        format_args!("{spaces}    [[ \"$RUSTFERRY_EXPECTED_WORKER_SHA256\" =~ ^[0-9a-f]{{64}}$ ]]"),
1611    );
1612    line(
1613        yaml,
1614        format_args!("{spaces}    test -f \"$candidate\" -a ! -L \"$candidate\""),
1615    );
1616    line(
1617        yaml,
1618        format_args!("{spaces}    actual=\"$(shasum -a 256 \"$candidate\" | awk '{{print $1}}')\""),
1619    );
1620    line(
1621        yaml,
1622        format_args!(
1623            "{spaces}    if [[ \"$actual\" != \"$RUSTFERRY_EXPECTED_WORKER_SHA256\" ]]; then"
1624        ),
1625    );
1626    line(
1627        yaml,
1628        format_args!("{spaces}      echo 'Trusted worker artifact integrity check failed.' >&2"),
1629    );
1630    line(yaml, format_args!("{spaces}      exit 1"));
1631    line(yaml, format_args!("{spaces}    fi"));
1632    line(
1633        yaml,
1634        format_args!("{spaces}    install -d -m 0700 \"$(dirname \"$RUSTFERRY_WORKER_PATH\")\""),
1635    );
1636    line(
1637        yaml,
1638        format_args!("{spaces}    install -m 0500 \"$candidate\" \"$RUSTFERRY_WORKER_PATH\""),
1639    );
1640    line(
1641        yaml,
1642        format_args!(
1643            "{spaces}    installed=\"$(shasum -a 256 \"$RUSTFERRY_WORKER_PATH\" | awk '{{print $1}}')\""
1644        ),
1645    );
1646    line(
1647        yaml,
1648        format_args!("{spaces}    [[ \"$installed\" == \"$RUSTFERRY_EXPECTED_WORKER_SHA256\" ]]"),
1649    );
1650    line(
1651        yaml,
1652        format_args!(
1653            "{spaces}    \"$RUSTFERRY_WORKER_PATH\" version --expect \"$RUSTFERRY_WORKER_VERSION\""
1654        ),
1655    );
1656}
1657
1658fn render_toolchain_setup(yaml: &mut String, indentation: usize, install_ios_target: bool) {
1659    let spaces = " ".repeat(indentation);
1660    let name = if install_ios_target {
1661        "Install exact Rust toolchain and physical-iPhone target"
1662    } else {
1663        "Install exact Rust toolchain"
1664    };
1665    line(yaml, format_args!("{spaces}- name: {name}"));
1666    line(yaml, format_args!("{spaces}  shell: bash"));
1667    line(yaml, format_args!("{spaces}  run: |"));
1668    line(yaml, format_args!("{spaces}    set -euo pipefail"));
1669    line(
1670        yaml,
1671        format_args!(
1672            "{spaces}    rustup toolchain install \"$RUSTUP_TOOLCHAIN\" --profile minimal --no-self-update"
1673        ),
1674    );
1675    if install_ios_target {
1676        line(
1677            yaml,
1678            format_args!(
1679                "{spaces}    rustup target add --toolchain \"$RUSTUP_TOOLCHAIN\" aarch64-apple-ios"
1680            ),
1681        );
1682    }
1683    line(
1684        yaml,
1685        format_args!("{spaces}    actual=\"$(rustup run \"$RUSTUP_TOOLCHAIN\" rustc --version)\""),
1686    );
1687    line(
1688        yaml,
1689        format_args!("{spaces}    [[ \"$actual\" == \"rustc {RUST_TOOLCHAIN} \"* ]]"),
1690    );
1691}
1692
1693fn push(output: &mut String, value: &str) {
1694    output.push_str(value);
1695}
1696
1697fn line(output: &mut String, arguments: fmt::Arguments<'_>) {
1698    writeln!(output, "{arguments}").expect("writing to a String cannot fail");
1699}
1700
1701fn validate_nonempty_length(
1702    field: &'static str,
1703    value: &str,
1704    maximum: usize,
1705) -> Result<(), WorkflowConfigError> {
1706    if value.is_empty() {
1707        return Err(WorkflowConfigError::Empty { field });
1708    }
1709    if value.len() > maximum {
1710        return Err(WorkflowConfigError::TooLong { field, maximum });
1711    }
1712    Ok(())
1713}
1714
1715fn validate_ascii_allowlist(
1716    field: &'static str,
1717    value: &str,
1718    allowed: impl Fn(u8) -> bool,
1719) -> Result<(), WorkflowConfigError> {
1720    if let Some(index) = value.bytes().position(|byte| !allowed(byte)) {
1721        return Err(WorkflowConfigError::InvalidCharacter { field, index });
1722    }
1723    Ok(())
1724}
1725
1726fn validate_identifier(
1727    field: &'static str,
1728    value: &str,
1729    maximum: usize,
1730    punctuation: &[u8],
1731) -> Result<(), WorkflowConfigError> {
1732    validate_nonempty_length(field, value, maximum)?;
1733    if !value.as_bytes()[0].is_ascii_alphanumeric() {
1734        return Err(WorkflowConfigError::InvalidFormat { field });
1735    }
1736    validate_ascii_allowlist(field, value, |byte| {
1737        byte.is_ascii_alphanumeric() || punctuation.contains(&byte)
1738    })
1739}
1740
1741#[allow(clippy::case_sensitive_file_extension_comparisons)]
1742fn validate_ref_tail(field: &'static str, tail: &str) -> Result<(), WorkflowConfigError> {
1743    validate_nonempty_length(field, tail, 255)?;
1744    validate_ascii_allowlist(field, tail, |byte| {
1745        byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'.' | b'_' | b'-')
1746    })?;
1747    if tail.starts_with('/')
1748        || tail.ends_with('/')
1749        || tail.ends_with('.')
1750        || tail.ends_with(".lock")
1751        || tail.contains("..")
1752        || tail.contains("//")
1753        || tail.split('/').any(str::is_empty)
1754    {
1755        return Err(WorkflowConfigError::InvalidFormat { field });
1756    }
1757    Ok(())
1758}
1759
1760fn validate_worker_url(value: &str) -> Result<(), WorkflowConfigError> {
1761    validate_nonempty_length("worker download URL", value, 512)?;
1762    let path =
1763        value
1764            .strip_prefix("https://github.com/")
1765            .ok_or(WorkflowConfigError::InvalidFormat {
1766                field: "worker download URL",
1767            })?;
1768    validate_ascii_allowlist("worker download URL", path, |byte| {
1769        byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'.' | b'_' | b'+' | b'-')
1770    })?;
1771    let segments = path.split('/').collect::<Vec<_>>();
1772    if segments.len() != 6
1773        || segments.iter().any(|segment| segment.is_empty())
1774        || segments[2] != "releases"
1775        || segments[3] != "download"
1776        || segments
1777            .iter()
1778            .any(|segment| matches!(*segment, "." | ".."))
1779    {
1780        return Err(WorkflowConfigError::InvalidFormat {
1781            field: "worker download URL",
1782        });
1783    }
1784    Ok(())
1785}
1786
1787fn normalize_github_repository(
1788    field: &'static str,
1789    value: &str,
1790) -> Result<(String, String), WorkflowConfigError> {
1791    validate_nonempty_length(field, value, 256)?;
1792    let path = value.strip_prefix("https://github.com/").unwrap_or(value);
1793    let path = path.strip_suffix('/').unwrap_or(path);
1794    let path = path.strip_suffix(".git").unwrap_or(path);
1795    validate_ascii_allowlist(field, path, |byte| {
1796        byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'.' | b'_' | b'-')
1797    })?;
1798    let mut segments = path.split('/');
1799    let owner = segments.next().unwrap_or_default();
1800    let repository = segments.next().unwrap_or_default();
1801    if segments.next().is_some()
1802        || owner.is_empty()
1803        || repository.is_empty()
1804        || owner.len() > 39
1805        || repository.len() > 100
1806        || !owner
1807            .bytes()
1808            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
1809        || !repository
1810            .bytes()
1811            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
1812        || !owner.as_bytes()[0].is_ascii_alphanumeric()
1813        || !owner.as_bytes()[owner.len() - 1].is_ascii_alphanumeric()
1814        || !repository.as_bytes()[0].is_ascii_alphanumeric()
1815        || matches!(repository, "." | "..")
1816    {
1817        return Err(WorkflowConfigError::InvalidFormat { field });
1818    }
1819    let slug = format!(
1820        "{}/{}",
1821        owner.to_ascii_lowercase(),
1822        repository.to_ascii_lowercase()
1823    );
1824    Ok((format!("https://github.com/{slug}"), slug))
1825}
1826
1827fn validate_commit_sha(field: &'static str, value: &str) -> Result<(), WorkflowConfigError> {
1828    if value.len() != 40 {
1829        return Err(WorkflowConfigError::InvalidFormat { field });
1830    }
1831    validate_ascii_allowlist(field, value, |byte| {
1832        byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)
1833    })
1834}
1835
1836fn validate_semver(field: &'static str, value: &str) -> Result<(), WorkflowConfigError> {
1837    validate_nonempty_length(field, value, 64)?;
1838    let parsed =
1839        semver::Version::parse(value).map_err(|_| WorkflowConfigError::InvalidFormat { field })?;
1840    if parsed.to_string() != value {
1841        return Err(WorkflowConfigError::InvalidFormat { field });
1842    }
1843    Ok(())
1844}
1845
1846fn validate_sha256(field: &'static str, value: &str) -> Result<(), WorkflowConfigError> {
1847    if value.len() != 64 {
1848        return Err(WorkflowConfigError::InvalidFormat { field });
1849    }
1850    validate_ascii_allowlist(field, value, |byte| {
1851        byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)
1852    })
1853}
1854
1855fn validate_policy(
1856    field: &'static str,
1857    value: u16,
1858    minimum: u16,
1859    maximum: u16,
1860) -> Result<(), WorkflowConfigError> {
1861    if !(minimum..=maximum).contains(&value) {
1862        return Err(WorkflowConfigError::PolicyOutOfRange {
1863            field,
1864            minimum,
1865            maximum,
1866        });
1867    }
1868    Ok(())
1869}
1870
1871#[cfg(test)]
1872mod tests {
1873    use super::*;
1874    use rustferry_remote::BundleIdentifier;
1875
1876    const WORKER_REVISION: &str = "0123456789abcdef0123456789abcdef01234567";
1877    const WORKER_SHA256: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
1878
1879    fn fixture_public_source() -> PublicSourceRepository {
1880        PublicSourceRepository::new("https://github.com/ShiroKSH/RustFerry.git/").unwrap()
1881    }
1882
1883    fn signing_target(
1884        name: &str,
1885        bundle_identifier: &str,
1886        kind: SigningTargetKind,
1887    ) -> SigningTarget {
1888        SigningTarget {
1889            name: name.to_owned(),
1890            bundle_identifier: BundleIdentifier::new(bundle_identifier).expect("bundle identifier"),
1891            kind,
1892        }
1893    }
1894
1895    fn fixture_config() -> WorkflowConfig {
1896        WorkflowConfig::new(
1897            WorkflowFileName::new("rustferry-goal3-iphone.yml").unwrap(),
1898            ProtectedEnvironment::new("rustferry-goal3-signing").unwrap(),
1899            SigningSecretNames::goal3_defaults(),
1900            WorkerDistribution::new(
1901                "https://github.com/ShiroKSH/rustferry/releases/download/v0.1.0/ferry-worker-macos",
1902                WORKER_SHA256,
1903                "0.1.0",
1904            )
1905            .unwrap(),
1906            fixture_public_source(),
1907            TrustedSourceRef::new("refs/heads/goal3/macless-iphone-builds").unwrap(),
1908            TemporaryBranchNamespace::new("rustferry/goal3/builds").unwrap(),
1909        )
1910        .unwrap()
1911    }
1912
1913    fn fixture_dispatch_config() -> WorkflowConfig {
1914        WorkflowConfig::new_with_run_trigger(
1915            WorkflowFileName::new("rustferry-goal3-iphone.yml").unwrap(),
1916            ProtectedEnvironment::new("rustferry-goal3-signing").unwrap(),
1917            SigningSecretNames::goal3_defaults(),
1918            WorkerDistribution::new(
1919                "https://github.com/ShiroKSH/rustferry/releases/download/v0.1.0/ferry-worker-macos",
1920                WORKER_SHA256,
1921                "0.1.0",
1922            )
1923            .unwrap(),
1924            fixture_public_source(),
1925            TrustedSourceRef::new("refs/heads/goal3/macless-iphone-builds").unwrap(),
1926            TemporaryBranchNamespace::new("rustferry/goal3/builds").unwrap(),
1927            WorkflowRunTrigger::WorkflowDispatch,
1928        )
1929        .unwrap()
1930    }
1931
1932    fn fixture_multi_profile_config() -> WorkflowConfig {
1933        let mut config = fixture_config();
1934        config.secret_names = SigningSecretNames::for_targets(&[
1935            signing_target(
1936                "RuntimeBridge",
1937                "com.example.weather.runtime-bridge",
1938                SigningTargetKind::Framework,
1939            ),
1940            signing_target(
1941                "Weather",
1942                "com.example.weather",
1943                SigningTargetKind::Application,
1944            ),
1945            signing_target(
1946                "WeatherWidget",
1947                "com.example.weather.widget",
1948                SigningTargetKind::Extension,
1949            ),
1950        ])
1951        .expect("multi-profile secret names");
1952        config
1953    }
1954
1955    fn fixture_modern_app_only_config() -> WorkflowConfig {
1956        let mut config = fixture_config();
1957        config.secret_names = SigningSecretNames::for_targets(&[signing_target(
1958            "App",
1959            "com.example.app",
1960            SigningTargetKind::Application,
1961        )])
1962        .expect("app-only target graph");
1963        config
1964    }
1965
1966    fn fixture_source_config() -> WorkflowConfig {
1967        WorkflowConfig::new(
1968            WorkflowFileName::new("rustferry-goal3-iphone.yml").unwrap(),
1969            ProtectedEnvironment::new("rustferry-goal3-signing").unwrap(),
1970            SigningSecretNames::goal3_defaults(),
1971            WorkerDistribution::from_source(
1972                "https://github.com/ShiroKSH/RustFerry.git/",
1973                WORKER_REVISION,
1974                "0.1.0",
1975            )
1976            .unwrap(),
1977            fixture_public_source(),
1978            TrustedSourceRef::new("refs/heads/goal3/macless-iphone-builds").unwrap(),
1979            TemporaryBranchNamespace::new("rustferry/goal3/builds").unwrap(),
1980        )
1981        .unwrap()
1982    }
1983
1984    fn fixture_split_config() -> WorkflowConfig {
1985        let mut config = fixture_source_config();
1986        config.public_source_repository =
1987            PublicSourceRepository::new("Public-Org/Public-App").unwrap();
1988        config
1989    }
1990
1991    #[test]
1992    fn deterministic_workflow_snapshot() {
1993        let first = generate_workflow(&fixture_config());
1994        let second = generate_workflow(&fixture_config());
1995
1996        assert_eq!(first, second);
1997        assert_eq!(first.path(), ".github/workflows/rustferry-goal3-iphone.yml");
1998        assert_eq!(first.yaml().lines().count(), 280);
1999        assert_eq!(fnv1a64(first.yaml().as_bytes()), 0x8351_0daa_6cce_5721);
2000    }
2001
2002    #[test]
2003    fn deterministic_source_build_workflow_snapshot() {
2004        let first = generate_workflow(&fixture_source_config());
2005        let second = generate_workflow(&fixture_source_config());
2006
2007        assert_eq!(first, second);
2008        assert_eq!(first.yaml().lines().count(), 369);
2009        assert_eq!(fnv1a64(first.yaml().as_bytes()), 0x58cf_50e3_6f3d_c831);
2010    }
2011
2012    #[test]
2013    fn deterministic_workflow_dispatch_snapshot() {
2014        let first = generate_workflow(&fixture_dispatch_config());
2015        let second = generate_workflow(&fixture_dispatch_config());
2016
2017        assert_eq!(first, second);
2018        assert_eq!(first.yaml().lines().count(), 297);
2019        assert_eq!(fnv1a64(first.yaml().as_bytes()), 0xb2c0_73ff_cf01_660d);
2020        let (header, _) = first
2021            .yaml()
2022            .split_once("\npermissions: {}\n")
2023            .expect("permissions follow trigger");
2024        assert_eq!(
2025            header,
2026            "name: RustFerry physical iPhone\n\nrun-name: 'rustferry-v1|${{ inputs.operation_id }}|${{ inputs.request_sha256 }}|${{ inputs.source_revision }}|${{ inputs.dispatch_revision }}'\n\non:\n  workflow_dispatch:\n    inputs:\n      operation_id:\n        description: 'Exact provider operation identifier'\n        required: true\n        type: string\n      request_sha256:\n        description: 'Canonical request SHA-256'\n        required: true\n        type: string\n      source_revision:\n        description: 'Exact trusted source revision'\n        required: true\n        type: string\n      dispatch_revision:\n        description: 'Exact temporary ref revision'\n        required: true\n        type: string\n"
2027        );
2028        assert!(!header.contains("push:"));
2029        assert!(!header.contains("${{ secrets."));
2030    }
2031
2032    #[test]
2033    fn source_distribution_normalizes_and_binds_trust_inputs() {
2034        let worker = fixture_source_config().worker().clone();
2035
2036        assert_eq!(
2037            worker.source_repository(),
2038            Some("https://github.com/shiroksh/rustferry")
2039        );
2040        assert_eq!(worker.source_revision(), Some(WORKER_REVISION));
2041        assert_eq!(worker.version(), "0.1.0");
2042        assert!(worker.is_source_build());
2043    }
2044
2045    #[test]
2046    fn public_source_repository_normalizes_and_rejects_ambiguous_inputs() {
2047        let repository =
2048            PublicSourceRepository::new("https://github.com/Public-Org/Public-App.git/").unwrap();
2049
2050        assert_eq!(repository.url(), "https://github.com/public-org/public-app");
2051        assert_eq!(repository.slug(), "public-org/public-app");
2052
2053        for rejected in [
2054            "https://example.com/public-org/public-app",
2055            "https://github.com/public-org/public-app/extra",
2056            "https://github.com/public-org/public-app?private=true",
2057            "git@github.com:public-org/public-app.git",
2058            "${{ github.repository }}",
2059        ] {
2060            assert!(
2061                PublicSourceRepository::new(rejected).is_err(),
2062                "accepted {rejected}"
2063            );
2064        }
2065    }
2066
2067    #[test]
2068    fn split_repository_workflow_keeps_execution_implicit_and_source_explicit() {
2069        let workflow = generate_workflow(&fixture_split_config());
2070        let yaml = workflow.yaml();
2071        let dispatch = yaml
2072            .split_once("      - name: Checkout immutable dispatch request\n")
2073            .unwrap()
2074            .1
2075            .split_once("      - name: Checkout trusted source policy ref\n")
2076            .unwrap()
2077            .0;
2078        let trusted = yaml
2079            .split_once("      - name: Checkout trusted source policy ref\n")
2080            .unwrap()
2081            .1
2082            .split_once("      - name: Validate dispatch request and workflow revision\n")
2083            .unwrap()
2084            .0;
2085        let requested = yaml
2086            .split_once("      - name: Checkout exact requested source revision\n")
2087            .unwrap()
2088            .1
2089            .split_once("      - name: Restore source-revision-scoped Cargo cache\n")
2090            .unwrap()
2091            .0;
2092
2093        assert!(
2094            yaml.contains(
2095                "RUSTFERRY_SOURCE_REPOSITORY: 'https://github.com/public-org/public-app'"
2096            )
2097        );
2098        assert!(yaml.contains("--source-repository \"$RUSTFERRY_SOURCE_REPOSITORY\""));
2099        assert!(!yaml.contains("private-org/private-execution"));
2100
2101        assert!(dispatch.contains("ref: '${{ github.sha }}'"));
2102        assert!(dispatch.contains("persist-credentials: false"));
2103        assert!(!dispatch.contains("repository:"));
2104
2105        for source_checkout in [trusted, requested] {
2106            assert!(source_checkout.contains("repository: 'public-org/public-app'"));
2107            assert!(source_checkout.contains("persist-credentials: false"));
2108        }
2109        assert!(trusted.contains("ref: '${{ env.RUSTFERRY_TRUSTED_SOURCE_REF }}'"));
2110        assert!(requested.contains("ref: '${{ steps.request.outputs.source_revision }}'"));
2111    }
2112
2113    #[test]
2114    fn same_repository_mode_keeps_dispatch_implicit_and_source_exact() {
2115        let workflow = generate_workflow(&fixture_config());
2116        let yaml = workflow.yaml();
2117        let dispatch = yaml
2118            .split_once("      - name: Checkout immutable dispatch request\n")
2119            .unwrap()
2120            .1
2121            .split_once("      - name: Checkout trusted source policy ref\n")
2122            .unwrap()
2123            .0;
2124
2125        assert!(!dispatch.contains("repository:"));
2126        assert_eq!(yaml.matches("repository: 'shiroksh/rustferry'").count(), 2);
2127        assert!(yaml.contains("ref: '${{ env.RUSTFERRY_TRUSTED_SOURCE_REF }}'"));
2128        assert!(yaml.contains("ref: '${{ steps.request.outputs.source_revision }}'"));
2129    }
2130
2131    #[test]
2132    fn source_distribution_rejects_mutable_or_ambiguous_inputs() {
2133        for repository in [
2134            "https://example.com/owner/repository",
2135            "https://github.com/owner/repository/extra",
2136            "https://github.com/owner/repository?ref=main",
2137            "git@github.com:owner/repository.git",
2138        ] {
2139            assert!(
2140                WorkerDistribution::from_source(repository, WORKER_REVISION, "0.1.0").is_err(),
2141                "accepted {repository}"
2142            );
2143        }
2144        for revision in [
2145            "main",
2146            "0123456789abcdef0123456789abcdef0123456",
2147            "0123456789abcdef0123456789abcdef012345678",
2148            "0123456789ABCDEF0123456789ABCDEF01234567",
2149        ] {
2150            assert!(
2151                WorkerDistribution::from_source("owner/repository", revision, "0.1.0").is_err(),
2152                "accepted {revision}"
2153            );
2154        }
2155        for version in ["v0.1.0", "0.1", "01.2.3", "0.1.0_"] {
2156            assert!(
2157                WorkerDistribution::from_source("owner/repository", WORKER_REVISION, version)
2158                    .is_err(),
2159                "accepted {version}"
2160            );
2161        }
2162    }
2163
2164    #[test]
2165    fn source_built_worker_is_isolated_and_hash_propagated() {
2166        let workflow = generate_workflow(&fixture_source_config());
2167        let (_, worker_and_later) = workflow.yaml().split_once("\n  worker:\n").unwrap();
2168        let (worker, compile_and_sign) = worker_and_later.split_once("\n  compile:\n").unwrap();
2169        let (compile, sign) = compile_and_sign.split_once("\n  sign:\n").unwrap();
2170
2171        assert!(!worker.contains("secrets."));
2172        assert!(!worker.contains("environment:"));
2173        assert!(worker.contains("permissions:\n      contents: read"));
2174        assert!(worker.contains("repository: 'shiroksh/rustferry'"));
2175        assert!(worker.contains(&format!("ref: '{WORKER_REVISION}'")));
2176        assert!(worker.contains("cargo build --locked --release"));
2177        assert!(worker.contains("--package rustferry-worker-macos"));
2178        assert!(worker.contains("--bin ferry-worker-macos"));
2179        assert!(worker.contains("version --expect \"$RUSTFERRY_WORKER_VERSION\""));
2180        assert!(worker.contains("printf 'sha256=%s\\n' \"$actual\""));
2181        assert!(worker.contains("Upload immutable trusted worker artifact"));
2182
2183        assert!(!compile.contains("secrets."));
2184        assert!(!compile.contains("environment:"));
2185        assert!(compile.contains("needs: worker"));
2186        assert!(compile.contains("actions: read"));
2187        assert!(compile.contains("Download exact trusted worker artifact"));
2188        assert!(compile.contains("needs.worker.outputs.sha256"));
2189        assert!(!compile.contains("cargo build --locked --release"));
2190        assert!(!compile.contains("Upload immutable trusted worker artifact"));
2191
2192        assert!(sign.contains("      - worker\n      - compile"));
2193        assert!(sign.contains("Download exact trusted worker artifact"));
2194        assert!(sign.contains("needs.worker.outputs.sha256"));
2195        assert!(!sign.contains("actions/checkout@"));
2196        assert!(!sign.contains("cargo build"));
2197        assert!(sign.contains(
2198            "rustup toolchain install \"$RUSTUP_TOOLCHAIN\" --profile minimal --no-self-update"
2199        ));
2200        assert!(
2201            sign.contains("rustup target add --toolchain \"$RUSTUP_TOOLCHAIN\" aarch64-apple-ios")
2202        );
2203
2204        assert_eq!(
2205            workflow
2206                .yaml()
2207                .matches("rustferry-worker-${{ github.run_id }}-${{ github.run_attempt }}")
2208                .count(),
2209            3
2210        );
2211        assert!(!workflow.yaml().contains("RUSTFERRY_WORKER_URL:"));
2212        assert!(!workflow.yaml().contains("RUSTFERRY_WORKER_SHA256:"));
2213    }
2214
2215    #[test]
2216    fn source_and_application_builds_use_exact_rust_toolchain() {
2217        let workflow = generate_workflow(&fixture_source_config());
2218        let yaml = workflow.yaml();
2219        let (_, worker_and_later) = yaml.split_once("\n  worker:\n").unwrap();
2220        let (worker, compile_and_sign) = worker_and_later.split_once("\n  compile:\n").unwrap();
2221        let (compile, sign) = compile_and_sign.split_once("\n  sign:\n").unwrap();
2222
2223        assert!(yaml.contains("RUSTUP_TOOLCHAIN: '1.92.0'"));
2224        assert_eq!(
2225            yaml.matches(
2226                "rustup toolchain install \"$RUSTUP_TOOLCHAIN\" --profile minimal --no-self-update"
2227            )
2228            .count(),
2229            3
2230        );
2231        assert!(worker.contains("rustc 1.92.0"));
2232        assert!(!worker.contains("rustup target add"));
2233        assert!(
2234            compile
2235                .contains("rustup target add --toolchain \"$RUSTUP_TOOLCHAIN\" aarch64-apple-ios")
2236        );
2237        assert!(compile.contains("rustc 1.92.0"));
2238        assert!(sign.contains("rustc 1.92.0"));
2239        assert!(
2240            sign.contains("rustup target add --toolchain \"$RUSTUP_TOOLCHAIN\" aarch64-apple-ios")
2241        );
2242    }
2243
2244    #[test]
2245    fn source_mode_actions_are_first_party_and_immutably_pinned() {
2246        let workflow = generate_workflow(&fixture_source_config());
2247        let references = workflow
2248            .yaml()
2249            .lines()
2250            .map(str::trim)
2251            .filter(|line| line.starts_with("uses:"))
2252            .collect::<Vec<_>>();
2253
2254        assert_eq!(references.len(), 11);
2255        for line in references {
2256            let reference = line
2257                .split_ascii_whitespace()
2258                .nth(1)
2259                .unwrap()
2260                .split('#')
2261                .next()
2262                .unwrap();
2263            let (action, revision) = reference.split_once('@').unwrap();
2264            assert!(action.starts_with("actions/"));
2265            assert_eq!(revision.len(), 40);
2266            assert!(revision.bytes().all(|byte| byte.is_ascii_hexdigit()));
2267        }
2268    }
2269
2270    #[test]
2271    fn prebuilt_mode_retains_url_sha_and_exact_device_toolchain_setup() {
2272        let config = fixture_config();
2273        let workflow = generate_workflow(&config);
2274        let yaml = workflow.yaml();
2275        let (compile, _) = yaml.split_once("\n  sign:\n").unwrap();
2276
2277        assert!(!config.worker().is_source_build());
2278        assert_eq!(
2279            config.worker().download_url(),
2280            "https://github.com/ShiroKSH/rustferry/releases/download/v0.1.0/ferry-worker-macos"
2281        );
2282        assert_eq!(config.worker().sha256(), WORKER_SHA256);
2283        assert!(yaml.contains(&format!("RUSTFERRY_WORKER_SHA256: '{WORKER_SHA256}'")));
2284        assert!(yaml.contains("RUSTFERRY_WORKER_URL: 'https://github.com/"));
2285        assert!(yaml.contains("curl --fail --location --proto '=https'"));
2286        assert!(!yaml.contains("\n  worker:\n"));
2287        assert_eq!(
2288            yaml.matches(
2289                "rustup toolchain install \"$RUSTUP_TOOLCHAIN\" --profile minimal --no-self-update"
2290            )
2291            .count(),
2292            2
2293        );
2294        assert!(
2295            compile
2296                .contains("rustup target add --toolchain \"$RUSTUP_TOOLCHAIN\" aarch64-apple-ios")
2297        );
2298    }
2299
2300    #[test]
2301    fn source_worker_job_uses_bounded_timeout_and_retention() {
2302        let workflow = generate_workflow(&fixture_source_config());
2303        let (_, worker_and_later) = workflow.yaml().split_once("\n  worker:\n").unwrap();
2304        let (worker, _) = worker_and_later.split_once("\n  compile:\n").unwrap();
2305
2306        assert!(worker.contains("timeout-minutes: 30"));
2307        assert!(worker.contains("retention-days: 3"));
2308        assert_eq!(workflow.yaml().matches("retention-days: 3").count(), 3);
2309    }
2310
2311    #[test]
2312    fn compile_phase_has_no_signing_secret_or_environment() {
2313        let workflow = generate_workflow(&fixture_config());
2314        let (compile, sign) = workflow.yaml().split_once("\n  sign:\n").unwrap();
2315
2316        assert!(!compile.contains("secrets."));
2317        assert!(!compile.contains("RUSTFERRY_GOAL3_IOS_"));
2318        assert!(!compile.contains("environment:"));
2319        assert!(sign.contains("environment: 'rustferry-goal3-signing'"));
2320        assert_eq!(sign.matches("${{ secrets.").count(), 3);
2321    }
2322
2323    #[test]
2324    fn modern_workflows_publish_only_the_exact_public_target_graph_digest() {
2325        for (config, expected_digest) in [
2326            (
2327                fixture_modern_app_only_config(),
2328                "144f18f553557a96d6a39105b3aa0928c54bf075a5e32fef077a2eef59b37aff",
2329            ),
2330            (
2331                fixture_multi_profile_config(),
2332                "112e2935c240c978a4e356b5a0e7b3ffc0068ba249807666e8d1662bd366a691",
2333            ),
2334        ] {
2335            assert_eq!(
2336                config.secret_names().target_graph_sha256(),
2337                Some(expected_digest)
2338            );
2339            let workflow = generate_workflow(&config);
2340            let (compile, _) = workflow.yaml().split_once("\n  sign:\n").unwrap();
2341
2342            assert!(workflow.yaml().contains(&format!(
2343                "RUSTFERRY_SIGNING_TARGET_GRAPH_SHA256: '{expected_digest}'"
2344            )));
2345            assert_eq!(
2346                workflow
2347                    .yaml()
2348                    .matches("--expected-signing-target-graph-sha256")
2349                    .count(),
2350                1
2351            );
2352            assert!(compile.contains(concat!(
2353                "--expected-signing-target-graph-sha256 ",
2354                "\"$RUSTFERRY_SIGNING_TARGET_GRAPH_SHA256\""
2355            )));
2356            assert!(!compile.contains("secrets."));
2357            assert!(
2358                !workflow
2359                    .yaml()
2360                    .contains("secrets.RUSTFERRY_SIGNING_TARGET_GRAPH_SHA256")
2361            );
2362        }
2363
2364        let legacy = generate_workflow(&fixture_config());
2365        assert!(
2366            !legacy
2367                .yaml()
2368                .contains("RUSTFERRY_SIGNING_TARGET_GRAPH_SHA256")
2369        );
2370        assert!(
2371            !legacy
2372                .yaml()
2373                .contains("--expected-signing-target-graph-sha256")
2374        );
2375    }
2376
2377    #[test]
2378    fn compile_cleanup_fails_closed_when_the_worker_disappears() {
2379        let workflow = generate_workflow(&fixture_source_config());
2380        let compile = workflow
2381            .yaml()
2382            .split_once("\n  compile:\n")
2383            .unwrap()
2384            .1
2385            .split_once("\n  sign:\n")
2386            .unwrap()
2387            .0;
2388
2389        assert!(compile.contains("if: '${{ always() }}'"));
2390        assert!(compile.contains("elif [[ -d \"$RUNNER_TEMP/rustferry-compile-job\" ]]"));
2391        assert!(compile.contains("Compile cleanup could not run"));
2392        assert!(compile.contains("exit 1"));
2393    }
2394
2395    #[test]
2396    fn signing_phase_never_checks_out_or_compiles_source() {
2397        let workflow = generate_workflow(&fixture_config());
2398        let sign = workflow.yaml().split_once("\n  sign:\n").unwrap().1;
2399
2400        for forbidden in [
2401            "actions/checkout@",
2402            "cargo ",
2403            "xcodebuild ",
2404            "codesign ",
2405            "security ",
2406            "pull_request",
2407        ] {
2408            assert!(!sign.contains(forbidden), "found {forbidden}");
2409        }
2410        assert!(sign.contains("Verify sealed handoff digest before signing"));
2411        assert!(sign.contains(
2412            "rustup toolchain install \"$RUSTUP_TOOLCHAIN\" --profile minimal --no-self-update"
2413        ));
2414        assert!(
2415            sign.contains("rustup target add --toolchain \"$RUSTUP_TOOLCHAIN\" aarch64-apple-ios")
2416        );
2417        assert!(sign.contains("if: '${{ always() }}'"));
2418        assert!(sign.contains("--require-complete"));
2419    }
2420
2421    #[test]
2422    fn signing_job_allowlists_every_supported_request_derived_artifact() {
2423        let workflow = generate_workflow(&fixture_config());
2424        let sign = workflow.yaml().split_once("\n  sign:\n").unwrap().1;
2425
2426        for file_name in [
2427            "application-development.ipa",
2428            "artifact-manifest.json",
2429            "signing-report.json",
2430            "validation-report.json",
2431            "sanitized-build-log.txt",
2432            "application.app.zip",
2433            "application.xcarchive.zip",
2434            "application.dSYM.zip",
2435        ] {
2436            assert!(sign.contains(&format!(
2437                "${{{{ runner.temp }}}}/rustferry-signed/{file_name}"
2438            )));
2439        }
2440        assert_eq!(
2441            sign.matches("${{ runner.temp }}/rustferry-signed/").count(),
2442            8
2443        );
2444    }
2445
2446    #[test]
2447    fn protected_worker_receives_only_public_references_and_stdin() {
2448        let config = fixture_config();
2449        let workflow = generate_workflow(&config);
2450        let sign = workflow.yaml().split_once("\n  sign:\n").unwrap().1;
2451
2452        assert!(sign.contains("printf '%s\\0%s\\0%s'"));
2453        assert!(sign.contains("/usr/bin/env -i \\"));
2454        assert!(!sign.contains("--certificate-p12-env"));
2455        assert!(!sign.contains("--certificate-password-env"));
2456        assert!(!sign.contains("--provisioning-profile-env"));
2457        for (flag, reference) in [
2458            (
2459                "--certificate-p12-reference",
2460                config.secret_names().certificate_p12().as_str(),
2461            ),
2462            (
2463                "--certificate-password-reference",
2464                config.secret_names().certificate_password().as_str(),
2465            ),
2466            (
2467                "--provisioning-profile-reference",
2468                config.secret_names().provisioning_profile().as_str(),
2469            ),
2470        ] {
2471            assert!(sign.contains(&format!("{flag} '{reference}'")));
2472        }
2473        let worker_environment = sign.split_once("/usr/bin/env -i").unwrap().1;
2474        assert!(worker_environment.contains("\"RUSTUP_TOOLCHAIN=$RUSTUP_TOOLCHAIN\""));
2475        assert!(!worker_environment.contains("${{ secrets."));
2476        assert!(!worker_environment.contains("RUSTFERRY_SIGNING_CERTIFICATE_P12="));
2477        assert!(!worker_environment.contains("RUSTFERRY_SIGNING_CERTIFICATE_PASSWORD="));
2478        assert!(!worker_environment.contains("RUSTFERRY_SIGNING_PROVISIONING_PROFILE="));
2479    }
2480
2481    #[test]
2482    fn multi_profile_workflow_uses_static_named_v2_records_only_in_signing_job() {
2483        let config = fixture_multi_profile_config();
2484        let workflow = generate_workflow(&config);
2485        let (compile, sign) = workflow.yaml().split_once("\n  sign:\n").unwrap();
2486        let profiles = config.secret_names().profile_names().collect::<Vec<_>>();
2487
2488        assert_eq!(profiles.len(), 2);
2489        assert!(!compile.contains("secrets."));
2490        assert!(!compile.contains("RUSTFERRY_GOAL3_IOS_"));
2491        assert_eq!(sign.matches("${{ secrets.").count(), 4);
2492        assert!(!sign.contains("secrets["));
2493        assert!(sign.contains("printf 'RFSIGNV2'"));
2494        assert!(sign.contains("write_u32_be '4'"));
2495        assert!(!sign.contains("printf '%s\\0%s\\0%s'"));
2496        assert_eq!(
2497            sign.matches("--provisioning-profile-reference").count(),
2498            profiles.len()
2499        );
2500
2501        let certificate_record = sign
2502            .find("write_record 'RUSTFERRY_GOAL3_IOS_CERTIFICATE_P12'")
2503            .expect("certificate record");
2504        let password_record = sign
2505            .find("write_record 'RUSTFERRY_GOAL3_IOS_CERTIFICATE_PASSWORD'")
2506            .expect("password record");
2507        assert!(certificate_record < password_record);
2508        let mut previous_record = password_record;
2509        for (index, profile) in profiles.iter().enumerate() {
2510            let secret_expression = format!("${{{{ secrets.{} }}}}", profile.as_str());
2511            assert_eq!(sign.matches(&secret_expression).count(), 1);
2512            assert!(sign.contains(&format!(
2513                "RUSTFERRY_SIGNING_PROVISIONING_PROFILE_{}: '{}'",
2514                index + 1,
2515                secret_expression
2516            )));
2517            let record = sign
2518                .find(&format!("write_record '{}'", profile.as_str()))
2519                .expect("profile record");
2520            assert!(record > previous_record);
2521            previous_record = record;
2522            assert!(sign.contains(&format!(
2523                "--provisioning-profile-reference '{}'",
2524                profile.as_str()
2525            )));
2526        }
2527
2528        let worker_environment = sign.split_once("/usr/bin/env -i").unwrap().1;
2529        assert!(!worker_environment.contains("$RUSTFERRY_SIGNING_CERTIFICATE_P12"));
2530        assert!(!worker_environment.contains("$RUSTFERRY_SIGNING_CERTIFICATE_PASSWORD"));
2531        assert!(!worker_environment.contains("$RUSTFERRY_SIGNING_PROVISIONING_PROFILE_"));
2532    }
2533
2534    #[test]
2535    fn signing_job_requires_manual_mode_and_private_event_repository() {
2536        let yaml = generate_workflow(&fixture_config());
2537
2538        assert!(
2539            yaml.yaml()
2540                .contains("signing_mode: '${{ steps.request.outputs.signing_mode }}'")
2541        );
2542        assert!(
2543            yaml.yaml()
2544                .contains("if: needs.compile.outputs.signing_mode == 'manual_development' && github.event.repository.private == true")
2545        );
2546    }
2547
2548    #[test]
2549    fn push_uses_temporary_namespace_and_explicit_source_revision() {
2550        let workflow = generate_workflow(&fixture_config());
2551        let yaml = workflow.yaml();
2552
2553        assert!(yaml.contains("      - 'rustferry/goal3/builds/**'"));
2554        assert!(yaml.contains("--push-manifest"));
2555        assert!(yaml.contains(".rustferry/goal3/request.json"));
2556        assert!(yaml.contains("ref: '${{ steps.request.outputs.source_revision }}'"));
2557        assert_eq!(yaml.matches("ref: '${{ github.sha }}'").count(), 1);
2558        assert!(!yaml.contains("pull_request:"));
2559        assert!(!yaml.contains("pull_request_target:"));
2560    }
2561
2562    #[test]
2563    fn all_actions_are_first_party_and_immutably_pinned() {
2564        let workflow = generate_workflow(&fixture_config());
2565        let mut count = 0;
2566        for line in workflow
2567            .yaml()
2568            .lines()
2569            .map(str::trim)
2570            .filter(|line| line.starts_with("uses:"))
2571        {
2572            count += 1;
2573            let reference = line
2574                .split_ascii_whitespace()
2575                .nth(1)
2576                .unwrap()
2577                .split('#')
2578                .next()
2579                .unwrap()
2580                .trim();
2581            let (action, revision) = reference.split_once('@').unwrap();
2582            assert!(action.starts_with("actions/"));
2583            assert_eq!(revision.len(), 40);
2584            assert!(revision.bytes().all(|byte| byte.is_ascii_hexdigit()));
2585        }
2586        assert_eq!(count, 7);
2587    }
2588
2589    #[test]
2590    fn security_policy_is_explicit_and_bounded() {
2591        let workflow = generate_workflow(&fixture_config());
2592        let yaml = workflow.yaml();
2593
2594        assert!(yaml.contains("permissions: {}"));
2595        assert!(yaml.contains("timeout-minutes: 90"));
2596        assert!(yaml.contains("timeout-minutes: 30"));
2597        assert_eq!(yaml.matches("retention-days: 3").count(), 2);
2598        assert!(yaml.contains("cancel-in-progress: false"));
2599        assert!(yaml.contains("DEVELOPER_DIR: '/Applications/Xcode.app/Contents/Developer'"));
2600        assert!(!yaml.contains("macos-latest"));
2601        assert!(!yaml.contains("persist-credentials: true"));
2602    }
2603
2604    #[test]
2605    fn runner_scoped_worker_path_is_used_only_inside_job_steps() {
2606        let workflow = generate_workflow(&fixture_source_config());
2607        let yaml = workflow.yaml();
2608        let (header, _) = yaml.split_once("\njobs:\n").unwrap();
2609
2610        assert!(!header.contains("runner.temp"));
2611        assert!(!yaml.contains("RUSTFERRY_WORKER_PATH"));
2612        assert!(yaml.contains("$RUNNER_TEMP/rustferry-worker-runtime/ferry-worker-macos"));
2613    }
2614
2615    #[test]
2616    fn manual_dispatch_is_not_advertised_without_a_bound_manifest() {
2617        let yaml = generate_workflow(&fixture_config());
2618
2619        assert!(!yaml.yaml().contains("workflow_dispatch:"));
2620        assert!(yaml.yaml().contains("push:"));
2621    }
2622
2623    #[test]
2624    fn rejects_yaml_shell_expression_and_ref_injection() {
2625        assert!(WorkflowFileName::new("good.yml\npull_request_target:").is_err());
2626        assert!(ProtectedEnvironment::new("prod'\nsecrets: inherit").is_err());
2627        assert!(SecretName::new("CERTIFICATE }}; echo owned").is_err());
2628        assert!(TrustedSourceRef::new("refs/pull/1/merge").is_err());
2629        assert!(TrustedSourceRef::new("refs/heads/main/**").is_err());
2630        assert!(TemporaryBranchNamespace::new("rustferry/jobs/**").is_err());
2631        assert!(DeveloperDirectory::new("/Applications/Xcode.app/../Secrets").is_err());
2632        assert!(
2633            WorkerDistribution::new("https://example.com/worker", WORKER_SHA256, "0.1.0").is_err()
2634        );
2635    }
2636
2637    #[test]
2638    fn rejects_duplicate_secrets_and_overlapping_refs() {
2639        let repeated = SecretName::new("RUSTFERRY_CERTIFICATE").unwrap();
2640        assert_eq!(
2641            SigningSecretNames::new(
2642                repeated.clone(),
2643                repeated,
2644                SecretName::new("RUSTFERRY_PROFILE").unwrap(),
2645            ),
2646            Err(WorkflowConfigError::DuplicateSecretName)
2647        );
2648
2649        let config = WorkflowConfig::new(
2650            WorkflowFileName::new("iphone.yml").unwrap(),
2651            ProtectedEnvironment::new("signing").unwrap(),
2652            SigningSecretNames::goal3_defaults(),
2653            WorkerDistribution::new(
2654                "https://github.com/ShiroKSH/rustferry/releases/download/v0.1.0/ferry-worker-macos",
2655                WORKER_SHA256,
2656                "0.1.0",
2657            )
2658            .unwrap(),
2659            fixture_public_source(),
2660            TrustedSourceRef::new("refs/heads/rustferry/goal3/builds/forbidden").unwrap(),
2661            TemporaryBranchNamespace::new("rustferry/goal3/builds").unwrap(),
2662        );
2663        assert_eq!(config, Err(WorkflowConfigError::RefNamespaceOverlap));
2664
2665        let sibling = WorkflowConfig::new(
2666            WorkflowFileName::new("iphone.yml").unwrap(),
2667            ProtectedEnvironment::new("signing").unwrap(),
2668            SigningSecretNames::goal3_defaults(),
2669            WorkerDistribution::new(
2670                "https://github.com/ShiroKSH/rustferry/releases/download/v0.1.0/ferry-worker-macos",
2671                WORKER_SHA256,
2672                "0.1.0",
2673            )
2674            .unwrap(),
2675            fixture_public_source(),
2676            TrustedSourceRef::new("refs/heads/rustferry/goal3/builds-next").unwrap(),
2677            TemporaryBranchNamespace::new("rustferry/goal3/builds").unwrap(),
2678        );
2679        assert!(sibling.is_ok());
2680    }
2681
2682    #[test]
2683    fn target_profile_secret_names_are_static_deterministic_and_exact() {
2684        let targets = vec![
2685            signing_target(
2686                "RuntimeBridge",
2687                "org.rustferry.runtime-bridge",
2688                SigningTargetKind::Framework,
2689            ),
2690            signing_target(
2691                "WeatherWidget",
2692                "com.example.weather.widget",
2693                SigningTargetKind::Extension,
2694            ),
2695            signing_target(
2696                "Weather",
2697                "com.example.weather",
2698                SigningTargetKind::Application,
2699            ),
2700            signing_target(
2701                "LiveActivity",
2702                "com.example.weather.liveactivity",
2703                SigningTargetKind::Extension,
2704            ),
2705        ];
2706        let names = SigningSecretNames::for_targets(&targets).expect("profile names");
2707        let reordered_targets = targets.iter().rev().cloned().collect::<Vec<_>>();
2708        let reordered =
2709            SigningSecretNames::for_targets(&reordered_targets).expect("reordered profile names");
2710
2711        assert_eq!(names, reordered);
2712        assert!(names.matches_target_graph(&reordered_targets));
2713        assert_eq!(names.profile_names().count(), MAX_SIGNING_PROFILES);
2714        assert_eq!(
2715            names.profile_for_target("Weather").map(SecretName::as_str),
2716            Some(GOAL3_APPLICATION_PROFILE_SECRET)
2717        );
2718        assert_eq!(
2719            names
2720                .profile_for_target("WeatherWidget")
2721                .map(SecretName::as_str),
2722            Some("RUSTFERRY_GOAL3_IOS_PROFILE_3FAD627B2731B5773D3929E36B01CA69")
2723        );
2724        assert!(names.profile_for_target("RuntimeBridge").is_none());
2725        assert_eq!(names.all_names().collect::<BTreeSet<_>>().len(), 5);
2726
2727        let mut app_bundle_drift = targets.clone();
2728        app_bundle_drift
2729            .iter_mut()
2730            .find(|target| target.name == "Weather")
2731            .expect("application target")
2732            .bundle_identifier =
2733            BundleIdentifier::new("com.example.weather.renamed").expect("drifted app bundle");
2734        assert!(!names.matches_target_graph(&app_bundle_drift));
2735
2736        let mut extension_bundle_drift = targets.clone();
2737        extension_bundle_drift
2738            .iter_mut()
2739            .find(|target| target.name == "WeatherWidget")
2740            .expect("extension target")
2741            .bundle_identifier = BundleIdentifier::new("com.example.weather.renamed-widget")
2742            .expect("drifted extension bundle");
2743        assert!(!names.matches_target_graph(&extension_bundle_drift));
2744
2745        let mut extension_name_drift = targets.clone();
2746        extension_name_drift
2747            .iter_mut()
2748            .find(|target| target.name == "WeatherWidget")
2749            .expect("extension target")
2750            .name = "RenamedWidget".to_owned();
2751        assert!(!names.matches_target_graph(&extension_name_drift));
2752
2753        let omitted_framework = targets
2754            .iter()
2755            .filter(|target| target.name != "RuntimeBridge")
2756            .cloned()
2757            .collect::<Vec<_>>();
2758        assert!(!names.matches_target_graph(&omitted_framework));
2759
2760        let mut extra_framework = targets.clone();
2761        extra_framework.push(signing_target(
2762            "SupportKit",
2763            "org.rustferry.support-kit",
2764            SigningTargetKind::Framework,
2765        ));
2766        assert!(!names.matches_target_graph(&extra_framework));
2767    }
2768
2769    #[test]
2770    fn target_profile_secret_names_reject_ambiguous_or_oversized_graphs() {
2771        let app = signing_target("App", "com.example.app", SigningTargetKind::Application);
2772        let extension = signing_target(
2773            "Widget",
2774            "com.example.app.widget",
2775            SigningTargetKind::Extension,
2776        );
2777        let duplicate_name = signing_target(
2778            "Widget",
2779            "com.example.app.liveactivity",
2780            SigningTargetKind::Extension,
2781        );
2782        assert_eq!(
2783            SigningSecretNames::for_targets(std::slice::from_ref(&extension)),
2784            Err(WorkflowConfigError::InvalidSigningTargets)
2785        );
2786        assert_eq!(
2787            SigningSecretNames::for_targets(&[app.clone(), app.clone()]),
2788            Err(WorkflowConfigError::InvalidSigningTargets)
2789        );
2790        assert_eq!(
2791            SigningSecretNames::for_targets(&[app.clone(), extension.clone(), duplicate_name]),
2792            Err(WorkflowConfigError::InvalidSigningTargets)
2793        );
2794        assert_eq!(
2795            SigningSecretNames::for_targets(&[
2796                app,
2797                extension,
2798                signing_target(
2799                    "LiveActivity",
2800                    "com.example.app.liveactivity",
2801                    SigningTargetKind::Extension,
2802                ),
2803                signing_target(
2804                    "ShareExtension",
2805                    "com.example.app.share",
2806                    SigningTargetKind::Extension,
2807                ),
2808            ]),
2809            Err(WorkflowConfigError::TooManySigningProfiles {
2810                maximum: MAX_SIGNING_PROFILES,
2811            })
2812        );
2813    }
2814
2815    #[test]
2816    fn temporary_ref_prefix_has_one_operation_separator() {
2817        let namespace = TemporaryBranchNamespace::new("rustferry/goal3/builds").unwrap();
2818
2819        assert_eq!(
2820            format!("{}/operation-1", namespace.full_ref_prefix()),
2821            "refs/heads/rustferry/goal3/builds/operation-1"
2822        );
2823    }
2824
2825    #[test]
2826    fn errors_do_not_echo_rejected_values() {
2827        let canary = "CERTIFICATE }} ${{ secrets.VALUE }}";
2828        let error = SecretName::new(canary).unwrap_err().to_string();
2829
2830        assert!(!error.contains(canary));
2831        assert!(!error.contains("secrets.VALUE"));
2832    }
2833
2834    fn fnv1a64(bytes: &[u8]) -> u64 {
2835        bytes.iter().fold(0xcbf2_9ce4_8422_2325, |hash, byte| {
2836            (hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3)
2837        })
2838    }
2839}