Skip to main content

rustferry_github/
artifact_store.rs

1//! Verified GitHub Actions artifact storage.
2//!
3//! The store owns a transport separate from run orchestration, downloads only
4//! exact run-attempt artifact names, treats both ZIP layers as untrusted, and
5//! publishes client files with atomic no-clobber semantics.
6
7use std::{
8    collections::{BTreeMap, BTreeSet, btree_map::Entry},
9    error::Error,
10    fmt,
11    fs::{self, File},
12    io::{self, Read, Write},
13    path::{Component, Path, PathBuf},
14};
15
16#[cfg(not(windows))]
17use std::fs::OpenOptions;
18#[cfg(windows)]
19use std::io::{Seek as _, SeekFrom};
20use std::sync::atomic::{AtomicU64, Ordering};
21
22#[cfg(unix)]
23use std::os::unix::fs::{DirBuilderExt as _, OpenOptionsExt as _, PermissionsExt as _};
24#[cfg(windows)]
25use std::os::windows::io::AsHandle as _;
26
27use camino::{Utf8Path, Utf8PathBuf};
28#[cfg(any(windows, test))]
29use rustferry_core::regular_file_identity_from_file;
30#[cfg(windows)]
31use rustferry_core::windows_private_directory::{
32    PrivateDirectoryCleanupStatus, PrivateDirectoryError, PrivateDirectoryErrorKind,
33    PrivateFileLinkState, create_private_directory as create_windows_private_directory,
34    create_private_file as create_windows_private_file,
35    create_private_staging_file as create_windows_private_staging_file,
36    open_private_directory as open_windows_private_directory,
37    open_private_file as open_windows_private_file,
38    open_private_file_for_removal as open_windows_private_file_for_removal,
39    open_private_file_for_removal_in_state as open_windows_private_file_for_removal_in_state,
40    remove_private_directory_handle as remove_windows_private_directory_handle,
41    remove_private_directory_tree_handle as remove_windows_private_directory_tree_handle,
42    remove_private_file_handle as remove_windows_private_file_handle,
43    remove_private_file_handle_in_state as remove_windows_private_file_handle_in_state,
44    seal_private_staging_file as seal_windows_private_staging_file,
45    verify_private_file_handle as verify_windows_private_file_handle,
46    verify_private_file_handle_in_state as verify_windows_private_file_handle_in_state,
47};
48use rustferry_core::{DirectoryIdentityError, RegularFileFilesystemIdentity};
49use rustferry_remote::{
50    ARTIFACT_MANIFEST_SCHEMA_VERSION, AppleToolchainEvidence, ArtifactDownloadRequest,
51    ArtifactDownloadResult, ArtifactKind, ArtifactManifest, ArtifactRecord,
52    ArtifactSigningEvidence, BuildProfile, COMPILE_HANDOFF_SCHEMA_VERSION,
53    COMPILE_PHASE_EVIDENCE_SCHEMA_VERSION, CleanupStatus, CompileHandoff, CompilePhaseEvidence,
54    IOS_DEVICE_RUST_TARGET, ProtocolPath, ProtocolPathSemantics, RemoteBuildError,
55    RemoteBuildResult, SEALED_UNSIGNED_ARCHIVE_SCHEMA_VERSION, SealedUnsignedArchive, SigningMode,
56    SigningStatus, SourceArchiveLimits, SourceLimits, SourceMode, UnsignedNestedBundleKind,
57    ValidationLevel, canonical_request_bytes, canonical_request_sha256, inspect_unsigned_xcarchive,
58    validate_source_manifest, verify_and_extract_source_bundle,
59};
60use same_file::Handle as FileIdentityHandle;
61use sha2::{Digest, Sha256};
62use unicode_normalization::UnicodeNormalization;
63use zip::{CompressionMethod, ZipArchive, read::ZipFile};
64
65use crate::{
66    artifact::{
67        APP_BUNDLE_ARCHIVE_NAME, ARTIFACT_MANIFEST_NAME, DEVELOPMENT_IPA_NAME, DSYM_ARCHIVE_NAME,
68        GithubArtifactError, GithubArtifactExpectation, GithubArtifactIngestion,
69        SANITIZED_BUILD_LOG_NAME, SIGNED_XCARCHIVE_NAME, SIGNING_REPORT_NAME,
70        VALIDATION_REPORT_NAME, ingest_github_actions_artifact,
71    },
72    provider::{
73        GITHUB_PROVIDER_ID, GithubArtifactContext, GithubSignedCleanupEvidenceV1,
74        GithubVerifiedRunEvidence, VerifiedArtifactStore,
75    },
76    strict_json,
77    transport::{
78        ArtifactDownloadTarget, ArtifactInfo, ArtifactName, GhRunner, GithubTransport,
79        RunConclusion, RunStatus, TransportError,
80    },
81};
82
83/// Exact inner unsigned archive filename emitted by the compile worker.
84pub const UNSIGNED_ARCHIVE_NAME: &str = "unsigned-archive.zip";
85/// Exact sealed descriptor filename emitted by the compile worker.
86pub const SEALED_ARCHIVE_REPORT_NAME: &str = "sealed-archive.json";
87/// Exact complete compile handoff filename emitted by the compile worker.
88pub const COMPILE_REPORT_NAME: &str = "compile-report.json";
89/// Exact sanitized compile log filename emitted by the compile worker.
90pub const SANITIZED_COMPILE_LOG_NAME: &str = "sanitized-compile-log.txt";
91
92const UNSIGNED_ARTIFACT_PREFIX: &str = "rustferry-unsigned";
93const FINAL_ARTIFACT_PREFIX: &str = "rustferry-iphone";
94const UNSIGNED_ARTIFACT_ID: &str = "unsigned-xcarchive";
95const MANIFEST_ARTIFACT_ID: &str = "artifact-manifest";
96const SANITIZED_BUILD_LOG_ID: &str = "sanitized-build-log";
97const UNSIGNED_ROOT_ENTRY_COUNT: usize = 4;
98const MAX_UNSIGNED_ARCHIVE_BYTES: u64 = 2 * 1024 * 1024 * 1024;
99const MAX_HANDOFF_JSON_BYTES: u64 = 4 * 1024 * 1024;
100const MAX_SANITIZED_LOG_BYTES: u64 = 4 * 1024 * 1024;
101const MAX_UNSIGNED_OUTER_EXPANDED_BYTES: u64 =
102    MAX_UNSIGNED_ARCHIVE_BYTES + 2 * MAX_HANDOFF_JSON_BYTES + MAX_SANITIZED_LOG_BYTES;
103const MAX_COMPRESSION_RATIO: u64 = 200;
104const COPY_BUFFER_BYTES: usize = 64 * 1024;
105const TEMPORARY_NAME_ATTEMPTS: u64 = 128;
106#[cfg(windows)]
107const WINDOWS_RUN_CACHE_CLEANUP_ATTEMPTS: usize = 200;
108#[cfg(windows)]
109const WINDOWS_RUN_CACHE_CLEANUP_RETRY_DELAY: std::time::Duration =
110    std::time::Duration::from_millis(5);
111
112static TEMPORARY_FILE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
113
114/// Secret-free configuration or verification failure.
115#[derive(Clone, Copy, Debug, Eq, PartialEq)]
116pub enum GithubArtifactStoreError {
117    /// Cache root is not an absolute, canonical, private directory.
118    InvalidCacheRoot,
119    /// Exact provider, run, request, or source identity differs.
120    InvalidContext,
121    /// GitHub metadata or download failed.
122    Transport(TransportError),
123    /// GitHub omitted the SHA-256 digest required before cache publication.
124    MissingApiDigest,
125    /// The unsigned outer ZIP has an unsafe or unexpected shape.
126    InvalidUnsignedEnvelope,
127    /// A strict JSON handoff document is malformed or ambiguous.
128    InvalidHandoffJson,
129    /// Compile evidence does not bind to the exact submitted request.
130    HandoffBindingMismatch,
131    /// The sealed archive descriptor or actual bytes are invalid.
132    InvalidSealedArchive,
133    /// The extracted unsigned archive failed independent inspection.
134    UnsignedInspectionFailed,
135    /// Final signed artifact ingestion failed closed.
136    FinalArtifact(GithubArtifactError),
137    /// A verified artifact identifier is absent.
138    ArtifactNotFound,
139    /// Client destination is unsafe or already exists.
140    InvalidDestination,
141    /// Cache or atomic client publication failed.
142    Io(io::ErrorKind),
143    /// Exact Windows cleanup could not be confirmed and requires inspection.
144    CleanupUncertain,
145}
146
147impl fmt::Display for GithubArtifactStoreError {
148    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
149        match self {
150            Self::InvalidCacheRoot => formatter.write_str("GitHub artifact cache root is invalid"),
151            Self::InvalidContext => formatter.write_str("GitHub artifact context is invalid"),
152            Self::Transport(error) => error.fmt(formatter),
153            Self::MissingApiDigest => {
154                formatter.write_str("GitHub artifact metadata omitted its SHA-256 digest")
155            }
156            Self::InvalidUnsignedEnvelope => {
157                formatter.write_str("unsigned GitHub artifact ZIP is invalid")
158            }
159            Self::InvalidHandoffJson => formatter.write_str("compile handoff JSON is invalid"),
160            Self::HandoffBindingMismatch => {
161                formatter.write_str("compile handoff does not match the requested run")
162            }
163            Self::InvalidSealedArchive => formatter.write_str("sealed unsigned archive is invalid"),
164            Self::UnsignedInspectionFailed => {
165                formatter.write_str("unsigned archive inspection failed")
166            }
167            Self::FinalArtifact(error) => error.fmt(formatter),
168            Self::ArtifactNotFound => formatter.write_str("verified artifact was not found"),
169            Self::InvalidDestination => {
170                formatter.write_str("client artifact destination is invalid")
171            }
172            Self::Io(kind) => write!(formatter, "GitHub artifact storage failed with {kind:?}"),
173            Self::CleanupUncertain => {
174                formatter.write_str("GitHub artifact storage cleanup could not be confirmed")
175            }
176        }
177    }
178}
179
180impl Error for GithubArtifactStoreError {}
181
182impl From<TransportError> for GithubArtifactStoreError {
183    fn from(value: TransportError) -> Self {
184        Self::Transport(value)
185    }
186}
187
188impl From<GithubArtifactError> for GithubArtifactStoreError {
189    fn from(value: GithubArtifactError) -> Self {
190        if value == GithubArtifactError::CleanupFailed {
191            Self::CleanupUncertain
192        } else {
193            Self::FinalArtifact(value)
194        }
195    }
196}
197
198#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
199struct RunCacheKey {
200    repository: String,
201    run_id: u64,
202    run_attempt: u64,
203    request_sha256: String,
204}
205
206#[derive(Clone, Debug)]
207struct CachedArtifact {
208    record: ArtifactRecord,
209    path: Utf8PathBuf,
210}
211
212#[derive(Debug)]
213struct VerifiedRun {
214    manifest: ArtifactManifest,
215    artifacts: BTreeMap<String, CachedArtifact>,
216    evidence: GithubVerifiedRunEvidence,
217    _verified_directory_guard: PrivateDirectoryGuard,
218    _cache_directory: RunCacheDirectory,
219}
220
221#[derive(Debug)]
222struct VerifiedRunContents {
223    manifest: ArtifactManifest,
224    artifacts: BTreeMap<String, CachedArtifact>,
225    evidence: GithubVerifiedRunEvidence,
226}
227
228#[derive(Debug)]
229struct RunCacheDirectory {
230    cache_root: Utf8PathBuf,
231    path: Utf8PathBuf,
232    guard: Option<PrivateDirectoryGuard>,
233    owned: bool,
234}
235
236impl RunCacheDirectory {
237    fn path(&self) -> &Utf8Path {
238        &self.path
239    }
240
241    fn cleanup(mut self) -> Result<(), GithubArtifactStoreError> {
242        let guard = self
243            .guard
244            .take()
245            .ok_or(GithubArtifactStoreError::CleanupUncertain)?;
246        self.owned = false;
247        remove_exact_run_directory(&self.cache_root, &self.path, guard)
248    }
249}
250
251impl Drop for RunCacheDirectory {
252    fn drop(&mut self) {
253        if self.owned {
254            self.owned = false;
255            if let Some(guard) = self.guard.take() {
256                let _ = remove_exact_run_directory(&self.cache_root, &self.path, guard);
257            }
258        }
259    }
260}
261
262#[derive(Debug)]
263#[cfg_attr(not(windows), derive(Clone, Copy))]
264struct PrivateDirectoryGuard {
265    #[cfg(windows)]
266    handle: File,
267}
268
269fn release_private_directory_guard(guard: PrivateDirectoryGuard) {
270    #[cfg(windows)]
271    drop(guard);
272    #[cfg(not(windows))]
273    let PrivateDirectoryGuard {} = guard;
274}
275
276/// Concrete independent GitHub artifact verifier and private local cache.
277pub struct GithubVerifiedArtifactStore<R> {
278    transport: GithubTransport<R>,
279    cache_root: Utf8PathBuf,
280    verified: BTreeMap<RunCacheKey, VerifiedRun>,
281    _cache_root_guard: PrivateDirectoryGuard,
282}
283
284impl<R> GithubVerifiedArtifactStore<R> {
285    /// Bind a dedicated GitHub transport to an existing private cache root.
286    ///
287    /// # Errors
288    ///
289    /// Rejects relative, aliased, linked, non-directory, or group/world-accessible
290    /// cache roots. Unix roots must have no group or other permission bits.
291    pub fn new(
292        transport: GithubTransport<R>,
293        cache_root: impl AsRef<Path>,
294    ) -> Result<Self, GithubArtifactStoreError> {
295        let (cache_root, cache_root_guard) = bind_private_cache_root(cache_root.as_ref())?;
296        Ok(Self {
297            transport,
298            cache_root,
299            verified: BTreeMap::new(),
300            _cache_root_guard: cache_root_guard,
301        })
302    }
303
304    /// Return the canonical private cache root.
305    pub fn cache_root(&self) -> &Utf8Path {
306        &self.cache_root
307    }
308
309    /// Consume the store and recover its dedicated transport.
310    pub fn into_transport(self) -> GithubTransport<R> {
311        self.transport
312    }
313}
314
315impl<R: GhRunner + Send> GithubVerifiedArtifactStore<R> {
316    fn ensure_verified(
317        &mut self,
318        context: &GithubArtifactContext,
319    ) -> Result<&VerifiedRun, GithubArtifactStoreError> {
320        validate_context(context)?;
321        let key = cache_key(context);
322        if !self.verified.contains_key(&key) {
323            let verified = self.verify_run(context)?;
324            self.verified.insert(key.clone(), verified);
325        }
326        self.verified
327            .get(&key)
328            .ok_or(GithubArtifactStoreError::Io(io::ErrorKind::Other))
329    }
330
331    fn verify_run(
332        &mut self,
333        context: &GithubArtifactContext,
334    ) -> Result<VerifiedRun, GithubArtifactStoreError> {
335        let unsigned_info = self.exact_artifact(context, UNSIGNED_ARTIFACT_PREFIX)?;
336        let final_info = match context.request.signing.mode {
337            SigningMode::UnsignedCompileOnly => None,
338            SigningMode::ManualDevelopment => {
339                Some(self.exact_artifact(context, FINAL_ARTIFACT_PREFIX)?)
340            }
341            _ => return Err(GithubArtifactStoreError::InvalidContext),
342        };
343        let run_directory = create_run_directory(&self.cache_root, context)?;
344        let verification = (|| {
345            let transport_directory = run_directory.path().join("transport");
346            let verified_directory = run_directory.path().join("verified");
347            let final_staging = run_directory.path().join("final-staging");
348            let _transport_guard = create_private_directory(&transport_directory)?;
349            let verified_guard = create_private_directory(&verified_directory)?;
350            let final_staging_guard = create_private_directory(&final_staging)?;
351
352            let unsigned_outer = self.download_outer(
353                context,
354                &unsigned_info,
355                &transport_directory,
356                "unsigned-outer.zip",
357            )?;
358            let verified_unsigned = verify_unsigned_outer(
359                context,
360                &unsigned_outer,
361                run_directory.path(),
362                &verified_directory,
363            )?;
364
365            if context.request.signing.mode == SigningMode::UnsignedCompileOnly {
366                return Ok((
367                    unsigned_verified_run(context, verified_unsigned),
368                    verified_guard,
369                ));
370            }
371            let final_info = final_info.ok_or(GithubArtifactStoreError::InvalidContext)?;
372            let final_outer = self.download_outer(
373                context,
374                &final_info,
375                &transport_directory,
376                "final-outer.zip",
377            )?;
378            let expected = GithubArtifactExpectation::new(
379                context.job_id.clone(),
380                GITHUB_PROVIDER_ID,
381                context.request.clone(),
382                verified_unsigned.compile.clone(),
383            )?;
384            let ipa_expectation = context
385                .request
386                .ipa_expectation()
387                .map_err(|_| GithubArtifactStoreError::InvalidContext)?;
388            release_private_directory_guard(final_staging_guard);
389            release_private_directory_guard(verified_guard);
390            let published = ingest_github_actions_artifact(GithubArtifactIngestion {
391                archive_path: &final_outer,
392                temporary_directory: &final_staging,
393                output_directory: &verified_directory,
394                expected: &expected,
395                ipa_expectation: &ipa_expectation,
396            })?;
397            let api_sha256 = final_info
398                .digest()
399                .and_then(|digest| digest.strip_prefix("sha256:"))
400                .ok_or(GithubArtifactStoreError::MissingApiDigest)?;
401            let cleanup_evidence = GithubSignedCleanupEvidenceV1::from_verified_artifact(
402                context,
403                &verified_unsigned.compile,
404                &published.manifest,
405                &published.manifest_sha256,
406                final_info.id().get(),
407                api_sha256,
408            )
409            .map_err(|_| GithubArtifactStoreError::InvalidContext)?;
410            let verified_guard = open_private_directory(&verified_directory)?;
411            Ok((
412                signed_verified_run(published, verified_unsigned.compile, cleanup_evidence)?,
413                verified_guard,
414            ))
415        })();
416        match verification {
417            Ok((verified, verified_directory_guard)) => {
418                prune_verified_run_cache(run_directory.path(), &verified)?;
419                Ok(VerifiedRun {
420                    manifest: verified.manifest,
421                    artifacts: verified.artifacts,
422                    evidence: verified.evidence,
423                    _verified_directory_guard: verified_directory_guard,
424                    _cache_directory: run_directory,
425                })
426            }
427            Err(error) => {
428                run_directory.cleanup()?;
429                Err(error)
430            }
431        }
432    }
433
434    fn exact_artifact(
435        &mut self,
436        context: &GithubArtifactContext,
437        prefix: &str,
438    ) -> Result<ArtifactInfo, GithubArtifactStoreError> {
439        let name = exact_artifact_name(prefix, context)?;
440        let artifact =
441            self.transport
442                .find_artifact(&context.repository, context.run.handle().id(), &name)?;
443        if artifact.size_bytes() == 0 || artifact.digest().is_none() {
444            return Err(GithubArtifactStoreError::MissingApiDigest);
445        }
446        Ok(artifact)
447    }
448
449    fn download_outer(
450        &mut self,
451        context: &GithubArtifactContext,
452        artifact: &ArtifactInfo,
453        directory: &Utf8Path,
454        filename: &str,
455    ) -> Result<Utf8PathBuf, GithubArtifactStoreError> {
456        let target = ArtifactDownloadTarget::new(directory.as_std_path(), filename)
457            .map_err(|_| GithubArtifactStoreError::InvalidCacheRoot)?;
458        let downloaded =
459            self.transport
460                .download_artifact_zip(&context.repository, artifact, &target)?;
461        let path = Utf8PathBuf::from_path_buf(downloaded.path().to_path_buf())
462            .map_err(|_| GithubArtifactStoreError::InvalidCacheRoot)?;
463        let expected_digest = artifact
464            .digest()
465            .and_then(|digest| digest.strip_prefix("sha256:"))
466            .ok_or(GithubArtifactStoreError::MissingApiDigest)?;
467        verify_regular_file(&path, artifact.size_bytes(), expected_digest)?;
468        Ok(path)
469    }
470}
471
472impl<R: GhRunner + Send> VerifiedArtifactStore for GithubVerifiedArtifactStore<R> {
473    fn supports_listing(&self) -> bool {
474        true
475    }
476
477    fn supports_download(&self) -> bool {
478        true
479    }
480
481    fn supports_removal(&self) -> bool {
482        false
483    }
484
485    fn supports_signed_cleanup_evidence(&self) -> bool {
486        true
487    }
488
489    fn list_verified(
490        &mut self,
491        context: &GithubArtifactContext,
492    ) -> RemoteBuildResult<Vec<ArtifactManifest>> {
493        match self.ensure_verified(context) {
494            Ok(verified) => Ok(vec![verified.manifest.clone()]),
495            Err(GithubArtifactStoreError::Transport(TransportError::ArtifactNotFound)) => {
496                Ok(Vec::new())
497            }
498            Err(error) => Err(remote_store_error(error)),
499        }
500    }
501
502    fn verified_run_evidence(
503        &mut self,
504        context: &GithubArtifactContext,
505    ) -> RemoteBuildResult<Option<GithubVerifiedRunEvidence>> {
506        match self.ensure_verified(context) {
507            Ok(verified) => Ok(Some(verified.evidence.clone())),
508            Err(GithubArtifactStoreError::Transport(TransportError::ArtifactNotFound)) => Ok(None),
509            Err(error) => Err(remote_store_error(error)),
510        }
511    }
512
513    fn download_verified(
514        &mut self,
515        context: &GithubArtifactContext,
516        request: &ArtifactDownloadRequest,
517    ) -> RemoteBuildResult<ArtifactDownloadResult> {
518        request.validate()?;
519        if request.job_id != context.job_id {
520            return Err(remote_store_error(GithubArtifactStoreError::InvalidContext));
521        }
522        let verified = self.ensure_verified(context).map_err(remote_store_error)?;
523        let artifact = verified
524            .artifacts
525            .get(&request.artifact_id)
526            .ok_or_else(|| RemoteBuildError::ArtifactNotFound {
527                job_id: request.job_id.clone(),
528                artifact_id: request.artifact_id.clone(),
529            })?;
530        let local_file_identity =
531            atomic_verified_copy(&artifact.path, &artifact.record, &request.destination)
532                .map_err(remote_store_error)?;
533        let mut manifest = verified.manifest.clone();
534        manifest
535            .validation_levels
536            .insert(ValidationLevel::DownloadedToClient);
537        Ok(ArtifactDownloadResult {
538            manifest,
539            local_path: request.destination.clone(),
540            local_file_identity: local_file_identity.to_string(),
541        })
542    }
543
544    fn remove_artifacts(&mut self, _context: &GithubArtifactContext) -> RemoteBuildResult<()> {
545        Err(RemoteBuildError::ProviderFailure {
546            provider: GITHUB_PROVIDER_ID.to_owned(),
547            code: "artifact_removal_unsupported".to_owned(),
548            message: "exact GitHub artifact deletion is not implemented".to_owned(),
549            retryable: false,
550        })
551    }
552}
553
554#[derive(Clone, Debug)]
555struct VerifiedUnsigned {
556    compile: CompilePhaseEvidence,
557    archive_path: Utf8PathBuf,
558    sanitized_log: CachedArtifact,
559}
560
561#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
562enum UnsignedEnvelopeFile {
563    Archive,
564    Descriptor,
565    Handoff,
566    SanitizedLog,
567}
568
569impl UnsignedEnvelopeFile {
570    const ALL: [Self; UNSIGNED_ROOT_ENTRY_COUNT] = [
571        Self::Archive,
572        Self::Descriptor,
573        Self::Handoff,
574        Self::SanitizedLog,
575    ];
576
577    const fn maximum_size(self) -> u64 {
578        match self {
579            Self::Archive => MAX_UNSIGNED_ARCHIVE_BYTES,
580            Self::Descriptor | Self::Handoff => MAX_HANDOFF_JSON_BYTES,
581            Self::SanitizedLog => MAX_SANITIZED_LOG_BYTES,
582        }
583    }
584
585    fn from_name(name: &str) -> Option<Self> {
586        match name {
587            UNSIGNED_ARCHIVE_NAME => Some(Self::Archive),
588            SEALED_ARCHIVE_REPORT_NAME => Some(Self::Descriptor),
589            COMPILE_REPORT_NAME => Some(Self::Handoff),
590            SANITIZED_COMPILE_LOG_NAME => Some(Self::SanitizedLog),
591            _ => None,
592        }
593    }
594}
595
596#[derive(Clone, Copy, Debug, Eq, PartialEq)]
597struct UnsignedEntryMetadata {
598    index: usize,
599    size: u64,
600}
601
602fn validate_context(context: &GithubArtifactContext) -> Result<(), GithubArtifactStoreError> {
603    context
604        .request
605        .validate()
606        .map_err(|_| GithubArtifactStoreError::InvalidContext)?;
607    let request_sha256 = canonical_request_sha256(&context.request)
608        .map_err(|_| GithubArtifactStoreError::InvalidContext)?;
609    if context.job_id.is_empty()
610        || context.operation_id != context.request.operation_id
611        || context.job_id != context.operation_id
612        || context.request_sha256 != request_sha256
613        || context.request.source_mode != SourceMode::Git
614        || context.request.source_repository.as_deref() != Some(context.source_repository.as_str())
615        || context.request.source_revision.as_deref() != Some(context.source_revision.as_str())
616        || context.run.handle().head_sha() != &context.dispatch_revision
617        || context.run.run_attempt() == 0
618        || context.run.status() != RunStatus::Completed
619        || context.run.conclusion() != Some(RunConclusion::Success)
620    {
621        return Err(GithubArtifactStoreError::InvalidContext);
622    }
623    Ok(())
624}
625
626fn cache_key(context: &GithubArtifactContext) -> RunCacheKey {
627    RunCacheKey {
628        repository: format!(
629            "{}/{}",
630            context.repository.owner(),
631            context.repository.name()
632        ),
633        run_id: context.run.handle().id().get(),
634        run_attempt: context.run.run_attempt(),
635        request_sha256: context.request_sha256.clone(),
636    }
637}
638
639fn exact_artifact_name(
640    prefix: &str,
641    context: &GithubArtifactContext,
642) -> Result<ArtifactName, GithubArtifactStoreError> {
643    ArtifactName::new(format!(
644        "{prefix}-{}-{}",
645        context.run.handle().id().get(),
646        context.run.run_attempt()
647    ))
648    .map_err(|_| GithubArtifactStoreError::InvalidContext)
649}
650
651fn verify_unsigned_outer(
652    context: &GithubArtifactContext,
653    outer_path: &Utf8Path,
654    run_directory: &Utf8Path,
655    verified_directory: &Utf8Path,
656) -> Result<VerifiedUnsigned, GithubArtifactStoreError> {
657    let archive_file = open_regular_file(outer_path)?;
658    let archive_size = archive_file
659        .metadata()
660        .map_err(|error| io_store_error(error.kind()))?
661        .len();
662    let mut archive = ZipArchive::new(archive_file)
663        .map_err(|_| GithubArtifactStoreError::InvalidUnsignedEnvelope)?;
664    let entries = scan_unsigned_envelope(&mut archive, archive_size)?;
665
666    let descriptor_bytes = read_unsigned_entry(
667        &mut archive,
668        entries[&UnsignedEnvelopeFile::Descriptor],
669        UnsignedEnvelopeFile::Descriptor,
670    )?;
671    let handoff_bytes = read_unsigned_entry(
672        &mut archive,
673        entries[&UnsignedEnvelopeFile::Handoff],
674        UnsignedEnvelopeFile::Handoff,
675    )?;
676    let log_bytes = read_unsigned_entry(
677        &mut archive,
678        entries[&UnsignedEnvelopeFile::SanitizedLog],
679        UnsignedEnvelopeFile::SanitizedLog,
680    )?;
681    validate_sanitized_log(&log_bytes)?;
682
683    let descriptor: SealedUnsignedArchive = strict_json::decode(
684        &descriptor_bytes,
685        usize::try_from(MAX_HANDOFF_JSON_BYTES).unwrap_or(usize::MAX),
686    )
687    .map_err(|_| GithubArtifactStoreError::InvalidHandoffJson)?;
688    let handoff: CompileHandoff = strict_json::decode(
689        &handoff_bytes,
690        usize::try_from(MAX_HANDOFF_JSON_BYTES).unwrap_or(usize::MAX),
691    )
692    .map_err(|_| GithubArtifactStoreError::InvalidHandoffJson)?;
693
694    let sealed_path = verified_directory.join(UNSIGNED_ARCHIVE_NAME);
695    extract_unsigned_entry(
696        &mut archive,
697        entries[&UnsignedEnvelopeFile::Archive],
698        UnsignedEnvelopeFile::Archive,
699        &sealed_path,
700    )?;
701    validate_compile_handoff(context, &handoff, &descriptor, &sealed_path)?;
702
703    let extraction_directory = run_directory.join("unsigned-xcarchive");
704    if extraction_directory.starts_with(outer_path)
705        || outer_path.starts_with(&extraction_directory)
706        || extraction_directory.starts_with(&sealed_path)
707        || sealed_path.starts_with(&extraction_directory)
708    {
709        return Err(GithubArtifactStoreError::InvalidCacheRoot);
710    }
711    verify_and_extract_source_bundle(
712        &sealed_path,
713        &descriptor.transport,
714        &descriptor.contents,
715        &extraction_directory,
716        sealed_archive_limits(),
717    )
718    .map_err(|_| GithubArtifactStoreError::InvalidSealedArchive)?;
719    let inspection = inspect_unsigned_xcarchive(&extraction_directory, &descriptor.expectation)
720        .map_err(|_| GithubArtifactStoreError::UnsignedInspectionFailed)?;
721    if inspection != handoff.compile.archive_inspection {
722        return Err(GithubArtifactStoreError::HandoffBindingMismatch);
723    }
724    // Keep phase-A evidence under its source name so phase B can publish its own
725    // no-clobber sanitized build log into the same verified directory.
726    let sanitized_log_path = compile_log_cache_path(verified_directory);
727    write_private_file(&sanitized_log_path, &log_bytes)?;
728    let sanitized_log = CachedArtifact {
729        record: ArtifactRecord {
730            artifact_id: SANITIZED_BUILD_LOG_ID.to_owned(),
731            kind: ArtifactKind::SanitizedLog,
732            file_name: SANITIZED_BUILD_LOG_NAME.to_owned(),
733            size: u64::try_from(log_bytes.len())
734                .map_err(|_| GithubArtifactStoreError::InvalidUnsignedEnvelope)?,
735            sha256: sha256_bytes(&log_bytes),
736            media_type: Some("text/plain; charset=utf-8".to_owned()),
737        },
738        path: sanitized_log_path,
739    };
740    Ok(VerifiedUnsigned {
741        compile: handoff.compile,
742        archive_path: sealed_path,
743        sanitized_log,
744    })
745}
746
747fn compile_log_cache_path(verified_directory: &Utf8Path) -> Utf8PathBuf {
748    verified_directory.join(SANITIZED_COMPILE_LOG_NAME)
749}
750
751fn scan_unsigned_envelope(
752    archive: &mut ZipArchive<File>,
753    archive_size: u64,
754) -> Result<BTreeMap<UnsignedEnvelopeFile, UnsignedEntryMetadata>, GithubArtifactStoreError> {
755    if archive.len() != UNSIGNED_ROOT_ENTRY_COUNT {
756        return Err(GithubArtifactStoreError::InvalidUnsignedEnvelope);
757    }
758    let mut exact_names = BTreeSet::new();
759    let mut portable_names = BTreeMap::<String, String>::new();
760    let mut header_starts = BTreeSet::new();
761    let mut compressed_ranges = Vec::with_capacity(UNSIGNED_ROOT_ENTRY_COUNT);
762    let mut entries = BTreeMap::new();
763    let mut expanded_size = 0_u64;
764    for index in 0..archive.len() {
765        let entry = archive
766            .by_index(index)
767            .map_err(|_| GithubArtifactStoreError::InvalidUnsignedEnvelope)?;
768        let name = validate_unsigned_entry_name(entry.name_raw())?;
769        if !exact_names.insert(name.to_owned()) {
770            return Err(GithubArtifactStoreError::InvalidUnsignedEnvelope);
771        }
772        let portable = portable_name_key(name);
773        if portable_names.insert(portable, name.to_owned()).is_some() {
774            return Err(GithubArtifactStoreError::InvalidUnsignedEnvelope);
775        }
776        let file = UnsignedEnvelopeFile::from_name(name)
777            .ok_or(GithubArtifactStoreError::InvalidUnsignedEnvelope)?;
778        validate_unsigned_entry_metadata(&entry, file)?;
779        if !header_starts.insert(entry.header_start()) {
780            return Err(GithubArtifactStoreError::InvalidUnsignedEnvelope);
781        }
782        let data_start = entry
783            .data_start()
784            .ok_or(GithubArtifactStoreError::InvalidUnsignedEnvelope)?;
785        let data_end = data_start
786            .checked_add(entry.compressed_size())
787            .ok_or(GithubArtifactStoreError::InvalidUnsignedEnvelope)?;
788        if data_end > archive_size {
789            return Err(GithubArtifactStoreError::InvalidUnsignedEnvelope);
790        }
791        compressed_ranges.push((data_start, data_end));
792        expanded_size = expanded_size
793            .checked_add(entry.size())
794            .ok_or(GithubArtifactStoreError::InvalidUnsignedEnvelope)?;
795        if expanded_size > MAX_UNSIGNED_OUTER_EXPANDED_BYTES
796            || entries
797                .insert(
798                    file,
799                    UnsignedEntryMetadata {
800                        index,
801                        size: entry.size(),
802                    },
803                )
804                .is_some()
805        {
806            return Err(GithubArtifactStoreError::InvalidUnsignedEnvelope);
807        }
808    }
809    compressed_ranges.sort_unstable();
810    if compressed_ranges
811        .windows(2)
812        .any(|pair| pair[1].0 < pair[0].1)
813        || UnsignedEnvelopeFile::ALL
814            .iter()
815            .any(|file| !entries.contains_key(file))
816    {
817        return Err(GithubArtifactStoreError::InvalidUnsignedEnvelope);
818    }
819    Ok(entries)
820}
821
822fn validate_unsigned_entry_name(raw_name: &[u8]) -> Result<&str, GithubArtifactStoreError> {
823    let name = std::str::from_utf8(raw_name)
824        .map_err(|_| GithubArtifactStoreError::InvalidUnsignedEnvelope)?;
825    if name.is_empty()
826        || name.starts_with(['/', '\\'])
827        || name.contains(['\\', '\0'])
828        || name.chars().any(char::is_control)
829        || (name.len() >= 2 && name.as_bytes()[1] == b':')
830        || name.split('/').count() != 1
831        || matches!(name, "." | "..")
832    {
833        return Err(GithubArtifactStoreError::InvalidUnsignedEnvelope);
834    }
835    Ok(name)
836}
837
838fn validate_unsigned_entry_metadata(
839    entry: &ZipFile<'_, File>,
840    file: UnsignedEnvelopeFile,
841) -> Result<(), GithubArtifactStoreError> {
842    let linked_or_special = entry.is_dir()
843        || entry.is_symlink()
844        || entry.unix_mode().is_some_and(|mode| {
845            let kind = mode & 0o170_000;
846            kind != 0 && kind != 0o100_000
847        });
848    if entry.encrypted()
849        || linked_or_special
850        || !matches!(
851            entry.compression(),
852            CompressionMethod::Stored | CompressionMethod::Deflated
853        )
854        || entry.size() == 0
855        || entry.size() > file.maximum_size()
856        || entry.compressed_size() == 0
857        || entry.size()
858            > entry
859                .compressed_size()
860                .saturating_mul(MAX_COMPRESSION_RATIO)
861    {
862        return Err(GithubArtifactStoreError::InvalidUnsignedEnvelope);
863    }
864    Ok(())
865}
866
867fn read_unsigned_entry(
868    archive: &mut ZipArchive<File>,
869    metadata: UnsignedEntryMetadata,
870    file: UnsignedEnvelopeFile,
871) -> Result<Vec<u8>, GithubArtifactStoreError> {
872    let mut entry = archive
873        .by_index(metadata.index)
874        .map_err(|_| GithubArtifactStoreError::InvalidUnsignedEnvelope)?;
875    let capacity = usize::try_from(metadata.size)
876        .map_err(|_| GithubArtifactStoreError::InvalidUnsignedEnvelope)?;
877    let mut bytes = Vec::with_capacity(capacity);
878    entry
879        .by_ref()
880        .take(metadata.size.saturating_add(1))
881        .read_to_end(&mut bytes)
882        .map_err(|_| GithubArtifactStoreError::InvalidUnsignedEnvelope)?;
883    if bytes.len() != capacity || metadata.size > file.maximum_size() {
884        return Err(GithubArtifactStoreError::InvalidUnsignedEnvelope);
885    }
886    Ok(bytes)
887}
888
889fn extract_unsigned_entry(
890    archive: &mut ZipArchive<File>,
891    metadata: UnsignedEntryMetadata,
892    file: UnsignedEnvelopeFile,
893    destination: &Utf8Path,
894) -> Result<(), GithubArtifactStoreError> {
895    let mut entry = archive
896        .by_index(metadata.index)
897        .map_err(|_| GithubArtifactStoreError::InvalidUnsignedEnvelope)?;
898    let mut output = create_private_file(destination)?;
899    let copied = match io::copy(
900        &mut entry.by_ref().take(metadata.size.saturating_add(1)),
901        &mut output,
902    ) {
903        Ok(copied) => copied,
904        Err(error) => {
905            remove_failed_private_file(destination, output)?;
906            return Err(io_store_error(error.kind()));
907        }
908    };
909    if copied != metadata.size || metadata.size > file.maximum_size() {
910        remove_failed_private_file(destination, output)?;
911        return Err(GithubArtifactStoreError::InvalidUnsignedEnvelope);
912    }
913    if let Err(error) = output.flush().and_then(|()| output.sync_all()) {
914        remove_failed_private_file(destination, output)?;
915        return Err(io_store_error(error.kind()));
916    }
917    Ok(())
918}
919
920fn validate_compile_handoff(
921    context: &GithubArtifactContext,
922    handoff: &CompileHandoff,
923    descriptor: &SealedUnsignedArchive,
924    sealed_path: &Utf8Path,
925) -> Result<(), GithubArtifactStoreError> {
926    let canonical_context = canonical_request_bytes(&context.request)
927        .map_err(|_| GithubArtifactStoreError::InvalidContext)?;
928    let canonical_handoff = canonical_request_bytes(&handoff.request)
929        .map_err(|_| GithubArtifactStoreError::InvalidHandoffJson)?;
930    let request_sha256 = canonical_request_sha256(&context.request)
931        .map_err(|_| GithubArtifactStoreError::InvalidContext)?;
932    let cargo_lock_sha256 = manifest_cargo_lock_sha256(&context.request.source)
933        .ok_or(GithubArtifactStoreError::HandoffBindingMismatch)?;
934    let config_sha256 = manifest_project_file_sha256(&context.request.source, "ferry.toml")
935        .ok_or(GithubArtifactStoreError::HandoffBindingMismatch)?;
936    let compile = &handoff.compile;
937    if handoff.schema_version != COMPILE_HANDOFF_SCHEMA_VERSION
938        || compile.schema_version != COMPILE_PHASE_EVIDENCE_SCHEMA_VERSION
939        || descriptor.schema_version != SEALED_UNSIGNED_ARCHIVE_SCHEMA_VERSION
940        || canonical_context != canonical_handoff
941        || handoff.request != context.request
942        || compile.job_id != context.job_id
943        || compile.provider != GITHUB_PROVIDER_ID
944        || compile.request_sha256 != request_sha256
945        || compile.request_sha256 != context.request_sha256
946        || compile.source_sha256 != context.request.source.sha256
947        || compile.cargo_lock_sha256 != cargo_lock_sha256
948        || compile.config_sha256 != config_sha256
949        || compile.finished_at_unix_seconds < compile.started_at_unix_seconds
950        || rfc3339_from_unix(compile.started_at_unix_seconds).is_none()
951        || rfc3339_from_unix(compile.finished_at_unix_seconds).is_none()
952        || compile.toolchain.rust_target != IOS_DEVICE_RUST_TARGET
953        || !is_safe_public_text(&compile.rustferry_version)
954        || !is_safe_public_text(&compile.worker_version)
955        || !is_safe_public_text(&compile.toolchain.worker_architecture)
956        || !is_safe_public_text(&compile.toolchain.worker_os)
957        || !is_safe_public_text(&compile.toolchain.xcode_version)
958        || !is_safe_public_text(&compile.toolchain.iphoneos_sdk_version)
959        || !is_safe_public_text(&compile.toolchain.iphoneos_sdk_build_version)
960        || !is_safe_public_text(&compile.toolchain.rust_version)
961        || !is_lower_sha256(&compile.toolchain.developer_directory_sha256)
962        || &compile.sealed_archive != descriptor
963        || !descriptor_matches_request(descriptor, compile, &context.request)
964    {
965        return Err(GithubArtifactStoreError::HandoffBindingMismatch);
966    }
967    validate_source_manifest(&descriptor.contents, sealed_archive_limits().source)
968        .map_err(|_| GithubArtifactStoreError::InvalidSealedArchive)?;
969    if descriptor.transport.size == 0
970        || descriptor.transport.size > MAX_UNSIGNED_ARCHIVE_BYTES
971        || !is_lower_sha256(&descriptor.transport.sha256)
972    {
973        return Err(GithubArtifactStoreError::InvalidSealedArchive);
974    }
975    verify_regular_file(
976        sealed_path,
977        descriptor.transport.size,
978        &descriptor.transport.sha256,
979    )
980}
981
982fn descriptor_matches_request(
983    descriptor: &SealedUnsignedArchive,
984    compile: &CompilePhaseEvidence,
985    request: &rustferry_remote::IosDeviceBuildRequest,
986) -> bool {
987    let expectation = &descriptor.expectation;
988    expectation.app_directory_name == request.product.app_directory_name
989        && expectation.bundle_identifier == request.bundle_identifier
990        && expectation.executable == request.product.executable
991        && expectation.app_version == request.product.app_version
992        && expectation.build_number == request.product.build_number
993        && expectation.minimum_os == request.minimum_ios_version
994        && expectation.sdk_version == compile.toolchain.iphoneos_sdk_version
995        && expectation.sdk_build_version == compile.toolchain.iphoneos_sdk_build_version
996        && expectation.nested_bundles == request.product.nested_bundles
997}
998
999fn manifest_project_file_sha256(
1000    manifest: &rustferry_remote::SourceManifest,
1001    file_name: &str,
1002) -> Option<String> {
1003    let path = if manifest.project_path == "." {
1004        file_name.to_owned()
1005    } else {
1006        format!("{}/{file_name}", manifest.project_path)
1007    };
1008    manifest
1009        .entries
1010        .iter()
1011        .find(|entry| entry.path == path)
1012        .map(|entry| entry.sha256.clone())
1013}
1014
1015fn manifest_cargo_lock_sha256(manifest: &rustferry_remote::SourceManifest) -> Option<String> {
1016    let mut components = if manifest.project_path == "." {
1017        Vec::new()
1018    } else {
1019        manifest.project_path.split('/').collect::<Vec<_>>()
1020    };
1021    loop {
1022        let candidate = if components.is_empty() {
1023            "Cargo.lock".to_owned()
1024        } else {
1025            format!("{}/Cargo.lock", components.join("/"))
1026        };
1027        if let Some(entry) = manifest
1028            .entries
1029            .iter()
1030            .find(|entry| entry.path == candidate)
1031        {
1032            return Some(entry.sha256.clone());
1033        }
1034        components.pop()?;
1035    }
1036}
1037
1038fn validate_sanitized_log(bytes: &[u8]) -> Result<(), GithubArtifactStoreError> {
1039    let text = std::str::from_utf8(bytes)
1040        .map_err(|_| GithubArtifactStoreError::InvalidUnsignedEnvelope)?;
1041    if text.is_empty()
1042        || text
1043            .chars()
1044            .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t'))
1045        || text.lines().any(|line| line.len() > 16 * 1024)
1046    {
1047        return Err(GithubArtifactStoreError::InvalidUnsignedEnvelope);
1048    }
1049    Ok(())
1050}
1051
1052fn sealed_archive_limits() -> SourceArchiveLimits {
1053    SourceArchiveLimits {
1054        source: SourceLimits {
1055            max_file_count: 50_000,
1056            max_file_size: 512 * 1024 * 1024,
1057            max_total_size: 2 * 1024 * 1024 * 1024,
1058            max_depth: 128,
1059            max_ignore_file_size: 64 * 1024,
1060            max_ignore_rules: 1,
1061        },
1062        max_archive_size: MAX_UNSIGNED_ARCHIVE_BYTES,
1063        max_compression_ratio: 100,
1064    }
1065}
1066
1067fn unsigned_verified_run(
1068    context: &GithubArtifactContext,
1069    verified: VerifiedUnsigned,
1070) -> VerifiedRunContents {
1071    let archive_record = ArtifactRecord {
1072        artifact_id: UNSIGNED_ARTIFACT_ID.to_owned(),
1073        kind: ArtifactKind::Xcarchive,
1074        file_name: UNSIGNED_ARCHIVE_NAME.to_owned(),
1075        size: verified.compile.sealed_archive.transport.size,
1076        sha256: verified.compile.sealed_archive.transport.sha256.clone(),
1077        media_type: Some("application/zip".to_owned()),
1078    };
1079    let mut records = vec![
1080        archive_record.clone(),
1081        verified.sanitized_log.record.clone(),
1082    ];
1083    records.sort_by(|left, right| left.artifact_id.cmp(&right.artifact_id));
1084    let manifest = unsigned_manifest(context, &verified.compile, records);
1085    VerifiedRunContents {
1086        manifest,
1087        artifacts: BTreeMap::from([
1088            (
1089                archive_record.artifact_id.clone(),
1090                CachedArtifact {
1091                    record: archive_record,
1092                    path: verified.archive_path,
1093                },
1094            ),
1095            (
1096                verified.sanitized_log.record.artifact_id.clone(),
1097                verified.sanitized_log,
1098            ),
1099        ]),
1100        evidence: GithubVerifiedRunEvidence::new(verified.compile, None),
1101    }
1102}
1103
1104fn unsigned_manifest(
1105    context: &GithubArtifactContext,
1106    compile: &CompilePhaseEvidence,
1107    records: Vec<ArtifactRecord>,
1108) -> ArtifactManifest {
1109    let request = &context.request;
1110    let mut extensions = request
1111        .product
1112        .nested_bundles
1113        .iter()
1114        .filter(|bundle| bundle.kind == UnsignedNestedBundleKind::AppExtension)
1115        .map(|bundle| bundle.bundle_identifier.clone())
1116        .collect::<Vec<_>>();
1117    extensions.sort();
1118    let mut manifest = ArtifactManifest::new(context.operation_id.clone(), context.job_id.clone());
1119    manifest.schema_version = ARTIFACT_MANIFEST_SCHEMA_VERSION;
1120    manifest.project_id.clone_from(&request.bundle_identifier);
1121    manifest
1122        .source_repository
1123        .clone_from(&request.source_repository);
1124    manifest
1125        .source_revision
1126        .clone_from(&request.source_revision);
1127    manifest.source_snapshot = false;
1128    manifest.source_sha256.clone_from(&request.source.sha256);
1129    manifest
1130        .cargo_lock_sha256
1131        .clone_from(&compile.cargo_lock_sha256);
1132    manifest.config_sha256.clone_from(&compile.config_sha256);
1133    manifest
1134        .rustferry_version
1135        .clone_from(&compile.rustferry_version);
1136    manifest.worker_version.clone_from(&compile.worker_version);
1137    manifest.provider.clone_from(&compile.provider);
1138    manifest.toolchain = AppleToolchainEvidence {
1139        worker_os: compile.toolchain.worker_os.clone(),
1140        worker_architecture: compile.toolchain.worker_architecture.clone(),
1141        xcode_version: compile.toolchain.xcode_version.clone(),
1142        iphoneos_sdk_version: compile.toolchain.iphoneos_sdk_version.clone(),
1143        rust_version: compile.toolchain.rust_version.clone(),
1144        rust_target: IOS_DEVICE_RUST_TARGET.to_owned(),
1145    };
1146    manifest.app_name.clone_from(&request.product_name);
1147    manifest
1148        .app_version
1149        .clone_from(&request.product.app_version);
1150    manifest
1151        .build_number
1152        .clone_from(&request.product.build_number);
1153    manifest
1154        .bundle_identifier
1155        .clone_from(&request.bundle_identifier);
1156    manifest.build_profile = match request.profile {
1157        BuildProfile::Debug => "debug".to_owned(),
1158        BuildProfile::Release => "release".to_owned(),
1159    };
1160    "arm64".clone_into(&mut manifest.architecture);
1161    manifest.signing = ArtifactSigningEvidence {
1162        mode: SigningMode::UnsignedCompileOnly,
1163        status: SigningStatus::Unsigned,
1164        team_id: None,
1165        certificate_fingerprint: None,
1166        profile_uuid: None,
1167        profile_expiration: None,
1168        entitlements_sha256: None,
1169    };
1170    manifest.extensions = extensions;
1171    manifest.artifacts = records;
1172    manifest.validation_levels = BTreeSet::from([
1173        ValidationLevel::SourceValidated,
1174        ValidationLevel::RemoteBuilderValidated,
1175        ValidationLevel::DeviceTargetCompiled,
1176        ValidationLevel::DeviceBinaryBuilt,
1177        ValidationLevel::AppBundleBuilt,
1178        ValidationLevel::ArchiveBuilt,
1179        ValidationLevel::ArtifactValidated,
1180    ]);
1181    manifest.started_at = rfc3339_from_unix(compile.started_at_unix_seconds)
1182        .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_owned());
1183    manifest.finished_at = rfc3339_from_unix(compile.finished_at_unix_seconds)
1184        .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_owned());
1185    manifest.cleanup_status = CleanupStatus::Confirmed;
1186    manifest
1187}
1188
1189fn signed_verified_run(
1190    published: crate::artifact::PublishedGithubArtifact,
1191    compile_evidence: CompilePhaseEvidence,
1192    cleanup_evidence: GithubSignedCleanupEvidenceV1,
1193) -> Result<VerifiedRunContents, GithubArtifactStoreError> {
1194    let crate::artifact::PublishedGithubArtifact {
1195        ipa_path,
1196        manifest_path,
1197        signing_report_path,
1198        validation_report_path,
1199        sanitized_log_path,
1200        app_bundle_archive_path,
1201        signed_xcarchive_path,
1202        dsym_archive_path,
1203        mut manifest,
1204        manifest_sha256,
1205        manifest_size,
1206        ..
1207    } = published;
1208    let mut paths = BTreeMap::from([
1209        (DEVELOPMENT_IPA_NAME, ipa_path),
1210        (SIGNING_REPORT_NAME, signing_report_path),
1211        (VALIDATION_REPORT_NAME, validation_report_path),
1212        (SANITIZED_BUILD_LOG_NAME, sanitized_log_path),
1213    ]);
1214    for (name, path) in [
1215        (APP_BUNDLE_ARCHIVE_NAME, app_bundle_archive_path),
1216        (SIGNED_XCARCHIVE_NAME, signed_xcarchive_path),
1217        (DSYM_ARCHIVE_NAME, dsym_archive_path),
1218    ] {
1219        if let Some(path) = path {
1220            paths.insert(name, path);
1221        }
1222    }
1223    let mut artifacts = BTreeMap::new();
1224    for record in &manifest.artifacts {
1225        let path = paths
1226            .get(record.file_name.as_str())
1227            .ok_or(GithubArtifactStoreError::FinalArtifact(
1228                GithubArtifactError::InvalidManifest,
1229            ))?
1230            .clone();
1231        if artifacts
1232            .insert(
1233                record.artifact_id.clone(),
1234                CachedArtifact {
1235                    record: record.clone(),
1236                    path,
1237                },
1238            )
1239            .is_some()
1240        {
1241            return Err(GithubArtifactStoreError::FinalArtifact(
1242                GithubArtifactError::InvalidManifest,
1243            ));
1244        }
1245    }
1246    let received_manifest = CachedArtifact {
1247        record: ArtifactRecord {
1248            artifact_id: MANIFEST_ARTIFACT_ID.to_owned(),
1249            kind: ArtifactKind::Manifest,
1250            file_name: ARTIFACT_MANIFEST_NAME.to_owned(),
1251            size: manifest_size,
1252            sha256: manifest_sha256,
1253            media_type: Some("application/json".to_owned()),
1254        },
1255        path: manifest_path,
1256    };
1257    augment_store_catalog(&mut manifest, &mut artifacts, [received_manifest])?;
1258    Ok(VerifiedRunContents {
1259        manifest,
1260        artifacts,
1261        evidence: GithubVerifiedRunEvidence::new(compile_evidence, Some(cleanup_evidence)),
1262    })
1263}
1264
1265/// Add client-transport records after validating the immutable worker manifest.
1266/// The received manifest cannot include its own size and digest without recursion.
1267fn augment_store_catalog<const N: usize>(
1268    manifest: &mut ArtifactManifest,
1269    artifacts: &mut BTreeMap<String, CachedArtifact>,
1270    additions: [CachedArtifact; N],
1271) -> Result<(), GithubArtifactStoreError> {
1272    for artifact in additions {
1273        insert_catalog_artifact(artifacts, artifact)?;
1274    }
1275    manifest.artifacts = artifacts
1276        .values()
1277        .map(|artifact| artifact.record.clone())
1278        .collect();
1279    Ok(())
1280}
1281
1282fn insert_catalog_artifact(
1283    artifacts: &mut BTreeMap<String, CachedArtifact>,
1284    artifact: CachedArtifact,
1285) -> Result<(), GithubArtifactStoreError> {
1286    if artifact.record.size == 0 || !is_lower_sha256(&artifact.record.sha256) {
1287        return Err(GithubArtifactStoreError::FinalArtifact(
1288            GithubArtifactError::InvalidManifest,
1289        ));
1290    }
1291    let Entry::Vacant(entry) = artifacts.entry(artifact.record.artifact_id.clone()) else {
1292        return Err(GithubArtifactStoreError::FinalArtifact(
1293            GithubArtifactError::InvalidManifest,
1294        ));
1295    };
1296    entry.insert(artifact);
1297    Ok(())
1298}
1299
1300fn bind_private_cache_root(
1301    path: &Path,
1302) -> Result<(Utf8PathBuf, PrivateDirectoryGuard), GithubArtifactStoreError> {
1303    if !path.is_absolute() || !is_normal_path(path) {
1304        return Err(GithubArtifactStoreError::InvalidCacheRoot);
1305    }
1306    let metadata = fs::symlink_metadata(path).map_err(|error| io_store_error(error.kind()))?;
1307    if metadata.file_type().is_symlink() || !metadata.is_dir() {
1308        return Err(GithubArtifactStoreError::InvalidCacheRoot);
1309    }
1310    #[cfg(unix)]
1311    if metadata.permissions().mode() & 0o077 != 0 {
1312        return Err(GithubArtifactStoreError::InvalidCacheRoot);
1313    }
1314    let canonical = path
1315        .canonicalize()
1316        .map_err(|error| io_store_error(error.kind()))?;
1317    if canonical != path {
1318        return Err(GithubArtifactStoreError::InvalidCacheRoot);
1319    }
1320    let canonical = Utf8PathBuf::from_path_buf(canonical)
1321        .map_err(|_| GithubArtifactStoreError::InvalidCacheRoot)?;
1322    let guard = open_private_directory(&canonical)?;
1323    Ok((canonical, guard))
1324}
1325
1326#[cfg(windows)]
1327fn open_private_directory(
1328    path: &Utf8Path,
1329) -> Result<PrivateDirectoryGuard, GithubArtifactStoreError> {
1330    open_windows_private_directory(path.as_std_path())
1331        .map(|handle| PrivateDirectoryGuard { handle })
1332        .map_err(map_private_cache_error)
1333}
1334
1335#[cfg(not(windows))]
1336#[allow(
1337    clippy::unnecessary_wraps,
1338    reason = "keeps the retained-directory guard contract platform-neutral"
1339)]
1340fn open_private_directory(
1341    _path: &Utf8Path,
1342) -> Result<PrivateDirectoryGuard, GithubArtifactStoreError> {
1343    Ok(PrivateDirectoryGuard {})
1344}
1345
1346#[cfg(windows)]
1347#[allow(
1348    clippy::needless_pass_by_value,
1349    reason = "used directly as a path-free Result::map_err adapter"
1350)]
1351fn map_private_cache_error(error: PrivateDirectoryError) -> GithubArtifactStoreError {
1352    if error.cleanup_status() == PrivateDirectoryCleanupStatus::Uncertain {
1353        return GithubArtifactStoreError::CleanupUncertain;
1354    }
1355    if error.kind() == PrivateDirectoryErrorKind::AlreadyExists {
1356        return GithubArtifactStoreError::Io(io::ErrorKind::AlreadyExists);
1357    }
1358    if matches!(error.os_code(), Some(2 | 3)) {
1359        return GithubArtifactStoreError::Io(io::ErrorKind::NotFound);
1360    }
1361    GithubArtifactStoreError::InvalidCacheRoot
1362}
1363
1364#[cfg(windows)]
1365#[allow(
1366    clippy::needless_pass_by_value,
1367    reason = "used directly as a path-free Result::map_err adapter"
1368)]
1369fn map_private_cleanup_error(error: PrivateDirectoryError) -> GithubArtifactStoreError {
1370    if error.cleanup_status() == PrivateDirectoryCleanupStatus::Uncertain {
1371        GithubArtifactStoreError::CleanupUncertain
1372    } else {
1373        GithubArtifactStoreError::InvalidCacheRoot
1374    }
1375}
1376
1377#[cfg(windows)]
1378#[allow(
1379    clippy::needless_pass_by_value,
1380    reason = "used directly as a path-free Result::map_err adapter"
1381)]
1382fn map_private_destination_error(error: PrivateDirectoryError) -> GithubArtifactStoreError {
1383    if error.cleanup_status() == PrivateDirectoryCleanupStatus::Uncertain {
1384        GithubArtifactStoreError::CleanupUncertain
1385    } else {
1386        GithubArtifactStoreError::InvalidDestination
1387    }
1388}
1389
1390#[cfg(windows)]
1391fn windows_private_not_found(error: &PrivateDirectoryError) -> bool {
1392    matches!(error.os_code(), Some(2 | 3))
1393}
1394
1395fn create_run_directory(
1396    cache_root: &Utf8Path,
1397    context: &GithubArtifactContext,
1398) -> Result<RunCacheDirectory, GithubArtifactStoreError> {
1399    let repository_hash = sha256_bytes(
1400        format!(
1401            "{}/{}",
1402            context.repository.owner(),
1403            context.repository.name()
1404        )
1405        .as_bytes(),
1406    );
1407    let prefix = format!(
1408        "run-{}-{}-{}-{}",
1409        &repository_hash[..16],
1410        context.run.handle().id().get(),
1411        context.run.run_attempt(),
1412        &context.request_sha256[..16]
1413    );
1414    for sequence in 1..=TEMPORARY_NAME_ATTEMPTS {
1415        let candidate = cache_root.join(format!("{prefix}-{sequence}"));
1416        match create_private_directory(&candidate) {
1417            Ok(guard) => {
1418                return Ok(RunCacheDirectory {
1419                    cache_root: cache_root.to_owned(),
1420                    path: candidate,
1421                    guard: Some(guard),
1422                    owned: true,
1423                });
1424            }
1425            Err(GithubArtifactStoreError::Io(io::ErrorKind::AlreadyExists)) => {}
1426            Err(error) => return Err(error),
1427        }
1428    }
1429    Err(GithubArtifactStoreError::Io(io::ErrorKind::AlreadyExists))
1430}
1431
1432fn remove_exact_run_directory(
1433    cache_root: &Utf8Path,
1434    run_directory: &Utf8Path,
1435    guard: PrivateDirectoryGuard,
1436) -> Result<(), GithubArtifactStoreError> {
1437    if !cache_root.is_absolute()
1438        || run_directory.parent() != Some(cache_root)
1439        || !run_directory
1440            .file_name()
1441            .is_some_and(|name| name.starts_with("run-"))
1442    {
1443        return Err(GithubArtifactStoreError::InvalidCacheRoot);
1444    }
1445    #[cfg(windows)]
1446    {
1447        remove_exact_windows_run_directory(guard)
1448    }
1449    #[cfg(not(windows))]
1450    {
1451        release_private_directory_guard(guard);
1452        remove_exact_cache_entry(cache_root, run_directory)
1453    }
1454}
1455
1456#[cfg(windows)]
1457fn remove_exact_windows_run_directory(
1458    guard: PrivateDirectoryGuard,
1459) -> Result<(), GithubArtifactStoreError> {
1460    for attempt in 1..=WINDOWS_RUN_CACHE_CLEANUP_ATTEMPTS {
1461        // A failed recursive attempt consumes its handle and may remove part of the tree. Keep the
1462        // original identity handle retained so every retry still targets the exact same root.
1463        let cleanup_handle = guard
1464            .handle
1465            .try_clone()
1466            .map_err(|error| io_store_error(error.kind()))?;
1467        match remove_windows_private_directory_tree_handle(cleanup_handle) {
1468            Ok(()) => {
1469                release_private_directory_guard(guard);
1470                return Ok(());
1471            }
1472            Err(error)
1473                if attempt < WINDOWS_RUN_CACHE_CLEANUP_ATTEMPTS
1474                    && matches!(error.kind(), PrivateDirectoryErrorKind::WindowsApi(_)) =>
1475            {
1476                std::thread::sleep(WINDOWS_RUN_CACHE_CLEANUP_RETRY_DELAY);
1477            }
1478            Err(error) => return Err(map_private_cleanup_error(error)),
1479        }
1480    }
1481    Err(GithubArtifactStoreError::CleanupUncertain)
1482}
1483
1484fn prune_verified_run_cache(
1485    run_directory: &Utf8Path,
1486    verified: &VerifiedRunContents,
1487) -> Result<(), GithubArtifactStoreError> {
1488    let verified_directory = run_directory.join("verified");
1489    let mut retained = BTreeSet::new();
1490    for artifact in verified.artifacts.values() {
1491        if artifact.path.parent() != Some(verified_directory.as_path())
1492            || !retained.insert(artifact.path.clone())
1493        {
1494            return Err(GithubArtifactStoreError::InvalidCacheRoot);
1495        }
1496        drop(open_regular_file(&artifact.path)?);
1497    }
1498    for name in ["transport", "unsigned-xcarchive", "final-staging"] {
1499        remove_exact_cache_entry(run_directory, &run_directory.join(name))?;
1500    }
1501    let entries = fs::read_dir(&verified_directory)
1502        .map_err(|error| io_store_error(error.kind()))?
1503        .collect::<Result<Vec<_>, _>>()
1504        .map_err(|error| io_store_error(error.kind()))?;
1505    for entry in entries {
1506        let path = Utf8PathBuf::from_path_buf(entry.path())
1507            .map_err(|_| GithubArtifactStoreError::InvalidCacheRoot)?;
1508        if !retained.contains(&path) {
1509            remove_exact_cache_entry(&verified_directory, &path)?;
1510        }
1511    }
1512    Ok(())
1513}
1514
1515#[cfg(not(windows))]
1516fn remove_exact_cache_entry(
1517    parent: &Utf8Path,
1518    path: &Utf8Path,
1519) -> Result<(), GithubArtifactStoreError> {
1520    if !parent.is_absolute() || path.parent() != Some(parent) {
1521        return Err(GithubArtifactStoreError::InvalidCacheRoot);
1522    }
1523    let metadata = match fs::symlink_metadata(path) {
1524        Ok(metadata) => metadata,
1525        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
1526        Err(error) => return Err(io_store_error(error.kind())),
1527    };
1528    let removal = if metadata.is_dir() && !metadata.file_type().is_symlink() {
1529        fs::remove_dir_all(path)
1530    } else {
1531        fs::remove_file(path)
1532    };
1533    match removal {
1534        Ok(()) => Ok(()),
1535        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
1536        Err(error) => Err(io_store_error(error.kind())),
1537    }
1538}
1539
1540#[cfg(windows)]
1541fn remove_exact_cache_entry(
1542    parent: &Utf8Path,
1543    path: &Utf8Path,
1544) -> Result<(), GithubArtifactStoreError> {
1545    if !parent.is_absolute() || path.parent() != Some(parent) {
1546        return Err(GithubArtifactStoreError::InvalidCacheRoot);
1547    }
1548    match open_windows_private_directory(path.as_std_path()) {
1549        Ok(directory) => {
1550            return remove_windows_private_directory_tree_handle(directory)
1551                .map_err(map_private_cleanup_error);
1552        }
1553        Err(error) if windows_private_not_found(&error) => return Ok(()),
1554        Err(error) if error.kind() == PrivateDirectoryErrorKind::NotDirectory => {}
1555        Err(error) => return Err(map_private_cache_error(error)),
1556    }
1557    let file = match open_windows_private_file_for_removal(path.as_std_path()) {
1558        Ok(file) => file,
1559        Err(error) if windows_private_not_found(&error) => return Ok(()),
1560        Err(error) => return Err(map_private_cache_error(error)),
1561    };
1562    remove_windows_private_file_handle(file).map_err(map_private_cleanup_error)
1563}
1564
1565#[cfg(windows)]
1566fn create_private_directory(
1567    path: &Utf8Path,
1568) -> Result<PrivateDirectoryGuard, GithubArtifactStoreError> {
1569    create_windows_private_directory(path.as_std_path())
1570        .map(|handle| PrivateDirectoryGuard { handle })
1571        .map_err(map_private_cache_error)
1572}
1573
1574#[cfg(not(windows))]
1575fn create_private_directory(
1576    path: &Utf8Path,
1577) -> Result<PrivateDirectoryGuard, GithubArtifactStoreError> {
1578    #[cfg(unix)]
1579    let builder = {
1580        let mut builder = fs::DirBuilder::new();
1581        builder.mode(0o700);
1582        builder
1583    };
1584    #[cfg(not(unix))]
1585    let builder = fs::DirBuilder::new();
1586    builder
1587        .create(path)
1588        .map_err(|error| io_store_error(error.kind()))?;
1589    let metadata = fs::symlink_metadata(path).map_err(|error| io_store_error(error.kind()))?;
1590    if metadata.file_type().is_symlink() || !metadata.is_dir() {
1591        return Err(GithubArtifactStoreError::InvalidCacheRoot);
1592    }
1593    #[cfg(unix)]
1594    if metadata.permissions().mode() & 0o077 != 0 {
1595        return Err(GithubArtifactStoreError::InvalidCacheRoot);
1596    }
1597    Ok(PrivateDirectoryGuard {})
1598}
1599
1600#[cfg(windows)]
1601fn create_private_file(path: &Utf8Path) -> Result<File, GithubArtifactStoreError> {
1602    create_windows_private_file(path.as_std_path()).map_err(map_private_cache_error)
1603}
1604
1605#[cfg(not(windows))]
1606fn create_private_file(path: &Utf8Path) -> Result<File, GithubArtifactStoreError> {
1607    let mut options = OpenOptions::new();
1608    options.write(true).create_new(true);
1609    #[cfg(unix)]
1610    options.mode(0o600);
1611    options
1612        .open(path)
1613        .map_err(|error| io_store_error(error.kind()))
1614}
1615
1616fn write_private_file(path: &Utf8Path, bytes: &[u8]) -> Result<(), GithubArtifactStoreError> {
1617    let mut file = create_private_file(path)?;
1618    if let Err(error) = file.write_all(bytes).and_then(|()| file.sync_all()) {
1619        remove_failed_private_file(path, file)?;
1620        return Err(io_store_error(error.kind()));
1621    }
1622    Ok(())
1623}
1624
1625#[cfg(windows)]
1626fn remove_failed_private_file(
1627    _path: &Utf8Path,
1628    file: File,
1629) -> Result<(), GithubArtifactStoreError> {
1630    remove_windows_private_file_handle(file).map_err(map_private_cleanup_error)
1631}
1632
1633#[cfg(not(windows))]
1634fn remove_failed_private_file(path: &Utf8Path, file: File) -> Result<(), GithubArtifactStoreError> {
1635    drop(file);
1636    remove_new_file(path)
1637}
1638
1639#[cfg(windows)]
1640fn open_regular_file(path: &Utf8Path) -> Result<File, GithubArtifactStoreError> {
1641    if !path.is_absolute() {
1642        return Err(GithubArtifactStoreError::InvalidCacheRoot);
1643    }
1644    open_windows_private_file(path.as_std_path()).map_err(map_private_cache_error)
1645}
1646
1647#[cfg(not(windows))]
1648fn open_regular_file(path: &Utf8Path) -> Result<File, GithubArtifactStoreError> {
1649    if !path.is_absolute() {
1650        return Err(GithubArtifactStoreError::InvalidCacheRoot);
1651    }
1652    let metadata = fs::symlink_metadata(path).map_err(|error| io_store_error(error.kind()))?;
1653    if metadata.file_type().is_symlink() || !metadata.is_file() {
1654        return Err(GithubArtifactStoreError::InvalidCacheRoot);
1655    }
1656    let file = File::open(path).map_err(|error| io_store_error(error.kind()))?;
1657    if !file
1658        .metadata()
1659        .map_err(|error| io_store_error(error.kind()))?
1660        .is_file()
1661    {
1662        return Err(GithubArtifactStoreError::InvalidCacheRoot);
1663    }
1664    Ok(file)
1665}
1666
1667fn verify_regular_file(
1668    path: &Utf8Path,
1669    expected_size: u64,
1670    expected_sha256: &str,
1671) -> Result<(), GithubArtifactStoreError> {
1672    if !is_lower_sha256(expected_sha256) {
1673        return Err(GithubArtifactStoreError::InvalidSealedArchive);
1674    }
1675    let mut file = open_regular_file(path)?;
1676    let initial = file
1677        .metadata()
1678        .map_err(|error| io_store_error(error.kind()))?;
1679    if initial.len() != expected_size {
1680        return Err(GithubArtifactStoreError::InvalidSealedArchive);
1681    }
1682    let mut hasher = Sha256::new();
1683    let mut size = 0_u64;
1684    let mut buffer = vec![0_u8; COPY_BUFFER_BYTES];
1685    loop {
1686        let read = file
1687            .read(&mut buffer)
1688            .map_err(|error| io_store_error(error.kind()))?;
1689        if read == 0 {
1690            break;
1691        }
1692        size = size
1693            .checked_add(u64::try_from(read).unwrap_or(u64::MAX))
1694            .ok_or(GithubArtifactStoreError::InvalidSealedArchive)?;
1695        if size > expected_size {
1696            return Err(GithubArtifactStoreError::InvalidSealedArchive);
1697        }
1698        hasher.update(&buffer[..read]);
1699    }
1700    let final_metadata = file
1701        .metadata()
1702        .map_err(|error| io_store_error(error.kind()))?;
1703    if size != expected_size
1704        || final_metadata.len() != expected_size
1705        || hex::encode(hasher.finalize()) != expected_sha256
1706    {
1707        return Err(GithubArtifactStoreError::InvalidSealedArchive);
1708    }
1709    Ok(())
1710}
1711
1712#[cfg(windows)]
1713fn atomic_verified_copy(
1714    source: &Utf8Path,
1715    record: &ArtifactRecord,
1716    destination: &ProtocolPath,
1717) -> Result<RegularFileFilesystemIdentity, GithubArtifactStoreError> {
1718    atomic_verified_copy_windows(source, record, destination, |_| Ok(()))
1719}
1720
1721#[cfg(not(windows))]
1722fn atomic_verified_copy(
1723    source: &Utf8Path,
1724    record: &ArtifactRecord,
1725    destination: &ProtocolPath,
1726) -> Result<RegularFileFilesystemIdentity, GithubArtifactStoreError> {
1727    atomic_verified_copy_with_unlink(source, record, destination, remove_new_file)
1728}
1729
1730#[cfg(not(windows))]
1731fn atomic_verified_copy_with_unlink(
1732    source: &Utf8Path,
1733    record: &ArtifactRecord,
1734    destination: &ProtocolPath,
1735    unlink_temporary: impl FnOnce(&Utf8Path) -> Result<(), GithubArtifactStoreError>,
1736) -> Result<RegularFileFilesystemIdentity, GithubArtifactStoreError> {
1737    let destination = validated_destination(destination)?;
1738    let destination = Utf8PathBuf::from_path_buf(destination)
1739        .map_err(|_| GithubArtifactStoreError::InvalidDestination)?;
1740    if fs::symlink_metadata(&destination).is_ok() {
1741        return Err(GithubArtifactStoreError::InvalidDestination);
1742    }
1743    let parent = destination
1744        .parent()
1745        .ok_or(GithubArtifactStoreError::InvalidDestination)?;
1746    let file_name = destination
1747        .file_name()
1748        .ok_or(GithubArtifactStoreError::InvalidDestination)?;
1749    let mut temporary = None;
1750    for _ in 0..TEMPORARY_NAME_ATTEMPTS {
1751        let sequence = TEMPORARY_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1752        let candidate = parent.join(format!(
1753            ".{file_name}.rustferry-{}-{sequence}.tmp",
1754            std::process::id()
1755        ));
1756        match create_private_file(&candidate) {
1757            Ok(file) => {
1758                temporary = Some((candidate, file));
1759                break;
1760            }
1761            Err(GithubArtifactStoreError::Io(io::ErrorKind::AlreadyExists)) => {}
1762            Err(error) => return Err(error),
1763        }
1764    }
1765    let (temporary_path, mut output) =
1766        temporary.ok_or(GithubArtifactStoreError::Io(io::ErrorKind::AlreadyExists))?;
1767    let copy_result = (|| {
1768        let mut input = open_regular_file(source)?;
1769        let source_metadata = input
1770            .metadata()
1771            .map_err(|error| io_store_error(error.kind()))?;
1772        if source_metadata.len() != record.size || !is_lower_sha256(&record.sha256) {
1773            return Err(GithubArtifactStoreError::InvalidSealedArchive);
1774        }
1775        let mut hasher = Sha256::new();
1776        let mut copied = 0_u64;
1777        let mut buffer = vec![0_u8; COPY_BUFFER_BYTES];
1778        loop {
1779            let read = input
1780                .read(&mut buffer)
1781                .map_err(|error| io_store_error(error.kind()))?;
1782            if read == 0 {
1783                break;
1784            }
1785            copied = copied
1786                .checked_add(u64::try_from(read).unwrap_or(u64::MAX))
1787                .ok_or(GithubArtifactStoreError::InvalidSealedArchive)?;
1788            if copied > record.size {
1789                return Err(GithubArtifactStoreError::InvalidSealedArchive);
1790            }
1791            output
1792                .write_all(&buffer[..read])
1793                .map_err(|error| io_store_error(error.kind()))?;
1794            hasher.update(&buffer[..read]);
1795        }
1796        output
1797            .flush()
1798            .and_then(|()| output.sync_all())
1799            .map_err(|error| io_store_error(error.kind()))?;
1800        let actual_sha256 = hex::encode(hasher.finalize());
1801        if copied != record.size || actual_sha256 != record.sha256 {
1802            return Err(GithubArtifactStoreError::InvalidSealedArchive);
1803        }
1804        let publication = ExactPublicationGuard::link(&temporary_path, &destination, output)?;
1805        if let Err(unlink_error) = unlink_temporary(&temporary_path) {
1806            publication.rollback()?;
1807            return Err(unlink_error);
1808        }
1809        let local_file_identity = match publication.identity() {
1810            Ok(identity) => identity,
1811            Err(error) => {
1812                publication.rollback()?;
1813                return Err(error);
1814            }
1815        };
1816        publication.commit();
1817        Ok(local_file_identity)
1818    })();
1819    if copy_result.is_err() {
1820        let _ = fs::remove_file(&temporary_path);
1821    }
1822    copy_result
1823}
1824
1825#[cfg(all(windows, test))]
1826fn atomic_verified_copy_with_unlink(
1827    source: &Utf8Path,
1828    record: &ArtifactRecord,
1829    destination: &ProtocolPath,
1830    before_unlink: impl FnOnce(&Utf8Path) -> Result<(), GithubArtifactStoreError>,
1831) -> Result<RegularFileFilesystemIdentity, GithubArtifactStoreError> {
1832    atomic_verified_copy_windows(source, record, destination, before_unlink)
1833}
1834
1835#[cfg(windows)]
1836fn atomic_verified_copy_windows(
1837    source: &Utf8Path,
1838    record: &ArtifactRecord,
1839    destination: &ProtocolPath,
1840    before_commit: impl FnOnce(&Utf8Path) -> Result<(), GithubArtifactStoreError>,
1841) -> Result<RegularFileFilesystemIdentity, GithubArtifactStoreError> {
1842    let destination = validated_destination(destination)?;
1843    let destination = Utf8PathBuf::from_path_buf(destination)
1844        .map_err(|_| GithubArtifactStoreError::InvalidDestination)?;
1845    if fs::symlink_metadata(&destination).is_ok() {
1846        return Err(GithubArtifactStoreError::InvalidDestination);
1847    }
1848    let parent = destination
1849        .parent()
1850        .ok_or(GithubArtifactStoreError::InvalidDestination)?;
1851    let (staging_directory, staging_path, mut staging_file) =
1852        create_windows_client_staging(parent)?;
1853
1854    let copy_result = copy_verified_bytes(source, record, &mut staging_file);
1855    if let Err(error) = copy_result {
1856        cleanup_windows_client_staging(staging_directory, staging_file)?;
1857        return Err(error);
1858    }
1859    if let Err(error) = verify_windows_private_file_handle(staging_file.as_handle()) {
1860        cleanup_windows_client_staging(staging_directory, staging_file)?;
1861        return Err(map_private_destination_error(error));
1862    }
1863    let (staging_directory, mut staging_file) =
1864        seal_windows_client_staging(staging_directory, staging_file)?;
1865    if let Err(error) = verify_sealed_staging_bytes(&mut staging_file, record) {
1866        cleanup_windows_client_staging(staging_directory, staging_file)?;
1867        return Err(error);
1868    }
1869    publish_windows_client_staging(
1870        staging_directory,
1871        &staging_path,
1872        staging_file,
1873        &destination,
1874        before_commit,
1875    )
1876}
1877
1878#[cfg(windows)]
1879fn create_windows_client_staging(
1880    parent: &Utf8Path,
1881) -> Result<(File, Utf8PathBuf, File), GithubArtifactStoreError> {
1882    for _ in 0..TEMPORARY_NAME_ATTEMPTS {
1883        let sequence = TEMPORARY_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1884        let directory_path = parent.join(format!(
1885            ".rustferry-client-{}-{sequence}.tmp",
1886            std::process::id()
1887        ));
1888        let directory = match create_windows_private_directory(directory_path.as_std_path()) {
1889            Ok(directory) => directory,
1890            Err(error) if error.kind() == PrivateDirectoryErrorKind::AlreadyExists => continue,
1891            Err(error) => return Err(map_private_destination_error(error)),
1892        };
1893        let file_path = directory_path.join("artifact.tmp");
1894        let file = match create_windows_private_staging_file(file_path.as_std_path()) {
1895            Ok(file) => file,
1896            Err(error) => {
1897                remove_windows_private_directory_handle(directory)
1898                    .map_err(map_private_cleanup_error)?;
1899                return Err(map_private_destination_error(error));
1900            }
1901        };
1902        return Ok((directory, file_path, file));
1903    }
1904    Err(GithubArtifactStoreError::InvalidDestination)
1905}
1906
1907#[cfg(windows)]
1908fn seal_windows_client_staging(
1909    directory: File,
1910    file: File,
1911) -> Result<(File, File), GithubArtifactStoreError> {
1912    match seal_windows_private_staging_file(file) {
1913        Ok(file) => Ok((directory, file)),
1914        Err(error) => {
1915            let directory_cleanup = remove_windows_private_directory_handle(directory);
1916            if error.cleanup_status() != PrivateDirectoryCleanupStatus::Confirmed
1917                || directory_cleanup.is_err()
1918            {
1919                return Err(GithubArtifactStoreError::CleanupUncertain);
1920            }
1921            Err(map_private_destination_error(error))
1922        }
1923    }
1924}
1925
1926#[cfg(windows)]
1927fn publish_windows_client_staging(
1928    staging_directory: File,
1929    staging_path: &Utf8Path,
1930    staging_file: File,
1931    destination: &Utf8Path,
1932    before_commit: impl FnOnce(&Utf8Path) -> Result<(), GithubArtifactStoreError>,
1933) -> Result<RegularFileFilesystemIdentity, GithubArtifactStoreError> {
1934    if let Err(error) = fs::hard_link(staging_path, destination) {
1935        cleanup_windows_client_staging(staging_directory, staging_file)?;
1936        return Err(if error.kind() == io::ErrorKind::AlreadyExists {
1937            GithubArtifactStoreError::InvalidDestination
1938        } else {
1939            io_store_error(error.kind())
1940        });
1941    }
1942    if verify_windows_private_file_handle_in_state(
1943        staging_file.as_handle(),
1944        PrivateFileLinkState::PublicationPair,
1945    )
1946    .is_err()
1947    {
1948        cleanup_windows_client_staging_in_state(
1949            staging_directory,
1950            staging_file,
1951            PrivateFileLinkState::PublicationPair,
1952        )?;
1953        return Err(GithubArtifactStoreError::CleanupUncertain);
1954    }
1955    let Ok(published) = open_windows_private_file_for_removal_in_state(
1956        destination.as_std_path(),
1957        PrivateFileLinkState::PublicationPair,
1958    ) else {
1959        cleanup_windows_client_staging_in_state(
1960            staging_directory,
1961            staging_file,
1962            PrivateFileLinkState::PublicationPair,
1963        )?;
1964        return Err(GithubArtifactStoreError::CleanupUncertain);
1965    };
1966    if open_files_match(&staging_file, &published) != Ok(true) {
1967        drop(published);
1968        cleanup_windows_client_staging_in_state(
1969            staging_directory,
1970            staging_file,
1971            PrivateFileLinkState::PublicationPair,
1972        )?;
1973        return Err(GithubArtifactStoreError::CleanupUncertain);
1974    }
1975    if path_matches_open_file(destination, &published) != Ok(true) {
1976        rollback_windows_client_publication(staging_directory, staging_file, published)?;
1977        return Err(GithubArtifactStoreError::InvalidDestination);
1978    }
1979    if let Err(error) = before_commit(destination) {
1980        rollback_windows_client_publication(staging_directory, staging_file, published)?;
1981        return Err(error);
1982    }
1983    let staging_unlink = staging_file
1984        .try_clone()
1985        .map_err(|error| io_store_error(error.kind()))
1986        .and_then(|file| {
1987            remove_windows_private_file_handle_in_state(file, PrivateFileLinkState::PublicationPair)
1988                .map_err(map_private_cleanup_error)
1989        });
1990    if staging_unlink.is_err() {
1991        rollback_windows_client_publication(staging_directory, staging_file, published)?;
1992        return Err(GithubArtifactStoreError::CleanupUncertain);
1993    }
1994    drop(staging_file);
1995    if remove_windows_private_directory_handle(staging_directory).is_err() {
1996        let _ = remove_windows_private_file_handle(published);
1997        return Err(GithubArtifactStoreError::CleanupUncertain);
1998    }
1999    if let Err(error) = verify_windows_private_file_handle(published.as_handle()) {
2000        remove_windows_private_file_handle(published).map_err(map_private_cleanup_error)?;
2001        return Err(map_private_destination_error(error));
2002    }
2003    if path_matches_open_file(destination, &published) != Ok(true) {
2004        remove_windows_private_file_handle(published).map_err(map_private_cleanup_error)?;
2005        return Err(GithubArtifactStoreError::InvalidDestination);
2006    }
2007    let local_file_identity = match regular_file_identity_from_file(&published) {
2008        Ok(identity) => identity,
2009        Err(error) => {
2010            remove_windows_private_file_handle(published).map_err(map_private_cleanup_error)?;
2011            return Err(map_local_identity_error(error));
2012        }
2013    };
2014    drop(published);
2015    Ok(local_file_identity)
2016}
2017
2018#[cfg(windows)]
2019fn cleanup_windows_client_staging(
2020    directory: File,
2021    file: File,
2022) -> Result<(), GithubArtifactStoreError> {
2023    cleanup_windows_client_staging_in_state(directory, file, PrivateFileLinkState::Single)
2024}
2025
2026#[cfg(windows)]
2027fn cleanup_windows_client_staging_in_state(
2028    directory: File,
2029    file: File,
2030    state: PrivateFileLinkState,
2031) -> Result<(), GithubArtifactStoreError> {
2032    let file_cleanup = remove_windows_private_file_handle_in_state(file, state);
2033    let directory_cleanup = remove_windows_private_directory_handle(directory);
2034    if file_cleanup.is_err() || directory_cleanup.is_err() {
2035        Err(GithubArtifactStoreError::CleanupUncertain)
2036    } else {
2037        Ok(())
2038    }
2039}
2040
2041#[cfg(windows)]
2042fn rollback_windows_client_publication(
2043    directory: File,
2044    staging_file: File,
2045    published: File,
2046) -> Result<(), GithubArtifactStoreError> {
2047    let published_cleanup = remove_windows_private_file_handle_in_state(
2048        published,
2049        PrivateFileLinkState::PublicationPair,
2050    );
2051    let staging_state = if published_cleanup.is_ok() {
2052        PrivateFileLinkState::Single
2053    } else {
2054        PrivateFileLinkState::PublicationPair
2055    };
2056    let staging_cleanup = remove_windows_private_file_handle_in_state(staging_file, staging_state);
2057    let directory_cleanup = remove_windows_private_directory_handle(directory);
2058    if published_cleanup.is_err() || staging_cleanup.is_err() || directory_cleanup.is_err() {
2059        Err(GithubArtifactStoreError::CleanupUncertain)
2060    } else {
2061        Ok(())
2062    }
2063}
2064
2065#[cfg(windows)]
2066fn open_files_match(left: &File, right: &File) -> Result<bool, GithubArtifactStoreError> {
2067    let left = FileIdentityHandle::from_file(
2068        left.try_clone()
2069            .map_err(|error| io_store_error(error.kind()))?,
2070    )
2071    .map_err(|error| io_store_error(error.kind()))?;
2072    let right = FileIdentityHandle::from_file(
2073        right
2074            .try_clone()
2075            .map_err(|error| io_store_error(error.kind()))?,
2076    )
2077    .map_err(|error| io_store_error(error.kind()))?;
2078    Ok(left == right)
2079}
2080
2081#[cfg(windows)]
2082fn verify_sealed_staging_bytes(
2083    file: &mut File,
2084    record: &ArtifactRecord,
2085) -> Result<(), GithubArtifactStoreError> {
2086    if !is_lower_sha256(&record.sha256) {
2087        return Err(GithubArtifactStoreError::InvalidSealedArchive);
2088    }
2089    verify_windows_private_file_handle(file.as_handle()).map_err(map_private_destination_error)?;
2090    let initial = file
2091        .metadata()
2092        .map_err(|error| io_store_error(error.kind()))?;
2093    if initial.len() != record.size {
2094        return Err(GithubArtifactStoreError::InvalidSealedArchive);
2095    }
2096    file.seek(SeekFrom::Start(0))
2097        .map_err(|error| io_store_error(error.kind()))?;
2098    let mut hasher = Sha256::new();
2099    let mut copied = 0_u64;
2100    let mut buffer = vec![0_u8; COPY_BUFFER_BYTES];
2101    loop {
2102        let read = file
2103            .read(&mut buffer)
2104            .map_err(|error| io_store_error(error.kind()))?;
2105        if read == 0 {
2106            break;
2107        }
2108        copied = copied
2109            .checked_add(u64::try_from(read).unwrap_or(u64::MAX))
2110            .ok_or(GithubArtifactStoreError::InvalidSealedArchive)?;
2111        if copied > record.size {
2112            return Err(GithubArtifactStoreError::InvalidSealedArchive);
2113        }
2114        hasher.update(&buffer[..read]);
2115    }
2116    let final_metadata = file
2117        .metadata()
2118        .map_err(|error| io_store_error(error.kind()))?;
2119    verify_windows_private_file_handle(file.as_handle()).map_err(map_private_destination_error)?;
2120    if copied != record.size
2121        || final_metadata.len() != record.size
2122        || hex::encode(hasher.finalize()) != record.sha256
2123    {
2124        return Err(GithubArtifactStoreError::InvalidSealedArchive);
2125    }
2126    Ok(())
2127}
2128
2129#[cfg(windows)]
2130fn copy_verified_bytes(
2131    source: &Utf8Path,
2132    record: &ArtifactRecord,
2133    output: &mut File,
2134) -> Result<(), GithubArtifactStoreError> {
2135    let mut input = open_regular_file(source)?;
2136    let source_metadata = input
2137        .metadata()
2138        .map_err(|error| io_store_error(error.kind()))?;
2139    if source_metadata.len() != record.size || !is_lower_sha256(&record.sha256) {
2140        return Err(GithubArtifactStoreError::InvalidSealedArchive);
2141    }
2142    let mut hasher = Sha256::new();
2143    let mut copied = 0_u64;
2144    let mut buffer = vec![0_u8; COPY_BUFFER_BYTES];
2145    loop {
2146        let read = input
2147            .read(&mut buffer)
2148            .map_err(|error| io_store_error(error.kind()))?;
2149        if read == 0 {
2150            break;
2151        }
2152        copied = copied
2153            .checked_add(u64::try_from(read).unwrap_or(u64::MAX))
2154            .ok_or(GithubArtifactStoreError::InvalidSealedArchive)?;
2155        if copied > record.size {
2156            return Err(GithubArtifactStoreError::InvalidSealedArchive);
2157        }
2158        output
2159            .write_all(&buffer[..read])
2160            .map_err(|error| io_store_error(error.kind()))?;
2161        hasher.update(&buffer[..read]);
2162    }
2163    output
2164        .flush()
2165        .and_then(|()| output.sync_all())
2166        .map_err(|error| io_store_error(error.kind()))?;
2167    verify_windows_private_file_handle(input.as_handle()).map_err(map_private_cache_error)?;
2168    let final_metadata = input
2169        .metadata()
2170        .map_err(|error| io_store_error(error.kind()))?;
2171    if copied != record.size
2172        || final_metadata.len() != record.size
2173        || hex::encode(hasher.finalize()) != record.sha256
2174    {
2175        return Err(GithubArtifactStoreError::InvalidSealedArchive);
2176    }
2177    Ok(())
2178}
2179
2180#[cfg(any(not(windows), test))]
2181struct ExactPublicationGuard {
2182    destination: Utf8PathBuf,
2183    linked_file: File,
2184    armed: bool,
2185}
2186
2187#[cfg(any(not(windows), test))]
2188impl ExactPublicationGuard {
2189    fn link(
2190        temporary: &Utf8Path,
2191        destination: &Utf8Path,
2192        linked_file: File,
2193    ) -> Result<Self, GithubArtifactStoreError> {
2194        fs::hard_link(temporary, destination).map_err(|error| {
2195            if error.kind() == io::ErrorKind::AlreadyExists {
2196                GithubArtifactStoreError::InvalidDestination
2197            } else {
2198                io_store_error(error.kind())
2199            }
2200        })?;
2201        let guard = Self {
2202            destination: destination.to_owned(),
2203            linked_file,
2204            armed: true,
2205        };
2206        guard.verify_destination()?;
2207        Ok(guard)
2208    }
2209
2210    fn verify_destination(&self) -> Result<(), GithubArtifactStoreError> {
2211        let metadata = fs::symlink_metadata(&self.destination)
2212            .map_err(|error| io_store_error(error.kind()))?;
2213        if metadata.file_type().is_symlink()
2214            || !metadata.is_file()
2215            || !path_matches_open_file(&self.destination, &self.linked_file)?
2216        {
2217            return Err(GithubArtifactStoreError::InvalidDestination);
2218        }
2219        Ok(())
2220    }
2221
2222    fn identity(&self) -> Result<RegularFileFilesystemIdentity, GithubArtifactStoreError> {
2223        let identity = RegularFileFilesystemIdentity::capture(self.destination.as_std_path())
2224            .map_err(map_local_identity_error)?;
2225        if !path_matches_open_file(&self.destination, &self.linked_file)? {
2226            return Err(GithubArtifactStoreError::InvalidDestination);
2227        }
2228        Ok(identity)
2229    }
2230
2231    fn rollback(mut self) -> Result<(), GithubArtifactStoreError> {
2232        remove_exact_published_file(&self.destination, &self.linked_file)?;
2233        self.armed = false;
2234        Ok(())
2235    }
2236
2237    #[cfg(not(windows))]
2238    fn commit(mut self) {
2239        self.armed = false;
2240    }
2241}
2242
2243#[cfg(any(not(windows), test))]
2244impl Drop for ExactPublicationGuard {
2245    fn drop(&mut self) {
2246        if self.armed {
2247            let _ = remove_exact_published_file(&self.destination, &self.linked_file);
2248        }
2249    }
2250}
2251
2252#[cfg(any(not(windows), test))]
2253fn remove_exact_published_file(
2254    destination: &Utf8Path,
2255    linked_file: &File,
2256) -> Result<(), GithubArtifactStoreError> {
2257    let path_metadata = match fs::symlink_metadata(destination) {
2258        Ok(metadata) => metadata,
2259        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
2260        Err(error) => return Err(io_store_error(error.kind())),
2261    };
2262    if path_metadata.file_type().is_symlink()
2263        || !path_metadata.is_file()
2264        || !path_matches_open_file(destination, linked_file)?
2265    {
2266        return Err(GithubArtifactStoreError::InvalidDestination);
2267    }
2268    let final_metadata =
2269        fs::symlink_metadata(destination).map_err(|error| io_store_error(error.kind()))?;
2270    if final_metadata.file_type().is_symlink()
2271        || !final_metadata.is_file()
2272        || !path_matches_open_file(destination, linked_file)?
2273    {
2274        return Err(GithubArtifactStoreError::InvalidDestination);
2275    }
2276    fs::remove_file(destination).map_err(|error| io_store_error(error.kind()))
2277}
2278
2279fn path_matches_open_file(path: &Utf8Path, file: &File) -> Result<bool, GithubArtifactStoreError> {
2280    let open_identity = FileIdentityHandle::from_file(
2281        file.try_clone()
2282            .map_err(|error| io_store_error(error.kind()))?,
2283    )
2284    .map_err(|error| io_store_error(error.kind()))?;
2285    let path_identity =
2286        FileIdentityHandle::from_path(path).map_err(|error| io_store_error(error.kind()))?;
2287    Ok(open_identity == path_identity)
2288}
2289
2290fn map_local_identity_error(error: DirectoryIdentityError) -> GithubArtifactStoreError {
2291    error
2292        .os_code()
2293        .map_or(GithubArtifactStoreError::InvalidDestination, |code| {
2294            io_store_error(io::Error::from_raw_os_error(code).kind())
2295        })
2296}
2297
2298fn validated_destination(destination: &ProtocolPath) -> Result<PathBuf, GithubArtifactStoreError> {
2299    destination
2300        .validate()
2301        .map_err(|_| GithubArtifactStoreError::InvalidDestination)?;
2302    if destination.semantics != ProtocolPathSemantics::ClientAbsolute {
2303        return Err(GithubArtifactStoreError::InvalidDestination);
2304    }
2305    let path = PathBuf::from(&destination.value);
2306    if !path.is_absolute() || !is_normal_path(&path) {
2307        return Err(GithubArtifactStoreError::InvalidDestination);
2308    }
2309    let parent = path
2310        .parent()
2311        .ok_or(GithubArtifactStoreError::InvalidDestination)?;
2312    let parent_metadata =
2313        fs::symlink_metadata(parent).map_err(|error| io_store_error(error.kind()))?;
2314    if parent_metadata.file_type().is_symlink() || !parent_metadata.is_dir() {
2315        return Err(GithubArtifactStoreError::InvalidDestination);
2316    }
2317    let canonical_parent = parent
2318        .canonicalize()
2319        .map_err(|error| io_store_error(error.kind()))?;
2320    if canonical_parent != parent {
2321        return Err(GithubArtifactStoreError::InvalidDestination);
2322    }
2323    let file_name = path
2324        .file_name()
2325        .ok_or(GithubArtifactStoreError::InvalidDestination)?;
2326    if file_name.to_string_lossy().is_empty() {
2327        return Err(GithubArtifactStoreError::InvalidDestination);
2328    }
2329    Ok(canonical_parent.join(file_name))
2330}
2331
2332#[cfg(not(windows))]
2333fn remove_new_file(path: &Utf8Path) -> Result<(), GithubArtifactStoreError> {
2334    fs::remove_file(path).map_err(|error| io_store_error(error.kind()))
2335}
2336
2337fn is_normal_path(path: &Path) -> bool {
2338    path.components()
2339        .all(|component| !matches!(component, Component::CurDir | Component::ParentDir))
2340}
2341
2342fn portable_name_key(name: &str) -> String {
2343    name.nfc().flat_map(char::to_lowercase).collect()
2344}
2345
2346fn is_lower_sha256(value: &str) -> bool {
2347    value.len() == 64
2348        && value
2349            .bytes()
2350            .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
2351}
2352
2353fn is_safe_public_text(value: &str) -> bool {
2354    !value.is_empty()
2355        && value.len() <= 255
2356        && value.trim() == value
2357        && !value.chars().any(char::is_control)
2358}
2359
2360fn sha256_bytes(bytes: &[u8]) -> String {
2361    hex::encode(Sha256::digest(bytes))
2362}
2363
2364fn rfc3339_from_unix(seconds: u64) -> Option<String> {
2365    let days = i64::try_from(seconds / 86_400).ok()?;
2366    let day_seconds = seconds % 86_400;
2367    let (year, month, day) = civil_from_days(days)?;
2368    if !(0..=9_999).contains(&year) {
2369        return None;
2370    }
2371    Some(format!(
2372        "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z",
2373        day_seconds / 3_600,
2374        (day_seconds % 3_600) / 60,
2375        day_seconds % 60
2376    ))
2377}
2378
2379fn civil_from_days(days_since_epoch: i64) -> Option<(i64, u64, u64)> {
2380    let days = days_since_epoch.checked_add(719_468)?;
2381    let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
2382    let day_of_era = days - era * 146_097;
2383    let year_of_era =
2384        (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
2385    let mut year = year_of_era + era * 400;
2386    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
2387    let month_prime = (5 * day_of_year + 2) / 153;
2388    let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
2389    let month = month_prime + if month_prime < 10 { 3 } else { -9 };
2390    year += i64::from(month <= 2);
2391    Some((year, u64::try_from(month).ok()?, u64::try_from(day).ok()?))
2392}
2393
2394fn remote_store_error(error: GithubArtifactStoreError) -> RemoteBuildError {
2395    let retryable = matches!(
2396        error,
2397        GithubArtifactStoreError::Transport(
2398            TransportError::ArtifactNotFound
2399                | TransportError::Execution(crate::transport::GhExecutionError::TimedOut)
2400        )
2401    );
2402    let code = match error {
2403        GithubArtifactStoreError::InvalidCacheRoot => "artifact_cache_invalid",
2404        GithubArtifactStoreError::InvalidContext => "artifact_context_mismatch",
2405        GithubArtifactStoreError::Transport(_) => "artifact_transport_failed",
2406        GithubArtifactStoreError::MissingApiDigest => "artifact_api_digest_missing",
2407        GithubArtifactStoreError::InvalidUnsignedEnvelope => "unsigned_artifact_invalid",
2408        GithubArtifactStoreError::InvalidHandoffJson => "compile_handoff_invalid",
2409        GithubArtifactStoreError::HandoffBindingMismatch => "compile_handoff_mismatch",
2410        GithubArtifactStoreError::InvalidSealedArchive => "sealed_archive_invalid",
2411        GithubArtifactStoreError::UnsignedInspectionFailed => "unsigned_archive_invalid",
2412        GithubArtifactStoreError::FinalArtifact(_) => "signed_artifact_invalid",
2413        GithubArtifactStoreError::ArtifactNotFound => "verified_artifact_not_found",
2414        GithubArtifactStoreError::InvalidDestination => "artifact_destination_invalid",
2415        GithubArtifactStoreError::Io(_) => "artifact_cache_io_failed",
2416        GithubArtifactStoreError::CleanupUncertain => "artifact_cleanup_uncertain",
2417    };
2418    RemoteBuildError::ProviderFailure {
2419        provider: GITHUB_PROVIDER_ID.to_owned(),
2420        code: code.to_owned(),
2421        message: "GitHub artifact verification failed".to_owned(),
2422        retryable,
2423    }
2424}
2425
2426const fn io_store_error(kind: io::ErrorKind) -> GithubArtifactStoreError {
2427    GithubArtifactStoreError::Io(kind)
2428}
2429
2430#[cfg(test)]
2431mod tests {
2432    use std::{
2433        collections::VecDeque,
2434        sync::{Arc, Mutex},
2435        time::Duration,
2436    };
2437
2438    use rustferry_remote::{
2439        BundleIdentifier, CURRENT_PROTOCOL_VERSION, CompileToolchainEvidence, DevelopmentTeam,
2440        DevelopmentTeamPlan, DevicePlan, EntitlementPlan, EntitlementSet, IosArtifactType,
2441        IosDeviceBuildRequest, IosDeviceProductExpectation, ProvisioningPlan,
2442        ProvisioningProfileType, SecretReference, SecretReferenceKind, SigningCertificate,
2443        SigningIdentity, SigningPlan, SigningPrivateKeyReference, SigningReference, SigningTarget,
2444        SigningTargetKind, SourceArchive, SourceManifest, SourceManifestEntry,
2445        UnsignedAppInspection, UnsignedXcarchiveExpectation, UnsignedXcarchiveInspection,
2446    };
2447    use tempfile::TempDir;
2448    use zip::{ZipWriter, write::SimpleFileOptions};
2449
2450    use crate::transport::{
2451        BranchName, CommitSha, GhExecutionError, GhRequest, RunEvent, TransportLimits,
2452    };
2453
2454    use super::*;
2455
2456    const OPERATION_ID: &str = "operation-1";
2457    const SOURCE_REVISION: &str = "0123456789abcdef0123456789abcdef01234567";
2458    const DISPATCH_REVISION: &str = "89abcdef0123456789abcdef0123456789abcdef";
2459    const WORKFLOW_PATH: &str = ".github/workflows/rustferry-goal3-iphone.yml";
2460    const WORKFLOW_PATH_BASE64: &str =
2461        "LmdpdGh1Yi93b3JrZmxvd3MvcnVzdGZlcnJ5LWdvYWwzLWlwaG9uZS55bWw=";
2462    const BRANCH: &str = "rustferry/goal3/builds/operation-1";
2463
2464    #[derive(Default)]
2465    struct FakeRunner {
2466        responses: VecDeque<Result<Vec<u8>, GhExecutionError>>,
2467        requests: Arc<Mutex<Vec<GhRequest>>>,
2468    }
2469
2470    impl FakeRunner {
2471        fn with(responses: impl IntoIterator<Item = Result<Vec<u8>, GhExecutionError>>) -> Self {
2472            Self {
2473                responses: responses.into_iter().collect(),
2474                requests: Arc::default(),
2475            }
2476        }
2477    }
2478
2479    impl GhRunner for FakeRunner {
2480        fn execute(&mut self, request: &GhRequest) -> Result<Vec<u8>, GhExecutionError> {
2481            self.requests.lock().unwrap().push(request.clone());
2482            self.responses
2483                .pop_front()
2484                .unwrap_or(Err(GhExecutionError::ProcessIo))
2485        }
2486    }
2487
2488    #[test]
2489    fn unsigned_envelope_rejects_portable_collisions_and_nested_entries() {
2490        let root = TempDir::new().unwrap();
2491        let duplicate = Utf8PathBuf::from_path_buf(root.path().join("duplicate.zip")).unwrap();
2492        write_outer(
2493            &duplicate,
2494            &[
2495                UNSIGNED_ARCHIVE_NAME,
2496                SEALED_ARCHIVE_REPORT_NAME,
2497                COMPILE_REPORT_NAME,
2498                "COMPILE-REPORT.JSON",
2499            ],
2500        );
2501        assert_eq!(
2502            scan_outer_path(&duplicate),
2503            Err(GithubArtifactStoreError::InvalidUnsignedEnvelope)
2504        );
2505
2506        let nested = Utf8PathBuf::from_path_buf(root.path().join("nested.zip")).unwrap();
2507        write_outer(
2508            &nested,
2509            &[
2510                UNSIGNED_ARCHIVE_NAME,
2511                SEALED_ARCHIVE_REPORT_NAME,
2512                COMPILE_REPORT_NAME,
2513                "wrapper/sanitized-compile-log.txt",
2514            ],
2515        );
2516        assert_eq!(
2517            scan_outer_path(&nested),
2518            Err(GithubArtifactStoreError::InvalidUnsignedEnvelope)
2519        );
2520    }
2521
2522    #[test]
2523    fn compile_handoff_json_rejects_duplicate_keys() {
2524        let duplicate = br#"{
2525            "schema_version":1,
2526            "schema_version":1,
2527            "request":{},
2528            "compile":{}
2529        }"#;
2530        assert!(
2531            strict_json::decode::<CompileHandoff>(
2532                duplicate,
2533                usize::try_from(MAX_HANDOFF_JSON_BYTES).unwrap_or(usize::MAX)
2534            )
2535            .is_err()
2536        );
2537    }
2538
2539    #[test]
2540    fn product_and_request_digest_tampering_break_handoff_binding() {
2541        let request = test_request(SigningMode::UnsignedCompileOnly);
2542        let compile = test_compile(&request);
2543        let context = test_context(request.clone());
2544        let mut product_tamper = compile.sealed_archive.clone();
2545        product_tamper.expectation.app_version = "9.9.9".to_owned();
2546        assert!(!descriptor_matches_request(
2547            &product_tamper,
2548            &compile,
2549            &request
2550        ));
2551
2552        let mut handoff = CompileHandoff {
2553            schema_version: COMPILE_HANDOFF_SCHEMA_VERSION,
2554            request,
2555            compile: compile.clone(),
2556        };
2557        handoff.compile.request_sha256 = "f".repeat(64);
2558        assert_eq!(
2559            validate_compile_handoff(
2560                &context,
2561                &handoff,
2562                &compile.sealed_archive,
2563                Utf8Path::new("/does-not-exist.zip")
2564            ),
2565            Err(GithubArtifactStoreError::HandoffBindingMismatch)
2566        );
2567    }
2568
2569    #[test]
2570    fn api_digest_is_required_before_any_cache_write() {
2571        let root = TempDir::new().unwrap();
2572        let context = test_context(test_request(SigningMode::UnsignedCompileOnly));
2573        let artifact_name = format!(
2574            "{UNSIGNED_ARTIFACT_PREFIX}-{}-{}",
2575            context.run.handle().id().get(),
2576            context.run.run_attempt()
2577        );
2578        let response =
2579            format!("73\t{}\t22\tfalse\t\n", base64(artifact_name.as_bytes())).into_bytes();
2580        let runner = FakeRunner::with([Ok(response)]);
2581        let requests = Arc::clone(&runner.requests);
2582        let transport = GithubTransport::new(runner, TransportLimits::secure_defaults());
2583        let cache_root =
2584            Utf8PathBuf::from_path_buf(root.path().canonicalize().unwrap().join("private-cache"))
2585                .unwrap();
2586        create_private_directory(&cache_root).unwrap();
2587        let mut store = GithubVerifiedArtifactStore::new(transport, &cache_root).unwrap();
2588        let error = store.list_verified(&context).unwrap_err();
2589        assert!(matches!(
2590            error,
2591            RemoteBuildError::ProviderFailure { ref code, .. }
2592                if code == "artifact_api_digest_missing"
2593        ));
2594        assert_eq!(
2595            requests.lock().unwrap()[0].endpoint(),
2596            "/repos/example/build-execution/actions/runs/41/artifacts"
2597        );
2598        assert_eq!(fs::read_dir(cache_root).unwrap().count(), 0);
2599    }
2600
2601    #[test]
2602    fn corrupt_and_transient_verification_leave_cache_root_empty() {
2603        let root = TempDir::new().unwrap();
2604        let cache_root = private_cache_root(&root, "verification-cache");
2605        let context = test_context(test_request(SigningMode::UnsignedCompileOnly));
2606        let corrupt_path = Utf8PathBuf::from_path_buf(root.path().join("corrupt.zip")).unwrap();
2607        write_outer(
2608            &corrupt_path,
2609            &[
2610                UNSIGNED_ARCHIVE_NAME,
2611                SEALED_ARCHIVE_REPORT_NAME,
2612                COMPILE_REPORT_NAME,
2613                SANITIZED_COMPILE_LOG_NAME,
2614            ],
2615        );
2616        let corrupt = fs::read(corrupt_path).unwrap();
2617        let metadata = artifact_metadata_response(&context, &corrupt);
2618
2619        {
2620            let transport = GithubTransport::new(
2621                FakeRunner::with([Ok(metadata.clone()), Ok(corrupt)]),
2622                TransportLimits::secure_defaults(),
2623            );
2624            let mut store = GithubVerifiedArtifactStore::new(transport, &cache_root).unwrap();
2625            let error = store.list_verified(&context).unwrap_err();
2626            assert!(matches!(
2627                error,
2628                RemoteBuildError::ProviderFailure { ref code, .. }
2629                    if code == "compile_handoff_invalid"
2630            ));
2631            assert_eq!(fs::read_dir(&cache_root).unwrap().count(), 0);
2632        }
2633
2634        {
2635            let transport = GithubTransport::new(
2636                FakeRunner::with([Ok(metadata), Err(GhExecutionError::TimedOut)]),
2637                TransportLimits::secure_defaults(),
2638            );
2639            let mut store = GithubVerifiedArtifactStore::new(transport, &cache_root).unwrap();
2640            let error = store.list_verified(&context).unwrap_err();
2641            assert!(matches!(
2642                error,
2643                RemoteBuildError::ProviderFailure {
2644                    ref code,
2645                    retryable: true,
2646                    ..
2647                } if code == "artifact_transport_failed"
2648            ));
2649            assert_eq!(fs::read_dir(&cache_root).unwrap().count(), 0);
2650        }
2651    }
2652
2653    #[test]
2654    fn repeated_fresh_stores_reuse_ephemeral_success_cache() {
2655        let root = TempDir::new().unwrap();
2656        let cache_root = private_cache_root(&root, "success-cache");
2657        let context = test_context(test_request(SigningMode::UnsignedCompileOnly));
2658        let mut names = BTreeSet::new();
2659
2660        for _ in 0..=TEMPORARY_NAME_ATTEMPTS {
2661            let transport =
2662                GithubTransport::new(FakeRunner::default(), TransportLimits::secure_defaults());
2663            let mut store = GithubVerifiedArtifactStore::new(transport, &cache_root).unwrap();
2664            let directory = create_run_directory(store.cache_root(), &context).unwrap();
2665            let path = directory.path().to_owned();
2666            let verified_directory_guard =
2667                create_private_directory(&directory.path().join("verified")).unwrap();
2668            names.insert(path.file_name().unwrap().to_owned());
2669            let manifest = ArtifactManifest::new(OPERATION_ID, OPERATION_ID);
2670            store.verified.insert(
2671                cache_key(&context),
2672                VerifiedRun {
2673                    manifest: manifest.clone(),
2674                    artifacts: BTreeMap::new(),
2675                    evidence: GithubVerifiedRunEvidence::new(test_compile(&context.request), None),
2676                    _verified_directory_guard: verified_directory_guard,
2677                    _cache_directory: directory,
2678                },
2679            );
2680
2681            assert_eq!(store.list_verified(&context).unwrap(), [manifest]);
2682            assert!(path.is_dir());
2683            assert_eq!(fs::read_dir(&cache_root).unwrap().count(), 1);
2684            drop(store);
2685            assert_cache_root_eventually_empty(&cache_root);
2686        }
2687
2688        assert_eq!(names.len(), 1);
2689        assert!(names.iter().next().unwrap().ends_with("-1"));
2690    }
2691
2692    #[test]
2693    fn successful_cache_prunes_staging_and_retains_only_downloads() {
2694        let root = TempDir::new().unwrap();
2695        let cache_root = private_cache_root(&root, "pruning-cache");
2696        let context = test_context(test_request(SigningMode::UnsignedCompileOnly));
2697        let directory = create_run_directory(&cache_root, &context).unwrap();
2698        for name in [
2699            "transport",
2700            "unsigned-xcarchive",
2701            "final-staging",
2702            "verified",
2703        ] {
2704            create_private_directory(&directory.path().join(name)).unwrap();
2705        }
2706        write_private_file(&directory.path().join("transport/outer.zip"), b"outer").unwrap();
2707        write_private_file(
2708            &directory.path().join("unsigned-xcarchive/content"),
2709            b"expanded",
2710        )
2711        .unwrap();
2712        write_private_file(
2713            &directory.path().join("final-staging/temporary"),
2714            b"temporary",
2715        )
2716        .unwrap();
2717        let retained_path = directory.path().join("verified/retained.zip");
2718        let redundant_path = directory.path().join("verified/redundant.zip");
2719        write_private_file(&retained_path, b"retained").unwrap();
2720        write_private_file(&redundant_path, b"redundant").unwrap();
2721        let verified = VerifiedRunContents {
2722            manifest: ArtifactManifest::new(OPERATION_ID, OPERATION_ID),
2723            artifacts: BTreeMap::from([(
2724                "retained".to_owned(),
2725                CachedArtifact {
2726                    record: ArtifactRecord {
2727                        artifact_id: "retained".to_owned(),
2728                        kind: ArtifactKind::Xcarchive,
2729                        file_name: "retained.zip".to_owned(),
2730                        size: 8,
2731                        sha256: sha256_bytes(b"retained"),
2732                        media_type: Some("application/zip".to_owned()),
2733                    },
2734                    path: retained_path.clone(),
2735                },
2736            )]),
2737            evidence: GithubVerifiedRunEvidence::new(test_compile(&context.request), None),
2738        };
2739
2740        prune_verified_run_cache(directory.path(), &verified).unwrap();
2741
2742        assert!(retained_path.is_file());
2743        assert!(!redundant_path.exists());
2744        assert!(!directory.path().join("transport").exists());
2745        assert!(!directory.path().join("unsigned-xcarchive").exists());
2746        assert!(!directory.path().join("final-staging").exists());
2747        drop(directory);
2748        assert_cache_root_eventually_empty(&cache_root);
2749    }
2750
2751    #[cfg(windows)]
2752    #[test]
2753    fn windows_run_cache_cleanup_retries_a_transient_busy_child() {
2754        use std::{fs::OpenOptions, os::windows::fs::OpenOptionsExt as _, sync::Arc};
2755
2756        use windows_sys::Win32::Storage::FileSystem::{FILE_SHARE_READ, FILE_SHARE_WRITE};
2757
2758        let root = TempDir::new().unwrap();
2759        let cache_root = private_cache_root(&root, "busy-cleanup-cache");
2760        let context = test_context(test_request(SigningMode::UnsignedCompileOnly));
2761        let directory = create_run_directory(&cache_root, &context).unwrap();
2762        let busy_path = directory.path().join("busy.txt");
2763        write_private_file(&busy_path, b"busy").unwrap();
2764        let busy = OpenOptions::new()
2765            .read(true)
2766            .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
2767            .open(&busy_path)
2768            .unwrap();
2769        let barrier = Arc::new(std::sync::Barrier::new(2));
2770        let release_barrier = Arc::clone(&barrier);
2771        let release = std::thread::spawn(move || {
2772            release_barrier.wait();
2773            std::thread::sleep(std::time::Duration::from_millis(100));
2774            drop(busy);
2775        });
2776
2777        barrier.wait();
2778        drop(directory);
2779        release.join().unwrap();
2780        assert_cache_root_eventually_empty(&cache_root);
2781    }
2782
2783    #[cfg(windows)]
2784    #[test]
2785    fn windows_cleanup_removes_inherited_nested_tree_below_strict_root() {
2786        let root = TempDir::new().unwrap();
2787        let cache_root = private_cache_root(&root, "nested-cleanup-cache");
2788        let context = test_context(test_request(SigningMode::UnsignedCompileOnly));
2789        let directory = create_run_directory(&cache_root, &context).unwrap();
2790        let extraction = directory.path().join("unsigned-xcarchive");
2791        let extraction_guard = create_private_directory(&extraction).unwrap();
2792        let nested = extraction.join("Products/Applications/App.app");
2793        fs::create_dir_all(&nested).unwrap();
2794        let inherited_file = nested.join("App");
2795        fs::write(&inherited_file, b"ordinary inherited child").unwrap();
2796        assert!(matches!(
2797            open_regular_file(&inherited_file),
2798            Err(GithubArtifactStoreError::InvalidCacheRoot)
2799        ));
2800        drop(extraction_guard);
2801
2802        remove_exact_cache_entry(directory.path(), &extraction).unwrap();
2803        assert!(!extraction.exists());
2804        drop(directory);
2805        assert_cache_root_eventually_empty(&cache_root);
2806    }
2807
2808    #[cfg(windows)]
2809    #[test]
2810    fn windows_cleanup_rejects_nested_reparse_without_touching_target() {
2811        let root = TempDir::new().unwrap();
2812        let cache_root = private_cache_root(&root, "reparse-cleanup-cache");
2813        let context = test_context(test_request(SigningMode::UnsignedCompileOnly));
2814        let directory = create_run_directory(&cache_root, &context).unwrap();
2815        let extraction = directory.path().join("unsigned-xcarchive");
2816        let extraction_guard = create_private_directory(&extraction).unwrap();
2817        let external = canonical_temp_root(&root).join("external.txt");
2818        fs::write(&external, b"preserve").unwrap();
2819        let linked = extraction.join("linked.txt");
2820        match std::os::windows::fs::symlink_file(&external, &linked) {
2821            Ok(()) => {
2822                drop(extraction_guard);
2823                assert_eq!(
2824                    remove_exact_cache_entry(directory.path(), &extraction),
2825                    Err(GithubArtifactStoreError::CleanupUncertain)
2826                );
2827                assert_eq!(fs::read(&external).unwrap(), b"preserve");
2828                fs::remove_file(&linked).unwrap();
2829                remove_exact_cache_entry(directory.path(), &extraction).unwrap();
2830            }
2831            Err(error) if error.raw_os_error() == Some(1314) => drop(extraction_guard),
2832            Err(error) => panic!("create nested test reparse point: {error}"),
2833        }
2834        drop(directory);
2835    }
2836
2837    #[test]
2838    fn artifact_context_binds_source_and_dispatch_revisions_separately() {
2839        let context = test_context(test_request(SigningMode::UnsignedCompileOnly));
2840        assert_eq!(context.repository.name(), "build-execution");
2841        assert_eq!(context.source_repository, "https://github.com/example/app");
2842        assert_ne!(context.source_revision, context.dispatch_revision);
2843        assert_eq!(context.run.handle().head_sha(), &context.dispatch_revision);
2844        assert_eq!(
2845            context.request.source_revision.as_deref(),
2846            Some(context.source_revision.as_str())
2847        );
2848        assert_eq!(validate_context(&context), Ok(()));
2849
2850        let mut wrong_source = context.clone();
2851        wrong_source.source_repository = "https://github.com/example/other".to_owned();
2852        assert_eq!(
2853            validate_context(&wrong_source),
2854            Err(GithubArtifactStoreError::InvalidContext)
2855        );
2856
2857        let mut wrong_dispatch = context;
2858        wrong_dispatch.dispatch_revision = wrong_dispatch.source_revision.clone();
2859        assert_eq!(
2860            validate_context(&wrong_dispatch),
2861            Err(GithubArtifactStoreError::InvalidContext)
2862        );
2863    }
2864
2865    #[test]
2866    fn every_client_copy_rehashes_cache_and_never_clobbers() {
2867        let root = TempDir::new().unwrap();
2868        let root_path = canonical_temp_root(&root);
2869        let source = root_path.join("cached.zip");
2870        write_private_file(&source, b"tampered").unwrap();
2871        let destination = root_path.join("download.zip");
2872        let destination_protocol = ProtocolPath::new(
2873            ProtocolPathSemantics::ClientAbsolute,
2874            destination.as_str().to_owned(),
2875        )
2876        .unwrap();
2877        let record = ArtifactRecord {
2878            artifact_id: UNSIGNED_ARTIFACT_ID.to_owned(),
2879            kind: ArtifactKind::Xcarchive,
2880            file_name: UNSIGNED_ARCHIVE_NAME.to_owned(),
2881            size: 7,
2882            sha256: sha256_bytes(b"trusted"),
2883            media_type: Some("application/zip".to_owned()),
2884        };
2885        assert_eq!(
2886            atomic_verified_copy(&source, &record, &destination_protocol),
2887            Err(GithubArtifactStoreError::InvalidSealedArchive)
2888        );
2889        assert!(!destination.exists());
2890
2891        fs::write(&source, b"trusted").unwrap();
2892        fs::write(&destination, b"keep").unwrap();
2893        assert_eq!(
2894            atomic_verified_copy(&source, &record, &destination_protocol),
2895            Err(GithubArtifactStoreError::InvalidDestination)
2896        );
2897        assert_eq!(fs::read(destination).unwrap(), b"keep");
2898    }
2899
2900    #[test]
2901    fn temporary_unlink_failure_rolls_back_exact_publication() {
2902        let root = TempDir::new().unwrap();
2903        let root_path = canonical_temp_root(&root);
2904        let source = root_path.join("cached.zip");
2905        write_private_file(&source, b"trusted").unwrap();
2906        let destination = root_path.join("download.zip");
2907        let destination_protocol = ProtocolPath::new(
2908            ProtocolPathSemantics::ClientAbsolute,
2909            destination.as_str().to_owned(),
2910        )
2911        .unwrap();
2912        let record = ArtifactRecord {
2913            artifact_id: UNSIGNED_ARTIFACT_ID.to_owned(),
2914            kind: ArtifactKind::Xcarchive,
2915            file_name: UNSIGNED_ARCHIVE_NAME.to_owned(),
2916            size: 7,
2917            sha256: sha256_bytes(b"trusted"),
2918            media_type: Some("application/zip".to_owned()),
2919        };
2920
2921        assert_eq!(
2922            atomic_verified_copy_with_unlink(&source, &record, &destination_protocol, |_| Err(
2923                io_store_error(io::ErrorKind::PermissionDenied)
2924            ),),
2925            Err(GithubArtifactStoreError::Io(
2926                io::ErrorKind::PermissionDenied
2927            ))
2928        );
2929        assert!(!destination.exists());
2930        assert!(source.is_file());
2931        assert_eq!(fs::read_dir(root_path).unwrap().count(), 1);
2932    }
2933
2934    #[test]
2935    fn publication_rollback_preserves_a_replaced_destination() {
2936        let root = TempDir::new().unwrap();
2937        let temporary = Utf8PathBuf::from_path_buf(root.path().join("temporary")).unwrap();
2938        let destination = Utf8PathBuf::from_path_buf(root.path().join("destination")).unwrap();
2939        fs::write(&temporary, b"published").unwrap();
2940        let linked_file = File::open(&temporary).unwrap();
2941        let publication =
2942            ExactPublicationGuard::link(&temporary, &destination, linked_file).unwrap();
2943        fs::remove_file(&destination).unwrap();
2944        fs::write(&destination, b"replacement").unwrap();
2945
2946        assert_eq!(
2947            publication.rollback(),
2948            Err(GithubArtifactStoreError::InvalidDestination)
2949        );
2950        assert_eq!(fs::read(destination).unwrap(), b"replacement");
2951    }
2952
2953    #[test]
2954    fn publication_identity_uses_the_retained_file_after_staging_unlink() {
2955        let root = TempDir::new().unwrap();
2956        let temporary = Utf8PathBuf::from_path_buf(root.path().join("temporary")).unwrap();
2957        let destination = Utf8PathBuf::from_path_buf(root.path().join("destination")).unwrap();
2958        fs::write(&temporary, b"published").unwrap();
2959        let linked_file = File::open(&temporary).unwrap();
2960        let publication =
2961            ExactPublicationGuard::link(&temporary, &destination, linked_file).unwrap();
2962
2963        fs::remove_file(&temporary).unwrap();
2964        assert_eq!(
2965            publication.identity().unwrap(),
2966            RegularFileFilesystemIdentity::capture(destination.as_std_path()).unwrap()
2967        );
2968        publication.rollback().unwrap();
2969        assert!(!destination.exists());
2970    }
2971
2972    #[test]
2973    fn sanitized_log_download_is_verified_and_never_clobbers() {
2974        let root = TempDir::new().unwrap();
2975        let root_path = canonical_temp_root(&root);
2976        let bytes = b"sanitized build output\n";
2977        let source = root_path.join("cached-log.txt");
2978        write_private_file(&source, bytes).unwrap();
2979        let record = ArtifactRecord {
2980            artifact_id: SANITIZED_BUILD_LOG_ID.to_owned(),
2981            kind: ArtifactKind::SanitizedLog,
2982            file_name: SANITIZED_BUILD_LOG_NAME.to_owned(),
2983            size: u64::try_from(bytes.len()).unwrap(),
2984            sha256: sha256_bytes(bytes),
2985            media_type: Some("text/plain; charset=utf-8".to_owned()),
2986        };
2987        let destination = root_path.join(SANITIZED_BUILD_LOG_NAME);
2988        let destination_protocol = ProtocolPath::new(
2989            ProtocolPathSemantics::ClientAbsolute,
2990            destination.as_str().to_owned(),
2991        )
2992        .unwrap();
2993
2994        let local_file_identity = atomic_verified_copy_with_unlink(
2995            &source,
2996            &record,
2997            &destination_protocol,
2998            |published| {
2999                assert_eq!(fs::read(published).unwrap(), bytes);
3000                #[cfg(not(windows))]
3001                remove_new_file(published)?;
3002                Ok(())
3003            },
3004        )
3005        .unwrap();
3006        let retained = File::open(&destination).unwrap();
3007        assert_eq!(
3008            local_file_identity,
3009            regular_file_identity_from_file(&retained).unwrap()
3010        );
3011        assert_eq!(fs::read(&destination).unwrap(), bytes);
3012        assert_eq!(
3013            atomic_verified_copy(&source, &record, &destination_protocol),
3014            Err(GithubArtifactStoreError::InvalidDestination)
3015        );
3016        assert_eq!(fs::read(destination).unwrap(), bytes);
3017    }
3018
3019    #[cfg(windows)]
3020    #[test]
3021    fn windows_private_cache_rejects_inherited_objects_hardlinks_and_reparse_points() {
3022        let root = TempDir::new().unwrap();
3023        let root_path = canonical_temp_root(&root);
3024        let inherited_directory = root_path.join("inherited-directory");
3025        fs::create_dir(&inherited_directory).unwrap();
3026        assert!(matches!(
3027            bind_private_cache_root(inherited_directory.as_std_path()),
3028            Err(GithubArtifactStoreError::InvalidCacheRoot)
3029        ));
3030
3031        let inherited_file = root_path.join("inherited-file");
3032        fs::write(&inherited_file, b"ordinary").unwrap();
3033        assert!(matches!(
3034            open_regular_file(&inherited_file),
3035            Err(GithubArtifactStoreError::InvalidCacheRoot)
3036        ));
3037
3038        let private_file = root_path.join("private-file");
3039        let second_link = root_path.join("second-link");
3040        write_private_file(&private_file, b"private").unwrap();
3041        fs::hard_link(&private_file, &second_link).unwrap();
3042        assert!(matches!(
3043            open_regular_file(&private_file),
3044            Err(GithubArtifactStoreError::InvalidCacheRoot)
3045        ));
3046        fs::remove_file(&second_link).unwrap();
3047
3048        let reparse_path = root_path.join("reparse-file");
3049        match std::os::windows::fs::symlink_file(&private_file, &reparse_path) {
3050            Ok(()) => {
3051                assert!(matches!(
3052                    open_regular_file(&reparse_path),
3053                    Err(GithubArtifactStoreError::InvalidCacheRoot)
3054                ));
3055            }
3056            Err(error) if error.raw_os_error() == Some(1314) => {}
3057            Err(error) => panic!("create test reparse point: {error}"),
3058        }
3059    }
3060
3061    #[cfg(windows)]
3062    #[test]
3063    fn windows_publication_is_private_single_link_and_leaves_no_staging_file() {
3064        let root = TempDir::new().unwrap();
3065        let root_path = canonical_temp_root(&root);
3066        let source = root_path.join("cached.zip");
3067        write_private_file(&source, b"trusted").unwrap();
3068        let destination = root_path.join("download.zip");
3069        let destination_protocol = ProtocolPath::new(
3070            ProtocolPathSemantics::ClientAbsolute,
3071            destination.as_str().to_owned(),
3072        )
3073        .unwrap();
3074        let record = ArtifactRecord {
3075            artifact_id: UNSIGNED_ARTIFACT_ID.to_owned(),
3076            kind: ArtifactKind::Xcarchive,
3077            file_name: UNSIGNED_ARCHIVE_NAME.to_owned(),
3078            size: 7,
3079            sha256: sha256_bytes(b"trusted"),
3080            media_type: Some("application/zip".to_owned()),
3081        };
3082
3083        atomic_verified_copy(&source, &record, &destination_protocol).unwrap();
3084        let published = open_windows_private_file(destination.as_std_path()).unwrap();
3085        verify_windows_private_file_handle(published.as_handle()).unwrap();
3086        assert_eq!(fs::read(&destination).unwrap(), b"trusted");
3087        let names = fs::read_dir(&root_path)
3088            .unwrap()
3089            .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
3090            .collect::<BTreeSet<_>>();
3091        assert_eq!(
3092            names,
3093            BTreeSet::from(["cached.zip".to_owned(), "download.zip".to_owned()])
3094        );
3095    }
3096
3097    #[cfg(windows)]
3098    #[test]
3099    fn windows_publication_rollback_preserves_replacement() {
3100        let root = TempDir::new().unwrap();
3101        let root_path = canonical_temp_root(&root);
3102        let source = root_path.join("cached.zip");
3103        write_private_file(&source, b"trusted").unwrap();
3104        let destination = root_path.join("download.zip");
3105        let displaced = root_path.join("displaced.zip");
3106        let destination_protocol = ProtocolPath::new(
3107            ProtocolPathSemantics::ClientAbsolute,
3108            destination.as_str().to_owned(),
3109        )
3110        .unwrap();
3111        let record = ArtifactRecord {
3112            artifact_id: UNSIGNED_ARTIFACT_ID.to_owned(),
3113            kind: ArtifactKind::Xcarchive,
3114            file_name: UNSIGNED_ARCHIVE_NAME.to_owned(),
3115            size: 7,
3116            sha256: sha256_bytes(b"trusted"),
3117            media_type: Some("application/zip".to_owned()),
3118        };
3119
3120        assert_eq!(
3121            atomic_verified_copy_with_unlink(
3122                &source,
3123                &record,
3124                &destination_protocol,
3125                |published| {
3126                    assert_eq!(fs::read(published).unwrap(), b"trusted");
3127                    fs::rename(published, &displaced)
3128                        .map_err(|error| io_store_error(error.kind()))?;
3129                    fs::write(published, b"replacement")
3130                        .map_err(|error| io_store_error(error.kind()))?;
3131                    Err(io_store_error(io::ErrorKind::PermissionDenied))
3132                },
3133            ),
3134            Err(GithubArtifactStoreError::Io(
3135                io::ErrorKind::PermissionDenied
3136            ))
3137        );
3138        assert_eq!(fs::read(&destination).unwrap(), b"replacement");
3139        assert!(!displaced.exists());
3140        assert_eq!(
3141            fs::read_dir(&root_path)
3142                .unwrap()
3143                .filter_map(Result::ok)
3144                .filter(|entry| entry.file_name().to_string_lossy().contains("rustferry"))
3145                .count(),
3146            0
3147        );
3148    }
3149
3150    #[cfg(windows)]
3151    #[test]
3152    fn windows_stale_private_staging_directory_cannot_publish_partial_final() {
3153        let root = TempDir::new().unwrap();
3154        let root_path = canonical_temp_root(&root);
3155        let source = root_path.join("cached.zip");
3156        write_private_file(&source, b"trusted").unwrap();
3157        let destination = root_path.join("download.zip");
3158        let destination_protocol = ProtocolPath::new(
3159            ProtocolPathSemantics::ClientAbsolute,
3160            destination.as_str().to_owned(),
3161        )
3162        .unwrap();
3163        let record = ArtifactRecord {
3164            artifact_id: UNSIGNED_ARTIFACT_ID.to_owned(),
3165            kind: ArtifactKind::Xcarchive,
3166            file_name: UNSIGNED_ARCHIVE_NAME.to_owned(),
3167            size: 7,
3168            sha256: sha256_bytes(b"trusted"),
3169            media_type: Some("application/zip".to_owned()),
3170        };
3171        let stale_directory_path = root_path.join(format!(
3172            ".rustferry-client-{}-{}.tmp",
3173            std::process::id(),
3174            u64::MAX
3175        ));
3176        let stale_directory =
3177            create_windows_private_directory(stale_directory_path.as_std_path()).unwrap();
3178        let stale_path = stale_directory_path.join("artifact.tmp");
3179        let mut stale_file = create_windows_private_staging_file(stale_path.as_std_path()).unwrap();
3180        stale_file.write_all(b"partial").unwrap();
3181        stale_file.sync_all().unwrap();
3182
3183        assert!(!destination.exists());
3184        atomic_verified_copy(&source, &record, &destination_protocol).unwrap();
3185        assert_eq!(fs::read(&destination).unwrap(), b"trusted");
3186        assert_eq!(fs::read(&stale_path).unwrap(), b"partial");
3187
3188        remove_windows_private_file_handle(stale_file).unwrap();
3189        remove_windows_private_directory_handle(stale_directory).unwrap();
3190    }
3191
3192    #[cfg(windows)]
3193    #[test]
3194    fn windows_post_seal_rehash_rejects_mutation_and_cleans_staging() {
3195        let root = TempDir::new().unwrap();
3196        let root_path = canonical_temp_root(&root);
3197        let (directory, staging_path, mut writer) =
3198            create_windows_client_staging(&root_path).unwrap();
3199        writer.write_all(b"mutated").unwrap();
3200        writer.sync_all().unwrap();
3201        let (directory, mut sealed) = seal_windows_client_staging(directory, writer).unwrap();
3202        let expected = ArtifactRecord {
3203            artifact_id: UNSIGNED_ARTIFACT_ID.to_owned(),
3204            kind: ArtifactKind::Xcarchive,
3205            file_name: UNSIGNED_ARCHIVE_NAME.to_owned(),
3206            size: 7,
3207            sha256: sha256_bytes(b"trusted"),
3208            media_type: Some("application/zip".to_owned()),
3209        };
3210
3211        assert_eq!(
3212            verify_sealed_staging_bytes(&mut sealed, &expected),
3213            Err(GithubArtifactStoreError::InvalidSealedArchive)
3214        );
3215        cleanup_windows_client_staging(directory, sealed).unwrap();
3216        assert!(!staging_path.exists());
3217        assert_eq!(fs::read_dir(&root_path).unwrap().count(), 0);
3218    }
3219
3220    #[cfg(windows)]
3221    #[test]
3222    fn windows_publication_surfaces_uncertain_cleanup() {
3223        let root = TempDir::new().unwrap();
3224        let root_path = canonical_temp_root(&root);
3225        let source = root_path.join("cached.zip");
3226        write_private_file(&source, b"trusted").unwrap();
3227        let destination = root_path.join("download.zip");
3228        let extra_link = root_path.join("extra-link.zip");
3229        let destination_protocol = ProtocolPath::new(
3230            ProtocolPathSemantics::ClientAbsolute,
3231            destination.as_str().to_owned(),
3232        )
3233        .unwrap();
3234        let record = ArtifactRecord {
3235            artifact_id: UNSIGNED_ARTIFACT_ID.to_owned(),
3236            kind: ArtifactKind::Xcarchive,
3237            file_name: UNSIGNED_ARCHIVE_NAME.to_owned(),
3238            size: 7,
3239            sha256: sha256_bytes(b"trusted"),
3240            media_type: Some("application/zip".to_owned()),
3241        };
3242
3243        assert_eq!(
3244            atomic_verified_copy_with_unlink(
3245                &source,
3246                &record,
3247                &destination_protocol,
3248                |temporary| {
3249                    fs::hard_link(temporary, &extra_link)
3250                        .map_err(|error| io_store_error(error.kind()))?;
3251                    Err(io_store_error(io::ErrorKind::PermissionDenied))
3252                },
3253            ),
3254            Err(GithubArtifactStoreError::CleanupUncertain)
3255        );
3256        assert!(destination.exists());
3257        assert!(extra_link.exists());
3258        assert!(
3259            fs::read_dir(&root_path)
3260                .unwrap()
3261                .filter_map(Result::ok)
3262                .any(|entry| entry
3263                    .file_name()
3264                    .to_string_lossy()
3265                    .starts_with(".rustferry-client-"))
3266        );
3267    }
3268
3269    #[test]
3270    fn compile_log_cache_path_leaves_the_signed_log_name_available() {
3271        let root = TempDir::new().unwrap();
3272        let root_path = canonical_temp_root(&root);
3273        let compile_log = compile_log_cache_path(&root_path);
3274
3275        assert_eq!(compile_log.file_name(), Some(SANITIZED_COMPILE_LOG_NAME));
3276        assert_ne!(compile_log, root_path.join(SANITIZED_BUILD_LOG_NAME));
3277    }
3278
3279    #[test]
3280    fn store_catalog_exposes_exact_received_manifest_record() {
3281        let root = TempDir::new().unwrap();
3282        let manifest_bytes = b"{\"schema_version\":1}\n";
3283        let manifest_path =
3284            Utf8PathBuf::from_path_buf(root.path().join(ARTIFACT_MANIFEST_NAME)).unwrap();
3285        fs::write(&manifest_path, manifest_bytes).unwrap();
3286        let log_path =
3287            Utf8PathBuf::from_path_buf(root.path().join(SANITIZED_BUILD_LOG_NAME)).unwrap();
3288        fs::write(&log_path, b"sanitized\n").unwrap();
3289        let manifest_artifact = CachedArtifact {
3290            record: ArtifactRecord {
3291                artifact_id: MANIFEST_ARTIFACT_ID.to_owned(),
3292                kind: ArtifactKind::Manifest,
3293                file_name: ARTIFACT_MANIFEST_NAME.to_owned(),
3294                size: u64::try_from(manifest_bytes.len()).unwrap(),
3295                sha256: sha256_bytes(manifest_bytes),
3296                media_type: Some("application/json".to_owned()),
3297            },
3298            path: manifest_path.clone(),
3299        };
3300        let log_artifact = CachedArtifact {
3301            record: ArtifactRecord {
3302                artifact_id: SANITIZED_BUILD_LOG_ID.to_owned(),
3303                kind: ArtifactKind::SanitizedLog,
3304                file_name: SANITIZED_BUILD_LOG_NAME.to_owned(),
3305                size: 10,
3306                sha256: sha256_bytes(b"sanitized\n"),
3307                media_type: Some("text/plain; charset=utf-8".to_owned()),
3308            },
3309            path: log_path,
3310        };
3311        let mut manifest = ArtifactManifest::new(OPERATION_ID, OPERATION_ID);
3312        let mut artifacts = BTreeMap::new();
3313
3314        augment_store_catalog(
3315            &mut manifest,
3316            &mut artifacts,
3317            [log_artifact, manifest_artifact],
3318        )
3319        .unwrap();
3320
3321        let received = artifacts.get(MANIFEST_ARTIFACT_ID).unwrap();
3322        assert_eq!(received.path, manifest_path);
3323        assert_eq!(
3324            received.record.size,
3325            u64::try_from(manifest_bytes.len()).unwrap()
3326        );
3327        assert_eq!(received.record.sha256, sha256_bytes(manifest_bytes));
3328        assert!(manifest.artifacts.contains(&received.record));
3329    }
3330
3331    fn scan_outer_path(
3332        path: &Utf8Path,
3333    ) -> Result<BTreeMap<UnsignedEnvelopeFile, UnsignedEntryMetadata>, GithubArtifactStoreError>
3334    {
3335        let file = File::open(path).unwrap();
3336        let size = file.metadata().unwrap().len();
3337        let mut archive = ZipArchive::new(file).unwrap();
3338        scan_unsigned_envelope(&mut archive, size)
3339    }
3340
3341    fn write_outer(path: &Utf8Path, names: &[&str]) {
3342        let mut writer = ZipWriter::new(create_private_file(path).unwrap());
3343        let options = SimpleFileOptions::default()
3344            .compression_method(CompressionMethod::Stored)
3345            .unix_permissions(0o600);
3346        for name in names {
3347            writer.start_file(*name, options).unwrap();
3348            writer.write_all(b"x").unwrap();
3349        }
3350        writer.finish().unwrap();
3351    }
3352
3353    fn private_cache_root(root: &TempDir, name: &str) -> Utf8PathBuf {
3354        let cache_root = canonical_temp_root(root).join(name);
3355        create_private_directory(&cache_root).unwrap();
3356        cache_root
3357    }
3358
3359    fn assert_cache_root_eventually_empty(cache_root: &Utf8Path) {
3360        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
3361        loop {
3362            let entries = fs::read_dir(cache_root)
3363                .unwrap()
3364                .collect::<Result<Vec<_>, _>>()
3365                .unwrap();
3366            if entries.is_empty() {
3367                return;
3368            }
3369            assert!(
3370                std::time::Instant::now() < deadline,
3371                "artifact cache entries remained after the store was dropped: {entries:?}"
3372            );
3373            std::thread::sleep(std::time::Duration::from_millis(1));
3374        }
3375    }
3376
3377    fn canonical_temp_root(root: &TempDir) -> Utf8PathBuf {
3378        Utf8PathBuf::from_path_buf(root.path().canonicalize().unwrap()).unwrap()
3379    }
3380
3381    fn artifact_metadata_response(context: &GithubArtifactContext, bytes: &[u8]) -> Vec<u8> {
3382        let name = format!(
3383            "{UNSIGNED_ARTIFACT_PREFIX}-{}-{}",
3384            context.run.handle().id().get(),
3385            context.run.run_attempt()
3386        );
3387        format!(
3388            "73\t{}\t{}\tfalse\tsha256:{}\n",
3389            base64(name.as_bytes()),
3390            bytes.len(),
3391            sha256_bytes(bytes)
3392        )
3393        .into_bytes()
3394    }
3395
3396    fn test_context(request: IosDeviceBuildRequest) -> GithubArtifactContext {
3397        let repository = crate::transport::Repository::new("example", "build-execution").unwrap();
3398        let head = CommitSha::new(DISPATCH_REVISION).unwrap();
3399        let branch = BranchName::new(BRANCH).unwrap();
3400        let branch_base64 = base64(BRANCH.as_bytes());
3401        let row = format!(
3402            "41\t17\t{WORKFLOW_PATH_BASE64}\t9\t2\t{DISPATCH_REVISION}\t{branch_base64}\tpush\tcompleted\tsuccess\n"
3403        )
3404        .into_bytes();
3405        let mut transport = GithubTransport::new(
3406            FakeRunner::with([Ok(row.clone()), Ok(row)]),
3407            TransportLimits::new(
3408                2,
3409                50,
3410                1024 * 1024,
3411                1024 * 1024,
3412                Duration::from_secs(5),
3413                Duration::from_secs(5),
3414            )
3415            .unwrap(),
3416        );
3417        let handle = transport
3418            .find_run(&repository, WORKFLOW_PATH, &head, &branch, RunEvent::Push)
3419            .unwrap();
3420        let run = transport.run(&repository, &handle).unwrap();
3421        GithubArtifactContext {
3422            job_id: OPERATION_ID.to_owned(),
3423            operation_id: OPERATION_ID.to_owned(),
3424            repository,
3425            execution_repository_id: Some(991),
3426            run,
3427            source_repository: "https://github.com/example/app".to_owned(),
3428            source_revision: CommitSha::new(SOURCE_REVISION).unwrap(),
3429            dispatch_revision: head,
3430            request_sha256: canonical_request_sha256(&request).unwrap(),
3431            request,
3432        }
3433    }
3434
3435    fn test_request(mode: SigningMode) -> IosDeviceBuildRequest {
3436        let team = DevelopmentTeam::new("ABCDE12345", None).unwrap();
3437        let secret =
3438            |name| SecretReference::new(SecretReferenceKind::GithubActions, name).expect("secret");
3439        let mut signing = SigningPlan {
3440            mode: SigningMode::ManualDevelopment,
3441            signing: Some(SigningReference {
3442                identity: SigningIdentity {
3443                    certificate: SigningCertificate {
3444                        common_name: "Apple Development".to_owned(),
3445                        sha256_fingerprint: "A".repeat(64),
3446                        team: team.clone(),
3447                        expires_at_unix_seconds: u64::MAX,
3448                    },
3449                    private_key: SigningPrivateKeyReference {
3450                        reference: secret("RUSTFERRY_CERTIFICATE_P12"),
3451                    },
3452                },
3453                password: Some(secret("RUSTFERRY_CERTIFICATE_PASSWORD")),
3454            }),
3455            team: Some(DevelopmentTeamPlan { expected: team }),
3456            device: Some(DevicePlan::new("00008110-001234567890801E", None).unwrap()),
3457            targets: vec![SigningTarget {
3458                name: "App".to_owned(),
3459                bundle_identifier: BundleIdentifier::new("com.example.app").unwrap(),
3460                kind: SigningTargetKind::Application,
3461            }],
3462            provisioning: vec![ProvisioningPlan {
3463                target: "App".to_owned(),
3464                profile: secret("RUSTFERRY_PROVISIONING_PROFILE"),
3465                profile_type: ProvisioningProfileType::Development,
3466            }],
3467            entitlements: vec![EntitlementPlan {
3468                target: "App".to_owned(),
3469                required: EntitlementSet::new(BTreeMap::new()).unwrap(),
3470            }],
3471            allow_provisioning_updates: false,
3472        };
3473        let requested_artifacts = if mode == SigningMode::UnsignedCompileOnly {
3474            signing.mode = mode;
3475            signing.signing = None;
3476            signing.team = None;
3477            signing.device = None;
3478            signing.provisioning.clear();
3479            BTreeSet::from([IosArtifactType::Xcarchive])
3480        } else {
3481            BTreeSet::from([IosArtifactType::Ipa, IosArtifactType::SigningReport])
3482        };
3483        let request = IosDeviceBuildRequest {
3484            protocol_version: CURRENT_PROTOCOL_VERSION,
3485            operation_id: OPERATION_ID.to_owned(),
3486            product_name: "App".to_owned(),
3487            bundle_identifier: "com.example.app".to_owned(),
3488            minimum_ios_version: "17.0".to_owned(),
3489            product: IosDeviceProductExpectation {
3490                app_directory_name: "App.app".to_owned(),
3491                executable: "App".to_owned(),
3492                app_version: "1.0.0".to_owned(),
3493                build_number: "1".to_owned(),
3494                nested_bundles: Vec::new(),
3495            },
3496            profile: BuildProfile::Release,
3497            source_mode: SourceMode::Git,
3498            source_repository: Some("https://github.com/example/app".to_owned()),
3499            source_revision: Some(SOURCE_REVISION.to_owned()),
3500            source: test_source_manifest(),
3501            signing,
3502            requested_artifacts,
3503        };
3504        request.validate().unwrap();
3505        request
3506    }
3507
3508    fn test_compile(request: &IosDeviceBuildRequest) -> CompilePhaseEvidence {
3509        let expectation = UnsignedXcarchiveExpectation {
3510            app_directory_name: request.product.app_directory_name.clone(),
3511            bundle_identifier: request.bundle_identifier.clone(),
3512            executable: request.product.executable.clone(),
3513            app_version: request.product.app_version.clone(),
3514            build_number: request.product.build_number.clone(),
3515            minimum_os: request.minimum_ios_version.clone(),
3516            sdk_version: "26.0".to_owned(),
3517            sdk_build_version: "23A".to_owned(),
3518            nested_bundles: Vec::new(),
3519            required_resources: BTreeMap::new(),
3520        };
3521        CompilePhaseEvidence {
3522            schema_version: COMPILE_PHASE_EVIDENCE_SCHEMA_VERSION,
3523            job_id: OPERATION_ID.to_owned(),
3524            provider: GITHUB_PROVIDER_ID.to_owned(),
3525            request_sha256: canonical_request_sha256(request).unwrap(),
3526            source_sha256: request.source.sha256.clone(),
3527            cargo_lock_sha256: manifest_cargo_lock_sha256(&request.source).unwrap(),
3528            config_sha256: manifest_project_file_sha256(&request.source, "ferry.toml").unwrap(),
3529            rustferry_version: "0.1.0".to_owned(),
3530            worker_version: "0.1.0".to_owned(),
3531            toolchain: CompileToolchainEvidence {
3532                worker_os: "macOS 26.0".to_owned(),
3533                worker_architecture: "arm64".to_owned(),
3534                xcode_version: "26.0".to_owned(),
3535                iphoneos_sdk_version: "26.0".to_owned(),
3536                iphoneos_sdk_build_version: "23A".to_owned(),
3537                developer_directory_sha256: "d".repeat(64),
3538                rust_version: "rustc 1.92.0".to_owned(),
3539                rust_target: IOS_DEVICE_RUST_TARGET.to_owned(),
3540            },
3541            sealed_archive: SealedUnsignedArchive {
3542                schema_version: SEALED_UNSIGNED_ARCHIVE_SCHEMA_VERSION,
3543                transport: SourceArchive {
3544                    size: 1,
3545                    sha256: "e".repeat(64),
3546                },
3547                contents: test_source_manifest(),
3548                expectation,
3549            },
3550            archive_inspection: UnsignedXcarchiveInspection {
3551                application_path: "Applications/App.app".to_owned(),
3552                architectures: vec!["arm64".to_owned()],
3553                app: UnsignedAppInspection {
3554                    app_directory_name: "App.app".to_owned(),
3555                    bundle_identifier: "com.example.app".to_owned(),
3556                    executable: "App".to_owned(),
3557                    main_executable: Vec::new(),
3558                    nested_executables: BTreeMap::new(),
3559                    extensions: Vec::new(),
3560                    resources: BTreeMap::new(),
3561                    entries: Vec::new(),
3562                },
3563                entries: Vec::new(),
3564            },
3565            started_at_unix_seconds: 1_700_000_000,
3566            finished_at_unix_seconds: 1_700_000_060,
3567        }
3568    }
3569
3570    fn test_source_manifest() -> SourceManifest {
3571        let entries = vec![
3572            SourceManifestEntry {
3573                path: "Cargo.lock".to_owned(),
3574                size: 0,
3575                sha256: "a".repeat(64),
3576                executable: false,
3577            },
3578            SourceManifestEntry {
3579                path: "ferry.toml".to_owned(),
3580                size: 0,
3581                sha256: "b".repeat(64),
3582                executable: false,
3583            },
3584        ];
3585        let total_size = 0;
3586        let mut digest = Sha256::new();
3587        digest.update(b"rustferry-source-manifest-v1\0");
3588        digest_string(&mut digest, ".");
3589        digest.update((entries.len() as u64).to_be_bytes());
3590        for entry in &entries {
3591            digest_string(&mut digest, &entry.path);
3592            digest.update(entry.size.to_be_bytes());
3593            digest_string(&mut digest, &entry.sha256);
3594            digest.update([u8::from(entry.executable)]);
3595        }
3596        digest.update(0_u64.to_be_bytes());
3597        SourceManifest {
3598            schema_version: 1,
3599            project_path: ".".to_owned(),
3600            entries,
3601            total_size,
3602            sha256: hex::encode(digest.finalize()),
3603        }
3604    }
3605
3606    fn digest_string(digest: &mut Sha256, value: &str) {
3607        digest.update((value.len() as u64).to_be_bytes());
3608        digest.update(value.as_bytes());
3609    }
3610
3611    fn base64(bytes: &[u8]) -> String {
3612        const TABLE: &[u8; 64] =
3613            b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
3614        let mut output = String::new();
3615        for chunk in bytes.chunks(3) {
3616            let first = chunk[0];
3617            let second = chunk.get(1).copied().unwrap_or(0);
3618            let third = chunk.get(2).copied().unwrap_or(0);
3619            output.push(char::from(TABLE[usize::from(first >> 2)]));
3620            output.push(char::from(
3621                TABLE[usize::from(((first & 0x03) << 4) | (second >> 4))],
3622            ));
3623            if chunk.len() > 1 {
3624                output.push(char::from(
3625                    TABLE[usize::from(((second & 0x0f) << 2) | (third >> 6))],
3626                ));
3627            } else {
3628                output.push('=');
3629            }
3630            if chunk.len() > 2 {
3631                output.push(char::from(TABLE[usize::from(third & 0x3f)]));
3632            } else {
3633                output.push('=');
3634            }
3635        }
3636        output
3637    }
3638}