Skip to main content

rustferry_github/
artifact.rs

1//! Bounded ingestion of the final GitHub Actions iPhone artifact.
2//!
3//! The GitHub artifact ZIP is untrusted input. This module accepts the exact
4//! request-derived public file set emitted by the protected signing job,
5//! validates its cross-file integrity, independently inspects the signed
6//! products, and publishes regular files without replacing a destination.
7
8use 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
56/// Exact IPA filename published by the protected GitHub Actions job.
57pub const DEVELOPMENT_IPA_NAME: &str = "application-development.ipa";
58/// Exact worker manifest filename published by the protected job.
59pub const ARTIFACT_MANIFEST_NAME: &str = "artifact-manifest.json";
60/// Exact public signing-report filename published by the protected job.
61pub const SIGNING_REPORT_NAME: &str = "signing-report.json";
62/// Exact independent-validation report filename published by the protected job.
63pub const VALIDATION_REPORT_NAME: &str = "validation-report.json";
64/// Exact sanitized signing-log filename published by the protected job.
65pub const SANITIZED_BUILD_LOG_NAME: &str = "sanitized-build-log.txt";
66/// Exact signed application-bundle transport filename published by the protected job.
67pub const APP_BUNDLE_ARCHIVE_NAME: &str = "application.app.zip";
68/// Exact signed Xcode-archive transport filename published by the protected job.
69pub const SIGNED_XCARCHIVE_NAME: &str = "application.xcarchive.zip";
70/// Exact dSYM transport filename published by the protected job.
71pub 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/// Trusted identifiers obtained independently from the submitted request and
103/// exact GitHub workflow run.
104#[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    /// Bind final signed output to one independently verified compile handoff.
114    ///
115    /// # Errors
116    ///
117    /// Rejects unsafe identifiers or non-canonical SHA-256 digests.
118    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    /// Expected client operation identifier.
169    pub fn operation_id(&self) -> &str {
170        &self.request.operation_id
171    }
172
173    /// Expected provider job identifier.
174    pub fn job_id(&self) -> &str {
175        &self.job_id
176    }
177
178    /// Expected provider name.
179    pub fn provider(&self) -> &str {
180        &self.provider
181    }
182
183    /// Complete submitted request independently recovered from the compile handoff.
184    pub fn request(&self) -> &IosDeviceBuildRequest {
185        &self.request
186    }
187
188    /// Independently verified compile evidence required by the protected signer.
189    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/// Paths and trusted expectations for one GitHub Actions artifact download.
236#[derive(Clone, Copy, Debug)]
237pub struct GithubArtifactIngestion<'a> {
238    /// Downloaded GitHub Actions artifact ZIP.
239    pub archive_path: &'a Utf8Path,
240    /// Existing, empty, caller-owned directory used only for this ingestion.
241    pub temporary_directory: &'a Utf8Path,
242    /// Existing caller-owned directory receiving the exact requested validated files.
243    pub output_directory: &'a Utf8Path,
244    /// Exact run identity and compile-handoff digests.
245    pub expected: &'a GithubArtifactExpectation,
246    /// Exact application metadata independently known by the client.
247    pub ipa_expectation: &'a IpaExpectation,
248}
249
250/// Validated and published development IPA plus its public evidence.
251#[derive(Clone, Debug, Eq, PartialEq)]
252pub struct PublishedGithubArtifact {
253    /// Atomically published development IPA.
254    pub ipa_path: Utf8PathBuf,
255    /// Atomically published immutable worker manifest.
256    pub manifest_path: Utf8PathBuf,
257    /// Atomically published public signing report.
258    pub signing_report_path: Utf8PathBuf,
259    /// Atomically published independent-validation report.
260    pub validation_report_path: Utf8PathBuf,
261    /// Atomically published sanitized signing and export log.
262    pub sanitized_log_path: Utf8PathBuf,
263    /// Atomically published signed application-bundle transport, when requested.
264    pub app_bundle_archive_path: Option<Utf8PathBuf>,
265    /// Atomically published signed Xcode-archive transport, when requested.
266    pub signed_xcarchive_path: Option<Utf8PathBuf>,
267    /// Atomically published dSYM transport, when requested.
268    pub dsym_archive_path: Option<Utf8PathBuf>,
269    /// Strictly decoded worker manifest.
270    pub manifest: ArtifactManifest,
271    /// Cross-platform inspection of the exact published IPA bytes.
272    pub ipa_inspection: IpaInspection,
273    /// SHA-256 of the exact manifest bytes received from the provider.
274    pub manifest_sha256: String,
275    /// Size of the exact manifest bytes received from the provider.
276    pub manifest_size: u64,
277    /// Worker levels plus client download verification.
278    pub validation_levels: BTreeSet<ValidationLevel>,
279}
280
281/// One required root file in the GitHub Actions artifact.
282#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
283pub enum RequiredArtifactFile {
284    /// Development-signed IPA.
285    Ipa,
286    /// Worker artifact manifest.
287    Manifest,
288    /// Public signing report.
289    SigningReport,
290    /// Independent validation report.
291    ValidationReport,
292    /// Sanitized protected-signing log.
293    SanitizedLog,
294    /// Signed application bundle encoded as a bounded ZIP transport.
295    AppBundleArchive,
296    /// Signed Xcode archive encoded as a bounded ZIP transport.
297    SignedXcarchive,
298    /// Debug-symbol bundle encoded as a bounded ZIP transport.
299    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/// Secret-free artifact-ingestion failure.
400#[derive(Clone, Copy, Debug, Eq, PartialEq)]
401pub enum GithubArtifactError {
402    /// Trusted expected identifiers are malformed.
403    InvalidExpectation,
404    /// A supplied path is relative, aliased, or not the required filesystem kind.
405    InvalidPath,
406    /// The caller-owned temporary directory is not empty.
407    TemporaryDirectoryNotEmpty,
408    /// The downloaded ZIP exceeds its compressed-byte limit.
409    ArchiveTooLarge,
410    /// The downloaded bytes are not a valid supported ZIP.
411    InvalidArchive,
412    /// The ZIP does not contain the exact request-derived central-directory entry count.
413    InvalidEntryCount,
414    /// An entry name is non-UTF-8, absolute, traversing, or otherwise unsafe.
415    UnsafeEntryName,
416    /// An entry uses a nested directory or wrapper root.
417    NestedArchiveRoot,
418    /// An entry is not one of the exact request-derived public outputs.
419    UnexpectedEntry,
420    /// An exact ZIP entry occurs more than once.
421    DuplicateEntry,
422    /// Distinct names collide after Unicode normalization and case folding.
423    PortableNameCollision,
424    /// A ZIP entry is encrypted.
425    EncryptedEntry,
426    /// A ZIP entry uses an unsupported compression codec.
427    UnsupportedCompression,
428    /// A ZIP entry is a symlink, hardlink-like alias, or special file.
429    LinkedOrSpecialEntry,
430    /// A required file is absent.
431    MissingEntry(RequiredArtifactFile),
432    /// A required entry exceeds its individual expanded-byte limit.
433    EntryTooLarge(RequiredArtifactFile),
434    /// Declared expanded sizes overflow or exceed the aggregate limit.
435    ExpandedArchiveTooLarge,
436    /// An entry exceeds the permitted expansion ratio.
437    CompressionRatioExceeded(RequiredArtifactFile),
438    /// Entry bytes did not match their declared ZIP metadata.
439    EntryIntegrityFailed(RequiredArtifactFile),
440    /// Manifest JSON or its semantic invariants are invalid.
441    InvalidManifest,
442    /// A manifest record is absent, ambiguous, or has invalid metadata.
443    InvalidArtifactRecord(RequiredArtifactFile),
444    /// A manifest or public-report field conflicts with trusted run identity.
445    EvidenceMismatch,
446    /// A signing or validation report is malformed or does not prove every gate.
447    InvalidPublicReport(RequiredArtifactFile),
448    /// Extracted bytes do not match the manifest SHA-256 and size.
449    ArtifactIntegrityFailed(RequiredArtifactFile),
450    /// Cross-platform IPA inspection rejected the exact downloaded bytes.
451    IpaInspectionFailed,
452    /// An optional signed-product ZIP is malformed or violates its exact layout.
453    InvalidProductArchive(RequiredArtifactFile),
454    /// Optional signed-product contents differ from the validated IPA or public evidence.
455    ProductEvidenceMismatch(RequiredArtifactFile),
456    /// An exact destination filename already exists.
457    OutputAlreadyExists(RequiredArtifactFile),
458    /// This filesystem cannot provide no-replace atomic publication.
459    AtomicPublicationFailed,
460    /// Created partial files could not all be removed.
461    CleanupFailed,
462    /// A path-free local I/O category.
463    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/// Ingest, independently validate, and publish one GitHub Actions artifact.
652///
653/// Publication uses no-replace hard links from the caller's staging directory.
654/// This makes every final filename atomic and fails closed when staging and
655/// output are not on the same filesystem. The IPA is linked last as the
656/// completion marker. Any partial links created by this call are removed on
657/// failure.
658///
659/// # Errors
660///
661/// Rejects unsafe ZIP structure, unbounded expansion, mismatched public
662/// evidence, invalid physical-device bytes, unsafe paths, existing output, or
663/// incomplete cleanup.
664#[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}