1use std::error::Error;
4use std::fmt;
5use std::fs::{self, File, OpenOptions};
6use std::io::{Read as _, Seek as _, Write as _};
7use std::path::{Path, PathBuf};
8use std::str::FromStr as _;
9
10use rustferry_core::{
11 DirectoryFilesystemIdentity, RegularFileFilesystemIdentity, RetainedDirectoryIdentity,
12 RetainedRegularFileIdentity, regular_file_identity_from_file,
13};
14use rustferry_remote::{
15 GIT_SNAPSHOT_ARCHIVE_PATH, GIT_SNAPSHOT_DESCRIPTOR_PATH, GitSnapshotDescriptor,
16 IosDeviceBuildRequest, MAX_GIT_SNAPSHOT_DESCRIPTOR_BYTES, SourceArchive,
17 SourceBundleDescriptor, canonical_git_snapshot_descriptor_bytes, git_snapshot_archive_limits,
18 git_snapshot_ref,
19};
20use serde::{Deserialize, Deserializer, Serialize};
21use sha2::{Digest as _, Sha256};
22
23pub const GIT_SNAPSHOT_STAGE_SCHEMA_VERSION: u32 = 1;
25pub const GIT_SNAPSHOT_STAGE_LOCATOR_SCHEMA_VERSION: u32 = 1;
27pub const GIT_SNAPSHOT_GRAPH_SCHEMA_VERSION: u32 = 1;
29pub const GIT_SNAPSHOT_KEEPALIVE_REF_PREFIX: &str = "refs/rustferry/goal3/keepalive";
31pub const GIT_SNAPSHOT_STAGE_DIRECTORY: &str = "snapshots";
33pub const GIT_SNAPSHOT_STAGE_ARCHIVE_FILE: &str = "source.zip";
35pub const GIT_SNAPSHOT_STAGE_DESCRIPTOR_FILE: &str = "source.json";
37pub const GIT_SNAPSHOT_STAGE_METADATA_FILE: &str = "stage.json";
39pub const MAX_GIT_SNAPSHOT_STAGE_BYTES: u64 = MAX_GIT_SNAPSHOT_DESCRIPTOR_BYTES + 64 * 1024;
41pub const MAX_GIT_SNAPSHOT_DISCOVERY_STAGES: usize = 16;
43
44const SNAPSHOT_ACTOR_NAME: &str = "ShiroKSH";
45const SNAPSHOT_ACTOR_EMAIL: &str = "kushidashiro@gmail.com";
46
47#[derive(Clone, Copy, Debug, Eq, PartialEq)]
49pub enum GitSnapshotError {
50 InvalidOperation,
52 InvalidRef,
54 InvalidObjectId,
56 InvalidTreeEntry,
58 InvalidTimestamp,
60 UnsupportedObjectFormat,
62 InvalidStage,
64 ObjectGraphMismatch,
66 InvalidEncoding,
68 StageTooLarge,
70 StageAlreadyExists,
72 DiscoveryChanged,
74}
75
76impl fmt::Display for GitSnapshotError {
77 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
78 let message = match self {
79 Self::InvalidOperation => "Git snapshot operation is invalid",
80 Self::InvalidRef => "Git snapshot ref is invalid",
81 Self::InvalidObjectId => "Git snapshot object ID is invalid",
82 Self::InvalidTreeEntry => "Git snapshot tree entry is invalid",
83 Self::InvalidTimestamp => "Git snapshot commit timestamp is invalid",
84 Self::UnsupportedObjectFormat => "Git snapshot database does not use SHA-1 object IDs",
85 Self::InvalidStage => "Git snapshot stage is invalid",
86 Self::ObjectGraphMismatch => "Git snapshot object graph does not match",
87 Self::InvalidEncoding => "Git snapshot metadata encoding is invalid",
88 Self::StageTooLarge => "Git snapshot stage metadata exceeds its size limit",
89 Self::StageAlreadyExists => "Git snapshot stage already exists",
90 Self::DiscoveryChanged => "Git snapshot recovery discovery changed during inspection",
91 };
92 formatter.write_str(message)
93 }
94}
95
96impl Error for GitSnapshotError {}
97
98#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
100#[serde(transparent)]
101pub struct GitSnapshotSourceRef(String);
102
103impl GitSnapshotSourceRef {
104 pub fn for_operation(operation_id: &str) -> Result<Self, GitSnapshotError> {
110 git_snapshot_ref(operation_id)
111 .map(Self)
112 .map_err(|_| GitSnapshotError::InvalidOperation)
113 }
114
115 pub fn as_str(&self) -> &str {
117 &self.0
118 }
119}
120
121impl<'de> Deserialize<'de> for GitSnapshotSourceRef {
122 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
123 where
124 D: Deserializer<'de>,
125 {
126 let value = String::deserialize(deserializer)?;
127 let operation_id = value
128 .strip_prefix("refs/rustferry/goal3/snapshots/")
129 .ok_or_else(|| serde::de::Error::custom("invalid Git snapshot source ref"))?;
130 let parsed = Self::for_operation(operation_id)
131 .map_err(|_| serde::de::Error::custom("invalid Git snapshot source ref"))?;
132 if parsed.as_str() != value {
133 return Err(serde::de::Error::custom(
134 "non-canonical Git snapshot source ref",
135 ));
136 }
137 Ok(parsed)
138 }
139}
140
141#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
143#[serde(transparent)]
144pub struct GitSnapshotKeepaliveRef(String);
145
146impl GitSnapshotKeepaliveRef {
147 pub fn for_operation(operation_id: &str) -> Result<Self, GitSnapshotError> {
153 git_snapshot_ref(operation_id).map_err(|_| GitSnapshotError::InvalidOperation)?;
154 Ok(Self(format!(
155 "{GIT_SNAPSHOT_KEEPALIVE_REF_PREFIX}/{operation_id}"
156 )))
157 }
158
159 pub fn as_str(&self) -> &str {
161 &self.0
162 }
163}
164
165impl<'de> Deserialize<'de> for GitSnapshotKeepaliveRef {
166 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
167 where
168 D: Deserializer<'de>,
169 {
170 let value = String::deserialize(deserializer)?;
171 let operation_id = value
172 .strip_prefix(&format!("{GIT_SNAPSHOT_KEEPALIVE_REF_PREFIX}/"))
173 .ok_or_else(|| serde::de::Error::custom("invalid Git snapshot keepalive ref"))?;
174 let parsed = Self::for_operation(operation_id)
175 .map_err(|_| serde::de::Error::custom("invalid Git snapshot keepalive ref"))?;
176 if parsed.as_str() != value {
177 return Err(serde::de::Error::custom(
178 "non-canonical Git snapshot keepalive ref",
179 ));
180 }
181 Ok(parsed)
182 }
183}
184
185#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
187#[serde(transparent)]
188pub struct GitSha1ObjectId(String);
189
190impl GitSha1ObjectId {
191 pub fn new(value: impl Into<String>) -> Result<Self, GitSnapshotError> {
198 let value = value.into();
199 if value.len() != 40
200 || !value
201 .bytes()
202 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
203 {
204 return Err(GitSnapshotError::InvalidObjectId);
205 }
206 Ok(Self(value))
207 }
208
209 pub fn as_str(&self) -> &str {
211 &self.0
212 }
213
214 fn raw(&self) -> Result<[u8; 20], GitSnapshotError> {
215 let decoded = hex::decode(&self.0).map_err(|_| GitSnapshotError::InvalidObjectId)?;
216 decoded
217 .try_into()
218 .map_err(|_| GitSnapshotError::InvalidObjectId)
219 }
220}
221
222impl<'de> Deserialize<'de> for GitSha1ObjectId {
223 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
224 where
225 D: Deserializer<'de>,
226 {
227 Self::new(String::deserialize(deserializer)?)
228 .map_err(|_| serde::de::Error::custom("invalid Git SHA-1 object ID"))
229 }
230}
231
232#[derive(Clone, Copy, Debug, Eq, PartialEq)]
234pub enum GitSnapshotObjectKind {
235 Blob,
237 Tree,
239 Commit,
241}
242
243impl GitSnapshotObjectKind {
244 pub const fn git_type(self) -> &'static str {
246 match self {
247 Self::Blob => "blob",
248 Self::Tree => "tree",
249 Self::Commit => "commit",
250 }
251 }
252}
253
254pub fn git_snapshot_stage_directory(
260 isolation_root: &Path,
261 operation_id: &str,
262) -> Result<PathBuf, GitSnapshotError> {
263 if !isolation_root.is_absolute() {
264 return Err(GitSnapshotError::InvalidStage);
265 }
266 GitSnapshotSourceRef::for_operation(operation_id)?;
267 Ok(isolation_root
268 .join(GIT_SNAPSHOT_STAGE_DIRECTORY)
269 .join(operation_id))
270}
271
272#[derive(Clone, Debug, PartialEq)]
274pub(crate) struct GitSnapshotDiscoveredStageV1 {
275 pub(crate) locator: GitSnapshotStageLocatorV1,
276 pub(crate) stage: GitSnapshotStageV1,
277 pub(crate) descriptor: GitSnapshotDescriptor,
278}
279
280pub(crate) fn discover_complete_git_snapshot_stages(
286 isolation_root: &Path,
287) -> Result<Vec<GitSnapshotDiscoveredStageV1>, GitSnapshotError> {
288 discover_complete_git_snapshot_stages_with_hook(isolation_root, || {})
289}
290
291fn discover_complete_git_snapshot_stages_with_hook(
292 isolation_root: &Path,
293 after_candidate_validation: impl FnOnce(),
294) -> Result<Vec<GitSnapshotDiscoveredStageV1>, GitSnapshotError> {
295 let isolation = GitSnapshotDirectoryGuard::open(isolation_root)?;
296 let snapshots_path = isolation.path().join(GIT_SNAPSHOT_STAGE_DIRECTORY);
297 match fs::symlink_metadata(&snapshots_path) {
298 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
299 isolation.verify()?;
300 return Ok(Vec::new());
301 }
302 Err(_) => return Err(GitSnapshotError::InvalidStage),
303 Ok(_) => {}
304 }
305 let snapshots = GitSnapshotDirectoryGuard::open(&snapshots_path)?;
306 let operations = read_snapshot_operation_entries(&snapshots_path, false)?;
307 isolation.verify()?;
308 snapshots.verify()?;
309 let mut discovered = Vec::with_capacity(operations.len());
310 for operation_id in operations {
311 discovered.push(GitSnapshotStageDirectory::discover_complete(
312 isolation_root,
313 &operation_id,
314 )?);
315 }
316 after_candidate_validation();
317 let final_operations = read_snapshot_operation_entries(&snapshots_path, true)?;
318 let discovered_operations = discovered
319 .iter()
320 .map(|candidate| candidate.stage.operation_id.as_str())
321 .collect::<Vec<_>>();
322 if final_operations
323 .iter()
324 .map(String::as_str)
325 .collect::<Vec<_>>()
326 != discovered_operations
327 {
328 return Err(GitSnapshotError::DiscoveryChanged);
329 }
330 isolation.verify()?;
331 snapshots.verify()?;
332 Ok(discovered)
333}
334
335fn read_snapshot_operation_entries(
336 snapshots_path: &Path,
337 changed_on_overflow: bool,
338) -> Result<Vec<String>, GitSnapshotError> {
339 let mut operations = Vec::new();
340 for entry in fs::read_dir(snapshots_path).map_err(|_| GitSnapshotError::InvalidStage)? {
341 let entry = entry.map_err(|_| GitSnapshotError::InvalidStage)?;
342 let operation_id = entry
343 .file_name()
344 .into_string()
345 .map_err(|_| GitSnapshotError::InvalidStage)?;
346 GitSnapshotSourceRef::for_operation(&operation_id)?;
347 operations.push(operation_id);
348 if operations.len() > MAX_GIT_SNAPSHOT_DISCOVERY_STAGES {
349 return Err(if changed_on_overflow {
350 GitSnapshotError::DiscoveryChanged
351 } else {
352 GitSnapshotError::InvalidStage
353 });
354 }
355 }
356 operations.sort();
357 Ok(operations)
358}
359
360#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
362#[serde(deny_unknown_fields)]
363pub struct GitSnapshotStageLocatorV1 {
364 pub schema_version: u32,
366 pub operation_id: String,
368 pub isolation_root_identity: String,
370 pub snapshots_store_identity: String,
372 pub stage_directory_identity: String,
374 pub metadata_file_identity: String,
376}
377
378impl GitSnapshotStageLocatorV1 {
379 pub fn validate_for_operation(&self, operation_id: &str) -> Result<(), GitSnapshotError> {
385 if self.schema_version != GIT_SNAPSHOT_STAGE_LOCATOR_SCHEMA_VERSION
386 || self.operation_id != operation_id
387 || GitSnapshotSourceRef::for_operation(operation_id).is_err()
388 || !valid_distinct_directory_identities(
389 &self.isolation_root_identity,
390 &self.snapshots_store_identity,
391 &self.stage_directory_identity,
392 )
393 || RegularFileFilesystemIdentity::from_str(&self.metadata_file_identity).is_err()
394 {
395 return Err(GitSnapshotError::InvalidStage);
396 }
397 Ok(())
398 }
399}
400
401#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
403#[serde(deny_unknown_fields)]
404pub struct GitSnapshotObjectGraphV1 {
405 pub schema_version: u32,
407 pub archive_blob: GitSha1ObjectId,
409 pub descriptor_blob: GitSha1ObjectId,
411 pub goal3_tree: GitSha1ObjectId,
413 pub rustferry_tree: GitSha1ObjectId,
415 pub root_tree: GitSha1ObjectId,
417 pub commit: GitSha1ObjectId,
419}
420
421impl GitSnapshotObjectGraphV1 {
422 pub fn validate(&self) -> Result<(), GitSnapshotError> {
428 if self.schema_version != GIT_SNAPSHOT_GRAPH_SCHEMA_VERSION {
429 return Err(GitSnapshotError::InvalidStage);
430 }
431 Ok(())
432 }
433
434 pub fn verify_rehashed<E>(
447 &self,
448 archive_blob: GitSha1ObjectId,
449 descriptor_blob: GitSha1ObjectId,
450 operation_id: &str,
451 created_at_ms: u64,
452 hash_object: impl FnMut(GitSnapshotObjectKind, &[u8]) -> Result<GitSha1ObjectId, E>,
453 ) -> Result<(), E>
454 where
455 E: From<GitSnapshotError>,
456 {
457 if archive_blob != self.archive_blob || descriptor_blob != self.descriptor_blob {
458 return Err(E::from(GitSnapshotError::ObjectGraphMismatch));
459 }
460 let actual = complete_git_snapshot_object_graph(
461 archive_blob,
462 descriptor_blob,
463 operation_id,
464 created_at_ms,
465 hash_object,
466 )?;
467 if actual != *self {
468 return Err(E::from(GitSnapshotError::ObjectGraphMismatch));
469 }
470 Ok(())
471 }
472}
473
474pub fn complete_git_snapshot_object_graph<E>(
483 archive_blob: GitSha1ObjectId,
484 descriptor_blob: GitSha1ObjectId,
485 operation_id: &str,
486 created_at_ms: u64,
487 mut hash_object: impl FnMut(GitSnapshotObjectKind, &[u8]) -> Result<GitSha1ObjectId, E>,
488) -> Result<GitSnapshotObjectGraphV1, E>
489where
490 E: From<GitSnapshotError>,
491{
492 let goal3_bytes =
493 canonical_goal3_tree_bytes(&descriptor_blob, &archive_blob).map_err(E::from)?;
494 let goal3_tree = hash_object(GitSnapshotObjectKind::Tree, &goal3_bytes)?;
495 let rustferry_bytes = canonical_rustferry_tree_bytes(&goal3_tree).map_err(E::from)?;
496 let rustferry_tree = hash_object(GitSnapshotObjectKind::Tree, &rustferry_bytes)?;
497 let root_bytes = canonical_root_tree_bytes(&rustferry_tree).map_err(E::from)?;
498 let root_tree = hash_object(GitSnapshotObjectKind::Tree, &root_bytes)?;
499 let commit_bytes =
500 canonical_parentless_snapshot_commit_bytes(&root_tree, operation_id, created_at_ms)
501 .map_err(E::from)?;
502 let commit = hash_object(GitSnapshotObjectKind::Commit, &commit_bytes)?;
503 Ok(GitSnapshotObjectGraphV1 {
504 schema_version: GIT_SNAPSHOT_GRAPH_SCHEMA_VERSION,
505 archive_blob,
506 descriptor_blob,
507 goal3_tree,
508 rustferry_tree,
509 root_tree,
510 commit,
511 })
512}
513
514#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
516#[serde(deny_unknown_fields)]
517pub struct GitSnapshotStageV1 {
518 pub schema_version: u32,
520 pub operation_id: String,
522 pub isolation_root_identity: String,
524 pub snapshots_store_identity: String,
526 pub stage_directory_identity: String,
528 pub source_repository: String,
530 pub source_ref: GitSnapshotSourceRef,
532 pub keepalive_ref: GitSnapshotKeepaliveRef,
534 pub source_created_at_ms: u64,
536 pub consent_sha256: String,
538 pub request_template_sha256: String,
540 pub manifest_sha256: String,
542 pub archive: SourceArchive,
544 pub descriptor_sha256: String,
546 pub final_request: IosDeviceBuildRequest,
548 pub archive_file_identity: String,
550 pub descriptor_file_identity: String,
552 pub graph: GitSnapshotObjectGraphV1,
554}
555
556impl GitSnapshotStageV1 {
557 pub fn validate_for_descriptor(
564 &self,
565 descriptor: &GitSnapshotDescriptor,
566 ) -> Result<(), GitSnapshotError> {
567 if self.schema_version != GIT_SNAPSHOT_STAGE_SCHEMA_VERSION
568 || self.source_created_at_ms / 1_000 > i64::MAX as u64
569 || !valid_distinct_directory_identities(
570 &self.isolation_root_identity,
571 &self.snapshots_store_identity,
572 &self.stage_directory_identity,
573 )
574 || !is_lower_sha256(&self.consent_sha256)
575 || !is_lower_sha256(&self.request_template_sha256)
576 || !is_lower_sha256(&self.manifest_sha256)
577 || !is_lower_sha256(&self.descriptor_sha256)
578 || self.source_ref != GitSnapshotSourceRef::for_operation(&self.operation_id)?
579 || self.keepalive_ref != GitSnapshotKeepaliveRef::for_operation(&self.operation_id)?
580 || self.source_ref.as_str() != descriptor.snapshot_ref
581 || self.source_repository != descriptor.source_repository
582 || self.request_template_sha256 != descriptor.request_template_sha256
583 || self.manifest_sha256 != descriptor.bundle.manifest.sha256
584 || self.archive != descriptor.bundle.archive
585 || RegularFileFilesystemIdentity::from_str(&self.archive_file_identity).is_err()
586 || RegularFileFilesystemIdentity::from_str(&self.descriptor_file_identity).is_err()
587 || self.archive_file_identity == self.descriptor_file_identity
588 {
589 return Err(GitSnapshotError::InvalidStage);
590 }
591 descriptor
592 .validate(git_snapshot_archive_limits())
593 .map_err(|_| GitSnapshotError::InvalidStage)?;
594 self.graph.validate()?;
595 descriptor
596 .validate_for_request(&self.final_request, git_snapshot_archive_limits())
597 .map_err(|_| GitSnapshotError::InvalidStage)?;
598 if self.final_request.operation_id != self.operation_id
599 || self.final_request.source_revision.as_deref() != Some(self.graph.commit.as_str())
600 {
601 return Err(GitSnapshotError::InvalidStage);
602 }
603 let descriptor_bytes = canonical_git_snapshot_descriptor_bytes(descriptor)
604 .map_err(|_| GitSnapshotError::InvalidStage)?;
605 if hex::encode(Sha256::digest(descriptor_bytes)) != self.descriptor_sha256 {
606 return Err(GitSnapshotError::InvalidStage);
607 }
608 Ok(())
609 }
610
611 pub fn validate_for_request(
618 &self,
619 descriptor: &GitSnapshotDescriptor,
620 request: &IosDeviceBuildRequest,
621 ) -> Result<(), GitSnapshotError> {
622 self.validate_for_descriptor(descriptor)?;
623 descriptor
624 .validate_for_request(request, git_snapshot_archive_limits())
625 .map_err(|_| GitSnapshotError::InvalidStage)?;
626 if request != &self.final_request
627 || request.source_revision.as_deref() != Some(self.graph.commit.as_str())
628 {
629 return Err(GitSnapshotError::InvalidStage);
630 }
631 Ok(())
632 }
633
634 pub fn canonical_bytes(
640 &self,
641 descriptor: &GitSnapshotDescriptor,
642 ) -> Result<Vec<u8>, GitSnapshotError> {
643 self.validate_for_descriptor(descriptor)?;
644 let mut bytes =
645 serde_json::to_vec_pretty(self).map_err(|_| GitSnapshotError::InvalidEncoding)?;
646 bytes.push(b'\n');
647 if bytes.len() as u64 > MAX_GIT_SNAPSHOT_STAGE_BYTES {
648 return Err(GitSnapshotError::StageTooLarge);
649 }
650 Ok(bytes)
651 }
652
653 pub fn decode(
660 bytes: &[u8],
661 descriptor: &GitSnapshotDescriptor,
662 ) -> Result<Self, GitSnapshotError> {
663 if bytes.len() as u64 > MAX_GIT_SNAPSHOT_STAGE_BYTES {
664 return Err(GitSnapshotError::StageTooLarge);
665 }
666 let stage: Self =
667 serde_json::from_slice(bytes).map_err(|_| GitSnapshotError::InvalidEncoding)?;
668 stage.validate_for_descriptor(descriptor)?;
669 if stage.canonical_bytes(descriptor)? != bytes {
670 return Err(GitSnapshotError::InvalidEncoding);
671 }
672 Ok(stage)
673 }
674
675 pub fn verify_staged_files(
686 &self,
687 isolation_root: &Path,
688 ) -> Result<GitSnapshotDescriptor, GitSnapshotError> {
689 let directories = GitSnapshotStageDirectory::open(isolation_root, &self.operation_id)?;
690 directories.require_stage_binding(self)?;
691 let stage_directory = directories.stage_directory.path();
692 if require_stage_entries(
693 stage_directory,
694 &[
695 GIT_SNAPSHOT_STAGE_ARCHIVE_FILE,
696 GIT_SNAPSHOT_STAGE_DESCRIPTOR_FILE,
697 ],
698 )
699 .is_err()
700 && require_stage_entries(
701 stage_directory,
702 &[
703 GIT_SNAPSHOT_STAGE_ARCHIVE_FILE,
704 GIT_SNAPSHOT_STAGE_DESCRIPTOR_FILE,
705 GIT_SNAPSHOT_STAGE_METADATA_FILE,
706 ],
707 )
708 .is_err()
709 {
710 return Err(GitSnapshotError::InvalidStage);
711 }
712 let descriptor_path = stage_directory.join(GIT_SNAPSHOT_STAGE_DESCRIPTOR_FILE);
713 let archive_path = stage_directory.join(GIT_SNAPSHOT_STAGE_ARCHIVE_FILE);
714 let descriptor_identity =
715 RegularFileFilesystemIdentity::from_str(&self.descriptor_file_identity)
716 .map_err(|_| GitSnapshotError::InvalidStage)?;
717 let descriptor_bytes = read_bound_file(
718 &descriptor_path,
719 &descriptor_identity,
720 MAX_GIT_SNAPSHOT_DESCRIPTOR_BYTES,
721 )?;
722 if hex::encode(Sha256::digest(&descriptor_bytes)) != self.descriptor_sha256 {
723 return Err(GitSnapshotError::InvalidStage);
724 }
725 let descriptor: GitSnapshotDescriptor = serde_json::from_slice(&descriptor_bytes)
726 .map_err(|_| GitSnapshotError::InvalidEncoding)?;
727 let canonical = canonical_git_snapshot_descriptor_bytes(&descriptor)
728 .map_err(|_| GitSnapshotError::InvalidStage)?;
729 if canonical != descriptor_bytes {
730 return Err(GitSnapshotError::InvalidEncoding);
731 }
732 self.validate_for_descriptor(&descriptor)?;
733
734 let archive_identity = RegularFileFilesystemIdentity::from_str(&self.archive_file_identity)
735 .map_err(|_| GitSnapshotError::InvalidStage)?;
736 let archive = describe_bound_file(
737 &archive_path,
738 &archive_identity,
739 git_snapshot_archive_limits().max_archive_size,
740 )?;
741 if archive != self.archive {
742 return Err(GitSnapshotError::InvalidStage);
743 }
744 directories.verify_directory_bindings()?;
745 Ok(descriptor)
746 }
747}
748
749pub struct GitSnapshotStageDirectory {
751 operation_id: String,
752 isolation_root: GitSnapshotDirectoryGuard,
753 snapshots_store: GitSnapshotDirectoryGuard,
754 stage_directory: GitSnapshotDirectoryGuard,
755}
756
757#[derive(Clone, Copy, Debug, Eq, PartialEq)]
758enum ExactRetryStageEntries {
759 Empty,
760 Archive,
761 ArchiveAndDescriptor,
762 Complete,
763}
764
765impl fmt::Debug for GitSnapshotStageDirectory {
766 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
767 formatter
768 .debug_struct("GitSnapshotStageDirectory")
769 .field("operation_id", &self.operation_id)
770 .field("isolation_root_identity", self.isolation_root.identity())
771 .field("snapshots_store_identity", self.snapshots_store.identity())
772 .field("stage_directory_identity", self.stage_directory.identity())
773 .finish_non_exhaustive()
774 }
775}
776
777impl GitSnapshotStageDirectory {
778 pub fn create(isolation_root: &Path, operation_id: &str) -> Result<Self, GitSnapshotError> {
788 GitSnapshotSourceRef::for_operation(operation_id)?;
789 let isolation_root = GitSnapshotDirectoryGuard::open(isolation_root)?;
790 let snapshots_store_path = isolation_root.path().join(GIT_SNAPSHOT_STAGE_DIRECTORY);
791 let snapshots_store = match fs::symlink_metadata(&snapshots_store_path) {
792 Ok(_) => GitSnapshotDirectoryGuard::open(&snapshots_store_path)?,
793 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
794 create_private_directory_create_new(&snapshots_store_path, &isolation_root, true)?;
795 GitSnapshotDirectoryGuard::open(&snapshots_store_path)?
796 }
797 Err(_) => return Err(GitSnapshotError::InvalidStage),
798 };
799 let stage_path = snapshots_store_path.join(operation_id);
800 if fs::symlink_metadata(&stage_path).is_ok() {
801 return Err(GitSnapshotError::StageAlreadyExists);
802 }
803 create_private_directory_create_new(&stage_path, &snapshots_store, false)?;
804 let stage_directory = GitSnapshotDirectoryGuard::open(&stage_path)?;
805 require_stage_entries(stage_directory.path(), &[])?;
806 let result = Self {
807 operation_id: operation_id.to_owned(),
808 isolation_root,
809 snapshots_store,
810 stage_directory,
811 };
812 result.verify_directory_bindings()?;
813 Ok(result)
814 }
815
816 pub(crate) fn open_or_create_exact_retry(
822 isolation_root: &Path,
823 operation_id: &str,
824 ) -> Result<Self, GitSnapshotError> {
825 let stage = match Self::create(isolation_root, operation_id) {
826 Ok(stage) => stage,
827 Err(GitSnapshotError::StageAlreadyExists) => Self::open(isolation_root, operation_id)?,
828 Err(error) => return Err(error),
829 };
830 stage.exact_retry_entries()?;
831 Ok(stage)
832 }
833
834 pub fn archive_path(&self) -> PathBuf {
836 self.stage_directory
837 .path()
838 .join(GIT_SNAPSHOT_STAGE_ARCHIVE_FILE)
839 }
840
841 pub fn write_archive_bytes_create_new(
852 &self,
853 bytes: &[u8],
854 expected: &SourceArchive,
855 ) -> Result<RegularFileFilesystemIdentity, GitSnapshotError> {
856 let limits = git_snapshot_archive_limits();
857 if bytes.len() as u64 != expected.size
858 || expected.size > limits.max_archive_size
859 || hex::encode(Sha256::digest(bytes)) != expected.sha256
860 {
861 return Err(GitSnapshotError::InvalidStage);
862 }
863 self.verify_directory_bindings()?;
864 let path = self.archive_path();
865 let identity = write_private_file_create_new(&path, bytes, &self.stage_directory)?;
866 let described = describe_bound_file(&path, &identity, limits.max_archive_size)?;
867 if described != *expected {
868 return Err(GitSnapshotError::InvalidStage);
869 }
870 self.verify_directory_bindings()?;
871 Ok(identity)
872 }
873
874 pub(crate) fn write_or_verify_archive_bytes_exact_retry(
876 &self,
877 bytes: &[u8],
878 expected: &SourceArchive,
879 ) -> Result<RegularFileFilesystemIdentity, GitSnapshotError> {
880 let limits = git_snapshot_archive_limits();
881 if bytes.len() as u64 != expected.size
882 || expected.size > limits.max_archive_size
883 || hex::encode(Sha256::digest(bytes)) != expected.sha256
884 {
885 return Err(GitSnapshotError::InvalidStage);
886 }
887 let entries = self.exact_retry_entries()?;
888 let recover_frontier = match entries {
889 ExactRetryStageEntries::Empty | ExactRetryStageEntries::Archive => true,
890 ExactRetryStageEntries::ArchiveAndDescriptor | ExactRetryStageEntries::Complete => {
891 false
892 }
893 };
894 reconcile_exact_retry_file(
895 &self.stage_directory,
896 GIT_SNAPSHOT_STAGE_ARCHIVE_FILE,
897 bytes,
898 limits.max_archive_size,
899 recover_frontier,
900 )
901 }
902
903 pub fn descriptor_path(&self) -> PathBuf {
905 self.stage_directory
906 .path()
907 .join(GIT_SNAPSHOT_STAGE_DESCRIPTOR_FILE)
908 }
909
910 pub fn isolation_root_identity(&self) -> &str {
912 self.isolation_root.identity().as_str()
913 }
914
915 pub fn snapshots_store_identity(&self) -> &str {
917 self.snapshots_store.identity().as_str()
918 }
919
920 pub fn stage_directory_identity(&self) -> &str {
922 self.stage_directory.identity().as_str()
923 }
924
925 pub fn write_descriptor_create_new(
932 &self,
933 descriptor: &GitSnapshotDescriptor,
934 ) -> Result<RegularFileFilesystemIdentity, GitSnapshotError> {
935 self.verify_directory_bindings()?;
936 let bytes = canonical_git_snapshot_descriptor_bytes(descriptor)
937 .map_err(|_| GitSnapshotError::InvalidStage)?;
938 let path = self.descriptor_path();
939 let identity = write_private_file_create_new(&path, &bytes, &self.stage_directory)?;
940 self.verify_directory_bindings()?;
941 Ok(identity)
942 }
943
944 pub(crate) fn write_or_verify_descriptor_exact_retry(
946 &self,
947 descriptor: &GitSnapshotDescriptor,
948 ) -> Result<RegularFileFilesystemIdentity, GitSnapshotError> {
949 let bytes = canonical_git_snapshot_descriptor_bytes(descriptor)
950 .map_err(|_| GitSnapshotError::InvalidStage)?;
951 let entries = self.exact_retry_entries()?;
952 let recover_frontier = match entries {
953 ExactRetryStageEntries::Archive | ExactRetryStageEntries::ArchiveAndDescriptor => true,
954 ExactRetryStageEntries::Complete => false,
955 ExactRetryStageEntries::Empty => return Err(GitSnapshotError::InvalidStage),
956 };
957 reconcile_exact_retry_file(
958 &self.stage_directory,
959 GIT_SNAPSHOT_STAGE_DESCRIPTOR_FILE,
960 &bytes,
961 MAX_GIT_SNAPSHOT_DESCRIPTOR_BYTES,
962 recover_frontier,
963 )
964 }
965
966 pub fn seal_archive(
977 &self,
978 expected: &SourceArchive,
979 ) -> Result<RegularFileFilesystemIdentity, GitSnapshotError> {
980 self.verify_directory_bindings()?;
981 let path = self.archive_path();
982 let sync_file = OpenOptions::new()
983 .read(true)
984 .write(true)
985 .open(&path)
986 .map_err(|_| GitSnapshotError::InvalidStage)?;
987 let synced_identity = regular_file_identity_from_file(&sync_file)
988 .map_err(|_| GitSnapshotError::InvalidStage)?;
989 sync_file
990 .sync_all()
991 .map_err(|_| GitSnapshotError::InvalidStage)?;
992 drop(sync_file);
993 let retained =
994 RetainedRegularFileIdentity::open(&path).map_err(|_| GitSnapshotError::InvalidStage)?;
995 let identity = retained.identity().clone();
996 if identity != synced_identity {
997 return Err(GitSnapshotError::InvalidStage);
998 }
999 let mut file = File::open(&path).map_err(|_| GitSnapshotError::InvalidStage)?;
1000 if regular_file_identity_from_file(&file).map_err(|_| GitSnapshotError::InvalidStage)?
1001 != identity
1002 {
1003 return Err(GitSnapshotError::InvalidStage);
1004 }
1005 let actual = describe_open_file(&mut file, git_snapshot_archive_limits().max_archive_size)?;
1006 retained
1007 .verify_path(&path)
1008 .map_err(|_| GitSnapshotError::InvalidStage)?;
1009 if &actual != expected {
1010 return Err(GitSnapshotError::InvalidStage);
1011 }
1012 self.stage_directory.sync_metadata()?;
1013 self.verify_directory_bindings()?;
1014 Ok(identity)
1015 }
1016
1017 pub fn precompute_inputs(
1029 &self,
1030 archive_file_identity: &RegularFileFilesystemIdentity,
1031 descriptor_file_identity: &RegularFileFilesystemIdentity,
1032 archive: &SourceArchive,
1033 descriptor: &GitSnapshotDescriptor,
1034 ) -> Result<GitSnapshotPrecomputeInputs, GitSnapshotError> {
1035 self.verify_directory_bindings()?;
1036 require_stage_entries(
1037 self.stage_directory.path(),
1038 &[
1039 GIT_SNAPSHOT_STAGE_ARCHIVE_FILE,
1040 GIT_SNAPSHOT_STAGE_DESCRIPTOR_FILE,
1041 ],
1042 )?;
1043 let directories = Self::open(self.isolation_root.path(), &self.operation_id)?;
1044 if directories.isolation_root_identity() != self.isolation_root_identity()
1045 || directories.snapshots_store_identity() != self.snapshots_store_identity()
1046 || directories.stage_directory_identity() != self.stage_directory_identity()
1047 {
1048 return Err(GitSnapshotError::InvalidStage);
1049 }
1050 GitSnapshotPrecomputeInputs::open(
1051 directories,
1052 archive_file_identity,
1053 descriptor_file_identity,
1054 archive,
1055 descriptor,
1056 )
1057 }
1058
1059 pub fn publish_metadata_create_new(
1070 &self,
1071 stage: &GitSnapshotStageV1,
1072 descriptor: &GitSnapshotDescriptor,
1073 final_request: &IosDeviceBuildRequest,
1074 ) -> Result<GitSnapshotStageLocatorV1, GitSnapshotError> {
1075 self.verify_directory_bindings()?;
1076 self.require_stage_binding(stage)?;
1077 stage.validate_for_request(descriptor, final_request)?;
1078 require_stage_entries(
1079 self.stage_directory.path(),
1080 &[
1081 GIT_SNAPSHOT_STAGE_ARCHIVE_FILE,
1082 GIT_SNAPSHOT_STAGE_DESCRIPTOR_FILE,
1083 ],
1084 )?;
1085 let archive_identity =
1086 RegularFileFilesystemIdentity::from_str(&stage.archive_file_identity)
1087 .map_err(|_| GitSnapshotError::InvalidStage)?;
1088 let descriptor_identity =
1089 RegularFileFilesystemIdentity::from_str(&stage.descriptor_file_identity)
1090 .map_err(|_| GitSnapshotError::InvalidStage)?;
1091 let mut payloads = self.precompute_inputs(
1092 &archive_identity,
1093 &descriptor_identity,
1094 &stage.archive,
1095 descriptor,
1096 )?;
1097 payloads.verify_contents()?;
1098
1099 let bytes = stage.canonical_bytes(descriptor)?;
1100 let metadata_path = self
1101 .stage_directory
1102 .path()
1103 .join(GIT_SNAPSHOT_STAGE_METADATA_FILE);
1104 let metadata_identity =
1105 write_private_file_create_new(&metadata_path, &bytes, &self.stage_directory)?;
1106 require_stage_entries(
1107 self.stage_directory.path(),
1108 &[
1109 GIT_SNAPSHOT_STAGE_ARCHIVE_FILE,
1110 GIT_SNAPSHOT_STAGE_DESCRIPTOR_FILE,
1111 GIT_SNAPSHOT_STAGE_METADATA_FILE,
1112 ],
1113 )?;
1114 payloads.verify_payload_files()?;
1115 self.verify_directory_bindings()?;
1116 let locator = GitSnapshotStageLocatorV1 {
1117 schema_version: GIT_SNAPSHOT_STAGE_LOCATOR_SCHEMA_VERSION,
1118 operation_id: self.operation_id.clone(),
1119 isolation_root_identity: self.isolation_root_identity().to_owned(),
1120 snapshots_store_identity: self.snapshots_store_identity().to_owned(),
1121 stage_directory_identity: self.stage_directory_identity().to_owned(),
1122 metadata_file_identity: metadata_identity.to_string(),
1123 };
1124 locator.validate_for_operation(&self.operation_id)?;
1125 Ok(locator)
1126 }
1127
1128 pub(crate) fn publish_or_verify_metadata_exact_retry(
1130 &self,
1131 stage: &GitSnapshotStageV1,
1132 descriptor: &GitSnapshotDescriptor,
1133 final_request: &IosDeviceBuildRequest,
1134 ) -> Result<GitSnapshotStageLocatorV1, GitSnapshotError> {
1135 self.verify_directory_bindings()?;
1136 self.require_stage_binding(stage)?;
1137 stage.validate_for_request(descriptor, final_request)?;
1138 match self.exact_retry_entries()? {
1139 ExactRetryStageEntries::ArchiveAndDescriptor => {
1140 return self.publish_metadata_create_new(stage, descriptor, final_request);
1141 }
1142 ExactRetryStageEntries::Complete => {}
1143 ExactRetryStageEntries::Empty | ExactRetryStageEntries::Archive => {
1144 return Err(GitSnapshotError::InvalidStage);
1145 }
1146 }
1147 let expected = stage.canonical_bytes(descriptor)?;
1148 match reconcile_exact_retry_file(
1149 &self.stage_directory,
1150 GIT_SNAPSHOT_STAGE_METADATA_FILE,
1151 &expected,
1152 MAX_GIT_SNAPSHOT_STAGE_BYTES,
1153 true,
1154 ) {
1155 Ok(metadata_identity) => {
1156 let locator = GitSnapshotStageLocatorV1 {
1157 schema_version: GIT_SNAPSHOT_STAGE_LOCATOR_SCHEMA_VERSION,
1158 operation_id: self.operation_id.clone(),
1159 isolation_root_identity: self.isolation_root_identity().to_owned(),
1160 snapshots_store_identity: self.snapshots_store_identity().to_owned(),
1161 stage_directory_identity: self.stage_directory_identity().to_owned(),
1162 metadata_file_identity: metadata_identity.to_string(),
1163 };
1164 let inputs = GitSnapshotImportInputs::load(
1165 self.isolation_root.path(),
1166 &locator,
1167 final_request,
1168 )?;
1169 if inputs.stage() != stage || inputs.descriptor() != descriptor {
1170 return Err(GitSnapshotError::InvalidStage);
1171 }
1172 drop(inputs);
1173 self.verify_directory_bindings()?;
1174 require_stage_entries(
1175 self.stage_directory.path(),
1176 &[
1177 GIT_SNAPSHOT_STAGE_ARCHIVE_FILE,
1178 GIT_SNAPSHOT_STAGE_DESCRIPTOR_FILE,
1179 GIT_SNAPSHOT_STAGE_METADATA_FILE,
1180 ],
1181 )?;
1182 Ok(locator)
1183 }
1184 Err(error) => Err(error),
1185 }
1186 }
1187
1188 fn open(isolation_root: &Path, operation_id: &str) -> Result<Self, GitSnapshotError> {
1189 GitSnapshotSourceRef::for_operation(operation_id)?;
1190 let isolation_root = GitSnapshotDirectoryGuard::open(isolation_root)?;
1191 let snapshots_store = GitSnapshotDirectoryGuard::open(
1192 &isolation_root.path().join(GIT_SNAPSHOT_STAGE_DIRECTORY),
1193 )?;
1194 let stage_directory =
1195 GitSnapshotDirectoryGuard::open(&snapshots_store.path().join(operation_id))?;
1196 let result = Self {
1197 operation_id: operation_id.to_owned(),
1198 isolation_root,
1199 snapshots_store,
1200 stage_directory,
1201 };
1202 result.verify_directory_bindings()?;
1203 Ok(result)
1204 }
1205
1206 fn discover_complete(
1207 isolation_root: &Path,
1208 operation_id: &str,
1209 ) -> Result<GitSnapshotDiscoveredStageV1, GitSnapshotError> {
1210 let directories = Self::open(isolation_root, operation_id)?;
1211 require_stage_entries(
1212 directories.stage_directory.path(),
1213 &[
1214 GIT_SNAPSHOT_STAGE_ARCHIVE_FILE,
1215 GIT_SNAPSHOT_STAGE_DESCRIPTOR_FILE,
1216 GIT_SNAPSHOT_STAGE_METADATA_FILE,
1217 ],
1218 )?;
1219 let metadata_path = directories
1220 .stage_directory
1221 .path()
1222 .join(GIT_SNAPSHOT_STAGE_METADATA_FILE);
1223 let (metadata_guard, mut metadata_file) = open_unbound_file(&metadata_path)?;
1224 let metadata_bytes = read_open_file(&mut metadata_file, MAX_GIT_SNAPSHOT_STAGE_BYTES)?;
1225 metadata_guard
1226 .verify_path(&metadata_path)
1227 .map_err(|_| GitSnapshotError::InvalidStage)?;
1228 let unverified_stage: GitSnapshotStageV1 = serde_json::from_slice(&metadata_bytes)
1229 .map_err(|_| GitSnapshotError::InvalidEncoding)?;
1230 directories.require_stage_binding(&unverified_stage)?;
1231 let descriptor_identity =
1232 RegularFileFilesystemIdentity::from_str(&unverified_stage.descriptor_file_identity)
1233 .map_err(|_| GitSnapshotError::InvalidStage)?;
1234 let descriptor_bytes = read_bound_file(
1235 &directories.descriptor_path(),
1236 &descriptor_identity,
1237 MAX_GIT_SNAPSHOT_DESCRIPTOR_BYTES,
1238 )?;
1239 let descriptor: GitSnapshotDescriptor = serde_json::from_slice(&descriptor_bytes)
1240 .map_err(|_| GitSnapshotError::InvalidEncoding)?;
1241 if canonical_git_snapshot_descriptor_bytes(&descriptor)
1242 .map_err(|_| GitSnapshotError::InvalidStage)?
1243 != descriptor_bytes
1244 {
1245 return Err(GitSnapshotError::InvalidEncoding);
1246 }
1247 let stage = GitSnapshotStageV1::decode(&metadata_bytes, &descriptor)?;
1248 if stage != unverified_stage
1249 || metadata_guard.identity().as_str() == stage.archive_file_identity
1250 || metadata_guard.identity().as_str() == stage.descriptor_file_identity
1251 {
1252 return Err(GitSnapshotError::InvalidStage);
1253 }
1254 let locator = GitSnapshotStageLocatorV1 {
1255 schema_version: GIT_SNAPSHOT_STAGE_LOCATOR_SCHEMA_VERSION,
1256 operation_id: operation_id.to_owned(),
1257 isolation_root_identity: directories.isolation_root_identity().to_owned(),
1258 snapshots_store_identity: directories.snapshots_store_identity().to_owned(),
1259 stage_directory_identity: directories.stage_directory_identity().to_owned(),
1260 metadata_file_identity: metadata_guard.identity().to_string(),
1261 };
1262 locator.validate_for_operation(operation_id)?;
1263 directories.verify_directory_bindings()?;
1264 metadata_guard
1265 .verify_path(&metadata_path)
1266 .map_err(|_| GitSnapshotError::InvalidStage)?;
1267 drop(metadata_file);
1268 drop(metadata_guard);
1269 drop(directories);
1270 let inputs = GitSnapshotImportInputs::load(isolation_root, &locator, &stage.final_request)?;
1271 if inputs.stage() != &stage || inputs.descriptor() != &descriptor {
1272 return Err(GitSnapshotError::InvalidStage);
1273 }
1274 drop(inputs);
1275 Ok(GitSnapshotDiscoveredStageV1 {
1276 locator,
1277 stage,
1278 descriptor,
1279 })
1280 }
1281
1282 fn exact_retry_entries(&self) -> Result<ExactRetryStageEntries, GitSnapshotError> {
1283 self.verify_directory_bindings()?;
1284 let entries = if require_stage_entries(self.stage_directory.path(), &[]).is_ok() {
1285 ExactRetryStageEntries::Empty
1286 } else if require_stage_entries(
1287 self.stage_directory.path(),
1288 &[GIT_SNAPSHOT_STAGE_ARCHIVE_FILE],
1289 )
1290 .is_ok()
1291 {
1292 ExactRetryStageEntries::Archive
1293 } else if require_stage_entries(
1294 self.stage_directory.path(),
1295 &[
1296 GIT_SNAPSHOT_STAGE_ARCHIVE_FILE,
1297 GIT_SNAPSHOT_STAGE_DESCRIPTOR_FILE,
1298 ],
1299 )
1300 .is_ok()
1301 {
1302 ExactRetryStageEntries::ArchiveAndDescriptor
1303 } else if require_stage_entries(
1304 self.stage_directory.path(),
1305 &[
1306 GIT_SNAPSHOT_STAGE_ARCHIVE_FILE,
1307 GIT_SNAPSHOT_STAGE_DESCRIPTOR_FILE,
1308 GIT_SNAPSHOT_STAGE_METADATA_FILE,
1309 ],
1310 )
1311 .is_ok()
1312 {
1313 ExactRetryStageEntries::Complete
1314 } else {
1315 return Err(GitSnapshotError::InvalidStage);
1316 };
1317 self.verify_directory_bindings()?;
1318 Ok(entries)
1319 }
1320
1321 fn verify_directory_bindings(&self) -> Result<(), GitSnapshotError> {
1322 self.isolation_root.verify()?;
1323 self.snapshots_store.verify()?;
1324 self.stage_directory.verify()?;
1325 if self.snapshots_store.path()
1326 != self
1327 .isolation_root
1328 .path()
1329 .join(GIT_SNAPSHOT_STAGE_DIRECTORY)
1330 || self.stage_directory.path() != self.snapshots_store.path().join(&self.operation_id)
1331 {
1332 return Err(GitSnapshotError::InvalidStage);
1333 }
1334 Ok(())
1335 }
1336
1337 fn require_stage_binding(&self, stage: &GitSnapshotStageV1) -> Result<(), GitSnapshotError> {
1338 if stage.operation_id != self.operation_id
1339 || stage.isolation_root_identity != self.isolation_root_identity()
1340 || stage.snapshots_store_identity != self.snapshots_store_identity()
1341 || stage.stage_directory_identity != self.stage_directory_identity()
1342 {
1343 return Err(GitSnapshotError::InvalidStage);
1344 }
1345 Ok(())
1346 }
1347}
1348
1349pub struct GitSnapshotPrecomputeInputs {
1351 descriptor_bytes: Vec<u8>,
1352 expected_archive: SourceArchive,
1353 directories: GitSnapshotStageDirectory,
1354 descriptor_guard: RetainedRegularFileIdentity,
1355 descriptor_file: File,
1356 archive_guard: RetainedRegularFileIdentity,
1357 archive_file: File,
1358}
1359
1360impl fmt::Debug for GitSnapshotPrecomputeInputs {
1361 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1362 formatter
1363 .debug_struct("GitSnapshotPrecomputeInputs")
1364 .field("operation_id", &self.directories.operation_id)
1365 .field("expected_archive", &self.expected_archive)
1366 .finish_non_exhaustive()
1367 }
1368}
1369
1370impl GitSnapshotPrecomputeInputs {
1371 pub const fn expected_archive(&self) -> &SourceArchive {
1373 &self.expected_archive
1374 }
1375
1376 pub fn descriptor_bytes(&self) -> &[u8] {
1378 &self.descriptor_bytes
1379 }
1380
1381 pub fn archive_reader(&mut self) -> Result<&mut File, GitSnapshotError> {
1388 self.verify_contents()?;
1389 self.archive_file
1390 .seek(std::io::SeekFrom::Start(0))
1391 .map_err(|_| GitSnapshotError::InvalidStage)?;
1392 Ok(&mut self.archive_file)
1393 }
1394
1395 pub fn verify_contents(&mut self) -> Result<(), GitSnapshotError> {
1405 self.directories.verify_directory_bindings()?;
1406 require_stage_entries(
1407 self.directories.stage_directory.path(),
1408 &[
1409 GIT_SNAPSHOT_STAGE_ARCHIVE_FILE,
1410 GIT_SNAPSHOT_STAGE_DESCRIPTOR_FILE,
1411 ],
1412 )?;
1413 self.verify_payload_files()?;
1414 self.directories.verify_directory_bindings()
1415 }
1416
1417 fn open(
1418 directories: GitSnapshotStageDirectory,
1419 archive_file_identity: &RegularFileFilesystemIdentity,
1420 descriptor_file_identity: &RegularFileFilesystemIdentity,
1421 expected_archive: &SourceArchive,
1422 descriptor: &GitSnapshotDescriptor,
1423 ) -> Result<Self, GitSnapshotError> {
1424 if archive_file_identity == descriptor_file_identity {
1425 return Err(GitSnapshotError::InvalidStage);
1426 }
1427 descriptor
1428 .validate(git_snapshot_archive_limits())
1429 .map_err(|_| GitSnapshotError::InvalidStage)?;
1430 if descriptor.bundle.archive != *expected_archive {
1431 return Err(GitSnapshotError::InvalidStage);
1432 }
1433 let expected_descriptor = canonical_git_snapshot_descriptor_bytes(descriptor)
1434 .map_err(|_| GitSnapshotError::InvalidStage)?;
1435 let descriptor_path = directories.descriptor_path();
1436 let (descriptor_guard, mut descriptor_file) =
1437 open_bound_file(&descriptor_path, descriptor_file_identity)?;
1438 let descriptor_bytes =
1439 read_open_file(&mut descriptor_file, MAX_GIT_SNAPSHOT_DESCRIPTOR_BYTES)?;
1440 descriptor_guard
1441 .verify_path(&descriptor_path)
1442 .map_err(|_| GitSnapshotError::InvalidStage)?;
1443 if descriptor_bytes != expected_descriptor {
1444 return Err(GitSnapshotError::InvalidStage);
1445 }
1446
1447 let archive_path = directories.archive_path();
1448 let (archive_guard, mut archive_file) =
1449 open_bound_file(&archive_path, archive_file_identity)?;
1450 let archive = describe_open_file(
1451 &mut archive_file,
1452 git_snapshot_archive_limits().max_archive_size,
1453 )?;
1454 archive_guard
1455 .verify_path(&archive_path)
1456 .map_err(|_| GitSnapshotError::InvalidStage)?;
1457 if archive != *expected_archive {
1458 return Err(GitSnapshotError::InvalidStage);
1459 }
1460 let mut result = Self {
1461 descriptor_bytes,
1462 expected_archive: expected_archive.clone(),
1463 directories,
1464 descriptor_guard,
1465 descriptor_file,
1466 archive_guard,
1467 archive_file,
1468 };
1469 result.verify_contents()?;
1470 Ok(result)
1471 }
1472
1473 fn verify_payload_files(&mut self) -> Result<(), GitSnapshotError> {
1474 let descriptor_path = self.directories.descriptor_path();
1475 if regular_file_identity_from_file(&self.descriptor_file)
1476 .map_err(|_| GitSnapshotError::InvalidStage)?
1477 != *self.descriptor_guard.identity()
1478 {
1479 return Err(GitSnapshotError::InvalidStage);
1480 }
1481 let actual_descriptor =
1482 read_open_file(&mut self.descriptor_file, MAX_GIT_SNAPSHOT_DESCRIPTOR_BYTES)?;
1483 self.descriptor_guard
1484 .verify_path(&descriptor_path)
1485 .map_err(|_| GitSnapshotError::InvalidStage)?;
1486 if actual_descriptor != self.descriptor_bytes {
1487 return Err(GitSnapshotError::InvalidStage);
1488 }
1489
1490 let archive_path = self.directories.archive_path();
1491 if regular_file_identity_from_file(&self.archive_file)
1492 .map_err(|_| GitSnapshotError::InvalidStage)?
1493 != *self.archive_guard.identity()
1494 {
1495 return Err(GitSnapshotError::InvalidStage);
1496 }
1497 let actual_archive = describe_open_file(
1498 &mut self.archive_file,
1499 git_snapshot_archive_limits().max_archive_size,
1500 )?;
1501 self.archive_file
1502 .seek(std::io::SeekFrom::Start(0))
1503 .map_err(|_| GitSnapshotError::InvalidStage)?;
1504 self.archive_guard
1505 .verify_path(&archive_path)
1506 .map_err(|_| GitSnapshotError::InvalidStage)?;
1507 if actual_archive != self.expected_archive {
1508 return Err(GitSnapshotError::InvalidStage);
1509 }
1510 Ok(())
1511 }
1512}
1513
1514pub struct GitSnapshotImportInputs {
1520 locator: GitSnapshotStageLocatorV1,
1521 stage: GitSnapshotStageV1,
1522 descriptor: GitSnapshotDescriptor,
1523 descriptor_bytes: Vec<u8>,
1524 directories: GitSnapshotStageDirectory,
1525 metadata_guard: RetainedRegularFileIdentity,
1526 metadata_file: File,
1527 descriptor_guard: RetainedRegularFileIdentity,
1528 descriptor_file: File,
1529 archive_guard: RetainedRegularFileIdentity,
1530 archive_file: File,
1531}
1532
1533struct GitSnapshotDirectoryGuard {
1534 path: PathBuf,
1535 retained: RetainedDirectoryIdentity,
1536 #[cfg(windows)]
1537 _private: File,
1538}
1539
1540impl GitSnapshotDirectoryGuard {
1541 fn open(path: &Path) -> Result<Self, GitSnapshotError> {
1542 if !path.is_absolute() {
1543 return Err(GitSnapshotError::InvalidStage);
1544 }
1545 let retained =
1546 RetainedDirectoryIdentity::open(path).map_err(|_| GitSnapshotError::InvalidStage)?;
1547 #[cfg(windows)]
1548 let private =
1549 rustferry_core::windows_private_directory::open_private_directory_read_guard(path)
1550 .map_err(|_| GitSnapshotError::InvalidStage)?;
1551 #[cfg(windows)]
1552 if rustferry_core::directory_identity_from_file(&private)
1553 .map_err(|_| GitSnapshotError::InvalidStage)?
1554 != *retained.identity()
1555 {
1556 return Err(GitSnapshotError::InvalidStage);
1557 }
1558 #[cfg(unix)]
1559 {
1560 use std::os::unix::fs::MetadataExt as _;
1561
1562 let metadata = retained
1563 .as_file()
1564 .metadata()
1565 .map_err(|_| GitSnapshotError::InvalidStage)?;
1566 if metadata.mode() & 0o077 != 0 {
1567 return Err(GitSnapshotError::InvalidStage);
1568 }
1569 }
1570 let result = Self {
1571 path: path.to_owned(),
1572 retained,
1573 #[cfg(windows)]
1574 _private: private,
1575 };
1576 result.verify()?;
1577 Ok(result)
1578 }
1579
1580 fn path(&self) -> &Path {
1581 &self.path
1582 }
1583
1584 fn identity(&self) -> &DirectoryFilesystemIdentity {
1585 self.retained.identity()
1586 }
1587
1588 fn verify(&self) -> Result<(), GitSnapshotError> {
1589 self.retained
1590 .verify_path(&self.path)
1591 .map_err(|_| GitSnapshotError::InvalidStage)
1592 }
1593
1594 fn sync_metadata(&self) -> Result<(), GitSnapshotError> {
1595 self.verify()?;
1596 #[cfg(windows)]
1597 self.retained
1598 .sync_metadata(&self.path)
1599 .map_err(|_| GitSnapshotError::InvalidStage)?;
1600 #[cfg(unix)]
1601 self.retained
1602 .as_file()
1603 .sync_all()
1604 .map_err(|_| GitSnapshotError::InvalidStage)?;
1605 self.verify()
1606 }
1607}
1608
1609impl fmt::Debug for GitSnapshotImportInputs {
1610 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1611 formatter
1612 .debug_struct("GitSnapshotImportInputs")
1613 .field("locator", &self.locator)
1614 .field("stage", &self.stage)
1615 .field("descriptor", &self.descriptor)
1616 .finish_non_exhaustive()
1617 }
1618}
1619
1620impl GitSnapshotImportInputs {
1621 pub(crate) fn adopt_without_locator(
1633 isolation_root: &Path,
1634 final_request: &IosDeviceBuildRequest,
1635 ) -> Result<(GitSnapshotStageLocatorV1, Self), GitSnapshotError> {
1636 let directories =
1637 GitSnapshotStageDirectory::open(isolation_root, &final_request.operation_id)?;
1638 let metadata_path = directories
1639 .stage_directory
1640 .path()
1641 .join(GIT_SNAPSHOT_STAGE_METADATA_FILE);
1642 let (metadata_guard, metadata_file) = open_unbound_file(&metadata_path)?;
1643 let locator = GitSnapshotStageLocatorV1 {
1644 schema_version: GIT_SNAPSHOT_STAGE_LOCATOR_SCHEMA_VERSION,
1645 operation_id: final_request.operation_id.clone(),
1646 isolation_root_identity: directories.isolation_root_identity().to_owned(),
1647 snapshots_store_identity: directories.snapshots_store_identity().to_owned(),
1648 stage_directory_identity: directories.stage_directory_identity().to_owned(),
1649 metadata_file_identity: metadata_guard.identity().to_string(),
1650 };
1651 locator.validate_for_operation(&final_request.operation_id)?;
1652 let inputs = Self::open_retained(
1653 directories,
1654 locator.clone(),
1655 metadata_guard,
1656 metadata_file,
1657 final_request,
1658 )?;
1659 Ok((locator, inputs))
1660 }
1661
1662 pub fn delete_stage_exact(
1675 isolation_root: &Path,
1676 locator: &GitSnapshotStageLocatorV1,
1677 expected_stage: &GitSnapshotStageV1,
1678 final_request: &IosDeviceBuildRequest,
1679 ) -> Result<(), GitSnapshotError> {
1680 locator.validate_for_operation(&final_request.operation_id)?;
1681 let descriptor = GitSnapshotDescriptor::from_request(
1682 final_request,
1683 SourceBundleDescriptor::new(
1684 expected_stage.archive.clone(),
1685 final_request.source.clone(),
1686 ),
1687 )
1688 .map_err(|_| GitSnapshotError::InvalidStage)?;
1689 expected_stage.validate_for_request(&descriptor, final_request)?;
1690 if locator.operation_id != expected_stage.operation_id
1691 || locator.isolation_root_identity != expected_stage.isolation_root_identity
1692 || locator.snapshots_store_identity != expected_stage.snapshots_store_identity
1693 || locator.stage_directory_identity != expected_stage.stage_directory_identity
1694 {
1695 return Err(GitSnapshotError::InvalidStage);
1696 }
1697
1698 let isolation_root_guard = GitSnapshotDirectoryGuard::open(isolation_root)?;
1699 let snapshots_store = GitSnapshotDirectoryGuard::open(
1700 &isolation_root_guard
1701 .path()
1702 .join(GIT_SNAPSHOT_STAGE_DIRECTORY),
1703 )?;
1704 if isolation_root_guard.identity().as_str() != locator.isolation_root_identity
1705 || snapshots_store.identity().as_str() != locator.snapshots_store_identity
1706 {
1707 return Err(GitSnapshotError::InvalidStage);
1708 }
1709 let stage_path = snapshots_store.path().join(&locator.operation_id);
1710 match fs::symlink_metadata(&stage_path) {
1711 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1712 snapshots_store.sync_metadata()?;
1713 isolation_root_guard.verify()?;
1714 snapshots_store.verify()?;
1715 return Ok(());
1716 }
1717 Err(_) => return Err(GitSnapshotError::InvalidStage),
1718 Ok(_) => {}
1719 }
1720 let stage_directory = GitSnapshotDirectoryGuard::open(&stage_path)?;
1721 if stage_directory.identity().as_str() != locator.stage_directory_identity {
1722 return Err(GitSnapshotError::InvalidStage);
1723 }
1724 let expected_files = [
1725 (
1726 GIT_SNAPSHOT_STAGE_ARCHIVE_FILE,
1727 &expected_stage.archive_file_identity,
1728 ),
1729 (
1730 GIT_SNAPSHOT_STAGE_DESCRIPTOR_FILE,
1731 &expected_stage.descriptor_file_identity,
1732 ),
1733 (
1734 GIT_SNAPSHOT_STAGE_METADATA_FILE,
1735 &locator.metadata_file_identity,
1736 ),
1737 ];
1738 require_stage_entry_subset(
1739 &stage_path,
1740 &[
1741 GIT_SNAPSHOT_STAGE_ARCHIVE_FILE,
1742 GIT_SNAPSHOT_STAGE_DESCRIPTOR_FILE,
1743 GIT_SNAPSHOT_STAGE_METADATA_FILE,
1744 ],
1745 )?;
1746 for (name, encoded_identity) in &expected_files {
1747 validate_exact_stage_file_if_present(&stage_directory, name, encoded_identity)?;
1748 }
1749 for (name, encoded_identity) in expected_files {
1750 remove_exact_stage_file_if_present(&stage_directory, name, encoded_identity)?;
1751 }
1752 require_stage_entries(&stage_path, &[])?;
1753 remove_exact_empty_stage_directory(stage_directory)?;
1754 snapshots_store.sync_metadata()?;
1755 require_confirmed_absent(&stage_path)?;
1756 isolation_root_guard.verify()?;
1757 snapshots_store.verify()
1758 }
1759
1760 pub fn load(
1767 isolation_root: &Path,
1768 locator: &GitSnapshotStageLocatorV1,
1769 final_request: &IosDeviceBuildRequest,
1770 ) -> Result<Self, GitSnapshotError> {
1771 locator.validate_for_operation(&final_request.operation_id)?;
1772 let directories = GitSnapshotStageDirectory::open(isolation_root, &locator.operation_id)?;
1773 if locator.isolation_root_identity != directories.isolation_root_identity()
1774 || locator.snapshots_store_identity != directories.snapshots_store_identity()
1775 || locator.stage_directory_identity != directories.stage_directory_identity()
1776 {
1777 return Err(GitSnapshotError::InvalidStage);
1778 }
1779 let metadata_identity =
1780 RegularFileFilesystemIdentity::from_str(&locator.metadata_file_identity)
1781 .map_err(|_| GitSnapshotError::InvalidStage)?;
1782 let metadata_path = directories
1783 .stage_directory
1784 .path()
1785 .join(GIT_SNAPSHOT_STAGE_METADATA_FILE);
1786 let (metadata_guard, metadata_file) = open_bound_file(&metadata_path, &metadata_identity)?;
1787 Self::open_retained(
1788 directories,
1789 locator.clone(),
1790 metadata_guard,
1791 metadata_file,
1792 final_request,
1793 )
1794 }
1795
1796 pub const fn locator(&self) -> &GitSnapshotStageLocatorV1 {
1798 &self.locator
1799 }
1800
1801 pub const fn stage(&self) -> &GitSnapshotStageV1 {
1803 &self.stage
1804 }
1805
1806 pub const fn descriptor(&self) -> &GitSnapshotDescriptor {
1808 &self.descriptor
1809 }
1810
1811 pub fn descriptor_bytes(&self) -> &[u8] {
1813 &self.descriptor_bytes
1814 }
1815
1816 pub fn archive_reader(&mut self) -> Result<&mut File, GitSnapshotError> {
1823 self.verify_bindings()?;
1824 self.archive_file
1825 .seek(std::io::SeekFrom::Start(0))
1826 .map_err(|_| GitSnapshotError::InvalidStage)?;
1827 Ok(&mut self.archive_file)
1828 }
1829
1830 pub fn verify_imported_archive_blob(
1840 &mut self,
1841 actual: &GitSha1ObjectId,
1842 ) -> Result<(), GitSnapshotError> {
1843 if actual != &self.stage.graph.archive_blob {
1844 return Err(GitSnapshotError::ObjectGraphMismatch);
1845 }
1846 self.verify_bindings()
1847 }
1848
1849 pub fn verify_imported_descriptor_blob(
1859 &mut self,
1860 actual: &GitSha1ObjectId,
1861 ) -> Result<(), GitSnapshotError> {
1862 if actual != &self.stage.graph.descriptor_blob {
1863 return Err(GitSnapshotError::ObjectGraphMismatch);
1864 }
1865 self.verify_bindings()
1866 }
1867
1868 pub fn verify_bindings(&mut self) -> Result<(), GitSnapshotError> {
1876 self.directories.verify_directory_bindings()?;
1877 self.directories.require_stage_binding(&self.stage)?;
1878 require_stage_entries(
1879 self.directories.stage_directory.path(),
1880 &[
1881 GIT_SNAPSHOT_STAGE_ARCHIVE_FILE,
1882 GIT_SNAPSHOT_STAGE_DESCRIPTOR_FILE,
1883 GIT_SNAPSHOT_STAGE_METADATA_FILE,
1884 ],
1885 )?;
1886 let metadata_path = self
1887 .directories
1888 .stage_directory
1889 .path()
1890 .join(GIT_SNAPSHOT_STAGE_METADATA_FILE);
1891 let descriptor_path = self.directories.descriptor_path();
1892 let archive_path = self.directories.archive_path();
1893 verify_open_bound_file(&self.metadata_guard, &self.metadata_file, &metadata_path)?;
1894 verify_open_bound_file(
1895 &self.descriptor_guard,
1896 &self.descriptor_file,
1897 &descriptor_path,
1898 )?;
1899 verify_open_bound_file(&self.archive_guard, &self.archive_file, &archive_path)?;
1900
1901 let metadata_bytes = read_open_file(&mut self.metadata_file, MAX_GIT_SNAPSHOT_STAGE_BYTES)?;
1902 if metadata_bytes != self.stage.canonical_bytes(&self.descriptor)? {
1903 return Err(GitSnapshotError::InvalidStage);
1904 }
1905 let descriptor_bytes =
1906 read_open_file(&mut self.descriptor_file, MAX_GIT_SNAPSHOT_DESCRIPTOR_BYTES)?;
1907 if descriptor_bytes != self.descriptor_bytes {
1908 return Err(GitSnapshotError::InvalidStage);
1909 }
1910 let archive = describe_open_file(
1911 &mut self.archive_file,
1912 git_snapshot_archive_limits().max_archive_size,
1913 )?;
1914 self.archive_file
1915 .seek(std::io::SeekFrom::Start(0))
1916 .map_err(|_| GitSnapshotError::InvalidStage)?;
1917 if archive != self.stage.archive {
1918 return Err(GitSnapshotError::InvalidStage);
1919 }
1920 verify_open_bound_file(&self.metadata_guard, &self.metadata_file, &metadata_path)?;
1921 verify_open_bound_file(
1922 &self.descriptor_guard,
1923 &self.descriptor_file,
1924 &descriptor_path,
1925 )?;
1926 verify_open_bound_file(&self.archive_guard, &self.archive_file, &archive_path)?;
1927 require_stage_entries(
1928 self.directories.stage_directory.path(),
1929 &[
1930 GIT_SNAPSHOT_STAGE_ARCHIVE_FILE,
1931 GIT_SNAPSHOT_STAGE_DESCRIPTOR_FILE,
1932 GIT_SNAPSHOT_STAGE_METADATA_FILE,
1933 ],
1934 )?;
1935 self.directories.verify_directory_bindings()
1936 }
1937
1938 fn open_retained(
1939 directories: GitSnapshotStageDirectory,
1940 locator: GitSnapshotStageLocatorV1,
1941 metadata_guard: RetainedRegularFileIdentity,
1942 mut metadata_file: File,
1943 final_request: &IosDeviceBuildRequest,
1944 ) -> Result<Self, GitSnapshotError> {
1945 let metadata_path = directories
1946 .stage_directory
1947 .path()
1948 .join(GIT_SNAPSHOT_STAGE_METADATA_FILE);
1949 let metadata_bytes = read_open_file(&mut metadata_file, MAX_GIT_SNAPSHOT_STAGE_BYTES)?;
1950 metadata_guard
1951 .verify_path(&metadata_path)
1952 .map_err(|_| GitSnapshotError::InvalidStage)?;
1953 let stage: GitSnapshotStageV1 = serde_json::from_slice(&metadata_bytes)
1954 .map_err(|_| GitSnapshotError::InvalidEncoding)?;
1955 directories.require_stage_binding(&stage)?;
1956 if locator.operation_id != stage.operation_id
1957 || locator.isolation_root_identity != stage.isolation_root_identity
1958 || locator.snapshots_store_identity != stage.snapshots_store_identity
1959 || locator.stage_directory_identity != stage.stage_directory_identity
1960 || locator.metadata_file_identity != metadata_guard.identity().as_str()
1961 {
1962 return Err(GitSnapshotError::InvalidStage);
1963 }
1964
1965 let descriptor_identity =
1966 RegularFileFilesystemIdentity::from_str(&stage.descriptor_file_identity)
1967 .map_err(|_| GitSnapshotError::InvalidStage)?;
1968 let descriptor_path = directories.descriptor_path();
1969 let (descriptor_guard, mut descriptor_file) =
1970 open_bound_file(&descriptor_path, &descriptor_identity)?;
1971 let descriptor_bytes =
1972 read_open_file(&mut descriptor_file, MAX_GIT_SNAPSHOT_DESCRIPTOR_BYTES)?;
1973 descriptor_guard
1974 .verify_path(&descriptor_path)
1975 .map_err(|_| GitSnapshotError::InvalidStage)?;
1976 let descriptor: GitSnapshotDescriptor = serde_json::from_slice(&descriptor_bytes)
1977 .map_err(|_| GitSnapshotError::InvalidEncoding)?;
1978 if canonical_git_snapshot_descriptor_bytes(&descriptor)
1979 .map_err(|_| GitSnapshotError::InvalidStage)?
1980 != descriptor_bytes
1981 || stage.canonical_bytes(&descriptor)? != metadata_bytes
1982 {
1983 return Err(GitSnapshotError::InvalidEncoding);
1984 }
1985 stage.validate_for_request(&descriptor, final_request)?;
1986
1987 let archive_identity =
1988 RegularFileFilesystemIdentity::from_str(&stage.archive_file_identity)
1989 .map_err(|_| GitSnapshotError::InvalidStage)?;
1990 let archive_path = directories.archive_path();
1991 let (archive_guard, mut archive_file) = open_bound_file(&archive_path, &archive_identity)?;
1992 let archive = describe_open_file(
1993 &mut archive_file,
1994 git_snapshot_archive_limits().max_archive_size,
1995 )?;
1996 archive_file
1997 .seek(std::io::SeekFrom::Start(0))
1998 .map_err(|_| GitSnapshotError::InvalidStage)?;
1999 archive_guard
2000 .verify_path(&archive_path)
2001 .map_err(|_| GitSnapshotError::InvalidStage)?;
2002 if archive != stage.archive
2003 || locator.metadata_file_identity == stage.archive_file_identity
2004 || locator.metadata_file_identity == stage.descriptor_file_identity
2005 {
2006 return Err(GitSnapshotError::InvalidStage);
2007 }
2008 let mut inputs = Self {
2009 locator,
2010 stage,
2011 descriptor,
2012 descriptor_bytes,
2013 directories,
2014 metadata_guard,
2015 metadata_file,
2016 descriptor_guard,
2017 descriptor_file,
2018 archive_guard,
2019 archive_file,
2020 };
2021 inputs.verify_bindings()?;
2022 Ok(inputs)
2023 }
2024}
2025
2026pub fn canonical_goal3_tree_bytes(
2033 descriptor_blob: &GitSha1ObjectId,
2034 archive_blob: &GitSha1ObjectId,
2035) -> Result<Vec<u8>, GitSnapshotError> {
2036 let descriptor_name = GIT_SNAPSHOT_DESCRIPTOR_PATH
2037 .rsplit_once('/')
2038 .map(|(_, name)| name)
2039 .ok_or(GitSnapshotError::InvalidTreeEntry)?;
2040 let archive_name = GIT_SNAPSHOT_ARCHIVE_PATH
2041 .rsplit_once('/')
2042 .map(|(_, name)| name)
2043 .ok_or(GitSnapshotError::InvalidTreeEntry)?;
2044 let mut entries = [
2045 (descriptor_name, descriptor_blob),
2046 (archive_name, archive_blob),
2047 ];
2048 entries.sort_by(|left, right| left.0.as_bytes().cmp(right.0.as_bytes()));
2049 let mut tree = Vec::with_capacity(128);
2050 for (name, object) in entries {
2051 append_tree_entry(&mut tree, "100644", name, object)?;
2052 }
2053 Ok(tree)
2054}
2055
2056pub fn canonical_rustferry_tree_bytes(
2062 goal3_tree: &GitSha1ObjectId,
2063) -> Result<Vec<u8>, GitSnapshotError> {
2064 let mut tree = Vec::with_capacity(40);
2065 append_tree_entry(&mut tree, "40000", "goal3", goal3_tree)?;
2066 Ok(tree)
2067}
2068
2069pub fn canonical_root_tree_bytes(
2075 rustferry_tree: &GitSha1ObjectId,
2076) -> Result<Vec<u8>, GitSnapshotError> {
2077 let mut tree = Vec::with_capacity(48);
2078 append_tree_entry(&mut tree, "40000", ".rustferry", rustferry_tree)?;
2079 Ok(tree)
2080}
2081
2082pub fn canonical_parentless_snapshot_commit_bytes(
2088 root_tree: &GitSha1ObjectId,
2089 operation_id: &str,
2090 created_at_ms: u64,
2091) -> Result<Vec<u8>, GitSnapshotError> {
2092 git_snapshot_ref(operation_id).map_err(|_| GitSnapshotError::InvalidOperation)?;
2093 let seconds = created_at_ms / 1_000;
2094 if seconds > i64::MAX as u64 {
2095 return Err(GitSnapshotError::InvalidTimestamp);
2096 }
2097 Ok(format!(
2098 "tree {}\nauthor {SNAPSHOT_ACTOR_NAME} <{SNAPSHOT_ACTOR_EMAIL}> {seconds} +0000\ncommitter {SNAPSHOT_ACTOR_NAME} <{SNAPSHOT_ACTOR_EMAIL}> {seconds} +0000\n\nRustFerry Git snapshot {operation_id}\n",
2099 root_tree.as_str()
2100 )
2101 .into_bytes())
2102}
2103
2104pub fn require_sha1_object_format(output: &[u8]) -> Result<(), GitSnapshotError> {
2113 if output == b"sha1\n" {
2114 Ok(())
2115 } else {
2116 Err(GitSnapshotError::UnsupportedObjectFormat)
2117 }
2118}
2119
2120fn append_tree_entry(
2121 output: &mut Vec<u8>,
2122 mode: &str,
2123 name: &str,
2124 object: &GitSha1ObjectId,
2125) -> Result<(), GitSnapshotError> {
2126 if !matches!(mode, "100644" | "40000")
2127 || name.is_empty()
2128 || name.len() > 255
2129 || name.as_bytes().contains(&b'/')
2130 || name.as_bytes().contains(&0)
2131 || matches!(name, "." | "..")
2132 {
2133 return Err(GitSnapshotError::InvalidTreeEntry);
2134 }
2135 output.extend_from_slice(mode.as_bytes());
2136 output.push(b' ');
2137 output.extend_from_slice(name.as_bytes());
2138 output.push(0);
2139 output.extend_from_slice(&object.raw()?);
2140 Ok(())
2141}
2142
2143fn is_lower_sha256(value: &str) -> bool {
2144 value.len() == 64
2145 && value
2146 .bytes()
2147 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
2148}
2149
2150fn valid_distinct_directory_identities(root: &str, store: &str, stage: &str) -> bool {
2151 DirectoryFilesystemIdentity::from_str(root).is_ok()
2152 && DirectoryFilesystemIdentity::from_str(store).is_ok()
2153 && DirectoryFilesystemIdentity::from_str(stage).is_ok()
2154 && root != store
2155 && root != stage
2156 && store != stage
2157}
2158
2159fn create_private_directory_create_new(
2160 path: &Path,
2161 parent: &GitSnapshotDirectoryGuard,
2162 allow_existing_race: bool,
2163) -> Result<(), GitSnapshotError> {
2164 parent.verify()?;
2165 #[cfg(windows)]
2166 {
2167 use std::os::windows::io::AsHandle as _;
2168
2169 match rustferry_core::windows_private_directory::create_private_directory(path) {
2170 Ok(directory) => {
2171 rustferry_core::windows_private_directory::sync_private_directory_handle(
2172 directory.as_handle(),
2173 )
2174 .map_err(|_| GitSnapshotError::InvalidStage)?;
2175 }
2176 Err(error)
2177 if allow_existing_race
2178 && error.kind()
2179 == rustferry_core::windows_private_directory::PrivateDirectoryErrorKind::AlreadyExists =>
2180 {
2181 parent.verify()?;
2182 return Ok(());
2183 }
2184 Err(error)
2185 if error.kind()
2186 == rustferry_core::windows_private_directory::PrivateDirectoryErrorKind::AlreadyExists =>
2187 {
2188 return Err(GitSnapshotError::StageAlreadyExists);
2189 }
2190 Err(_) => return Err(GitSnapshotError::InvalidStage),
2191 }
2192 }
2193 #[cfg(unix)]
2194 {
2195 use std::os::unix::fs::DirBuilderExt as _;
2196
2197 let mut builder = fs::DirBuilder::new();
2198 builder.mode(0o700);
2199 match builder.create(path) {
2200 Ok(()) => File::open(path)
2201 .and_then(|directory| directory.sync_all())
2202 .map_err(|_| GitSnapshotError::InvalidStage)?,
2203 Err(error)
2204 if allow_existing_race && error.kind() == std::io::ErrorKind::AlreadyExists =>
2205 {
2206 parent.verify()?;
2207 return Ok(());
2208 }
2209 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
2210 return Err(GitSnapshotError::StageAlreadyExists);
2211 }
2212 Err(_) => return Err(GitSnapshotError::InvalidStage),
2213 }
2214 }
2215 #[cfg(not(any(windows, unix)))]
2216 {
2217 let _ = allow_existing_race;
2218 fs::create_dir(path).map_err(|error| {
2219 if error.kind() == std::io::ErrorKind::AlreadyExists {
2220 GitSnapshotError::StageAlreadyExists
2221 } else {
2222 GitSnapshotError::InvalidStage
2223 }
2224 })?;
2225 File::open(path)
2226 .and_then(|directory| directory.sync_all())
2227 .map_err(|_| GitSnapshotError::InvalidStage)?;
2228 }
2229 parent.sync_metadata()
2230}
2231
2232fn write_private_file_create_new(
2233 path: &Path,
2234 bytes: &[u8],
2235 parent: &GitSnapshotDirectoryGuard,
2236) -> Result<RegularFileFilesystemIdentity, GitSnapshotError> {
2237 parent.verify()?;
2238 #[cfg(windows)]
2239 let mut file = rustferry_core::windows_private_directory::create_private_file(path).map_err(
2240 |error| {
2241 if error.kind()
2242 == rustferry_core::windows_private_directory::PrivateDirectoryErrorKind::AlreadyExists
2243 {
2244 GitSnapshotError::StageAlreadyExists
2245 } else {
2246 GitSnapshotError::InvalidStage
2247 }
2248 },
2249 )?;
2250 #[cfg(unix)]
2251 let mut file = {
2252 use std::os::unix::fs::OpenOptionsExt as _;
2253
2254 let mut options = OpenOptions::new();
2255 options.write(true).create_new(true).mode(0o600);
2256 options.open(path).map_err(|error| {
2257 if error.kind() == std::io::ErrorKind::AlreadyExists {
2258 GitSnapshotError::StageAlreadyExists
2259 } else {
2260 GitSnapshotError::InvalidStage
2261 }
2262 })?
2263 };
2264 #[cfg(not(any(windows, unix)))]
2265 let mut file = OpenOptions::new()
2266 .write(true)
2267 .create_new(true)
2268 .open(path)
2269 .map_err(|error| {
2270 if error.kind() == std::io::ErrorKind::AlreadyExists {
2271 GitSnapshotError::StageAlreadyExists
2272 } else {
2273 GitSnapshotError::InvalidStage
2274 }
2275 })?;
2276 file.write_all(bytes)
2277 .and_then(|()| file.sync_all())
2278 .map_err(|_| GitSnapshotError::InvalidStage)?;
2279 let identity =
2280 regular_file_identity_from_file(&file).map_err(|_| GitSnapshotError::InvalidStage)?;
2281 parent.sync_metadata()?;
2282 if RegularFileFilesystemIdentity::capture(path).map_err(|_| GitSnapshotError::InvalidStage)?
2283 != identity
2284 {
2285 return Err(GitSnapshotError::InvalidStage);
2286 }
2287 Ok(identity)
2288}
2289
2290fn reconcile_exact_retry_file(
2291 parent: &GitSnapshotDirectoryGuard,
2292 name: &str,
2293 expected_bytes: &[u8],
2294 max_bytes: u64,
2295 recover_frontier: bool,
2296) -> Result<RegularFileFilesystemIdentity, GitSnapshotError> {
2297 parent.verify()?;
2298 let path = parent.path().join(name);
2299 match fs::symlink_metadata(&path) {
2300 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2301 return write_private_file_create_new(&path, expected_bytes, parent);
2302 }
2303 Err(_) => return Err(GitSnapshotError::InvalidStage),
2304 Ok(_) => {}
2305 }
2306 let retained =
2307 RetainedRegularFileIdentity::open(&path).map_err(|_| GitSnapshotError::InvalidStage)?;
2308 let identity = retained.identity().clone();
2309 let actual = read_bound_file(&path, &identity, max_bytes);
2310 retained
2311 .verify_path(&path)
2312 .map_err(|_| GitSnapshotError::InvalidStage)?;
2313 parent.verify()?;
2314 if matches!(actual.as_deref(), Ok(bytes) if bytes == expected_bytes) {
2315 drop(retained);
2316 let sync_file = OpenOptions::new()
2317 .read(true)
2318 .write(true)
2319 .open(&path)
2320 .map_err(|_| GitSnapshotError::InvalidStage)?;
2321 if regular_file_identity_from_file(&sync_file)
2322 .map_err(|_| GitSnapshotError::InvalidStage)?
2323 != identity
2324 {
2325 return Err(GitSnapshotError::InvalidStage);
2326 }
2327 sync_file
2328 .sync_all()
2329 .map_err(|_| GitSnapshotError::InvalidStage)?;
2330 drop(sync_file);
2331 parent.sync_metadata()?;
2332 if read_bound_file(&path, &identity, max_bytes)?.as_slice() != expected_bytes {
2333 return Err(GitSnapshotError::InvalidStage);
2334 }
2335 parent.verify()?;
2336 return Ok(identity);
2337 }
2338 let recoverable_prefix = actual
2339 .as_ref()
2340 .is_ok_and(|bytes| bytes.len() < expected_bytes.len() && expected_bytes.starts_with(bytes));
2341 if !recover_frontier || !recoverable_prefix {
2342 return Err(GitSnapshotError::InvalidStage);
2343 }
2344 drop(retained);
2345 remove_exact_stage_file_if_present(parent, name, identity.as_str())?;
2346 write_private_file_create_new(&path, expected_bytes, parent)
2347}
2348
2349fn require_stage_entries(path: &Path, expected: &[&str]) -> Result<(), GitSnapshotError> {
2350 let mut actual = fs::read_dir(path)
2351 .map_err(|_| GitSnapshotError::InvalidStage)?
2352 .map(|entry| {
2353 let entry = entry.map_err(|_| GitSnapshotError::InvalidStage)?;
2354 entry
2355 .file_name()
2356 .into_string()
2357 .map_err(|_| GitSnapshotError::InvalidStage)
2358 })
2359 .collect::<Result<Vec<_>, _>>()?;
2360 actual.sort();
2361 let mut expected = expected.iter().map(ToString::to_string).collect::<Vec<_>>();
2362 expected.sort();
2363 if actual != expected {
2364 return Err(GitSnapshotError::InvalidStage);
2365 }
2366 Ok(())
2367}
2368
2369fn require_stage_entry_subset(path: &Path, allowed: &[&str]) -> Result<(), GitSnapshotError> {
2370 for entry in fs::read_dir(path).map_err(|_| GitSnapshotError::InvalidStage)? {
2371 let entry = entry.map_err(|_| GitSnapshotError::InvalidStage)?;
2372 let name = entry
2373 .file_name()
2374 .into_string()
2375 .map_err(|_| GitSnapshotError::InvalidStage)?;
2376 if !allowed.contains(&name.as_str()) {
2377 return Err(GitSnapshotError::InvalidStage);
2378 }
2379 }
2380 Ok(())
2381}
2382
2383fn validate_exact_stage_file_if_present(
2384 parent: &GitSnapshotDirectoryGuard,
2385 name: &str,
2386 encoded_identity: &str,
2387) -> Result<(), GitSnapshotError> {
2388 let expected = RegularFileFilesystemIdentity::from_str(encoded_identity)
2389 .map_err(|_| GitSnapshotError::InvalidStage)?;
2390 let path = parent.path().join(name);
2391 match fs::symlink_metadata(&path) {
2392 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
2393 Err(_) => return Err(GitSnapshotError::InvalidStage),
2394 Ok(_) => {}
2395 }
2396 parent.verify()?;
2397 if RegularFileFilesystemIdentity::capture(&path).map_err(|_| GitSnapshotError::InvalidStage)?
2398 != expected
2399 {
2400 return Err(GitSnapshotError::InvalidStage);
2401 }
2402 parent.verify()
2403}
2404
2405fn remove_exact_stage_file_if_present(
2406 parent: &GitSnapshotDirectoryGuard,
2407 name: &str,
2408 encoded_identity: &str,
2409) -> Result<(), GitSnapshotError> {
2410 let expected = RegularFileFilesystemIdentity::from_str(encoded_identity)
2411 .map_err(|_| GitSnapshotError::InvalidStage)?;
2412 let path = parent.path().join(name);
2413 match fs::symlink_metadata(&path) {
2414 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
2415 Err(_) => return Err(GitSnapshotError::InvalidStage),
2416 Ok(_) => {}
2417 }
2418 parent.verify()?;
2419 #[cfg(windows)]
2420 {
2421 let removal = rustferry_core::open_regular_file_for_exact_removal(&path)
2422 .map_err(|_| GitSnapshotError::InvalidStage)?;
2423 if removal.identity() != &expected {
2424 return Err(GitSnapshotError::InvalidStage);
2425 }
2426 removal
2427 .remove()
2428 .map_err(|_| GitSnapshotError::InvalidStage)?;
2429 }
2430 #[cfg(not(windows))]
2431 {
2432 let retained =
2433 RetainedRegularFileIdentity::open(&path).map_err(|_| GitSnapshotError::InvalidStage)?;
2434 if retained.identity() != &expected {
2435 return Err(GitSnapshotError::InvalidStage);
2436 }
2437 retained
2438 .verify_path(&path)
2439 .map_err(|_| GitSnapshotError::InvalidStage)?;
2440 fs::remove_file(&path).map_err(|_| GitSnapshotError::InvalidStage)?;
2441 drop(retained);
2442 }
2443 require_confirmed_absent(&path)?;
2444 parent.sync_metadata()
2445}
2446
2447fn require_confirmed_absent(path: &Path) -> Result<(), GitSnapshotError> {
2448 match fs::symlink_metadata(path) {
2449 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
2450 Ok(_) | Err(_) => Err(GitSnapshotError::InvalidStage),
2451 }
2452}
2453
2454fn remove_exact_empty_stage_directory(
2455 directory: GitSnapshotDirectoryGuard,
2456) -> Result<(), GitSnapshotError> {
2457 directory.verify()?;
2458 #[cfg(windows)]
2459 {
2460 let path = directory.path().to_owned();
2461 let expected = directory.identity().clone();
2462 drop(directory);
2463 let removal = rustferry_core::windows_private_directory::open_private_directory(&path)
2464 .map_err(|_| GitSnapshotError::InvalidStage)?;
2465 if rustferry_core::directory_identity_from_file(&removal)
2466 .map_err(|_| GitSnapshotError::InvalidStage)?
2467 != expected
2468 {
2469 return Err(GitSnapshotError::InvalidStage);
2470 }
2471 rustferry_core::windows_private_directory::remove_private_directory_handle(removal)
2472 .map_err(|_| GitSnapshotError::InvalidStage)
2473 }
2474 #[cfg(not(windows))]
2475 {
2476 fs::remove_dir(directory.path()).map_err(|_| GitSnapshotError::InvalidStage)?;
2477 drop(directory);
2478 Ok(())
2479 }
2480}
2481
2482fn open_unbound_file(path: &Path) -> Result<(RetainedRegularFileIdentity, File), GitSnapshotError> {
2483 let retained =
2484 RetainedRegularFileIdentity::open(path).map_err(|_| GitSnapshotError::InvalidStage)?;
2485 let expected = retained.identity().clone();
2486 open_bound_file_with_retained(path, &expected, retained)
2487}
2488
2489fn verify_open_bound_file(
2490 retained: &RetainedRegularFileIdentity,
2491 file: &File,
2492 path: &Path,
2493) -> Result<(), GitSnapshotError> {
2494 if regular_file_identity_from_file(file).map_err(|_| GitSnapshotError::InvalidStage)?
2495 != *retained.identity()
2496 {
2497 return Err(GitSnapshotError::InvalidStage);
2498 }
2499 retained
2500 .verify_path(path)
2501 .map_err(|_| GitSnapshotError::InvalidStage)
2502}
2503
2504fn open_bound_file_with_retained(
2505 path: &Path,
2506 expected: &RegularFileFilesystemIdentity,
2507 retained: RetainedRegularFileIdentity,
2508) -> Result<(RetainedRegularFileIdentity, File), GitSnapshotError> {
2509 if retained.identity() != expected {
2510 return Err(GitSnapshotError::InvalidStage);
2511 }
2512 retained
2513 .verify_path(path)
2514 .map_err(|_| GitSnapshotError::InvalidStage)?;
2515 let file = File::open(path).map_err(|_| GitSnapshotError::InvalidStage)?;
2516 if regular_file_identity_from_file(&file).map_err(|_| GitSnapshotError::InvalidStage)?
2517 != *expected
2518 {
2519 return Err(GitSnapshotError::InvalidStage);
2520 }
2521 retained
2522 .verify_path(path)
2523 .map_err(|_| GitSnapshotError::InvalidStage)?;
2524 Ok((retained, file))
2525}
2526
2527fn read_open_file(file: &mut File, maximum: u64) -> Result<Vec<u8>, GitSnapshotError> {
2528 if file
2529 .metadata()
2530 .map_err(|_| GitSnapshotError::InvalidStage)?
2531 .len()
2532 > maximum
2533 {
2534 return Err(GitSnapshotError::StageTooLarge);
2535 }
2536 file.seek(std::io::SeekFrom::Start(0))
2537 .map_err(|_| GitSnapshotError::InvalidStage)?;
2538 let capacity = usize::try_from(maximum).map_err(|_| GitSnapshotError::StageTooLarge)?;
2539 let mut bytes = Vec::with_capacity(capacity.min(64 * 1024));
2540 std::io::Read::take(file, maximum.saturating_add(1))
2541 .read_to_end(&mut bytes)
2542 .map_err(|_| GitSnapshotError::InvalidStage)?;
2543 if bytes.len() as u64 > maximum {
2544 return Err(GitSnapshotError::StageTooLarge);
2545 }
2546 Ok(bytes)
2547}
2548
2549fn describe_open_file(file: &mut File, maximum: u64) -> Result<SourceArchive, GitSnapshotError> {
2550 if file
2551 .metadata()
2552 .map_err(|_| GitSnapshotError::InvalidStage)?
2553 .len()
2554 > maximum
2555 {
2556 return Err(GitSnapshotError::StageTooLarge);
2557 }
2558 file.seek(std::io::SeekFrom::Start(0))
2559 .map_err(|_| GitSnapshotError::InvalidStage)?;
2560 let mut size = 0_u64;
2561 let mut digest = Sha256::new();
2562 let mut buffer = vec![0_u8; 64 * 1024];
2563 loop {
2564 let read = file
2565 .read(&mut buffer)
2566 .map_err(|_| GitSnapshotError::InvalidStage)?;
2567 if read == 0 {
2568 break;
2569 }
2570 size = size
2571 .checked_add(u64::try_from(read).map_err(|_| GitSnapshotError::StageTooLarge)?)
2572 .ok_or(GitSnapshotError::StageTooLarge)?;
2573 if size > maximum {
2574 return Err(GitSnapshotError::StageTooLarge);
2575 }
2576 digest.update(&buffer[..read]);
2577 }
2578 Ok(SourceArchive {
2579 size,
2580 sha256: hex::encode(digest.finalize()),
2581 })
2582}
2583
2584fn open_bound_file(
2585 path: &Path,
2586 expected: &RegularFileFilesystemIdentity,
2587) -> Result<(RetainedRegularFileIdentity, File), GitSnapshotError> {
2588 let retained =
2589 RetainedRegularFileIdentity::open(path).map_err(|_| GitSnapshotError::InvalidStage)?;
2590 if retained.identity() != expected {
2591 return Err(GitSnapshotError::InvalidStage);
2592 }
2593 retained
2594 .verify_path(path)
2595 .map_err(|_| GitSnapshotError::InvalidStage)?;
2596 let file = File::open(path).map_err(|_| GitSnapshotError::InvalidStage)?;
2597 if regular_file_identity_from_file(&file).map_err(|_| GitSnapshotError::InvalidStage)?
2598 != *expected
2599 {
2600 return Err(GitSnapshotError::InvalidStage);
2601 }
2602 retained
2603 .verify_path(path)
2604 .map_err(|_| GitSnapshotError::InvalidStage)?;
2605 Ok((retained, file))
2606}
2607
2608fn read_bound_file(
2609 path: &Path,
2610 expected: &RegularFileFilesystemIdentity,
2611 maximum: u64,
2612) -> Result<Vec<u8>, GitSnapshotError> {
2613 let (retained, mut file) = open_bound_file(path, expected)?;
2614 if file
2615 .metadata()
2616 .map_err(|_| GitSnapshotError::InvalidStage)?
2617 .len()
2618 > maximum
2619 {
2620 return Err(GitSnapshotError::StageTooLarge);
2621 }
2622 let capacity = usize::try_from(maximum).map_err(|_| GitSnapshotError::StageTooLarge)?;
2623 let mut bytes = Vec::with_capacity(capacity.min(64 * 1024));
2624 std::io::Read::take(&mut file, maximum.saturating_add(1))
2625 .read_to_end(&mut bytes)
2626 .map_err(|_| GitSnapshotError::InvalidStage)?;
2627 if bytes.len() as u64 > maximum {
2628 return Err(GitSnapshotError::StageTooLarge);
2629 }
2630 retained
2631 .verify_path(path)
2632 .map_err(|_| GitSnapshotError::InvalidStage)?;
2633 Ok(bytes)
2634}
2635
2636fn describe_bound_file(
2637 path: &Path,
2638 expected: &RegularFileFilesystemIdentity,
2639 maximum: u64,
2640) -> Result<SourceArchive, GitSnapshotError> {
2641 let (retained, mut file) = open_bound_file(path, expected)?;
2642 if file
2643 .metadata()
2644 .map_err(|_| GitSnapshotError::InvalidStage)?
2645 .len()
2646 > maximum
2647 {
2648 return Err(GitSnapshotError::StageTooLarge);
2649 }
2650 let mut size = 0_u64;
2651 let mut digest = Sha256::new();
2652 let mut buffer = vec![0_u8; 64 * 1024];
2653 loop {
2654 let read = file
2655 .read(&mut buffer)
2656 .map_err(|_| GitSnapshotError::InvalidStage)?;
2657 if read == 0 {
2658 break;
2659 }
2660 size = size
2661 .checked_add(u64::try_from(read).map_err(|_| GitSnapshotError::StageTooLarge)?)
2662 .ok_or(GitSnapshotError::StageTooLarge)?;
2663 if size > maximum {
2664 return Err(GitSnapshotError::StageTooLarge);
2665 }
2666 digest.update(&buffer[..read]);
2667 }
2668 retained
2669 .verify_path(path)
2670 .map_err(|_| GitSnapshotError::InvalidStage)?;
2671 Ok(SourceArchive {
2672 size,
2673 sha256: hex::encode(digest.finalize()),
2674 })
2675}
2676
2677#[cfg(test)]
2678mod tests {
2679 use std::collections::BTreeSet;
2680 use std::fs;
2681 use std::io::Write as _;
2682 use std::path::Path;
2683 use std::process::{Command, Stdio};
2684
2685 use camino::Utf8PathBuf;
2686 use rustferry_remote::{
2687 BuildProfile, BundleIdentifier, CURRENT_PROTOCOL_VERSION, IosArtifactType,
2688 IosDeviceBuildRequest, IosDeviceProductExpectation, SigningMode, SigningPlan,
2689 SigningTarget, SigningTargetKind, SourceBundleDescriptor, SourceBundleRequest, SourceMode,
2690 create_source_bundle_archive, plan_source_bundle,
2691 };
2692
2693 use super::*;
2694
2695 struct StageFixture {
2696 _temporary: tempfile::TempDir,
2697 isolation_root: PathBuf,
2698 archive_path: PathBuf,
2699 descriptor_path: PathBuf,
2700 metadata_path: PathBuf,
2701 descriptor: GitSnapshotDescriptor,
2702 request: IosDeviceBuildRequest,
2703 stage: GitSnapshotStageV1,
2704 locator: GitSnapshotStageLocatorV1,
2705 }
2706
2707 #[derive(Clone, Copy, Debug)]
2708 enum ExactRetryFrontier {
2709 Empty,
2710 Archive,
2711 ArchiveAndDescriptor,
2712 PartialArchive,
2713 PartialDescriptor,
2714 PartialMetadata,
2715 Complete,
2716 }
2717
2718 struct ExactRetryFixture {
2719 operation_id: String,
2720 archive_path: PathBuf,
2721 descriptor_path: PathBuf,
2722 metadata_path: PathBuf,
2723 descriptor: GitSnapshotDescriptor,
2724 request: IosDeviceBuildRequest,
2725 stage: GitSnapshotStageV1,
2726 locator: GitSnapshotStageLocatorV1,
2727 preexisting_identities: Vec<RegularFileFilesystemIdentity>,
2728 }
2729
2730 fn object(byte: char) -> GitSha1ObjectId {
2731 GitSha1ObjectId::new(byte.to_string().repeat(40)).expect("object ID")
2732 }
2733
2734 fn hash_git_object(
2735 git_directory: &Path,
2736 kind: GitSnapshotObjectKind,
2737 write: bool,
2738 bytes: &[u8],
2739 ) -> GitSha1ObjectId {
2740 let mut command = Command::new("git");
2741 command
2742 .arg("--git-dir")
2743 .arg(git_directory)
2744 .arg("hash-object");
2745 if write {
2746 command.arg("-w");
2747 }
2748 if kind != GitSnapshotObjectKind::Blob {
2749 command.args(["-t", kind.git_type()]);
2750 }
2751 let mut child = command
2752 .arg("--stdin")
2753 .stdin(Stdio::piped())
2754 .stdout(Stdio::piped())
2755 .stderr(Stdio::piped())
2756 .spawn()
2757 .expect("spawn Git hash-object");
2758 child
2759 .stdin
2760 .take()
2761 .unwrap()
2762 .write_all(bytes)
2763 .expect("write Git object");
2764 let output = child.wait_with_output().expect("wait for Git hash-object");
2765 assert!(output.status.success(), "Git hash-object failed");
2766 GitSha1ObjectId::new(String::from_utf8(output.stdout).unwrap().trim().to_owned()).unwrap()
2767 }
2768
2769 fn hash_git_object_file(
2770 git_directory: &Path,
2771 kind: GitSnapshotObjectKind,
2772 write: bool,
2773 file: &File,
2774 ) -> GitSha1ObjectId {
2775 let mut command = Command::new("git");
2776 command
2777 .arg("--git-dir")
2778 .arg(git_directory)
2779 .arg("hash-object");
2780 if write {
2781 command.arg("-w");
2782 }
2783 if kind != GitSnapshotObjectKind::Blob {
2784 command.args(["-t", kind.git_type()]);
2785 }
2786 let output = command
2787 .arg("--stdin")
2788 .stdin(Stdio::from(file.try_clone().expect("clone retained input")))
2789 .stdout(Stdio::piped())
2790 .stderr(Stdio::piped())
2791 .output()
2792 .expect("run Git hash-object with retained input");
2793 assert!(output.status.success(), "Git hash-object failed");
2794 GitSha1ObjectId::new(String::from_utf8(output.stdout).unwrap().trim().to_owned()).unwrap()
2795 }
2796
2797 fn create_private_test_root(path: &Path) {
2798 #[cfg(windows)]
2799 drop(
2800 rustferry_core::windows_private_directory::create_private_directory(path)
2801 .expect("private test root"),
2802 );
2803 #[cfg(unix)]
2804 {
2805 use std::os::unix::fs::DirBuilderExt as _;
2806
2807 let mut builder = fs::DirBuilder::new();
2808 builder.mode(0o700).create(path).expect("private test root");
2809 }
2810 }
2811
2812 #[allow(clippy::too_many_lines)]
2813 fn stage_fixture() -> StageFixture {
2814 let temporary = tempfile::tempdir().expect("temporary directory");
2815 let utf8 = |path| Utf8PathBuf::from_path_buf(path).expect("UTF-8 temporary path");
2816 let workspace = utf8(temporary.path().join("workspace"));
2817 let project = workspace.join("app");
2818 let isolation_root = temporary.path().join("private");
2819 let operation_id = "operation-snapshot-stage-1";
2820 fs::create_dir_all(project.join("src")).unwrap();
2821 create_private_test_root(&isolation_root);
2822 let stage_directory =
2823 GitSnapshotStageDirectory::create(&isolation_root, operation_id).unwrap();
2824 fs::write(
2825 workspace.join("Cargo.toml"),
2826 b"[workspace]\nmembers = [\"app\"]\n",
2827 )
2828 .unwrap();
2829 fs::write(workspace.join("Cargo.lock"), b"").unwrap();
2830 fs::write(
2831 project.join("Cargo.toml"),
2832 b"[package]\nname = \"app\"\nversion = \"0.1.0\"\n",
2833 )
2834 .unwrap();
2835 fs::write(project.join("src/main.rs"), b"fn main() {}\n").unwrap();
2836
2837 let limits = git_snapshot_archive_limits();
2838 let plan = plan_source_bundle(
2839 &SourceBundleRequest::new(workspace, project).with_limits(limits.source),
2840 )
2841 .unwrap();
2842 let archive_path = utf8(stage_directory.archive_path());
2843 let archive = create_source_bundle_archive(&plan, &archive_path, limits).unwrap();
2844 let archive_identity = stage_directory.seal_archive(&archive).unwrap();
2845 let signing = SigningPlan {
2846 mode: SigningMode::UnsignedCompileOnly,
2847 signing: None,
2848 team: None,
2849 device: None,
2850 targets: vec![SigningTarget {
2851 name: "App".to_owned(),
2852 bundle_identifier: BundleIdentifier::new("com.example.app").unwrap(),
2853 kind: SigningTargetKind::Application,
2854 }],
2855 provisioning: Vec::new(),
2856 entitlements: Vec::new(),
2857 allow_provisioning_updates: false,
2858 };
2859 let mut request = IosDeviceBuildRequest {
2860 protocol_version: CURRENT_PROTOCOL_VERSION,
2861 operation_id: operation_id.to_owned(),
2862 product_name: "App".to_owned(),
2863 bundle_identifier: "com.example.app".to_owned(),
2864 minimum_ios_version: "16.0".to_owned(),
2865 product: IosDeviceProductExpectation {
2866 app_directory_name: "App.app".to_owned(),
2867 executable: "App".to_owned(),
2868 app_version: "1.0.0".to_owned(),
2869 build_number: "1".to_owned(),
2870 nested_bundles: Vec::new(),
2871 },
2872 profile: BuildProfile::Release,
2873 source_mode: SourceMode::GitSnapshot,
2874 source_repository: Some("https://github.com/example/project".to_owned()),
2875 source_revision: None,
2876 source: plan.manifest().clone(),
2877 signing,
2878 requested_artifacts: BTreeSet::from([IosArtifactType::Xcarchive]),
2879 };
2880 let descriptor = GitSnapshotDescriptor::from_request(
2881 &request,
2882 SourceBundleDescriptor::new(archive.clone(), plan.manifest().clone()),
2883 )
2884 .unwrap();
2885 let descriptor_bytes = canonical_git_snapshot_descriptor_bytes(&descriptor).unwrap();
2886 let descriptor_path = stage_directory.descriptor_path();
2887 let descriptor_identity = stage_directory
2888 .write_descriptor_create_new(&descriptor)
2889 .unwrap();
2890 let graph = GitSnapshotObjectGraphV1 {
2891 schema_version: GIT_SNAPSHOT_GRAPH_SCHEMA_VERSION,
2892 archive_blob: object('1'),
2893 descriptor_blob: object('2'),
2894 goal3_tree: object('3'),
2895 rustferry_tree: object('4'),
2896 root_tree: object('5'),
2897 commit: object('6'),
2898 };
2899 request.source_revision = Some(graph.commit.as_str().to_owned());
2900 let stage = GitSnapshotStageV1 {
2901 schema_version: GIT_SNAPSHOT_STAGE_SCHEMA_VERSION,
2902 operation_id: operation_id.to_owned(),
2903 isolation_root_identity: stage_directory.isolation_root_identity().to_owned(),
2904 snapshots_store_identity: stage_directory.snapshots_store_identity().to_owned(),
2905 stage_directory_identity: stage_directory.stage_directory_identity().to_owned(),
2906 source_repository: descriptor.source_repository.clone(),
2907 source_ref: GitSnapshotSourceRef::for_operation(operation_id).unwrap(),
2908 keepalive_ref: GitSnapshotKeepaliveRef::for_operation(operation_id).unwrap(),
2909 source_created_at_ms: 1_234_567,
2910 consent_sha256: hex::encode(Sha256::digest(b"accepted-plan-v1")),
2911 request_template_sha256: descriptor.request_template_sha256.clone(),
2912 manifest_sha256: descriptor.bundle.manifest.sha256.clone(),
2913 archive,
2914 descriptor_sha256: hex::encode(Sha256::digest(&descriptor_bytes)),
2915 final_request: request.clone(),
2916 archive_file_identity: archive_identity.to_string(),
2917 descriptor_file_identity: descriptor_identity.to_string(),
2918 graph,
2919 };
2920 let locator = stage_directory
2921 .publish_metadata_create_new(&stage, &descriptor, &request)
2922 .unwrap();
2923 let metadata_path = git_snapshot_stage_directory(&isolation_root, operation_id)
2924 .unwrap()
2925 .join(GIT_SNAPSHOT_STAGE_METADATA_FILE);
2926 StageFixture {
2927 _temporary: temporary,
2928 isolation_root,
2929 archive_path: archive_path.into_std_path_buf(),
2930 descriptor_path,
2931 metadata_path,
2932 descriptor,
2933 request,
2934 stage,
2935 locator,
2936 }
2937 }
2938
2939 #[allow(clippy::too_many_lines)]
2940 fn reconcile_exact_retry_fixture(
2941 parent: &StageFixture,
2942 operation_id: &str,
2943 frontier: ExactRetryFrontier,
2944 ) -> ExactRetryFixture {
2945 let archive_bytes = fs::read(&parent.archive_path).unwrap();
2946 let directory = GitSnapshotStageDirectory::open_or_create_exact_retry(
2947 &parent.isolation_root,
2948 operation_id,
2949 )
2950 .unwrap();
2951 let archive_path = directory.archive_path();
2952 let descriptor_path = directory.descriptor_path();
2953 let metadata_path = git_snapshot_stage_directory(&parent.isolation_root, operation_id)
2954 .unwrap()
2955 .join(GIT_SNAPSHOT_STAGE_METADATA_FILE);
2956 let mut request = parent.request.clone();
2957 request.operation_id = operation_id.to_owned();
2958 request.source_revision = None;
2959 let descriptor = GitSnapshotDescriptor::from_request(
2960 &request,
2961 SourceBundleDescriptor::new(
2962 parent.stage.archive.clone(),
2963 parent.descriptor.bundle.manifest.clone(),
2964 ),
2965 )
2966 .unwrap();
2967 let descriptor_bytes = canonical_git_snapshot_descriptor_bytes(&descriptor).unwrap();
2968
2969 match frontier {
2970 ExactRetryFrontier::Archive | ExactRetryFrontier::ArchiveAndDescriptor => {
2971 write_private_file_create_new(
2972 &archive_path,
2973 &archive_bytes,
2974 &directory.stage_directory,
2975 )
2976 .unwrap();
2977 }
2978 ExactRetryFrontier::PartialArchive => {
2979 write_private_file_create_new(
2980 &archive_path,
2981 &archive_bytes[..archive_bytes.len() / 2],
2982 &directory.stage_directory,
2983 )
2984 .unwrap();
2985 }
2986 ExactRetryFrontier::PartialDescriptor
2987 | ExactRetryFrontier::PartialMetadata
2988 | ExactRetryFrontier::Complete => {
2989 write_private_file_create_new(
2990 &archive_path,
2991 &archive_bytes,
2992 &directory.stage_directory,
2993 )
2994 .unwrap();
2995 let descriptor_payload =
2996 if matches!(frontier, ExactRetryFrontier::PartialDescriptor) {
2997 &descriptor_bytes[..descriptor_bytes.len() / 2]
2998 } else {
2999 &descriptor_bytes
3000 };
3001 write_private_file_create_new(
3002 &descriptor_path,
3003 descriptor_payload,
3004 &directory.stage_directory,
3005 )
3006 .unwrap();
3007 }
3008 ExactRetryFrontier::Empty => {}
3009 }
3010 if matches!(frontier, ExactRetryFrontier::ArchiveAndDescriptor) {
3011 write_private_file_create_new(
3012 &descriptor_path,
3013 &descriptor_bytes,
3014 &directory.stage_directory,
3015 )
3016 .unwrap();
3017 }
3018 let mut preexisting_identities = [
3019 archive_path.as_path(),
3020 descriptor_path.as_path(),
3021 metadata_path.as_path(),
3022 ]
3023 .into_iter()
3024 .filter_map(|path| RegularFileFilesystemIdentity::capture(path).ok())
3025 .collect::<Vec<_>>();
3026
3027 let archive_identity = directory
3028 .write_or_verify_archive_bytes_exact_retry(&archive_bytes, &parent.stage.archive)
3029 .unwrap();
3030 let descriptor_identity = directory
3031 .write_or_verify_descriptor_exact_retry(&descriptor)
3032 .unwrap();
3033 let graph = GitSnapshotObjectGraphV1 {
3034 schema_version: GIT_SNAPSHOT_GRAPH_SCHEMA_VERSION,
3035 archive_blob: parent.stage.graph.archive_blob.clone(),
3036 descriptor_blob: object('a'),
3037 goal3_tree: object('b'),
3038 rustferry_tree: object('c'),
3039 root_tree: object('d'),
3040 commit: object('e'),
3041 };
3042 request.source_revision = Some(graph.commit.as_str().to_owned());
3043 let stage = GitSnapshotStageV1 {
3044 schema_version: GIT_SNAPSHOT_STAGE_SCHEMA_VERSION,
3045 operation_id: operation_id.to_owned(),
3046 isolation_root_identity: directory.isolation_root_identity().to_owned(),
3047 snapshots_store_identity: directory.snapshots_store_identity().to_owned(),
3048 stage_directory_identity: directory.stage_directory_identity().to_owned(),
3049 source_repository: descriptor.source_repository.clone(),
3050 source_ref: GitSnapshotSourceRef::for_operation(operation_id).unwrap(),
3051 keepalive_ref: GitSnapshotKeepaliveRef::for_operation(operation_id).unwrap(),
3052 source_created_at_ms: 9_876_543,
3053 consent_sha256: hex::encode(Sha256::digest(b"retry-lineage-authorization-v1")),
3054 request_template_sha256: descriptor.request_template_sha256.clone(),
3055 manifest_sha256: descriptor.bundle.manifest.sha256.clone(),
3056 archive: parent.stage.archive.clone(),
3057 descriptor_sha256: hex::encode(Sha256::digest(&descriptor_bytes)),
3058 final_request: request.clone(),
3059 archive_file_identity: archive_identity.to_string(),
3060 descriptor_file_identity: descriptor_identity.to_string(),
3061 graph,
3062 };
3063 if matches!(
3064 frontier,
3065 ExactRetryFrontier::PartialMetadata | ExactRetryFrontier::Complete
3066 ) {
3067 let metadata_bytes = stage.canonical_bytes(&descriptor).unwrap();
3068 let metadata_payload = if matches!(frontier, ExactRetryFrontier::PartialMetadata) {
3069 &metadata_bytes[..metadata_bytes.len() / 2]
3070 } else {
3071 &metadata_bytes
3072 };
3073 write_private_file_create_new(
3074 &metadata_path,
3075 metadata_payload,
3076 &directory.stage_directory,
3077 )
3078 .unwrap();
3079 preexisting_identities
3080 .push(RegularFileFilesystemIdentity::capture(&metadata_path).unwrap());
3081 }
3082 let locator = directory
3083 .publish_or_verify_metadata_exact_retry(&stage, &descriptor, &request)
3084 .unwrap();
3085
3086 let replay_directory = GitSnapshotStageDirectory::open_or_create_exact_retry(
3087 &parent.isolation_root,
3088 operation_id,
3089 )
3090 .unwrap();
3091 assert_eq!(
3092 replay_directory
3093 .write_or_verify_archive_bytes_exact_retry(&archive_bytes, &parent.stage.archive)
3094 .unwrap(),
3095 archive_identity
3096 );
3097 assert_eq!(
3098 replay_directory
3099 .write_or_verify_descriptor_exact_retry(&descriptor)
3100 .unwrap(),
3101 descriptor_identity
3102 );
3103 assert_eq!(
3104 replay_directory
3105 .publish_or_verify_metadata_exact_retry(&stage, &descriptor, &request)
3106 .unwrap(),
3107 locator
3108 );
3109
3110 ExactRetryFixture {
3111 operation_id: operation_id.to_owned(),
3112 archive_path,
3113 descriptor_path,
3114 metadata_path,
3115 descriptor,
3116 request,
3117 stage,
3118 locator,
3119 preexisting_identities,
3120 }
3121 }
3122
3123 #[test]
3124 fn exact_retry_recovers_every_ordered_stage_frontier_and_replays_complete_stage() {
3125 let parent = stage_fixture();
3126 for (index, frontier) in [
3127 ExactRetryFrontier::Empty,
3128 ExactRetryFrontier::Archive,
3129 ExactRetryFrontier::ArchiveAndDescriptor,
3130 ExactRetryFrontier::PartialArchive,
3131 ExactRetryFrontier::PartialDescriptor,
3132 ExactRetryFrontier::PartialMetadata,
3133 ExactRetryFrontier::Complete,
3134 ]
3135 .into_iter()
3136 .enumerate()
3137 {
3138 let recovered = reconcile_exact_retry_fixture(
3139 &parent,
3140 &format!("operation-exact-retry-frontier-{index}"),
3141 frontier,
3142 );
3143 let loaded = GitSnapshotImportInputs::load(
3144 &parent.isolation_root,
3145 &recovered.locator,
3146 &recovered.request,
3147 )
3148 .unwrap();
3149 assert_eq!(loaded.stage(), &recovered.stage);
3150 assert_eq!(loaded.descriptor(), &recovered.descriptor);
3151 assert_eq!(
3152 fs::read(&recovered.archive_path).unwrap(),
3153 fs::read(&parent.archive_path).unwrap()
3154 );
3155 assert_eq!(
3156 fs::read(&recovered.descriptor_path).unwrap(),
3157 canonical_git_snapshot_descriptor_bytes(&recovered.descriptor).unwrap()
3158 );
3159 assert_eq!(
3160 fs::read(&recovered.metadata_path).unwrap(),
3161 recovered
3162 .stage
3163 .canonical_bytes(&recovered.descriptor)
3164 .unwrap()
3165 );
3166 if matches!(
3167 frontier,
3168 ExactRetryFrontier::Archive
3169 | ExactRetryFrontier::ArchiveAndDescriptor
3170 | ExactRetryFrontier::Complete
3171 ) {
3172 assert!(recovered.preexisting_identities.contains(
3173 &RegularFileFilesystemIdentity::capture(&recovered.archive_path).unwrap()
3174 ));
3175 }
3176 if matches!(
3177 frontier,
3178 ExactRetryFrontier::ArchiveAndDescriptor | ExactRetryFrontier::Complete
3179 ) {
3180 assert!(recovered.preexisting_identities.contains(
3181 &RegularFileFilesystemIdentity::capture(&recovered.descriptor_path).unwrap()
3182 ));
3183 }
3184 if matches!(frontier, ExactRetryFrontier::Complete) {
3185 assert!(recovered.preexisting_identities.contains(
3186 &RegularFileFilesystemIdentity::capture(&recovered.metadata_path).unwrap()
3187 ));
3188 }
3189 }
3190 }
3191
3192 #[test]
3193 fn exact_retry_rejects_nonprefix_out_of_order_extra_and_complete_mismatch_without_mutation() {
3194 let parent = stage_fixture();
3195 let archive = fs::read(&parent.archive_path).unwrap();
3196 let operation_id = "operation-exact-retry-invalid-archive";
3197 let directory = GitSnapshotStageDirectory::open_or_create_exact_retry(
3198 &parent.isolation_root,
3199 operation_id,
3200 )
3201 .unwrap();
3202 write_private_file_create_new(
3203 &directory.archive_path(),
3204 b"not-an-archive-prefix",
3205 &directory.stage_directory,
3206 )
3207 .unwrap();
3208 let before = fs::read(directory.archive_path()).unwrap();
3209 assert_eq!(
3210 directory.write_or_verify_archive_bytes_exact_retry(&archive, &parent.stage.archive),
3211 Err(GitSnapshotError::InvalidStage)
3212 );
3213 assert_eq!(fs::read(directory.archive_path()).unwrap(), before);
3214
3215 let operation_id = "operation-exact-retry-descriptor-only";
3216 let directory = GitSnapshotStageDirectory::open_or_create_exact_retry(
3217 &parent.isolation_root,
3218 operation_id,
3219 )
3220 .unwrap();
3221 write_private_file_create_new(
3222 &directory.descriptor_path(),
3223 b"descriptor-without-archive",
3224 &directory.stage_directory,
3225 )
3226 .unwrap();
3227 assert_eq!(
3228 GitSnapshotStageDirectory::open_or_create_exact_retry(
3229 &parent.isolation_root,
3230 operation_id,
3231 )
3232 .unwrap_err(),
3233 GitSnapshotError::InvalidStage
3234 );
3235
3236 let operation_id = "operation-exact-retry-extra-entry";
3237 let directory = GitSnapshotStageDirectory::open_or_create_exact_retry(
3238 &parent.isolation_root,
3239 operation_id,
3240 )
3241 .unwrap();
3242 write_private_file_create_new(
3243 &directory.stage_directory.path().join("unexpected"),
3244 b"unexpected",
3245 &directory.stage_directory,
3246 )
3247 .unwrap();
3248 assert_eq!(
3249 GitSnapshotStageDirectory::open_or_create_exact_retry(
3250 &parent.isolation_root,
3251 operation_id,
3252 )
3253 .unwrap_err(),
3254 GitSnapshotError::InvalidStage
3255 );
3256
3257 let recovered = reconcile_exact_retry_fixture(
3258 &parent,
3259 "operation-exact-retry-complete-mismatch",
3260 ExactRetryFrontier::Complete,
3261 );
3262 let before = [
3263 fs::read(&recovered.archive_path).unwrap(),
3264 fs::read(&recovered.descriptor_path).unwrap(),
3265 fs::read(&recovered.metadata_path).unwrap(),
3266 ];
3267 let directory = GitSnapshotStageDirectory::open_or_create_exact_retry(
3268 &parent.isolation_root,
3269 &recovered.operation_id,
3270 )
3271 .unwrap();
3272 let mut changed = recovered.stage.clone();
3273 changed.consent_sha256 = hex::encode(Sha256::digest(b"different-lineage-authority"));
3274 assert_eq!(
3275 directory.publish_or_verify_metadata_exact_retry(
3276 &changed,
3277 &recovered.descriptor,
3278 &recovered.request,
3279 ),
3280 Err(GitSnapshotError::InvalidStage)
3281 );
3282 assert_eq!(
3283 [
3284 fs::read(&recovered.archive_path).unwrap(),
3285 fs::read(&recovered.descriptor_path).unwrap(),
3286 fs::read(&recovered.metadata_path).unwrap(),
3287 ],
3288 before
3289 );
3290 }
3291
3292 #[test]
3293 fn complete_stage_discovery_is_bounded_strict_path_free_and_read_only() {
3294 let empty = tempfile::tempdir().unwrap();
3295 let empty_root = empty.path().join("private");
3296 create_private_test_root(&empty_root);
3297 assert!(
3298 discover_complete_git_snapshot_stages(&empty_root)
3299 .unwrap()
3300 .is_empty()
3301 );
3302 assert!(fs::read_dir(&empty_root).unwrap().next().is_none());
3303
3304 let fixture = stage_fixture();
3305 let before = [
3306 fs::read(&fixture.archive_path).unwrap(),
3307 fs::read(&fixture.descriptor_path).unwrap(),
3308 fs::read(&fixture.metadata_path).unwrap(),
3309 ];
3310 let discovered = discover_complete_git_snapshot_stages(&fixture.isolation_root).unwrap();
3311 assert_eq!(discovered.len(), 1);
3312 assert_eq!(discovered[0].locator, fixture.locator);
3313 assert_eq!(discovered[0].stage, fixture.stage);
3314 assert_eq!(discovered[0].descriptor, fixture.descriptor);
3315 assert_eq!(discovered[0].stage.final_request, fixture.request);
3316 assert_eq!(
3317 [
3318 fs::read(&fixture.archive_path).unwrap(),
3319 fs::read(&fixture.descriptor_path).unwrap(),
3320 fs::read(&fixture.metadata_path).unwrap(),
3321 ],
3322 before
3323 );
3324 }
3325
3326 #[test]
3327 fn discovery_fails_the_whole_read_for_partial_unknown_overflow_and_rebound_state() {
3328 let changed = stage_fixture();
3329 assert_eq!(
3330 discover_complete_git_snapshot_stages_with_hook(&changed.isolation_root, || {
3331 GitSnapshotStageDirectory::create(
3332 &changed.isolation_root,
3333 "operation-discovery-concurrent-partial",
3334 )
3335 .unwrap();
3336 }),
3337 Err(GitSnapshotError::DiscoveryChanged)
3338 );
3339
3340 let partial = stage_fixture();
3341 GitSnapshotStageDirectory::create(&partial.isolation_root, "operation-discovery-partial")
3342 .unwrap();
3343 assert_eq!(
3344 discover_complete_git_snapshot_stages(&partial.isolation_root),
3345 Err(GitSnapshotError::InvalidStage)
3346 );
3347
3348 let unknown = stage_fixture();
3349 fs::create_dir(
3350 unknown
3351 .isolation_root
3352 .join(GIT_SNAPSHOT_STAGE_DIRECTORY)
3353 .join(".not-an-operation"),
3354 )
3355 .unwrap();
3356 assert_eq!(
3357 discover_complete_git_snapshot_stages(&unknown.isolation_root),
3358 Err(GitSnapshotError::InvalidOperation)
3359 );
3360
3361 let overflow = tempfile::tempdir().unwrap();
3362 let overflow_root = overflow.path().join("private");
3363 create_private_test_root(&overflow_root);
3364 for index in 0..=MAX_GIT_SNAPSHOT_DISCOVERY_STAGES {
3365 GitSnapshotStageDirectory::create(
3366 &overflow_root,
3367 &format!("operation-discovery-overflow-{index:02}"),
3368 )
3369 .unwrap();
3370 }
3371 assert_eq!(
3372 discover_complete_git_snapshot_stages(&overflow_root),
3373 Err(GitSnapshotError::InvalidStage)
3374 );
3375
3376 let rebound = stage_fixture();
3377 let hardlink = rebound
3378 .isolation_root
3379 .join("descriptor-hardlink-outside-stage");
3380 fs::hard_link(&rebound.descriptor_path, &hardlink).unwrap();
3381 assert_eq!(
3382 discover_complete_git_snapshot_stages(&rebound.isolation_root),
3383 Err(GitSnapshotError::InvalidStage)
3384 );
3385 }
3386
3387 #[test]
3388 fn full_refs_are_typed_and_operation_derived() {
3389 let operation = "operation-snapshot-1";
3390 let source = GitSnapshotSourceRef::for_operation(operation).unwrap();
3391 let keepalive = GitSnapshotKeepaliveRef::for_operation(operation).unwrap();
3392 assert_eq!(
3393 source.as_str(),
3394 "refs/rustferry/goal3/snapshots/operation-snapshot-1"
3395 );
3396 assert_eq!(
3397 keepalive.as_str(),
3398 "refs/rustferry/goal3/keepalive/operation-snapshot-1"
3399 );
3400 assert!(GitSnapshotSourceRef::for_operation("../unsafe").is_err());
3401 assert!(GitSnapshotKeepaliveRef::for_operation("name.lock").is_err());
3402 assert!(
3403 serde_json::from_str::<GitSnapshotSourceRef>(
3404 "\"refs/heads/rustferry/goal3/snapshots/operation-snapshot-1\""
3405 )
3406 .is_err()
3407 );
3408 }
3409
3410 #[test]
3411 fn strict_stage_round_trips_and_reopens_exact_bound_files() {
3412 let fixture = stage_fixture();
3413 let bytes = fixture.stage.canonical_bytes(&fixture.descriptor).unwrap();
3414 let decoded = GitSnapshotStageV1::decode(&bytes, &fixture.descriptor).unwrap();
3415 assert_eq!(decoded, fixture.stage);
3416 assert_eq!(
3417 decoded
3418 .verify_staged_files(&fixture.isolation_root)
3419 .unwrap(),
3420 fixture.descriptor
3421 );
3422
3423 let mut noncanonical = bytes;
3424 noncanonical.pop();
3425 assert_eq!(
3426 GitSnapshotStageV1::decode(&noncanonical, &fixture.descriptor),
3427 Err(GitSnapshotError::InvalidEncoding)
3428 );
3429 }
3430
3431 #[test]
3432 fn complete_stage_load_and_no_mutation_adoption_retain_exact_import_inputs() {
3433 let fixture = stage_fixture();
3434 let before = [
3435 fs::read(&fixture.archive_path).unwrap(),
3436 fs::read(&fixture.descriptor_path).unwrap(),
3437 fs::read(&fixture.metadata_path).unwrap(),
3438 ];
3439 let mut loaded = GitSnapshotImportInputs::load(
3440 &fixture.isolation_root,
3441 &fixture.locator,
3442 &fixture.request,
3443 )
3444 .unwrap();
3445 assert_eq!(loaded.stage(), &fixture.stage);
3446 assert_eq!(loaded.descriptor(), &fixture.descriptor);
3447 assert_eq!(
3448 loaded.descriptor_bytes(),
3449 canonical_git_snapshot_descriptor_bytes(&fixture.descriptor).unwrap()
3450 );
3451 let mut archive = Vec::new();
3452 loaded
3453 .archive_reader()
3454 .unwrap()
3455 .read_to_end(&mut archive)
3456 .unwrap();
3457 assert_eq!(archive, before[0]);
3458 loaded.verify_bindings().unwrap();
3459 drop(loaded);
3460
3461 let (adopted_locator, mut adopted) = GitSnapshotImportInputs::adopt_without_locator(
3462 &fixture.isolation_root,
3463 &fixture.request,
3464 )
3465 .unwrap();
3466 assert_eq!(adopted_locator, fixture.locator);
3467 assert_eq!(adopted.locator(), &fixture.locator);
3468 adopted.verify_bindings().unwrap();
3469 drop(adopted);
3470 assert_eq!(
3471 [
3472 fs::read(&fixture.archive_path).unwrap(),
3473 fs::read(&fixture.descriptor_path).unwrap(),
3474 fs::read(&fixture.metadata_path).unwrap(),
3475 ],
3476 before
3477 );
3478 assert_eq!(
3479 GitSnapshotStageDirectory::create(
3480 &fixture.isolation_root,
3481 &fixture.request.operation_id,
3482 )
3483 .unwrap_err(),
3484 GitSnapshotError::StageAlreadyExists
3485 );
3486 }
3487
3488 #[test]
3489 fn locator_and_durable_request_bind_root_store_operation_and_commit() {
3490 let fixture = stage_fixture();
3491 let mut locator = fixture.locator.clone();
3492 locator.isolation_root_identity = locator.snapshots_store_identity.clone();
3493 assert!(
3494 GitSnapshotImportInputs::load(&fixture.isolation_root, &locator, &fixture.request,)
3495 .is_err()
3496 );
3497
3498 let mut request = fixture.request.clone();
3499 request.source_revision = Some("a".repeat(40));
3500 assert!(
3501 GitSnapshotImportInputs::load(&fixture.isolation_root, &fixture.locator, &request,)
3502 .is_err()
3503 );
3504
3505 let mut request = fixture.request.clone();
3506 request.operation_id = "operation-snapshot-stage-other".to_owned();
3507 assert!(
3508 GitSnapshotImportInputs::adopt_without_locator(&fixture.isolation_root, &request)
3509 .is_err()
3510 );
3511 }
3512
3513 #[test]
3514 fn partial_crash_stage_without_metadata_is_cleanup_only() {
3515 let fixture = stage_fixture();
3516 fs::remove_file(&fixture.metadata_path).unwrap();
3517 assert!(
3518 GitSnapshotImportInputs::adopt_without_locator(
3519 &fixture.isolation_root,
3520 &fixture.request,
3521 )
3522 .is_err()
3523 );
3524 assert_eq!(
3525 GitSnapshotStageDirectory::create(
3526 &fixture.isolation_root,
3527 &fixture.request.operation_id,
3528 )
3529 .unwrap_err(),
3530 GitSnapshotError::StageAlreadyExists
3531 );
3532 assert_eq!(
3533 fs::read(&fixture.archive_path).unwrap().len() as u64,
3534 fixture.stage.archive.size
3535 );
3536 }
3537
3538 #[test]
3539 fn exact_stage_deletion_is_durable_and_replayable() {
3540 let fixture = stage_fixture();
3541 let stage_path = fixture.metadata_path.parent().unwrap().to_path_buf();
3542 GitSnapshotImportInputs::delete_stage_exact(
3543 &fixture.isolation_root,
3544 &fixture.locator,
3545 &fixture.stage,
3546 &fixture.request,
3547 )
3548 .unwrap();
3549 assert!(!stage_path.exists());
3550 assert!(
3551 fixture
3552 .isolation_root
3553 .join(GIT_SNAPSHOT_STAGE_DIRECTORY)
3554 .is_dir()
3555 );
3556
3557 GitSnapshotImportInputs::delete_stage_exact(
3558 &fixture.isolation_root,
3559 &fixture.locator,
3560 &fixture.stage,
3561 &fixture.request,
3562 )
3563 .unwrap();
3564 }
3565
3566 #[test]
3567 fn exact_stage_deletion_recovers_identity_bound_partial_stage() {
3568 let fixture = stage_fixture();
3569 let stage_path = fixture.metadata_path.parent().unwrap().to_path_buf();
3570 fs::remove_file(&fixture.archive_path).unwrap();
3571 GitSnapshotImportInputs::delete_stage_exact(
3572 &fixture.isolation_root,
3573 &fixture.locator,
3574 &fixture.stage,
3575 &fixture.request,
3576 )
3577 .unwrap();
3578 assert!(!stage_path.exists());
3579 }
3580
3581 #[test]
3582 fn exact_stage_deletion_rejects_unexpected_or_rebound_entries_before_mutation() {
3583 let fixture = stage_fixture();
3584 let unexpected = fixture.metadata_path.parent().unwrap().join("unexpected");
3585 fs::write(&unexpected, b"unexpected").unwrap();
3586 assert_eq!(
3587 GitSnapshotImportInputs::delete_stage_exact(
3588 &fixture.isolation_root,
3589 &fixture.locator,
3590 &fixture.stage,
3591 &fixture.request,
3592 ),
3593 Err(GitSnapshotError::InvalidStage)
3594 );
3595 assert!(fixture.archive_path.exists());
3596 assert!(fixture.descriptor_path.exists());
3597 assert!(fixture.metadata_path.exists());
3598
3599 let fixture = stage_fixture();
3600 let displaced_metadata = fixture.isolation_root.join("displaced-metadata");
3601 fs::rename(&fixture.metadata_path, &displaced_metadata).unwrap();
3602 fs::write(&fixture.metadata_path, b"replacement").unwrap();
3603 assert_eq!(
3604 GitSnapshotImportInputs::delete_stage_exact(
3605 &fixture.isolation_root,
3606 &fixture.locator,
3607 &fixture.stage,
3608 &fixture.request,
3609 ),
3610 Err(GitSnapshotError::InvalidStage)
3611 );
3612 assert!(fixture.archive_path.exists());
3613 assert!(fixture.descriptor_path.exists());
3614 assert!(fixture.metadata_path.exists());
3615 }
3616
3617 #[test]
3618 fn completed_stage_rejects_metadata_archive_and_entry_tampering() {
3619 let fixture = stage_fixture();
3620 let mut bytes = fs::read(&fixture.metadata_path).unwrap();
3621 *bytes.last_mut().unwrap() = b' ';
3622 fs::write(&fixture.metadata_path, bytes).unwrap();
3623 assert!(
3624 GitSnapshotImportInputs::load(
3625 &fixture.isolation_root,
3626 &fixture.locator,
3627 &fixture.request,
3628 )
3629 .is_err()
3630 );
3631
3632 let fixture = stage_fixture();
3633 fs::write(&fixture.archive_path, b"tampered archive").unwrap();
3634 assert!(
3635 GitSnapshotImportInputs::load(
3636 &fixture.isolation_root,
3637 &fixture.locator,
3638 &fixture.request,
3639 )
3640 .is_err()
3641 );
3642
3643 let fixture = stage_fixture();
3644 let unexpected = fixture.metadata_path.parent().unwrap().join("unexpected");
3645 fs::write(unexpected, b"unexpected").unwrap();
3646 assert!(
3647 GitSnapshotImportInputs::load(
3648 &fixture.isolation_root,
3649 &fixture.locator,
3650 &fixture.request,
3651 )
3652 .is_err()
3653 );
3654 }
3655
3656 #[cfg(unix)]
3657 #[test]
3658 fn post_git_verification_rejects_unix_in_place_mutation_after_archive_eof() {
3659 use std::fs::OpenOptions;
3660 use std::io::Seek as _;
3661
3662 let fixture = stage_fixture();
3663 let mut inputs = GitSnapshotImportInputs::load(
3664 &fixture.isolation_root,
3665 &fixture.locator,
3666 &fixture.request,
3667 )
3668 .unwrap();
3669 let mut consumed = Vec::new();
3670 inputs
3671 .archive_reader()
3672 .unwrap()
3673 .read_to_end(&mut consumed)
3674 .unwrap();
3675 assert_eq!(consumed.len() as u64, fixture.stage.archive.size);
3676
3677 let original_identity = RegularFileFilesystemIdentity::capture(&fixture.archive_path)
3678 .unwrap()
3679 .to_string();
3680 let mut writer = OpenOptions::new()
3681 .write(true)
3682 .open(&fixture.archive_path)
3683 .unwrap();
3684 writer.seek(std::io::SeekFrom::Start(0)).unwrap();
3685 writer.write_all(&[0]).unwrap();
3686 writer.sync_all().unwrap();
3687 drop(writer);
3688 assert_eq!(
3689 RegularFileFilesystemIdentity::capture(&fixture.archive_path)
3690 .unwrap()
3691 .to_string(),
3692 original_identity
3693 );
3694 assert_eq!(
3695 inputs.verify_bindings(),
3696 Err(GitSnapshotError::InvalidStage)
3697 );
3698 }
3699
3700 #[test]
3701 fn stage_reopen_rejects_identity_or_descriptor_byte_drift() {
3702 let mut fixture = stage_fixture();
3703 fixture.stage.descriptor_file_identity = fixture.stage.archive_file_identity.clone();
3704 assert_eq!(
3705 fixture.stage.verify_staged_files(&fixture.isolation_root),
3706 Err(GitSnapshotError::InvalidStage)
3707 );
3708
3709 let fixture = stage_fixture();
3710 let mut bytes = fs::read(&fixture.descriptor_path).unwrap();
3711 let last = bytes.last_mut().unwrap();
3712 *last = b' ';
3713 fs::write(&fixture.descriptor_path, bytes).unwrap();
3714 assert_eq!(
3715 fixture.stage.verify_staged_files(&fixture.isolation_root),
3716 Err(GitSnapshotError::InvalidStage)
3717 );
3718 }
3719
3720 #[test]
3721 fn six_object_tree_hierarchy_is_canonical_and_never_uses_full_path_entries() {
3722 let leaf = canonical_goal3_tree_bytes(&object('1'), &object('2')).unwrap();
3723 assert!(leaf.starts_with(b"100644 source.json\0"));
3724 assert!(
3725 leaf.windows(b"100644 source.zip\0".len())
3726 .any(|window| window == b"100644 source.zip\0")
3727 );
3728 assert!(!leaf.windows(10).any(|window| window.contains(&b'/')));
3729
3730 let middle = canonical_rustferry_tree_bytes(&object('3')).unwrap();
3731 assert!(middle.starts_with(b"40000 goal3\0"));
3732 let root = canonical_root_tree_bytes(&object('4')).unwrap();
3733 assert!(root.starts_with(b"40000 .rustferry\0"));
3734
3735 let mut malformed = Vec::new();
3736 assert_eq!(
3737 append_tree_entry(
3738 &mut malformed,
3739 "100644",
3740 ".rustferry/goal3/source.json",
3741 &object('1')
3742 ),
3743 Err(GitSnapshotError::InvalidTreeEntry)
3744 );
3745 }
3746
3747 #[test]
3748 #[allow(clippy::too_many_lines)]
3749 fn no_write_precompute_and_import_rehash_the_same_parentless_six_object_graph() {
3750 let temporary = tempfile::tempdir().unwrap();
3751 let git_directory = temporary.path().join("repository.git");
3752 let initialized = Command::new("git")
3753 .args(["init", "--bare", "--quiet"])
3754 .arg(&git_directory)
3755 .output()
3756 .expect("initialize bare Git repository");
3757 assert!(initialized.status.success());
3758 let format = Command::new("git")
3759 .arg("--git-dir")
3760 .arg(&git_directory)
3761 .args(["rev-parse", "--show-object-format"])
3762 .output()
3763 .unwrap();
3764 assert!(format.status.success());
3765 require_sha1_object_format(&format.stdout).unwrap();
3766
3767 let archive_bytes = b"deterministic source archive fixture\n";
3768 let descriptor_bytes = b"canonical descriptor fixture\n";
3769 let archive_blob = hash_git_object(
3770 &git_directory,
3771 GitSnapshotObjectKind::Blob,
3772 false,
3773 archive_bytes,
3774 );
3775 let descriptor_blob = hash_git_object(
3776 &git_directory,
3777 GitSnapshotObjectKind::Blob,
3778 false,
3779 descriptor_bytes,
3780 );
3781 let graph = complete_git_snapshot_object_graph::<GitSnapshotError>(
3782 archive_blob.clone(),
3783 descriptor_blob.clone(),
3784 "operation-snapshot-graph-1",
3785 1_234_567,
3786 |kind, bytes| Ok(hash_git_object(&git_directory, kind, false, bytes)),
3787 )
3788 .unwrap();
3789 let absent = Command::new("git")
3790 .arg("--git-dir")
3791 .arg(&git_directory)
3792 .args([
3793 "cat-file",
3794 "-e",
3795 &format!("{}^{{commit}}", graph.commit.as_str()),
3796 ])
3797 .stdout(Stdio::null())
3798 .stderr(Stdio::null())
3799 .status()
3800 .unwrap();
3801 assert!(!absent.success());
3802
3803 assert_eq!(
3804 hash_git_object(
3805 &git_directory,
3806 GitSnapshotObjectKind::Blob,
3807 true,
3808 archive_bytes,
3809 ),
3810 archive_blob
3811 );
3812 assert_eq!(
3813 hash_git_object(
3814 &git_directory,
3815 GitSnapshotObjectKind::Blob,
3816 true,
3817 descriptor_bytes,
3818 ),
3819 descriptor_blob
3820 );
3821 let tampered_archive_blob = hash_git_object(
3822 &git_directory,
3823 GitSnapshotObjectKind::Blob,
3824 true,
3825 b"different staged archive bytes\n",
3826 );
3827 assert_ne!(tampered_archive_blob, graph.archive_blob);
3828 assert_eq!(
3829 graph.verify_rehashed::<GitSnapshotError>(
3830 tampered_archive_blob,
3831 descriptor_blob.clone(),
3832 "operation-snapshot-graph-1",
3833 1_234_567,
3834 |kind, bytes| Ok(hash_git_object(&git_directory, kind, true, bytes)),
3835 ),
3836 Err(GitSnapshotError::ObjectGraphMismatch)
3837 );
3838 graph
3839 .verify_rehashed::<GitSnapshotError>(
3840 archive_blob,
3841 descriptor_blob,
3842 "operation-snapshot-graph-1",
3843 1_234_567,
3844 |kind, bytes| Ok(hash_git_object(&git_directory, kind, true, bytes)),
3845 )
3846 .unwrap();
3847
3848 let names = Command::new("git")
3849 .arg("--git-dir")
3850 .arg(&git_directory)
3851 .args(["ls-tree", "-r", "--name-only", "-z", graph.commit.as_str()])
3852 .output()
3853 .unwrap();
3854 assert!(names.status.success());
3855 assert_eq!(
3856 names.stdout,
3857 b".rustferry/goal3/source.json\0.rustferry/goal3/source.zip\0"
3858 );
3859 let parents = Command::new("git")
3860 .arg("--git-dir")
3861 .arg(&git_directory)
3862 .args(["rev-list", "--parents", "-n", "1", graph.commit.as_str()])
3863 .output()
3864 .unwrap();
3865 assert!(parents.status.success());
3866 assert_eq!(
3867 String::from_utf8(parents.stdout).unwrap(),
3868 format!("{}\n", graph.commit.as_str())
3869 );
3870 }
3871
3872 #[test]
3873 #[allow(clippy::too_many_lines)]
3874 fn retained_stage_inputs_are_the_blob_authority_for_import_rehash() {
3875 let fixture = stage_fixture();
3876 let git_temporary = tempfile::tempdir().unwrap();
3877 let git_directory = git_temporary.path().join("repository.git");
3878 let initialized = Command::new("git")
3879 .args(["init", "--bare", "--quiet"])
3880 .arg(&git_directory)
3881 .output()
3882 .expect("initialize bare Git repository");
3883 assert!(initialized.status.success());
3884 let format = Command::new("git")
3885 .arg("--git-dir")
3886 .arg(&git_directory)
3887 .args(["rev-parse", "--show-object-format"])
3888 .output()
3889 .unwrap();
3890 assert!(format.status.success());
3891 require_sha1_object_format(&format.stdout).unwrap();
3892
3893 fs::remove_file(&fixture.metadata_path).unwrap();
3894 let directories =
3895 GitSnapshotStageDirectory::open(&fixture.isolation_root, &fixture.request.operation_id)
3896 .unwrap();
3897 let archive_identity =
3898 RegularFileFilesystemIdentity::from_str(&fixture.stage.archive_file_identity).unwrap();
3899 let descriptor_identity =
3900 RegularFileFilesystemIdentity::from_str(&fixture.stage.descriptor_file_identity)
3901 .unwrap();
3902 let mut precompute = directories
3903 .precompute_inputs(
3904 &archive_identity,
3905 &descriptor_identity,
3906 &fixture.stage.archive,
3907 &fixture.descriptor,
3908 )
3909 .unwrap();
3910 let archive_blob = {
3911 let archive = precompute.archive_reader().unwrap();
3912 hash_git_object_file(&git_directory, GitSnapshotObjectKind::Blob, false, archive)
3913 };
3914 precompute.verify_contents().unwrap();
3915 let descriptor_blob = hash_git_object(
3916 &git_directory,
3917 GitSnapshotObjectKind::Blob,
3918 false,
3919 precompute.descriptor_bytes(),
3920 );
3921 precompute.verify_contents().unwrap();
3922 let graph = complete_git_snapshot_object_graph::<GitSnapshotError>(
3923 archive_blob,
3924 descriptor_blob,
3925 &fixture.request.operation_id,
3926 fixture.stage.source_created_at_ms,
3927 |kind, bytes| Ok(hash_git_object(&git_directory, kind, false, bytes)),
3928 )
3929 .unwrap();
3930 drop(precompute);
3931
3932 let mut request = fixture.request.clone();
3933 request.source_revision = Some(graph.commit.as_str().to_owned());
3934 let mut stage = fixture.stage.clone();
3935 stage.graph = graph.clone();
3936 stage.final_request = request.clone();
3937 let locator = directories
3938 .publish_metadata_create_new(&stage, &fixture.descriptor, &request)
3939 .unwrap();
3940 drop(directories);
3941
3942 let mut inputs =
3943 GitSnapshotImportInputs::load(&fixture.isolation_root, &locator, &request).unwrap();
3944 let imported_archive = {
3946 let archive = inputs.archive_reader().unwrap();
3947 hash_git_object_file(&git_directory, GitSnapshotObjectKind::Blob, true, archive)
3948 };
3949 inputs
3950 .verify_imported_archive_blob(&imported_archive)
3951 .unwrap();
3952 let imported_descriptor = hash_git_object(
3953 &git_directory,
3954 GitSnapshotObjectKind::Blob,
3955 true,
3956 inputs.descriptor_bytes(),
3957 );
3958 inputs
3959 .verify_imported_descriptor_blob(&imported_descriptor)
3960 .unwrap();
3961 assert_eq!(imported_archive, stage.graph.archive_blob);
3962 assert_eq!(imported_descriptor, stage.graph.descriptor_blob);
3963 stage
3964 .graph
3965 .verify_rehashed::<GitSnapshotError>(
3966 imported_archive,
3967 imported_descriptor,
3968 &stage.operation_id,
3969 stage.source_created_at_ms,
3970 |kind, bytes| Ok(hash_git_object(&git_directory, kind, true, bytes)),
3971 )
3972 .unwrap();
3973 inputs.verify_bindings().unwrap();
3974 }
3975
3976 #[test]
3977 fn parentless_commit_bytes_bind_actor_time_root_and_operation() {
3978 let bytes = canonical_parentless_snapshot_commit_bytes(
3979 &object('5'),
3980 "operation-snapshot-1",
3981 1_234_567,
3982 )
3983 .unwrap();
3984 assert_eq!(
3985 String::from_utf8(bytes).unwrap(),
3986 concat!(
3987 "tree 5555555555555555555555555555555555555555\n",
3988 "author ShiroKSH <kushidashiro@gmail.com> 1234 +0000\n",
3989 "committer ShiroKSH <kushidashiro@gmail.com> 1234 +0000\n",
3990 "\n",
3991 "RustFerry Git snapshot operation-snapshot-1\n",
3992 )
3993 );
3994 }
3995
3996 #[test]
3997 fn object_ids_are_exact_lowercase_sha1_names() {
3998 assert!(GitSha1ObjectId::new("a".repeat(40)).is_ok());
3999 for invalid in ["a".repeat(39), "A".repeat(40), "g".repeat(40)] {
4000 assert!(GitSha1ObjectId::new(invalid).is_err());
4001 }
4002 }
4003
4004 #[test]
4005 fn imported_blob_mismatch_stops_before_any_tree_or_commit_write() {
4006 let graph = GitSnapshotObjectGraphV1 {
4007 schema_version: GIT_SNAPSHOT_GRAPH_SCHEMA_VERSION,
4008 archive_blob: object('1'),
4009 descriptor_blob: object('2'),
4010 goal3_tree: object('3'),
4011 rustferry_tree: object('4'),
4012 root_tree: object('5'),
4013 commit: object('6'),
4014 };
4015 let mut calls = 0_u8;
4016 assert_eq!(
4017 graph.verify_rehashed::<GitSnapshotError>(
4018 object('a'),
4019 object('2'),
4020 "operation-snapshot-1",
4021 1_234_567,
4022 |_, _| {
4023 calls = calls.saturating_add(1);
4024 Ok(object('f'))
4025 },
4026 ),
4027 Err(GitSnapshotError::ObjectGraphMismatch)
4028 );
4029 assert_eq!(calls, 0);
4030 }
4031
4032 #[test]
4033 fn object_format_gate_accepts_only_exact_sha1_probe_output() {
4034 assert_eq!(require_sha1_object_format(b"sha1\n"), Ok(()));
4035 for invalid in [
4036 b"sha256\n".as_slice(),
4037 b"sha1 sha256\n".as_slice(),
4038 b"sha1".as_slice(),
4039 b"SHA1\n".as_slice(),
4040 ] {
4041 assert_eq!(
4042 require_sha1_object_format(invalid),
4043 Err(GitSnapshotError::UnsupportedObjectFormat)
4044 );
4045 }
4046 }
4047
4048 #[test]
4049 fn git_rejects_the_malformed_flat_full_path_tree_shape() {
4050 let mut malformed = Vec::new();
4051 malformed.extend_from_slice(b"100644 .rustferry/goal3/source.json\0");
4052 malformed.extend_from_slice(&object('1').raw().unwrap());
4053 malformed.extend_from_slice(b"100644 .rustferry/goal3/source.zip\0");
4054 malformed.extend_from_slice(&object('2').raw().unwrap());
4055
4056 let mut child = Command::new("git")
4057 .args(["hash-object", "-t", "tree", "--stdin"])
4058 .stdin(Stdio::piped())
4059 .stdout(Stdio::piped())
4060 .stderr(Stdio::piped())
4061 .spawn()
4062 .expect("spawn Git tree parser");
4063 child
4064 .stdin
4065 .take()
4066 .expect("Git stdin")
4067 .write_all(&malformed)
4068 .expect("write malformed tree");
4069 let output = child.wait_with_output().expect("wait for Git");
4070 assert!(!output.status.success());
4071 }
4072}