1use 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
83pub const UNSIGNED_ARCHIVE_NAME: &str = "unsigned-archive.zip";
85pub const SEALED_ARCHIVE_REPORT_NAME: &str = "sealed-archive.json";
87pub const COMPILE_REPORT_NAME: &str = "compile-report.json";
89pub 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
116pub enum GithubArtifactStoreError {
117 InvalidCacheRoot,
119 InvalidContext,
121 Transport(TransportError),
123 MissingApiDigest,
125 InvalidUnsignedEnvelope,
127 InvalidHandoffJson,
129 HandoffBindingMismatch,
131 InvalidSealedArchive,
133 UnsignedInspectionFailed,
135 FinalArtifact(GithubArtifactError),
137 ArtifactNotFound,
139 InvalidDestination,
141 Io(io::ErrorKind),
143 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
276pub 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 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 pub fn cache_root(&self) -> &Utf8Path {
306 &self.cache_root
307 }
308
309 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 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
1265fn 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 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}