1use 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
27pub const MAX_SIGNING_PROFILES: usize = 3;
30
31#[derive(Clone, Copy, Debug, Eq, PartialEq)]
35pub enum WorkflowConfigError {
36 Empty {
38 field: &'static str,
40 },
41 TooLong {
43 field: &'static str,
45 maximum: usize,
47 },
48 InvalidCharacter {
50 field: &'static str,
52 index: usize,
54 },
55 InvalidFormat {
57 field: &'static str,
59 },
60 Reserved {
62 field: &'static str,
64 },
65 DuplicateSecretName,
67 InvalidSigningTargets,
69 TooManySigningProfiles {
71 maximum: usize,
73 },
74 RefNamespaceOverlap,
76 PolicyOutOfRange {
78 field: &'static str,
80 minimum: u16,
82 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#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
132pub struct WorkflowFileName(String);
133
134impl WorkflowFileName {
135 #[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 pub fn as_str(&self) -> &str {
161 &self.0
162 }
163
164 pub fn repository_path(&self) -> String {
166 format!(".github/workflows/{}", self.0)
167 }
168}
169
170#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
172pub struct ProtectedEnvironment(String);
173
174impl ProtectedEnvironment {
175 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 pub fn as_str(&self) -> &str {
194 &self.0
195 }
196}
197
198#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
200pub struct SecretName(String);
201
202impl SecretName {
203 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 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#[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 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 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 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 pub fn certificate_p12(&self) -> &SecretName {
369 &self.certificate_p12
370 }
371
372 pub fn certificate_password(&self) -> &SecretName {
374 &self.certificate_password
375 }
376
377 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 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 pub fn profile_names(&self) -> impl ExactSizeIterator<Item = &SecretName> {
414 self.provisioning_profiles
415 .iter()
416 .map(|profile| &profile.name)
417 }
418
419 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 pub fn target_graph_sha256(&self) -> Option<&str> {
430 self.target_graph_sha256.as_deref()
431 }
432
433 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#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
510pub struct PublicSourceRepository {
511 url: String,
512 slug: String,
513}
514
515impl PublicSourceRepository {
516 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 pub fn url(&self) -> &str {
530 &self.url
531 }
532
533 pub fn slug(&self) -> &str {
535 &self.slug
536 }
537}
538
539#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
541pub struct TrustedSourceRef(String);
542
543impl TrustedSourceRef {
544 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 pub fn as_str(&self) -> &str {
565 &self.0
566 }
567}
568
569#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
571pub struct TemporaryBranchNamespace(String);
572
573impl TemporaryBranchNamespace {
574 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 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#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
611pub struct DeveloperDirectory(String);
612
613impl DeveloperDirectory {
614 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 pub fn github_default() -> Self {
644 Self::new(DEFAULT_DEVELOPER_DIRECTORY).expect("constant developer directory is valid")
645 }
646
647 pub fn as_str(&self) -> &str {
649 &self.0
650 }
651}
652
653#[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 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 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 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 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 pub fn is_source_build(&self) -> bool {
776 matches!(&self.provisioning, WorkerProvisioning::SourceBuild { .. })
777 }
778
779 pub fn version(&self) -> &str {
781 &self.version
782 }
783
784 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 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#[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#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
822pub enum WorkflowRunTrigger {
823 #[default]
825 Push,
826 WorkflowDispatch,
828}
829
830impl WorkflowLimits {
831 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 pub fn secure_defaults() -> Self {
858 Self::new(90, 30, 3).expect("constant workflow limits are valid")
859 }
860
861 pub const fn compile_timeout_minutes(self) -> u16 {
863 self.compile_timeout_minutes
864 }
865
866 pub const fn signing_timeout_minutes(self) -> u16 {
868 self.signing_timeout_minutes
869 }
870
871 pub const fn retention_days(self) -> u16 {
873 self.retention_days
874 }
875}
876
877#[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 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 #[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 #[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 #[must_use]
972 pub fn with_limits(mut self, limits: WorkflowLimits) -> Self {
973 self.limits = limits;
974 self
975 }
976
977 pub fn filename(&self) -> &WorkflowFileName {
979 &self.filename
980 }
981
982 pub fn protected_environment(&self) -> &ProtectedEnvironment {
984 &self.protected_environment
985 }
986
987 pub fn secret_names(&self) -> &SigningSecretNames {
989 &self.secret_names
990 }
991
992 pub fn worker(&self) -> &WorkerDistribution {
994 &self.worker
995 }
996
997 pub fn public_source_repository(&self) -> &PublicSourceRepository {
999 &self.public_source_repository
1000 }
1001
1002 pub fn trusted_source_ref(&self) -> &TrustedSourceRef {
1004 &self.trusted_source_ref
1005 }
1006
1007 pub fn temporary_branch_namespace(&self) -> &TemporaryBranchNamespace {
1009 &self.temporary_branch_namespace
1010 }
1011
1012 pub fn developer_directory(&self) -> &DeveloperDirectory {
1014 &self.developer_directory
1015 }
1016
1017 pub const fn limits(&self) -> WorkflowLimits {
1019 self.limits
1020 }
1021
1022 pub const fn run_trigger(&self) -> WorkflowRunTrigger {
1024 self.run_trigger
1025 }
1026}
1027
1028#[derive(Clone, Debug, Eq, PartialEq)]
1030pub struct GeneratedWorkflow {
1031 path: String,
1032 yaml: String,
1033}
1034
1035impl GeneratedWorkflow {
1036 pub fn path(&self) -> &str {
1038 &self.path
1039 }
1040
1041 pub fn yaml(&self) -> &str {
1043 &self.yaml
1044 }
1045
1046 pub fn into_parts(self) -> (String, String) {
1048 (self.path, self.yaml)
1049 }
1050}
1051
1052pub 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 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}