1use 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
59pub 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#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
75#[serde(deny_unknown_fields)]
76pub struct OfflineSourceEvidence {
77 pub repository: Option<String>,
79 pub revision: Option<String>,
81 pub sha256: String,
83}
84
85#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
87#[serde(deny_unknown_fields)]
88pub struct OfflineArtifactFile {
89 pub record: ArtifactRecord,
91 pub path: Utf8PathBuf,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub expected_filesystem_identity: Option<String>,
96}
97
98#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
100#[serde(deny_unknown_fields)]
101pub struct OfflineArtifactVerificationRequest {
102 pub artifact: OfflineArtifactFile,
104 pub request: IosDeviceBuildRequest,
106 pub request_sha256: String,
108 pub source: OfflineSourceEvidence,
110 pub compile_evidence: CompilePhaseEvidence,
112 pub manifest: Option<ArtifactManifest>,
114 pub signed_cleanup_evidence: Option<GithubSignedCleanupEvidenceV1>,
116 pub catalog: Vec<OfflineArtifactFile>,
118}
119
120#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
122#[serde(rename_all = "snake_case")]
123pub enum OfflineArtifactEvidenceLevel {
124 Integrity,
126 ArchiveSafety,
128 Product,
130 CrossValidated,
132}
133
134#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
136#[serde(rename_all = "snake_case")]
137pub enum OfflineArtifactVerificationOutcome {
138 Verified,
140 EvidenceUnavailable,
142}
143
144#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
146#[serde(tag = "kind", rename_all = "snake_case")]
147pub enum OfflineArtifactContainer {
148 Opaque,
150 Zip {
152 entry_count: u32,
154 expanded_size: u64,
156 },
157}
158
159#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
161#[serde(deny_unknown_fields)]
162pub struct OfflineArtifactInspection {
163 pub schema_version: u32,
165 pub size: u64,
167 pub sha256: String,
169 pub filesystem_identity: String,
171 pub container: OfflineArtifactContainer,
173}
174
175#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
177#[serde(tag = "kind", content = "evidence", rename_all = "snake_case")]
178pub enum OfflineProductEvidence {
179 UnsignedXcarchive(UnsignedXcarchiveInspection),
181 Ipa(IpaInspection),
183 SignedArtifactSet(IpaInspection),
185}
186
187#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
189#[serde(deny_unknown_fields)]
190pub struct OfflineArtifactVerification {
191 pub schema_version: u32,
193 pub artifact_id: String,
195 pub artifact_kind: ArtifactKind,
197 pub file_name: String,
199 pub evidence_level: OfflineArtifactEvidenceLevel,
201 pub outcome: OfflineArtifactVerificationOutcome,
203 pub inspection: OfflineArtifactInspection,
205 pub validation_levels: BTreeSet<ValidationLevel>,
207 pub product: Option<OfflineProductEvidence>,
209 pub signed_cleanup_evidence_bound: bool,
211}
212
213#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
215#[serde(rename_all = "snake_case")]
216pub enum OfflineArtifactError {
217 InvalidInput,
219 InvalidPath,
221 UnsafeFilesystemObject,
223 ResourceLimitExceeded,
225 InvalidZip,
227 UnsafeZip,
229 IntegrityMismatch,
231 EvidenceMismatch,
233 ProductValidationFailed,
235 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
307pub 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
320pub 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(¤t).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}