1use std::{
9 collections::{BTreeMap, BTreeSet},
10 error::Error,
11 fmt, fs,
12 fs::File,
13 io::{self, Read, Write},
14};
15
16#[cfg(windows)]
17use std::cell::Cell;
18#[cfg(not(windows))]
19use std::fs::OpenOptions;
20#[cfg(unix)]
21use std::os::unix::fs::OpenOptionsExt as _;
22#[cfg(windows)]
23use std::os::windows::io::AsHandle as _;
24
25use camino::{Utf8Path, Utf8PathBuf};
26use goblin::mach::{Mach, SingleArch, load_command::CommandVariant};
27#[cfg(windows)]
28use rustferry_core::windows_private_directory::{
29 PrivateDirectoryCleanupStatus, PrivateDirectoryError, PrivateDirectoryErrorKind,
30 PrivateFileLinkState, create_private_file as create_windows_private_file,
31 open_private_directory as open_windows_private_directory,
32 open_private_file as open_windows_private_file,
33 open_private_file_for_removal as open_windows_private_file_for_removal,
34 open_private_file_for_removal_in_state as open_windows_private_file_for_removal_in_state,
35 remove_private_file_handle as remove_windows_private_file_handle,
36 remove_private_file_handle_in_state as remove_windows_private_file_handle_in_state,
37 verify_private_file_handle as verify_windows_private_file_handle,
38 verify_private_file_handle_in_state as verify_windows_private_file_handle_in_state,
39};
40use rustferry_remote::{
41 ARTIFACT_MANIFEST_SCHEMA_VERSION, ArtifactKind, ArtifactManifest, ArtifactRecord, BuildProfile,
42 COMPILE_PHASE_EVIDENCE_SCHEMA_VERSION, CleanupStatus, CompilePhaseEvidence,
43 IOS_DEVICE_RUST_TARGET, IOS_DEVICE_SDK, IosDeviceBuildRequest, IpaExpectation, IpaInspection,
44 PROTECTED_SIGNING_SANITIZED_LOG_V1, SEALED_UNSIGNED_ARCHIVE_SCHEMA_VERSION, SigningMode,
45 SigningStatus, SigningTargetKind, SourceMode, UnsignedNestedBundleKind, ValidationLevel,
46 canonical_request_sha256, inspect_ipa, verify_downloaded_file,
47};
48use same_file::Handle as FileIdentityHandle;
49use serde::Deserialize;
50use sha2::{Digest, Sha256};
51use unicode_normalization::UnicodeNormalization;
52use zip::{CompressionMethod, ZipArchive, read::ZipFile};
53
54use crate::strict_json;
55
56pub const DEVELOPMENT_IPA_NAME: &str = "application-development.ipa";
58pub const ARTIFACT_MANIFEST_NAME: &str = "artifact-manifest.json";
60pub const SIGNING_REPORT_NAME: &str = "signing-report.json";
62pub const VALIDATION_REPORT_NAME: &str = "validation-report.json";
64pub const SANITIZED_BUILD_LOG_NAME: &str = "sanitized-build-log.txt";
66pub const APP_BUNDLE_ARCHIVE_NAME: &str = "application.app.zip";
68pub const SIGNED_XCARCHIVE_NAME: &str = "application.xcarchive.zip";
70pub const DSYM_ARCHIVE_NAME: &str = "application.dSYM.zip";
72
73const BASE_ENTRY_COUNT: usize = 5;
74const MAX_IPA_BYTES: u64 = 2 * 1024 * 1024 * 1024 + 16 * 1024 * 1024;
75const MAX_APP_BUNDLE_ARCHIVE_BYTES: u64 = MAX_IPA_BYTES;
76const MAX_XCARCHIVE_BYTES: u64 = 2 * 1024 * 1024 * 1024 + 32 * 1024 * 1024;
77const MAX_DSYM_ARCHIVE_BYTES: u64 = 2 * 1024 * 1024 * 1024 + 32 * 1024 * 1024;
78const MAX_MANIFEST_BYTES: u64 = 1024 * 1024;
79const MAX_REPORT_BYTES: u64 = 4 * 1024 * 1024;
80const MAX_SANITIZED_LOG_BYTES: u64 = 4 * 1024 * 1024;
81const MAX_TOTAL_EXPANDED_BYTES: u64 = MAX_IPA_BYTES
82 + MAX_APP_BUNDLE_ARCHIVE_BYTES
83 + MAX_XCARCHIVE_BYTES
84 + MAX_DSYM_ARCHIVE_BYTES
85 + MAX_MANIFEST_BYTES
86 + 2 * MAX_REPORT_BYTES
87 + MAX_SANITIZED_LOG_BYTES;
88const MAX_ARCHIVE_BYTES: u64 = MAX_TOTAL_EXPANDED_BYTES + 64 * 1024 * 1024;
89const MAX_COMPRESSION_RATIO: u64 = 200;
90const MAX_INNER_ENTRY_COUNT: usize = 100_001;
91const MAX_INNER_PATH_BYTES: usize = 4_096;
92const MAX_INNER_TREE_DEPTH: usize = 128;
93const MAX_INNER_ENTRY_BYTES: u64 = 512 * 1024 * 1024;
94const MAX_INNER_TOTAL_BYTES: u64 = 2 * 1024 * 1024 * 1024;
95const MAX_CAPTURED_MACHO_BYTES: u64 = 256 * 1024 * 1024;
96const SIGNED_TREE_SHA256_DOMAIN: &[u8] = b"rustferry-signed-tree-v1\0";
97const MAX_PUBLIC_TEXT_BYTES: usize = 255;
98const MAX_SIGNED_BUNDLES: usize = 512;
99const MAX_CODE_OBJECTS: usize = 512;
100const PUBLIC_REPORT_SCHEMA_VERSION: u32 = 1;
101
102#[derive(Clone, Debug, PartialEq)]
105pub struct GithubArtifactExpectation {
106 job_id: String,
107 provider: String,
108 request: IosDeviceBuildRequest,
109 compile: CompilePhaseEvidence,
110}
111
112impl GithubArtifactExpectation {
113 pub fn new(
119 job_id: impl Into<String>,
120 provider: impl Into<String>,
121 request: IosDeviceBuildRequest,
122 compile: CompilePhaseEvidence,
123 ) -> Result<Self, GithubArtifactError> {
124 request
125 .validate()
126 .map_err(|_| GithubArtifactError::InvalidExpectation)?;
127 let expectation = Self {
128 job_id: job_id.into(),
129 provider: provider.into(),
130 request,
131 compile,
132 };
133 let request_sha256 = canonical_request_sha256(&expectation.request)
134 .map_err(|_| GithubArtifactError::InvalidExpectation)?;
135 let archive_expectation = &expectation.compile.sealed_archive.expectation;
136 let product = &expectation.request.product;
137 if !is_safe_public_identifier(&expectation.job_id)
138 || !is_safe_public_identifier(&expectation.provider)
139 || expectation.compile.schema_version != COMPILE_PHASE_EVIDENCE_SCHEMA_VERSION
140 || expectation.compile.sealed_archive.schema_version
141 != SEALED_UNSIGNED_ARCHIVE_SCHEMA_VERSION
142 || expectation.compile.job_id != expectation.job_id
143 || expectation.compile.provider != expectation.provider
144 || expectation.compile.request_sha256 != request_sha256
145 || expectation.compile.source_sha256 != expectation.request.source.sha256
146 || source_project_file_sha256(&expectation.request, "ferry.toml")
147 != Some(expectation.compile.config_sha256.as_str())
148 || source_cargo_lock_sha256(&expectation.request)
149 != Some(expectation.compile.cargo_lock_sha256.as_str())
150 || archive_expectation.app_directory_name != product.app_directory_name
151 || archive_expectation.bundle_identifier != expectation.request.bundle_identifier
152 || archive_expectation.executable != product.executable
153 || archive_expectation.app_version != product.app_version
154 || archive_expectation.build_number != product.build_number
155 || archive_expectation.minimum_os != expectation.request.minimum_ios_version
156 || archive_expectation.nested_bundles != product.nested_bundles
157 || archive_expectation.sdk_version != expectation.compile.toolchain.iphoneos_sdk_version
158 || archive_expectation.sdk_build_version
159 != expectation.compile.toolchain.iphoneos_sdk_build_version
160 || expectation.compile.toolchain.rust_target != IOS_DEVICE_RUST_TARGET
161 || !is_lower_sha256(&expectation.compile.sealed_archive.transport.sha256)
162 {
163 return Err(GithubArtifactError::InvalidExpectation);
164 }
165 Ok(expectation)
166 }
167
168 pub fn operation_id(&self) -> &str {
170 &self.request.operation_id
171 }
172
173 pub fn job_id(&self) -> &str {
175 &self.job_id
176 }
177
178 pub fn provider(&self) -> &str {
180 &self.provider
181 }
182
183 pub fn request(&self) -> &IosDeviceBuildRequest {
185 &self.request
186 }
187
188 pub fn compile(&self) -> &CompilePhaseEvidence {
190 &self.compile
191 }
192}
193
194fn source_project_file_sha256<'a>(
195 request: &'a IosDeviceBuildRequest,
196 file_name: &str,
197) -> Option<&'a str> {
198 let path = if request.source.project_path == "." {
199 file_name.to_owned()
200 } else {
201 format!("{}/{file_name}", request.source.project_path)
202 };
203 request
204 .source
205 .entries
206 .iter()
207 .find(|entry| entry.path == path)
208 .map(|entry| entry.sha256.as_str())
209}
210
211fn source_cargo_lock_sha256(request: &IosDeviceBuildRequest) -> Option<&str> {
212 let mut components = if request.source.project_path == "." {
213 Vec::new()
214 } else {
215 request.source.project_path.split('/').collect::<Vec<_>>()
216 };
217 loop {
218 let candidate = if components.is_empty() {
219 "Cargo.lock".to_owned()
220 } else {
221 format!("{}/Cargo.lock", components.join("/"))
222 };
223 if let Some(entry) = request
224 .source
225 .entries
226 .iter()
227 .find(|entry| entry.path == candidate)
228 {
229 return Some(entry.sha256.as_str());
230 }
231 components.pop()?;
232 }
233}
234
235#[derive(Clone, Copy, Debug)]
237pub struct GithubArtifactIngestion<'a> {
238 pub archive_path: &'a Utf8Path,
240 pub temporary_directory: &'a Utf8Path,
242 pub output_directory: &'a Utf8Path,
244 pub expected: &'a GithubArtifactExpectation,
246 pub ipa_expectation: &'a IpaExpectation,
248}
249
250#[derive(Clone, Debug, Eq, PartialEq)]
252pub struct PublishedGithubArtifact {
253 pub ipa_path: Utf8PathBuf,
255 pub manifest_path: Utf8PathBuf,
257 pub signing_report_path: Utf8PathBuf,
259 pub validation_report_path: Utf8PathBuf,
261 pub sanitized_log_path: Utf8PathBuf,
263 pub app_bundle_archive_path: Option<Utf8PathBuf>,
265 pub signed_xcarchive_path: Option<Utf8PathBuf>,
267 pub dsym_archive_path: Option<Utf8PathBuf>,
269 pub manifest: ArtifactManifest,
271 pub ipa_inspection: IpaInspection,
273 pub manifest_sha256: String,
275 pub manifest_size: u64,
277 pub validation_levels: BTreeSet<ValidationLevel>,
279}
280
281#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
283pub enum RequiredArtifactFile {
284 Ipa,
286 Manifest,
288 SigningReport,
290 ValidationReport,
292 SanitizedLog,
294 AppBundleArchive,
296 SignedXcarchive,
298 DsymArchive,
300}
301
302impl RequiredArtifactFile {
303 const BASE: [Self; BASE_ENTRY_COUNT] = [
304 Self::Manifest,
305 Self::SigningReport,
306 Self::ValidationReport,
307 Self::SanitizedLog,
308 Self::Ipa,
309 ];
310
311 const fn file_name(self) -> &'static str {
312 match self {
313 Self::Ipa => DEVELOPMENT_IPA_NAME,
314 Self::Manifest => ARTIFACT_MANIFEST_NAME,
315 Self::SigningReport => SIGNING_REPORT_NAME,
316 Self::ValidationReport => VALIDATION_REPORT_NAME,
317 Self::SanitizedLog => SANITIZED_BUILD_LOG_NAME,
318 Self::AppBundleArchive => APP_BUNDLE_ARCHIVE_NAME,
319 Self::SignedXcarchive => SIGNED_XCARCHIVE_NAME,
320 Self::DsymArchive => DSYM_ARCHIVE_NAME,
321 }
322 }
323
324 const fn maximum_size(self) -> u64 {
325 match self {
326 Self::Ipa => MAX_IPA_BYTES,
327 Self::Manifest => MAX_MANIFEST_BYTES,
328 Self::SigningReport | Self::ValidationReport => MAX_REPORT_BYTES,
329 Self::SanitizedLog => MAX_SANITIZED_LOG_BYTES,
330 Self::AppBundleArchive => MAX_APP_BUNDLE_ARCHIVE_BYTES,
331 Self::SignedXcarchive => MAX_XCARCHIVE_BYTES,
332 Self::DsymArchive => MAX_DSYM_ARCHIVE_BYTES,
333 }
334 }
335
336 const fn artifact_kind(self) -> Option<ArtifactKind> {
337 match self {
338 Self::Ipa => Some(ArtifactKind::Ipa),
339 Self::Manifest => None,
340 Self::SigningReport => Some(ArtifactKind::SigningReport),
341 Self::ValidationReport => Some(ArtifactKind::ValidationReport),
342 Self::SanitizedLog => Some(ArtifactKind::SanitizedLog),
343 Self::AppBundleArchive => Some(ArtifactKind::App),
344 Self::SignedXcarchive => Some(ArtifactKind::Xcarchive),
345 Self::DsymArchive => Some(ArtifactKind::Dsym),
346 }
347 }
348
349 const fn media_type(self) -> Option<&'static str> {
350 match self {
351 Self::Ipa => Some("application/octet-stream"),
352 Self::Manifest => None,
353 Self::SigningReport | Self::ValidationReport => Some("application/json"),
354 Self::SanitizedLog => Some("text/plain; charset=utf-8"),
355 Self::AppBundleArchive | Self::SignedXcarchive | Self::DsymArchive => {
356 Some("application/zip")
357 }
358 }
359 }
360
361 fn from_file_name(name: &str) -> Option<Self> {
362 match name {
363 DEVELOPMENT_IPA_NAME => Some(Self::Ipa),
364 ARTIFACT_MANIFEST_NAME => Some(Self::Manifest),
365 SIGNING_REPORT_NAME => Some(Self::SigningReport),
366 VALIDATION_REPORT_NAME => Some(Self::ValidationReport),
367 SANITIZED_BUILD_LOG_NAME => Some(Self::SanitizedLog),
368 APP_BUNDLE_ARCHIVE_NAME => Some(Self::AppBundleArchive),
369 SIGNED_XCARCHIVE_NAME => Some(Self::SignedXcarchive),
370 DSYM_ARCHIVE_NAME => Some(Self::DsymArchive),
371 _ => None,
372 }
373 }
374}
375
376fn required_artifact_files(expected: &GithubArtifactExpectation) -> BTreeSet<RequiredArtifactFile> {
377 let mut files = RequiredArtifactFile::BASE
378 .into_iter()
379 .collect::<BTreeSet<_>>();
380 let requested = &expected.request().requested_artifacts;
381 if requested.contains(&rustferry_remote::IosArtifactType::AppBundle) {
382 files.insert(RequiredArtifactFile::AppBundleArchive);
383 }
384 if requested.contains(&rustferry_remote::IosArtifactType::Xcarchive) {
385 files.insert(RequiredArtifactFile::SignedXcarchive);
386 }
387 if requested.contains(&rustferry_remote::IosArtifactType::Dsym) {
388 files.insert(RequiredArtifactFile::DsymArchive);
389 }
390 files
391}
392
393impl fmt::Display for RequiredArtifactFile {
394 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
395 formatter.write_str(self.file_name())
396 }
397}
398
399#[derive(Clone, Copy, Debug, Eq, PartialEq)]
401pub enum GithubArtifactError {
402 InvalidExpectation,
404 InvalidPath,
406 TemporaryDirectoryNotEmpty,
408 ArchiveTooLarge,
410 InvalidArchive,
412 InvalidEntryCount,
414 UnsafeEntryName,
416 NestedArchiveRoot,
418 UnexpectedEntry,
420 DuplicateEntry,
422 PortableNameCollision,
424 EncryptedEntry,
426 UnsupportedCompression,
428 LinkedOrSpecialEntry,
430 MissingEntry(RequiredArtifactFile),
432 EntryTooLarge(RequiredArtifactFile),
434 ExpandedArchiveTooLarge,
436 CompressionRatioExceeded(RequiredArtifactFile),
438 EntryIntegrityFailed(RequiredArtifactFile),
440 InvalidManifest,
442 InvalidArtifactRecord(RequiredArtifactFile),
444 EvidenceMismatch,
446 InvalidPublicReport(RequiredArtifactFile),
448 ArtifactIntegrityFailed(RequiredArtifactFile),
450 IpaInspectionFailed,
452 InvalidProductArchive(RequiredArtifactFile),
454 ProductEvidenceMismatch(RequiredArtifactFile),
456 OutputAlreadyExists(RequiredArtifactFile),
458 AtomicPublicationFailed,
460 CleanupFailed,
462 Io(io::ErrorKind),
464}
465
466impl fmt::Display for GithubArtifactError {
467 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
468 match self {
469 Self::InvalidExpectation => formatter.write_str("artifact expectation is invalid"),
470 Self::InvalidPath => formatter.write_str("artifact path binding is invalid"),
471 Self::TemporaryDirectoryNotEmpty => {
472 formatter.write_str("artifact temporary directory is not empty")
473 }
474 Self::ArchiveTooLarge => formatter.write_str("artifact ZIP exceeds its byte limit"),
475 Self::InvalidArchive => formatter.write_str("artifact ZIP is invalid"),
476 Self::InvalidEntryCount => {
477 formatter.write_str("artifact ZIP has an invalid entry count")
478 }
479 Self::UnsafeEntryName => formatter.write_str("artifact ZIP has an unsafe entry name"),
480 Self::NestedArchiveRoot => {
481 formatter.write_str("artifact ZIP contains a nested wrapper root")
482 }
483 Self::UnexpectedEntry => {
484 formatter.write_str("artifact ZIP contains an unexpected file")
485 }
486 Self::DuplicateEntry => formatter.write_str("artifact ZIP contains a duplicate entry"),
487 Self::PortableNameCollision => {
488 formatter.write_str("artifact ZIP contains portable-name collisions")
489 }
490 Self::EncryptedEntry => formatter.write_str("artifact ZIP contains an encrypted entry"),
491 Self::UnsupportedCompression => {
492 formatter.write_str("artifact ZIP uses unsupported compression")
493 }
494 Self::LinkedOrSpecialEntry => {
495 formatter.write_str("artifact ZIP contains a linked or special entry")
496 }
497 Self::MissingEntry(file) => write!(formatter, "artifact ZIP is missing {file}"),
498 Self::EntryTooLarge(file) => write!(formatter, "{file} exceeds its byte limit"),
499 Self::ExpandedArchiveTooLarge => {
500 formatter.write_str("artifact ZIP exceeds its expanded-byte limit")
501 }
502 Self::CompressionRatioExceeded(file) => {
503 write!(formatter, "{file} exceeds its compression-ratio limit")
504 }
505 Self::EntryIntegrityFailed(file) => {
506 write!(formatter, "{file} failed ZIP integrity validation")
507 }
508 Self::InvalidManifest => formatter.write_str("artifact manifest is invalid"),
509 Self::InvalidArtifactRecord(file) => {
510 write!(formatter, "artifact manifest record for {file} is invalid")
511 }
512 Self::EvidenceMismatch => {
513 formatter.write_str("artifact evidence does not match the requested run")
514 }
515 Self::InvalidPublicReport(file) => write!(formatter, "{file} is invalid"),
516 Self::ArtifactIntegrityFailed(file) => {
517 write!(formatter, "{file} does not match the artifact manifest")
518 }
519 Self::IpaInspectionFailed => {
520 formatter.write_str("downloaded IPA failed physical-iPhone inspection")
521 }
522 Self::InvalidProductArchive(file) => {
523 write!(formatter, "{file} has an invalid signed-product layout")
524 }
525 Self::ProductEvidenceMismatch(file) => {
526 write!(
527 formatter,
528 "{file} does not match the validated signed product"
529 )
530 }
531 Self::OutputAlreadyExists(file) => {
532 write!(formatter, "refusing to overwrite existing {file}")
533 }
534 Self::AtomicPublicationFailed => {
535 formatter.write_str("artifact files could not be atomically published")
536 }
537 Self::CleanupFailed => formatter.write_str("artifact partial-file cleanup failed"),
538 Self::Io(kind) => write!(formatter, "artifact I/O failed with {kind:?}"),
539 }
540 }
541}
542
543impl Error for GithubArtifactError {}
544
545#[derive(Clone, Copy, Debug, Eq, PartialEq)]
546struct EntryMetadata {
547 index: usize,
548 size: u64,
549}
550
551#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
552#[serde(deny_unknown_fields)]
553struct PublishedEvidenceReport {
554 schema_version: u32,
555 request_sha256: String,
556 sealed_archive_sha256: String,
557 signed_ipa: PublishedSignedIpaEvidence,
558 signed_products: PublishedSignedProductsEvidence,
559 cleanup: PublishedCleanupEvidence,
560}
561
562#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
563#[serde(deny_unknown_fields)]
564struct PublishedSignedProductsEvidence {
565 app_tree: Option<PublishedSignedTreeEvidence>,
566 archive: Option<PublishedSignedArchiveEvidence>,
567 dsym: Option<PublishedSignedDsymEvidence>,
568}
569
570#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
571#[serde(deny_unknown_fields)]
572struct PublishedSignedTreeEvidence {
573 entry_count: u32,
574 total_size: u64,
575 sha256: String,
576}
577
578#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
579#[serde(deny_unknown_fields)]
580struct PublishedSignedArchiveEvidence {
581 app_tree: PublishedSignedTreeEvidence,
582 root_deep_signature_verified: bool,
583}
584
585#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
586#[serde(deny_unknown_fields)]
587struct PublishedSignedDsymEvidence {
588 architecture: String,
589 signed_executable_uuid: String,
590 dsym_uuid: String,
591}
592
593#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
594#[serde(deny_unknown_fields)]
595struct PublishedSignedIpaEvidence {
596 ipa_sha256: String,
597 ipa_size: u64,
598 bundle_identifier: String,
599 team_identifier: String,
600 certificate_sha256_fingerprint: String,
601 bundles: Vec<PublishedSignedBundleEvidence>,
602 rust_target: String,
603 apple_sdk: String,
604 architectures: Vec<String>,
605 verified_code_objects: Vec<String>,
606 individual_signatures_verified: bool,
607 root_deep_signature_verified: bool,
608 cleanup_confirmed: bool,
609}
610
611#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
612#[serde(deny_unknown_fields)]
613struct PublishedSignedBundleEvidence {
614 relative_path: String,
615 bundle_identifier: String,
616 kind: SigningTargetKind,
617 certificate_sha256_fingerprint: String,
618 profile_uuid: Option<String>,
619 profile_expires_at_unix_seconds: Option<u64>,
620 entitlements_sha256: Option<String>,
621 selected_device_authorized: Option<bool>,
622}
623
624#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
625#[serde(deny_unknown_fields)]
626#[allow(clippy::struct_excessive_bools)]
627struct PublishedCleanupEvidence {
628 keychain_search_list_restored: bool,
629 keychain_removed: bool,
630 keychain_signing_files_removed: bool,
631 keychain_job_directory_removed: bool,
632 isolated_home_removed: bool,
633 export_options_removed: bool,
634 validation_workspace_removed: bool,
635 private_workspace_removed: bool,
636}
637
638impl PublishedCleanupEvidence {
639 const fn is_complete(self) -> bool {
640 self.keychain_search_list_restored
641 && self.keychain_removed
642 && self.keychain_signing_files_removed
643 && self.keychain_job_directory_removed
644 && self.isolated_home_removed
645 && self.export_options_removed
646 && self.validation_workspace_removed
647 && self.private_workspace_removed
648 }
649}
650
651#[allow(clippy::too_many_lines)]
665pub fn ingest_github_actions_artifact(
666 request: GithubArtifactIngestion<'_>,
667) -> Result<PublishedGithubArtifact, GithubArtifactError> {
668 validate_ipa_expectation(request.ipa_expectation)?;
669 let required_files = required_artifact_files(request.expected);
670 let (temporary_directory, _temporary_directory_guard) =
671 bind_empty_directory(request.temporary_directory, true)?;
672 let (output_directory, _output_directory_guard) =
673 bind_empty_directory(request.output_directory, false)?;
674 if temporary_directory == output_directory {
675 return Err(GithubArtifactError::InvalidPath);
676 }
677 for file in required_files.iter().copied() {
678 ensure_output_absent(&output_directory.join(file.file_name()), file)?;
679 }
680
681 let archive_file = open_regular_archive(request.archive_path)?;
682 let archive_size = archive_file.metadata().map_err(io_error)?.len();
683 if archive_size > MAX_ARCHIVE_BYTES {
684 return Err(GithubArtifactError::ArchiveTooLarge);
685 }
686 let mut archive =
687 ZipArchive::new(archive_file).map_err(|_| GithubArtifactError::InvalidArchive)?;
688 let entries = scan_archive(&mut archive, archive_size, &required_files)?;
689
690 let manifest_bytes = read_bounded_entry(
691 &mut archive,
692 entries[&RequiredArtifactFile::Manifest],
693 RequiredArtifactFile::Manifest,
694 )?;
695 let manifest: ArtifactManifest = strict_json::decode(
696 &manifest_bytes,
697 usize::try_from(MAX_MANIFEST_BYTES).unwrap_or(usize::MAX),
698 )
699 .map_err(|_| GithubArtifactError::InvalidManifest)?;
700 let records = validate_manifest(
701 &manifest,
702 request.expected,
703 request.ipa_expectation,
704 &entries,
705 &required_files,
706 )?;
707
708 let signing_report_bytes = read_bounded_entry(
709 &mut archive,
710 entries[&RequiredArtifactFile::SigningReport],
711 RequiredArtifactFile::SigningReport,
712 )?;
713 let validation_report_bytes = read_bounded_entry(
714 &mut archive,
715 entries[&RequiredArtifactFile::ValidationReport],
716 RequiredArtifactFile::ValidationReport,
717 )?;
718 let sanitized_log_bytes = read_bounded_entry(
719 &mut archive,
720 entries[&RequiredArtifactFile::SanitizedLog],
721 RequiredArtifactFile::SanitizedLog,
722 )?;
723 validate_sanitized_log(&sanitized_log_bytes)?;
724 let signing_report =
725 parse_public_report(&signing_report_bytes, RequiredArtifactFile::SigningReport)?;
726 let validation_report = parse_public_report(
727 &validation_report_bytes,
728 RequiredArtifactFile::ValidationReport,
729 )?;
730 if signing_report != validation_report {
731 return Err(GithubArtifactError::EvidenceMismatch);
732 }
733 validate_public_report(
734 &validation_report,
735 &manifest,
736 records[&RequiredArtifactFile::Ipa],
737 request.expected,
738 &required_files,
739 )?;
740
741 let staged_paths = required_files
742 .iter()
743 .copied()
744 .map(|file| (file, temporary_directory.join(file.file_name())))
745 .collect::<BTreeMap<_, _>>();
746 let staging_result = (|| {
747 write_new_file(
748 &staged_paths[&RequiredArtifactFile::Manifest],
749 &manifest_bytes,
750 )?;
751 write_new_file(
752 &staged_paths[&RequiredArtifactFile::SigningReport],
753 &signing_report_bytes,
754 )?;
755 write_new_file(
756 &staged_paths[&RequiredArtifactFile::ValidationReport],
757 &validation_report_bytes,
758 )?;
759 write_new_file(
760 &staged_paths[&RequiredArtifactFile::SanitizedLog],
761 &sanitized_log_bytes,
762 )?;
763 for file in required_files.iter().copied().filter(|file| {
764 !matches!(
765 file,
766 RequiredArtifactFile::Manifest
767 | RequiredArtifactFile::SigningReport
768 | RequiredArtifactFile::ValidationReport
769 | RequiredArtifactFile::SanitizedLog
770 )
771 }) {
772 extract_entry_to_new_file(&mut archive, entries[&file], file, &staged_paths[&file])?;
773 }
774 Ok(())
775 })();
776 if let Err(error) = staging_result {
777 cleanup_paths(staged_paths.values())?;
778 return Err(error);
779 }
780
781 let validation_result = validate_staged_files(
782 &staged_paths,
783 &manifest_bytes,
784 &records,
785 request.ipa_expectation,
786 &manifest,
787 &required_files,
788 &validation_report,
789 );
790 let ipa_inspection = match validation_result {
791 Ok(inspection) => inspection,
792 Err(error) => {
793 cleanup_paths(staged_paths.values())?;
794 return Err(error);
795 }
796 };
797
798 let published_links =
799 match publish_no_replace(&staged_paths, &output_directory, &required_files) {
800 Ok(links) => links,
801 Err(error) => {
802 cleanup_paths(staged_paths.values())?;
803 return Err(error);
804 }
805 };
806 if finalize_published_links(published_links.values(), staged_paths.values()).is_err() {
807 let _ = cleanup_published_links(published_links.values());
808 #[cfg(not(windows))]
809 let _ = cleanup_paths(staged_paths.values());
810 return Err(GithubArtifactError::CleanupFailed);
811 }
812 if published_links
813 .values()
814 .any(|link| published_link_matches(link) != Ok(true))
815 {
816 let _ = cleanup_published_links(published_links.values());
817 return Err(GithubArtifactError::CleanupFailed);
818 }
819 let published_paths = published_links
820 .iter()
821 .map(|(file, link)| (*file, link.path.clone()))
822 .collect::<BTreeMap<_, _>>();
823
824 let mut validation_levels = manifest.validation_levels.clone();
825 validation_levels.insert(ValidationLevel::DownloadedToClient);
826 Ok(PublishedGithubArtifact {
827 ipa_path: published_paths[&RequiredArtifactFile::Ipa].clone(),
828 manifest_path: published_paths[&RequiredArtifactFile::Manifest].clone(),
829 signing_report_path: published_paths[&RequiredArtifactFile::SigningReport].clone(),
830 validation_report_path: published_paths[&RequiredArtifactFile::ValidationReport].clone(),
831 sanitized_log_path: published_paths[&RequiredArtifactFile::SanitizedLog].clone(),
832 app_bundle_archive_path: published_paths
833 .get(&RequiredArtifactFile::AppBundleArchive)
834 .cloned(),
835 signed_xcarchive_path: published_paths
836 .get(&RequiredArtifactFile::SignedXcarchive)
837 .cloned(),
838 dsym_archive_path: published_paths
839 .get(&RequiredArtifactFile::DsymArchive)
840 .cloned(),
841 manifest,
842 ipa_inspection,
843 manifest_sha256: sha256_bytes(&manifest_bytes),
844 manifest_size: entries[&RequiredArtifactFile::Manifest].size,
845 validation_levels,
846 })
847}
848
849#[derive(Debug)]
850struct PrivateDirectoryGuard {
851 #[cfg(windows)]
852 _handle: File,
853}
854
855fn bind_empty_directory(
856 path: &Utf8Path,
857 require_empty: bool,
858) -> Result<(Utf8PathBuf, PrivateDirectoryGuard), GithubArtifactError> {
859 if !path.is_absolute() {
860 return Err(GithubArtifactError::InvalidPath);
861 }
862 let metadata = fs::symlink_metadata(path).map_err(io_error)?;
863 if metadata.file_type().is_symlink() || !metadata.is_dir() {
864 return Err(GithubArtifactError::InvalidPath);
865 }
866 let canonical = path.canonicalize_utf8().map_err(io_error)?;
867 if require_empty {
868 let mut entries = fs::read_dir(&canonical).map_err(io_error)?;
869 if entries.next().transpose().map_err(io_error)?.is_some() {
870 return Err(GithubArtifactError::TemporaryDirectoryNotEmpty);
871 }
872 }
873 #[cfg(windows)]
874 let guard = open_windows_private_directory(canonical.as_std_path())
875 .map(|handle| PrivateDirectoryGuard { _handle: handle })
876 .map_err(map_windows_private_path_error)?;
877 #[cfg(not(windows))]
878 let guard = PrivateDirectoryGuard {};
879 Ok((canonical, guard))
880}
881
882#[cfg(windows)]
883fn open_regular_archive(path: &Utf8Path) -> Result<File, GithubArtifactError> {
884 if !path.is_absolute() {
885 return Err(GithubArtifactError::InvalidPath);
886 }
887 open_windows_private_file(path.as_std_path()).map_err(map_windows_private_path_error)
888}
889
890#[cfg(not(windows))]
891fn open_regular_archive(path: &Utf8Path) -> Result<File, GithubArtifactError> {
892 if !path.is_absolute() {
893 return Err(GithubArtifactError::InvalidPath);
894 }
895 let metadata = fs::symlink_metadata(path).map_err(io_error)?;
896 if metadata.file_type().is_symlink() || !metadata.is_file() {
897 return Err(GithubArtifactError::InvalidPath);
898 }
899 let file = File::open(path).map_err(io_error)?;
900 if !file.metadata().map_err(io_error)?.is_file() {
901 return Err(GithubArtifactError::InvalidPath);
902 }
903 Ok(file)
904}
905
906fn scan_archive(
907 archive: &mut ZipArchive<File>,
908 archive_size: u64,
909 required_files: &BTreeSet<RequiredArtifactFile>,
910) -> Result<BTreeMap<RequiredArtifactFile, EntryMetadata>, GithubArtifactError> {
911 if archive.len() != required_files.len() {
912 return Err(GithubArtifactError::InvalidEntryCount);
913 }
914 let mut exact_names = BTreeSet::new();
915 let mut portable_names = BTreeMap::<String, String>::new();
916 let mut header_starts = BTreeSet::new();
917 let mut compressed_ranges = Vec::with_capacity(archive.len());
918 let mut entries = BTreeMap::new();
919 let mut expanded_size = 0_u64;
920
921 for index in 0..archive.len() {
922 let entry = archive
923 .by_index(index)
924 .map_err(|_| GithubArtifactError::InvalidArchive)?;
925 let name = validate_entry_name(entry.name_raw())?;
926 if !exact_names.insert(name.to_owned()) {
927 return Err(GithubArtifactError::DuplicateEntry);
928 }
929 let portable = portable_name_key(name);
930 if portable_names.insert(portable, name.to_owned()).is_some() {
931 return Err(GithubArtifactError::PortableNameCollision);
932 }
933 let file = RequiredArtifactFile::from_file_name(name)
934 .ok_or(GithubArtifactError::UnexpectedEntry)?;
935 if !required_files.contains(&file) {
936 return Err(GithubArtifactError::UnexpectedEntry);
937 }
938 validate_entry_metadata(&entry, file)?;
939 if !header_starts.insert(entry.header_start()) {
940 return Err(GithubArtifactError::LinkedOrSpecialEntry);
941 }
942 let data_start = entry
943 .data_start()
944 .ok_or(GithubArtifactError::InvalidArchive)?;
945 let data_end = data_start
946 .checked_add(entry.compressed_size())
947 .ok_or(GithubArtifactError::InvalidArchive)?;
948 if data_end > archive_size {
949 return Err(GithubArtifactError::InvalidArchive);
950 }
951 compressed_ranges.push((data_start, data_end));
952 expanded_size = expanded_size
953 .checked_add(entry.size())
954 .ok_or(GithubArtifactError::ExpandedArchiveTooLarge)?;
955 if expanded_size > MAX_TOTAL_EXPANDED_BYTES {
956 return Err(GithubArtifactError::ExpandedArchiveTooLarge);
957 }
958 if entries
959 .insert(
960 file,
961 EntryMetadata {
962 index,
963 size: entry.size(),
964 },
965 )
966 .is_some()
967 {
968 return Err(GithubArtifactError::DuplicateEntry);
969 }
970 }
971 if zip_layout_has_aliases(&header_starts, &mut compressed_ranges) {
972 return Err(GithubArtifactError::LinkedOrSpecialEntry);
973 }
974 for file in required_files.iter().copied() {
975 if !entries.contains_key(&file) {
976 return Err(GithubArtifactError::MissingEntry(file));
977 }
978 }
979 Ok(entries)
980}
981
982fn zip_layout_has_aliases(
983 header_starts: &BTreeSet<u64>,
984 compressed_ranges: &mut [(u64, u64)],
985) -> bool {
986 compressed_ranges.sort_unstable();
987 compressed_ranges
988 .windows(2)
989 .any(|pair| pair[1].0 < pair[0].1)
990 || compressed_ranges
991 .iter()
992 .any(|&(start, end)| start < end && header_starts.range(start..end).next().is_some())
993}
994
995fn validate_entry_name(raw_name: &[u8]) -> Result<&str, GithubArtifactError> {
996 let name = std::str::from_utf8(raw_name).map_err(|_| GithubArtifactError::UnsafeEntryName)?;
997 if name.is_empty()
998 || name.starts_with('/')
999 || name.starts_with('\\')
1000 || name.contains('\\')
1001 || name.contains('\0')
1002 || name.chars().any(char::is_control)
1003 || (name.len() >= 2 && name.as_bytes()[1] == b':')
1004 {
1005 return Err(GithubArtifactError::UnsafeEntryName);
1006 }
1007 let components = name.split('/').collect::<Vec<_>>();
1008 if components
1009 .iter()
1010 .any(|component| component.is_empty() || matches!(*component, "." | ".."))
1011 {
1012 return Err(GithubArtifactError::UnsafeEntryName);
1013 }
1014 if components.len() != 1 {
1015 return Err(GithubArtifactError::NestedArchiveRoot);
1016 }
1017 Ok(name)
1018}
1019
1020fn validate_entry_metadata(
1021 entry: &ZipFile<'_, File>,
1022 file: RequiredArtifactFile,
1023) -> Result<(), GithubArtifactError> {
1024 if entry.encrypted() {
1025 return Err(GithubArtifactError::EncryptedEntry);
1026 }
1027 if !matches!(
1028 entry.compression(),
1029 CompressionMethod::Stored | CompressionMethod::Deflated
1030 ) {
1031 return Err(GithubArtifactError::UnsupportedCompression);
1032 }
1033 if entry.is_dir() || entry.is_symlink() {
1034 return Err(GithubArtifactError::LinkedOrSpecialEntry);
1035 }
1036 if let Some(mode) = entry.unix_mode() {
1037 let kind = mode & 0o170_000;
1038 if kind != 0 && kind != 0o100_000 {
1039 return Err(GithubArtifactError::LinkedOrSpecialEntry);
1040 }
1041 }
1042 if entry.size() == 0 || entry.size() > file.maximum_size() {
1043 return Err(GithubArtifactError::EntryTooLarge(file));
1044 }
1045 if entry.compressed_size() == 0
1046 || entry.size()
1047 > entry
1048 .compressed_size()
1049 .saturating_mul(MAX_COMPRESSION_RATIO)
1050 {
1051 return Err(GithubArtifactError::CompressionRatioExceeded(file));
1052 }
1053 Ok(())
1054}
1055
1056fn read_bounded_entry(
1057 archive: &mut ZipArchive<File>,
1058 metadata: EntryMetadata,
1059 file: RequiredArtifactFile,
1060) -> Result<Vec<u8>, GithubArtifactError> {
1061 let mut entry = archive
1062 .by_index(metadata.index)
1063 .map_err(|_| GithubArtifactError::InvalidArchive)?;
1064 let capacity =
1065 usize::try_from(metadata.size).map_err(|_| GithubArtifactError::EntryTooLarge(file))?;
1066 let mut bytes = Vec::with_capacity(capacity);
1067 entry
1068 .by_ref()
1069 .take(metadata.size.saturating_add(1))
1070 .read_to_end(&mut bytes)
1071 .map_err(|_| GithubArtifactError::EntryIntegrityFailed(file))?;
1072 if bytes.len() != capacity {
1073 return Err(GithubArtifactError::EntryIntegrityFailed(file));
1074 }
1075 Ok(bytes)
1076}
1077
1078fn validate_sanitized_log(bytes: &[u8]) -> Result<(), GithubArtifactError> {
1079 if bytes != PROTECTED_SIGNING_SANITIZED_LOG_V1 {
1080 return Err(GithubArtifactError::InvalidPublicReport(
1081 RequiredArtifactFile::SanitizedLog,
1082 ));
1083 }
1084 Ok(())
1085}
1086
1087fn validate_manifest<'a>(
1088 manifest: &'a ArtifactManifest,
1089 expected: &GithubArtifactExpectation,
1090 ipa_expectation: &IpaExpectation,
1091 entries: &BTreeMap<RequiredArtifactFile, EntryMetadata>,
1092 required_files: &BTreeSet<RequiredArtifactFile>,
1093) -> Result<BTreeMap<RequiredArtifactFile, &'a ArtifactRecord>, GithubArtifactError> {
1094 if !manifest_identity_matches(manifest, expected)
1095 || !manifest_build_matches(manifest, expected, ipa_expectation)
1096 || !manifest_signing_matches(manifest, expected)
1097 || manifest.artifacts.len() != required_files.len().saturating_sub(1)
1098 || !validate_manifest_public_fields(manifest)
1099 {
1100 return Err(GithubArtifactError::InvalidManifest);
1101 }
1102 validate_manifest_records(manifest, entries, required_files)
1103}
1104
1105fn manifest_identity_matches(
1106 manifest: &ArtifactManifest,
1107 expected: &GithubArtifactExpectation,
1108) -> bool {
1109 let request = &expected.request;
1110 let compile = &expected.compile;
1111 manifest.schema_version == ARTIFACT_MANIFEST_SCHEMA_VERSION
1112 && manifest.operation_id == request.operation_id
1113 && manifest.job_id == expected.job_id
1114 && manifest.provider == expected.provider
1115 && manifest.provider == compile.provider
1116 && manifest.project_id == request.bundle_identifier
1117 && manifest.source_repository == request.source_repository
1118 && manifest.source_revision == request.source_revision
1119 && manifest.source_snapshot == (request.source_mode == SourceMode::Snapshot)
1120 && manifest.source_sha256 == request.source.sha256
1121 && manifest.cargo_lock_sha256 == compile.cargo_lock_sha256
1122 && manifest.config_sha256 == compile.config_sha256
1123 && manifest.rustferry_version == compile.rustferry_version
1124 && manifest.worker_version == compile.worker_version
1125}
1126
1127fn manifest_build_matches(
1128 manifest: &ArtifactManifest,
1129 expected: &GithubArtifactExpectation,
1130 ipa_expectation: &IpaExpectation,
1131) -> bool {
1132 let request = &expected.request;
1133 let compile = &expected.compile;
1134 let mut expected_extensions = request
1135 .product
1136 .nested_bundles
1137 .iter()
1138 .filter(|bundle| bundle.kind == UnsignedNestedBundleKind::AppExtension)
1139 .map(|bundle| bundle.bundle_identifier.clone())
1140 .collect::<Vec<_>>();
1141 expected_extensions.sort();
1142 let expected_profile = match request.profile {
1143 BuildProfile::Debug => "debug",
1144 BuildProfile::Release => "release",
1145 };
1146 manifest.toolchain.worker_os == compile.toolchain.worker_os
1147 && manifest.toolchain.worker_architecture == compile.toolchain.worker_architecture
1148 && manifest.toolchain.xcode_version == compile.toolchain.xcode_version
1149 && manifest.toolchain.iphoneos_sdk_version == compile.toolchain.iphoneos_sdk_version
1150 && manifest.toolchain.rust_version == compile.toolchain.rust_version
1151 && manifest.app_name == request.product_name
1152 && manifest.app_version == request.product.app_version
1153 && manifest.build_number == request.product.build_number
1154 && manifest.bundle_identifier == ipa_expectation.bundle_identifier
1155 && ipa_expectation.app_version.as_deref() == Some(manifest.app_version.as_str())
1156 && ipa_expectation.build_number.as_deref() == Some(manifest.build_number.as_str())
1157 && manifest.build_profile == expected_profile
1158 && manifest.architecture == "arm64"
1159 && manifest.toolchain.rust_target == IOS_DEVICE_RUST_TARGET
1160 && manifest.extensions == expected_extensions
1161}
1162
1163fn manifest_signing_matches(
1164 manifest: &ArtifactManifest,
1165 expected: &GithubArtifactExpectation,
1166) -> bool {
1167 let request = &expected.request;
1168 let expected_team = request.signing.team.as_ref().map(|team| team.expected.id());
1169 let expected_certificate = request
1170 .signing
1171 .signing
1172 .as_ref()
1173 .map(|signing| signing.identity.certificate.sha256_fingerprint.as_str());
1174 let required_levels = BTreeSet::from([
1175 ValidationLevel::SourceValidated,
1176 ValidationLevel::RemoteBuilderValidated,
1177 ValidationLevel::DeviceTargetCompiled,
1178 ValidationLevel::DeviceBinaryBuilt,
1179 ValidationLevel::AppBundleBuilt,
1180 ValidationLevel::ArchiveBuilt,
1181 ValidationLevel::CertificateValidated,
1182 ValidationLevel::ProvisioningValidated,
1183 ValidationLevel::NestedCodeSigned,
1184 ValidationLevel::ApplicationSigned,
1185 ValidationLevel::IpaExported,
1186 ValidationLevel::ArtifactValidated,
1187 ]);
1188 manifest.signing.status == SigningStatus::ArtifactValidated
1189 && manifest.signing.mode == SigningMode::ManualDevelopment
1190 && manifest.signing.team_id.as_deref() == expected_team
1191 && manifest.signing.certificate_fingerprint.as_deref() == expected_certificate
1192 && manifest.cleanup_status == CleanupStatus::Confirmed
1193 && required_levels.is_subset(&manifest.validation_levels)
1194 && !manifest
1195 .validation_levels
1196 .contains(&ValidationLevel::DownloadedToClient)
1197}
1198
1199fn validate_manifest_records<'a>(
1200 manifest: &'a ArtifactManifest,
1201 entries: &BTreeMap<RequiredArtifactFile, EntryMetadata>,
1202 required_files: &BTreeSet<RequiredArtifactFile>,
1203) -> Result<BTreeMap<RequiredArtifactFile, &'a ArtifactRecord>, GithubArtifactError> {
1204 let mut records = BTreeMap::new();
1205 let mut artifact_ids = BTreeSet::new();
1206 for file in required_files
1207 .iter()
1208 .copied()
1209 .filter(|file| *file != RequiredArtifactFile::Manifest)
1210 {
1211 let kind = file
1212 .artifact_kind()
1213 .ok_or(GithubArtifactError::InvalidArtifactRecord(file))?;
1214 let media_type = file
1215 .media_type()
1216 .ok_or(GithubArtifactError::InvalidArtifactRecord(file))?;
1217 let record = manifest
1218 .one_artifact(kind)
1219 .map_err(|_| GithubArtifactError::InvalidArtifactRecord(file))?;
1220 if record.file_name != file.file_name()
1221 || record.media_type.as_deref() != Some(media_type)
1222 || record.size != entries[&file].size
1223 || record.size == 0
1224 || record.size > file.maximum_size()
1225 || !is_lower_sha256(&record.sha256)
1226 || !is_safe_public_identifier(&record.artifact_id)
1227 || !artifact_ids.insert(record.artifact_id.as_str())
1228 {
1229 return Err(GithubArtifactError::InvalidArtifactRecord(file));
1230 }
1231 records.insert(file, record);
1232 }
1233 Ok(records)
1234}
1235
1236fn validate_manifest_public_fields(manifest: &ArtifactManifest) -> bool {
1237 let signing = &manifest.signing;
1238 let extensions_are_sorted = manifest.extensions.windows(2).all(|pair| pair[0] < pair[1]);
1239 let source_is_bound = match (
1240 manifest.source_snapshot,
1241 manifest.source_repository.as_deref(),
1242 manifest.source_revision.as_deref(),
1243 ) {
1244 (true, None, None) => true,
1245 (false, Some(repository), Some(revision)) => {
1246 is_normalized_github_repository(repository) && is_lower_git_revision(revision)
1247 }
1248 _ => false,
1249 };
1250 source_is_bound
1251 && is_lower_sha256(&manifest.source_sha256)
1252 && is_lower_sha256(&manifest.cargo_lock_sha256)
1253 && is_lower_sha256(&manifest.config_sha256)
1254 && is_safe_public_text(&manifest.project_id)
1255 && manifest.project_id == manifest.bundle_identifier
1256 && is_safe_public_text(&manifest.rustferry_version)
1257 && is_safe_public_text(&manifest.worker_version)
1258 && is_safe_public_text(&manifest.toolchain.worker_os)
1259 && is_safe_public_text(&manifest.toolchain.worker_architecture)
1260 && is_safe_public_text(&manifest.toolchain.xcode_version)
1261 && is_safe_public_text(&manifest.toolchain.iphoneos_sdk_version)
1262 && is_safe_public_text(&manifest.toolchain.rust_version)
1263 && is_safe_public_text(&manifest.app_name)
1264 && is_safe_public_text(&manifest.app_version)
1265 && is_safe_public_text(&manifest.build_number)
1266 && matches!(manifest.build_profile.as_str(), "debug" | "release")
1267 && is_safe_bundle_identifier(&manifest.bundle_identifier)
1268 && manifest.extensions.len() <= MAX_SIGNED_BUNDLES
1269 && extensions_are_sorted
1270 && manifest
1271 .extensions
1272 .iter()
1273 .all(|extension| is_safe_bundle_identifier(extension))
1274 && is_worker_timestamp(&manifest.started_at)
1275 && is_worker_timestamp(&manifest.finished_at)
1276 && manifest.started_at <= manifest.finished_at
1277 && signing.team_id.as_deref().is_some_and(is_team_identifier)
1278 && signing
1279 .certificate_fingerprint
1280 .as_deref()
1281 .is_some_and(is_sha256_any_case)
1282 && signing
1283 .profile_uuid
1284 .as_deref()
1285 .is_some_and(is_safe_public_identifier)
1286 && signing
1287 .profile_expiration
1288 .as_deref()
1289 .is_some_and(is_worker_timestamp)
1290 && signing
1291 .entitlements_sha256
1292 .as_deref()
1293 .is_some_and(is_lower_sha256)
1294}
1295
1296fn parse_public_report(
1297 bytes: &[u8],
1298 file: RequiredArtifactFile,
1299) -> Result<PublishedEvidenceReport, GithubArtifactError> {
1300 strict_json::decode(
1301 bytes,
1302 usize::try_from(MAX_REPORT_BYTES).unwrap_or(usize::MAX),
1303 )
1304 .map_err(|_| GithubArtifactError::InvalidPublicReport(file))
1305}
1306
1307#[allow(clippy::too_many_lines)]
1308fn validate_public_report(
1309 report: &PublishedEvidenceReport,
1310 manifest: &ArtifactManifest,
1311 ipa_record: &ArtifactRecord,
1312 expected: &GithubArtifactExpectation,
1313 required_files: &BTreeSet<RequiredArtifactFile>,
1314) -> Result<(), GithubArtifactError> {
1315 let evidence = &report.signed_ipa;
1316 let signing = &manifest.signing;
1317 if report.request_sha256 != expected.compile.request_sha256
1318 || report.sealed_archive_sha256 != expected.compile.sealed_archive.transport.sha256
1319 {
1320 return Err(GithubArtifactError::EvidenceMismatch);
1321 }
1322 if report.schema_version != PUBLIC_REPORT_SCHEMA_VERSION
1323 || !is_lower_sha256(&report.request_sha256)
1324 || !is_lower_sha256(&report.sealed_archive_sha256)
1325 || evidence.ipa_sha256 != ipa_record.sha256
1326 || evidence.ipa_size != ipa_record.size
1327 || evidence.bundle_identifier != manifest.bundle_identifier
1328 || Some(evidence.team_identifier.as_str()) != signing.team_id.as_deref()
1329 || Some(evidence.certificate_sha256_fingerprint.as_str())
1330 != signing.certificate_fingerprint.as_deref()
1331 || evidence.rust_target != IOS_DEVICE_RUST_TARGET
1332 || evidence.apple_sdk != IOS_DEVICE_SDK
1333 || evidence.architectures.as_slice() != ["arm64"]
1334 || !evidence.individual_signatures_verified
1335 || !evidence.root_deep_signature_verified
1336 || !evidence.cleanup_confirmed
1337 || !report.cleanup.is_complete()
1338 || !is_team_identifier(&evidence.team_identifier)
1339 || !is_sha256_any_case(&evidence.certificate_sha256_fingerprint)
1340 || !strict_safe_sorted_paths(&evidence.verified_code_objects, MAX_CODE_OBJECTS)
1341 || evidence.bundles.is_empty()
1342 || evidence.bundles.len() > MAX_SIGNED_BUNDLES
1343 {
1344 return Err(GithubArtifactError::InvalidPublicReport(
1345 RequiredArtifactFile::ValidationReport,
1346 ));
1347 }
1348
1349 let expected_targets = expected
1350 .request
1351 .signing
1352 .targets
1353 .iter()
1354 .map(|target| (target.bundle_identifier.as_str().to_owned(), target.kind))
1355 .collect::<BTreeSet<_>>();
1356 let mut actual_targets = BTreeSet::new();
1357 let mut previous_path = None;
1358 let mut applications = Vec::new();
1359 let mut extensions = Vec::new();
1360 for bundle in &evidence.bundles {
1361 if !is_safe_report_path(&bundle.relative_path)
1362 || previous_path.is_some_and(|previous| previous >= bundle.relative_path.as_str())
1363 || !is_safe_public_text(&bundle.bundle_identifier)
1364 || bundle.certificate_sha256_fingerprint != evidence.certificate_sha256_fingerprint
1365 {
1366 return Err(GithubArtifactError::InvalidPublicReport(
1367 RequiredArtifactFile::ValidationReport,
1368 ));
1369 }
1370 previous_path = Some(bundle.relative_path.as_str());
1371 if !actual_targets.insert((bundle.bundle_identifier.clone(), bundle.kind)) {
1372 return Err(GithubArtifactError::InvalidPublicReport(
1373 RequiredArtifactFile::ValidationReport,
1374 ));
1375 }
1376 match bundle.kind {
1377 SigningTargetKind::Application | SigningTargetKind::Extension => {
1378 if bundle
1379 .profile_uuid
1380 .as_deref()
1381 .is_none_or(|value| !is_safe_public_identifier(value))
1382 || bundle
1383 .profile_expires_at_unix_seconds
1384 .is_none_or(|value| value == 0)
1385 || bundle
1386 .entitlements_sha256
1387 .as_deref()
1388 .is_none_or(|value| !is_lower_sha256(value))
1389 || bundle.selected_device_authorized != Some(true)
1390 {
1391 return Err(GithubArtifactError::InvalidPublicReport(
1392 RequiredArtifactFile::ValidationReport,
1393 ));
1394 }
1395 }
1396 SigningTargetKind::DynamicLibrary | SigningTargetKind::Framework => {
1397 if bundle.profile_uuid.is_some()
1398 || bundle.profile_expires_at_unix_seconds.is_some()
1399 || bundle.selected_device_authorized.is_some()
1400 {
1401 return Err(GithubArtifactError::InvalidPublicReport(
1402 RequiredArtifactFile::ValidationReport,
1403 ));
1404 }
1405 }
1406 }
1407 if bundle.kind == SigningTargetKind::Application {
1408 applications.push(bundle);
1409 } else if bundle.kind == SigningTargetKind::Extension {
1410 extensions.push(bundle.bundle_identifier.clone());
1411 }
1412 }
1413 if applications.len() != 1 {
1414 return Err(GithubArtifactError::InvalidPublicReport(
1415 RequiredArtifactFile::ValidationReport,
1416 ));
1417 }
1418 extensions.sort();
1419 let application = applications[0];
1420 let expiration_matches = application
1421 .profile_expires_at_unix_seconds
1422 .and_then(worker_timestamp_from_unix)
1423 .as_deref()
1424 == signing.profile_expiration.as_deref();
1425 if application.relative_path != "."
1426 || application.bundle_identifier != manifest.bundle_identifier
1427 || application.profile_uuid.as_deref() != signing.profile_uuid.as_deref()
1428 || application.entitlements_sha256.as_deref() != signing.entitlements_sha256.as_deref()
1429 || !expiration_matches
1430 || extensions != manifest.extensions
1431 || actual_targets != expected_targets
1432 {
1433 return Err(GithubArtifactError::EvidenceMismatch);
1434 }
1435 validate_signed_products_report(&report.signed_products, required_files)?;
1436 Ok(())
1437}
1438
1439fn validate_signed_products_report(
1440 products: &PublishedSignedProductsEvidence,
1441 required_files: &BTreeSet<RequiredArtifactFile>,
1442) -> Result<(), GithubArtifactError> {
1443 let app_requested = required_files.contains(&RequiredArtifactFile::AppBundleArchive);
1444 let archive_requested = required_files.contains(&RequiredArtifactFile::SignedXcarchive);
1445 let dsym_requested = required_files.contains(&RequiredArtifactFile::DsymArchive);
1446 let product_materialized = app_requested || archive_requested || dsym_requested;
1447 if products.app_tree.is_some() != product_materialized
1448 || products.archive.is_some() != archive_requested
1449 || products.dsym.is_some() != dsym_requested
1450 {
1451 return Err(GithubArtifactError::EvidenceMismatch);
1452 }
1453 if let Some(app_tree) = &products.app_tree {
1454 validate_signed_tree_evidence(app_tree)?;
1455 }
1456 if let Some(archive) = &products.archive {
1457 validate_signed_tree_evidence(&archive.app_tree)?;
1458 if !archive.root_deep_signature_verified
1459 || Some(&archive.app_tree) != products.app_tree.as_ref()
1460 {
1461 return Err(GithubArtifactError::EvidenceMismatch);
1462 }
1463 }
1464 if let Some(dsym) = &products.dsym
1465 && (dsym.architecture != "arm64"
1466 || dsym.signed_executable_uuid != dsym.dsym_uuid
1467 || !is_canonical_macho_uuid(&dsym.signed_executable_uuid))
1468 {
1469 return Err(GithubArtifactError::EvidenceMismatch);
1470 }
1471 Ok(())
1472}
1473
1474fn validate_signed_tree_evidence(
1475 evidence: &PublishedSignedTreeEvidence,
1476) -> Result<(), GithubArtifactError> {
1477 if evidence.entry_count == 0 || evidence.total_size == 0 || !is_lower_sha256(&evidence.sha256) {
1478 return Err(GithubArtifactError::EvidenceMismatch);
1479 }
1480 Ok(())
1481}
1482
1483fn is_canonical_macho_uuid(value: &str) -> bool {
1484 value != "00000000-0000-0000-0000-000000000000"
1485 && value.len() == 36
1486 && value.bytes().enumerate().all(|(index, byte)| {
1487 if matches!(index, 8 | 13 | 18 | 23) {
1488 byte == b'-'
1489 } else {
1490 byte.is_ascii_digit() || matches!(byte, b'A'..=b'F')
1491 }
1492 })
1493}
1494
1495fn validate_staged_files(
1496 staged_paths: &BTreeMap<RequiredArtifactFile, Utf8PathBuf>,
1497 manifest_bytes: &[u8],
1498 records: &BTreeMap<RequiredArtifactFile, &ArtifactRecord>,
1499 expectation: &IpaExpectation,
1500 manifest: &ArtifactManifest,
1501 required_files: &BTreeSet<RequiredArtifactFile>,
1502 report: &PublishedEvidenceReport,
1503) -> Result<IpaInspection, GithubArtifactError> {
1504 let manifest_record = ArtifactRecord {
1505 artifact_id: "artifact-manifest".to_owned(),
1506 kind: ArtifactKind::Manifest,
1507 file_name: ARTIFACT_MANIFEST_NAME.to_owned(),
1508 size: u64::try_from(manifest_bytes.len())
1509 .map_err(|_| GithubArtifactError::InvalidManifest)?,
1510 sha256: sha256_bytes(manifest_bytes),
1511 media_type: Some("application/json".to_owned()),
1512 };
1513 verify_downloaded_file(
1514 &staged_paths[&RequiredArtifactFile::Manifest],
1515 &manifest_record,
1516 )
1517 .map_err(|_| GithubArtifactError::ArtifactIntegrityFailed(RequiredArtifactFile::Manifest))?;
1518 for file in required_files
1519 .iter()
1520 .copied()
1521 .filter(|file| *file != RequiredArtifactFile::Manifest)
1522 {
1523 verify_downloaded_file(&staged_paths[&file], records[&file])
1524 .map_err(|_| GithubArtifactError::ArtifactIntegrityFailed(file))?;
1525 }
1526 let inspection = inspect_ipa(&staged_paths[&RequiredArtifactFile::Ipa], expectation)
1527 .map_err(|_| GithubArtifactError::IpaInspectionFailed)?;
1528 if inspection.sha256 != records[&RequiredArtifactFile::Ipa].sha256
1529 || inspection.size != records[&RequiredArtifactFile::Ipa].size
1530 || inspection.bundle_identifier != manifest.bundle_identifier
1531 || inspection.extensions != manifest.extensions
1532 || !inspection.provisioning_profile_present
1533 {
1534 return Err(GithubArtifactError::EvidenceMismatch);
1535 }
1536 validate_signed_product_archives(staged_paths, expectation, required_files, report)?;
1537 Ok(inspection)
1538}
1539
1540#[derive(Clone, Debug, Eq, PartialEq)]
1541struct SignedTreeInspection {
1542 directories: BTreeSet<String>,
1543 files: BTreeMap<String, SignedTreeFile>,
1544 total_size: u64,
1545}
1546
1547#[derive(Clone, Debug, Eq, PartialEq)]
1548struct SignedTreeFile {
1549 size: u64,
1550 sha256: String,
1551 executable: bool,
1552}
1553
1554impl SignedTreeInspection {
1555 fn evidence(&self) -> Result<PublishedSignedTreeEvidence, GithubArtifactError> {
1556 let mut digest = Sha256::new();
1557 digest.update(SIGNED_TREE_SHA256_DOMAIN);
1558 digest.update(
1559 u64::try_from(self.directories.len())
1560 .map_err(|_| GithubArtifactError::EvidenceMismatch)?
1561 .to_be_bytes(),
1562 );
1563 for path in &self.directories {
1564 update_signed_tree_path(&mut digest, path)?;
1565 }
1566 digest.update(
1567 u64::try_from(self.files.len())
1568 .map_err(|_| GithubArtifactError::EvidenceMismatch)?
1569 .to_be_bytes(),
1570 );
1571 for (path, file) in &self.files {
1572 update_signed_tree_path(&mut digest, path)?;
1573 digest.update(file.size.to_be_bytes());
1574 let sha256 =
1575 hex::decode(&file.sha256).map_err(|_| GithubArtifactError::EvidenceMismatch)?;
1576 if sha256.len() != 32 {
1577 return Err(GithubArtifactError::EvidenceMismatch);
1578 }
1579 digest.update(sha256);
1580 digest.update([u8::from(file.executable)]);
1581 }
1582 Ok(PublishedSignedTreeEvidence {
1583 entry_count: u32::try_from(self.files.len())
1584 .map_err(|_| GithubArtifactError::EvidenceMismatch)?,
1585 total_size: self.total_size,
1586 sha256: hex::encode(digest.finalize()),
1587 })
1588 }
1589}
1590
1591fn update_signed_tree_path(digest: &mut Sha256, path: &str) -> Result<(), GithubArtifactError> {
1592 digest.update(
1593 u32::try_from(path.len())
1594 .map_err(|_| GithubArtifactError::EvidenceMismatch)?
1595 .to_be_bytes(),
1596 );
1597 digest.update(path.as_bytes());
1598 Ok(())
1599}
1600
1601#[allow(clippy::too_many_lines)]
1602fn validate_signed_product_archives(
1603 staged_paths: &BTreeMap<RequiredArtifactFile, Utf8PathBuf>,
1604 expectation: &IpaExpectation,
1605 required_files: &BTreeSet<RequiredArtifactFile>,
1606 report: &PublishedEvidenceReport,
1607) -> Result<(), GithubArtifactError> {
1608 let app_requested = required_files.contains(&RequiredArtifactFile::AppBundleArchive);
1609 let archive_requested = required_files.contains(&RequiredArtifactFile::SignedXcarchive);
1610 let dsym_requested = required_files.contains(&RequiredArtifactFile::DsymArchive);
1611 if !app_requested && !archive_requested && !dsym_requested {
1612 return Ok(());
1613 }
1614 let ipa_root = format!("Payload/{}", expectation.app_directory_name);
1615 let ipa = inspect_signed_tree_zip(
1616 &staged_paths[&RequiredArtifactFile::Ipa],
1617 &ipa_root,
1618 None,
1619 RequiredArtifactFile::Ipa,
1620 Some(&expectation.executable),
1621 )?;
1622 let app_evidence = ipa.tree.evidence()?;
1623 if report.signed_products.app_tree.as_ref() != Some(&app_evidence) {
1624 return Err(GithubArtifactError::ProductEvidenceMismatch(
1625 RequiredArtifactFile::Ipa,
1626 ));
1627 }
1628 if app_requested {
1629 let app = inspect_signed_tree_zip(
1630 &staged_paths[&RequiredArtifactFile::AppBundleArchive],
1631 &expectation.app_directory_name,
1632 Some(&expectation.app_directory_name),
1633 RequiredArtifactFile::AppBundleArchive,
1634 None,
1635 )?;
1636 if app.tree != ipa.tree {
1637 return Err(GithubArtifactError::ProductEvidenceMismatch(
1638 RequiredArtifactFile::AppBundleArchive,
1639 ));
1640 }
1641 }
1642 if archive_requested {
1643 let stem = expectation
1644 .app_directory_name
1645 .strip_suffix(".app")
1646 .ok_or(GithubArtifactError::InvalidExpectation)?;
1647 let archive_root = format!("{stem}.xcarchive");
1648 let archive_app_root = format!(
1649 "{archive_root}/Products/Applications/{}",
1650 expectation.app_directory_name
1651 );
1652 let archive = inspect_signed_tree_zip(
1653 &staged_paths[&RequiredArtifactFile::SignedXcarchive],
1654 &archive_app_root,
1655 Some(&archive_root),
1656 RequiredArtifactFile::SignedXcarchive,
1657 None,
1658 )?;
1659 if archive.tree != ipa.tree
1660 || report
1661 .signed_products
1662 .archive
1663 .as_ref()
1664 .map(|evidence| &evidence.app_tree)
1665 != Some(&app_evidence)
1666 {
1667 return Err(GithubArtifactError::ProductEvidenceMismatch(
1668 RequiredArtifactFile::SignedXcarchive,
1669 ));
1670 }
1671 }
1672 if dsym_requested {
1673 let dsym_root = format!("{}.dSYM", expectation.app_directory_name);
1674 let dwarf_relative = format!("Contents/Resources/DWARF/{}", expectation.executable);
1675 let dsym = inspect_signed_tree_zip(
1676 &staged_paths[&RequiredArtifactFile::DsymArchive],
1677 &dsym_root,
1678 Some(&dsym_root),
1679 RequiredArtifactFile::DsymArchive,
1680 Some(&dwarf_relative),
1681 )?;
1682 let dwarf_entries = dsym
1683 .tree
1684 .files
1685 .keys()
1686 .filter(|path| path.starts_with("Contents/Resources/DWARF/"))
1687 .collect::<Vec<_>>();
1688 if dwarf_entries.as_slice() != [&dwarf_relative]
1689 || !dsym.tree.files.contains_key("Contents/Info.plist")
1690 {
1691 return Err(GithubArtifactError::InvalidProductArchive(
1692 RequiredArtifactFile::DsymArchive,
1693 ));
1694 }
1695 let main_bytes = ipa
1696 .captured
1697 .ok_or(GithubArtifactError::InvalidProductArchive(
1698 RequiredArtifactFile::Ipa,
1699 ))?;
1700 let dsym_bytes = dsym
1701 .captured
1702 .ok_or(GithubArtifactError::InvalidProductArchive(
1703 RequiredArtifactFile::DsymArchive,
1704 ))?;
1705 let main_uuid = arm64_macho_uuid(&main_bytes, goblin::mach::header::MH_EXECUTE)
1706 .map_err(|()| GithubArtifactError::InvalidProductArchive(RequiredArtifactFile::Ipa))?;
1707 let dsym_uuid =
1708 arm64_macho_uuid(&dsym_bytes, goblin::mach::header::MH_DSYM).map_err(|()| {
1709 GithubArtifactError::InvalidProductArchive(RequiredArtifactFile::DsymArchive)
1710 })?;
1711 let evidence = report
1712 .signed_products
1713 .dsym
1714 .as_ref()
1715 .ok_or(GithubArtifactError::EvidenceMismatch)?;
1716 if main_uuid != dsym_uuid
1717 || main_uuid != evidence.signed_executable_uuid
1718 || dsym_uuid != evidence.dsym_uuid
1719 {
1720 return Err(GithubArtifactError::ProductEvidenceMismatch(
1721 RequiredArtifactFile::DsymArchive,
1722 ));
1723 }
1724 }
1725 Ok(())
1726}
1727
1728struct InspectedSignedTreeZip {
1729 tree: SignedTreeInspection,
1730 captured: Option<Vec<u8>>,
1731}
1732
1733#[allow(clippy::too_many_lines)]
1734fn inspect_signed_tree_zip(
1735 path: &Utf8Path,
1736 tree_root: &str,
1737 wrapper_root: Option<&str>,
1738 artifact: RequiredArtifactFile,
1739 capture_relative: Option<&str>,
1740) -> Result<InspectedSignedTreeZip, GithubArtifactError> {
1741 let file = open_regular_archive(path)
1742 .map_err(|_| GithubArtifactError::InvalidProductArchive(artifact))?;
1743 let archive_size = file
1744 .metadata()
1745 .map_err(|_| GithubArtifactError::InvalidProductArchive(artifact))?
1746 .len();
1747 if archive_size == 0 || archive_size > artifact.maximum_size() {
1748 return Err(GithubArtifactError::InvalidProductArchive(artifact));
1749 }
1750 let mut archive =
1751 ZipArchive::new(file).map_err(|_| GithubArtifactError::InvalidProductArchive(artifact))?;
1752 if archive.is_empty() || archive.len() > MAX_INNER_ENTRY_COUNT {
1753 return Err(GithubArtifactError::InvalidProductArchive(artifact));
1754 }
1755 let root_prefix = format!("{tree_root}/");
1756 let wrapper_prefix = wrapper_root.map(|root| format!("{root}/"));
1757 let wrapper_info_path = wrapper_root.map(|root| format!("{root}/Info.plist"));
1758 let mut exact_names = BTreeSet::new();
1759 let mut portable_names = BTreeMap::<String, String>::new();
1760 let mut header_starts = BTreeSet::new();
1761 let mut compressed_ranges = Vec::with_capacity(archive.len());
1762 let mut archive_directories = BTreeSet::new();
1763 let mut archive_files = BTreeSet::new();
1764 let mut directories = BTreeSet::new();
1765 let mut files = BTreeMap::new();
1766 let mut total_size = 0_u64;
1767 let mut expanded_size = 0_u64;
1768 let mut captured = None;
1769 let mut wrapper_root_directory = wrapper_root.is_none();
1770 let mut wrapper_info_plist = false;
1771 let mut buffer = vec![0_u8; 64 * 1024].into_boxed_slice();
1772 for index in 0..archive.len() {
1773 let mut entry = archive
1774 .by_index(index)
1775 .map_err(|_| GithubArtifactError::InvalidProductArchive(artifact))?;
1776 let (name, is_directory) = validate_inner_entry_name(entry.name_raw())
1777 .map_err(|()| GithubArtifactError::InvalidProductArchive(artifact))?;
1778 let name = name.to_owned();
1779 if !exact_names.insert(name.clone())
1780 || portable_names
1781 .insert(portable_name_key(&name), name.clone())
1782 .is_some()
1783 {
1784 return Err(GithubArtifactError::InvalidProductArchive(artifact));
1785 }
1786 if !header_starts.insert(entry.header_start()) {
1787 return Err(GithubArtifactError::InvalidProductArchive(artifact));
1788 }
1789 let data_start = entry
1790 .data_start()
1791 .ok_or(GithubArtifactError::InvalidProductArchive(artifact))?;
1792 let data_end = data_start
1793 .checked_add(entry.compressed_size())
1794 .ok_or(GithubArtifactError::InvalidProductArchive(artifact))?;
1795 if data_end > archive_size {
1796 return Err(GithubArtifactError::InvalidProductArchive(artifact));
1797 }
1798 compressed_ranges.push((data_start, data_end));
1799 if let (Some(wrapper_root), Some(wrapper_prefix)) =
1800 (wrapper_root, wrapper_prefix.as_deref())
1801 {
1802 if name != wrapper_root && !name.starts_with(wrapper_prefix) {
1803 return Err(GithubArtifactError::InvalidProductArchive(artifact));
1804 }
1805 if name == wrapper_root && is_directory {
1806 wrapper_root_directory = true;
1807 }
1808 }
1809 validate_inner_entry_metadata(&entry, is_directory, artifact)?;
1810 if is_sensitive_inner_path(&name) {
1811 return Err(GithubArtifactError::InvalidProductArchive(artifact));
1812 }
1813 insert_parent_directories(&name, &mut archive_directories);
1814 if is_directory {
1815 archive_directories.insert(name.clone());
1816 } else {
1817 archive_files.insert(name.clone());
1818 }
1819 if !is_directory && wrapper_info_path.as_deref() == Some(name.as_str()) {
1820 wrapper_info_plist = true;
1821 }
1822 expanded_size = expanded_size
1823 .checked_add(entry.size())
1824 .ok_or(GithubArtifactError::InvalidProductArchive(artifact))?;
1825 if expanded_size > MAX_INNER_TOTAL_BYTES {
1826 return Err(GithubArtifactError::InvalidProductArchive(artifact));
1827 }
1828 let relative = if name == tree_root {
1829 if !is_directory {
1830 return Err(GithubArtifactError::InvalidProductArchive(artifact));
1831 }
1832 None
1833 } else {
1834 name.strip_prefix(&root_prefix)
1835 };
1836 if relative.is_some_and(str::is_empty) {
1837 return Err(GithubArtifactError::InvalidProductArchive(artifact));
1838 }
1839 if let Some(relative) = relative {
1840 insert_parent_directories(relative, &mut directories);
1841 }
1842 if is_directory {
1843 if let Some(relative) = relative {
1844 if files.contains_key(relative) {
1845 return Err(GithubArtifactError::InvalidProductArchive(artifact));
1846 }
1847 directories.insert(relative.to_owned());
1848 }
1849 continue;
1850 }
1851 if relative
1852 .is_some_and(|relative| directories.contains(relative) || files.contains_key(relative))
1853 {
1854 return Err(GithubArtifactError::InvalidProductArchive(artifact));
1855 }
1856 let executable = entry.unix_mode().is_some_and(|mode| mode & 0o111 != 0);
1857 let capture = relative.is_some_and(|relative| capture_relative == Some(relative));
1858 if capture && (entry.size() == 0 || entry.size() > MAX_CAPTURED_MACHO_BYTES) {
1859 return Err(GithubArtifactError::InvalidProductArchive(artifact));
1860 }
1861 let mut hasher = Sha256::new();
1862 let mut bytes = capture.then(Vec::new);
1863 let mut read = 0_u64;
1864 loop {
1865 let count = entry
1866 .read(&mut buffer)
1867 .map_err(|_| GithubArtifactError::InvalidProductArchive(artifact))?;
1868 if count == 0 {
1869 break;
1870 }
1871 read = read
1872 .checked_add(
1873 u64::try_from(count)
1874 .map_err(|_| GithubArtifactError::InvalidProductArchive(artifact))?,
1875 )
1876 .ok_or(GithubArtifactError::InvalidProductArchive(artifact))?;
1877 if read > entry.size() {
1878 return Err(GithubArtifactError::InvalidProductArchive(artifact));
1879 }
1880 hasher.update(&buffer[..count]);
1881 if let Some(bytes) = &mut bytes {
1882 bytes.extend_from_slice(&buffer[..count]);
1883 }
1884 }
1885 if read != entry.size() {
1886 return Err(GithubArtifactError::InvalidProductArchive(artifact));
1887 }
1888 if let Some(relative) = relative {
1889 total_size = total_size
1890 .checked_add(read)
1891 .ok_or(GithubArtifactError::InvalidProductArchive(artifact))?;
1892 files.insert(
1893 relative.to_owned(),
1894 SignedTreeFile {
1895 size: read,
1896 sha256: hex::encode(hasher.finalize()),
1897 executable,
1898 },
1899 );
1900 }
1901 if capture && captured.replace(bytes.unwrap_or_default()).is_some() {
1902 return Err(GithubArtifactError::InvalidProductArchive(artifact));
1903 }
1904 }
1905 if zip_layout_has_aliases(&header_starts, &mut compressed_ranges) {
1906 return Err(GithubArtifactError::InvalidProductArchive(artifact));
1907 }
1908 let mut tree_portable_paths = BTreeMap::<String, &str>::new();
1909 let tree_paths_are_portable = directories
1910 .iter()
1911 .map(String::as_str)
1912 .chain(files.keys().map(String::as_str))
1913 .all(|path| {
1914 tree_portable_paths
1915 .insert(portable_name_key(path), path)
1916 .is_none()
1917 });
1918 if files.is_empty()
1919 || (!files.contains_key("Info.plist") && !files.contains_key("Contents/Info.plist"))
1920 || directories.iter().any(|path| files.contains_key(path))
1921 || archive_directories
1922 .iter()
1923 .any(|path| archive_files.contains(path))
1924 || !tree_paths_are_portable
1925 || !wrapper_root_directory
1926 || (artifact == RequiredArtifactFile::SignedXcarchive && !wrapper_info_plist)
1927 {
1928 return Err(GithubArtifactError::InvalidProductArchive(artifact));
1929 }
1930 Ok(InspectedSignedTreeZip {
1931 tree: SignedTreeInspection {
1932 directories,
1933 files,
1934 total_size,
1935 },
1936 captured,
1937 })
1938}
1939
1940fn validate_inner_entry_name(raw: &[u8]) -> Result<(&str, bool), ()> {
1941 let raw = std::str::from_utf8(raw).map_err(|_| ())?;
1942 let is_directory = raw.ends_with('/');
1943 let name = raw.strip_suffix('/').unwrap_or(raw);
1944 if name.is_empty()
1945 || name.len() > MAX_INNER_PATH_BYTES
1946 || name.starts_with('/')
1947 || name.starts_with('\\')
1948 || name.contains('\\')
1949 || name.contains('\0')
1950 || name.chars().any(char::is_control)
1951 || (name.len() >= 2 && name.as_bytes()[1] == b':')
1952 || name.split('/').count() > MAX_INNER_TREE_DEPTH
1953 || name
1954 .split('/')
1955 .any(|component| !is_portable_inner_component(component))
1956 {
1957 return Err(());
1958 }
1959 Ok((name, is_directory))
1960}
1961
1962fn is_sensitive_inner_path(name: &str) -> bool {
1963 name.split('/').any(|component| {
1964 let lower = component.to_ascii_lowercase();
1965 let extension = lower.rsplit_once('.').map(|(_, extension)| extension);
1966 matches!(
1967 extension,
1968 Some("p12" | "p8" | "key" | "pem" | "swift" | "m" | "mm")
1969 ) || lower == "project.pbxproj"
1970 || matches!(lower.as_str(), "keychains" | "credentials" | "secrets")
1971 })
1972}
1973
1974fn is_portable_inner_component(component: &str) -> bool {
1975 let basename = component
1976 .split('.')
1977 .next()
1978 .unwrap_or_default()
1979 .to_ascii_uppercase();
1980 let reserved = matches!(basename.as_str(), "CON" | "PRN" | "AUX" | "NUL")
1981 || basename
1982 .strip_prefix("COM")
1983 .or_else(|| basename.strip_prefix("LPT"))
1984 .is_some_and(|suffix| {
1985 matches!(suffix, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")
1986 });
1987 !component.is_empty()
1988 && component.len() <= 255
1989 && !matches!(component, "." | "..")
1990 && !component.ends_with(['.', ' '])
1991 && !component.contains([':', '*', '?', '"', '<', '>', '|'])
1992 && !reserved
1993}
1994
1995fn validate_inner_entry_metadata(
1996 entry: &ZipFile<'_, File>,
1997 is_directory: bool,
1998 artifact: RequiredArtifactFile,
1999) -> Result<(), GithubArtifactError> {
2000 if entry.encrypted()
2001 || !matches!(
2002 entry.compression(),
2003 CompressionMethod::Stored | CompressionMethod::Deflated
2004 )
2005 || entry.is_symlink()
2006 || entry.is_dir() != is_directory
2007 {
2008 return Err(GithubArtifactError::InvalidProductArchive(artifact));
2009 }
2010 if let Some(mode) = entry.unix_mode() {
2011 let kind = mode & 0o170_000;
2012 let expected = if is_directory { 0o040_000 } else { 0o100_000 };
2013 if kind != 0 && kind != expected {
2014 return Err(GithubArtifactError::InvalidProductArchive(artifact));
2015 }
2016 }
2017 if (is_directory && (entry.size() != 0 || entry.compressed_size() != 0))
2018 || (!is_directory
2019 && (entry.size() > MAX_INNER_ENTRY_BYTES
2020 || (entry.size() != 0
2021 && (entry.compressed_size() == 0
2022 || entry.size()
2023 > entry
2024 .compressed_size()
2025 .saturating_mul(MAX_COMPRESSION_RATIO)))))
2026 {
2027 return Err(GithubArtifactError::InvalidProductArchive(artifact));
2028 }
2029 Ok(())
2030}
2031
2032fn insert_parent_directories(path: &str, directories: &mut BTreeSet<String>) {
2033 let mut parent = String::new();
2034 let mut components = path.split('/').peekable();
2035 while let Some(component) = components.next() {
2036 if components.peek().is_none() {
2037 break;
2038 }
2039 if !parent.is_empty() {
2040 parent.push('/');
2041 }
2042 parent.push_str(component);
2043 directories.insert(parent.clone());
2044 }
2045}
2046
2047fn arm64_macho_uuid(bytes: &[u8], expected_file_type: u32) -> Result<String, ()> {
2048 let parsed = Mach::parse(bytes).map_err(|_| ())?;
2049 let mut uuids = Vec::new();
2050 match parsed {
2051 Mach::Binary(binary) => {
2052 if binary.header.cputype != goblin::mach::constants::cputype::CPU_TYPE_ARM64 {
2053 return Err(());
2054 }
2055 uuids.push(macho_uuid(&binary, expected_file_type)?);
2056 }
2057 Mach::Fat(container) => {
2058 for entry in &container {
2059 let SingleArch::MachO(binary) = entry.map_err(|_| ())? else {
2060 return Err(());
2061 };
2062 if binary.header.cputype != goblin::mach::constants::cputype::CPU_TYPE_ARM64 {
2063 return Err(());
2064 }
2065 uuids.push(macho_uuid(&binary, expected_file_type)?);
2066 }
2067 }
2068 }
2069 if uuids.len() != 1 {
2070 return Err(());
2071 }
2072 uuids.pop().ok_or(())
2073}
2074
2075fn macho_uuid(binary: &goblin::mach::MachO<'_>, expected_file_type: u32) -> Result<String, ()> {
2076 if binary.header.filetype != expected_file_type {
2077 return Err(());
2078 }
2079 let uuids = binary
2080 .load_commands
2081 .iter()
2082 .filter_map(|command| match &command.command {
2083 CommandVariant::Uuid(command) => Some(command.uuid),
2084 _ => None,
2085 })
2086 .collect::<Vec<_>>();
2087 if uuids.len() != 1 {
2088 return Err(());
2089 }
2090 let bytes = uuids[0];
2091 if bytes.iter().all(|byte| *byte == 0) {
2092 return Err(());
2093 }
2094 Ok(format!(
2095 "{:02X}{:02X}{:02X}{:02X}-{:02X}{:02X}-{:02X}{:02X}-{:02X}{:02X}-{:02X}{:02X}{:02X}{:02X}{:02X}{:02X}",
2096 bytes[0],
2097 bytes[1],
2098 bytes[2],
2099 bytes[3],
2100 bytes[4],
2101 bytes[5],
2102 bytes[6],
2103 bytes[7],
2104 bytes[8],
2105 bytes[9],
2106 bytes[10],
2107 bytes[11],
2108 bytes[12],
2109 bytes[13],
2110 bytes[14],
2111 bytes[15]
2112 ))
2113}
2114
2115fn extract_entry_to_new_file(
2116 archive: &mut ZipArchive<File>,
2117 metadata: EntryMetadata,
2118 artifact: RequiredArtifactFile,
2119 path: &Utf8Path,
2120) -> Result<(), GithubArtifactError> {
2121 let mut entry = archive
2122 .by_index(metadata.index)
2123 .map_err(|_| GithubArtifactError::InvalidArchive)?;
2124 let mut output = create_new_artifact_file(path)?;
2125 let result = (|| {
2126 let copied = io::copy(
2127 &mut entry.by_ref().take(metadata.size.saturating_add(1)),
2128 &mut output,
2129 )
2130 .map_err(|_| GithubArtifactError::EntryIntegrityFailed(artifact))?;
2131 if copied != metadata.size {
2132 return Err(GithubArtifactError::EntryIntegrityFailed(artifact));
2133 }
2134 output.flush().map_err(io_error)?;
2135 output.sync_all().map_err(io_error)
2136 })();
2137 if let Err(error) = result {
2138 cleanup_created_file(path, output)?;
2139 return Err(error);
2140 }
2141 Ok(())
2142}
2143
2144fn write_new_file(path: &Utf8Path, bytes: &[u8]) -> Result<(), GithubArtifactError> {
2145 let mut output = create_new_artifact_file(path)?;
2146 let result = output
2147 .write_all(bytes)
2148 .and_then(|()| output.flush())
2149 .and_then(|()| output.sync_all())
2150 .map_err(io_error);
2151 if let Err(error) = result {
2152 cleanup_created_file(path, output)?;
2153 return Err(error);
2154 }
2155 Ok(())
2156}
2157
2158#[cfg(windows)]
2159fn create_new_artifact_file(path: &Utf8Path) -> Result<File, GithubArtifactError> {
2160 create_windows_private_file(path.as_std_path()).map_err(map_windows_private_file_error)
2161}
2162
2163#[cfg(not(windows))]
2164fn create_new_artifact_file(path: &Utf8Path) -> Result<File, GithubArtifactError> {
2165 let mut options = OpenOptions::new();
2166 options.write(true).create_new(true);
2167 #[cfg(unix)]
2168 options.mode(0o600);
2169 options.open(path).map_err(io_error)
2170}
2171
2172#[cfg(windows)]
2173fn cleanup_created_file(_path: &Utf8Path, file: File) -> Result<(), GithubArtifactError> {
2174 remove_windows_private_file_handle(file).map_err(|_| GithubArtifactError::CleanupFailed)
2175}
2176
2177#[cfg(not(windows))]
2178fn cleanup_created_file(path: &Utf8Path, file: File) -> Result<(), GithubArtifactError> {
2179 drop(file);
2180 fs::remove_file(path).map_err(|_| GithubArtifactError::CleanupFailed)
2181}
2182
2183struct PublishedLink {
2184 path: Utf8PathBuf,
2185 linked_file: File,
2186 #[cfg(windows)]
2187 staging_path: Utf8PathBuf,
2188 #[cfg(windows)]
2189 staging_file: File,
2190 #[cfg(windows)]
2191 staging_removed: Cell<bool>,
2192}
2193
2194#[cfg(windows)]
2195fn publish_no_replace(
2196 staged: &BTreeMap<RequiredArtifactFile, Utf8PathBuf>,
2197 output_directory: &Utf8Path,
2198 required_files: &BTreeSet<RequiredArtifactFile>,
2199) -> Result<BTreeMap<RequiredArtifactFile, PublishedLink>, GithubArtifactError> {
2200 for file in required_files.iter().copied() {
2201 ensure_output_absent(&output_directory.join(file.file_name()), file)?;
2202 }
2203 let mut published = BTreeMap::new();
2204 let publication_order = required_files
2205 .iter()
2206 .copied()
2207 .filter(|file| *file != RequiredArtifactFile::Ipa)
2208 .chain(std::iter::once(RequiredArtifactFile::Ipa));
2209 for file in publication_order {
2210 let staging_path = staged[&file].clone();
2211 let Ok(staging_file) = open_windows_private_file_for_removal(staging_path.as_std_path())
2212 else {
2213 cleanup_published_links(published.values())?;
2214 return Err(GithubArtifactError::AtomicPublicationFailed);
2215 };
2216 let destination = output_directory.join(file.file_name());
2217 if let Err(error) = fs::hard_link(&staging_path, &destination) {
2218 cleanup_published_links(published.values())?;
2219 return Err(if error.kind() == io::ErrorKind::AlreadyExists {
2220 GithubArtifactError::OutputAlreadyExists(file)
2221 } else {
2222 GithubArtifactError::AtomicPublicationFailed
2223 });
2224 }
2225 if verify_windows_private_file_handle_in_state(
2226 staging_file.as_handle(),
2227 PrivateFileLinkState::PublicationPair,
2228 )
2229 .is_err()
2230 {
2231 let _ = cleanup_published_links(published.values());
2232 return Err(GithubArtifactError::CleanupFailed);
2233 }
2234 let Ok(linked_file) = open_windows_private_file_for_removal_in_state(
2235 destination.as_std_path(),
2236 PrivateFileLinkState::PublicationPair,
2237 ) else {
2238 let _ = remove_windows_private_file_handle_in_state(
2239 staging_file,
2240 PrivateFileLinkState::PublicationPair,
2241 );
2242 let _ = cleanup_published_links(published.values());
2243 return Err(GithubArtifactError::CleanupFailed);
2244 };
2245 let link = PublishedLink {
2246 path: destination,
2247 linked_file,
2248 staging_path,
2249 staging_file,
2250 staging_removed: Cell::new(false),
2251 };
2252 if published_link_matches(&link) != Ok(true) {
2253 let _ = cleanup_published_links([&link]);
2254 let _ = cleanup_published_links(published.values());
2255 return Err(GithubArtifactError::CleanupFailed);
2256 }
2257 published.insert(file, link);
2258 }
2259 Ok(published)
2260}
2261
2262#[cfg(windows)]
2263fn published_link_matches(link: &PublishedLink) -> Result<bool, GithubArtifactError> {
2264 let metadata = fs::symlink_metadata(&link.path).map_err(io_error)?;
2265 if metadata.file_type().is_symlink() || !metadata.is_file() {
2266 return Ok(false);
2267 }
2268 let expected_state = if link.staging_removed.get() {
2269 PrivateFileLinkState::Single
2270 } else {
2271 PrivateFileLinkState::PublicationPair
2272 };
2273 if verify_windows_private_file_handle_in_state(link.linked_file.as_handle(), expected_state)
2274 .is_err()
2275 || (!link.staging_removed.get()
2276 && verify_windows_private_file_handle_in_state(
2277 link.staging_file.as_handle(),
2278 PrivateFileLinkState::PublicationPair,
2279 )
2280 .is_err())
2281 || !open_files_match(&link.linked_file, &link.staging_file)?
2282 || !path_matches_file(&link.path, &link.linked_file)?
2283 || (!link.staging_removed.get()
2284 && !path_matches_file(&link.staging_path, &link.staging_file)?)
2285 {
2286 return Ok(false);
2287 }
2288 Ok(true)
2289}
2290
2291#[cfg(windows)]
2292fn finalize_published_links<'a>(
2293 links: impl IntoIterator<Item = &'a PublishedLink>,
2294 _staged_paths: impl IntoIterator<Item = &'a Utf8PathBuf>,
2295) -> Result<(), GithubArtifactError> {
2296 for link in links {
2297 if link.staging_removed.get() || published_link_matches(link) != Ok(true) {
2298 return Err(GithubArtifactError::CleanupFailed);
2299 }
2300 remove_windows_private_file_handle_in_state(
2301 link.staging_file.try_clone().map_err(io_error)?,
2302 PrivateFileLinkState::PublicationPair,
2303 )
2304 .map_err(|_| GithubArtifactError::CleanupFailed)?;
2305 link.staging_removed.set(true);
2306 verify_windows_private_file_handle(link.linked_file.as_handle())
2307 .map_err(|_| GithubArtifactError::CleanupFailed)?;
2308 if published_link_matches(link) != Ok(true) {
2309 return Err(GithubArtifactError::CleanupFailed);
2310 }
2311 }
2312 Ok(())
2313}
2314
2315#[cfg(windows)]
2316fn cleanup_published_links<'a>(
2317 links: impl IntoIterator<Item = &'a PublishedLink>,
2318) -> Result<(), GithubArtifactError> {
2319 let mut failed = false;
2320 for link in links {
2321 let result = if link.staging_removed.get() {
2322 remove_windows_private_file_handle(link.linked_file.try_clone().map_err(io_error)?)
2323 } else {
2324 let destination_cleanup = remove_windows_private_file_handle_in_state(
2325 link.linked_file.try_clone().map_err(io_error)?,
2326 PrivateFileLinkState::PublicationPair,
2327 );
2328 if destination_cleanup.is_err() {
2329 failed = true;
2330 continue;
2331 }
2332 link.staging_removed.set(true);
2333 remove_windows_private_file_handle(link.staging_file.try_clone().map_err(io_error)?)
2334 };
2335 if result.is_err() {
2336 failed = true;
2337 }
2338 }
2339 if failed {
2340 Err(GithubArtifactError::CleanupFailed)
2341 } else {
2342 Ok(())
2343 }
2344}
2345
2346#[cfg(windows)]
2347fn cleanup_paths<'a>(
2348 paths: impl IntoIterator<Item = &'a Utf8PathBuf>,
2349) -> Result<(), GithubArtifactError> {
2350 let mut failed = false;
2351 for path in paths {
2352 match fs::symlink_metadata(path) {
2353 Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
2354 Err(_) => {
2355 failed = true;
2356 continue;
2357 }
2358 Ok(_) => {}
2359 }
2360 match open_windows_private_file_for_removal(path.as_std_path()) {
2361 Ok(file) => {
2362 if remove_windows_private_file_handle(file).is_err() {
2363 failed = true;
2364 }
2365 }
2366 Err(_) => failed = true,
2367 }
2368 }
2369 if failed {
2370 Err(GithubArtifactError::CleanupFailed)
2371 } else {
2372 Ok(())
2373 }
2374}
2375
2376#[cfg(windows)]
2377fn open_files_match(left: &File, right: &File) -> Result<bool, GithubArtifactError> {
2378 let left =
2379 FileIdentityHandle::from_file(left.try_clone().map_err(io_error)?).map_err(io_error)?;
2380 let right =
2381 FileIdentityHandle::from_file(right.try_clone().map_err(io_error)?).map_err(io_error)?;
2382 Ok(left == right)
2383}
2384
2385#[cfg(windows)]
2386fn path_matches_file(path: &Utf8Path, file: &File) -> Result<bool, GithubArtifactError> {
2387 let open =
2388 FileIdentityHandle::from_file(file.try_clone().map_err(io_error)?).map_err(io_error)?;
2389 let path = FileIdentityHandle::from_path(path).map_err(io_error)?;
2390 Ok(open == path)
2391}
2392
2393#[cfg(not(windows))]
2394fn publish_no_replace(
2395 staged: &BTreeMap<RequiredArtifactFile, Utf8PathBuf>,
2396 output_directory: &Utf8Path,
2397 required_files: &BTreeSet<RequiredArtifactFile>,
2398) -> Result<BTreeMap<RequiredArtifactFile, PublishedLink>, GithubArtifactError> {
2399 for file in required_files.iter().copied() {
2400 ensure_output_absent(&output_directory.join(file.file_name()), file)?;
2401 }
2402 let mut published = BTreeMap::new();
2403 let publication_order = required_files
2404 .iter()
2405 .copied()
2406 .filter(|file| *file != RequiredArtifactFile::Ipa)
2407 .chain(std::iter::once(RequiredArtifactFile::Ipa));
2408 for file in publication_order {
2409 let destination = output_directory.join(file.file_name());
2410 let linked_file = match File::open(&staged[&file]) {
2411 Ok(file) if file.metadata().is_ok_and(|metadata| metadata.is_file()) => file,
2412 Ok(_) | Err(_) => {
2413 cleanup_published_links(published.values())?;
2414 return Err(GithubArtifactError::AtomicPublicationFailed);
2415 }
2416 };
2417 match fs::hard_link(&staged[&file], &destination) {
2418 Ok(()) => {
2419 let link = PublishedLink {
2420 path: destination,
2421 linked_file,
2422 };
2423 if published_link_matches(&link) != Ok(true) {
2424 published.insert(file, link);
2425 cleanup_published_links(published.values())?;
2426 return Err(GithubArtifactError::AtomicPublicationFailed);
2427 }
2428 published.insert(file, link);
2429 }
2430 Err(error) => {
2431 cleanup_published_links(published.values())?;
2432 if error.kind() == io::ErrorKind::AlreadyExists {
2433 return Err(GithubArtifactError::OutputAlreadyExists(file));
2434 }
2435 return Err(GithubArtifactError::AtomicPublicationFailed);
2436 }
2437 }
2438 }
2439 Ok(published)
2440}
2441
2442#[cfg(not(windows))]
2443fn published_link_matches(link: &PublishedLink) -> Result<bool, GithubArtifactError> {
2444 let metadata = fs::symlink_metadata(&link.path).map_err(io_error)?;
2445 if metadata.file_type().is_symlink() || !metadata.is_file() {
2446 return Ok(false);
2447 }
2448 let open_identity =
2449 FileIdentityHandle::from_file(link.linked_file.try_clone().map_err(io_error)?)
2450 .map_err(io_error)?;
2451 let path_identity = FileIdentityHandle::from_path(&link.path).map_err(io_error)?;
2452 if open_identity != path_identity {
2453 return Ok(false);
2454 }
2455 let final_metadata = fs::symlink_metadata(&link.path).map_err(io_error)?;
2456 if final_metadata.file_type().is_symlink() || !final_metadata.is_file() {
2457 return Ok(false);
2458 }
2459 let final_identity = FileIdentityHandle::from_path(&link.path).map_err(io_error)?;
2460 Ok(open_identity == final_identity)
2461}
2462
2463#[cfg(not(windows))]
2464fn cleanup_published_links<'a>(
2465 links: impl IntoIterator<Item = &'a PublishedLink>,
2466) -> Result<(), GithubArtifactError> {
2467 let mut failed = false;
2468 for link in links {
2469 let metadata = match fs::symlink_metadata(&link.path) {
2470 Ok(metadata) => metadata,
2471 Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
2472 Err(_) => {
2473 failed = true;
2474 continue;
2475 }
2476 };
2477 if metadata.file_type().is_symlink()
2478 || !metadata.is_file()
2479 || published_link_matches(link) != Ok(true)
2480 {
2481 failed = true;
2482 continue;
2483 }
2484 if published_link_matches(link) != Ok(true) {
2485 failed = true;
2486 continue;
2487 }
2488 match fs::remove_file(&link.path) {
2489 Ok(()) => {}
2490 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
2491 Err(_) => failed = true,
2492 }
2493 }
2494 if failed {
2495 Err(GithubArtifactError::CleanupFailed)
2496 } else {
2497 Ok(())
2498 }
2499}
2500
2501fn ensure_output_absent(
2502 path: &Utf8Path,
2503 file: RequiredArtifactFile,
2504) -> Result<(), GithubArtifactError> {
2505 match fs::symlink_metadata(path) {
2506 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
2507 Ok(_) => Err(GithubArtifactError::OutputAlreadyExists(file)),
2508 Err(error) => Err(io_error(error)),
2509 }
2510}
2511
2512#[cfg(not(windows))]
2513fn cleanup_paths<'a>(
2514 paths: impl IntoIterator<Item = &'a Utf8PathBuf>,
2515) -> Result<(), GithubArtifactError> {
2516 let mut failed = false;
2517 for path in paths {
2518 match fs::remove_file(path) {
2519 Ok(()) => {}
2520 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
2521 Err(_) => failed = true,
2522 }
2523 }
2524 if failed {
2525 Err(GithubArtifactError::CleanupFailed)
2526 } else {
2527 Ok(())
2528 }
2529}
2530
2531#[cfg(not(windows))]
2532fn finalize_published_links<'a>(
2533 _links: impl IntoIterator<Item = &'a PublishedLink>,
2534 staged_paths: impl IntoIterator<Item = &'a Utf8PathBuf>,
2535) -> Result<(), GithubArtifactError> {
2536 cleanup_paths(staged_paths)
2537}
2538
2539fn validate_ipa_expectation(expectation: &IpaExpectation) -> Result<(), GithubArtifactError> {
2540 if !expectation.provisioning_required
2541 || Utf8Path::new(&expectation.app_directory_name).extension() != Some("app")
2542 || !is_safe_public_identifier(&expectation.app_directory_name)
2543 || !is_safe_bundle_identifier(&expectation.bundle_identifier)
2544 || !is_safe_public_identifier(&expectation.executable)
2545 || !is_safe_public_text(&expectation.minimum_os)
2546 || expectation
2547 .app_version
2548 .as_deref()
2549 .is_none_or(|value| !is_safe_public_text(value))
2550 || expectation
2551 .build_number
2552 .as_deref()
2553 .is_none_or(|value| !is_safe_public_text(value))
2554 || expectation.nested_bundles.len() > MAX_SIGNED_BUNDLES
2555 || !expectation
2556 .nested_bundles
2557 .windows(2)
2558 .all(|pair| pair[0].relative_path < pair[1].relative_path)
2559 {
2560 return Err(GithubArtifactError::InvalidExpectation);
2561 }
2562 Ok(())
2563}
2564
2565fn strict_safe_sorted_paths(paths: &[String], maximum: usize) -> bool {
2566 !paths.is_empty()
2567 && paths.len() <= maximum
2568 && paths.iter().all(|path| is_safe_report_path(path))
2569 && paths.windows(2).all(|pair| pair[0] < pair[1])
2570}
2571
2572fn is_safe_report_path(path: &str) -> bool {
2573 if path == "." {
2574 return true;
2575 }
2576 !path.is_empty()
2577 && path.len() <= 4_096
2578 && !path.starts_with('/')
2579 && !path.contains('\\')
2580 && !path.contains('\0')
2581 && !path.chars().any(char::is_control)
2582 && path
2583 .split('/')
2584 .all(|component| !component.is_empty() && !matches!(component, "." | ".."))
2585}
2586
2587fn is_safe_public_identifier(value: &str) -> bool {
2588 !value.is_empty()
2589 && value.len() <= MAX_PUBLIC_TEXT_BYTES
2590 && value
2591 .bytes()
2592 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
2593}
2594
2595fn is_safe_public_text(value: &str) -> bool {
2596 !value.is_empty()
2597 && value.len() <= MAX_PUBLIC_TEXT_BYTES
2598 && value.trim() == value
2599 && !value.chars().any(char::is_control)
2600}
2601
2602fn is_team_identifier(value: &str) -> bool {
2603 value.len() == 10
2604 && value
2605 .bytes()
2606 .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit())
2607}
2608
2609fn is_safe_bundle_identifier(value: &str) -> bool {
2610 value.len() <= MAX_PUBLIC_TEXT_BYTES
2611 && value.split('.').count() >= 2
2612 && value.split('.').all(|component| {
2613 !component.is_empty()
2614 && component
2615 .bytes()
2616 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
2617 && component.as_bytes()[0].is_ascii_alphanumeric()
2618 && component.as_bytes()[component.len() - 1].is_ascii_alphanumeric()
2619 })
2620}
2621
2622fn is_normalized_github_repository(value: &str) -> bool {
2623 let Some(repository) = value.strip_prefix("https://github.com/") else {
2624 return false;
2625 };
2626 let mut components = repository.split('/');
2627 let owner = components.next();
2628 let name = components.next();
2629 components.next().is_none()
2630 && owner.is_some_and(is_github_slug)
2631 && name.is_some_and(is_github_slug)
2632}
2633
2634fn is_github_slug(value: &str) -> bool {
2635 !value.is_empty()
2636 && value.len() <= 100
2637 && value
2638 .bytes()
2639 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
2640 && !matches!(value, "." | "..")
2641}
2642
2643fn is_lower_git_revision(value: &str) -> bool {
2644 value.len() == 40
2645 && value
2646 .bytes()
2647 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
2648}
2649
2650fn is_worker_timestamp(value: &str) -> bool {
2651 if value.len() != 20 {
2652 return false;
2653 }
2654 let bytes = value.as_bytes();
2655 if bytes.get(4) != Some(&b'-')
2656 || bytes.get(7) != Some(&b'-')
2657 || bytes.get(10) != Some(&b'T')
2658 || bytes.get(13) != Some(&b':')
2659 || bytes.get(16) != Some(&b':')
2660 || bytes.get(19) != Some(&b'Z')
2661 || !bytes.iter().enumerate().all(|(index, byte)| {
2662 matches!(index, 4 | 7 | 10 | 13 | 16 | 19) || byte.is_ascii_digit()
2663 })
2664 {
2665 return false;
2666 }
2667 let Some(year) = parse_decimal(&bytes[0..4]) else {
2668 return false;
2669 };
2670 let Some(month) = parse_decimal(&bytes[5..7]) else {
2671 return false;
2672 };
2673 let Some(day) = parse_decimal(&bytes[8..10]) else {
2674 return false;
2675 };
2676 let Some(hour) = parse_decimal(&bytes[11..13]) else {
2677 return false;
2678 };
2679 let Some(minute) = parse_decimal(&bytes[14..16]) else {
2680 return false;
2681 };
2682 let Some(second) = parse_decimal(&bytes[17..19]) else {
2683 return false;
2684 };
2685 year >= 1970
2686 && (1..=12).contains(&month)
2687 && (1..=days_in_month(year, month)).contains(&day)
2688 && hour < 24
2689 && minute < 60
2690 && second < 60
2691}
2692
2693fn parse_decimal(bytes: &[u8]) -> Option<u64> {
2694 bytes.iter().try_fold(0_u64, |value, byte| {
2695 value
2696 .checked_mul(10)?
2697 .checked_add(u64::from(byte.checked_sub(b'0')?))
2698 })
2699}
2700
2701const fn days_in_month(year: u64, month: u64) -> u64 {
2702 match month {
2703 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
2704 4 | 6 | 9 | 11 => 30,
2705 2 if year.is_multiple_of(400) || (year.is_multiple_of(4) && !year.is_multiple_of(100)) => {
2706 29
2707 }
2708 2 => 28,
2709 _ => 0,
2710 }
2711}
2712
2713fn worker_timestamp_from_unix(seconds: u64) -> Option<String> {
2714 let days = i64::try_from(seconds / 86_400).ok()?;
2715 let day_seconds = seconds % 86_400;
2716 let (year, month, day) = civil_from_days(days)?;
2717 if !(0..=9_999).contains(&year) {
2718 return None;
2719 }
2720 let hour = day_seconds / 3_600;
2721 let minute = (day_seconds % 3_600) / 60;
2722 let second = day_seconds % 60;
2723 Some(format!(
2724 "{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z"
2725 ))
2726}
2727
2728fn civil_from_days(days_since_epoch: i64) -> Option<(i64, u64, u64)> {
2729 let days = days_since_epoch.checked_add(719_468)?;
2730 let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
2731 let day_of_era = days - era * 146_097;
2732 let year_of_era =
2733 (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
2734 let mut year = year_of_era + era * 400;
2735 let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
2736 let month_prime = (5 * day_of_year + 2) / 153;
2737 let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
2738 let month = month_prime + if month_prime < 10 { 3 } else { -9 };
2739 year += i64::from(month <= 2);
2740 Some((year, u64::try_from(month).ok()?, u64::try_from(day).ok()?))
2741}
2742
2743fn is_lower_sha256(value: &str) -> bool {
2744 value.len() == 64
2745 && value
2746 .bytes()
2747 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
2748}
2749
2750fn is_sha256_any_case(value: &str) -> bool {
2751 value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
2752}
2753
2754fn portable_name_key(name: &str) -> String {
2755 name.nfc().flat_map(char::to_lowercase).collect()
2756}
2757
2758fn sha256_bytes(bytes: &[u8]) -> String {
2759 let mut hasher = Sha256::new();
2760 hasher.update(bytes);
2761 hex::encode(hasher.finalize())
2762}
2763
2764#[allow(clippy::needless_pass_by_value)]
2765fn io_error(error: io::Error) -> GithubArtifactError {
2766 GithubArtifactError::Io(error.kind())
2767}
2768
2769#[cfg(windows)]
2770#[allow(
2771 clippy::needless_pass_by_value,
2772 reason = "used directly as a path-free Result::map_err adapter"
2773)]
2774fn map_windows_private_path_error(error: PrivateDirectoryError) -> GithubArtifactError {
2775 if error.cleanup_status() == PrivateDirectoryCleanupStatus::Uncertain {
2776 GithubArtifactError::CleanupFailed
2777 } else if matches!(error.os_code(), Some(2 | 3)) {
2778 GithubArtifactError::Io(io::ErrorKind::NotFound)
2779 } else {
2780 GithubArtifactError::InvalidPath
2781 }
2782}
2783
2784#[cfg(windows)]
2785#[allow(
2786 clippy::needless_pass_by_value,
2787 reason = "used directly as a path-free Result::map_err adapter"
2788)]
2789fn map_windows_private_file_error(error: PrivateDirectoryError) -> GithubArtifactError {
2790 if error.cleanup_status() == PrivateDirectoryCleanupStatus::Uncertain {
2791 GithubArtifactError::CleanupFailed
2792 } else if error.kind() == PrivateDirectoryErrorKind::AlreadyExists {
2793 GithubArtifactError::Io(io::ErrorKind::AlreadyExists)
2794 } else {
2795 GithubArtifactError::InvalidPath
2796 }
2797}
2798
2799#[cfg(test)]
2800mod tests {
2801 use super::*;
2802 use std::io::Cursor;
2803
2804 use rustferry_remote::{
2805 AppleToolchainEvidence, ArtifactSigningEvidence, BundleIdentifier,
2806 COMPILE_PHASE_EVIDENCE_SCHEMA_VERSION, CURRENT_PROTOCOL_VERSION, CompileToolchainEvidence,
2807 DevelopmentTeam, DevelopmentTeamPlan, DevicePlan, EntitlementPlan, EntitlementSet,
2808 IosArtifactType, IosDeviceProductExpectation, ProvisioningPlan, ProvisioningProfileType,
2809 SEALED_UNSIGNED_ARCHIVE_SCHEMA_VERSION, SealedUnsignedArchive, SecretReference,
2810 SecretReferenceKind, SigningCertificate, SigningIdentity, SigningPlan,
2811 SigningPrivateKeyReference, SigningReference, SigningTarget, SourceArchive, SourceManifest,
2812 SourceManifestEntry, UnsignedAppInspection, UnsignedXcarchiveExpectation,
2813 UnsignedXcarchiveInspection,
2814 };
2815 use serde_json::json;
2816 use tempfile::TempDir;
2817 use zip::{ZipWriter, write::SimpleFileOptions};
2818
2819 const OPERATION_ID: &str = "operation-123";
2820 const JOB_ID: &str = "987654321";
2821 const PROVIDER: &str = "github-actions";
2822 const SEALED_SHA256: &str = "2222222222222222222222222222222222222222222222222222222222222222";
2823 const TEAM_ID: &str = "ABCDE12345";
2824 const CERTIFICATE_FINGERPRINT: &str =
2825 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
2826 const PROFILE_UUID: &str = "12345678-1234-1234-1234-123456789ABC";
2827 const ENTITLEMENTS_SHA256: &str =
2828 "3333333333333333333333333333333333333333333333333333333333333333";
2829 #[test]
2830 fn entry_names_reject_escape_and_nested_roots() {
2831 for name in [
2832 b"../secret".as_slice(),
2833 b"/absolute",
2834 b"C:/drive",
2835 b"bad\\path",
2836 b"nul\0x",
2837 ] {
2838 assert!(validate_entry_name(name).is_err());
2839 }
2840 assert_eq!(
2841 validate_entry_name(b"wrapper/artifact-manifest.json"),
2842 Err(GithubArtifactError::NestedArchiveRoot)
2843 );
2844 }
2845
2846 #[test]
2847 fn portable_name_key_folds_unicode_and_case() {
2848 assert_eq!(
2849 portable_name_key("SIGNING-R\u{c9}PORT.JSON"),
2850 portable_name_key("signing-re\u{301}port.json")
2851 );
2852 }
2853
2854 #[test]
2855 fn signed_product_paths_reject_private_material_and_source() {
2856 for name in [
2857 "App.app/certificate.p12",
2858 "App.app/certificate.pem/value",
2859 "App.xcarchive/credentials/value",
2860 "App.app/Source/main.swift",
2861 "App.xcarchive/project.pbxproj",
2862 ] {
2863 assert!(is_sensitive_inner_path(name), "accepted {name}");
2864 }
2865 for name in [
2866 "App.app/embedded.mobileprovision",
2867 "App.app/_CodeSignature/CodeResources",
2868 "App.app/Frameworks/Example.swiftmodule/arm64-apple-ios.swiftinterface",
2869 ] {
2870 assert!(!is_sensitive_inner_path(name), "rejected {name}");
2871 }
2872 }
2873
2874 #[test]
2875 fn signed_product_archive_requires_an_explicit_wrapper_directory() {
2876 let root = TempDir::new().unwrap();
2877 let path = Utf8PathBuf::from_path_buf(root.path().join("missing-root.zip")).unwrap();
2878 let file = create_new_artifact_file(&path).unwrap();
2879 let mut writer = ZipWriter::new(file);
2880 let options = SimpleFileOptions::default()
2881 .compression_method(CompressionMethod::Stored)
2882 .unix_permissions(0o644);
2883 for (name, bytes) in [
2884 ("App.app/Info.plist", b"plist".as_slice()),
2885 ("App.app/App", b"binary".as_slice()),
2886 ] {
2887 writer.start_file(name, options).unwrap();
2888 writer.write_all(bytes).unwrap();
2889 }
2890 writer.finish().unwrap();
2891 assert_eq!(
2892 inspect_signed_tree_zip(
2893 &path,
2894 "App.app",
2895 Some("App.app"),
2896 RequiredArtifactFile::AppBundleArchive,
2897 None,
2898 )
2899 .map(|_| ()),
2900 Err(GithubArtifactError::InvalidProductArchive(
2901 RequiredArtifactFile::AppBundleArchive
2902 ))
2903 );
2904 }
2905
2906 #[test]
2907 fn signed_app_and_xcarchive_trees_bind_to_the_exact_ipa_app() {
2908 let root = TempDir::new().unwrap();
2909 let root = Utf8PathBuf::from_path_buf(root.path().to_owned()).unwrap();
2910 let ipa_path = root.join(DEVELOPMENT_IPA_NAME);
2911 let app_path = root.join(APP_BUNDLE_ARCHIVE_NAME);
2912 let archive_path = root.join(SIGNED_XCARCHIVE_NAME);
2913 let tampered_path = root.join("tampered.app.zip");
2914 write_new_file(&ipa_path, &test_ipa()).unwrap();
2915 write_rewrapped_app_zip(&app_path, "", false);
2916 write_rewrapped_app_zip(&archive_path, "App.xcarchive/Products/Applications/", false);
2917 write_rewrapped_app_zip(&tampered_path, "", true);
2918
2919 let ipa = inspect_signed_tree_zip(
2920 &ipa_path,
2921 "Payload/App.app",
2922 None,
2923 RequiredArtifactFile::Ipa,
2924 None,
2925 )
2926 .unwrap();
2927 let app = inspect_signed_tree_zip(
2928 &app_path,
2929 "App.app",
2930 Some("App.app"),
2931 RequiredArtifactFile::AppBundleArchive,
2932 None,
2933 )
2934 .unwrap();
2935 let archive = inspect_signed_tree_zip(
2936 &archive_path,
2937 "App.xcarchive/Products/Applications/App.app",
2938 Some("App.xcarchive"),
2939 RequiredArtifactFile::SignedXcarchive,
2940 None,
2941 )
2942 .unwrap();
2943 let tampered = inspect_signed_tree_zip(
2944 &tampered_path,
2945 "App.app",
2946 Some("App.app"),
2947 RequiredArtifactFile::AppBundleArchive,
2948 None,
2949 )
2950 .unwrap();
2951 assert_eq!(app.tree, ipa.tree);
2952 assert_eq!(archive.tree, ipa.tree);
2953 assert_eq!(app.tree.evidence().unwrap(), ipa.tree.evidence().unwrap());
2954 assert_ne!(tampered.tree, ipa.tree);
2955 }
2956
2957 #[test]
2958 fn arm64_dsym_uuid_must_match_the_signed_executable() {
2959 let uuid = [
2960 0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66,
2961 0x77, 0x88,
2962 ];
2963 let executable = macho_with_uuid(goblin::mach::header::MH_EXECUTE, uuid);
2964 let dsym = macho_with_uuid(goblin::mach::header::MH_DSYM, uuid);
2965 let expected = "12345678-9ABC-DEF0-1122-334455667788";
2966 assert_eq!(
2967 arm64_macho_uuid(&executable, goblin::mach::header::MH_EXECUTE).unwrap(),
2968 expected
2969 );
2970 assert_eq!(
2971 arm64_macho_uuid(&dsym, goblin::mach::header::MH_DSYM).unwrap(),
2972 expected
2973 );
2974 assert!(arm64_macho_uuid(&dsym, goblin::mach::header::MH_EXECUTE).is_err());
2975 assert!(
2976 arm64_macho_uuid(
2977 &macho_with_uuid(goblin::mach::header::MH_EXECUTE, [0; 16]),
2978 goblin::mach::header::MH_EXECUTE
2979 )
2980 .is_err()
2981 );
2982 let mut other = uuid;
2983 other[15] ^= 1;
2984 assert_ne!(
2985 arm64_macho_uuid(
2986 &macho_with_uuid(goblin::mach::header::MH_DSYM, other),
2987 goblin::mach::header::MH_DSYM
2988 )
2989 .unwrap(),
2990 expected
2991 );
2992 }
2993
2994 #[test]
2995 fn sanitized_log_requires_the_exact_secret_free_protocol_payload() {
2996 assert!(validate_sanitized_log(PROTECTED_SIGNING_SANITIZED_LOG_V1).is_ok());
2997 for rejected in [
2998 b"".as_slice(),
2999 b"printable secret-like payload\n",
3000 b"secret\0value",
3001 &[0xff],
3002 ] {
3003 assert_eq!(
3004 validate_sanitized_log(rejected),
3005 Err(GithubArtifactError::InvalidPublicReport(
3006 RequiredArtifactFile::SanitizedLog
3007 ))
3008 );
3009 }
3010 }
3011
3012 #[test]
3013 fn valid_fixture_is_inspected_and_published_without_staging_residue() {
3014 let fixture = Fixture::new();
3015 let published = fixture.ingest().expect("ingest fixture");
3016 assert!(published.ipa_path.is_file());
3017 assert!(published.manifest_path.is_file());
3018 assert!(published.signing_report_path.is_file());
3019 assert!(published.validation_report_path.is_file());
3020 assert!(published.sanitized_log_path.is_file());
3021 assert!(published.app_bundle_archive_path.is_none());
3022 assert!(published.signed_xcarchive_path.is_none());
3023 assert!(published.dsym_archive_path.is_none());
3024 assert_eq!(
3025 fs::read(&published.sanitized_log_path).unwrap(),
3026 PROTECTED_SIGNING_SANITIZED_LOG_V1
3027 );
3028 assert_eq!(published.manifest.operation_id, OPERATION_ID);
3029 assert_eq!(
3030 published.manifest_size,
3031 fs::metadata(&published.manifest_path).unwrap().len()
3032 );
3033 assert_eq!(
3034 published.manifest_sha256,
3035 sha256_bytes(&fs::read(&published.manifest_path).unwrap())
3036 );
3037 assert_eq!(
3038 published.ipa_inspection.bundle_identifier,
3039 "com.example.App"
3040 );
3041 assert!(
3042 published
3043 .validation_levels
3044 .contains(&ValidationLevel::DownloadedToClient)
3045 );
3046 assert_eq!(fs::read_dir(&fixture.staging).unwrap().count(), 0);
3047 }
3048
3049 #[test]
3050 fn existing_output_is_never_replaced() {
3051 let fixture = Fixture::new();
3052 let existing = fixture.output.join(DEVELOPMENT_IPA_NAME);
3053 fs::write(&existing, b"keep me").unwrap();
3054 let error = fixture.ingest().unwrap_err();
3055 assert_eq!(
3056 error,
3057 GithubArtifactError::OutputAlreadyExists(RequiredArtifactFile::Ipa)
3058 );
3059 assert_eq!(fs::read(&existing).unwrap(), b"keep me");
3060 assert_eq!(fs::read_dir(&fixture.staging).unwrap().count(), 0);
3061 }
3062
3063 #[cfg(not(windows))]
3064 #[test]
3065 fn publication_cleanup_preserves_a_replaced_destination() {
3066 let root = TempDir::new().unwrap();
3067 let source = Utf8PathBuf::from_path_buf(root.path().join("source")).unwrap();
3068 let destination = Utf8PathBuf::from_path_buf(root.path().join("destination")).unwrap();
3069 fs::write(&source, b"published").unwrap();
3070 let linked_file = File::open(&source).unwrap();
3071 fs::hard_link(&source, &destination).unwrap();
3072 let link = PublishedLink {
3073 path: destination.clone(),
3074 linked_file,
3075 };
3076 fs::remove_file(&destination).unwrap();
3077 fs::write(&destination, b"foreign").unwrap();
3078
3079 assert_eq!(
3080 cleanup_published_links([&link]),
3081 Err(GithubArtifactError::CleanupFailed)
3082 );
3083 assert_eq!(fs::read(destination).unwrap(), b"foreign");
3084 }
3085
3086 #[cfg(windows)]
3087 #[test]
3088 fn publication_cleanup_removes_exact_original_and_preserves_replacement() {
3089 let root = TempDir::new().unwrap();
3090 let source = Utf8PathBuf::from_path_buf(root.path().join("source")).unwrap();
3091 let destination = Utf8PathBuf::from_path_buf(root.path().join("destination")).unwrap();
3092 let displaced = Utf8PathBuf::from_path_buf(root.path().join("displaced")).unwrap();
3093 let mut output = create_new_artifact_file(&source).unwrap();
3094 output.write_all(b"published").unwrap();
3095 output.sync_all().unwrap();
3096 drop(output);
3097 let staging_file = open_windows_private_file_for_removal(source.as_std_path()).unwrap();
3098 fs::hard_link(&source, &destination).unwrap();
3099 let linked_file = open_windows_private_file_for_removal_in_state(
3100 destination.as_std_path(),
3101 PrivateFileLinkState::PublicationPair,
3102 )
3103 .unwrap();
3104 let link = PublishedLink {
3105 path: destination.clone(),
3106 linked_file,
3107 staging_path: source.clone(),
3108 staging_file,
3109 staging_removed: Cell::new(false),
3110 };
3111 fs::rename(&destination, &displaced).unwrap();
3112 fs::write(&destination, b"foreign").unwrap();
3113
3114 cleanup_published_links([&link]).unwrap();
3115 drop(link);
3116 assert_eq!(fs::read(destination).unwrap(), b"foreign");
3117 assert!(!source.exists());
3118 assert!(!displaced.exists());
3119 }
3120
3121 #[test]
3122 fn report_with_different_run_digest_is_rejected() {
3123 let mut fixture = Fixture::new();
3124 fixture.expected.compile.request_sha256 = "9".repeat(64);
3125 assert_eq!(fixture.ingest(), Err(GithubArtifactError::EvidenceMismatch));
3126 assert_eq!(fs::read_dir(&fixture.output).unwrap().count(), 0);
3127 }
3128
3129 #[test]
3130 fn artifact_expectation_rejects_an_invalid_request_before_binding() {
3131 let (mut request, compile) = test_request_and_compile();
3132 request.product_name.clear();
3133 assert_eq!(
3134 GithubArtifactExpectation::new(JOB_ID, PROVIDER, request, compile),
3135 Err(GithubArtifactError::InvalidExpectation)
3136 );
3137 }
3138
3139 #[test]
3140 fn optional_artifact_files_are_derived_only_from_the_signed_request() {
3141 let (mut request, _) = test_request_and_compile();
3142 request.requested_artifacts.extend([
3143 IosArtifactType::AppBundle,
3144 IosArtifactType::Xcarchive,
3145 IosArtifactType::Dsym,
3146 ]);
3147 let source = request.source.clone();
3148 let compile = test_compile_evidence(&request, source);
3149 let expected = GithubArtifactExpectation::new(JOB_ID, PROVIDER, request, compile).unwrap();
3150 let files = required_artifact_files(&expected);
3151 assert_eq!(files.len(), BASE_ENTRY_COUNT + 3);
3152 assert!(files.contains(&RequiredArtifactFile::AppBundleArchive));
3153 assert!(files.contains(&RequiredArtifactFile::SignedXcarchive));
3154 assert!(files.contains(&RequiredArtifactFile::DsymArchive));
3155 }
3156
3157 #[test]
3158 fn unexpected_archive_entry_is_rejected() {
3159 let root = TempDir::new().unwrap();
3160 let path = Utf8PathBuf::from_path_buf(root.path().join("unexpected.zip")).unwrap();
3161 let file = File::create(&path).unwrap();
3162 let mut writer = ZipWriter::new(file);
3163 let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
3164 for name in [
3165 DEVELOPMENT_IPA_NAME,
3166 ARTIFACT_MANIFEST_NAME,
3167 SIGNING_REPORT_NAME,
3168 VALIDATION_REPORT_NAME,
3169 "secret.pem",
3170 ] {
3171 writer.start_file(name, options).unwrap();
3172 writer.write_all(b"x").unwrap();
3173 }
3174 writer.finish().unwrap();
3175 let archive_size = path.metadata().unwrap().len();
3176 let mut archive = ZipArchive::new(File::open(path).unwrap()).unwrap();
3177 let required = RequiredArtifactFile::BASE.into_iter().collect();
3178 assert_eq!(
3179 scan_archive(&mut archive, archive_size, &required),
3180 Err(GithubArtifactError::UnexpectedEntry)
3181 );
3182 }
3183
3184 #[test]
3185 fn symlink_entry_is_rejected() {
3186 let root = TempDir::new().unwrap();
3187 let path = Utf8PathBuf::from_path_buf(root.path().join("symlink.zip")).unwrap();
3188 let file = File::create(&path).unwrap();
3189 let mut writer = ZipWriter::new(file);
3190 let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
3191 writer
3192 .add_symlink(DEVELOPMENT_IPA_NAME, "elsewhere", options)
3193 .unwrap();
3194 for name in [
3195 ARTIFACT_MANIFEST_NAME,
3196 SIGNING_REPORT_NAME,
3197 VALIDATION_REPORT_NAME,
3198 SANITIZED_BUILD_LOG_NAME,
3199 ] {
3200 writer.start_file(name, options).unwrap();
3201 writer.write_all(b"x").unwrap();
3202 }
3203 writer.finish().unwrap();
3204 let archive_size = path.metadata().unwrap().len();
3205 let mut archive = ZipArchive::new(File::open(path).unwrap()).unwrap();
3206 let required = RequiredArtifactFile::BASE.into_iter().collect();
3207 assert_eq!(
3208 scan_archive(&mut archive, archive_size, &required),
3209 Err(GithubArtifactError::LinkedOrSpecialEntry)
3210 );
3211 }
3212
3213 struct Fixture {
3214 _root: TempDir,
3215 archive: Utf8PathBuf,
3216 staging: Utf8PathBuf,
3217 output: Utf8PathBuf,
3218 expected: GithubArtifactExpectation,
3219 ipa_expectation: IpaExpectation,
3220 }
3221
3222 impl Fixture {
3223 fn new() -> Self {
3224 let root = TempDir::new().unwrap();
3225 let root_path = Utf8PathBuf::from_path_buf(root.path().to_owned()).unwrap();
3226 let archive = root_path.join("github-artifact.zip");
3227 let staging = root_path.join("staging");
3228 let output = root_path.join("output");
3229 create_test_private_directory(&staging);
3230 create_test_private_directory(&output);
3231
3232 let (request, compile) = test_request_and_compile();
3233 let ipa_bytes = test_ipa();
3234 let ipa_sha256 = sha256_bytes(&ipa_bytes);
3235 let ipa_size = u64::try_from(ipa_bytes.len()).unwrap();
3236 let report_bytes = test_report(&compile.request_sha256, &ipa_sha256, ipa_size);
3237 let manifest = test_manifest(
3238 &request,
3239 &compile,
3240 &ipa_sha256,
3241 ipa_size,
3242 &report_bytes,
3243 PROTECTED_SIGNING_SANITIZED_LOG_V1,
3244 );
3245 let manifest_bytes = serde_json::to_vec_pretty(&manifest).unwrap();
3246 write_artifact_zip(
3247 &archive,
3248 &ipa_bytes,
3249 &manifest_bytes,
3250 &report_bytes,
3251 &report_bytes,
3252 PROTECTED_SIGNING_SANITIZED_LOG_V1,
3253 );
3254 Self {
3255 _root: root,
3256 archive,
3257 staging,
3258 output,
3259 expected: GithubArtifactExpectation::new(JOB_ID, PROVIDER, request, compile)
3260 .unwrap(),
3261 ipa_expectation: IpaExpectation {
3262 app_directory_name: "App.app".to_owned(),
3263 bundle_identifier: "com.example.App".to_owned(),
3264 executable: "App".to_owned(),
3265 app_version: Some("1.2.3".to_owned()),
3266 build_number: Some("42".to_owned()),
3267 minimum_os: "17.0".to_owned(),
3268 nested_bundles: Vec::new(),
3269 provisioning_required: true,
3270 },
3271 }
3272 }
3273
3274 fn ingest(&self) -> Result<PublishedGithubArtifact, GithubArtifactError> {
3275 ingest_github_actions_artifact(GithubArtifactIngestion {
3276 archive_path: &self.archive,
3277 temporary_directory: &self.staging,
3278 output_directory: &self.output,
3279 expected: &self.expected,
3280 ipa_expectation: &self.ipa_expectation,
3281 })
3282 }
3283 }
3284
3285 fn test_request_and_compile() -> (IosDeviceBuildRequest, CompilePhaseEvidence) {
3286 let team = DevelopmentTeam::new(TEAM_ID, None).unwrap();
3287 let source = empty_source_manifest();
3288 let request = test_request(source.clone(), team);
3289 request.validate().unwrap();
3290 let compile = test_compile_evidence(&request, source);
3291 (request, compile)
3292 }
3293
3294 fn test_request(source: SourceManifest, team: DevelopmentTeam) -> IosDeviceBuildRequest {
3295 IosDeviceBuildRequest {
3296 protocol_version: CURRENT_PROTOCOL_VERSION,
3297 operation_id: OPERATION_ID.to_owned(),
3298 product_name: "App".to_owned(),
3299 bundle_identifier: "com.example.App".to_owned(),
3300 minimum_ios_version: "17.0".to_owned(),
3301 product: IosDeviceProductExpectation {
3302 app_directory_name: "App.app".to_owned(),
3303 executable: "App".to_owned(),
3304 app_version: "1.2.3".to_owned(),
3305 build_number: "42".to_owned(),
3306 nested_bundles: Vec::new(),
3307 },
3308 profile: BuildProfile::Release,
3309 source_mode: SourceMode::Git,
3310 source_repository: Some("https://github.com/example/app".to_owned()),
3311 source_revision: Some("4".repeat(40)),
3312 source,
3313 signing: test_signing_plan(team),
3314 requested_artifacts: BTreeSet::from([
3315 IosArtifactType::Ipa,
3316 IosArtifactType::SigningReport,
3317 ]),
3318 }
3319 }
3320
3321 fn test_signing_plan(team: DevelopmentTeam) -> SigningPlan {
3322 let secret =
3323 |name| SecretReference::new(SecretReferenceKind::GithubActions, name).expect("secret");
3324 SigningPlan {
3325 mode: SigningMode::ManualDevelopment,
3326 signing: Some(SigningReference {
3327 identity: SigningIdentity {
3328 certificate: SigningCertificate {
3329 common_name: "Apple Development".to_owned(),
3330 sha256_fingerprint: CERTIFICATE_FINGERPRINT.to_owned(),
3331 team: team.clone(),
3332 expires_at_unix_seconds: u64::MAX,
3333 },
3334 private_key: SigningPrivateKeyReference {
3335 reference: secret("RUSTFERRY_CERTIFICATE_P12"),
3336 },
3337 },
3338 password: Some(secret("RUSTFERRY_CERTIFICATE_PASSWORD")),
3339 }),
3340 team: Some(DevelopmentTeamPlan { expected: team }),
3341 device: Some(DevicePlan::new("00008110-001234567890801E", None).expect("device")),
3342 targets: vec![SigningTarget {
3343 name: "App".to_owned(),
3344 bundle_identifier: BundleIdentifier::new("com.example.App").unwrap(),
3345 kind: SigningTargetKind::Application,
3346 }],
3347 provisioning: vec![ProvisioningPlan {
3348 target: "App".to_owned(),
3349 profile: secret("RUSTFERRY_PROVISIONING_PROFILE"),
3350 profile_type: ProvisioningProfileType::Development,
3351 }],
3352 entitlements: vec![EntitlementPlan {
3353 target: "App".to_owned(),
3354 required: EntitlementSet::new(BTreeMap::new()).unwrap(),
3355 }],
3356 allow_provisioning_updates: false,
3357 }
3358 }
3359
3360 fn test_compile_evidence(
3361 request: &IosDeviceBuildRequest,
3362 source: SourceManifest,
3363 ) -> CompilePhaseEvidence {
3364 let request_sha256 = canonical_request_sha256(request).unwrap();
3365 let expectation = UnsignedXcarchiveExpectation {
3366 app_directory_name: "App.app".to_owned(),
3367 bundle_identifier: "com.example.App".to_owned(),
3368 executable: "App".to_owned(),
3369 app_version: "1.2.3".to_owned(),
3370 build_number: "42".to_owned(),
3371 minimum_os: "17.0".to_owned(),
3372 sdk_version: "26.0".to_owned(),
3373 sdk_build_version: "23A".to_owned(),
3374 nested_bundles: Vec::new(),
3375 required_resources: BTreeMap::new(),
3376 };
3377 let inspection = UnsignedXcarchiveInspection {
3378 application_path: "Applications/App.app".to_owned(),
3379 architectures: vec!["arm64".to_owned()],
3380 app: UnsignedAppInspection {
3381 app_directory_name: "App.app".to_owned(),
3382 bundle_identifier: "com.example.App".to_owned(),
3383 executable: "App".to_owned(),
3384 main_executable: Vec::new(),
3385 nested_executables: BTreeMap::new(),
3386 extensions: Vec::new(),
3387 resources: BTreeMap::new(),
3388 entries: Vec::new(),
3389 },
3390 entries: Vec::new(),
3391 };
3392 CompilePhaseEvidence {
3393 schema_version: COMPILE_PHASE_EVIDENCE_SCHEMA_VERSION,
3394 job_id: JOB_ID.to_owned(),
3395 provider: PROVIDER.to_owned(),
3396 request_sha256,
3397 source_sha256: source.sha256.clone(),
3398 cargo_lock_sha256: "6".repeat(64),
3399 config_sha256: "7".repeat(64),
3400 rustferry_version: "0.1.0".to_owned(),
3401 worker_version: "0.1.0".to_owned(),
3402 toolchain: CompileToolchainEvidence {
3403 worker_os: "macOS 26.0".to_owned(),
3404 worker_architecture: "arm64".to_owned(),
3405 xcode_version: "26.0".to_owned(),
3406 iphoneos_sdk_version: "26.0".to_owned(),
3407 iphoneos_sdk_build_version: "23A".to_owned(),
3408 developer_directory_sha256: "8".repeat(64),
3409 rust_version: "rustc 1.92.0".to_owned(),
3410 rust_target: IOS_DEVICE_RUST_TARGET.to_owned(),
3411 },
3412 sealed_archive: SealedUnsignedArchive {
3413 schema_version: SEALED_UNSIGNED_ARCHIVE_SCHEMA_VERSION,
3414 transport: SourceArchive {
3415 size: 1,
3416 sha256: SEALED_SHA256.to_owned(),
3417 },
3418 contents: source,
3419 expectation,
3420 },
3421 archive_inspection: inspection,
3422 started_at_unix_seconds: 0,
3423 finished_at_unix_seconds: 60,
3424 }
3425 }
3426
3427 fn empty_source_manifest() -> SourceManifest {
3428 let entries = vec![
3429 SourceManifestEntry {
3430 path: "Cargo.lock".to_owned(),
3431 size: 0,
3432 sha256: "6".repeat(64),
3433 executable: false,
3434 },
3435 SourceManifestEntry {
3436 path: "ferry.toml".to_owned(),
3437 size: 0,
3438 sha256: "7".repeat(64),
3439 executable: false,
3440 },
3441 ];
3442 let mut digest = Sha256::new();
3443 digest.update(b"rustferry-source-manifest-v1\0");
3444 digest.update(1_u64.to_be_bytes());
3445 digest.update(b".");
3446 digest.update((entries.len() as u64).to_be_bytes());
3447 for entry in &entries {
3448 digest.update((entry.path.len() as u64).to_be_bytes());
3449 digest.update(entry.path.as_bytes());
3450 digest.update(entry.size.to_be_bytes());
3451 digest.update((entry.sha256.len() as u64).to_be_bytes());
3452 digest.update(entry.sha256.as_bytes());
3453 digest.update([u8::from(entry.executable)]);
3454 }
3455 digest.update(0_u64.to_be_bytes());
3456 SourceManifest {
3457 schema_version: 1,
3458 project_path: ".".to_owned(),
3459 entries,
3460 total_size: 0,
3461 sha256: hex::encode(digest.finalize()),
3462 }
3463 }
3464
3465 fn test_ipa() -> Vec<u8> {
3466 let mut bytes = Cursor::new(Vec::new());
3467 let mut writer = ZipWriter::new(&mut bytes);
3468 let options = SimpleFileOptions::default()
3469 .compression_method(CompressionMethod::Stored)
3470 .unix_permissions(0o644);
3471 writer
3472 .start_file("Payload/App.app/Info.plist", options)
3473 .unwrap();
3474 writer.write_all(
3475 br#"<?xml version="1.0" encoding="UTF-8"?>
3476<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3477<plist version="1.0"><dict>
3478<key>CFBundleIdentifier</key><string>com.example.App</string>
3479<key>CFBundleExecutable</key><string>App</string>
3480<key>CFBundleShortVersionString</key><string>1.2.3</string>
3481<key>CFBundleVersion</key><string>42</string>
3482<key>MinimumOSVersion</key><string>17.0</string>
3483</dict></plist>"#,
3484 )
3485 .unwrap();
3486 writer.start_file("Payload/App.app/App", options).unwrap();
3487 writer.write_all(&thin_arm64_device()).unwrap();
3488 writer
3489 .start_file("Payload/App.app/embedded.mobileprovision", options)
3490 .unwrap();
3491 writer.write_all(b"public fixture profile").unwrap();
3492 writer.finish().unwrap();
3493 bytes.into_inner()
3494 }
3495
3496 fn thin_arm64_device() -> Vec<u8> {
3497 let mut bytes = Vec::new();
3498 for value in [
3499 0xfeed_facfu32,
3500 0x0100_000c,
3501 0,
3502 2,
3503 1,
3504 24,
3505 0,
3506 0,
3507 0x32,
3508 24,
3509 2,
3510 0x0011_0000,
3511 0x0012_0200,
3512 0,
3513 ] {
3514 bytes.extend_from_slice(&value.to_le_bytes());
3515 }
3516 bytes
3517 }
3518
3519 fn macho_with_uuid(file_type: u32, uuid: [u8; 16]) -> Vec<u8> {
3520 let mut bytes = Vec::new();
3521 for value in [
3522 0xfeed_facfu32,
3523 goblin::mach::constants::cputype::CPU_TYPE_ARM64,
3524 0,
3525 file_type,
3526 1,
3527 24,
3528 0,
3529 0,
3530 goblin::mach::load_command::LC_UUID,
3531 24,
3532 ] {
3533 bytes.extend_from_slice(&value.to_le_bytes());
3534 }
3535 bytes.extend_from_slice(&uuid);
3536 bytes
3537 }
3538
3539 fn test_report(request_sha256: &str, ipa_sha256: &str, ipa_size: u64) -> Vec<u8> {
3540 serde_json::to_vec_pretty(&json!({
3541 "schema_version": PUBLIC_REPORT_SCHEMA_VERSION,
3542 "request_sha256": request_sha256,
3543 "sealed_archive_sha256": SEALED_SHA256,
3544 "signed_ipa": {
3545 "ipa_sha256": ipa_sha256,
3546 "ipa_size": ipa_size,
3547 "bundle_identifier": "com.example.App",
3548 "team_identifier": TEAM_ID,
3549 "certificate_sha256_fingerprint": CERTIFICATE_FINGERPRINT,
3550 "bundles": [{
3551 "relative_path": ".",
3552 "bundle_identifier": "com.example.App",
3553 "kind": "application",
3554 "certificate_sha256_fingerprint": CERTIFICATE_FINGERPRINT,
3555 "profile_uuid": PROFILE_UUID,
3556 "profile_expires_at_unix_seconds": 2_000_000_000_u64,
3557 "entitlements_sha256": ENTITLEMENTS_SHA256,
3558 "selected_device_authorized": true
3559 }],
3560 "rust_target": IOS_DEVICE_RUST_TARGET,
3561 "apple_sdk": IOS_DEVICE_SDK,
3562 "architectures": ["arm64"],
3563 "verified_code_objects": ["Payload/App.app/App"],
3564 "individual_signatures_verified": true,
3565 "root_deep_signature_verified": true,
3566 "cleanup_confirmed": true
3567 },
3568 "signed_products": {
3569 "app_tree": null,
3570 "archive": null,
3571 "dsym": null
3572 },
3573 "cleanup": {
3574 "keychain_search_list_restored": true,
3575 "keychain_removed": true,
3576 "keychain_signing_files_removed": true,
3577 "keychain_job_directory_removed": true,
3578 "isolated_home_removed": true,
3579 "export_options_removed": true,
3580 "validation_workspace_removed": true,
3581 "private_workspace_removed": true
3582 }
3583 }))
3584 .unwrap()
3585 }
3586
3587 fn test_manifest(
3588 request: &IosDeviceBuildRequest,
3589 compile: &CompilePhaseEvidence,
3590 ipa_sha256: &str,
3591 ipa_size: u64,
3592 report_bytes: &[u8],
3593 sanitized_log: &[u8],
3594 ) -> ArtifactManifest {
3595 let report_sha256 = sha256_bytes(report_bytes);
3596 let report_size = u64::try_from(report_bytes.len()).unwrap();
3597 let mut manifest = ArtifactManifest::new(OPERATION_ID, JOB_ID);
3598 manifest.project_id = "com.example.App".to_owned();
3599 manifest.source_repository = Some("https://github.com/example/app".to_owned());
3600 manifest.source_revision = Some("4".repeat(40));
3601 manifest.source_sha256 = request.source.sha256.clone();
3602 manifest.cargo_lock_sha256 = compile.cargo_lock_sha256.clone();
3603 manifest.config_sha256 = compile.config_sha256.clone();
3604 manifest.rustferry_version = compile.rustferry_version.clone();
3605 manifest.worker_version = compile.worker_version.clone();
3606 manifest.provider = PROVIDER.to_owned();
3607 manifest.toolchain = AppleToolchainEvidence {
3608 worker_os: "macOS 26.0".to_owned(),
3609 worker_architecture: "arm64".to_owned(),
3610 xcode_version: "26.0".to_owned(),
3611 iphoneos_sdk_version: "26.0".to_owned(),
3612 rust_version: "rustc 1.92.0".to_owned(),
3613 rust_target: IOS_DEVICE_RUST_TARGET.to_owned(),
3614 };
3615 manifest.app_name = "App".to_owned();
3616 manifest.app_version = "1.2.3".to_owned();
3617 manifest.build_number = "42".to_owned();
3618 manifest.bundle_identifier = "com.example.App".to_owned();
3619 manifest.build_profile = "release".to_owned();
3620 manifest.architecture = "arm64".to_owned();
3621 manifest.signing = ArtifactSigningEvidence {
3622 mode: SigningMode::ManualDevelopment,
3623 status: SigningStatus::ArtifactValidated,
3624 team_id: Some(TEAM_ID.to_owned()),
3625 certificate_fingerprint: Some(CERTIFICATE_FINGERPRINT.to_owned()),
3626 profile_uuid: Some(PROFILE_UUID.to_owned()),
3627 profile_expiration: Some("2033-05-18T03:33:20Z".to_owned()),
3628 entitlements_sha256: Some(ENTITLEMENTS_SHA256.to_owned()),
3629 };
3630 manifest.artifacts = vec![
3631 ArtifactRecord {
3632 artifact_id: "iphone-ipa".to_owned(),
3633 kind: ArtifactKind::Ipa,
3634 file_name: DEVELOPMENT_IPA_NAME.to_owned(),
3635 size: ipa_size,
3636 sha256: ipa_sha256.to_owned(),
3637 media_type: Some("application/octet-stream".to_owned()),
3638 },
3639 ArtifactRecord {
3640 artifact_id: "signing-report".to_owned(),
3641 kind: ArtifactKind::SigningReport,
3642 file_name: SIGNING_REPORT_NAME.to_owned(),
3643 size: report_size,
3644 sha256: report_sha256.clone(),
3645 media_type: Some("application/json".to_owned()),
3646 },
3647 ArtifactRecord {
3648 artifact_id: "validation-report".to_owned(),
3649 kind: ArtifactKind::ValidationReport,
3650 file_name: VALIDATION_REPORT_NAME.to_owned(),
3651 size: report_size,
3652 sha256: report_sha256,
3653 media_type: Some("application/json".to_owned()),
3654 },
3655 ArtifactRecord {
3656 artifact_id: "sanitized-build-log".to_owned(),
3657 kind: ArtifactKind::SanitizedLog,
3658 file_name: SANITIZED_BUILD_LOG_NAME.to_owned(),
3659 size: u64::try_from(sanitized_log.len()).unwrap(),
3660 sha256: sha256_bytes(sanitized_log),
3661 media_type: Some("text/plain; charset=utf-8".to_owned()),
3662 },
3663 ];
3664 manifest.validation_levels = BTreeSet::from([
3665 ValidationLevel::SourceValidated,
3666 ValidationLevel::RemoteBuilderValidated,
3667 ValidationLevel::DeviceTargetCompiled,
3668 ValidationLevel::DeviceBinaryBuilt,
3669 ValidationLevel::AppBundleBuilt,
3670 ValidationLevel::ArchiveBuilt,
3671 ValidationLevel::CertificateValidated,
3672 ValidationLevel::ProvisioningValidated,
3673 ValidationLevel::NestedCodeSigned,
3674 ValidationLevel::ApplicationSigned,
3675 ValidationLevel::IpaExported,
3676 ValidationLevel::ArtifactValidated,
3677 ]);
3678 manifest.started_at = "1970-01-01T00:00:00Z".to_owned();
3679 manifest.finished_at = "1970-01-01T00:01:00Z".to_owned();
3680 manifest.cleanup_status = CleanupStatus::Confirmed;
3681 manifest
3682 }
3683
3684 fn write_artifact_zip(
3685 path: &Utf8Path,
3686 ipa: &[u8],
3687 manifest: &[u8],
3688 signing_report: &[u8],
3689 validation_report: &[u8],
3690 sanitized_log: &[u8],
3691 ) {
3692 let file = create_new_artifact_file(path).unwrap();
3693 let mut writer = ZipWriter::new(file);
3694 let options = SimpleFileOptions::default()
3695 .compression_method(CompressionMethod::Stored)
3696 .unix_permissions(0o644);
3697 for (name, bytes) in [
3698 (DEVELOPMENT_IPA_NAME, ipa),
3699 (ARTIFACT_MANIFEST_NAME, manifest),
3700 (SIGNING_REPORT_NAME, signing_report),
3701 (VALIDATION_REPORT_NAME, validation_report),
3702 (SANITIZED_BUILD_LOG_NAME, sanitized_log),
3703 ] {
3704 writer.start_file(name, options).unwrap();
3705 writer.write_all(bytes).unwrap();
3706 }
3707 writer.finish().unwrap();
3708 }
3709
3710 fn write_rewrapped_app_zip(path: &Utf8Path, prefix: &str, tamper_executable: bool) {
3711 let mut source = ZipArchive::new(Cursor::new(test_ipa())).unwrap();
3712 let file = create_new_artifact_file(path).unwrap();
3713 let mut writer = ZipWriter::new(file);
3714 let directory_options = SimpleFileOptions::default()
3715 .compression_method(CompressionMethod::Stored)
3716 .unix_permissions(0o755);
3717 if let Some(archive_root) = prefix.strip_suffix("Products/Applications/") {
3718 writer
3719 .add_directory(archive_root, directory_options)
3720 .unwrap();
3721 let options = SimpleFileOptions::default()
3722 .compression_method(CompressionMethod::Stored)
3723 .unix_permissions(0o644);
3724 writer
3725 .start_file(format!("{archive_root}Info.plist"), options)
3726 .unwrap();
3727 writer.write_all(b"public archive fixture").unwrap();
3728 } else {
3729 writer.add_directory("App.app/", directory_options).unwrap();
3730 }
3731 for index in 0..source.len() {
3732 let mut entry = source.by_index(index).unwrap();
3733 let relative = entry.name().strip_prefix("Payload/").unwrap().to_owned();
3734 let options = SimpleFileOptions::default()
3735 .compression_method(CompressionMethod::Stored)
3736 .unix_permissions(entry.unix_mode().unwrap_or(0o644) & 0o777);
3737 writer
3738 .start_file(format!("{prefix}{relative}"), options)
3739 .unwrap();
3740 let mut bytes = Vec::new();
3741 entry.read_to_end(&mut bytes).unwrap();
3742 if tamper_executable && relative == "App.app/App" {
3743 bytes.push(0);
3744 }
3745 writer.write_all(&bytes).unwrap();
3746 }
3747 writer.finish().unwrap();
3748 }
3749
3750 #[cfg(windows)]
3751 fn create_test_private_directory(path: &Utf8Path) {
3752 drop(
3753 rustferry_core::windows_private_directory::create_private_directory(path.as_std_path())
3754 .unwrap(),
3755 );
3756 }
3757
3758 #[cfg(not(windows))]
3759 fn create_test_private_directory(path: &Utf8Path) {
3760 fs::create_dir(path).unwrap();
3761 }
3762}