Skip to main content

rustferry_github/
artifact_offline.rs

1//! Secret-free offline artifact inspection and verification.
2//!
3//! All local files are opened without following their final component, retained while bytes are
4//! hashed, and required to have one filesystem link. ZIP inspection is bounded and never extracts.
5//! Product validation materializes a private copy only when an existing validator requires a path.
6
7use std::{
8    collections::{BTreeMap, BTreeSet},
9    error::Error,
10    fmt,
11    fs::{self, File, OpenOptions},
12    io::{self, Read, Seek, SeekFrom, Write},
13    path::{Component, PathBuf},
14};
15
16#[cfg(unix)]
17use std::os::unix::fs::{DirBuilderExt as _, OpenOptionsExt as _};
18#[cfg(windows)]
19use std::os::windows::fs::OpenOptionsExt as _;
20
21use camino::{Utf8Path, Utf8PathBuf};
22#[cfg(windows)]
23use rustferry_core::windows_private_directory::{
24    create_private_directory as create_windows_private_directory,
25    create_private_file as create_windows_private_file,
26};
27use rustferry_core::{
28    DirectoryIdentityErrorKind, RegularFileFilesystemIdentity, RetainedDirectoryIdentity,
29    regular_file_identity_from_file, verify_regular_file_identity,
30};
31use rustferry_remote::{
32    ARTIFACT_MANIFEST_SCHEMA_VERSION, ArtifactKind, ArtifactManifest, ArtifactRecord, BuildProfile,
33    CleanupStatus, CompilePhaseEvidence, IOS_DEVICE_RUST_TARGET, IosArtifactType,
34    IosDeviceBuildRequest, IpaInspection, SigningMode, SigningStatus, SourceArchiveLimits,
35    SourceLimits, SourceMode, UnsignedNestedBundleKind, UnsignedXcarchiveInspection,
36    ValidationLevel, canonical_request_sha256, inspect_ipa, inspect_unsigned_xcarchive,
37    verify_and_extract_source_bundle,
38};
39use serde::{Deserialize, Serialize};
40use sha2::{Digest, Sha256};
41use tempfile::TempDir;
42use unicode_normalization::UnicodeNormalization;
43use zip::{CompressionMethod, ZipArchive, ZipWriter, write::SimpleFileOptions};
44
45use crate::{
46    artifact::{
47        APP_BUNDLE_ARCHIVE_NAME, ARTIFACT_MANIFEST_NAME, DEVELOPMENT_IPA_NAME, DSYM_ARCHIVE_NAME,
48        GithubArtifactExpectation, GithubArtifactIngestion, SANITIZED_BUILD_LOG_NAME,
49        SIGNED_XCARCHIVE_NAME, SIGNING_REPORT_NAME, VALIDATION_REPORT_NAME,
50        ingest_github_actions_artifact,
51    },
52    provider::{
53        GITHUB_PROVIDER_ID, GITHUB_SIGNED_CLEANUP_EVIDENCE_SCHEMA_VERSION, GithubRunConclusionV1,
54        GithubRunStatusV1, GithubSignedCleanupEvidenceV1,
55    },
56    strict_json,
57};
58
59/// Current stable offline-verification result schema.
60pub const OFFLINE_ARTIFACT_EVIDENCE_SCHEMA_VERSION: u32 = 1;
61
62const MAX_LOCAL_ARTIFACT_BYTES: u64 = 4 * 1024 * 1024 * 1024;
63const MAX_ZIP_ENTRIES: usize = 100_001;
64const MAX_ZIP_ENTRY_NAME_BYTES: usize = 4_096;
65const MAX_ZIP_ENTRY_BYTES: u64 = 512 * 1024 * 1024;
66const MAX_ZIP_EXPANDED_BYTES: u64 = 2 * 1024 * 1024 * 1024 + 64 * 1024 * 1024;
67const MAX_ZIP_DEPTH: usize = 128;
68const MAX_ZIP_COMPRESSION_RATIO: u64 = 200;
69const MAX_CATALOG_FILES: usize = 32;
70const COPY_BUFFER_BYTES: usize = 64 * 1024;
71const MAX_MANIFEST_BYTES: usize = 1024 * 1024;
72
73/// Exact source identity carried separately from provider state.
74#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
75#[serde(deny_unknown_fields)]
76pub struct OfflineSourceEvidence {
77    /// Normalized source repository for Git mode; absent for snapshots.
78    pub repository: Option<String>,
79    /// Exact source revision for Git mode; absent for snapshots.
80    pub revision: Option<String>,
81    /// Deterministic source-manifest SHA-256.
82    pub sha256: String,
83}
84
85/// One exact local artifact record and its client path.
86#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
87#[serde(deny_unknown_fields)]
88pub struct OfflineArtifactFile {
89    /// Immutable artifact metadata received from the verified provider result.
90    pub record: ArtifactRecord,
91    /// Absolute local path expected to identify those exact bytes.
92    pub path: Utf8PathBuf,
93    /// Optional canonical single-link identity expected by durable managed storage.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub expected_filesystem_identity: Option<String>,
96}
97
98/// Provider-neutral inputs needed to validate one local artifact offline.
99#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
100#[serde(deny_unknown_fields)]
101pub struct OfflineArtifactVerificationRequest {
102    /// Primary artifact selected by the caller.
103    pub artifact: OfflineArtifactFile,
104    /// Exact declarative build request.
105    pub request: IosDeviceBuildRequest,
106    /// Canonical SHA-256 of the complete request.
107    pub request_sha256: String,
108    /// Exact source identity independent of provider resume storage.
109    pub source: OfflineSourceEvidence,
110    /// Credential-free compile evidence.
111    pub compile_evidence: CompilePhaseEvidence,
112    /// Optional complete manifest catalog retained by the client.
113    pub manifest: Option<ArtifactManifest>,
114    /// Optional provider proof binding a successful signed attempt and cleanup reports.
115    pub signed_cleanup_evidence: Option<GithubSignedCleanupEvidenceV1>,
116    /// Optional exact local companion artifacts used for cross-file validation.
117    pub catalog: Vec<OfflineArtifactFile>,
118}
119
120/// Highest independently established evidence level.
121#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
122#[serde(rename_all = "snake_case")]
123pub enum OfflineArtifactEvidenceLevel {
124    /// Exact size and SHA-256 match the supplied artifact record.
125    Integrity,
126    /// Every ZIP entry passed bounded structure and CRC validation.
127    ArchiveSafety,
128    /// Product bytes passed the applicable cross-platform product inspector.
129    Product,
130    /// Product, manifest, signing reports, and companion files were cross-validated together.
131    CrossValidated,
132}
133
134/// Whether the requested product received strict evidence or only the strongest available proof.
135#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
136#[serde(rename_all = "snake_case")]
137pub enum OfflineArtifactVerificationOutcome {
138    /// The artifact reached the applicable strict evidence level.
139    Verified,
140    /// Integrity is proven, but no validator can honestly establish the missing product evidence.
141    EvidenceUnavailable,
142}
143
144/// Container shape observed without extraction.
145#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
146#[serde(tag = "kind", rename_all = "snake_case")]
147pub enum OfflineArtifactContainer {
148    /// The file is not presented as a ZIP container.
149    Opaque,
150    /// The file is a bounded, structurally safe ZIP.
151    Zip {
152        /// Number of exact central-directory entries.
153        entry_count: u32,
154        /// Sum of declared and CRC-checked expanded bytes.
155        expanded_size: u64,
156    },
157}
158
159/// Path-free inspection evidence for one retained local file.
160#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
161#[serde(deny_unknown_fields)]
162pub struct OfflineArtifactInspection {
163    /// Evidence schema version.
164    pub schema_version: u32,
165    /// Exact byte length read from the retained file.
166    pub size: u64,
167    /// Lowercase SHA-256 of the retained file bytes.
168    pub sha256: String,
169    /// Stable filesystem identity of the retained single-link file.
170    pub filesystem_identity: String,
171    /// Opaque or bounded ZIP container evidence.
172    pub container: OfflineArtifactContainer,
173}
174
175/// Cross-platform product evidence returned by an applicable strict validator.
176#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
177#[serde(tag = "kind", content = "evidence", rename_all = "snake_case")]
178pub enum OfflineProductEvidence {
179    /// Unsigned `.xcarchive` inspection recomputed after manifest-bound extraction.
180    UnsignedXcarchive(UnsignedXcarchiveInspection),
181    /// Signed IPA product inspection recomputed from the exact downloaded bytes.
182    Ipa(IpaInspection),
183    /// Signed artifact set cross-validated around this independently inspected IPA.
184    SignedArtifactSet(IpaInspection),
185}
186
187/// Stable, path-free offline verification result.
188#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
189#[serde(deny_unknown_fields)]
190pub struct OfflineArtifactVerification {
191    /// Evidence schema version.
192    pub schema_version: u32,
193    /// Exact provider-scoped artifact identifier.
194    pub artifact_id: String,
195    /// Artifact kind from the verified catalog.
196    pub artifact_kind: ArtifactKind,
197    /// Portable artifact filename.
198    pub file_name: String,
199    /// Strongest independently established level.
200    pub evidence_level: OfflineArtifactEvidenceLevel,
201    /// Whether strict evidence was available for this artifact kind.
202    pub outcome: OfflineArtifactVerificationOutcome,
203    /// Retained-file and optional ZIP evidence.
204    pub inspection: OfflineArtifactInspection,
205    /// Protocol validation levels independently established by this invocation.
206    pub validation_levels: BTreeSet<ValidationLevel>,
207    /// Recomputed product evidence, when applicable.
208    pub product: Option<OfflineProductEvidence>,
209    /// Whether optional signed-cleanup evidence exactly bound the supplied request and catalog.
210    pub signed_cleanup_evidence_bound: bool,
211}
212
213/// Stable, path-free offline artifact failure.
214#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
215#[serde(rename_all = "snake_case")]
216pub enum OfflineArtifactError {
217    /// A DTO field is malformed, duplicated, or internally inconsistent.
218    InvalidInput,
219    /// A path is relative, traversing, too deep, or unavailable.
220    InvalidPath,
221    /// A path component is a symlink/reparse point, or the file has multiple links.
222    UnsafeFilesystemObject,
223    /// A file or archive expansion exceeds a fixed resource limit.
224    ResourceLimitExceeded,
225    /// ZIP bytes are malformed, unsupported, overlapping, or fail CRC validation.
226    InvalidZip,
227    /// A ZIP entry path, type, name alias, encryption state, or compression ratio is unsafe.
228    UnsafeZip,
229    /// Local size, SHA-256, or filesystem identity differs from the exact record.
230    IntegrityMismatch,
231    /// Request, source, compile, manifest, report, or cleanup evidence conflicts.
232    EvidenceMismatch,
233    /// Existing cross-platform product validation rejected the exact bytes.
234    ProductValidationFailed,
235    /// Private temporary materialization or a local I/O operation failed.
236    LocalIo,
237}
238
239impl fmt::Display for OfflineArtifactError {
240    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
241        let message = match self {
242            Self::InvalidInput => "offline artifact input is invalid",
243            Self::InvalidPath => "offline artifact path is invalid",
244            Self::UnsafeFilesystemObject => "offline artifact path is not a plain single-link file",
245            Self::ResourceLimitExceeded => "offline artifact exceeds a fixed resource limit",
246            Self::InvalidZip => "offline artifact ZIP is invalid",
247            Self::UnsafeZip => "offline artifact ZIP has an unsafe shape",
248            Self::IntegrityMismatch => "offline artifact integrity does not match its record",
249            Self::EvidenceMismatch => "offline artifact evidence does not bind consistently",
250            Self::ProductValidationFailed => "offline artifact product validation failed",
251            Self::LocalIo => "offline artifact local I/O failed",
252        };
253        formatter.write_str(message)
254    }
255}
256
257impl Error for OfflineArtifactError {}
258
259struct RetainedArtifactFile {
260    file: File,
261    identity: RegularFileFilesystemIdentity,
262    parent_guards: Vec<(PathBuf, RetainedDirectoryIdentity)>,
263}
264
265impl RetainedArtifactFile {
266    fn open(path: &Utf8Path) -> Result<Self, OfflineArtifactError> {
267        validate_local_path(path)?;
268        let parent_guards = retain_parent_chain(path)?;
269        let mut options = OpenOptions::new();
270        options.read(true);
271        #[cfg(unix)]
272        options.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW);
273        #[cfg(windows)]
274        options
275            .custom_flags(windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT)
276            .share_mode(windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ);
277        let file = options
278            .open(path)
279            .map_err(|_| OfflineArtifactError::UnsafeFilesystemObject)?;
280        let identity = regular_file_identity_from_file(&file).map_err(map_identity_error)?;
281        verify_regular_file_identity(path.as_std_path(), &identity).map_err(map_identity_error)?;
282        verify_parent_chain(&parent_guards)?;
283        Ok(Self {
284            file,
285            identity,
286            parent_guards,
287        })
288    }
289
290    fn revalidate(&self, path: &Utf8Path) -> Result<(), OfflineArtifactError> {
291        let parent = path.parent().ok_or(OfflineArtifactError::InvalidPath)?;
292        reject_reparse_ancestors(parent.as_std_path())?;
293        verify_regular_file_identity(path.as_std_path(), &self.identity)
294            .map_err(map_identity_error)?;
295        verify_parent_chain(&self.parent_guards)?;
296        reject_reparse_ancestors(parent.as_std_path())
297    }
298}
299
300struct VerifiedInputFile {
301    record: ArtifactRecord,
302    path: Utf8PathBuf,
303    retained: RetainedArtifactFile,
304    inspection: OfflineArtifactInspection,
305}
306
307/// Inspect one local artifact without trusting its extension or extracting it.
308///
309/// # Errors
310///
311/// Rejects unsafe paths, symlinks/reparse points, hard links, files larger than the fixed bound,
312/// or malformed and unsafe ZIP containers.
313pub fn inspect(path: &Utf8Path) -> Result<OfflineArtifactInspection, OfflineArtifactError> {
314    let mut retained = RetainedArtifactFile::open(path)?;
315    let inspection = inspect_retained(path, &mut retained, false, None)?;
316    retained.revalidate(path)?;
317    Ok(inspection)
318}
319
320/// Verify one exact local artifact against request, source, compile, and optional signed evidence.
321///
322/// # Errors
323///
324/// Rejects any filesystem, integrity, ZIP, evidence-binding, or product-validation mismatch.
325/// Missing evidence is represented by [`OfflineArtifactVerificationOutcome::EvidenceUnavailable`]
326/// and never promoted to strict validation.
327pub fn verify(
328    request: &OfflineArtifactVerificationRequest,
329) -> Result<OfflineArtifactVerification, OfflineArtifactError> {
330    validate_evidence_context(request)?;
331    let primary_id = request.artifact.record.artifact_id.clone();
332    let mut files = Vec::with_capacity(request.catalog.len().saturating_add(1));
333    files.push(verify_input_file(&request.artifact)?);
334    for file in &request.catalog {
335        files.push(verify_input_file(file)?);
336    }
337    validate_file_catalog(&files, request.manifest.as_ref())?;
338
339    let primary_index = files
340        .iter()
341        .position(|file| file.record.artifact_id == primary_id)
342        .ok_or(OfflineArtifactError::InvalidInput)?;
343    let primary_record = files[primary_index].record.clone();
344    let primary_inspection = files[primary_index].inspection.clone();
345    let base_level = match primary_inspection.container {
346        OfflineArtifactContainer::Opaque => OfflineArtifactEvidenceLevel::Integrity,
347        OfflineArtifactContainer::Zip { .. } => OfflineArtifactEvidenceLevel::ArchiveSafety,
348    };
349    let mut result = OfflineArtifactVerification {
350        schema_version: OFFLINE_ARTIFACT_EVIDENCE_SCHEMA_VERSION,
351        artifact_id: primary_record.artifact_id.clone(),
352        artifact_kind: primary_record.kind,
353        file_name: primary_record.file_name.clone(),
354        evidence_level: base_level,
355        outcome: OfflineArtifactVerificationOutcome::Verified,
356        inspection: primary_inspection,
357        validation_levels: BTreeSet::from([ValidationLevel::DownloadedToClient]),
358        product: None,
359        signed_cleanup_evidence_bound: false,
360    };
361
362    if complete_signed_set_available(request, &files)
363        && required_signed_file_names(&request.request).contains(primary_record.file_name.as_str())
364    {
365        let (inspection, cleanup_bound) = validate_complete_signed_set(request, &mut files)?;
366        result.evidence_level = OfflineArtifactEvidenceLevel::CrossValidated;
367        result
368            .validation_levels
369            .insert(ValidationLevel::ArtifactValidated);
370        result.product = Some(OfflineProductEvidence::SignedArtifactSet(inspection));
371        result.signed_cleanup_evidence_bound = cleanup_bound;
372        revalidate_verified_files(&files)?;
373        return Ok(result);
374    }
375
376    match primary_record.kind {
377        ArtifactKind::Ipa => {
378            let inspection = validate_standalone_ipa(request, &mut files[primary_index])?;
379            result.evidence_level = OfflineArtifactEvidenceLevel::Product;
380            result
381                .validation_levels
382                .insert(ValidationLevel::ArtifactValidated);
383            result.product = Some(OfflineProductEvidence::Ipa(inspection));
384        }
385        ArtifactKind::Xcarchive
386            if record_matches_unsigned_archive(&primary_record, &request.compile_evidence) =>
387        {
388            let inspection =
389                validate_unsigned_archive(&request.compile_evidence, &mut files[primary_index])?;
390            result.evidence_level = OfflineArtifactEvidenceLevel::Product;
391            result
392                .validation_levels
393                .insert(ValidationLevel::ArtifactValidated);
394            result.product = Some(OfflineProductEvidence::UnsignedXcarchive(inspection));
395        }
396        ArtifactKind::App
397        | ArtifactKind::Dsym
398        | ArtifactKind::Xcarchive
399        | ArtifactKind::Manifest
400        | ArtifactKind::SigningReport
401        | ArtifactKind::ValidationReport
402        | ArtifactKind::SanitizedLog => {
403            result.outcome = OfflineArtifactVerificationOutcome::EvidenceUnavailable;
404        }
405    }
406    revalidate_verified_files(&files)?;
407    Ok(result)
408}
409
410fn revalidate_verified_files(files: &[VerifiedInputFile]) -> Result<(), OfflineArtifactError> {
411    for file in files {
412        file.retained.revalidate(&file.path)?;
413    }
414    Ok(())
415}
416
417fn verify_input_file(
418    input: &OfflineArtifactFile,
419) -> Result<VerifiedInputFile, OfflineArtifactError> {
420    validate_record(&input.record)?;
421    let expected_identity = input
422        .expected_filesystem_identity
423        .as_deref()
424        .map(|identity| {
425            let parsed = identity
426                .parse::<RegularFileFilesystemIdentity>()
427                .map_err(|_| OfflineArtifactError::InvalidInput)?;
428            if parsed.to_string() != identity {
429                return Err(OfflineArtifactError::InvalidInput);
430            }
431            Ok(parsed)
432        })
433        .transpose()?;
434    let mut retained = RetainedArtifactFile::open(&input.path)?;
435    if expected_identity
436        .as_ref()
437        .is_some_and(|expected| expected != &retained.identity)
438    {
439        return Err(OfflineArtifactError::IntegrityMismatch);
440    }
441    let inspection = inspect_retained(
442        &input.path,
443        &mut retained,
444        artifact_kind_requires_zip(input.record.kind),
445        Some(&input.record),
446    )?;
447    retained.revalidate(&input.path)?;
448    Ok(VerifiedInputFile {
449        record: input.record.clone(),
450        path: input.path.clone(),
451        retained,
452        inspection,
453    })
454}
455
456fn inspect_retained(
457    path: &Utf8Path,
458    retained: &mut RetainedArtifactFile,
459    force_zip: bool,
460    expected: Option<&ArtifactRecord>,
461) -> Result<OfflineArtifactInspection, OfflineArtifactError> {
462    let metadata = retained
463        .file
464        .metadata()
465        .map_err(|_| OfflineArtifactError::LocalIo)?;
466    let size = metadata.len();
467    if size == 0 || size > MAX_LOCAL_ARTIFACT_BYTES {
468        return Err(OfflineArtifactError::ResourceLimitExceeded);
469    }
470    let sha256 = hash_retained_file(&mut retained.file, size)?;
471    if expected.is_some_and(|record| record.size != size || record.sha256 != sha256) {
472        return Err(OfflineArtifactError::IntegrityMismatch);
473    }
474    let zip_required = force_zip
475        || path
476            .extension()
477            .is_some_and(|extension| matches_ignore_ascii_case(extension, &["ipa", "zip"]));
478    let mut magic = [0_u8; 4];
479    let magic_size = retained
480        .file
481        .read(&mut magic)
482        .map_err(|_| OfflineArtifactError::LocalIo)?;
483    retained
484        .file
485        .seek(SeekFrom::Start(0))
486        .map_err(|_| OfflineArtifactError::LocalIo)?;
487    let zip_magic = magic_size == magic.len()
488        && matches!(
489            magic,
490            [b'P', b'K', 3, 4] | [b'P', b'K', 5, 6] | [b'P', b'K', 7, 8]
491        );
492    let zip_marker = zip_magic || has_zip_tail_marker(&mut retained.file, size)?;
493    let container = if zip_required || zip_marker {
494        scan_zip(&retained.file, size)?
495    } else {
496        OfflineArtifactContainer::Opaque
497    };
498    retained
499        .file
500        .seek(SeekFrom::Start(0))
501        .map_err(|_| OfflineArtifactError::LocalIo)?;
502    Ok(OfflineArtifactInspection {
503        schema_version: OFFLINE_ARTIFACT_EVIDENCE_SCHEMA_VERSION,
504        size,
505        sha256,
506        filesystem_identity: retained.identity.to_string(),
507        container,
508    })
509}
510
511fn artifact_kind_requires_zip(kind: ArtifactKind) -> bool {
512    matches!(
513        kind,
514        ArtifactKind::App | ArtifactKind::Xcarchive | ArtifactKind::Ipa | ArtifactKind::Dsym
515    )
516}
517
518fn has_zip_tail_marker(file: &mut File, size: u64) -> Result<bool, OfflineArtifactError> {
519    const MAX_ZIP_TAIL_BYTES: u64 = 65_557;
520    let tail_size = size.min(MAX_ZIP_TAIL_BYTES);
521    file.seek(SeekFrom::Start(size.saturating_sub(tail_size)))
522        .map_err(|_| OfflineArtifactError::LocalIo)?;
523    let mut tail =
524        vec![0_u8; usize::try_from(tail_size).map_err(|_| OfflineArtifactError::LocalIo)?];
525    file.read_exact(&mut tail)
526        .map_err(|_| OfflineArtifactError::LocalIo)?;
527    file.seek(SeekFrom::Start(0))
528        .map_err(|_| OfflineArtifactError::LocalIo)?;
529    Ok(tail.windows(4).any(|window| {
530        matches!(
531            window,
532            [b'P', b'K', 1, 2] | [b'P', b'K', 5 | 6, 6] | [b'P', b'K', 6, 7]
533        )
534    }))
535}
536
537fn hash_retained_file(file: &mut File, expected_size: u64) -> Result<String, OfflineArtifactError> {
538    file.seek(SeekFrom::Start(0))
539        .map_err(|_| OfflineArtifactError::LocalIo)?;
540    let mut digest = Sha256::new();
541    let mut total = 0_u64;
542    let mut buffer = vec![0_u8; COPY_BUFFER_BYTES].into_boxed_slice();
543    loop {
544        let read = file
545            .read(&mut buffer)
546            .map_err(|_| OfflineArtifactError::LocalIo)?;
547        if read == 0 {
548            break;
549        }
550        total = total
551            .checked_add(u64::try_from(read).map_err(|_| OfflineArtifactError::LocalIo)?)
552            .ok_or(OfflineArtifactError::ResourceLimitExceeded)?;
553        if total > expected_size || total > MAX_LOCAL_ARTIFACT_BYTES {
554            return Err(OfflineArtifactError::IntegrityMismatch);
555        }
556        digest.update(&buffer[..read]);
557    }
558    if total != expected_size {
559        return Err(OfflineArtifactError::IntegrityMismatch);
560    }
561    file.seek(SeekFrom::Start(0))
562        .map_err(|_| OfflineArtifactError::LocalIo)?;
563    Ok(hex::encode(digest.finalize()))
564}
565
566#[allow(clippy::too_many_lines)]
567fn scan_zip(
568    file: &File,
569    archive_size: u64,
570) -> Result<OfflineArtifactContainer, OfflineArtifactError> {
571    let declared_entries = preflight_zip_entry_count(file, archive_size)?;
572    let cloned = file
573        .try_clone()
574        .map_err(|_| OfflineArtifactError::LocalIo)?;
575    let mut archive = ZipArchive::new(cloned).map_err(|_| OfflineArtifactError::InvalidZip)?;
576    if archive.is_empty() || archive.len() > MAX_ZIP_ENTRIES {
577        return Err(OfflineArtifactError::ResourceLimitExceeded);
578    }
579    if archive.len() != declared_entries {
580        return Err(OfflineArtifactError::UnsafeZip);
581    }
582    let mut exact = BTreeSet::new();
583    let mut portable = BTreeMap::<String, String>::new();
584    let mut headers = BTreeSet::new();
585    let mut files = BTreeSet::new();
586    let mut directories = BTreeSet::new();
587    let mut ranges = Vec::with_capacity(archive.len().saturating_mul(2));
588    let mut expanded_size = 0_u64;
589    let mut buffer = vec![0_u8; COPY_BUFFER_BYTES].into_boxed_slice();
590    for index in 0..archive.len() {
591        let mut entry = archive
592            .by_index(index)
593            .map_err(|_| OfflineArtifactError::InvalidZip)?;
594        let name = validate_zip_name(entry.name_raw(), entry.is_dir())?;
595        let portable_name = portable_zip_key(&name);
596        if !exact.insert(name.clone())
597            || portable
598                .insert(portable_name.clone(), name.clone())
599                .is_some()
600            || !headers.insert(entry.header_start())
601        {
602            return Err(OfflineArtifactError::UnsafeZip);
603        }
604        register_zip_path(&portable_name, entry.is_dir(), &mut files, &mut directories)?;
605        validate_zip_metadata(&entry)?;
606        let data_start = entry.data_start().ok_or(OfflineArtifactError::InvalidZip)?;
607        if entry.header_start() >= data_start || data_start > archive_size {
608            return Err(OfflineArtifactError::InvalidZip);
609        }
610        let end = data_start
611            .checked_add(entry.compressed_size())
612            .ok_or(OfflineArtifactError::InvalidZip)?;
613        if end > archive_size {
614            return Err(OfflineArtifactError::InvalidZip);
615        }
616        ranges.push((entry.header_start(), data_start));
617        if entry.compressed_size() != 0 {
618            ranges.push((data_start, end));
619        }
620        expanded_size = expanded_size
621            .checked_add(entry.size())
622            .ok_or(OfflineArtifactError::ResourceLimitExceeded)?;
623        if expanded_size > MAX_ZIP_EXPANDED_BYTES {
624            return Err(OfflineArtifactError::ResourceLimitExceeded);
625        }
626        let mut read_size = 0_u64;
627        loop {
628            let read = entry
629                .read(&mut buffer)
630                .map_err(|_| OfflineArtifactError::InvalidZip)?;
631            if read == 0 {
632                break;
633            }
634            read_size = read_size
635                .checked_add(u64::try_from(read).map_err(|_| OfflineArtifactError::InvalidZip)?)
636                .ok_or(OfflineArtifactError::ResourceLimitExceeded)?;
637            if read_size > entry.size() {
638                return Err(OfflineArtifactError::InvalidZip);
639            }
640        }
641        if read_size != entry.size() {
642            return Err(OfflineArtifactError::InvalidZip);
643        }
644    }
645    ranges.sort_unstable();
646    if ranges.windows(2).any(|pair| pair[1].0 < pair[0].1) {
647        return Err(OfflineArtifactError::InvalidZip);
648    }
649    Ok(OfflineArtifactContainer::Zip {
650        entry_count: u32::try_from(archive.len())
651            .map_err(|_| OfflineArtifactError::ResourceLimitExceeded)?,
652        expanded_size,
653    })
654}
655
656fn preflight_zip_entry_count(
657    file: &File,
658    archive_size: u64,
659) -> Result<usize, OfflineArtifactError> {
660    const MAX_EOCD_BYTES: u64 = 65_557;
661    let tail_size = archive_size.min(MAX_EOCD_BYTES);
662    let mut reader = file
663        .try_clone()
664        .map_err(|_| OfflineArtifactError::LocalIo)?;
665    reader
666        .seek(SeekFrom::Start(archive_size.saturating_sub(tail_size)))
667        .map_err(|_| OfflineArtifactError::LocalIo)?;
668    let mut tail =
669        vec![0_u8; usize::try_from(tail_size).map_err(|_| OfflineArtifactError::LocalIo)?];
670    reader
671        .read_exact(&mut tail)
672        .map_err(|_| OfflineArtifactError::LocalIo)?;
673    let eocd = (0..tail.len().saturating_sub(21))
674        .rev()
675        .find(|offset| {
676            tail.get(*offset..offset.saturating_add(4)) == Some(b"PK\x05\x06")
677                && read_le_u16(&tail, offset.saturating_add(20)).is_some_and(|comment_size| {
678                    offset
679                        .saturating_add(22)
680                        .saturating_add(usize::from(comment_size))
681                        == tail.len()
682                })
683        })
684        .ok_or(OfflineArtifactError::InvalidZip)?;
685    if read_le_u16(&tail, eocd.saturating_add(4)) != Some(0)
686        || read_le_u16(&tail, eocd.saturating_add(6)) != Some(0)
687    {
688        return Err(OfflineArtifactError::UnsafeZip);
689    }
690    let locator = eocd
691        .checked_sub(20)
692        .filter(|offset| tail.get(*offset..offset.saturating_add(4)) == Some(b"PK\x06\x07"));
693    let entries = if let Some(locator) = locator {
694        zip64_entry_count(&tail, locator)?
695    } else {
696        let disk_entries =
697            read_le_u16(&tail, eocd.saturating_add(8)).ok_or(OfflineArtifactError::InvalidZip)?;
698        let total_entries =
699            read_le_u16(&tail, eocd.saturating_add(10)).ok_or(OfflineArtifactError::InvalidZip)?;
700        if disk_entries == u16::MAX || total_entries == u16::MAX || disk_entries != total_entries {
701            return Err(OfflineArtifactError::InvalidZip);
702        }
703        usize::from(total_entries)
704    };
705    if entries == 0 || entries > MAX_ZIP_ENTRIES {
706        return Err(OfflineArtifactError::ResourceLimitExceeded);
707    }
708    Ok(entries)
709}
710
711fn zip64_entry_count(tail: &[u8], locator: usize) -> Result<usize, OfflineArtifactError> {
712    if read_le_u32(tail, locator.saturating_add(4)) != Some(0)
713        || read_le_u32(tail, locator.saturating_add(16)) != Some(1)
714    {
715        return Err(OfflineArtifactError::UnsafeZip);
716    }
717    let record = (0..locator.saturating_sub(55))
718        .rev()
719        .find(|offset| {
720            tail.get(*offset..offset.saturating_add(4)) == Some(b"PK\x06\x06")
721                && read_le_u64(tail, offset.saturating_add(4)).is_some_and(|record_size| {
722                    usize::try_from(record_size)
723                        .ok()
724                        .and_then(|size| offset.checked_add(12)?.checked_add(size))
725                        == Some(locator)
726                })
727        })
728        .ok_or(OfflineArtifactError::InvalidZip)?;
729    if read_le_u32(tail, record.saturating_add(16)) != Some(0)
730        || read_le_u32(tail, record.saturating_add(20)) != Some(0)
731    {
732        return Err(OfflineArtifactError::UnsafeZip);
733    }
734    let disk_entries =
735        read_le_u64(tail, record.saturating_add(24)).ok_or(OfflineArtifactError::InvalidZip)?;
736    let total_entries =
737        read_le_u64(tail, record.saturating_add(32)).ok_or(OfflineArtifactError::InvalidZip)?;
738    if disk_entries != total_entries {
739        return Err(OfflineArtifactError::UnsafeZip);
740    }
741    usize::try_from(total_entries).map_err(|_| OfflineArtifactError::ResourceLimitExceeded)
742}
743
744fn read_le_u16(bytes: &[u8], offset: usize) -> Option<u16> {
745    Some(u16::from_le_bytes(
746        bytes.get(offset..offset.checked_add(2)?)?.try_into().ok()?,
747    ))
748}
749
750fn read_le_u32(bytes: &[u8], offset: usize) -> Option<u32> {
751    Some(u32::from_le_bytes(
752        bytes.get(offset..offset.checked_add(4)?)?.try_into().ok()?,
753    ))
754}
755
756fn read_le_u64(bytes: &[u8], offset: usize) -> Option<u64> {
757    Some(u64::from_le_bytes(
758        bytes.get(offset..offset.checked_add(8)?)?.try_into().ok()?,
759    ))
760}
761
762fn validate_zip_name(raw: &[u8], directory: bool) -> Result<String, OfflineArtifactError> {
763    if raw.is_empty() || raw.len() > MAX_ZIP_ENTRY_NAME_BYTES {
764        return Err(OfflineArtifactError::UnsafeZip);
765    }
766    let name = std::str::from_utf8(raw).map_err(|_| OfflineArtifactError::UnsafeZip)?;
767    if name.starts_with(['/', '\\'])
768        || name.contains(['\\', '\0'])
769        || name.chars().any(char::is_control)
770        || directory != name.ends_with('/')
771    {
772        return Err(OfflineArtifactError::UnsafeZip);
773    }
774    let trimmed = name.strip_suffix('/').unwrap_or(name);
775    let components = trimmed.split('/').collect::<Vec<_>>();
776    if components.is_empty()
777        || components.len() > MAX_ZIP_DEPTH
778        || components.iter().any(|component| {
779            component.is_empty()
780                || matches!(*component, "." | "..")
781                || component.contains(':')
782                || component.ends_with(['.', ' '])
783                || is_windows_device_name(component)
784        })
785    {
786        return Err(OfflineArtifactError::UnsafeZip);
787    }
788    Ok(name.to_owned())
789}
790
791fn validate_zip_metadata(entry: &zip::read::ZipFile<'_, File>) -> Result<(), OfflineArtifactError> {
792    let kind = entry.unix_mode().map(|mode| mode & 0o170_000);
793    let expected_kind = if entry.is_dir() { 0o040_000 } else { 0o100_000 };
794    if entry.encrypted()
795        || kind.is_some_and(|kind| kind != 0 && kind != expected_kind)
796        || !matches!(
797            entry.compression(),
798            CompressionMethod::Stored | CompressionMethod::Deflated
799        )
800        || entry.size() > MAX_ZIP_ENTRY_BYTES
801        || (entry.is_dir() && (entry.size() != 0 || entry.compressed_size() != 0))
802        || (entry.compression() == CompressionMethod::Stored
803            && entry.size() != entry.compressed_size())
804        || (!entry.is_dir() && entry.size() > 0 && entry.compressed_size() == 0)
805        || (entry.compressed_size() > 0
806            && entry.size()
807                > entry
808                    .compressed_size()
809                    .saturating_mul(MAX_ZIP_COMPRESSION_RATIO))
810    {
811        return Err(OfflineArtifactError::UnsafeZip);
812    }
813    Ok(())
814}
815
816fn portable_zip_key(value: &str) -> String {
817    value
818        .trim_end_matches('/')
819        .nfkc()
820        .flat_map(char::to_lowercase)
821        .collect()
822}
823
824fn register_zip_path(
825    path: &str,
826    directory: bool,
827    files: &mut BTreeSet<String>,
828    directories: &mut BTreeSet<String>,
829) -> Result<(), OfflineArtifactError> {
830    let components = path.split('/').collect::<Vec<_>>();
831    let mut parent = String::new();
832    for component in components.iter().take(components.len().saturating_sub(1)) {
833        if !parent.is_empty() {
834            parent.push('/');
835        }
836        parent.push_str(component);
837        if files.contains(&parent) {
838            return Err(OfflineArtifactError::UnsafeZip);
839        }
840        directories.insert(parent.clone());
841    }
842    if directory {
843        if files.contains(path) {
844            return Err(OfflineArtifactError::UnsafeZip);
845        }
846        directories.insert(path.to_owned());
847    } else {
848        if directories.contains(path)
849            || has_zip_descendant(files, path)
850            || has_zip_descendant(directories, path)
851        {
852            return Err(OfflineArtifactError::UnsafeZip);
853        }
854        files.insert(path.to_owned());
855    }
856    Ok(())
857}
858
859fn has_zip_descendant(paths: &BTreeSet<String>, path: &str) -> bool {
860    let prefix = format!("{path}/");
861    paths
862        .range(prefix.clone()..)
863        .next()
864        .is_some_and(|candidate| candidate.starts_with(&prefix))
865}
866
867fn is_windows_device_name(component: &str) -> bool {
868    let stem = component
869        .split('.')
870        .next()
871        .unwrap_or(component)
872        .trim_end_matches(['.', ' ']);
873    if matches_ignore_ascii_case(stem, &["con", "prn", "aux", "nul", "clock$"]) {
874        return true;
875    }
876    let Some(prefix) = stem.get(..3) else {
877        return false;
878    };
879    let Some(suffix) = stem.get(3..) else {
880        return false;
881    };
882    matches_ignore_ascii_case(prefix, &["com", "lpt"])
883        && matches!(
884            suffix,
885            "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³"
886        )
887}
888
889fn validate_evidence_context(
890    input: &OfflineArtifactVerificationRequest,
891) -> Result<(), OfflineArtifactError> {
892    input
893        .request
894        .validate()
895        .map_err(|_| OfflineArtifactError::InvalidInput)?;
896    let actual_request_sha256 =
897        canonical_request_sha256(&input.request).map_err(|_| OfflineArtifactError::InvalidInput)?;
898    if input.request_sha256 != actual_request_sha256
899        || input.compile_evidence.request_sha256 != input.request_sha256
900        || input.source.repository != input.request.source_repository
901        || input.source.revision != input.request.source_revision
902        || input.source.sha256 != input.request.source.sha256
903        || input.source.sha256 != input.compile_evidence.source_sha256
904        || input.compile_evidence.sealed_archive.contents != input.request.source
905    {
906        return Err(OfflineArtifactError::EvidenceMismatch);
907    }
908    GithubArtifactExpectation::new(
909        input.compile_evidence.job_id.clone(),
910        input.compile_evidence.provider.clone(),
911        input.request.clone(),
912        input.compile_evidence.clone(),
913    )
914    .map_err(|_| OfflineArtifactError::EvidenceMismatch)?;
915    if input.compile_evidence.finished_at_unix_seconds
916        < input.compile_evidence.started_at_unix_seconds
917    {
918        return Err(OfflineArtifactError::EvidenceMismatch);
919    }
920    if let Some(manifest) = &input.manifest
921        && !manifest_matches_context(manifest, input)
922    {
923        return Err(OfflineArtifactError::EvidenceMismatch);
924    }
925    if let Some(evidence) = &input.signed_cleanup_evidence {
926        let manifest = input
927            .manifest
928            .as_ref()
929            .ok_or(OfflineArtifactError::EvidenceMismatch)?;
930        validate_signed_cleanup_binding(input, manifest, evidence)?;
931    }
932    Ok(())
933}
934
935fn manifest_matches_context(
936    manifest: &ArtifactManifest,
937    input: &OfflineArtifactVerificationRequest,
938) -> bool {
939    let request = &input.request;
940    let compile = &input.compile_evidence;
941    let expected_profile = match request.profile {
942        BuildProfile::Debug => "debug",
943        BuildProfile::Release => "release",
944    };
945    let mut expected_extensions = request
946        .product
947        .nested_bundles
948        .iter()
949        .filter(|bundle| bundle.kind == UnsignedNestedBundleKind::AppExtension)
950        .map(|bundle| bundle.bundle_identifier.clone())
951        .collect::<Vec<_>>();
952    expected_extensions.sort();
953    manifest.schema_version == ARTIFACT_MANIFEST_SCHEMA_VERSION
954        && manifest.operation_id == request.operation_id
955        && manifest.job_id == compile.job_id
956        && manifest.project_id == request.bundle_identifier
957        && manifest.provider == compile.provider
958        && manifest.source_repository == input.source.repository
959        && manifest.source_revision == input.source.revision
960        && manifest.source_snapshot == (request.source_mode == SourceMode::Snapshot)
961        && manifest.source_sha256 == input.source.sha256
962        && manifest.cargo_lock_sha256 == compile.cargo_lock_sha256
963        && manifest.config_sha256 == compile.config_sha256
964        && manifest.rustferry_version == compile.rustferry_version
965        && manifest.worker_version == compile.worker_version
966        && manifest.toolchain.worker_os == compile.toolchain.worker_os
967        && manifest.toolchain.worker_architecture == compile.toolchain.worker_architecture
968        && manifest.toolchain.xcode_version == compile.toolchain.xcode_version
969        && manifest.toolchain.iphoneos_sdk_version == compile.toolchain.iphoneos_sdk_version
970        && manifest.toolchain.rust_version == compile.toolchain.rust_version
971        && manifest.toolchain.rust_target == IOS_DEVICE_RUST_TARGET
972        && manifest.app_name == request.product_name
973        && manifest.app_version == request.product.app_version
974        && manifest.build_number == request.product.build_number
975        && manifest.bundle_identifier == request.bundle_identifier
976        && manifest.build_profile == expected_profile
977        && manifest.architecture == "arm64"
978        && manifest.signing.mode == request.signing.mode
979        && manifest.extensions == expected_extensions
980}
981
982fn validate_record(record: &ArtifactRecord) -> Result<(), OfflineArtifactError> {
983    if record.artifact_id.is_empty()
984        || record.artifact_id.len() > 255
985        || !record
986            .artifact_id
987            .bytes()
988            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
989        || record.file_name.is_empty()
990        || record.file_name.len() > 255
991        || record.file_name.starts_with('.')
992        || record.file_name.contains(['/', '\\', ':', '\0'])
993        || record.file_name.chars().any(char::is_control)
994        || record.file_name.ends_with(['.', ' '])
995        || is_windows_device_name(&record.file_name)
996        || record.size == 0
997        || record.size > MAX_LOCAL_ARTIFACT_BYTES
998        || !is_lower_sha256(&record.sha256)
999        || record.media_type.as_deref().is_some_and(|media_type| {
1000            media_type.is_empty()
1001                || media_type.len() > 255
1002                || media_type.chars().any(char::is_control)
1003        })
1004    {
1005        return Err(OfflineArtifactError::InvalidInput);
1006    }
1007    Ok(())
1008}
1009
1010fn validate_file_catalog(
1011    files: &[VerifiedInputFile],
1012    manifest: Option<&ArtifactManifest>,
1013) -> Result<(), OfflineArtifactError> {
1014    if files.is_empty() || files.len() > MAX_CATALOG_FILES.saturating_add(1) {
1015        return Err(OfflineArtifactError::InvalidInput);
1016    }
1017    let mut identifiers = BTreeSet::new();
1018    let mut names = BTreeSet::new();
1019    let mut portable_names = BTreeSet::new();
1020    let mut identities = BTreeSet::new();
1021    for file in files {
1022        if !identifiers.insert(file.record.artifact_id.as_str())
1023            || !names.insert(file.record.file_name.as_str())
1024            || !portable_names.insert(portable_zip_key(&file.record.file_name))
1025            || !identities.insert(file.inspection.filesystem_identity.as_str())
1026        {
1027            return Err(OfflineArtifactError::InvalidInput);
1028        }
1029        if let Some(manifest) = manifest {
1030            if file.record.kind == ArtifactKind::Manifest {
1031                let manifest_records = manifest
1032                    .artifacts
1033                    .iter()
1034                    .filter(|record| record.kind == ArtifactKind::Manifest)
1035                    .collect::<Vec<_>>();
1036                if manifest_records.len() > 1
1037                    || manifest_records
1038                        .first()
1039                        .is_some_and(|record| **record != file.record)
1040                {
1041                    return Err(OfflineArtifactError::EvidenceMismatch);
1042                }
1043            } else if !manifest.artifacts.contains(&file.record) {
1044                return Err(OfflineArtifactError::EvidenceMismatch);
1045            }
1046        }
1047    }
1048    if let Some(manifest) = manifest {
1049        let mut artifact_ids = BTreeSet::new();
1050        let mut artifact_names = BTreeSet::new();
1051        let mut portable_artifact_names = BTreeSet::new();
1052        if manifest.artifacts.iter().any(|record| {
1053            validate_record(record).is_err()
1054                || !artifact_ids.insert(record.artifact_id.as_str())
1055                || !artifact_names.insert(record.file_name.as_str())
1056                || !portable_artifact_names.insert(portable_zip_key(&record.file_name))
1057        }) {
1058            return Err(OfflineArtifactError::EvidenceMismatch);
1059        }
1060    }
1061    Ok(())
1062}
1063
1064fn record_matches_unsigned_archive(
1065    record: &ArtifactRecord,
1066    compile: &CompilePhaseEvidence,
1067) -> bool {
1068    record.kind == ArtifactKind::Xcarchive
1069        && record.size == compile.sealed_archive.transport.size
1070        && record.sha256 == compile.sealed_archive.transport.sha256
1071}
1072
1073fn validate_unsigned_archive(
1074    compile: &CompilePhaseEvidence,
1075    file: &mut VerifiedInputFile,
1076) -> Result<UnsignedXcarchiveInspection, OfflineArtifactError> {
1077    let mut workspace = OfflineWorkspace::new()?;
1078    let materialized =
1079        workspace.materialize("unsigned-archive.zip", &mut file.retained, file.record.size)?;
1080    let destination = workspace.root.join("unsigned-xcarchive");
1081    verify_and_extract_source_bundle(
1082        &materialized.path,
1083        &compile.sealed_archive.transport,
1084        &compile.sealed_archive.contents,
1085        &destination,
1086        offline_source_archive_limits(),
1087    )
1088    .map_err(|_| OfflineArtifactError::ProductValidationFailed)?;
1089    let inspection = inspect_unsigned_xcarchive(&destination, &compile.sealed_archive.expectation)
1090        .map_err(|_| OfflineArtifactError::ProductValidationFailed)?;
1091    if inspection != compile.archive_inspection {
1092        return Err(OfflineArtifactError::EvidenceMismatch);
1093    }
1094    file.retained.revalidate(&file.path)?;
1095    Ok(inspection)
1096}
1097
1098fn validate_standalone_ipa(
1099    input: &OfflineArtifactVerificationRequest,
1100    file: &mut VerifiedInputFile,
1101) -> Result<IpaInspection, OfflineArtifactError> {
1102    let expectation = input
1103        .request
1104        .ipa_expectation()
1105        .map_err(|_| OfflineArtifactError::InvalidInput)?;
1106    let mut workspace = OfflineWorkspace::new()?;
1107    let materialized =
1108        workspace.materialize("artifact.ipa", &mut file.retained, file.record.size)?;
1109    let inspection = inspect_ipa(&materialized.path, &expectation)
1110        .map_err(|_| OfflineArtifactError::ProductValidationFailed)?;
1111    if inspection.size != file.record.size || inspection.sha256 != file.record.sha256 {
1112        return Err(OfflineArtifactError::EvidenceMismatch);
1113    }
1114    file.retained.revalidate(&file.path)?;
1115    Ok(inspection)
1116}
1117
1118fn complete_signed_set_available(
1119    input: &OfflineArtifactVerificationRequest,
1120    files: &[VerifiedInputFile],
1121) -> bool {
1122    if input.manifest.is_none()
1123        || input.request.signing.mode == rustferry_remote::SigningMode::UnsignedCompileOnly
1124    {
1125        return false;
1126    }
1127    let names = files
1128        .iter()
1129        .map(|file| file.record.file_name.as_str())
1130        .collect::<BTreeSet<_>>();
1131    required_signed_file_names(&input.request)
1132        .iter()
1133        .all(|name| names.contains(name))
1134}
1135
1136fn required_signed_file_names(request: &IosDeviceBuildRequest) -> BTreeSet<&'static str> {
1137    let mut names = BTreeSet::from([
1138        DEVELOPMENT_IPA_NAME,
1139        ARTIFACT_MANIFEST_NAME,
1140        SIGNING_REPORT_NAME,
1141        VALIDATION_REPORT_NAME,
1142        SANITIZED_BUILD_LOG_NAME,
1143    ]);
1144    if request
1145        .requested_artifacts
1146        .contains(&IosArtifactType::AppBundle)
1147    {
1148        names.insert(APP_BUNDLE_ARCHIVE_NAME);
1149    }
1150    if request
1151        .requested_artifacts
1152        .contains(&IosArtifactType::Xcarchive)
1153    {
1154        names.insert(SIGNED_XCARCHIVE_NAME);
1155    }
1156    if request.requested_artifacts.contains(&IosArtifactType::Dsym) {
1157        names.insert(DSYM_ARCHIVE_NAME);
1158    }
1159    names
1160}
1161
1162fn validate_complete_signed_set(
1163    input: &OfflineArtifactVerificationRequest,
1164    files: &mut [VerifiedInputFile],
1165) -> Result<(IpaInspection, bool), OfflineArtifactError> {
1166    let manifest = input
1167        .manifest
1168        .as_ref()
1169        .ok_or(OfflineArtifactError::EvidenceMismatch)?;
1170    let worker_manifest = worker_manifest(manifest);
1171    let manifest_file = files
1172        .iter_mut()
1173        .find(|file| file.record.file_name == ARTIFACT_MANIFEST_NAME)
1174        .ok_or(OfflineArtifactError::EvidenceMismatch)?;
1175    let manifest_bytes =
1176        read_retained_bounded(&mut manifest_file.retained.file, MAX_MANIFEST_BYTES)?;
1177    let decoded: ArtifactManifest = strict_json::decode(&manifest_bytes, MAX_MANIFEST_BYTES)
1178        .map_err(|_| OfflineArtifactError::EvidenceMismatch)?;
1179    if !manifests_equivalent(&decoded, &worker_manifest) {
1180        return Err(OfflineArtifactError::EvidenceMismatch);
1181    }
1182
1183    let expected = GithubArtifactExpectation::new(
1184        input.compile_evidence.job_id.clone(),
1185        input.compile_evidence.provider.clone(),
1186        input.request.clone(),
1187        input.compile_evidence.clone(),
1188    )
1189    .map_err(|_| OfflineArtifactError::EvidenceMismatch)?;
1190    let ipa_expectation = input
1191        .request
1192        .ipa_expectation()
1193        .map_err(|_| OfflineArtifactError::InvalidInput)?;
1194    let required_names = required_signed_file_names(&input.request);
1195    let mut workspace = OfflineWorkspace::new()?;
1196    let staging = workspace.create_directory("staging")?;
1197    let output = workspace.create_directory("output")?;
1198    let outer_path = workspace.root.join("signed-artifact.zip");
1199    let outer = workspace.create_file(&outer_path)?;
1200    write_signed_outer(outer, files, &required_names)?;
1201    let published = ingest_github_actions_artifact(GithubArtifactIngestion {
1202        archive_path: &outer_path,
1203        temporary_directory: &staging,
1204        output_directory: &output,
1205        expected: &expected,
1206        ipa_expectation: &ipa_expectation,
1207    })
1208    .map_err(|_| OfflineArtifactError::ProductValidationFailed)?;
1209    if !manifests_equivalent(&published.manifest, &worker_manifest) {
1210        return Err(OfflineArtifactError::EvidenceMismatch);
1211    }
1212    let cleanup_bound = match &input.signed_cleanup_evidence {
1213        Some(evidence) => validate_signed_cleanup_binding(input, manifest, evidence)?,
1214        None => false,
1215    };
1216    for file in files {
1217        file.retained.revalidate(&file.path)?;
1218    }
1219    Ok((published.ipa_inspection, cleanup_bound))
1220}
1221
1222fn worker_manifest(manifest: &ArtifactManifest) -> ArtifactManifest {
1223    let mut worker = manifest.clone();
1224    worker
1225        .artifacts
1226        .retain(|record| record.kind != ArtifactKind::Manifest);
1227    worker
1228}
1229
1230fn manifests_equivalent(left: &ArtifactManifest, right: &ArtifactManifest) -> bool {
1231    let mut left = left.clone();
1232    let mut right = right.clone();
1233    left.artifacts
1234        .sort_by(|first, second| first.artifact_id.cmp(&second.artifact_id));
1235    right
1236        .artifacts
1237        .sort_by(|first, second| first.artifact_id.cmp(&second.artifact_id));
1238    left == right
1239}
1240
1241fn write_signed_outer(
1242    file: File,
1243    files: &mut [VerifiedInputFile],
1244    required_names: &BTreeSet<&str>,
1245) -> Result<(), OfflineArtifactError> {
1246    let mut writer = ZipWriter::new(file);
1247    let options = SimpleFileOptions::default()
1248        .compression_method(CompressionMethod::Stored)
1249        .unix_permissions(0o600);
1250    for name in required_names {
1251        let source = files
1252            .iter_mut()
1253            .find(|file| file.record.file_name == *name)
1254            .ok_or(OfflineArtifactError::EvidenceMismatch)?;
1255        writer
1256            .start_file(*name, options)
1257            .map_err(|_| OfflineArtifactError::LocalIo)?;
1258        copy_retained_exact(&mut source.retained.file, &mut writer, source.record.size)?;
1259    }
1260    let file = writer.finish().map_err(|_| OfflineArtifactError::LocalIo)?;
1261    file.sync_all().map_err(|_| OfflineArtifactError::LocalIo)
1262}
1263
1264fn validate_signed_cleanup_binding(
1265    input: &OfflineArtifactVerificationRequest,
1266    manifest: &ArtifactManifest,
1267    evidence: &GithubSignedCleanupEvidenceV1,
1268) -> Result<bool, OfflineArtifactError> {
1269    let manifest_record = manifest
1270        .one_artifact(ArtifactKind::Manifest)
1271        .map_err(|_| OfflineArtifactError::EvidenceMismatch)?;
1272    let signing_report = manifest
1273        .one_artifact(ArtifactKind::SigningReport)
1274        .map_err(|_| OfflineArtifactError::EvidenceMismatch)?;
1275    let validation_report = manifest
1276        .one_artifact(ArtifactKind::ValidationReport)
1277        .map_err(|_| OfflineArtifactError::EvidenceMismatch)?;
1278    let compile_bytes = serde_json::to_vec(&input.compile_evidence)
1279        .map_err(|_| OfflineArtifactError::EvidenceMismatch)?;
1280    let compile_sha256 = hex::encode(Sha256::digest(compile_bytes));
1281    let valid = evidence.schema_version == GITHUB_SIGNED_CLEANUP_EVIDENCE_SCHEMA_VERSION
1282        && evidence.provider == GITHUB_PROVIDER_ID
1283        && evidence.provider == input.compile_evidence.provider
1284        && input.request.signing.mode == SigningMode::ManualDevelopment
1285        && input.request.operation_id == input.compile_evidence.job_id
1286        && evidence.operation_id == input.request.operation_id
1287        && evidence.job_id == input.compile_evidence.job_id
1288        && evidence.request_sha256 == input.request_sha256
1289        && Some(evidence.source_repository.as_str()) == input.source.repository.as_deref()
1290        && Some(evidence.source_revision.as_str()) == input.source.revision.as_deref()
1291        && evidence.source_sha256 == input.source.sha256
1292        && evidence.compile_evidence_sha256 == compile_sha256
1293        && evidence.manifest_sha256 == manifest_record.sha256
1294        && evidence.signing_report_sha256 == signing_report.sha256
1295        && evidence.validation_report_sha256 == validation_report.sha256
1296        && evidence.execution_repository_id != 0
1297        && evidence.github_artifact_id != 0
1298        && evidence.run.run_id != 0
1299        && evidence.run.workflow_id != 0
1300        && evidence.run.run_number != 0
1301        && evidence.run.run_attempt != 0
1302        && evidence.run.status == GithubRunStatusV1::Completed
1303        && evidence.run.conclusion == Some(GithubRunConclusionV1::Success)
1304        && evidence.run.head_sha == evidence.dispatch_revision
1305        && manifest.cleanup_status == CleanupStatus::Confirmed
1306        && manifest.signing.mode == SigningMode::ManualDevelopment
1307        && manifest.signing.status == SigningStatus::ArtifactValidated
1308        && is_lower_sha256(&evidence.compile_evidence_sha256)
1309        && is_lower_sha256(&evidence.manifest_sha256)
1310        && is_lower_sha256(&evidence.signing_report_sha256)
1311        && is_lower_sha256(&evidence.validation_report_sha256)
1312        && is_lower_sha256(&evidence.github_artifact_api_sha256);
1313    if !valid {
1314        return Err(OfflineArtifactError::EvidenceMismatch);
1315    }
1316    Ok(true)
1317}
1318
1319fn read_retained_bounded(file: &mut File, maximum: usize) -> Result<Vec<u8>, OfflineArtifactError> {
1320    file.seek(SeekFrom::Start(0))
1321        .map_err(|_| OfflineArtifactError::LocalIo)?;
1322    let mut bytes = Vec::new();
1323    (&mut *file)
1324        .take(u64::try_from(maximum).unwrap_or(u64::MAX).saturating_add(1))
1325        .read_to_end(&mut bytes)
1326        .map_err(|_| OfflineArtifactError::LocalIo)?;
1327    if bytes.len() > maximum {
1328        return Err(OfflineArtifactError::ResourceLimitExceeded);
1329    }
1330    file.seek(SeekFrom::Start(0))
1331        .map_err(|_| OfflineArtifactError::LocalIo)?;
1332    Ok(bytes)
1333}
1334
1335fn copy_retained_exact(
1336    source: &mut File,
1337    destination: &mut impl Write,
1338    expected_size: u64,
1339) -> Result<(), OfflineArtifactError> {
1340    source
1341        .seek(SeekFrom::Start(0))
1342        .map_err(|_| OfflineArtifactError::LocalIo)?;
1343    let copied = io::copy(
1344        &mut (&mut *source).take(expected_size.saturating_add(1)),
1345        destination,
1346    )
1347    .map_err(|_| OfflineArtifactError::LocalIo)?;
1348    if copied != expected_size {
1349        return Err(OfflineArtifactError::IntegrityMismatch);
1350    }
1351    source
1352        .seek(SeekFrom::Start(0))
1353        .map_err(|_| OfflineArtifactError::LocalIo)?;
1354    Ok(())
1355}
1356
1357struct MaterializedFile {
1358    path: Utf8PathBuf,
1359    _guard: File,
1360}
1361
1362struct OfflineWorkspace {
1363    directory_guards: Vec<WorkspaceDirectoryGuard>,
1364    _root_guard: WorkspaceDirectoryGuard,
1365    root: Utf8PathBuf,
1366    _outer: TempDir,
1367}
1368
1369#[cfg(windows)]
1370type WorkspaceDirectoryGuard = File;
1371
1372#[cfg(not(windows))]
1373struct WorkspaceDirectoryGuard;
1374
1375impl OfflineWorkspace {
1376    fn new() -> Result<Self, OfflineArtifactError> {
1377        let outer = tempfile::tempdir().map_err(|_| OfflineArtifactError::LocalIo)?;
1378        let outer_path = outer
1379            .path()
1380            .canonicalize()
1381            .map_err(|_| OfflineArtifactError::LocalIo)?;
1382        let root = Utf8PathBuf::from_path_buf(outer_path.join("offline"))
1383            .map_err(|_| OfflineArtifactError::LocalIo)?;
1384        let root_guard = create_workspace_directory(&root)?;
1385        Ok(Self {
1386            directory_guards: Vec::new(),
1387            _root_guard: root_guard,
1388            root,
1389            _outer: outer,
1390        })
1391    }
1392
1393    fn create_directory(&mut self, name: &str) -> Result<Utf8PathBuf, OfflineArtifactError> {
1394        if name.is_empty() || name.contains(['/', '\\', ':']) {
1395            return Err(OfflineArtifactError::InvalidInput);
1396        }
1397        let path = self.root.join(name);
1398        let guard = create_workspace_directory(&path)?;
1399        self.directory_guards.push(guard);
1400        Ok(path)
1401    }
1402
1403    fn create_file(&self, path: &Utf8Path) -> Result<File, OfflineArtifactError> {
1404        if path.parent() != Some(self.root.as_path()) {
1405            return Err(OfflineArtifactError::InvalidPath);
1406        }
1407        create_workspace_file(path)
1408    }
1409
1410    fn materialize(
1411        &mut self,
1412        name: &str,
1413        source: &mut RetainedArtifactFile,
1414        expected_size: u64,
1415    ) -> Result<MaterializedFile, OfflineArtifactError> {
1416        let path = self.root.join(name);
1417        let mut file = self.create_file(&path)?;
1418        copy_retained_exact(&mut source.file, &mut file, expected_size)?;
1419        file.flush().map_err(|_| OfflineArtifactError::LocalIo)?;
1420        file.sync_all().map_err(|_| OfflineArtifactError::LocalIo)?;
1421        file.seek(SeekFrom::Start(0))
1422            .map_err(|_| OfflineArtifactError::LocalIo)?;
1423        regular_file_identity_from_file(&file).map_err(map_identity_error)?;
1424        Ok(MaterializedFile { path, _guard: file })
1425    }
1426}
1427
1428#[cfg(windows)]
1429fn create_workspace_directory(
1430    path: &Utf8Path,
1431) -> Result<WorkspaceDirectoryGuard, OfflineArtifactError> {
1432    create_windows_private_directory(path.as_std_path()).map_err(|_| OfflineArtifactError::LocalIo)
1433}
1434
1435#[cfg(not(windows))]
1436fn create_workspace_directory(
1437    path: &Utf8Path,
1438) -> Result<WorkspaceDirectoryGuard, OfflineArtifactError> {
1439    let mut builder = std::fs::DirBuilder::new();
1440    builder.mode(0o700);
1441    builder
1442        .create(path)
1443        .map_err(|_| OfflineArtifactError::LocalIo)?;
1444    Ok(WorkspaceDirectoryGuard)
1445}
1446
1447#[cfg(windows)]
1448fn create_workspace_file(path: &Utf8Path) -> Result<File, OfflineArtifactError> {
1449    create_windows_private_file(path.as_std_path()).map_err(|_| OfflineArtifactError::LocalIo)
1450}
1451
1452#[cfg(not(windows))]
1453fn create_workspace_file(path: &Utf8Path) -> Result<File, OfflineArtifactError> {
1454    let mut options = OpenOptions::new();
1455    options.read(true).write(true).create_new(true).mode(0o600);
1456    options
1457        .open(path)
1458        .map_err(|_| OfflineArtifactError::LocalIo)
1459}
1460
1461fn offline_source_archive_limits() -> SourceArchiveLimits {
1462    SourceArchiveLimits {
1463        source: SourceLimits {
1464            max_file_count: 50_000,
1465            max_file_size: MAX_ZIP_ENTRY_BYTES,
1466            max_total_size: 2 * 1024 * 1024 * 1024,
1467            max_depth: MAX_ZIP_DEPTH,
1468            max_ignore_file_size: 64 * 1024,
1469            max_ignore_rules: 1,
1470        },
1471        max_archive_size: 2 * 1024 * 1024 * 1024,
1472        max_compression_ratio: 100,
1473    }
1474}
1475
1476fn validate_local_path(path: &Utf8Path) -> Result<(), OfflineArtifactError> {
1477    if !path.is_absolute() || path.as_str().contains('\0') {
1478        return Err(OfflineArtifactError::InvalidPath);
1479    }
1480    let components = path.as_std_path().components().collect::<Vec<_>>();
1481    if components.len() > MAX_ZIP_DEPTH
1482        || components
1483            .iter()
1484            .any(|component| matches!(component, Component::CurDir | Component::ParentDir))
1485    {
1486        return Err(OfflineArtifactError::InvalidPath);
1487    }
1488    #[cfg(windows)]
1489    if components.iter().any(|component| {
1490        matches!(component, Component::Normal(value) if value.to_string_lossy().contains(':'))
1491    }) {
1492        return Err(OfflineArtifactError::InvalidPath);
1493    }
1494    Ok(())
1495}
1496
1497fn retain_parent_chain(
1498    path: &Utf8Path,
1499) -> Result<Vec<(PathBuf, RetainedDirectoryIdentity)>, OfflineArtifactError> {
1500    let parent = path.parent().ok_or(OfflineArtifactError::InvalidPath)?;
1501    reject_reparse_ancestors(parent.as_std_path())?;
1502    let parent_path = parent.as_std_path().to_path_buf();
1503    let guard = RetainedDirectoryIdentity::open(&parent_path).map_err(map_identity_error)?;
1504    Ok(vec![(parent_path, guard)])
1505}
1506
1507fn reject_reparse_ancestors(path: &std::path::Path) -> Result<(), OfflineArtifactError> {
1508    let mut current = PathBuf::new();
1509    let mut rooted = false;
1510    for component in path.components() {
1511        current.push(component.as_os_str());
1512        rooted |= component == Component::RootDir;
1513        if rooted && current.is_absolute() {
1514            let metadata =
1515                fs::symlink_metadata(&current).map_err(|_| OfflineArtifactError::LocalIo)?;
1516            if metadata.file_type().is_symlink() || metadata_is_windows_reparse(&metadata) {
1517                return Err(OfflineArtifactError::UnsafeFilesystemObject);
1518            }
1519        }
1520    }
1521    Ok(())
1522}
1523
1524#[cfg(windows)]
1525fn metadata_is_windows_reparse(metadata: &fs::Metadata) -> bool {
1526    use std::os::windows::fs::MetadataExt as _;
1527
1528    metadata.file_attributes()
1529        & windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT
1530        != 0
1531}
1532
1533#[cfg(not(windows))]
1534const fn metadata_is_windows_reparse(_metadata: &fs::Metadata) -> bool {
1535    false
1536}
1537
1538fn verify_parent_chain(
1539    parents: &[(PathBuf, RetainedDirectoryIdentity)],
1540) -> Result<(), OfflineArtifactError> {
1541    for (path, guard) in parents {
1542        guard.verify_path(path).map_err(map_identity_error)?;
1543    }
1544    Ok(())
1545}
1546
1547fn map_identity_error(error: rustferry_core::DirectoryIdentityError) -> OfflineArtifactError {
1548    match error.kind() {
1549        DirectoryIdentityErrorKind::InvalidPath => OfflineArtifactError::InvalidPath,
1550        DirectoryIdentityErrorKind::ReparsePoint
1551        | DirectoryIdentityErrorKind::MultipleLinks
1552        | DirectoryIdentityErrorKind::NotRegularFile
1553        | DirectoryIdentityErrorKind::NotDirectory
1554        | DirectoryIdentityErrorKind::IdentityMismatch => {
1555            OfflineArtifactError::UnsafeFilesystemObject
1556        }
1557        _ => OfflineArtifactError::LocalIo,
1558    }
1559}
1560
1561fn matches_ignore_ascii_case(value: &str, expected: &[&str]) -> bool {
1562    expected
1563        .iter()
1564        .any(|candidate| value.eq_ignore_ascii_case(candidate))
1565}
1566
1567fn is_lower_sha256(value: &str) -> bool {
1568    value.len() == 64
1569        && value
1570            .bytes()
1571            .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
1572}
1573
1574#[cfg(test)]
1575mod tests {
1576    use super::*;
1577    use std::{fs, path::Path};
1578
1579    use rustferry_remote::{
1580        BuildProfile, BundleIdentifier, COMPILE_PHASE_EVIDENCE_SCHEMA_VERSION,
1581        CURRENT_PROTOCOL_VERSION, CompileToolchainEvidence, EntitlementPlan, EntitlementSet,
1582        IOS_DEVICE_RUST_TARGET, IosDeviceProductExpectation,
1583        SEALED_UNSIGNED_ARCHIVE_SCHEMA_VERSION, SealedUnsignedArchive, SigningMode, SigningPlan,
1584        SigningTarget, SigningTargetKind, SourceArchive, SourceManifest, SourceManifestEntry,
1585        SourceMode, UnsignedAppInspection, UnsignedXcarchiveExpectation,
1586    };
1587
1588    const OPERATION_ID: &str = "offline-artifact-test";
1589    const SOURCE_REVISION: &str = "4444444444444444444444444444444444444444";
1590
1591    #[test]
1592    fn inspect_rejects_relative_directory_and_hard_link_paths() {
1593        assert_eq!(
1594            inspect(Utf8Path::new("relative.zip")),
1595            Err(OfflineArtifactError::InvalidPath)
1596        );
1597
1598        let root = TempDir::new().unwrap();
1599        let path = root.path().join("artifact.zip");
1600        let alias = root.path().join("alias.zip");
1601        fs::write(&path, b"not a zip").unwrap();
1602        fs::hard_link(&path, &alias).unwrap();
1603        let path = absolute_utf8(&path);
1604        assert_eq!(
1605            inspect(&path),
1606            Err(OfflineArtifactError::UnsafeFilesystemObject)
1607        );
1608
1609        let directory = absolute_utf8(root.path());
1610        assert_eq!(
1611            inspect(&directory),
1612            Err(OfflineArtifactError::UnsafeFilesystemObject)
1613        );
1614    }
1615
1616    #[test]
1617    fn inspect_rejects_symlink_or_reparse_file() {
1618        let root = TempDir::new().unwrap();
1619        let root_path = absolute_utf8(root.path());
1620        let target = root_path.join("target.zip");
1621        let linked = root_path.join("linked.zip");
1622        fs::write(&target, b"not a zip").unwrap();
1623        if create_file_symlink(&target, &linked).is_err() {
1624            return;
1625        }
1626        assert_eq!(
1627            inspect(&linked),
1628            Err(OfflineArtifactError::UnsafeFilesystemObject)
1629        );
1630
1631        let target_directory = root_path.join("target-directory");
1632        let linked_directory = root_path.join("linked-directory");
1633        fs::create_dir(&target_directory).unwrap();
1634        fs::write(target_directory.join("artifact.zip"), b"not a zip").unwrap();
1635        if create_directory_symlink(&target_directory, &linked_directory).is_ok() {
1636            assert_eq!(
1637                inspect(&linked_directory.join("artifact.zip")),
1638                Err(OfflineArtifactError::UnsafeFilesystemObject)
1639            );
1640        }
1641    }
1642
1643    #[test]
1644    fn inspect_rejects_traversal_links_and_portable_zip_aliases() {
1645        for (name, entries) in [
1646            ("traversal.zip", vec![("../escape", 0o100_644)]),
1647            ("link.zip", vec![("linked", 0o120_777)]),
1648            (
1649                "collision.zip",
1650                vec![("Payload/App", 0o100_644), ("payload/app", 0o100_644)],
1651            ),
1652            (
1653                "file-directory.zip",
1654                vec![("Payload/App", 0o100_644), ("Payload/App/", 0o040_755)],
1655            ),
1656            (
1657                "file-parent.zip",
1658                vec![("Payload", 0o100_644), ("Payload/App", 0o100_644)],
1659            ),
1660            (
1661                "late-file-parent.zip",
1662                vec![("Payload/App", 0o100_644), ("Payload", 0o100_644)],
1663            ),
1664            ("reserved.zip", vec![("Payload/CON.txt", 0o100_644)]),
1665            ("trailing-dot.zip", vec![("Payload/App.", 0o100_644)]),
1666        ] {
1667            let root = TempDir::new().unwrap();
1668            let path = root.path().join(name);
1669            write_zip(&path, &entries);
1670            let path = absolute_utf8(&path);
1671            assert_eq!(
1672                inspect(&path),
1673                Err(OfflineArtifactError::UnsafeZip),
1674                "{name}"
1675            );
1676        }
1677
1678        let root = TempDir::new().unwrap();
1679        let path = root.path().join("duplicate.zip");
1680        write_zip(
1681            &path,
1682            &[("Payload/App", 0o100_644), ("Payload/Apx", 0o100_644)],
1683        );
1684        rewrite_zip_name(&path, b"Payload/Apx", b"Payload/App");
1685        assert_eq!(
1686            inspect(&absolute_utf8(&path)),
1687            Err(OfflineArtifactError::UnsafeZip)
1688        );
1689    }
1690
1691    #[test]
1692    fn inspect_returns_bounded_path_free_zip_evidence() {
1693        let root = TempDir::new().unwrap();
1694        let path = root.path().join("artifact.zip");
1695        write_zip(
1696            &path,
1697            &[("Payload/", 0o040_755), ("Payload/App", 0o100_644)],
1698        );
1699        let path = absolute_utf8(&path);
1700        let evidence = inspect(&path).unwrap();
1701        assert_eq!(
1702            evidence.container,
1703            OfflineArtifactContainer::Zip {
1704                entry_count: 2,
1705                expanded_size: 14,
1706            }
1707        );
1708        assert_eq!(evidence.size, fs::metadata(&path).unwrap().len());
1709        assert!(is_lower_sha256(&evidence.sha256));
1710        let encoded = serde_json::to_string(&evidence).unwrap();
1711        assert!(!encoded.contains(path.as_str()));
1712    }
1713
1714    #[test]
1715    fn inspect_recognizes_zip_bytes_without_a_zip_extension() {
1716        let root = TempDir::new().unwrap();
1717        let path = root.path().join("artifact.bin");
1718        write_zip(&path, &[("Payload/App", 0o100_644)]);
1719        let path = absolute_utf8(&path);
1720        assert!(matches!(
1721            inspect(&path).unwrap().container,
1722            OfflineArtifactContainer::Zip { entry_count: 1, .. }
1723        ));
1724    }
1725
1726    #[test]
1727    fn verify_detects_post_record_tampering() {
1728        let root = TempDir::new().unwrap();
1729        let path = root.path().join("build.log");
1730        fs::write(&path, b"trusted").unwrap();
1731        let path = absolute_utf8(&path);
1732        let request = test_verification_request(&path, ArtifactKind::SanitizedLog);
1733        let verified = verify(&request).unwrap();
1734        assert_eq!(
1735            verified.evidence_level,
1736            OfflineArtifactEvidenceLevel::Integrity
1737        );
1738        assert_eq!(
1739            verified.outcome,
1740            OfflineArtifactVerificationOutcome::EvidenceUnavailable
1741        );
1742        assert_eq!(
1743            verified.validation_levels,
1744            BTreeSet::from([ValidationLevel::DownloadedToClient])
1745        );
1746        let encoded = serde_json::to_vec(&verified).unwrap();
1747        let decoded: OfflineArtifactVerification = serde_json::from_slice(&encoded).unwrap();
1748        assert_eq!(decoded, verified);
1749
1750        fs::write(&path, b"tampered").unwrap();
1751        assert_eq!(
1752            verify(&request),
1753            Err(OfflineArtifactError::IntegrityMismatch)
1754        );
1755    }
1756
1757    #[test]
1758    fn verify_reports_missing_product_evidence_without_overclaiming() {
1759        let root = TempDir::new().unwrap();
1760        let path = root.path().join(APP_BUNDLE_ARCHIVE_NAME);
1761        write_zip(&path, &[("App.app/Info.plist", 0o100_644)]);
1762        let path = absolute_utf8(&path);
1763        let request = test_verification_request(&path, ArtifactKind::App);
1764        let verified = verify(&request).unwrap();
1765        assert_eq!(
1766            verified.outcome,
1767            OfflineArtifactVerificationOutcome::EvidenceUnavailable
1768        );
1769        assert_eq!(
1770            verified.evidence_level,
1771            OfflineArtifactEvidenceLevel::ArchiveSafety
1772        );
1773        assert!(verified.product.is_none());
1774        assert!(!verified.signed_cleanup_evidence_bound);
1775    }
1776
1777    #[test]
1778    fn verify_rejects_request_source_and_catalog_mismatches() {
1779        let root = TempDir::new().unwrap();
1780        let path = root.path().join("build.log");
1781        fs::write(&path, b"trusted").unwrap();
1782        let path = absolute_utf8(&path);
1783        let mut request = test_verification_request(&path, ArtifactKind::SanitizedLog);
1784        request.source.sha256 = "0".repeat(64);
1785        assert_eq!(
1786            verify(&request),
1787            Err(OfflineArtifactError::EvidenceMismatch)
1788        );
1789
1790        let mut request = test_verification_request(&path, ArtifactKind::SanitizedLog);
1791        request
1792            .compile_evidence
1793            .sealed_archive
1794            .contents
1795            .entries
1796            .clear();
1797        assert_eq!(
1798            verify(&request),
1799            Err(OfflineArtifactError::EvidenceMismatch)
1800        );
1801
1802        let mut request = test_verification_request(&path, ArtifactKind::SanitizedLog);
1803        request.catalog.push(request.artifact.clone());
1804        assert_eq!(verify(&request), Err(OfflineArtifactError::InvalidInput));
1805    }
1806
1807    #[test]
1808    fn verify_rejects_same_byte_primary_and_companion_replacements() {
1809        let root = TempDir::new().unwrap();
1810        let primary = root.path().join("primary.log");
1811        fs::write(&primary, b"primary bytes").unwrap();
1812        let primary = absolute_utf8(&primary);
1813        let request = test_verification_request(&primary, ArtifactKind::SanitizedLog);
1814        replace_with_same_bytes(&primary, b"primary bytes");
1815        assert_eq!(
1816            verify(&request),
1817            Err(OfflineArtifactError::IntegrityMismatch)
1818        );
1819
1820        let companion = root.path().join("companion.log");
1821        fs::write(&companion, b"companion bytes").unwrap();
1822        let companion = absolute_utf8(&companion);
1823        let mut request = test_verification_request(&primary, ArtifactKind::SanitizedLog);
1824        request.artifact.expected_filesystem_identity = Some(
1825            RegularFileFilesystemIdentity::capture(primary.as_std_path())
1826                .unwrap()
1827                .to_string(),
1828        );
1829        request.catalog.push(test_artifact_file(
1830            &companion,
1831            "offline-companion",
1832            ArtifactKind::SanitizedLog,
1833        ));
1834        replace_with_same_bytes(&companion, b"companion bytes");
1835        assert_eq!(
1836            verify(&request),
1837            Err(OfflineArtifactError::IntegrityMismatch)
1838        );
1839
1840        request.catalog[0].expected_filesystem_identity = Some("not-an-identity".to_owned());
1841        assert_eq!(verify(&request), Err(OfflineArtifactError::InvalidInput));
1842    }
1843
1844    #[test]
1845    fn verify_forces_archive_shape_and_strict_unsigned_validation() {
1846        let root = TempDir::new().unwrap();
1847        let opaque = root.path().join("artifact.bin");
1848        fs::write(&opaque, b"not a zip").unwrap();
1849        let opaque = absolute_utf8(&opaque);
1850        let request = test_verification_request(&opaque, ArtifactKind::Xcarchive);
1851        assert_eq!(verify(&request), Err(OfflineArtifactError::InvalidZip));
1852
1853        let archive = root.path().join("unsigned.bin");
1854        write_zip(&archive, &[("fake.txt", 0o100_644)]);
1855        let archive = absolute_utf8(&archive);
1856        let mut request = test_verification_request(&archive, ArtifactKind::Xcarchive);
1857        request.compile_evidence.sealed_archive.transport.size = request.artifact.record.size;
1858        request.compile_evidence.sealed_archive.transport.sha256 =
1859            request.artifact.record.sha256.clone();
1860        assert_eq!(
1861            verify(&request),
1862            Err(OfflineArtifactError::ProductValidationFailed)
1863        );
1864    }
1865
1866    fn test_verification_request(
1867        path: &Utf8Path,
1868        kind: ArtifactKind,
1869    ) -> OfflineArtifactVerificationRequest {
1870        let request = test_request();
1871        let compile_evidence = test_compile(&request);
1872        OfflineArtifactVerificationRequest {
1873            artifact: test_artifact_file(path, "offline-artifact", kind),
1874            request_sha256: canonical_request_sha256(&request).unwrap(),
1875            source: OfflineSourceEvidence {
1876                repository: request.source_repository.clone(),
1877                revision: request.source_revision.clone(),
1878                sha256: request.source.sha256.clone(),
1879            },
1880            request,
1881            compile_evidence,
1882            manifest: None,
1883            signed_cleanup_evidence: None,
1884            catalog: Vec::new(),
1885        }
1886    }
1887
1888    fn test_artifact_file(
1889        path: &Utf8Path,
1890        artifact_id: &str,
1891        kind: ArtifactKind,
1892    ) -> OfflineArtifactFile {
1893        let bytes = fs::read(path).unwrap();
1894        OfflineArtifactFile {
1895            record: ArtifactRecord {
1896                artifact_id: artifact_id.to_owned(),
1897                kind,
1898                file_name: path.file_name().unwrap().to_owned(),
1899                size: u64::try_from(bytes.len()).unwrap(),
1900                sha256: hex::encode(Sha256::digest(&bytes)),
1901                media_type: None,
1902            },
1903            path: path.to_owned(),
1904            expected_filesystem_identity: Some(
1905                RegularFileFilesystemIdentity::capture(path.as_std_path())
1906                    .unwrap()
1907                    .to_string(),
1908            ),
1909        }
1910    }
1911
1912    fn replace_with_same_bytes(path: &Utf8Path, bytes: &[u8]) {
1913        let replacement = path.with_extension("replacement");
1914        fs::write(&replacement, bytes).unwrap();
1915        let replacement_identity =
1916            RegularFileFilesystemIdentity::capture(replacement.as_std_path()).unwrap();
1917        assert_ne!(
1918            replacement_identity.to_string(),
1919            RegularFileFilesystemIdentity::capture(path.as_std_path())
1920                .unwrap()
1921                .to_string()
1922        );
1923        fs::remove_file(path).unwrap();
1924        fs::rename(replacement, path).unwrap();
1925    }
1926
1927    fn test_request() -> IosDeviceBuildRequest {
1928        let source = test_source_manifest();
1929        let target = SigningTarget {
1930            name: "App".to_owned(),
1931            bundle_identifier: BundleIdentifier::new("com.example.app").unwrap(),
1932            kind: SigningTargetKind::Application,
1933        };
1934        let signing = SigningPlan {
1935            mode: SigningMode::UnsignedCompileOnly,
1936            signing: None,
1937            team: None,
1938            device: None,
1939            targets: vec![target],
1940            provisioning: Vec::new(),
1941            entitlements: vec![EntitlementPlan {
1942                target: "App".to_owned(),
1943                required: EntitlementSet::new(BTreeMap::new()).unwrap(),
1944            }],
1945            allow_provisioning_updates: false,
1946        };
1947        let request = IosDeviceBuildRequest {
1948            protocol_version: CURRENT_PROTOCOL_VERSION,
1949            operation_id: OPERATION_ID.to_owned(),
1950            product_name: "App".to_owned(),
1951            bundle_identifier: "com.example.app".to_owned(),
1952            minimum_ios_version: "17.0".to_owned(),
1953            product: IosDeviceProductExpectation {
1954                app_directory_name: "App.app".to_owned(),
1955                executable: "App".to_owned(),
1956                app_version: "1.0.0".to_owned(),
1957                build_number: "1".to_owned(),
1958                nested_bundles: Vec::new(),
1959            },
1960            profile: BuildProfile::Release,
1961            source_mode: SourceMode::Git,
1962            source_repository: Some("https://github.com/example/app".to_owned()),
1963            source_revision: Some(SOURCE_REVISION.to_owned()),
1964            source,
1965            signing,
1966            requested_artifacts: BTreeSet::from([IosArtifactType::Xcarchive]),
1967        };
1968        request.validate().unwrap();
1969        request
1970    }
1971
1972    fn test_compile(request: &IosDeviceBuildRequest) -> CompilePhaseEvidence {
1973        CompilePhaseEvidence {
1974            schema_version: COMPILE_PHASE_EVIDENCE_SCHEMA_VERSION,
1975            job_id: OPERATION_ID.to_owned(),
1976            provider: "offline-test".to_owned(),
1977            request_sha256: canonical_request_sha256(request).unwrap(),
1978            source_sha256: request.source.sha256.clone(),
1979            cargo_lock_sha256: project_file_sha256(&request.source, "Cargo.lock"),
1980            config_sha256: project_file_sha256(&request.source, "ferry.toml"),
1981            rustferry_version: "0.1.0".to_owned(),
1982            worker_version: "0.1.0".to_owned(),
1983            toolchain: CompileToolchainEvidence {
1984                worker_os: "macOS 26.0".to_owned(),
1985                worker_architecture: "arm64".to_owned(),
1986                xcode_version: "26.0".to_owned(),
1987                iphoneos_sdk_version: "26.0".to_owned(),
1988                iphoneos_sdk_build_version: "23A".to_owned(),
1989                developer_directory_sha256: "d".repeat(64),
1990                rust_version: "rustc 1.92.0".to_owned(),
1991                rust_target: IOS_DEVICE_RUST_TARGET.to_owned(),
1992            },
1993            sealed_archive: SealedUnsignedArchive {
1994                schema_version: SEALED_UNSIGNED_ARCHIVE_SCHEMA_VERSION,
1995                transport: SourceArchive {
1996                    size: 1,
1997                    sha256: "e".repeat(64),
1998                },
1999                contents: request.source.clone(),
2000                expectation: UnsignedXcarchiveExpectation {
2001                    app_directory_name: request.product.app_directory_name.clone(),
2002                    bundle_identifier: request.bundle_identifier.clone(),
2003                    executable: request.product.executable.clone(),
2004                    app_version: request.product.app_version.clone(),
2005                    build_number: request.product.build_number.clone(),
2006                    minimum_os: request.minimum_ios_version.clone(),
2007                    sdk_version: "26.0".to_owned(),
2008                    sdk_build_version: "23A".to_owned(),
2009                    nested_bundles: Vec::new(),
2010                    required_resources: BTreeMap::new(),
2011                },
2012            },
2013            archive_inspection: UnsignedXcarchiveInspection {
2014                application_path: "Applications/App.app".to_owned(),
2015                architectures: vec!["arm64".to_owned()],
2016                app: UnsignedAppInspection {
2017                    app_directory_name: "App.app".to_owned(),
2018                    bundle_identifier: "com.example.app".to_owned(),
2019                    executable: "App".to_owned(),
2020                    main_executable: Vec::new(),
2021                    nested_executables: BTreeMap::new(),
2022                    extensions: Vec::new(),
2023                    resources: BTreeMap::new(),
2024                    entries: Vec::new(),
2025                },
2026                entries: Vec::new(),
2027            },
2028            started_at_unix_seconds: 1_700_000_000,
2029            finished_at_unix_seconds: 1_700_000_060,
2030        }
2031    }
2032
2033    fn test_source_manifest() -> SourceManifest {
2034        let entries = vec![
2035            SourceManifestEntry {
2036                path: "Cargo.lock".to_owned(),
2037                size: 0,
2038                sha256: "a".repeat(64),
2039                executable: false,
2040            },
2041            SourceManifestEntry {
2042                path: "ferry.toml".to_owned(),
2043                size: 0,
2044                sha256: "b".repeat(64),
2045                executable: false,
2046            },
2047        ];
2048        let mut digest = Sha256::new();
2049        digest.update(b"rustferry-source-manifest-v1\0");
2050        digest_string(&mut digest, ".");
2051        digest.update((entries.len() as u64).to_be_bytes());
2052        for entry in &entries {
2053            digest_string(&mut digest, &entry.path);
2054            digest.update(entry.size.to_be_bytes());
2055            digest_string(&mut digest, &entry.sha256);
2056            digest.update([u8::from(entry.executable)]);
2057        }
2058        digest.update(0_u64.to_be_bytes());
2059        SourceManifest {
2060            schema_version: 1,
2061            project_path: ".".to_owned(),
2062            entries,
2063            total_size: 0,
2064            sha256: hex::encode(digest.finalize()),
2065        }
2066    }
2067
2068    fn project_file_sha256(manifest: &SourceManifest, name: &str) -> String {
2069        manifest
2070            .entries
2071            .iter()
2072            .find(|entry| entry.path == name)
2073            .unwrap()
2074            .sha256
2075            .clone()
2076    }
2077
2078    fn digest_string(digest: &mut Sha256, value: &str) {
2079        digest.update((value.len() as u64).to_be_bytes());
2080        digest.update(value.as_bytes());
2081    }
2082
2083    fn write_zip(path: &Path, entries: &[(&str, u32)]) {
2084        let mut writer = ZipWriter::new(File::create(path).unwrap());
2085        for (name, mode) in entries {
2086            let options = SimpleFileOptions::default()
2087                .compression_method(CompressionMethod::Stored)
2088                .unix_permissions(*mode);
2089            if mode & 0o170_000 == 0o120_000 {
2090                writer.add_symlink(*name, "target", options).unwrap();
2091            } else if name.ends_with('/') {
2092                writer.add_directory(*name, options).unwrap();
2093            } else {
2094                writer.start_file(*name, options).unwrap();
2095                writer.write_all(b"zip-entry-data").unwrap();
2096            }
2097        }
2098        writer.finish().unwrap();
2099    }
2100
2101    fn rewrite_zip_name(path: &Path, from: &[u8], to: &[u8]) {
2102        assert_eq!(from.len(), to.len());
2103        let mut bytes = fs::read(path).unwrap();
2104        let offsets = bytes
2105            .windows(from.len())
2106            .enumerate()
2107            .filter_map(|(offset, window)| (window == from).then_some(offset))
2108            .collect::<Vec<_>>();
2109        assert_eq!(offsets.len(), 2);
2110        for offset in offsets {
2111            bytes[offset..offset + to.len()].copy_from_slice(to);
2112        }
2113        fs::write(path, bytes).unwrap();
2114    }
2115
2116    fn absolute_utf8(path: &Path) -> Utf8PathBuf {
2117        Utf8PathBuf::from_path_buf(path.canonicalize().unwrap()).unwrap()
2118    }
2119
2120    #[cfg(unix)]
2121    fn create_file_symlink(target: &Utf8Path, link: &Utf8Path) -> io::Result<()> {
2122        std::os::unix::fs::symlink(target, link)
2123    }
2124
2125    #[cfg(unix)]
2126    fn create_directory_symlink(target: &Utf8Path, link: &Utf8Path) -> io::Result<()> {
2127        std::os::unix::fs::symlink(target, link)
2128    }
2129
2130    #[cfg(windows)]
2131    fn create_file_symlink(target: &Utf8Path, link: &Utf8Path) -> io::Result<()> {
2132        std::os::windows::fs::symlink_file(target, link)
2133    }
2134
2135    #[cfg(windows)]
2136    fn create_directory_symlink(target: &Utf8Path, link: &Utf8Path) -> io::Result<()> {
2137        std::os::windows::fs::symlink_dir(target, link)
2138    }
2139}