1use std::{
34 collections::{BTreeMap, BTreeSet},
35 error::Error,
36 fmt,
37 fs::{self, File, Metadata},
38 io::{self, Read, Seek, SeekFrom, Write},
39 path::Path,
40};
41
42#[cfg(windows)]
43use std::{fs::OpenOptions, os::windows::io::AsHandle as _};
44
45use camino::{Utf8Path, Utf8PathBuf};
46use cap_std::{
47 ambient_authority,
48 fs::{Dir as CapabilityDir, OpenOptions as CapabilityOpenOptions},
49};
50use same_file::Handle as FileIdentityHandle;
51use schemars::JsonSchema;
52use serde::{Deserialize, Serialize};
53use sha2::{Digest, Sha256};
54use unicode_normalization::UnicodeNormalization;
55use uuid::Uuid;
56use zip::{CompressionMethod, DateTime, ZipArchive, ZipWriter, write::SimpleFileOptions};
57
58#[cfg(windows)]
59use rustferry_core::{RegularFileStreamErrorKind, verify_regular_file_has_no_named_streams};
60
61#[cfg(windows)]
62use rustferry_core::windows_private_directory::{
63 regular_file_link_count, remove_private_directory_tree_handle, verify_private_directory_handle,
64};
65
66const MANIFEST_SCHEMA_VERSION: u32 = 1;
67const MANIFEST_DOMAIN: &[u8] = b"rustferry-source-manifest-v1\0";
68pub const SOURCE_BUNDLE_DESCRIPTOR_SCHEMA_VERSION: u32 = 1;
70pub const MAX_SOURCE_BUNDLE_DESCRIPTOR_BYTES: u64 = 128 * 1024 * 1024;
72pub const MAX_GIT_SNAPSHOT_BYTES: u64 = 90 * 1024 * 1024;
74pub const MAX_GIT_SNAPSHOT_DESCRIPTOR_BYTES: u64 = 8 * 1024 * 1024;
76pub const GIT_SNAPSHOT_DESCRIPTOR_SCHEMA_VERSION: u32 = 1;
78pub const GIT_SNAPSHOT_ARCHIVE_PATH: &str = ".rustferry/goal3/source.zip";
80pub const GIT_SNAPSHOT_DESCRIPTOR_PATH: &str = ".rustferry/goal3/source.json";
82pub const GIT_SNAPSHOT_TREE_PATHS: [&str; 2] =
84 [GIT_SNAPSHOT_DESCRIPTOR_PATH, GIT_SNAPSHOT_ARCHIVE_PATH];
85pub const GIT_SNAPSHOT_REF_PREFIX: &str = "refs/rustferry/goal3/snapshots";
87const MAX_PORTABLE_PATH_BYTES: usize = 4_096;
88const MAX_PORTABLE_SEGMENT_BYTES: usize = 255;
89const MAX_IGNORE_PATTERN_BYTES: usize = 1_024;
90const MAX_SUPPORTED_DEPTH: usize = 256;
91pub const MAX_SOURCE_TRAVERSAL_ENTRIES: usize = 100_000;
93const SOURCE_ARCHIVE_TEMP_ATTEMPTS: u64 = 128;
94const SOURCE_ARCHIVE_BUFFER_SIZE: usize = 64 * 1024;
95#[cfg(windows)]
96const WINDOWS_FILE_SHARE_READ: u32 = 0x0000_0001;
97#[cfg(windows)]
98const WINDOWS_FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
99#[cfg(windows)]
100const WINDOWS_FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
101
102const WORKSPACE_BUILD_INPUTS: &[&str] = &[
103 "Cargo.toml",
104 "Cargo.lock",
105 "rust-toolchain",
106 "rust-toolchain.toml",
107 ".cargo/config",
108 ".cargo/config.toml",
109];
110
111const SENSITIVE_DIRECTORIES: &[&str] = &[
112 ".aws",
113 ".azure",
114 ".git",
115 ".gnupg",
116 ".hg",
117 ".kube",
118 ".svn",
119 ".goal2",
120 ".goal3",
121 ".cache",
122 ".ssh",
123 ".tmp",
124 ".venv",
125 "__pycache__",
126 "build",
127 "credentials",
128 "deriveddata",
129 "device-pairing",
130 "dist",
131 "keychains",
132 "node_modules",
133 "out",
134 "provisioning",
135 "secrets",
136 "signing",
137 "target",
138];
139
140const SENSITIVE_FILE_NAMES: &[&str] = &[
141 ".envrc",
142 ".git-credentials",
143 ".netrc",
144 ".npmrc",
145 ".pypirc",
146 "application_default_credentials.json",
147 "credentials",
148 "credentials.json",
149 "credentials.toml",
150 "credentials.yaml",
151 "credentials.yml",
152 "id_dsa",
153 "id_ecdsa",
154 "id_ed25519",
155 "id_rsa",
156 "keystore.properties",
157 "service-account.json",
158 "service_account.json",
159];
160
161const SENSITIVE_EXTENSIONS: &[&str] = &[
162 "cer",
163 "crd",
164 "crt",
165 "der",
166 "ipa",
167 "jks",
168 "key",
169 "keystore",
170 "mobileprovision",
171 "p12",
172 "p8",
173 "pem",
174 "ppk",
175 "provisionprofile",
176 "xcarchive",
177];
178
179#[derive(
181 Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
182)]
183#[serde(rename_all = "snake_case")]
184pub enum SourceMode {
185 Git,
187 GitSnapshot,
189 Snapshot,
191}
192
193#[derive(Clone, Copy, Debug, Eq, PartialEq)]
195pub struct SourceLimits {
196 pub max_file_count: usize,
198 pub max_file_size: u64,
200 pub max_total_size: u64,
202 pub max_depth: usize,
204 pub max_ignore_file_size: u64,
206 pub max_ignore_rules: usize,
208}
209
210#[derive(Clone, Copy, Debug, Eq, PartialEq)]
212pub struct SourceArchiveLimits {
213 pub source: SourceLimits,
215 pub max_archive_size: u64,
217 pub max_compression_ratio: u64,
219}
220
221impl Default for SourceArchiveLimits {
222 fn default() -> Self {
223 Self {
224 source: SourceLimits::default(),
225 max_archive_size: 640 * 1024 * 1024,
226 max_compression_ratio: 100,
227 }
228 }
229}
230
231impl Default for SourceLimits {
232 fn default() -> Self {
233 Self {
234 max_file_count: 20_000,
235 max_file_size: 64 * 1024 * 1024,
236 max_total_size: 512 * 1024 * 1024,
237 max_depth: 64,
238 max_ignore_file_size: 64 * 1024,
239 max_ignore_rules: 1_024,
240 }
241 }
242}
243
244#[derive(Clone, Debug, Eq, PartialEq)]
249#[must_use]
250pub struct SourceBundleRequest {
251 workspace_root: Utf8PathBuf,
252 project_root: Utf8PathBuf,
253 workspace_inputs: Vec<Utf8PathBuf>,
254 workspace_exclusions: Vec<Utf8PathBuf>,
255 executable_modes: Vec<(Utf8PathBuf, bool)>,
256 max_traversal_entries: usize,
257 limits: SourceLimits,
258}
259
260impl SourceBundleRequest {
261 pub fn new(
263 workspace_root: impl Into<Utf8PathBuf>,
264 project_root: impl Into<Utf8PathBuf>,
265 ) -> Self {
266 Self {
267 workspace_root: workspace_root.into(),
268 project_root: project_root.into(),
269 workspace_inputs: Vec::new(),
270 workspace_exclusions: Vec::new(),
271 executable_modes: Vec::new(),
272 max_traversal_entries: MAX_SOURCE_TRAVERSAL_ENTRIES,
273 limits: SourceLimits::default(),
274 }
275 }
276
277 pub fn include_workspace_path(mut self, path: impl Into<Utf8PathBuf>) -> Self {
279 self.workspace_inputs.push(path.into());
280 self
281 }
282
283 pub fn exclude_workspace_path(mut self, path: impl Into<Utf8PathBuf>) -> Self {
290 self.workspace_exclusions.push(path.into());
291 self
292 }
293
294 pub fn with_executable_mode(mut self, path: impl Into<Utf8PathBuf>, executable: bool) -> Self {
301 self.executable_modes.push((path.into(), executable));
302 self
303 }
304
305 pub fn with_max_traversal_entries(mut self, maximum: usize) -> Self {
312 self.max_traversal_entries = maximum;
313 self
314 }
315
316 pub fn with_limits(mut self, limits: SourceLimits) -> Self {
318 self.limits = limits;
319 self
320 }
321
322 pub fn workspace_root(&self) -> &Utf8Path {
324 &self.workspace_root
325 }
326
327 pub fn project_root(&self) -> &Utf8Path {
329 &self.project_root
330 }
331
332 pub fn workspace_inputs(&self) -> &[Utf8PathBuf] {
334 &self.workspace_inputs
335 }
336
337 pub fn workspace_exclusions(&self) -> &[Utf8PathBuf] {
339 &self.workspace_exclusions
340 }
341
342 pub fn executable_modes(&self) -> &[(Utf8PathBuf, bool)] {
344 &self.executable_modes
345 }
346
347 pub fn max_traversal_entries(&self) -> usize {
352 self.max_traversal_entries
353 }
354
355 pub fn limits(&self) -> SourceLimits {
357 self.limits
358 }
359}
360
361#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
363#[serde(deny_unknown_fields)]
364pub struct SourceManifestEntry {
365 pub path: String,
367 pub size: u64,
369 pub sha256: String,
371 pub executable: bool,
373}
374
375#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
377#[serde(deny_unknown_fields)]
378pub struct SourceManifest {
379 pub schema_version: u32,
381 pub project_path: String,
383 pub entries: Vec<SourceManifestEntry>,
385 pub total_size: u64,
387 pub sha256: String,
389}
390
391#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
393#[serde(deny_unknown_fields)]
394pub struct SourceArchive {
395 pub size: u64,
397 pub sha256: String,
399}
400
401#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
403#[serde(deny_unknown_fields)]
404pub struct SourceBundleDescriptor {
405 pub schema_version: u32,
407 pub archive: SourceArchive,
409 pub manifest: SourceManifest,
411}
412
413#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
420#[serde(deny_unknown_fields)]
421pub struct GitSnapshotDescriptor {
422 pub schema_version: u32,
424 pub operation_id: String,
426 pub source_repository: String,
428 pub snapshot_ref: String,
430 pub request_template_sha256: String,
432 pub bundle: SourceBundleDescriptor,
434}
435
436impl GitSnapshotDescriptor {
437 pub fn from_request(
444 request: &crate::protocol::IosDeviceBuildRequest,
445 bundle: SourceBundleDescriptor,
446 ) -> Result<Self, SourceError> {
447 if request.source_mode != SourceMode::GitSnapshot {
448 return Err(invalid_git_snapshot(
449 "descriptor requires Git snapshot source mode",
450 ));
451 }
452 if bundle.manifest != request.source {
453 return Err(invalid_git_snapshot(
454 "descriptor manifest does not match request source",
455 ));
456 }
457 let source_repository = request
458 .source_repository
459 .clone()
460 .ok_or_else(|| invalid_git_snapshot("request has no source repository"))?;
461 let snapshot_ref = git_snapshot_ref(&request.operation_id)?;
462 let request_template_sha256 =
463 crate::protocol::canonical_git_snapshot_request_template_sha256(request)
464 .map_err(|_| invalid_git_snapshot("request template is invalid"))?;
465 let descriptor = Self {
466 schema_version: GIT_SNAPSHOT_DESCRIPTOR_SCHEMA_VERSION,
467 operation_id: request.operation_id.clone(),
468 source_repository,
469 snapshot_ref,
470 request_template_sha256,
471 bundle,
472 };
473 descriptor.validate(git_snapshot_archive_limits())?;
474 Ok(descriptor)
475 }
476
477 pub fn validate(&self, limits: SourceArchiveLimits) -> Result<(), SourceError> {
483 if self.schema_version != GIT_SNAPSHOT_DESCRIPTOR_SCHEMA_VERSION {
484 return Err(invalid_git_snapshot(
485 "unsupported Git snapshot descriptor schema version",
486 ));
487 }
488 let expected_ref = git_snapshot_ref(&self.operation_id)?;
489 if self.snapshot_ref != expected_ref {
490 return Err(invalid_git_snapshot(
491 "snapshot ref is not derived from the operation identifier",
492 ));
493 }
494 crate::protocol::validate_github_repository(&self.source_repository)
495 .map_err(|_| invalid_git_snapshot("source repository is not canonical"))?;
496 validate_sha256(&self.request_template_sha256)
497 .map_err(|_| invalid_git_snapshot("request template SHA-256 is invalid"))?;
498 self.bundle.validate(limits)?;
499 if self.bundle.archive.size > MAX_GIT_SNAPSHOT_BYTES
500 || self.bundle.manifest.total_size > MAX_GIT_SNAPSHOT_BYTES
501 {
502 return Err(limit_error(
503 SourceLimitKind::GitSnapshotSize,
504 None,
505 MAX_GIT_SNAPSHOT_BYTES,
506 self.bundle
507 .archive
508 .size
509 .max(self.bundle.manifest.total_size),
510 ));
511 }
512 Ok(())
513 }
514
515 pub fn validate_for_request(
521 &self,
522 request: &crate::protocol::IosDeviceBuildRequest,
523 limits: SourceArchiveLimits,
524 ) -> Result<(), SourceError> {
525 request
526 .validate()
527 .map_err(|_| invalid_git_snapshot("final request is invalid"))?;
528 self.validate(limits)?;
529 if request.source_mode != SourceMode::GitSnapshot
530 || request.operation_id != self.operation_id
531 || request.source_repository.as_deref() != Some(self.source_repository.as_str())
532 || request.source != self.bundle.manifest
533 {
534 return Err(invalid_git_snapshot(
535 "descriptor does not match the final request",
536 ));
537 }
538 let template_sha256 =
539 crate::protocol::canonical_git_snapshot_request_template_sha256(request)
540 .map_err(|_| invalid_git_snapshot("final request template is invalid"))?;
541 if template_sha256 != self.request_template_sha256 {
542 return Err(invalid_git_snapshot(
543 "request template SHA-256 does not match the descriptor",
544 ));
545 }
546 Ok(())
547 }
548}
549
550pub fn git_snapshot_archive_limits() -> SourceArchiveLimits {
552 let mut limits = SourceArchiveLimits::default();
553 limits.source.max_total_size = MAX_GIT_SNAPSHOT_BYTES;
554 limits.max_archive_size = MAX_GIT_SNAPSHOT_BYTES;
555 limits
556}
557
558pub fn git_snapshot_ref(operation_id: &str) -> Result<String, SourceError> {
564 if operation_id.is_empty()
565 || operation_id.len() > 128
566 || operation_id.starts_with('.')
567 || operation_id.ends_with('.')
568 || operation_id
569 .get(operation_id.len().saturating_sub(5)..)
570 .is_some_and(|suffix| suffix.eq_ignore_ascii_case(".lock"))
571 || operation_id.contains("..")
572 || !operation_id
573 .bytes()
574 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
575 {
576 return Err(invalid_git_snapshot(
577 "operation identifier is not one safe Git-ref segment",
578 ));
579 }
580 Ok(format!("{GIT_SNAPSHOT_REF_PREFIX}/{operation_id}"))
581}
582
583pub fn canonical_git_snapshot_descriptor_bytes(
590 descriptor: &GitSnapshotDescriptor,
591) -> Result<Vec<u8>, SourceError> {
592 descriptor.validate(git_snapshot_archive_limits())?;
593 let mut bytes =
594 serde_json::to_vec_pretty(descriptor).map_err(|error| SourceError::InvalidGitSnapshot {
595 reason: format!("descriptor JSON serialization failed: {error}"),
596 })?;
597 bytes.push(b'\n');
598 let size = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
599 if size > MAX_GIT_SNAPSHOT_DESCRIPTOR_BYTES {
600 return Err(limit_error(
601 SourceLimitKind::DescriptorSize,
602 Some(GIT_SNAPSHOT_DESCRIPTOR_PATH.to_owned()),
603 MAX_GIT_SNAPSHOT_DESCRIPTOR_BYTES,
604 size,
605 ));
606 }
607 let combined = descriptor
608 .bundle
609 .archive
610 .size
611 .checked_add(size)
612 .ok_or_else(|| {
613 limit_error(
614 SourceLimitKind::GitSnapshotSize,
615 None,
616 MAX_GIT_SNAPSHOT_BYTES,
617 u64::MAX,
618 )
619 })?;
620 if combined > MAX_GIT_SNAPSHOT_BYTES {
621 return Err(limit_error(
622 SourceLimitKind::GitSnapshotSize,
623 None,
624 MAX_GIT_SNAPSHOT_BYTES,
625 combined,
626 ));
627 }
628 Ok(bytes)
629}
630
631impl SourceBundleDescriptor {
632 pub fn new(archive: SourceArchive, manifest: SourceManifest) -> Self {
634 Self {
635 schema_version: SOURCE_BUNDLE_DESCRIPTOR_SCHEMA_VERSION,
636 archive,
637 manifest,
638 }
639 }
640
641 pub fn validate(&self, limits: SourceArchiveLimits) -> Result<(), SourceError> {
651 if self.schema_version != SOURCE_BUNDLE_DESCRIPTOR_SCHEMA_VERSION {
652 return Err(SourceError::InvalidArchive {
653 reason: "unsupported source-bundle descriptor schema version".to_owned(),
654 });
655 }
656 validate_archive_limits(limits)?;
657 if self.archive.size == 0 {
658 return Err(SourceError::InvalidArchive {
659 reason: "source archive size must be positive".to_owned(),
660 });
661 }
662 validate_sha256(&self.archive.sha256).map_err(|_| SourceError::InvalidArchive {
663 reason: "source archive SHA-256 is not 64 lowercase hexadecimal characters".to_owned(),
664 })?;
665 if self.archive.size > limits.max_archive_size {
666 return Err(limit_error(
667 SourceLimitKind::ArchiveSize,
668 None,
669 limits.max_archive_size,
670 self.archive.size,
671 ));
672 }
673 validate_source_manifest(&self.manifest, limits.source)
674 }
675}
676
677#[derive(Clone, Debug, Eq, PartialEq)]
682pub struct PlannedSourceFile {
683 source_path: Utf8PathBuf,
684 bundle_path: Utf8PathBuf,
685}
686
687impl PlannedSourceFile {
688 pub fn source_path(&self) -> &Utf8Path {
690 &self.source_path
691 }
692
693 pub fn bundle_path(&self) -> &Utf8Path {
695 &self.bundle_path
696 }
697}
698
699#[derive(Clone, Debug, Eq, PartialEq)]
701pub struct SourceBundlePlan {
702 request: SourceBundleRequest,
703 manifest: SourceManifest,
704 files: Vec<PlannedSourceFile>,
705 executable_modes: BTreeMap<String, bool>,
706 excluded_sensitive_paths: Vec<String>,
707}
708
709impl SourceBundlePlan {
710 pub fn manifest(&self) -> &SourceManifest {
712 &self.manifest
713 }
714
715 pub fn files(&self) -> &[PlannedSourceFile] {
717 &self.files
718 }
719
720 pub fn excluded_sensitive_paths(&self) -> &[String] {
725 &self.excluded_sensitive_paths
726 }
727}
728
729#[derive(Clone, Copy, Debug, Eq, PartialEq)]
731pub enum PortablePathReason {
732 Empty,
734 Absolute,
736 DrivePrefix,
738 UncPrefix,
740 Backslash,
742 DotComponent,
744 EmptyComponent,
746 InvalidCharacter,
748 TrailingDotOrSpace,
750 NonNfc,
752 ReservedName,
754 PathTooLong,
756 SegmentTooLong,
758}
759
760#[derive(Clone, Copy, Debug, Eq, PartialEq)]
762pub enum SourceLimitKind {
763 FileCount,
765 FileSize,
767 TotalSize,
769 Depth,
771 IgnoreFileSize,
773 IgnoreRuleCount,
775 TraversalEntryCount,
777 ArchiveSize,
779 DescriptorSize,
781 GitSnapshotSize,
783 CompressionRatio,
785}
786
787#[derive(Clone, Copy, Debug, Eq, PartialEq)]
789pub enum IgnoreRuleReason {
790 Negation,
792 Glob,
794 EdgeWhitespace,
796 PatternTooLong,
798 EmptyDirectoryPattern,
800 NonPortable(PortablePathReason),
802}
803
804#[derive(Debug)]
806pub enum SourceError {
807 InvalidRoot {
809 root: &'static str,
811 reason: &'static str,
813 },
814 ProjectOutsideWorkspace,
816 NonUtf8Path,
818 NonPortablePath {
820 path: String,
822 reason: PortablePathReason,
824 },
825 SensitivePath {
827 path: String,
829 },
830 SensitiveContent {
832 path: String,
834 },
835 ReparsePoint {
837 path: String,
839 },
840 AlternateDataStream {
842 path: String,
844 },
845 StreamInspectionUnavailable {
847 path: String,
849 },
850 MissingInput {
852 path: String,
854 },
855 Symlink {
857 path: String,
859 },
860 HardLink {
862 path: String,
864 links: u64,
866 },
867 HardLinkInspectionUnavailable {
869 path: String,
871 },
872 UnsupportedFileType {
874 path: String,
876 },
877 CaseCollision {
879 first: String,
881 second: String,
883 },
884 LimitExceeded {
886 kind: SourceLimitKind,
888 path: Option<String>,
890 maximum: u64,
892 actual: u64,
894 },
895 UnsupportedDepthLimit {
897 requested: usize,
899 maximum: usize,
901 },
902 UnsupportedTraversalLimit {
904 requested: usize,
906 maximum: usize,
908 },
909 DuplicateExecutableMode {
911 path: String,
913 },
914 ExecutableModePathNotSelected {
916 path: String,
918 },
919 InvalidIgnoreRule {
921 file: String,
923 line: usize,
925 reason: IgnoreRuleReason,
927 },
928 ChangedDuringRead {
930 path: String,
932 },
933 InvalidManifest {
935 reason: &'static str,
937 },
938 ManifestMismatch,
940 OutputExists {
942 path: String,
944 },
945 DestinationExists {
947 path: String,
949 },
950 InvalidDestination {
952 reason: &'static str,
954 },
955 InvalidArchive {
957 reason: String,
959 },
960 InvalidGitSnapshot {
962 reason: String,
964 },
965 ArchiveIntegrityMismatch {
967 reason: &'static str,
969 },
970 CleanupFailed {
972 path: String,
974 original: String,
976 source: io::Error,
978 },
979 Zip {
981 operation: &'static str,
983 message: String,
985 },
986 Io {
988 operation: &'static str,
990 path: String,
992 source: io::Error,
994 },
995}
996
997impl fmt::Display for SourceError {
998 #[allow(clippy::too_many_lines)]
999 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1000 match self {
1001 Self::InvalidRoot { root, reason } => write!(formatter, "invalid {root}: {reason}"),
1002 Self::ProjectOutsideWorkspace => {
1003 formatter.write_str("project root resolves outside workspace root")
1004 }
1005 Self::NonUtf8Path => formatter.write_str("source path is not valid UTF-8"),
1006 Self::NonPortablePath { path, reason } => {
1007 write!(formatter, "non-portable source path {path:?}: {reason:?}")
1008 }
1009 Self::SensitivePath { path } => {
1010 write!(formatter, "sensitive source path is excluded: {path}")
1011 }
1012 Self::SensitiveContent { path } => {
1013 write!(
1014 formatter,
1015 "credential-like source contents are excluded: {path}"
1016 )
1017 }
1018 Self::ReparsePoint { path } => {
1019 write!(formatter, "Windows reparse points are not allowed: {path}")
1020 }
1021 Self::AlternateDataStream { path } => {
1022 write!(formatter, "alternate data streams are not allowed: {path}")
1023 }
1024 Self::StreamInspectionUnavailable { path } => {
1025 write!(
1026 formatter,
1027 "cannot inspect data streams for source file: {path}"
1028 )
1029 }
1030 Self::MissingInput { path } => write!(formatter, "source input does not exist: {path}"),
1031 Self::Symlink { path } => write!(formatter, "symbolic links are not allowed: {path}"),
1032 Self::HardLink { path, links } => {
1033 write!(
1034 formatter,
1035 "hard-linked source file is not allowed: {path} ({links} links)"
1036 )
1037 }
1038 Self::HardLinkInspectionUnavailable { path } => {
1039 write!(
1040 formatter,
1041 "cannot inspect hard-link count for source file: {path}"
1042 )
1043 }
1044 Self::UnsupportedFileType { path } => {
1045 write!(formatter, "unsupported source file type: {path}")
1046 }
1047 Self::CaseCollision { first, second } => {
1048 write!(
1049 formatter,
1050 "case-insensitive path collision: {first} and {second}"
1051 )
1052 }
1053 Self::LimitExceeded {
1054 kind,
1055 path,
1056 maximum,
1057 actual,
1058 } => write!(
1059 formatter,
1060 "source {kind:?} limit exceeded{}: {actual} > {maximum}",
1061 path.as_deref()
1062 .map_or_else(String::new, |path| format!(" at {path}"))
1063 ),
1064 Self::UnsupportedDepthLimit { requested, maximum } => write!(
1065 formatter,
1066 "source depth limit {requested} exceeds supported maximum {maximum}"
1067 ),
1068 Self::UnsupportedTraversalLimit { requested, maximum } => write!(
1069 formatter,
1070 "source traversal-entry limit {requested} exceeds supported maximum {maximum}"
1071 ),
1072 Self::DuplicateExecutableMode { path } => {
1073 write!(formatter, "duplicate executable-mode metadata for {path}")
1074 }
1075 Self::ExecutableModePathNotSelected { path } => {
1076 write!(
1077 formatter,
1078 "executable-mode metadata path is not selected: {path}"
1079 )
1080 }
1081 Self::InvalidIgnoreRule { file, line, reason } => {
1082 write!(formatter, "invalid {file} rule on line {line}: {reason:?}")
1083 }
1084 Self::ChangedDuringRead { path } => {
1085 write!(formatter, "source file changed while being read: {path}")
1086 }
1087 Self::InvalidManifest { reason } => {
1088 write!(formatter, "invalid source manifest: {reason}")
1089 }
1090 Self::ManifestMismatch => formatter.write_str("source manifest does not match exactly"),
1091 Self::OutputExists { path } => {
1092 write!(formatter, "source archive output already exists: {path}")
1093 }
1094 Self::DestinationExists { path } => {
1095 write!(
1096 formatter,
1097 "source extraction destination already exists: {path}"
1098 )
1099 }
1100 Self::InvalidDestination { reason } => {
1101 write!(formatter, "invalid source archive destination: {reason}")
1102 }
1103 Self::InvalidArchive { reason } => {
1104 write!(formatter, "invalid source archive: {reason}")
1105 }
1106 Self::InvalidGitSnapshot { reason } => {
1107 write!(formatter, "invalid Git source snapshot: {reason}")
1108 }
1109 Self::ArchiveIntegrityMismatch { reason } => {
1110 write!(formatter, "source archive integrity mismatch: {reason}")
1111 }
1112 Self::CleanupFailed {
1113 path,
1114 original,
1115 source,
1116 } => write!(
1117 formatter,
1118 "failed to clean operation-created path {path} after {original}: {source}"
1119 ),
1120 Self::Zip { operation, message } => {
1121 write!(formatter, "failed to {operation} source ZIP: {message}")
1122 }
1123 Self::Io {
1124 operation,
1125 path,
1126 source,
1127 } => write!(formatter, "failed to {operation} {path}: {source}"),
1128 }
1129 }
1130}
1131
1132impl Error for SourceError {
1133 fn source(&self) -> Option<&(dyn Error + 'static)> {
1134 match self {
1135 Self::Io { source, .. } | Self::CleanupFailed { source, .. } => Some(source),
1136 _ => None,
1137 }
1138 }
1139}
1140
1141pub fn plan_source_bundle(request: &SourceBundleRequest) -> Result<SourceBundlePlan, SourceError> {
1148 check_supported_depth(request.limits)?;
1149 let mut traversal = TraversalBudget::new(request.max_traversal_entries)?;
1150 let roots = ResolvedRoots::resolve(request)?;
1151 let project_path = display_relative_path(&roots.project_relative);
1152 let executable_modes = validate_executable_modes(request, &mut traversal)?;
1153 let workspace_exclusions = validate_workspace_exclusions(request, &mut traversal)?;
1154 let ignore_rules = load_ignore_rules(&roots, &workspace_exclusions, request.limits)?;
1155 let include_roots =
1156 collect_include_roots(request, &roots, &workspace_exclusions, &mut traversal)?;
1157 let mut scan = scan_paths(
1158 &roots.workspace,
1159 include_roots,
1160 &workspace_exclusions,
1161 &ignore_rules,
1162 request.limits,
1163 &mut traversal,
1164 )?;
1165 apply_executable_modes(&mut scan.entries, &executable_modes)?;
1166 consume_planned_manifest_traversal(&mut traversal, &scan.entries)?;
1167 let manifest = build_manifest(project_path, scan.entries);
1168 validate_source_manifest(&manifest, request.limits)?;
1169 let files = manifest
1170 .entries
1171 .iter()
1172 .map(|entry| PlannedSourceFile {
1173 source_path: roots.workspace.join(&entry.path),
1174 bundle_path: Utf8PathBuf::from(&entry.path),
1175 })
1176 .collect();
1177
1178 Ok(SourceBundlePlan {
1179 request: request.clone(),
1180 manifest,
1181 files,
1182 executable_modes,
1183 excluded_sensitive_paths: scan.excluded_sensitive_paths,
1184 })
1185}
1186
1187pub fn verify_source_bundle_plan(plan: &SourceBundlePlan) -> Result<(), SourceError> {
1195 verify_source_manifest(&plan.request, &plan.manifest)
1196}
1197
1198pub fn verify_source_manifest(
1205 request: &SourceBundleRequest,
1206 expected: &SourceManifest,
1207) -> Result<(), SourceError> {
1208 validate_source_manifest(expected, request.limits)?;
1209 let actual = plan_source_bundle(request)?;
1210 if actual.manifest == *expected {
1211 Ok(())
1212 } else {
1213 Err(SourceError::ManifestMismatch)
1214 }
1215}
1216
1217pub fn verify_materialized_bundle(
1228 bundle_root: &Utf8Path,
1229 expected: &SourceManifest,
1230 limits: SourceLimits,
1231) -> Result<(), SourceError> {
1232 validate_source_manifest(expected, limits)?;
1233 check_supported_depth(limits)?;
1234 reject_root_symlink(bundle_root, "bundle root")?;
1235 let root = canonical_directory(bundle_root, "bundle root")?;
1236 let directory = CapabilityDir::open_ambient_dir(root.as_std_path(), ambient_authority())
1237 .map_err(|source| SourceError::Io {
1238 operation: "open verified bundle root",
1239 path: bundle_root.as_str().to_owned(),
1240 source,
1241 })?;
1242 let identity = capability_directory_identity(&directory).map_err(|source| SourceError::Io {
1243 operation: "capture verified bundle root identity",
1244 path: bundle_root.as_str().to_owned(),
1245 source,
1246 })?;
1247 ensure_capability_directory_path(&directory, &root, &identity).map_err(|source| {
1248 SourceError::Io {
1249 operation: "bind verified bundle root handle to path",
1250 path: bundle_root.as_str().to_owned(),
1251 source,
1252 }
1253 })?;
1254 verify_materialized_capability(&directory, expected, limits)?;
1255 ensure_capability_directory_path(&directory, &root, &identity).map_err(|source| {
1256 SourceError::Io {
1257 operation: "rebind verified bundle root handle to path",
1258 path: bundle_root.as_str().to_owned(),
1259 source,
1260 }
1261 })
1262}
1263
1264fn verify_materialized_capability(
1265 directory: &CapabilityDir,
1266 expected: &SourceManifest,
1267 limits: SourceLimits,
1268) -> Result<(), SourceError> {
1269 let mut scan = scan_capability_paths(directory, limits)?;
1270 normalize_unobservable_executable_modes(&mut scan.entries, &expected.entries);
1271 let unexpected_directory = has_unexpected_directory(&scan.directories, expected);
1272 let actual = build_manifest(expected.project_path.clone(), scan.entries);
1273 if actual == *expected && !unexpected_directory {
1274 Ok(())
1275 } else {
1276 Err(SourceError::ManifestMismatch)
1277 }
1278}
1279
1280pub fn validate_source_manifest(
1286 manifest: &SourceManifest,
1287 limits: SourceLimits,
1288) -> Result<(), SourceError> {
1289 if manifest.schema_version != MANIFEST_SCHEMA_VERSION {
1290 return Err(SourceError::InvalidManifest {
1291 reason: "unsupported schema version",
1292 });
1293 }
1294 validate_portable_path_str(&manifest.project_path, true).map_err(|reason| {
1295 SourceError::NonPortablePath {
1296 path: manifest.project_path.clone(),
1297 reason,
1298 }
1299 })?;
1300 if manifest.project_path != "." {
1301 reject_sensitive(&manifest.project_path)?;
1302 }
1303 check_supported_depth(limits)?;
1304 if manifest.entries.len() > limits.max_file_count {
1305 return Err(limit_error(
1306 SourceLimitKind::FileCount,
1307 None,
1308 limits.max_file_count as u64,
1309 manifest.entries.len() as u64,
1310 ));
1311 }
1312
1313 let mut previous: Option<&str> = None;
1314 let mut collisions = BTreeMap::new();
1315 let mut file_paths = BTreeSet::new();
1316 let mut total_size = 0_u64;
1317 let mut traversal_entries = 0_u64;
1318 for entry in &manifest.entries {
1319 validate_portable_path_str(&entry.path, false).map_err(|reason| {
1320 SourceError::NonPortablePath {
1321 path: entry.path.clone(),
1322 reason,
1323 }
1324 })?;
1325 reject_sensitive(&entry.path)?;
1326 check_depth(&entry.path, limits.max_depth)?;
1327 consume_manifest_traversal(&mut traversal_entries, &entry.path)?;
1328 register_path_components(&entry.path, &mut collisions)?;
1329 if entry.path == manifest.project_path {
1330 return Err(SourceError::InvalidManifest {
1331 reason: "project path is a file entry",
1332 });
1333 }
1334 if has_file_ancestor(&entry.path, &file_paths) {
1335 return Err(SourceError::InvalidManifest {
1336 reason: "a file entry is also used as a directory",
1337 });
1338 }
1339 if previous.is_some_and(|previous| previous >= entry.path.as_str()) {
1340 return Err(SourceError::InvalidManifest {
1341 reason: "entries are not strictly sorted by path",
1342 });
1343 }
1344 previous = Some(&entry.path);
1345 file_paths.insert(entry.path.clone());
1346 if entry.size > limits.max_file_size {
1347 return Err(limit_error(
1348 SourceLimitKind::FileSize,
1349 Some(entry.path.clone()),
1350 limits.max_file_size,
1351 entry.size,
1352 ));
1353 }
1354 validate_sha256(&entry.sha256)?;
1355 total_size = total_size
1356 .checked_add(entry.size)
1357 .ok_or(SourceError::InvalidManifest {
1358 reason: "total size overflow",
1359 })?;
1360 if total_size > limits.max_total_size {
1361 return Err(limit_error(
1362 SourceLimitKind::TotalSize,
1363 Some(entry.path.clone()),
1364 limits.max_total_size,
1365 total_size,
1366 ));
1367 }
1368 }
1369 if total_size != manifest.total_size {
1370 return Err(SourceError::InvalidManifest {
1371 reason: "total size does not equal entry sizes",
1372 });
1373 }
1374 validate_sha256(&manifest.sha256)?;
1375 if manifest_digest(
1376 &manifest.project_path,
1377 &manifest.entries,
1378 manifest.total_size,
1379 ) != manifest.sha256
1380 {
1381 return Err(SourceError::InvalidManifest {
1382 reason: "manifest digest does not match canonical fields",
1383 });
1384 }
1385 Ok(())
1386}
1387
1388pub fn write_source_bundle_descriptor_file(
1402 descriptor: &SourceBundleDescriptor,
1403 output: &Utf8Path,
1404 limits: SourceArchiveLimits,
1405) -> Result<(), SourceError> {
1406 descriptor.validate(limits)?;
1407 let temporary = create_archive_temporary(output)?;
1408 let operation_parent = match temporary.parent.try_clone() {
1409 Ok(parent) => parent,
1410 Err(source) => {
1411 let original = SourceError::Io {
1412 operation: "clone descriptor output parent handle",
1413 path: output.as_str().to_owned(),
1414 source,
1415 };
1416 drop(temporary.file);
1417 return match remove_owned_capability_file(
1418 &temporary.parent,
1419 &temporary.temporary_name,
1420 &temporary.identity,
1421 ) {
1422 Ok(()) => Err(original),
1423 Err(source) => Err(SourceError::CleanupFailed {
1424 path: temporary.temporary_path.as_str().to_owned(),
1425 original: original.to_string(),
1426 source,
1427 }),
1428 };
1429 }
1430 };
1431 let mut cleanup = PartialFileCleanup::new(
1432 temporary.temporary_path.clone(),
1433 temporary.parent,
1434 temporary.temporary_name.clone(),
1435 temporary.identity,
1436 );
1437 let publication = (|| {
1438 let mut writer =
1439 DescriptorSizeLimitedWriter::new(temporary.file, MAX_SOURCE_BUNDLE_DESCRIPTOR_BYTES);
1440 if let Err(error) = serde_json::to_writer_pretty(&mut writer, descriptor) {
1441 return Err(descriptor_json_error(writer.exceeded(), output, &error));
1442 }
1443 if let Err(source) = writer.write_all(b"\n") {
1444 return Err(descriptor_write_error(
1445 writer.exceeded(),
1446 output,
1447 "write source bundle descriptor",
1448 source,
1449 ));
1450 }
1451 let file = writer.into_inner();
1452 file.sync_all().map_err(|source| SourceError::Io {
1453 operation: "synchronize source bundle descriptor",
1454 path: output.as_str().to_owned(),
1455 source,
1456 })?;
1457 drop(file);
1458
1459 ensure_capability_directory_path(
1460 &operation_parent,
1461 &temporary.parent_path,
1462 &temporary.parent_identity,
1463 )
1464 .map_err(|source| SourceError::Io {
1465 operation: "bind descriptor output parent handle to path",
1466 path: output.as_str().to_owned(),
1467 source,
1468 })?;
1469 publish_archive_no_clobber(
1470 &operation_parent,
1471 &temporary.temporary_name,
1472 &temporary.output_name,
1473 &cleanup.identity,
1474 output,
1475 )?;
1476 cleanup.mark_published(&temporary.output_name);
1477 cleanup.remove_temporary_link_after_publish()?;
1478 ensure_capability_directory_path(
1479 &operation_parent,
1480 &temporary.parent_path,
1481 &temporary.parent_identity,
1482 )
1483 .map_err(|source| SourceError::Io {
1484 operation: "rebind descriptor output parent handle to path",
1485 path: output.as_str().to_owned(),
1486 source,
1487 })?;
1488 ensure_named_capability_file(&operation_parent, &temporary.output_name, &cleanup.identity)
1489 .map_err(|source| SourceError::Io {
1490 operation: "bind published descriptor handle to output path",
1491 path: output.as_str().to_owned(),
1492 source,
1493 })?;
1494 cleanup.keep_published();
1495 Ok(())
1496 })();
1497
1498 match publication {
1499 Ok(()) => Ok(()),
1500 Err(error) => cleanup.fail(error),
1501 }
1502}
1503
1504pub fn create_source_bundle_archive(
1514 plan: &SourceBundlePlan,
1515 output: &Utf8Path,
1516 limits: SourceArchiveLimits,
1517) -> Result<SourceArchive, SourceError> {
1518 create_source_bundle_archive_with_temporary_hook(plan, output, limits, || {})
1519}
1520
1521#[allow(clippy::too_many_lines)]
1522fn create_source_bundle_archive_with_temporary_hook(
1523 plan: &SourceBundlePlan,
1524 output: &Utf8Path,
1525 limits: SourceArchiveLimits,
1526 temporary_created: impl FnOnce(),
1527) -> Result<SourceArchive, SourceError> {
1528 validate_archive_limits(limits)?;
1529 validate_source_manifest(&plan.manifest, limits.source)?;
1530 if plan.manifest.entries.len() != plan.files.len() {
1531 return Err(SourceError::ManifestMismatch);
1532 }
1533 verify_source_bundle_plan(plan)?;
1534
1535 let temporary = create_archive_temporary(output)?;
1536 temporary_created();
1537 let cleanup_parent = match temporary.parent.try_clone() {
1538 Ok(parent) => parent,
1539 Err(source) => {
1540 let original = SourceError::Io {
1541 operation: "clone archive output parent handle",
1542 path: output.as_str().to_owned(),
1543 source,
1544 };
1545 drop(temporary.file);
1546 return match remove_owned_capability_file(
1547 &temporary.parent,
1548 &temporary.temporary_name,
1549 &temporary.identity,
1550 ) {
1551 Ok(()) => Err(original),
1552 Err(source) => Err(SourceError::CleanupFailed {
1553 path: temporary.temporary_path.as_str().to_owned(),
1554 original: original.to_string(),
1555 source,
1556 }),
1557 };
1558 }
1559 };
1560 let mut cleanup = PartialFileCleanup::new(
1561 temporary.temporary_path.clone(),
1562 cleanup_parent,
1563 temporary.temporary_name.clone(),
1564 temporary.identity,
1565 );
1566 let creation = (|| {
1567 let mut writer = ZipWriter::new(ArchiveSizeLimitedWriter::new(
1568 temporary.file,
1569 limits.max_archive_size,
1570 ));
1571 for (planned, expected) in plan.files.iter().zip(&plan.manifest.entries) {
1572 if planned.bundle_path.as_str() != expected.path {
1573 return Err(SourceError::ManifestMismatch);
1574 }
1575 write_verified_zip_entry(
1576 &mut writer,
1577 planned,
1578 expected,
1579 plan.executable_modes.get(&expected.path).copied(),
1580 )?;
1581 }
1582 let mut file = writer
1583 .finish()
1584 .map_err(|error| archive_zip_error("finish", error))?
1585 .into_inner();
1586 file.sync_all().map_err(|source| SourceError::Io {
1587 operation: "synchronize",
1588 path: temporary.temporary_path.as_str().to_owned(),
1589 source,
1590 })?;
1591 verify_source_bundle_plan(plan)?;
1592 let descriptor = describe_open_archive(&mut file, limits.max_archive_size)?;
1593 drop(file);
1594 ensure_capability_directory_path(
1595 &temporary.parent,
1596 &temporary.parent_path,
1597 &temporary.parent_identity,
1598 )
1599 .map_err(|source| SourceError::Io {
1600 operation: "bind archive output parent handle to path",
1601 path: output.as_str().to_owned(),
1602 source,
1603 })?;
1604 publish_archive_no_clobber(
1605 &temporary.parent,
1606 &temporary.temporary_name,
1607 &temporary.output_name,
1608 &cleanup.identity,
1609 output,
1610 )?;
1611 cleanup.mark_published(&temporary.output_name);
1612 cleanup.remove_temporary_link_after_publish()?;
1613 ensure_capability_directory_path(
1614 &temporary.parent,
1615 &temporary.parent_path,
1616 &temporary.parent_identity,
1617 )
1618 .map_err(|source| SourceError::Io {
1619 operation: "rebind archive output parent handle to path",
1620 path: output.as_str().to_owned(),
1621 source,
1622 })?;
1623 ensure_named_capability_file(&temporary.parent, &temporary.output_name, &cleanup.identity)
1624 .map_err(|source| SourceError::Io {
1625 operation: "bind published archive handle to output path",
1626 path: output.as_str().to_owned(),
1627 source,
1628 })?;
1629 cleanup.keep_published();
1630 Ok(descriptor)
1631 })();
1632
1633 match creation {
1634 Ok(descriptor) => Ok(descriptor),
1635 Err(error) => cleanup.fail(error),
1636 }
1637}
1638
1639pub fn verify_and_extract_source_bundle(
1651 archive_path: &Utf8Path,
1652 expected_archive: &SourceArchive,
1653 expected_manifest: &SourceManifest,
1654 destination: &Utf8Path,
1655 limits: SourceArchiveLimits,
1656) -> Result<SourceArchive, SourceError> {
1657 verify_and_extract_source_bundle_inner(
1658 archive_path,
1659 expected_archive,
1660 expected_manifest,
1661 destination,
1662 None,
1663 limits,
1664 )
1665}
1666
1667pub fn verify_and_extract_source_bundle_with_parent_handle(
1680 archive_path: &Utf8Path,
1681 expected_archive: &SourceArchive,
1682 expected_manifest: &SourceManifest,
1683 destination: &Utf8Path,
1684 destination_parent: File,
1685 limits: SourceArchiveLimits,
1686) -> Result<SourceArchive, SourceError> {
1687 verify_and_extract_source_bundle_inner(
1688 archive_path,
1689 expected_archive,
1690 expected_manifest,
1691 destination,
1692 Some(destination_parent),
1693 limits,
1694 )
1695}
1696
1697fn verify_and_extract_source_bundle_inner(
1698 archive_path: &Utf8Path,
1699 expected_archive: &SourceArchive,
1700 expected_manifest: &SourceManifest,
1701 destination: &Utf8Path,
1702 destination_parent: Option<File>,
1703 limits: SourceArchiveLimits,
1704) -> Result<SourceArchive, SourceError> {
1705 validate_archive_limits(limits)?;
1706 validate_source_manifest(expected_manifest, limits.source)?;
1707 validate_sha256(&expected_archive.sha256)?;
1708 if expected_archive.size > limits.max_archive_size {
1709 return Err(limit_error(
1710 SourceLimitKind::ArchiveSize,
1711 None,
1712 limits.max_archive_size,
1713 expected_archive.size,
1714 ));
1715 }
1716
1717 let (mut archive, initial_metadata, actual_descriptor) =
1718 open_verified_archive(archive_path, expected_archive, limits.max_archive_size)?;
1719 preflight_archive(&mut archive, expected_manifest, limits)?;
1720
1721 let FreshDestination {
1722 path: actual_destination,
1723 parent: destination_parent,
1724 parent_path: destination_parent_path,
1725 parent_identity: destination_parent_identity,
1726 name: destination_name,
1727 directory: destination_directory,
1728 identity,
1729 } = create_fresh_destination(destination, destination_parent)?;
1730 let mut cleanup = PartialDirectoryCleanup::new(
1731 actual_destination,
1732 destination_parent,
1733 destination_parent_path,
1734 destination_parent_identity,
1735 destination_name,
1736 destination_directory,
1737 identity,
1738 );
1739 let extraction = (|| {
1740 for (index, expected) in expected_manifest.entries.iter().enumerate() {
1741 extract_verified_entry(&mut archive, index, expected, cleanup.directory()?)?;
1742 }
1743 let mut archive_file = archive.into_inner();
1744 let final_descriptor = describe_open_archive(&mut archive_file, limits.max_archive_size)?;
1745 ensure_archive_file_stable(archive_path, &initial_metadata, &archive_file)?;
1746 if final_descriptor != actual_descriptor {
1747 return Err(SourceError::ArchiveIntegrityMismatch {
1748 reason: "archive changed during extraction",
1749 });
1750 }
1751 verify_materialized_capability(cleanup.directory()?, expected_manifest, limits.source)?;
1752 cleanup.keep()?;
1753 Ok(actual_descriptor)
1754 })();
1755
1756 match extraction {
1757 Ok(descriptor) => Ok(descriptor),
1758 Err(error) => cleanup.fail(error),
1759 }
1760}
1761
1762#[derive(Debug)]
1763struct PartialFileCleanup {
1764 path: Utf8PathBuf,
1765 parent: CapabilityDir,
1766 name: String,
1767 identity: FileIdentityHandle,
1768 published_name: Option<String>,
1769 active: bool,
1770}
1771
1772#[derive(Debug)]
1773struct PartialDirectoryCleanup {
1774 path: Utf8PathBuf,
1775 parent: CapabilityDir,
1776 parent_path: Utf8PathBuf,
1777 parent_identity: FileIdentityHandle,
1778 #[cfg(not(windows))]
1779 name: String,
1780 directory: Option<CapabilityDir>,
1781 identity: Option<FileIdentityHandle>,
1782 active: bool,
1783}
1784
1785#[derive(Debug)]
1786struct FreshDestination {
1787 path: Utf8PathBuf,
1788 parent: CapabilityDir,
1789 parent_path: Utf8PathBuf,
1790 parent_identity: FileIdentityHandle,
1791 name: String,
1792 directory: CapabilityDir,
1793 identity: FileIdentityHandle,
1794}
1795
1796impl PartialFileCleanup {
1797 fn new(
1798 path: Utf8PathBuf,
1799 parent: CapabilityDir,
1800 name: String,
1801 identity: FileIdentityHandle,
1802 ) -> Self {
1803 Self {
1804 path,
1805 parent,
1806 name,
1807 identity,
1808 published_name: None,
1809 active: true,
1810 }
1811 }
1812
1813 fn mark_published(&mut self, output_name: &str) {
1814 self.published_name = Some(output_name.to_owned());
1815 }
1816
1817 fn remove_temporary_link_after_publish(&mut self) -> Result<(), SourceError> {
1818 match remove_owned_capability_file(&self.parent, &self.name, &self.identity) {
1819 Ok(()) => Ok(()),
1820 Err(source) => Err(SourceError::CleanupFailed {
1821 path: self.path.as_str().to_owned(),
1822 original: "published archive temporary-link cleanup failed".to_owned(),
1823 source,
1824 }),
1825 }
1826 }
1827
1828 fn keep_published(&mut self) {
1829 self.published_name = None;
1830 self.active = false;
1831 }
1832
1833 fn fail<T>(&mut self, error: SourceError) -> Result<T, SourceError> {
1834 if !self.active {
1835 return Err(error);
1836 }
1837 let published_cleanup = self.published_name.as_ref().map_or(Ok(()), |name| {
1838 remove_owned_capability_file(&self.parent, name, &self.identity)
1839 });
1840 let temporary_cleanup =
1841 remove_owned_capability_file(&self.parent, &self.name, &self.identity);
1842 match published_cleanup.and(temporary_cleanup) {
1843 Ok(()) => {
1844 self.published_name = None;
1845 self.active = false;
1846 Err(error)
1847 }
1848 Err(source) if source.kind() == io::ErrorKind::NotFound => {
1849 self.active = false;
1850 Err(error)
1851 }
1852 Err(source) => Err(SourceError::CleanupFailed {
1853 path: self.path.as_str().to_owned(),
1854 original: error.to_string(),
1855 source,
1856 }),
1857 }
1858 }
1859}
1860
1861impl Drop for PartialFileCleanup {
1862 fn drop(&mut self) {
1863 if self.active {
1864 if let Some(output_name) = &self.published_name {
1865 let _ = remove_owned_capability_file(&self.parent, output_name, &self.identity);
1866 }
1867 let _ = remove_owned_capability_file(&self.parent, &self.name, &self.identity);
1868 }
1869 }
1870}
1871
1872impl PartialDirectoryCleanup {
1873 fn new(
1874 path: Utf8PathBuf,
1875 parent: CapabilityDir,
1876 parent_path: Utf8PathBuf,
1877 parent_identity: FileIdentityHandle,
1878 name: String,
1879 directory: CapabilityDir,
1880 identity: FileIdentityHandle,
1881 ) -> Self {
1882 #[cfg(windows)]
1883 drop(name);
1884 Self {
1885 path,
1886 parent,
1887 parent_path,
1888 parent_identity,
1889 #[cfg(not(windows))]
1890 name,
1891 directory: Some(directory),
1892 identity: Some(identity),
1893 active: true,
1894 }
1895 }
1896
1897 fn directory(&self) -> Result<&CapabilityDir, SourceError> {
1898 self.directory
1899 .as_ref()
1900 .ok_or(SourceError::InvalidDestination {
1901 reason: "extraction destination handle is unavailable",
1902 })
1903 }
1904
1905 fn keep(&mut self) -> Result<(), SourceError> {
1906 ensure_capability_directory_path(&self.parent, &self.parent_path, &self.parent_identity)
1907 .map_err(|source| SourceError::Io {
1908 operation: "bind extraction destination parent handle to path",
1909 path: self.path.as_str().to_owned(),
1910 source,
1911 })?;
1912 #[cfg(windows)]
1913 verify_private_directory_handle(
1914 self.directory()?
1915 .try_clone()
1916 .map_err(|source| SourceError::Io {
1917 operation: "clone extraction destination handle",
1918 path: self.path.as_str().to_owned(),
1919 source,
1920 })?
1921 .into_std_file()
1922 .as_handle(),
1923 )
1924 .map_err(|source| SourceError::Io {
1925 operation: "verify extraction destination handle",
1926 path: self.path.as_str().to_owned(),
1927 source: io::Error::other(source),
1928 })?;
1929 #[cfg(not(windows))]
1930 {
1931 let identity = self
1932 .identity
1933 .as_ref()
1934 .ok_or(SourceError::InvalidDestination {
1935 reason: "extraction destination identity is unavailable",
1936 })?;
1937 ensure_named_capability_directory(&self.parent, &self.name, identity).map_err(
1938 |source| SourceError::Io {
1939 operation: "bind extracted destination handle to final path",
1940 path: self.path.as_str().to_owned(),
1941 source,
1942 },
1943 )?;
1944 }
1945 drop(self.identity.take());
1946 drop(self.directory.take());
1947 self.active = false;
1948 Ok(())
1949 }
1950
1951 fn fail<T>(&mut self, error: SourceError) -> Result<T, SourceError> {
1952 if !self.active {
1953 return Err(error);
1954 }
1955 let Some(directory) = self.directory.take() else {
1956 return Err(error);
1957 };
1958 drop(self.identity.take());
1959 match remove_created_directory_tree(directory) {
1960 Ok(()) => {
1961 self.active = false;
1962 Err(error)
1963 }
1964 Err(source) if source.kind() == io::ErrorKind::NotFound => {
1965 self.active = false;
1966 Err(error)
1967 }
1968 Err(source) => Err(SourceError::CleanupFailed {
1969 path: self.path.as_str().to_owned(),
1970 original: error.to_string(),
1971 source,
1972 }),
1973 }
1974 }
1975}
1976
1977impl Drop for PartialDirectoryCleanup {
1978 fn drop(&mut self) {
1979 if self.active {
1980 drop(self.identity.take());
1981 if let Some(directory) = self.directory.take() {
1982 let _ = remove_created_directory_tree(directory);
1983 }
1984 }
1985 }
1986}
1987
1988fn remove_owned_capability_file(
1989 parent: &CapabilityDir,
1990 name: &str,
1991 identity: &FileIdentityHandle,
1992) -> io::Result<()> {
1993 let file = match parent.open(name) {
1994 Ok(file) => file,
1995 Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(()),
1996 Err(source) => return Err(source),
1997 };
1998 let current = FileIdentityHandle::from_file(file.into_std())?;
1999 if ¤t != identity {
2000 return Err(io::Error::other(
2001 "cleanup target no longer identifies the operation-created file",
2002 ));
2003 }
2004 parent.remove_file(name)
2005}
2006
2007fn validate_archive_limits(limits: SourceArchiveLimits) -> Result<(), SourceError> {
2008 check_supported_depth(limits.source)?;
2009 if limits.max_compression_ratio == 0 {
2010 return Err(SourceError::InvalidArchive {
2011 reason: "maximum compression ratio must be positive".to_owned(),
2012 });
2013 }
2014 Ok(())
2015}
2016
2017#[derive(Debug)]
2018struct ArchiveSizeExceeded {
2019 maximum: u64,
2020 actual: u64,
2021}
2022
2023impl fmt::Display for ArchiveSizeExceeded {
2024 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2025 write!(
2026 formatter,
2027 "source archive size would exceed {} bytes: {}",
2028 self.maximum, self.actual
2029 )
2030 }
2031}
2032
2033impl Error for ArchiveSizeExceeded {}
2034
2035#[derive(Debug)]
2036struct ArchiveSizeLimitedWriter {
2037 file: File,
2038 maximum: u64,
2039}
2040
2041impl ArchiveSizeLimitedWriter {
2042 const fn new(file: File, maximum: u64) -> Self {
2043 Self { file, maximum }
2044 }
2045
2046 fn into_inner(self) -> File {
2047 self.file
2048 }
2049
2050 fn limit_error(&self, actual: u64) -> io::Error {
2051 io::Error::new(
2052 io::ErrorKind::FileTooLarge,
2053 ArchiveSizeExceeded {
2054 maximum: self.maximum,
2055 actual,
2056 },
2057 )
2058 }
2059}
2060
2061impl Write for ArchiveSizeLimitedWriter {
2062 fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
2063 let position = self.file.stream_position()?;
2064 let end = position.saturating_add(buffer.len() as u64);
2065 if end > self.maximum {
2066 return Err(self.limit_error(end));
2067 }
2068 self.file.write(buffer)
2069 }
2070
2071 fn flush(&mut self) -> io::Result<()> {
2072 self.file.flush()
2073 }
2074}
2075
2076#[derive(Debug)]
2077struct DescriptorSizeLimitedWriter {
2078 file: File,
2079 maximum: u64,
2080 written: u64,
2081 exceeded: Option<u64>,
2082}
2083
2084impl DescriptorSizeLimitedWriter {
2085 const fn new(file: File, maximum: u64) -> Self {
2086 Self {
2087 file,
2088 maximum,
2089 written: 0,
2090 exceeded: None,
2091 }
2092 }
2093
2094 const fn exceeded(&self) -> Option<u64> {
2095 self.exceeded
2096 }
2097
2098 fn into_inner(self) -> File {
2099 self.file
2100 }
2101}
2102
2103impl Write for DescriptorSizeLimitedWriter {
2104 fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
2105 let actual = self.written.saturating_add(buffer.len() as u64);
2106 if actual > self.maximum {
2107 self.exceeded = Some(actual);
2108 return Err(io::Error::new(
2109 io::ErrorKind::FileTooLarge,
2110 "source bundle descriptor exceeds its fixed size limit",
2111 ));
2112 }
2113 let written = self.file.write(buffer)?;
2114 self.written = self.written.saturating_add(written as u64);
2115 Ok(written)
2116 }
2117
2118 fn flush(&mut self) -> io::Result<()> {
2119 self.file.flush()
2120 }
2121}
2122
2123fn descriptor_json_error(
2124 exceeded: Option<u64>,
2125 output: &Utf8Path,
2126 error: &serde_json::Error,
2127) -> SourceError {
2128 if let Some(actual) = exceeded {
2129 return limit_error(
2130 SourceLimitKind::DescriptorSize,
2131 None,
2132 MAX_SOURCE_BUNDLE_DESCRIPTOR_BYTES,
2133 actual,
2134 );
2135 }
2136 if let Some(kind) = error.io_error_kind() {
2137 return SourceError::Io {
2138 operation: "serialize source bundle descriptor",
2139 path: output.as_str().to_owned(),
2140 source: io::Error::new(kind, error.to_string()),
2141 };
2142 }
2143 SourceError::InvalidArchive {
2144 reason: "source bundle descriptor could not be serialized".to_owned(),
2145 }
2146}
2147
2148fn descriptor_write_error(
2149 exceeded: Option<u64>,
2150 output: &Utf8Path,
2151 operation: &'static str,
2152 source: io::Error,
2153) -> SourceError {
2154 if let Some(actual) = exceeded {
2155 limit_error(
2156 SourceLimitKind::DescriptorSize,
2157 None,
2158 MAX_SOURCE_BUNDLE_DESCRIPTOR_BYTES,
2159 actual,
2160 )
2161 } else {
2162 SourceError::Io {
2163 operation,
2164 path: output.as_str().to_owned(),
2165 source,
2166 }
2167 }
2168}
2169
2170impl Seek for ArchiveSizeLimitedWriter {
2171 fn seek(&mut self, position: SeekFrom) -> io::Result<u64> {
2172 let original = self.file.stream_position()?;
2173 let actual = self.file.seek(position)?;
2174 if actual > self.maximum {
2175 self.file.seek(SeekFrom::Start(original))?;
2176 return Err(self.limit_error(actual));
2177 }
2178 Ok(actual)
2179 }
2180}
2181
2182fn archive_write_error(operation: &'static str, source: io::Error) -> SourceError {
2183 if let Some(exceeded) = source
2184 .get_ref()
2185 .and_then(|error| error.downcast_ref::<ArchiveSizeExceeded>())
2186 {
2187 limit_error(
2188 SourceLimitKind::ArchiveSize,
2189 None,
2190 exceeded.maximum,
2191 exceeded.actual,
2192 )
2193 } else {
2194 SourceError::Io {
2195 operation,
2196 path: "source archive".to_owned(),
2197 source,
2198 }
2199 }
2200}
2201
2202fn archive_zip_error(operation: &'static str, error: zip::result::ZipError) -> SourceError {
2203 match error {
2204 zip::result::ZipError::Io(source) => archive_write_error(operation, source),
2205 error => SourceError::Zip {
2206 operation,
2207 message: error.to_string(),
2208 },
2209 }
2210}
2211
2212#[derive(Debug)]
2213struct ArchiveTemporary {
2214 parent: CapabilityDir,
2215 parent_path: Utf8PathBuf,
2216 parent_identity: FileIdentityHandle,
2217 output_name: String,
2218 temporary_path: Utf8PathBuf,
2219 temporary_name: String,
2220 file: File,
2221 identity: FileIdentityHandle,
2222}
2223
2224#[allow(clippy::too_many_lines)]
2225fn create_archive_temporary(output: &Utf8Path) -> Result<ArchiveTemporary, SourceError> {
2226 let file_name = output.file_name().filter(|name| !name.is_empty()).ok_or(
2227 SourceError::InvalidDestination {
2228 reason: "archive output has no file name",
2229 },
2230 )?;
2231 let parent = output
2232 .parent()
2233 .filter(|path| !path.as_str().is_empty())
2234 .unwrap_or_else(|| Utf8Path::new("."));
2235 let canonical_parent = canonical_directory(parent, "archive output parent")?;
2236 let parent_directory =
2237 CapabilityDir::open_ambient_dir(canonical_parent.as_std_path(), ambient_authority())
2238 .map_err(|source| SourceError::Io {
2239 operation: "open archive output parent",
2240 path: parent.as_str().to_owned(),
2241 source,
2242 })?;
2243 let parent_identity =
2244 capability_directory_identity(&parent_directory).map_err(|source| SourceError::Io {
2245 operation: "capture archive output parent identity",
2246 path: parent.as_str().to_owned(),
2247 source,
2248 })?;
2249 ensure_capability_directory_path(&parent_directory, &canonical_parent, &parent_identity)
2250 .map_err(|source| SourceError::Io {
2251 operation: "bind archive output parent handle to path",
2252 path: parent.as_str().to_owned(),
2253 source,
2254 })?;
2255 match parent_directory.symlink_metadata(file_name) {
2256 Ok(_) => {
2257 return Err(SourceError::OutputExists {
2258 path: output.as_str().to_owned(),
2259 });
2260 }
2261 Err(source) if source.kind() == io::ErrorKind::NotFound => {}
2262 Err(source) => {
2263 return Err(SourceError::Io {
2264 operation: "inspect archive output",
2265 path: output.as_str().to_owned(),
2266 source,
2267 });
2268 }
2269 }
2270
2271 for _ in 0..SOURCE_ARCHIVE_TEMP_ATTEMPTS {
2272 let temporary_name = format!(".{file_name}.rustferry-partial-{}", Uuid::new_v4());
2273 let temporary_path = canonical_parent.join(&temporary_name);
2274 match open_new_private_capability_file(&parent_directory, &temporary_name) {
2275 Ok(file) => {
2276 let file = file.into_std();
2277 let identity = match file.try_clone().and_then(FileIdentityHandle::from_file) {
2278 Ok(identity) => identity,
2279 Err(source) => {
2280 drop(file);
2281 let cleanup = parent_directory.remove_file(&temporary_name);
2282 if let Err(cleanup_source) = cleanup {
2283 return Err(SourceError::CleanupFailed {
2284 path: temporary_path.as_str().to_owned(),
2285 original: source.to_string(),
2286 source: cleanup_source,
2287 });
2288 }
2289 return Err(SourceError::Io {
2290 operation: "inspect archive temporary file",
2291 path: temporary_path.as_str().to_owned(),
2292 source,
2293 });
2294 }
2295 };
2296 let binding = ensure_capability_directory_path(
2297 &parent_directory,
2298 &canonical_parent,
2299 &parent_identity,
2300 )
2301 .and_then(|()| {
2302 ensure_named_capability_file(&parent_directory, &temporary_name, &identity)
2303 });
2304 if let Err(source) = binding {
2305 drop(file);
2306 let original = SourceError::Io {
2307 operation: "bind archive temporary handle to path",
2308 path: temporary_path.as_str().to_owned(),
2309 source,
2310 };
2311 return match remove_owned_capability_file(
2312 &parent_directory,
2313 &temporary_name,
2314 &identity,
2315 ) {
2316 Ok(()) => Err(original),
2317 Err(source) => Err(SourceError::CleanupFailed {
2318 path: temporary_path.as_str().to_owned(),
2319 original: original.to_string(),
2320 source,
2321 }),
2322 };
2323 }
2324 return Ok(ArchiveTemporary {
2325 parent: parent_directory,
2326 parent_path: canonical_parent,
2327 parent_identity,
2328 output_name: file_name.to_owned(),
2329 temporary_path,
2330 temporary_name,
2331 file,
2332 identity,
2333 });
2334 }
2335 Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {}
2336 Err(source) => {
2337 return Err(SourceError::Io {
2338 operation: "create archive temporary file",
2339 path: temporary_path.as_str().to_owned(),
2340 source,
2341 });
2342 }
2343 }
2344 }
2345 Err(SourceError::InvalidDestination {
2346 reason: "could not allocate a unique archive temporary file",
2347 })
2348}
2349
2350#[cfg(windows)]
2351fn open_read_stable(path: &Utf8Path) -> io::Result<File> {
2352 use std::os::windows::fs::OpenOptionsExt;
2353
2354 OpenOptions::new()
2355 .read(true)
2356 .share_mode(WINDOWS_FILE_SHARE_READ)
2357 .custom_flags(WINDOWS_FILE_FLAG_OPEN_REPARSE_POINT)
2358 .open(path)
2359}
2360
2361#[cfg(not(windows))]
2362fn open_read_stable(path: &Utf8Path) -> io::Result<File> {
2363 File::open(path)
2364}
2365
2366fn publish_archive_no_clobber(
2367 parent: &CapabilityDir,
2368 temporary_name: &str,
2369 output_name: &str,
2370 identity: &FileIdentityHandle,
2371 output: &Utf8Path,
2372) -> Result<(), SourceError> {
2373 ensure_named_capability_file(parent, temporary_name, identity).map_err(|source| {
2374 SourceError::Io {
2375 operation: "bind archive temporary handle to path",
2376 path: output.as_str().to_owned(),
2377 source,
2378 }
2379 })?;
2380 parent
2381 .hard_link(temporary_name, parent, output_name)
2382 .map_err(|source| {
2383 if source.kind() == io::ErrorKind::AlreadyExists {
2384 SourceError::OutputExists {
2385 path: output.as_str().to_owned(),
2386 }
2387 } else {
2388 SourceError::Io {
2389 operation: "atomically publish archive without clobbering",
2390 path: output.as_str().to_owned(),
2391 source,
2392 }
2393 }
2394 })?;
2395 if let Err(source) = ensure_named_capability_file(parent, output_name, identity) {
2396 let original = SourceError::Io {
2397 operation: "bind published archive handle to output path",
2398 path: output.as_str().to_owned(),
2399 source,
2400 };
2401 return match remove_owned_capability_file(parent, output_name, identity) {
2402 Ok(()) => Err(original),
2403 Err(source) => Err(SourceError::CleanupFailed {
2404 path: output.as_str().to_owned(),
2405 original: original.to_string(),
2406 source,
2407 }),
2408 };
2409 }
2410 Ok(())
2411}
2412
2413#[allow(clippy::too_many_lines)]
2414fn write_verified_zip_entry(
2415 writer: &mut ZipWriter<ArchiveSizeLimitedWriter>,
2416 planned: &PlannedSourceFile,
2417 expected: &SourceManifestEntry,
2418 portable_executable: Option<bool>,
2419) -> Result<(), SourceError> {
2420 let path = planned.source_path();
2421 let path_display = expected.path.clone();
2422 let initial = fs::symlink_metadata(path).map_err(|source| SourceError::Io {
2423 operation: "inspect planned source",
2424 path: path_display.clone(),
2425 source,
2426 })?;
2427 if initial.file_type().is_symlink() {
2428 return Err(SourceError::Symlink { path: path_display });
2429 }
2430 if !initial.is_file() {
2431 return Err(SourceError::UnsupportedFileType { path: path_display });
2432 }
2433 #[cfg(not(windows))]
2434 reject_hard_link(&initial, &expected.path)?;
2435 if initial.len() != expected.size
2436 || resolved_executable_mode(executable_bit(&initial), portable_executable)
2437 != expected.executable
2438 {
2439 return Err(SourceError::ManifestMismatch);
2440 }
2441
2442 let mut source_file = open_read_stable(path).map_err(|source| SourceError::Io {
2443 operation: "reopen planned source",
2444 path: expected.path.clone(),
2445 source,
2446 })?;
2447 let opened = source_file.metadata().map_err(|source| SourceError::Io {
2448 operation: "inspect reopened source",
2449 path: expected.path.clone(),
2450 source,
2451 })?;
2452 reject_open_hard_link(&source_file, &expected.path)?;
2453 #[cfg(windows)]
2454 reject_named_streams(&source_file, &expected.path)?;
2455 if !opened.is_file()
2456 || !same_file_identity(&initial, &opened)
2457 || opened.len() != expected.size
2458 || resolved_executable_mode(executable_bit(&opened), portable_executable)
2459 != expected.executable
2460 {
2461 return Err(SourceError::ChangedDuringRead {
2462 path: expected.path.clone(),
2463 });
2464 }
2465
2466 let mode = if expected.executable { 0o755 } else { 0o644 };
2467 let options = SimpleFileOptions::default()
2468 .compression_method(CompressionMethod::Stored)
2469 .compression_level(None)
2470 .last_modified_time(DateTime::default())
2471 .unix_permissions(mode)
2472 .large_file(false);
2473 writer
2474 .start_file(&expected.path, options)
2475 .map_err(|error| archive_zip_error("start entry", error))?;
2476
2477 let initial_modified = initial.modified().ok();
2478 let mut digest = Sha256::new();
2479 let mut count = 0_u64;
2480 let mut buffer = vec![0_u8; SOURCE_ARCHIVE_BUFFER_SIZE];
2481 loop {
2482 let read = source_file
2483 .read(&mut buffer)
2484 .map_err(|source| SourceError::Io {
2485 operation: "read planned source",
2486 path: expected.path.clone(),
2487 source,
2488 })?;
2489 if read == 0 {
2490 break;
2491 }
2492 count = count
2493 .checked_add(read as u64)
2494 .ok_or(SourceError::ManifestMismatch)?;
2495 if count > expected.size {
2496 return Err(SourceError::ChangedDuringRead {
2497 path: expected.path.clone(),
2498 });
2499 }
2500 digest.update(&buffer[..read]);
2501 writer
2502 .write_all(&buffer[..read])
2503 .map_err(|source| archive_write_error("write source ZIP entry", source))?;
2504 }
2505 let open_final = source_file.metadata().map_err(|source| SourceError::Io {
2506 operation: "reinspect open source",
2507 path: expected.path.clone(),
2508 source,
2509 })?;
2510 let path_final = fs::symlink_metadata(path).map_err(|source| SourceError::Io {
2511 operation: "reinspect source path",
2512 path: expected.path.clone(),
2513 source,
2514 })?;
2515 reject_open_hard_link(&source_file, &expected.path)?;
2516 #[cfg(windows)]
2517 reject_named_streams(&source_file, &expected.path)?;
2518 #[cfg(not(windows))]
2519 reject_hard_link(&path_final, &expected.path)?;
2520 ensure_source_metadata_stable(
2521 expected,
2522 &opened,
2523 &open_final,
2524 &path_final,
2525 initial_modified,
2526 portable_executable,
2527 )?;
2528 if count != expected.size || hex_digest(digest.finalize()) != expected.sha256 {
2529 return Err(SourceError::ManifestMismatch);
2530 }
2531 Ok(())
2532}
2533
2534fn ensure_source_metadata_stable(
2535 expected: &SourceManifestEntry,
2536 opened: &Metadata,
2537 open_final: &Metadata,
2538 path_final: &Metadata,
2539 initial_modified: Option<std::time::SystemTime>,
2540 portable_executable: Option<bool>,
2541) -> Result<(), SourceError> {
2542 let stable = !path_final.file_type().is_symlink()
2543 && open_final.is_file()
2544 && path_final.is_file()
2545 && same_file_identity(opened, open_final)
2546 && same_file_identity(opened, path_final)
2547 && open_final.len() == expected.size
2548 && path_final.len() == expected.size
2549 && open_final.modified().ok() == initial_modified
2550 && path_final.modified().ok() == initial_modified
2551 && resolved_executable_mode(executable_bit(open_final), portable_executable)
2552 == expected.executable
2553 && resolved_executable_mode(executable_bit(path_final), portable_executable)
2554 == expected.executable;
2555 if stable {
2556 Ok(())
2557 } else {
2558 Err(SourceError::ChangedDuringRead {
2559 path: expected.path.clone(),
2560 })
2561 }
2562}
2563
2564fn describe_open_archive(file: &mut File, maximum_size: u64) -> Result<SourceArchive, SourceError> {
2565 let metadata = file.metadata().map_err(|source| SourceError::Io {
2566 operation: "inspect source archive",
2567 path: "source archive".to_owned(),
2568 source,
2569 })?;
2570 if metadata.len() > maximum_size {
2571 return Err(limit_error(
2572 SourceLimitKind::ArchiveSize,
2573 None,
2574 maximum_size,
2575 metadata.len(),
2576 ));
2577 }
2578 file.seek(SeekFrom::Start(0))
2579 .map_err(|source| SourceError::Io {
2580 operation: "rewind source archive",
2581 path: "source archive".to_owned(),
2582 source,
2583 })?;
2584 let mut digest = Sha256::new();
2585 let mut size = 0_u64;
2586 let mut buffer = vec![0_u8; SOURCE_ARCHIVE_BUFFER_SIZE];
2587 loop {
2588 let read = file.read(&mut buffer).map_err(|source| SourceError::Io {
2589 operation: "hash source archive",
2590 path: "source archive".to_owned(),
2591 source,
2592 })?;
2593 if read == 0 {
2594 break;
2595 }
2596 size = size.checked_add(read as u64).ok_or_else(|| {
2597 limit_error(SourceLimitKind::ArchiveSize, None, maximum_size, u64::MAX)
2598 })?;
2599 if size > maximum_size {
2600 return Err(limit_error(
2601 SourceLimitKind::ArchiveSize,
2602 None,
2603 maximum_size,
2604 size,
2605 ));
2606 }
2607 digest.update(&buffer[..read]);
2608 }
2609 file.seek(SeekFrom::Start(0))
2610 .map_err(|source| SourceError::Io {
2611 operation: "rewind source archive",
2612 path: "source archive".to_owned(),
2613 source,
2614 })?;
2615 if size != metadata.len() {
2616 return Err(SourceError::ArchiveIntegrityMismatch {
2617 reason: "archive size changed while hashing",
2618 });
2619 }
2620 Ok(SourceArchive {
2621 size,
2622 sha256: hex_digest(digest.finalize()),
2623 })
2624}
2625
2626fn open_verified_archive(
2627 archive_path: &Utf8Path,
2628 expected: &SourceArchive,
2629 maximum_size: u64,
2630) -> Result<(ZipArchive<File>, Metadata, SourceArchive), SourceError> {
2631 let path_metadata = fs::symlink_metadata(archive_path).map_err(|source| SourceError::Io {
2632 operation: "inspect source archive",
2633 path: archive_path.as_str().to_owned(),
2634 source,
2635 })?;
2636 if path_metadata.file_type().is_symlink() || !path_metadata.is_file() {
2637 return Err(SourceError::InvalidArchive {
2638 reason: "archive path is not a regular file".to_owned(),
2639 });
2640 }
2641 if path_metadata.len() != expected.size {
2642 return Err(SourceError::ArchiveIntegrityMismatch {
2643 reason: "archive size differs from transport descriptor",
2644 });
2645 }
2646 let mut file = open_read_stable(archive_path).map_err(|source| SourceError::Io {
2647 operation: "open source archive",
2648 path: archive_path.as_str().to_owned(),
2649 source,
2650 })?;
2651 let opened = file.metadata().map_err(|source| SourceError::Io {
2652 operation: "inspect open source archive",
2653 path: archive_path.as_str().to_owned(),
2654 source,
2655 })?;
2656 if !opened.is_file() || !same_file_identity(&path_metadata, &opened) {
2657 return Err(SourceError::ArchiveIntegrityMismatch {
2658 reason: "archive path changed while opening",
2659 });
2660 }
2661 let descriptor = describe_open_archive(&mut file, maximum_size)?;
2662 ensure_archive_file_stable(archive_path, &opened, &file)?;
2663 if descriptor != *expected {
2664 return Err(SourceError::ArchiveIntegrityMismatch {
2665 reason: "archive SHA-256 differs from transport descriptor",
2666 });
2667 }
2668 let archive = ZipArchive::new(file).map_err(|error| SourceError::Zip {
2669 operation: "open",
2670 message: error.to_string(),
2671 })?;
2672 Ok((archive, opened, descriptor))
2673}
2674
2675fn ensure_archive_file_stable(
2676 archive_path: &Utf8Path,
2677 initial: &Metadata,
2678 file: &File,
2679) -> Result<(), SourceError> {
2680 let open_final = file.metadata().map_err(|source| SourceError::Io {
2681 operation: "reinspect open source archive",
2682 path: archive_path.as_str().to_owned(),
2683 source,
2684 })?;
2685 let path_final = fs::symlink_metadata(archive_path).map_err(|source| SourceError::Io {
2686 operation: "reinspect source archive path",
2687 path: archive_path.as_str().to_owned(),
2688 source,
2689 })?;
2690 let stable = !path_final.file_type().is_symlink()
2691 && open_final.is_file()
2692 && path_final.is_file()
2693 && same_file_identity(initial, &open_final)
2694 && same_file_identity(initial, &path_final)
2695 && open_final.len() == initial.len()
2696 && path_final.len() == initial.len()
2697 && open_final.modified().ok() == initial.modified().ok()
2698 && path_final.modified().ok() == initial.modified().ok();
2699 if stable {
2700 Ok(())
2701 } else {
2702 Err(SourceError::ArchiveIntegrityMismatch {
2703 reason: "archive changed while being read",
2704 })
2705 }
2706}
2707
2708#[derive(Debug)]
2709struct ArchiveEntryMetadata {
2710 path: String,
2711 size: u64,
2712 executable: bool,
2713}
2714
2715#[allow(clippy::too_many_lines)]
2716fn preflight_archive(
2717 archive: &mut ZipArchive<File>,
2718 expected_manifest: &SourceManifest,
2719 limits: SourceArchiveLimits,
2720) -> Result<(), SourceError> {
2721 if !archive.comment().is_empty() {
2722 return Err(SourceError::InvalidArchive {
2723 reason: "archive comment is forbidden".to_owned(),
2724 });
2725 }
2726 if archive.len() > limits.source.max_file_count {
2727 return Err(limit_error(
2728 SourceLimitKind::FileCount,
2729 None,
2730 limits.source.max_file_count as u64,
2731 archive.len() as u64,
2732 ));
2733 }
2734
2735 let mut entries = Vec::with_capacity(archive.len());
2736 let mut exact_paths = BTreeSet::new();
2737 let mut collisions = BTreeMap::new();
2738 let mut total_size = 0_u64;
2739 for index in 0..archive.len() {
2740 let entry = archive.by_index(index).map_err(|error| SourceError::Zip {
2741 operation: "read entry metadata",
2742 message: error.to_string(),
2743 })?;
2744 let path = std::str::from_utf8(entry.name_raw())
2745 .map_err(|_| SourceError::InvalidArchive {
2746 reason: "entry name is not UTF-8".to_owned(),
2747 })?
2748 .to_owned();
2749 validate_portable_path_str(&path, false).map_err(|reason| {
2750 SourceError::NonPortablePath {
2751 path: path.clone(),
2752 reason,
2753 }
2754 })?;
2755 reject_sensitive(&path)?;
2756 check_depth(&path, limits.source.max_depth)?;
2757 if !exact_paths.insert(path.clone()) {
2758 return Err(SourceError::InvalidArchive {
2759 reason: format!("duplicate ZIP entry {path:?}"),
2760 });
2761 }
2762 register_path_components(&path, &mut collisions)?;
2763 if entry.encrypted() {
2764 return Err(SourceError::InvalidArchive {
2765 reason: format!("encrypted ZIP entry is forbidden: {path:?}"),
2766 });
2767 }
2768 if entry.is_dir() || entry.is_symlink() {
2769 return Err(SourceError::InvalidArchive {
2770 reason: format!("link or directory ZIP entry is forbidden: {path:?}"),
2771 });
2772 }
2773 let mode = entry
2774 .unix_mode()
2775 .ok_or_else(|| SourceError::InvalidArchive {
2776 reason: format!("ZIP entry has no normalized Unix mode: {path:?}"),
2777 })?;
2778 if mode & 0o170_000 != 0o100_000 {
2779 return Err(SourceError::InvalidArchive {
2780 reason: format!("special ZIP entry is forbidden: {path:?}"),
2781 });
2782 }
2783 let permissions = mode & 0o777;
2784 if !matches!(permissions, 0o644 | 0o755) {
2785 return Err(SourceError::InvalidArchive {
2786 reason: format!("ZIP entry has non-canonical permissions: {path:?}"),
2787 });
2788 }
2789 if entry.last_modified() != Some(DateTime::default()) {
2790 return Err(SourceError::InvalidArchive {
2791 reason: format!("ZIP entry has a non-canonical timestamp: {path:?}"),
2792 });
2793 }
2794 if !entry.comment().is_empty() || entry.extra_data().is_some_and(|data| !data.is_empty()) {
2795 return Err(SourceError::InvalidArchive {
2796 reason: format!("ZIP entry has forbidden auxiliary metadata: {path:?}"),
2797 });
2798 }
2799 if entry.size() > limits.source.max_file_size {
2800 return Err(limit_error(
2801 SourceLimitKind::FileSize,
2802 Some(path),
2803 limits.source.max_file_size,
2804 entry.size(),
2805 ));
2806 }
2807 total_size =
2808 total_size
2809 .checked_add(entry.size())
2810 .ok_or_else(|| SourceError::InvalidArchive {
2811 reason: "uncompressed source ZIP size overflow".to_owned(),
2812 })?;
2813 if total_size > limits.source.max_total_size {
2814 return Err(limit_error(
2815 SourceLimitKind::TotalSize,
2816 Some(path),
2817 limits.source.max_total_size,
2818 total_size,
2819 ));
2820 }
2821 if entry.size() > 0 {
2822 let compressed = entry.compressed_size();
2823 let ratio = if compressed == 0 {
2824 u64::MAX
2825 } else {
2826 entry.size().div_ceil(compressed)
2827 };
2828 if ratio > limits.max_compression_ratio {
2829 return Err(limit_error(
2830 SourceLimitKind::CompressionRatio,
2831 Some(path),
2832 limits.max_compression_ratio,
2833 ratio,
2834 ));
2835 }
2836 }
2837 if entry.compression() == CompressionMethod::Stored
2838 && entry.compressed_size() != entry.size()
2839 {
2840 return Err(SourceError::InvalidArchive {
2841 reason: format!("stored ZIP entry size is inconsistent: {path:?}"),
2842 });
2843 }
2844 if entry.compression() != CompressionMethod::Stored {
2845 return Err(SourceError::InvalidArchive {
2846 reason: format!("ZIP entry uses non-canonical compression: {path:?}"),
2847 });
2848 }
2849 entries.push(ArchiveEntryMetadata {
2850 path,
2851 size: entry.size(),
2852 executable: permissions == 0o755,
2853 });
2854 }
2855
2856 if entries.len() != expected_manifest.entries.len() {
2857 return Err(SourceError::ManifestMismatch);
2858 }
2859 for (actual, expected) in entries.iter().zip(&expected_manifest.entries) {
2860 if actual.path != expected.path
2861 || actual.size != expected.size
2862 || actual.executable != expected.executable
2863 {
2864 return Err(SourceError::ManifestMismatch);
2865 }
2866 }
2867 Ok(())
2868}
2869
2870#[allow(clippy::too_many_lines)]
2871fn create_fresh_destination(
2872 destination: &Utf8Path,
2873 retained_parent: Option<File>,
2874) -> Result<FreshDestination, SourceError> {
2875 let name = destination
2876 .file_name()
2877 .filter(|name| !name.is_empty())
2878 .ok_or(SourceError::InvalidDestination {
2879 reason: "extraction destination has no directory name",
2880 })?;
2881 let parent = destination
2882 .parent()
2883 .filter(|path| !path.as_str().is_empty())
2884 .unwrap_or_else(|| Utf8Path::new("."));
2885 let canonical_parent = canonical_directory(parent, "extraction destination parent")?;
2886 let actual = canonical_parent.join(name);
2887 let retained_parent_supplied = retained_parent.is_some();
2888 let parent_directory = if let Some(parent) = retained_parent {
2889 CapabilityDir::from_std_file(parent)
2890 } else {
2891 CapabilityDir::open_ambient_dir(canonical_parent.as_std_path(), ambient_authority())
2892 .map_err(|source| SourceError::Io {
2893 operation: "open extraction destination parent",
2894 path: parent.as_str().to_owned(),
2895 source,
2896 })?
2897 };
2898 let parent_identity =
2899 capability_directory_identity(&parent_directory).map_err(|source| SourceError::Io {
2900 operation: "capture extraction destination parent identity",
2901 path: parent.as_str().to_owned(),
2902 source,
2903 })?;
2904 ensure_capability_directory_path(&parent_directory, &canonical_parent, &parent_identity)
2905 .map_err(|source| SourceError::Io {
2906 operation: "bind extraction destination parent handle to path",
2907 path: parent.as_str().to_owned(),
2908 source,
2909 })?;
2910 #[cfg(windows)]
2911 if retained_parent_supplied {
2912 let parent_handle = parent_directory
2913 .try_clone()
2914 .map_err(|source| SourceError::Io {
2915 operation: "clone retained extraction destination parent",
2916 path: parent.as_str().to_owned(),
2917 source,
2918 })?;
2919 verify_private_directory_handle(parent_handle.into_std_file().as_handle()).map_err(
2920 |source| SourceError::Io {
2921 operation: "verify retained extraction destination parent",
2922 path: parent.as_str().to_owned(),
2923 source: io::Error::other(source),
2924 },
2925 )?;
2926 }
2927 #[cfg(not(windows))]
2928 let _ = retained_parent_supplied;
2929 match parent_directory.symlink_metadata(name) {
2930 Ok(_) => {
2931 return Err(SourceError::DestinationExists {
2932 path: destination.as_str().to_owned(),
2933 });
2934 }
2935 Err(source) if source.kind() == io::ErrorKind::NotFound => {}
2936 Err(source) => {
2937 return Err(SourceError::Io {
2938 operation: "inspect extraction destination",
2939 path: destination.as_str().to_owned(),
2940 source,
2941 });
2942 }
2943 }
2944 #[cfg(windows)]
2945 let private_directory =
2946 rustferry_core::windows_private_directory::create_private_directory(actual.as_std_path())
2947 .map_err(|error| {
2948 if error.kind()
2949 == rustferry_core::windows_private_directory::PrivateDirectoryErrorKind::AlreadyExists
2950 {
2951 SourceError::DestinationExists {
2952 path: destination.as_str().to_owned(),
2953 }
2954 } else {
2955 SourceError::Io {
2956 operation: "create private extraction destination",
2957 path: destination.as_str().to_owned(),
2958 source: io::Error::other(error),
2959 }
2960 }
2961 })?;
2962 #[cfg(not(windows))]
2963 create_private_capability_directory(&parent_directory, name).map_err(|source| {
2964 if source.kind() == io::ErrorKind::AlreadyExists {
2965 SourceError::DestinationExists {
2966 path: destination.as_str().to_owned(),
2967 }
2968 } else {
2969 SourceError::Io {
2970 operation: "create extraction destination",
2971 path: destination.as_str().to_owned(),
2972 source,
2973 }
2974 }
2975 })?;
2976 #[cfg(windows)]
2977 let directory = CapabilityDir::from_std_file(private_directory);
2978 #[cfg(not(windows))]
2979 let directory = match parent_directory.open_dir(name) {
2980 Ok(directory) => directory,
2981 Err(source) => {
2982 let cleanup = parent_directory.remove_dir(name);
2983 if let Err(cleanup_source) = cleanup {
2984 return Err(SourceError::CleanupFailed {
2985 path: actual.as_str().to_owned(),
2986 original: source.to_string(),
2987 source: cleanup_source,
2988 });
2989 }
2990 return Err(SourceError::Io {
2991 operation: "inspect created extraction destination",
2992 path: destination.as_str().to_owned(),
2993 source,
2994 });
2995 }
2996 };
2997 let opened_metadata = match directory.dir_metadata() {
2998 Ok(metadata) => metadata,
2999 Err(source) => {
3000 return cleanup_created_directory(
3001 directory,
3002 &actual,
3003 SourceError::Io {
3004 operation: "inspect created extraction destination handle",
3005 path: destination.as_str().to_owned(),
3006 source,
3007 },
3008 );
3009 }
3010 };
3011 if opened_metadata.is_symlink() || !opened_metadata.is_dir() {
3012 return cleanup_created_directory(
3013 directory,
3014 &actual,
3015 SourceError::InvalidDestination {
3016 reason: "created extraction destination is not a directory",
3017 },
3018 );
3019 }
3020 #[cfg(not(windows))]
3021 {
3022 let named_metadata = match parent_directory.symlink_metadata(name) {
3023 Ok(metadata) => metadata,
3024 Err(source) => {
3025 return cleanup_created_directory(
3026 directory,
3027 &actual,
3028 SourceError::Io {
3029 operation: "reinspect created extraction destination",
3030 path: destination.as_str().to_owned(),
3031 source,
3032 },
3033 );
3034 }
3035 };
3036 if named_metadata.is_symlink() || !named_metadata.is_dir() {
3037 return cleanup_created_directory(
3038 directory,
3039 &actual,
3040 SourceError::InvalidDestination {
3041 reason: "created extraction destination is not a directory",
3042 },
3043 );
3044 }
3045 }
3046 let identity = match capability_directory_identity(&directory) {
3047 Ok(identity) => identity,
3048 Err(source) => {
3049 return cleanup_created_directory(
3050 directory,
3051 &actual,
3052 SourceError::Io {
3053 operation: "capture extraction destination identity",
3054 path: destination.as_str().to_owned(),
3055 source,
3056 },
3057 );
3058 }
3059 };
3060 let binding =
3061 ensure_capability_directory_path(&parent_directory, &canonical_parent, &parent_identity);
3062 #[cfg(windows)]
3063 let binding = binding.and_then(|()| {
3064 verify_private_directory_handle(directory.try_clone()?.into_std_file().as_handle())
3065 .map_err(io::Error::other)
3066 });
3067 #[cfg(not(windows))]
3068 let binding = binding
3069 .and_then(|()| ensure_named_capability_directory(&parent_directory, name, &identity));
3070 if let Err(source) = binding {
3071 drop(identity);
3072 return cleanup_created_directory(
3073 directory,
3074 &actual,
3075 SourceError::Io {
3076 operation: "bind extraction destination handle to path",
3077 path: destination.as_str().to_owned(),
3078 source,
3079 },
3080 );
3081 }
3082 Ok(FreshDestination {
3083 path: actual,
3084 parent: parent_directory,
3085 parent_path: canonical_parent,
3086 parent_identity,
3087 name: name.to_owned(),
3088 directory,
3089 identity,
3090 })
3091}
3092
3093fn cleanup_created_directory<T>(
3094 directory: CapabilityDir,
3095 path: &Utf8Path,
3096 original: SourceError,
3097) -> Result<T, SourceError> {
3098 match remove_created_directory_tree(directory) {
3099 Ok(()) => Err(original),
3100 Err(source) => Err(SourceError::CleanupFailed {
3101 path: path.as_str().to_owned(),
3102 original: original.to_string(),
3103 source,
3104 }),
3105 }
3106}
3107
3108#[cfg(windows)]
3109fn remove_created_directory_tree(directory: CapabilityDir) -> io::Result<()> {
3110 remove_private_directory_tree_handle(directory.into_std_file()).map_err(io::Error::other)
3111}
3112
3113#[cfg(not(windows))]
3114fn remove_created_directory_tree(directory: CapabilityDir) -> io::Result<()> {
3115 directory.remove_open_dir_all()
3116}
3117
3118fn capability_directory_identity(directory: &CapabilityDir) -> io::Result<FileIdentityHandle> {
3119 FileIdentityHandle::from_file(directory.try_clone()?.into_std_file())
3120}
3121
3122fn ensure_capability_directory_path(
3123 directory: &CapabilityDir,
3124 path: &Utf8Path,
3125 expected: &FileIdentityHandle,
3126) -> io::Result<()> {
3127 let open = capability_directory_identity(directory)?;
3128 let named = FileIdentityHandle::from_path(path.as_std_path())?;
3129 if &open == expected && &named == expected {
3130 Ok(())
3131 } else {
3132 Err(io::Error::other(
3133 "directory path no longer identifies the capability directory",
3134 ))
3135 }
3136}
3137
3138#[cfg(not(windows))]
3139fn ensure_named_capability_directory(
3140 parent: &CapabilityDir,
3141 name: &str,
3142 expected: &FileIdentityHandle,
3143) -> io::Result<()> {
3144 let named = parent.open_dir(name)?;
3145 let actual = capability_directory_identity(&named)?;
3146 if &actual == expected {
3147 Ok(())
3148 } else {
3149 Err(io::Error::other(
3150 "destination path no longer identifies the operation-created directory",
3151 ))
3152 }
3153}
3154
3155fn ensure_named_capability_file(
3156 parent: &CapabilityDir,
3157 name: &str,
3158 expected: &FileIdentityHandle,
3159) -> io::Result<()> {
3160 let metadata = parent.symlink_metadata(name)?;
3161 if metadata.is_symlink() || !metadata.is_file() {
3162 return Err(io::Error::other(
3163 "archive path no longer identifies a regular file",
3164 ));
3165 }
3166 let file = parent.open(name)?;
3167 let actual = FileIdentityHandle::from_file(file.into_std())?;
3168 if &actual == expected {
3169 Ok(())
3170 } else {
3171 Err(io::Error::other(
3172 "archive path no longer identifies the operation-created file",
3173 ))
3174 }
3175}
3176
3177#[cfg(unix)]
3178fn create_private_capability_directory(parent: &CapabilityDir, name: &str) -> io::Result<()> {
3179 use cap_std::fs::DirBuilderExt as _;
3180
3181 let mut builder = cap_std::fs::DirBuilder::new();
3182 builder.mode(0o700);
3183 parent.create_dir_with(name, &builder)
3184}
3185
3186#[cfg(not(unix))]
3187fn create_private_capability_directory(parent: &CapabilityDir, name: &str) -> io::Result<()> {
3188 parent.create_dir(name)
3189}
3190
3191fn extract_verified_entry(
3192 archive: &mut ZipArchive<File>,
3193 index: usize,
3194 expected: &SourceManifestEntry,
3195 destination: &CapabilityDir,
3196) -> Result<(), SourceError> {
3197 let relative = Utf8Path::new(&expected.path);
3198 let parent = create_safe_parent_directories(destination, relative)?;
3199 let file_name = relative.file_name().ok_or(SourceError::ManifestMismatch)?;
3200 let mut output =
3201 open_new_private_capability_file(&parent, file_name).map_err(|source| SourceError::Io {
3202 operation: "create extracted source file",
3203 path: expected.path.clone(),
3204 source,
3205 })?;
3206 let mut entry = archive.by_index(index).map_err(|error| SourceError::Zip {
3207 operation: "open entry for extraction",
3208 message: error.to_string(),
3209 })?;
3210 if entry.name_raw() != expected.path.as_bytes() {
3211 return Err(SourceError::ManifestMismatch);
3212 }
3213
3214 let mut digest = Sha256::new();
3215 let mut size = 0_u64;
3216 let mut buffer = vec![0_u8; SOURCE_ARCHIVE_BUFFER_SIZE];
3217 loop {
3218 let read = entry
3219 .read(&mut buffer)
3220 .map_err(|error| SourceError::InvalidArchive {
3221 reason: format!(
3222 "ZIP entry {:?} failed integrity verification: {error}",
3223 expected.path
3224 ),
3225 })?;
3226 if read == 0 {
3227 break;
3228 }
3229 size = size
3230 .checked_add(read as u64)
3231 .ok_or(SourceError::ManifestMismatch)?;
3232 if size > expected.size {
3233 return Err(SourceError::ManifestMismatch);
3234 }
3235 digest.update(&buffer[..read]);
3236 output
3237 .write_all(&buffer[..read])
3238 .map_err(|source| SourceError::Io {
3239 operation: "write extracted source file",
3240 path: expected.path.clone(),
3241 source,
3242 })?;
3243 }
3244 if size != expected.size || hex_digest(digest.finalize()) != expected.sha256 {
3245 return Err(SourceError::ManifestMismatch);
3246 }
3247 output.sync_all().map_err(|source| SourceError::Io {
3248 operation: "synchronize extracted source file",
3249 path: expected.path.clone(),
3250 source,
3251 })?;
3252 drop(entry);
3253 #[cfg(unix)]
3254 set_extracted_permissions(&output, expected.executable).map_err(|source| SourceError::Io {
3255 operation: "set extracted source permissions",
3256 path: expected.path.clone(),
3257 source,
3258 })?;
3259 Ok(())
3260}
3261
3262fn create_safe_parent_directories(
3263 destination: &CapabilityDir,
3264 relative: &Utf8Path,
3265) -> Result<CapabilityDir, SourceError> {
3266 let mut current = destination.try_clone().map_err(|source| SourceError::Io {
3267 operation: "clone extraction destination handle",
3268 path: "extraction destination".to_owned(),
3269 source,
3270 })?;
3271 let Some(parent) = relative.parent() else {
3272 return Ok(current);
3273 };
3274 for component in parent {
3275 match current.symlink_metadata(component) {
3276 Ok(metadata) if metadata.is_symlink() || !metadata.is_dir() => {
3277 return Err(SourceError::InvalidArchive {
3278 reason: "extraction path encountered a non-directory component".to_owned(),
3279 });
3280 }
3281 Ok(_) => {}
3282 Err(source) if source.kind() == io::ErrorKind::NotFound => {
3283 create_private_capability_directory(¤t, component).map_err(|source| {
3284 SourceError::Io {
3285 operation: "create extracted source directory",
3286 path: display_relative_path(parent),
3287 source,
3288 }
3289 })?;
3290 }
3291 Err(source) => {
3292 return Err(SourceError::Io {
3293 operation: "inspect extracted source directory",
3294 path: display_relative_path(parent),
3295 source,
3296 });
3297 }
3298 }
3299 let next = current
3300 .open_dir(component)
3301 .map_err(|source| SourceError::Io {
3302 operation: "open extracted source directory",
3303 path: display_relative_path(parent),
3304 source,
3305 })?;
3306 if current
3307 .symlink_metadata(component)
3308 .map_err(|source| SourceError::Io {
3309 operation: "reinspect extracted source directory",
3310 path: display_relative_path(parent),
3311 source,
3312 })?
3313 .is_symlink()
3314 {
3315 return Err(SourceError::InvalidArchive {
3316 reason: "extraction path encountered a symbolic link".to_owned(),
3317 });
3318 }
3319 current = next;
3320 }
3321 Ok(current)
3322}
3323
3324#[cfg(unix)]
3325fn set_extracted_permissions(file: &cap_std::fs::File, executable: bool) -> io::Result<()> {
3326 use cap_std::fs::PermissionsExt as _;
3327
3328 let mode = if executable { 0o755 } else { 0o644 };
3329 file.set_permissions(cap_std::fs::Permissions::from_mode(mode))
3330}
3331
3332fn open_new_private_capability_file(
3333 parent: &CapabilityDir,
3334 name: &str,
3335) -> io::Result<cap_std::fs::File> {
3336 let mut options = CapabilityOpenOptions::new();
3337 options.read(true).write(true).create_new(true);
3338 #[cfg(unix)]
3339 {
3340 use cap_std::fs::OpenOptionsExt as _;
3341 options.mode(0o600);
3342 }
3343 parent.open_with(name, &options)
3344}
3345
3346#[allow(clippy::too_many_lines)]
3347fn scan_capability_paths(
3348 root: &CapabilityDir,
3349 limits: SourceLimits,
3350) -> Result<ScanResult, SourceError> {
3351 let root = root.try_clone().map_err(|source| SourceError::Io {
3352 operation: "clone verified bundle root handle",
3353 path: "bundle root".to_owned(),
3354 source,
3355 })?;
3356 let mut stack = vec![(root, Utf8PathBuf::from("."))];
3357 let mut directories = BTreeSet::new();
3358 let mut entries = BTreeMap::new();
3359 let mut collisions = BTreeMap::new();
3360 let mut total_size = 0_u64;
3361 let mut traversal = TraversalBudget::new(MAX_SOURCE_TRAVERSAL_ENTRIES)?;
3362
3363 while let Some((directory, relative)) = stack.pop() {
3364 let display = display_relative_path(&relative);
3365 let iterator = directory.entries().map_err(|source| SourceError::Io {
3366 operation: "read verified bundle directory",
3367 path: display.clone(),
3368 source,
3369 })?;
3370 let mut children = Vec::new();
3371 for child in iterator {
3372 traversal.consume(1, Some(&display))?;
3373 let child = child.map_err(|source| SourceError::Io {
3374 operation: "read verified bundle entry",
3375 path: display.clone(),
3376 source,
3377 })?;
3378 let name = child
3379 .file_name()
3380 .into_string()
3381 .map_err(|_| SourceError::NonUtf8Path)?;
3382 children.push(name);
3383 }
3384 children.sort();
3385
3386 for name in children.into_iter().rev() {
3387 let child_relative = if relative.as_str() == "." {
3388 Utf8PathBuf::from(&name)
3389 } else {
3390 relative.join(&name)
3391 };
3392 let child_display = display_relative_path(&child_relative);
3393 validate_portable_path_str(&child_display, false).map_err(|reason| {
3394 SourceError::NonPortablePath {
3395 path: child_display.clone(),
3396 reason,
3397 }
3398 })?;
3399 reject_sensitive(&child_display)?;
3400 check_depth(&child_display, limits.max_depth)?;
3401 let metadata = directory
3402 .symlink_metadata(&name)
3403 .map_err(|source| SourceError::Io {
3404 operation: "inspect verified bundle entry",
3405 path: child_display.clone(),
3406 source,
3407 })?;
3408 if metadata.is_symlink() {
3409 return Err(SourceError::Symlink {
3410 path: child_display,
3411 });
3412 }
3413 register_collision_key(&child_display, &mut collisions)?;
3414
3415 if metadata.is_dir() {
3416 directories.insert(child_display);
3417 let child = directory
3418 .open_dir(&name)
3419 .map_err(|source| SourceError::Io {
3420 operation: "open verified bundle directory",
3421 path: display_relative_path(&child_relative),
3422 source,
3423 })?;
3424 stack.push((child, child_relative));
3425 continue;
3426 }
3427 if !metadata.is_file() {
3428 return Err(SourceError::UnsupportedFileType {
3429 path: child_display,
3430 });
3431 }
3432 #[cfg(not(windows))]
3433 reject_capability_hard_link(&metadata, &child_display)?;
3434 if entries.len() >= limits.max_file_count {
3435 return Err(limit_error(
3436 SourceLimitKind::FileCount,
3437 Some(child_display),
3438 limits.max_file_count as u64,
3439 (entries.len() + 1) as u64,
3440 ));
3441 }
3442 if metadata.len() > limits.max_file_size {
3443 return Err(limit_error(
3444 SourceLimitKind::FileSize,
3445 Some(child_display),
3446 limits.max_file_size,
3447 metadata.len(),
3448 ));
3449 }
3450 let next_total = total_size.checked_add(metadata.len()).ok_or_else(|| {
3451 limit_error(
3452 SourceLimitKind::TotalSize,
3453 Some(child_display.clone()),
3454 limits.max_total_size,
3455 u64::MAX,
3456 )
3457 })?;
3458 if next_total > limits.max_total_size {
3459 return Err(limit_error(
3460 SourceLimitKind::TotalSize,
3461 Some(child_display.clone()),
3462 limits.max_total_size,
3463 next_total,
3464 ));
3465 }
3466 let contents = read_bounded_capability_file(
3467 &directory,
3468 &name,
3469 &child_display,
3470 limits.max_file_size,
3471 &metadata,
3472 )?;
3473 reject_sensitive_contents(&child_display, &contents)?;
3474 let executable = capability_executable_bit(&metadata);
3475 total_size = next_total;
3476 entries.insert(
3477 child_display.clone(),
3478 SourceManifestEntry {
3479 path: child_display,
3480 size: metadata.len(),
3481 sha256: hex_digest(Sha256::digest(contents)),
3482 executable,
3483 },
3484 );
3485 }
3486 }
3487
3488 Ok(ScanResult {
3489 entries: entries.into_values().collect(),
3490 directories: directories.into_iter().collect(),
3491 excluded_sensitive_paths: Vec::new(),
3492 })
3493}
3494
3495fn read_bounded_capability_file(
3496 directory: &CapabilityDir,
3497 name: &str,
3498 display: &str,
3499 maximum: u64,
3500 initial: &cap_std::fs::Metadata,
3501) -> Result<Vec<u8>, SourceError> {
3502 let file = directory.open(name).map_err(|source| SourceError::Io {
3503 operation: "open verified bundle file",
3504 path: display.to_owned(),
3505 source,
3506 })?;
3507 let opened = file.metadata().map_err(|source| SourceError::Io {
3508 operation: "inspect open verified bundle file",
3509 path: display.to_owned(),
3510 source,
3511 })?;
3512 reject_open_capability_hard_link(&file, display)?;
3513 if !opened.is_file() || !same_capability_file_identity(initial, &opened) {
3514 return Err(SourceError::ChangedDuringRead {
3515 path: display.to_owned(),
3516 });
3517 }
3518 let initial_modified = initial.modified().ok();
3519 let mut reader = file.take(maximum.saturating_add(1));
3520 let mut contents =
3521 Vec::with_capacity(usize::try_from(initial.len().min(64 * 1024)).unwrap_or(64 * 1024));
3522 reader
3523 .read_to_end(&mut contents)
3524 .map_err(|source| SourceError::Io {
3525 operation: "read verified bundle file",
3526 path: display.to_owned(),
3527 source,
3528 })?;
3529 if contents.len() as u64 > maximum {
3530 return Err(limit_error(
3531 SourceLimitKind::FileSize,
3532 Some(display.to_owned()),
3533 maximum,
3534 contents.len() as u64,
3535 ));
3536 }
3537 let opened_final = reader
3538 .get_ref()
3539 .metadata()
3540 .map_err(|source| SourceError::Io {
3541 operation: "reinspect open verified bundle file",
3542 path: display.to_owned(),
3543 source,
3544 })?;
3545 let path_final = directory
3546 .symlink_metadata(name)
3547 .map_err(|source| SourceError::Io {
3548 operation: "reinspect verified bundle file",
3549 path: display.to_owned(),
3550 source,
3551 })?;
3552 reject_open_capability_hard_link(reader.get_ref(), display)?;
3553 #[cfg(not(windows))]
3554 reject_capability_hard_link(&path_final, display)?;
3555 if path_final.is_symlink()
3556 || !path_final.is_file()
3557 || !same_capability_file_identity(&opened, &opened_final)
3558 || !same_capability_file_identity(&opened, &path_final)
3559 || opened_final.len() != initial.len()
3560 || path_final.len() != initial.len()
3561 || contents.len() as u64 != initial.len()
3562 || opened_final.modified().ok() != initial_modified
3563 || path_final.modified().ok() != initial_modified
3564 || capability_executable_bit(&opened_final) != capability_executable_bit(initial)
3565 || capability_executable_bit(&path_final) != capability_executable_bit(initial)
3566 {
3567 return Err(SourceError::ChangedDuringRead {
3568 path: display.to_owned(),
3569 });
3570 }
3571 Ok(contents)
3572}
3573
3574fn reject_link_count(links: u64, path: &str) -> Result<(), SourceError> {
3575 match links {
3576 1 => Ok(()),
3577 2.. => Err(SourceError::HardLink {
3578 path: path.to_owned(),
3579 links,
3580 }),
3581 _ => Err(SourceError::HardLinkInspectionUnavailable {
3582 path: path.to_owned(),
3583 }),
3584 }
3585}
3586
3587#[cfg(windows)]
3588fn reject_open_capability_hard_link(
3589 file: &cap_std::fs::File,
3590 path: &str,
3591) -> Result<(), SourceError> {
3592 let links = regular_file_link_count(file.as_handle()).map_err(|_| {
3593 SourceError::HardLinkInspectionUnavailable {
3594 path: path.to_owned(),
3595 }
3596 })?;
3597 reject_link_count(u64::from(links), path)
3598}
3599
3600#[cfg(not(windows))]
3601fn reject_open_capability_hard_link(
3602 file: &cap_std::fs::File,
3603 path: &str,
3604) -> Result<(), SourceError> {
3605 let metadata = file.metadata().map_err(|source| SourceError::Io {
3606 operation: "inspect open file hard-link count",
3607 path: path.to_owned(),
3608 source,
3609 })?;
3610 reject_capability_hard_link(&metadata, path)
3611}
3612
3613#[cfg(unix)]
3614fn reject_capability_hard_link(
3615 metadata: &cap_std::fs::Metadata,
3616 path: &str,
3617) -> Result<(), SourceError> {
3618 use cap_std::fs::MetadataExt as _;
3619
3620 reject_link_count(metadata.nlink(), path)
3621}
3622
3623#[cfg(not(any(unix, windows)))]
3624fn reject_capability_hard_link(
3625 _metadata: &cap_std::fs::Metadata,
3626 path: &str,
3627) -> Result<(), SourceError> {
3628 Err(SourceError::HardLinkInspectionUnavailable {
3629 path: path.to_owned(),
3630 })
3631}
3632
3633#[cfg(unix)]
3634fn same_capability_file_identity(
3635 first: &cap_std::fs::Metadata,
3636 second: &cap_std::fs::Metadata,
3637) -> bool {
3638 use cap_std::fs::MetadataExt as _;
3639
3640 first.dev() == second.dev() && first.ino() == second.ino()
3641}
3642
3643#[cfg(windows)]
3644fn same_capability_file_identity(
3645 first: &cap_std::fs::Metadata,
3646 second: &cap_std::fs::Metadata,
3647) -> bool {
3648 use cap_std::fs::MetadataExt as _;
3649
3650 first.file_attributes() == second.file_attributes()
3651 && first.creation_time() == second.creation_time()
3652 && first.last_write_time() == second.last_write_time()
3653 && first.file_size() == second.file_size()
3654}
3655
3656#[cfg(not(any(unix, windows)))]
3657fn same_capability_file_identity(
3658 _first: &cap_std::fs::Metadata,
3659 _second: &cap_std::fs::Metadata,
3660) -> bool {
3661 false
3662}
3663
3664#[cfg(unix)]
3665fn capability_executable_bit(metadata: &cap_std::fs::Metadata) -> bool {
3666 use cap_std::fs::MetadataExt as _;
3667
3668 metadata.mode() & 0o111 != 0
3669}
3670
3671#[cfg(not(unix))]
3672fn capability_executable_bit(_metadata: &cap_std::fs::Metadata) -> bool {
3673 false
3674}
3675
3676#[cfg(unix)]
3677fn normalize_unobservable_executable_modes(
3678 _actual: &mut [SourceManifestEntry],
3679 _expected: &[SourceManifestEntry],
3680) {
3681}
3682
3683#[cfg(not(unix))]
3684fn normalize_unobservable_executable_modes(
3685 actual: &mut [SourceManifestEntry],
3686 expected: &[SourceManifestEntry],
3687) {
3688 for entry in actual {
3689 if let Ok(index) = expected.binary_search_by(|candidate| candidate.path.cmp(&entry.path)) {
3690 entry.executable = expected[index].executable;
3691 }
3692 }
3693}
3694
3695#[derive(Debug)]
3696struct ResolvedRoots {
3697 workspace: Utf8PathBuf,
3698 project: Utf8PathBuf,
3699 project_relative: Utf8PathBuf,
3700}
3701
3702impl ResolvedRoots {
3703 fn resolve(request: &SourceBundleRequest) -> Result<Self, SourceError> {
3704 reject_root_symlink(&request.workspace_root, "workspace root")?;
3705 reject_root_symlink(&request.project_root, "project root")?;
3706 let workspace = canonical_directory(&request.workspace_root, "workspace root")?;
3707 let project = canonical_directory(&request.project_root, "project root")?;
3708 let relative = project
3709 .strip_prefix(&workspace)
3710 .map_err(|_| SourceError::ProjectOutsideWorkspace)?;
3711 let project_relative = if relative.as_str().is_empty() {
3712 Utf8PathBuf::from(".")
3713 } else {
3714 relative.to_owned()
3715 };
3716 let display = display_relative_path(&project_relative);
3717 validate_portable_path_str(&display, true).map_err(|reason| {
3718 SourceError::NonPortablePath {
3719 path: display.clone(),
3720 reason,
3721 }
3722 })?;
3723 if display != "." {
3724 reject_sensitive(&display)?;
3725 }
3726 Ok(Self {
3727 workspace,
3728 project,
3729 project_relative,
3730 })
3731 }
3732}
3733
3734#[derive(Debug)]
3735struct ScanResult {
3736 entries: Vec<SourceManifestEntry>,
3737 directories: Vec<String>,
3738 excluded_sensitive_paths: Vec<String>,
3739}
3740
3741#[derive(Debug)]
3742struct TraversalBudget {
3743 maximum: usize,
3744 visited: usize,
3745}
3746
3747impl TraversalBudget {
3748 fn new(maximum: usize) -> Result<Self, SourceError> {
3749 if maximum > MAX_SOURCE_TRAVERSAL_ENTRIES {
3750 return Err(SourceError::UnsupportedTraversalLimit {
3751 requested: maximum,
3752 maximum: MAX_SOURCE_TRAVERSAL_ENTRIES,
3753 });
3754 }
3755 Ok(Self {
3756 maximum,
3757 visited: 0,
3758 })
3759 }
3760
3761 fn consume(&mut self, count: usize, path: Option<&str>) -> Result<(), SourceError> {
3762 let actual = self.visited.saturating_add(count);
3763 if actual > self.maximum {
3764 return Err(limit_error(
3765 SourceLimitKind::TraversalEntryCount,
3766 path.map(str::to_owned),
3767 self.maximum as u64,
3768 actual as u64,
3769 ));
3770 }
3771 self.visited = actual;
3772 Ok(())
3773 }
3774}
3775
3776fn consume_manifest_traversal(total: &mut u64, path: &str) -> Result<(), SourceError> {
3777 *total = total.saturating_add(path.split('/').count() as u64);
3778 if *total > MAX_SOURCE_TRAVERSAL_ENTRIES as u64 {
3779 Err(limit_error(
3780 SourceLimitKind::TraversalEntryCount,
3781 Some(path.to_owned()),
3782 MAX_SOURCE_TRAVERSAL_ENTRIES as u64,
3783 *total,
3784 ))
3785 } else {
3786 Ok(())
3787 }
3788}
3789
3790fn consume_planned_manifest_traversal(
3791 traversal: &mut TraversalBudget,
3792 entries: &[SourceManifestEntry],
3793) -> Result<(), SourceError> {
3794 for entry in entries {
3795 traversal.consume(entry.path.split('/').count(), Some(&entry.path))?;
3796 }
3797 Ok(())
3798}
3799
3800#[derive(Clone, Debug)]
3801struct IgnoreRule {
3802 base: String,
3803 path: String,
3804}
3805
3806fn validate_executable_modes(
3807 request: &SourceBundleRequest,
3808 traversal: &mut TraversalBudget,
3809) -> Result<BTreeMap<String, bool>, SourceError> {
3810 let mut modes = BTreeMap::new();
3811 let mut collisions = BTreeMap::new();
3812 for (path, executable) in request.executable_modes() {
3813 let display = path.as_str().to_owned();
3814 traversal.consume(path.components().count(), Some(&display))?;
3815 validate_portable_path_str(&display, false).map_err(|reason| {
3816 SourceError::NonPortablePath {
3817 path: display.clone(),
3818 reason,
3819 }
3820 })?;
3821 reject_sensitive(&display)?;
3822 check_depth(&display, request.limits.max_depth)?;
3823 register_path_components(&display, &mut collisions)?;
3824 if modes.insert(display.clone(), *executable).is_some() {
3825 return Err(SourceError::DuplicateExecutableMode { path: display });
3826 }
3827 }
3828 Ok(modes)
3829}
3830
3831fn validate_workspace_exclusions(
3832 request: &SourceBundleRequest,
3833 traversal: &mut TraversalBudget,
3834) -> Result<Vec<String>, SourceError> {
3835 let mut requested = BTreeSet::new();
3836 let mut collisions = BTreeMap::new();
3837 for path in request.workspace_exclusions() {
3838 let display = path.as_str().to_owned();
3839 validate_portable_path_str(&display, false).map_err(|reason| {
3840 SourceError::NonPortablePath {
3841 path: display.clone(),
3842 reason,
3843 }
3844 })?;
3845 check_depth(&display, request.limits.max_depth)?;
3846 register_path_components(&display, &mut collisions)?;
3847 traversal.consume(display.split('/').count(), Some(&display))?;
3848 requested.insert(display);
3849 }
3850
3851 let mut exclusions: Vec<String> = Vec::new();
3852 for display in requested {
3853 if exclusions
3854 .iter()
3855 .any(|ancestor| path_is_within(&display, ancestor))
3856 {
3857 continue;
3858 }
3859 exclusions.push(display);
3860 }
3861 Ok(exclusions)
3862}
3863
3864fn apply_executable_modes(
3865 entries: &mut [SourceManifestEntry],
3866 modes: &BTreeMap<String, bool>,
3867) -> Result<(), SourceError> {
3868 for (path, portable) in modes {
3869 let index = entries
3870 .binary_search_by(|entry| entry.path.as_str().cmp(path))
3871 .map_err(|_| SourceError::ExecutableModePathNotSelected { path: path.clone() })?;
3872 entries[index].executable =
3873 resolved_executable_mode(entries[index].executable, Some(*portable));
3874 }
3875 Ok(())
3876}
3877
3878#[cfg(unix)]
3879fn resolved_executable_mode(filesystem: bool, portable: Option<bool>) -> bool {
3880 filesystem || portable.unwrap_or(false)
3881}
3882
3883#[cfg(not(unix))]
3884fn resolved_executable_mode(_filesystem: bool, portable: Option<bool>) -> bool {
3885 portable.unwrap_or(false)
3886}
3887
3888fn check_supported_depth(limits: SourceLimits) -> Result<(), SourceError> {
3889 if limits.max_depth > MAX_SUPPORTED_DEPTH {
3890 Err(SourceError::UnsupportedDepthLimit {
3891 requested: limits.max_depth,
3892 maximum: MAX_SUPPORTED_DEPTH,
3893 })
3894 } else {
3895 Ok(())
3896 }
3897}
3898
3899fn canonical_directory(path: &Utf8Path, root: &'static str) -> Result<Utf8PathBuf, SourceError> {
3900 let canonical = fs::canonicalize(path.as_std_path()).map_err(|source| SourceError::Io {
3901 operation: "canonicalize",
3902 path: root.to_owned(),
3903 source,
3904 })?;
3905 let canonical = Utf8PathBuf::from_path_buf(canonical).map_err(|_| SourceError::NonUtf8Path)?;
3906 let metadata = fs::metadata(canonical.as_std_path()).map_err(|source| SourceError::Io {
3907 operation: "inspect",
3908 path: root.to_owned(),
3909 source,
3910 })?;
3911 if !metadata.is_dir() {
3912 return Err(SourceError::InvalidRoot {
3913 root,
3914 reason: "not a directory",
3915 });
3916 }
3917 Ok(canonical)
3918}
3919
3920fn reject_root_symlink(path: &Utf8Path, root: &'static str) -> Result<(), SourceError> {
3921 match fs::symlink_metadata(path.as_std_path()) {
3922 Ok(metadata) if metadata.file_type().is_symlink() => Err(SourceError::InvalidRoot {
3923 root,
3924 reason: "root is a symbolic link",
3925 }),
3926 Ok(metadata) => reject_reparse_point(&metadata, root),
3927 Err(source) => Err(SourceError::Io {
3928 operation: "inspect",
3929 path: root.to_owned(),
3930 source,
3931 }),
3932 }
3933}
3934
3935fn collect_include_roots(
3936 request: &SourceBundleRequest,
3937 roots: &ResolvedRoots,
3938 workspace_exclusions: &[String],
3939 traversal: &mut TraversalBudget,
3940) -> Result<Vec<Utf8PathBuf>, SourceError> {
3941 let mut include_roots = BTreeSet::new();
3942 include_roots.insert(roots.project_relative.clone());
3943
3944 for path in WORKSPACE_BUILD_INPUTS {
3945 let relative = Utf8PathBuf::from(path);
3946 if roots
3947 .workspace
3948 .join(&relative)
3949 .try_exists()
3950 .map_err(|source| SourceError::Io {
3951 operation: "inspect",
3952 path: (*path).to_owned(),
3953 source,
3954 })?
3955 {
3956 include_roots.insert(relative);
3957 }
3958 }
3959
3960 for input in &request.workspace_inputs {
3961 let display = input.as_str().to_owned();
3962 traversal.consume(input.components().count(), Some(&display))?;
3963 validate_portable_path_str(&display, false).map_err(|reason| {
3964 SourceError::NonPortablePath {
3965 path: display.clone(),
3966 reason,
3967 }
3968 })?;
3969 reject_sensitive(&display)?;
3970 if is_workspace_excluded(&display, workspace_exclusions) {
3971 continue;
3972 }
3973 ensure_no_symlink_components(&roots.workspace, input)?;
3974 if !roots
3975 .workspace
3976 .join(input)
3977 .try_exists()
3978 .map_err(|source| SourceError::Io {
3979 operation: "inspect",
3980 path: display.clone(),
3981 source,
3982 })?
3983 {
3984 return Err(SourceError::MissingInput { path: display });
3985 }
3986 include_roots.insert(input.clone());
3987 }
3988
3989 Ok(include_roots.into_iter().collect())
3990}
3991
3992fn ensure_no_symlink_components(root: &Utf8Path, relative: &Utf8Path) -> Result<(), SourceError> {
3993 let mut current = root.to_owned();
3994 let mut display = Utf8PathBuf::new();
3995 for component in relative {
3996 current.push(component);
3997 display.push(component);
3998 match fs::symlink_metadata(current.as_std_path()) {
3999 Ok(metadata) if metadata.file_type().is_symlink() => {
4000 return Err(SourceError::Symlink {
4001 path: display.as_str().to_owned(),
4002 });
4003 }
4004 Ok(metadata) => reject_reparse_point(&metadata, display.as_str())?,
4005 Err(source) if source.kind() == io::ErrorKind::NotFound => {
4006 return Err(SourceError::MissingInput {
4007 path: relative.as_str().to_owned(),
4008 });
4009 }
4010 Err(source) => {
4011 return Err(SourceError::Io {
4012 operation: "inspect",
4013 path: display.as_str().to_owned(),
4014 source,
4015 });
4016 }
4017 }
4018 }
4019 Ok(())
4020}
4021
4022fn load_ignore_rules(
4023 roots: &ResolvedRoots,
4024 workspace_exclusions: &[String],
4025 limits: SourceLimits,
4026) -> Result<Vec<IgnoreRule>, SourceError> {
4027 let mut locations = vec![(Utf8PathBuf::from("."), roots.workspace.join(".ferryignore"))];
4028 if roots.project != roots.workspace {
4029 locations.push((
4030 roots.project_relative.clone(),
4031 roots.project.join(".ferryignore"),
4032 ));
4033 }
4034
4035 let mut rules = Vec::new();
4036 for (base, source_path) in locations {
4037 let display = relative_ignore_path(&base);
4038 if is_workspace_excluded(&display, workspace_exclusions) {
4039 continue;
4040 }
4041 let metadata = match fs::symlink_metadata(source_path.as_std_path()) {
4042 Ok(metadata) => metadata,
4043 Err(source) if source.kind() == io::ErrorKind::NotFound => continue,
4044 Err(source) => {
4045 return Err(SourceError::Io {
4046 operation: "inspect",
4047 path: display,
4048 source,
4049 });
4050 }
4051 };
4052 if metadata.file_type().is_symlink() {
4053 return Err(SourceError::Symlink { path: display });
4054 }
4055 reject_reparse_point(&metadata, &display)?;
4056 if !metadata.is_file() {
4057 return Err(SourceError::UnsupportedFileType { path: display });
4058 }
4059 #[cfg(not(windows))]
4060 reject_hard_link(&metadata, &display)?;
4061 if metadata.len() > limits.max_ignore_file_size {
4062 return Err(limit_error(
4063 SourceLimitKind::IgnoreFileSize,
4064 Some(display),
4065 limits.max_ignore_file_size,
4066 metadata.len(),
4067 ));
4068 }
4069 let bytes = read_bounded_file(
4070 &source_path,
4071 &display,
4072 limits.max_ignore_file_size,
4073 &metadata,
4074 SourceLimitKind::IgnoreFileSize,
4075 )?;
4076 let text = std::str::from_utf8(&bytes).map_err(|_| SourceError::InvalidIgnoreRule {
4077 file: display.clone(),
4078 line: 1,
4079 reason: IgnoreRuleReason::NonPortable(PortablePathReason::InvalidCharacter),
4080 })?;
4081 parse_ignore_file(text, &display, &base, limits, &mut rules)?;
4082 }
4083 Ok(rules)
4084}
4085
4086fn parse_ignore_file(
4087 text: &str,
4088 file: &str,
4089 base: &Utf8Path,
4090 limits: SourceLimits,
4091 rules: &mut Vec<IgnoreRule>,
4092) -> Result<(), SourceError> {
4093 for (index, raw_line) in text.lines().enumerate() {
4094 let line_number = index + 1;
4095 let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
4096 if line.is_empty() || line.starts_with('#') {
4097 continue;
4098 }
4099 let invalid = |reason| SourceError::InvalidIgnoreRule {
4100 file: file.to_owned(),
4101 line: line_number,
4102 reason,
4103 };
4104 if line.len() > MAX_IGNORE_PATTERN_BYTES {
4105 return Err(invalid(IgnoreRuleReason::PatternTooLong));
4106 }
4107 if line.trim_matches([' ', '\t']) != line {
4108 return Err(invalid(IgnoreRuleReason::EdgeWhitespace));
4109 }
4110 if line.starts_with('!') {
4111 return Err(invalid(IgnoreRuleReason::Negation));
4112 }
4113 if line.contains(['*', '?', '[', ']']) {
4114 return Err(invalid(IgnoreRuleReason::Glob));
4115 }
4116 let pattern = line.strip_suffix('/').unwrap_or(line);
4117 if pattern.is_empty() {
4118 return Err(invalid(IgnoreRuleReason::EmptyDirectoryPattern));
4119 }
4120 validate_portable_path_str(pattern, false)
4121 .map_err(|reason| invalid(IgnoreRuleReason::NonPortable(reason)))?;
4122 if rules.len() >= limits.max_ignore_rules {
4123 return Err(limit_error(
4124 SourceLimitKind::IgnoreRuleCount,
4125 Some(file.to_owned()),
4126 limits.max_ignore_rules as u64,
4127 (rules.len() + 1) as u64,
4128 ));
4129 }
4130 rules.push(IgnoreRule {
4131 base: display_relative_path(base),
4132 path: pattern.to_owned(),
4133 });
4134 }
4135 Ok(())
4136}
4137
4138#[allow(clippy::too_many_lines)]
4139fn scan_paths(
4140 root: &Utf8Path,
4141 include_roots: Vec<Utf8PathBuf>,
4142 workspace_exclusions: &[String],
4143 ignore_rules: &[IgnoreRule],
4144 limits: SourceLimits,
4145 traversal: &mut TraversalBudget,
4146) -> Result<ScanResult, SourceError> {
4147 let mut stack: Vec<_> = include_roots.into_iter().rev().collect();
4148 let mut visited_directories = BTreeSet::new();
4149 let mut directories = BTreeSet::new();
4150 let mut entries = BTreeMap::new();
4151 let mut collisions = BTreeMap::new();
4152 let mut excluded_sensitive_paths = BTreeSet::new();
4153 let mut total_size = 0_u64;
4154
4155 while let Some(relative) = stack.pop() {
4156 let display = display_relative_path(&relative);
4157 if display != "." {
4158 validate_portable_path_str(&display, false).map_err(|reason| {
4159 SourceError::NonPortablePath {
4160 path: display.clone(),
4161 reason,
4162 }
4163 })?;
4164 check_depth(&display, limits.max_depth)?;
4165 if is_sensitive_path(&display) {
4166 excluded_sensitive_paths.insert(display);
4167 continue;
4168 }
4169 if is_workspace_excluded(&display, workspace_exclusions) {
4170 continue;
4171 }
4172 if is_ignored(&display, ignore_rules) {
4173 continue;
4174 }
4175 }
4176
4177 let source_path = root.join(&relative);
4178 let metadata = match fs::symlink_metadata(source_path.as_std_path()) {
4179 Ok(metadata) => metadata,
4180 Err(source) if source.kind() == io::ErrorKind::NotFound => {
4181 return Err(SourceError::MissingInput { path: display });
4182 }
4183 Err(source) => {
4184 return Err(SourceError::Io {
4185 operation: "inspect",
4186 path: display,
4187 source,
4188 });
4189 }
4190 };
4191 if metadata.file_type().is_symlink() {
4192 return Err(SourceError::Symlink { path: display });
4193 }
4194 reject_reparse_point(&metadata, &display)?;
4195
4196 if metadata.is_dir() {
4197 if display != "." {
4198 register_collision_key(&display, &mut collisions)?;
4199 directories.insert(display.clone());
4200 }
4201 if !visited_directories.insert(display.clone()) {
4202 continue;
4203 }
4204 let mut children = read_directory_children(&source_path, &display, traversal)?;
4205 children.sort();
4206 for child in children.into_iter().rev() {
4207 let child_relative = if relative.as_str() == "." {
4208 Utf8PathBuf::from(child)
4209 } else {
4210 relative.join(child)
4211 };
4212 stack.push(child_relative);
4213 }
4214 continue;
4215 }
4216 if !metadata.is_file() {
4217 return Err(SourceError::UnsupportedFileType { path: display });
4218 }
4219 #[cfg(not(windows))]
4220 reject_hard_link(&metadata, &display)?;
4221 register_collision_key(&display, &mut collisions)?;
4222 if entries.contains_key(&display) {
4223 continue;
4224 }
4225 if entries.len() >= limits.max_file_count {
4226 return Err(limit_error(
4227 SourceLimitKind::FileCount,
4228 Some(display),
4229 limits.max_file_count as u64,
4230 (entries.len() + 1) as u64,
4231 ));
4232 }
4233 if metadata.len() > limits.max_file_size {
4234 return Err(limit_error(
4235 SourceLimitKind::FileSize,
4236 Some(display),
4237 limits.max_file_size,
4238 metadata.len(),
4239 ));
4240 }
4241 let next_total = total_size.checked_add(metadata.len()).ok_or_else(|| {
4242 limit_error(
4243 SourceLimitKind::TotalSize,
4244 Some(display.clone()),
4245 limits.max_total_size,
4246 u64::MAX,
4247 )
4248 })?;
4249 if next_total > limits.max_total_size {
4250 return Err(limit_error(
4251 SourceLimitKind::TotalSize,
4252 Some(display.clone()),
4253 limits.max_total_size,
4254 next_total,
4255 ));
4256 }
4257 let contents = read_bounded_file(
4258 &source_path,
4259 &display,
4260 limits.max_file_size,
4261 &metadata,
4262 SourceLimitKind::FileSize,
4263 )?;
4264 reject_sensitive_contents(&display, &contents)?;
4265 let digest = hex_digest(Sha256::digest(&contents));
4266 let executable = executable_bit(&metadata);
4267 total_size = next_total;
4268 entries.insert(
4269 display.clone(),
4270 SourceManifestEntry {
4271 path: display,
4272 size: metadata.len(),
4273 sha256: digest,
4274 executable,
4275 },
4276 );
4277 }
4278
4279 Ok(ScanResult {
4280 entries: entries.into_values().collect(),
4281 directories: directories.into_iter().collect(),
4282 excluded_sensitive_paths: excluded_sensitive_paths.into_iter().collect(),
4283 })
4284}
4285
4286fn read_directory_children(
4287 directory: &Utf8Path,
4288 display: &str,
4289 traversal: &mut TraversalBudget,
4290) -> Result<Vec<String>, SourceError> {
4291 let iterator = fs::read_dir(directory.as_std_path()).map_err(|source| SourceError::Io {
4292 operation: "read directory",
4293 path: display.to_owned(),
4294 source,
4295 })?;
4296 let mut children = Vec::new();
4297 for entry in iterator {
4298 traversal.consume(1, Some(display))?;
4299 let entry = entry.map_err(|source| SourceError::Io {
4300 operation: "read directory entry",
4301 path: display.to_owned(),
4302 source,
4303 })?;
4304 let name = entry
4305 .file_name()
4306 .into_string()
4307 .map_err(|_| SourceError::NonUtf8Path)?;
4308 children.push(name);
4309 }
4310 Ok(children)
4311}
4312
4313fn read_bounded_file(
4314 path: &Utf8Path,
4315 display: &str,
4316 maximum: u64,
4317 initial_metadata: &Metadata,
4318 limit_kind: SourceLimitKind,
4319) -> Result<Vec<u8>, SourceError> {
4320 reject_reparse_point(initial_metadata, display)?;
4321 let file = open_read_stable(path).map_err(|source| SourceError::Io {
4322 operation: "open",
4323 path: display.to_owned(),
4324 source,
4325 })?;
4326 let opened_metadata = file.metadata().map_err(|source| SourceError::Io {
4327 operation: "inspect open file",
4328 path: display.to_owned(),
4329 source,
4330 })?;
4331 reject_open_hard_link(&file, display)?;
4332 #[cfg(windows)]
4333 reject_named_streams(&file, display)?;
4334 if !opened_metadata.is_file() || !same_file_identity(initial_metadata, &opened_metadata) {
4335 return Err(SourceError::ChangedDuringRead {
4336 path: display.to_owned(),
4337 });
4338 }
4339
4340 let initial_size = initial_metadata.len();
4341 let initial_modified = initial_metadata.modified().ok();
4342 let mut reader = file.take(maximum.saturating_add(1));
4343 let initial_capacity = usize::try_from(initial_size.min(64 * 1024)).unwrap_or(64 * 1024);
4344 let mut contents = Vec::with_capacity(initial_capacity);
4345 reader
4346 .read_to_end(&mut contents)
4347 .map_err(|source| SourceError::Io {
4348 operation: "read",
4349 path: display.to_owned(),
4350 source,
4351 })?;
4352 let actual_size = contents.len() as u64;
4353 if actual_size > maximum {
4354 return Err(limit_error(
4355 limit_kind,
4356 Some(display.to_owned()),
4357 maximum,
4358 actual_size,
4359 ));
4360 }
4361
4362 let open_final_metadata = reader
4363 .get_ref()
4364 .metadata()
4365 .map_err(|source| SourceError::Io {
4366 operation: "reinspect open file",
4367 path: display.to_owned(),
4368 source,
4369 })?;
4370 let path_final_metadata =
4371 fs::symlink_metadata(path.as_std_path()).map_err(|source| SourceError::Io {
4372 operation: "reinspect",
4373 path: display.to_owned(),
4374 source,
4375 })?;
4376 if path_final_metadata.file_type().is_symlink() {
4377 return Err(SourceError::ChangedDuringRead {
4378 path: display.to_owned(),
4379 });
4380 }
4381 reject_reparse_point(&open_final_metadata, display)?;
4382 reject_reparse_point(&path_final_metadata, display)?;
4383 reject_open_hard_link(reader.get_ref(), display)?;
4384 #[cfg(windows)]
4385 reject_named_streams(reader.get_ref(), display)?;
4386 #[cfg(not(windows))]
4387 {
4388 reject_hard_link(&open_final_metadata, display)?;
4389 reject_hard_link(&path_final_metadata, display)?;
4390 }
4391 let stable = open_final_metadata.is_file()
4392 && path_final_metadata.is_file()
4393 && same_file_identity(&opened_metadata, &open_final_metadata)
4394 && same_file_identity(&opened_metadata, &path_final_metadata)
4395 && open_final_metadata.len() == initial_size
4396 && path_final_metadata.len() == initial_size
4397 && actual_size == initial_size
4398 && open_final_metadata.modified().ok() == initial_modified
4399 && path_final_metadata.modified().ok() == initial_modified
4400 && executable_bit(&open_final_metadata) == executable_bit(initial_metadata)
4401 && executable_bit(&path_final_metadata) == executable_bit(initial_metadata);
4402 if !stable {
4403 return Err(SourceError::ChangedDuringRead {
4404 path: display.to_owned(),
4405 });
4406 }
4407 Ok(contents)
4408}
4409
4410#[cfg(windows)]
4411fn reject_open_hard_link(file: &File, path: &str) -> Result<(), SourceError> {
4412 let links = regular_file_link_count(file.as_handle()).map_err(|_| {
4413 SourceError::HardLinkInspectionUnavailable {
4414 path: path.to_owned(),
4415 }
4416 })?;
4417 reject_link_count(u64::from(links), path)
4418}
4419
4420#[cfg(windows)]
4421fn reject_named_streams(file: &File, path: &str) -> Result<(), SourceError> {
4422 verify_regular_file_has_no_named_streams(file).map_err(|error| match error.kind() {
4423 RegularFileStreamErrorKind::NamedStream => SourceError::AlternateDataStream {
4424 path: path.to_owned(),
4425 },
4426 _ => SourceError::StreamInspectionUnavailable {
4427 path: path.to_owned(),
4428 },
4429 })
4430}
4431
4432#[cfg(not(windows))]
4433fn reject_open_hard_link(file: &File, path: &str) -> Result<(), SourceError> {
4434 let metadata = file.metadata().map_err(|source| SourceError::Io {
4435 operation: "inspect open file hard-link count",
4436 path: path.to_owned(),
4437 source,
4438 })?;
4439 reject_hard_link(&metadata, path)
4440}
4441
4442#[cfg(unix)]
4443fn reject_hard_link(metadata: &Metadata, path: &str) -> Result<(), SourceError> {
4444 use std::os::unix::fs::MetadataExt;
4445
4446 reject_link_count(metadata.nlink(), path)
4447}
4448
4449#[cfg(not(any(unix, windows)))]
4450fn reject_hard_link(_metadata: &Metadata, path: &str) -> Result<(), SourceError> {
4451 Err(SourceError::HardLinkInspectionUnavailable {
4452 path: path.to_owned(),
4453 })
4454}
4455
4456#[cfg(unix)]
4457fn same_file_identity(first: &Metadata, second: &Metadata) -> bool {
4458 use std::os::unix::fs::MetadataExt;
4459
4460 first.dev() == second.dev() && first.ino() == second.ino()
4461}
4462
4463#[cfg(windows)]
4464fn same_file_identity(first: &Metadata, second: &Metadata) -> bool {
4465 use std::os::windows::fs::MetadataExt;
4466
4467 first.file_attributes() == second.file_attributes()
4468 && first.creation_time() == second.creation_time()
4469 && first.last_write_time() == second.last_write_time()
4470 && first.file_size() == second.file_size()
4471}
4472
4473#[cfg(not(any(unix, windows)))]
4474fn same_file_identity(_first: &Metadata, _second: &Metadata) -> bool {
4475 false
4476}
4477
4478#[cfg(unix)]
4479fn executable_bit(metadata: &Metadata) -> bool {
4480 use std::os::unix::fs::PermissionsExt;
4481
4482 metadata.permissions().mode() & 0o111 != 0
4483}
4484
4485#[cfg(not(unix))]
4486fn executable_bit(_metadata: &Metadata) -> bool {
4487 false
4488}
4489
4490fn build_manifest(project_path: String, entries: Vec<SourceManifestEntry>) -> SourceManifest {
4491 let total_size = entries.iter().map(|entry| entry.size).sum();
4492 let sha256 = manifest_digest(&project_path, &entries, total_size);
4493 SourceManifest {
4494 schema_version: MANIFEST_SCHEMA_VERSION,
4495 project_path,
4496 entries,
4497 total_size,
4498 sha256,
4499 }
4500}
4501
4502fn manifest_digest(project_path: &str, entries: &[SourceManifestEntry], total_size: u64) -> String {
4503 let mut digest = Sha256::new();
4504 digest.update(MANIFEST_DOMAIN);
4505 digest_string(&mut digest, project_path);
4506 digest.update((entries.len() as u64).to_be_bytes());
4507 for entry in entries {
4508 digest_string(&mut digest, &entry.path);
4509 digest.update(entry.size.to_be_bytes());
4510 digest_string(&mut digest, &entry.sha256);
4511 digest.update([u8::from(entry.executable)]);
4512 }
4513 digest.update(total_size.to_be_bytes());
4514 hex_digest(digest.finalize())
4515}
4516
4517fn digest_string(digest: &mut Sha256, value: &str) {
4518 digest.update((value.len() as u64).to_be_bytes());
4519 digest.update(value.as_bytes());
4520}
4521
4522fn hex_digest(bytes: impl AsRef<[u8]>) -> String {
4523 const HEX: &[u8; 16] = b"0123456789abcdef";
4524 let bytes = bytes.as_ref();
4525 let mut encoded = String::with_capacity(bytes.len() * 2);
4526 for byte in bytes {
4527 encoded.push(char::from(HEX[usize::from(byte >> 4)]));
4528 encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
4529 }
4530 encoded
4531}
4532
4533fn validate_sha256(value: &str) -> Result<(), SourceError> {
4534 if value.len() == 64
4535 && value
4536 .bytes()
4537 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
4538 {
4539 Ok(())
4540 } else {
4541 Err(SourceError::InvalidManifest {
4542 reason: "SHA-256 digest is not 64 lowercase hexadecimal characters",
4543 })
4544 }
4545}
4546
4547fn validate_portable_path_str(path: &str, allow_dot: bool) -> Result<(), PortablePathReason> {
4548 if allow_dot && path == "." {
4549 return Ok(());
4550 }
4551 if path.is_empty() {
4552 return Err(PortablePathReason::Empty);
4553 }
4554 if path.len() > MAX_PORTABLE_PATH_BYTES {
4555 return Err(PortablePathReason::PathTooLong);
4556 }
4557 if path.starts_with("//") || path.starts_with("\\\\") {
4558 return Err(PortablePathReason::UncPrefix);
4559 }
4560 let bytes = path.as_bytes();
4561 if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
4562 return Err(PortablePathReason::DrivePrefix);
4563 }
4564 if Path::new(path).is_absolute() || path.starts_with('/') {
4565 return Err(PortablePathReason::Absolute);
4566 }
4567 if path.contains('\\') {
4568 return Err(PortablePathReason::Backslash);
4569 }
4570
4571 for segment in path.split('/') {
4572 if segment.is_empty() {
4573 return Err(PortablePathReason::EmptyComponent);
4574 }
4575 if segment == "." || segment == ".." {
4576 return Err(PortablePathReason::DotComponent);
4577 }
4578 if segment.len() > MAX_PORTABLE_SEGMENT_BYTES {
4579 return Err(PortablePathReason::SegmentTooLong);
4580 }
4581 if segment.ends_with(['.', ' ']) {
4582 return Err(PortablePathReason::TrailingDotOrSpace);
4583 }
4584 if !segment.nfc().eq(segment.chars()) {
4585 return Err(PortablePathReason::NonNfc);
4586 }
4587 if segment.chars().any(|character| {
4588 character.is_control()
4589 || matches!(character, '<' | '>' | ':' | '"' | '|' | '?' | '*')
4590 || is_unsafe_unicode_format(character)
4591 }) {
4592 return Err(PortablePathReason::InvalidCharacter);
4593 }
4594 let stem = segment.split('.').next().unwrap_or(segment);
4595 if is_windows_reserved_name(stem) {
4596 return Err(PortablePathReason::ReservedName);
4597 }
4598 }
4599 Ok(())
4600}
4601
4602fn is_windows_reserved_name(stem: &str) -> bool {
4603 let upper = stem.to_ascii_uppercase();
4604 matches!(
4605 upper.as_str(),
4606 "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$"
4607 ) || upper.strip_prefix("COM").is_some_and(|suffix| {
4608 matches!(
4609 suffix,
4610 "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³"
4611 )
4612 }) || upper.strip_prefix("LPT").is_some_and(|suffix| {
4613 matches!(
4614 suffix,
4615 "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³"
4616 )
4617 })
4618}
4619
4620fn reject_sensitive(path: &str) -> Result<(), SourceError> {
4621 if is_sensitive_path(path) {
4622 Err(SourceError::SensitivePath {
4623 path: path.to_owned(),
4624 })
4625 } else {
4626 Ok(())
4627 }
4628}
4629
4630fn reject_sensitive_contents(path: &str, contents: &[u8]) -> Result<(), SourceError> {
4631 if contains_private_key(contents) || contains_access_token(contents) {
4632 Err(SourceError::SensitiveContent {
4633 path: path.to_owned(),
4634 })
4635 } else {
4636 Ok(())
4637 }
4638}
4639
4640fn contains_private_key(contents: &[u8]) -> bool {
4641 if contains_putty_private_key(contents) {
4642 return true;
4643 }
4644
4645 let begin = b"-----BEGIN ";
4646 let key_suffix = b"PRIVATE KEY-----";
4647 let end = b"-----END ";
4648 let mut offset = 0;
4649 while let Some(relative_begin) = find_bytes(&contents[offset..], begin) {
4650 let header_start = offset + relative_begin + begin.len();
4651 let header_window_end = header_start.saturating_add(64).min(contents.len());
4652 let Some(relative_suffix) =
4653 find_bytes(&contents[header_start..header_window_end], key_suffix)
4654 else {
4655 offset = header_start;
4656 continue;
4657 };
4658 let body_start = header_start + relative_suffix + key_suffix.len();
4659 let Some(relative_end) = find_bytes(&contents[body_start..], end) else {
4660 offset = body_start;
4661 continue;
4662 };
4663 let body = &contents[body_start..body_start + relative_end];
4664 if body
4665 .iter()
4666 .filter(|byte| byte.is_ascii_alphanumeric())
4667 .count()
4668 >= 128
4669 {
4670 return true;
4671 }
4672 offset = body_start;
4673 }
4674 false
4675}
4676
4677fn contains_putty_private_key(contents: &[u8]) -> bool {
4678 let mut lines = contents
4679 .split(|byte| matches!(*byte, b'\r' | b'\n'))
4680 .filter(|line| !line.is_empty());
4681 let mut saw_header = false;
4682
4683 while let Some(line) = lines.next() {
4684 if is_putty_private_key_header(line) {
4685 saw_header = true;
4686 continue;
4687 }
4688 if !saw_header {
4689 continue;
4690 }
4691 let Some(private_line_count) = putty_private_line_count(line) else {
4692 continue;
4693 };
4694
4695 let mut encoded_bytes = 0_usize;
4696 let mut complete = private_line_count > 0;
4697 let mut next_header = false;
4698 for _ in 0..private_line_count {
4699 let Some(line) = lines.next() else {
4700 complete = false;
4701 break;
4702 };
4703 if is_putty_private_key_header(line) {
4704 complete = false;
4705 next_header = true;
4706 break;
4707 }
4708 if line.is_empty() || !line.iter().all(|byte| is_base64_byte(*byte)) {
4709 complete = false;
4710 break;
4711 }
4712 encoded_bytes = encoded_bytes
4713 .saturating_add(line.iter().filter(|byte| !matches!(**byte, b'=')).count());
4714 }
4715 if complete && encoded_bytes >= 32 {
4716 return true;
4717 }
4718 saw_header = next_header;
4719 }
4720 false
4721}
4722
4723fn is_putty_private_key_header(line: &[u8]) -> bool {
4724 const PREFIX: &[u8] = b"PuTTY-User-Key-File-";
4725 let Some(rest) = line.strip_prefix(PREFIX) else {
4726 return false;
4727 };
4728 rest.len() > 3
4729 && matches!(rest[0], b'1' | b'2' | b'3')
4730 && &rest[1..3] == b": "
4731 && rest[3..].iter().all(u8::is_ascii_graphic)
4732}
4733
4734fn putty_private_line_count(line: &[u8]) -> Option<usize> {
4735 let digits = line.strip_prefix(b"Private-Lines: ")?;
4736 if digits.is_empty() {
4737 return None;
4738 }
4739 digits.iter().try_fold(0_usize, |count, byte| {
4740 if !byte.is_ascii_digit() {
4741 return None;
4742 }
4743 count
4744 .checked_mul(10)?
4745 .checked_add(usize::from(*byte - b'0'))
4746 })
4747}
4748
4749const fn is_base64_byte(byte: u8) -> bool {
4750 byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'=')
4751}
4752
4753fn contains_access_token(contents: &[u8]) -> bool {
4754 const TOKEN_SIGNATURES: &[(&[u8], usize, usize)] = &[
4755 (b"\x67hp_", 36, 64),
4756 (b"\x67ithub_pat_", 40, 128),
4757 (b"\x67ho_", 36, 128),
4758 (b"\x67hu_", 36, 128),
4759 (b"\x67hs_", 36, 128),
4760 (b"\x67hr_", 36, 128),
4761 (b"\x6epm_", 36, 64),
4762 (b"\x73k_live_", 24, 96),
4763 (b"\x78oxb-", 20, 160),
4764 (b"\x78oxp-", 20, 160),
4765 (b"\x79a29.", 20, 160),
4766 (b"\x41KIA", 16, 16),
4767 (b"\x41SIA", 16, 16),
4768 ];
4769
4770 TOKEN_SIGNATURES.iter().any(|(prefix, minimum, maximum)| {
4771 let mut offset = 0;
4772 while let Some(relative) = find_bytes(&contents[offset..], prefix) {
4773 let start = offset + relative;
4774 let before_ok = start == 0 || !is_token_byte(contents[start - 1]);
4775 let tail_start = start + prefix.len();
4776 let tail_length = contents[tail_start..]
4777 .iter()
4778 .take_while(|byte| is_token_byte(**byte))
4779 .count();
4780 let after = tail_start.saturating_add(tail_length);
4781 let after_ok = after == contents.len() || !is_token_byte(contents[after]);
4782 if before_ok && after_ok && (*minimum..=*maximum).contains(&tail_length) {
4783 return true;
4784 }
4785 offset = tail_start;
4786 }
4787 false
4788 })
4789}
4790
4791fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
4792 haystack
4793 .windows(needle.len())
4794 .position(|window| window == needle)
4795}
4796
4797const fn is_token_byte(byte: u8) -> bool {
4798 byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')
4799}
4800
4801#[cfg_attr(
4802 not(windows),
4803 expect(
4804 clippy::unnecessary_wraps,
4805 reason = "Windows reparse validation has no non-Windows equivalent"
4806 )
4807)]
4808fn reject_reparse_point(metadata: &Metadata, path: &str) -> Result<(), SourceError> {
4809 #[cfg(windows)]
4810 {
4811 use std::os::windows::fs::MetadataExt as _;
4812
4813 if metadata.file_attributes() & WINDOWS_FILE_ATTRIBUTE_REPARSE_POINT != 0 {
4814 return Err(SourceError::ReparsePoint {
4815 path: path.to_owned(),
4816 });
4817 }
4818 }
4819 #[cfg(not(windows))]
4820 let _ = (metadata, path);
4821 Ok(())
4822}
4823
4824fn is_sensitive_path(path: &str) -> bool {
4825 let mut components = path.split('/');
4826 let Some(file_name) = components.next_back() else {
4827 return false;
4828 };
4829 let parent_sensitive = components.any(|component| {
4830 let lower = component.to_lowercase();
4831 SENSITIVE_DIRECTORIES.contains(&lower.as_str())
4832 });
4833 if parent_sensitive {
4834 return true;
4835 }
4836
4837 let lower = file_name.to_lowercase();
4838 if SENSITIVE_DIRECTORIES.contains(&lower.as_str())
4839 || SENSITIVE_FILE_NAMES.contains(&lower.as_str())
4840 || lower == ".env"
4841 || lower.starts_with(".env.")
4842 {
4843 return true;
4844 }
4845 lower
4846 .rsplit_once('.')
4847 .is_some_and(|(_, extension)| SENSITIVE_EXTENSIONS.contains(&extension))
4848}
4849
4850fn is_ignored(path: &str, rules: &[IgnoreRule]) -> bool {
4851 rules.iter().any(|rule| {
4852 let relative = if rule.base == "." {
4853 Some(path)
4854 } else {
4855 path.strip_prefix(&rule.base)
4856 .and_then(|rest| rest.strip_prefix('/'))
4857 };
4858 relative.is_some_and(|relative| {
4859 relative == rule.path
4860 || relative
4861 .strip_prefix(&rule.path)
4862 .is_some_and(|rest| rest.starts_with('/'))
4863 })
4864 })
4865}
4866
4867fn is_workspace_excluded(path: &str, exclusions: &[String]) -> bool {
4868 exclusions
4869 .iter()
4870 .any(|excluded| path_is_within(path, excluded))
4871}
4872
4873fn path_is_within(path: &str, root: &str) -> bool {
4874 path == root
4875 || path
4876 .strip_prefix(root)
4877 .is_some_and(|rest| rest.starts_with('/'))
4878}
4879
4880fn register_collision_key(
4881 path: &str,
4882 collisions: &mut BTreeMap<String, String>,
4883) -> Result<(), SourceError> {
4884 let lowercase: String = path.nfc().flat_map(char::to_lowercase).collect();
4885 let key: String = lowercase.nfc().collect();
4886 if let Some(existing) = collisions.get(&key) {
4887 if existing != path {
4888 return Err(SourceError::CaseCollision {
4889 first: existing.clone(),
4890 second: path.to_owned(),
4891 });
4892 }
4893 } else {
4894 collisions.insert(key, path.to_owned());
4895 }
4896 Ok(())
4897}
4898
4899fn register_path_components(
4900 path: &str,
4901 collisions: &mut BTreeMap<String, String>,
4902) -> Result<(), SourceError> {
4903 let mut prefix = String::new();
4904 for component in path.split('/') {
4905 if !prefix.is_empty() {
4906 prefix.push('/');
4907 }
4908 prefix.push_str(component);
4909 register_collision_key(&prefix, collisions)?;
4910 }
4911 Ok(())
4912}
4913
4914fn has_file_ancestor(path: &str, file_paths: &BTreeSet<String>) -> bool {
4915 let mut prefix = String::new();
4916 let component_count = path.split('/').count();
4917 for component in path.split('/').take(component_count.saturating_sub(1)) {
4918 if !prefix.is_empty() {
4919 prefix.push('/');
4920 }
4921 prefix.push_str(component);
4922 if file_paths.contains(&prefix) {
4923 return true;
4924 }
4925 }
4926 false
4927}
4928
4929fn is_unsafe_unicode_format(character: char) -> bool {
4930 matches!(
4931 character,
4932 '\u{061c}'
4933 | '\u{200b}'
4934 | '\u{200c}'
4935 | '\u{200d}'
4936 | '\u{200e}'
4937 | '\u{200f}'
4938 | '\u{202a}'..='\u{202e}'
4939 | '\u{2060}'
4940 | '\u{2066}'..='\u{2069}'
4941 | '\u{feff}'
4942 )
4943}
4944
4945fn check_depth(path: &str, maximum: usize) -> Result<(), SourceError> {
4946 let actual = path.split('/').count();
4947 if actual > maximum {
4948 Err(limit_error(
4949 SourceLimitKind::Depth,
4950 Some(path.to_owned()),
4951 maximum as u64,
4952 actual as u64,
4953 ))
4954 } else {
4955 Ok(())
4956 }
4957}
4958
4959fn limit_error(
4960 kind: SourceLimitKind,
4961 path: Option<String>,
4962 maximum: u64,
4963 actual: u64,
4964) -> SourceError {
4965 SourceError::LimitExceeded {
4966 kind,
4967 path,
4968 maximum,
4969 actual,
4970 }
4971}
4972
4973fn invalid_git_snapshot(reason: impl Into<String>) -> SourceError {
4974 SourceError::InvalidGitSnapshot {
4975 reason: reason.into(),
4976 }
4977}
4978
4979fn display_relative_path(path: &Utf8Path) -> String {
4980 if path.as_str().is_empty() || path.as_str() == "." {
4981 return ".".to_owned();
4982 }
4983 path.iter()
4984 .filter(|component| *component != ".")
4985 .collect::<Vec<_>>()
4986 .join("/")
4987}
4988
4989fn relative_ignore_path(base: &Utf8Path) -> String {
4990 let base = display_relative_path(base);
4991 if base == "." {
4992 ".ferryignore".to_owned()
4993 } else {
4994 format!("{base}/.ferryignore")
4995 }
4996}
4997
4998fn has_unexpected_directory(directories: &[String], expected: &SourceManifest) -> bool {
4999 directories.iter().any(|directory| {
5000 !expected.entries.iter().any(|entry| {
5001 entry
5002 .path
5003 .strip_prefix(directory)
5004 .is_some_and(|rest| rest.starts_with('/'))
5005 })
5006 })
5007}
5008
5009#[cfg(test)]
5010mod tests {
5011 use super::*;
5012
5013 #[cfg(unix)]
5014 #[test]
5015 fn archive_parent_swap_never_publishes_into_replacement_directory() {
5016 use std::{sync::mpsc, thread};
5017
5018 let temporary = tempfile::tempdir().unwrap();
5019 let workspace = Utf8PathBuf::from_path_buf(temporary.path().join("workspace")).unwrap();
5020 let project = workspace.join("app");
5021 fs::create_dir_all(project.join("src")).unwrap();
5022 fs::write(workspace.join("Cargo.toml"), b"[workspace]\n").unwrap();
5023 fs::write(workspace.join("Cargo.lock"), b"").unwrap();
5024 fs::write(project.join("Cargo.toml"), b"[package]\nname = \"app\"\n").unwrap();
5025 fs::write(project.join("src/lib.rs"), b"pub fn value() -> u8 { 1 }\n").unwrap();
5026 let request = SourceBundleRequest::new(workspace.clone(), project);
5027 let plan = plan_source_bundle(&request).unwrap();
5028 let root = workspace.parent().unwrap();
5029 let output_parent = root.join("publish-parent");
5030 let moved_parent = root.join("publish-parent-owned");
5031 fs::create_dir(&output_parent).unwrap();
5032 let output = output_parent.join("source.zip");
5033
5034 let output_for_creator = output.clone();
5035 let (created_sender, created_receiver) = mpsc::sync_channel(0);
5036 let (continue_sender, continue_receiver) = mpsc::sync_channel(0);
5037 let creator = thread::spawn(move || {
5038 create_source_bundle_archive_with_temporary_hook(
5039 &plan,
5040 &output_for_creator,
5041 SourceArchiveLimits::default(),
5042 || {
5043 created_sender.send(()).unwrap();
5044 continue_receiver.recv().unwrap();
5045 },
5046 )
5047 });
5048
5049 created_receiver.recv().unwrap();
5050 fs::rename(&output_parent, &moved_parent).unwrap();
5051 fs::create_dir(&output_parent).unwrap();
5052 fs::write(output_parent.join("sentinel"), b"unchanged").unwrap();
5053 continue_sender.send(()).unwrap();
5054 let result = creator.join().unwrap();
5055
5056 assert!(result.is_err());
5057 assert_eq!(
5058 fs::read(output_parent.join("sentinel")).unwrap(),
5059 b"unchanged"
5060 );
5061 assert!(!output.exists());
5062 assert!(fs::read_dir(&moved_parent).unwrap().all(|entry| {
5063 !entry
5064 .unwrap()
5065 .file_name()
5066 .to_string_lossy()
5067 .starts_with(".source.zip.rustferry-partial-")
5068 }));
5069 }
5070
5071 #[cfg(unix)]
5072 #[test]
5073 fn capability_root_binding_rejects_named_replacement() {
5074 let temporary = tempfile::tempdir().unwrap();
5075 let root = Utf8PathBuf::from_path_buf(temporary.path().join("bundle")).unwrap();
5076 let detached = Utf8PathBuf::from_path_buf(temporary.path().join("detached")).unwrap();
5077 fs::create_dir(&root).unwrap();
5078 let canonical_root = Utf8PathBuf::from_path_buf(fs::canonicalize(&root).unwrap()).unwrap();
5079 let directory =
5080 CapabilityDir::open_ambient_dir(canonical_root.as_std_path(), ambient_authority())
5081 .unwrap();
5082 let identity = capability_directory_identity(&directory).unwrap();
5083 ensure_capability_directory_path(&directory, &canonical_root, &identity).unwrap();
5084
5085 fs::rename(&root, &detached).unwrap();
5086 fs::create_dir(&root).unwrap();
5087
5088 assert!(ensure_capability_directory_path(&directory, &canonical_root, &identity).is_err());
5089 }
5090
5091 #[cfg(windows)]
5092 #[test]
5093 fn bounded_read_rejects_a_hard_link_outside_the_selected_tree() {
5094 let temporary = tempfile::tempdir().unwrap();
5095 let selected = temporary.path().join("selected");
5096 fs::create_dir(&selected).unwrap();
5097 let source = selected.join("source.rs");
5098 let outside_link = temporary.path().join("outside-link.rs");
5099 fs::write(&source, b"fn main() {}").unwrap();
5100 fs::hard_link(&source, &outside_link).unwrap();
5101
5102 let source = Utf8PathBuf::from_path_buf(source).unwrap();
5103 let metadata = fs::symlink_metadata(&source).unwrap();
5104 let error = read_bounded_file(
5105 &source,
5106 "selected/source.rs",
5107 1024,
5108 &metadata,
5109 SourceLimitKind::FileSize,
5110 )
5111 .unwrap_err();
5112
5113 assert!(matches!(error, SourceError::HardLink { links: 2, .. }));
5114 }
5115
5116 #[cfg(windows)]
5117 #[test]
5118 fn capability_read_rejects_a_hard_link_outside_the_bundle() {
5119 let temporary = tempfile::tempdir().unwrap();
5120 let bundle = temporary.path().join("bundle");
5121 fs::create_dir(&bundle).unwrap();
5122 let source = bundle.join("source.rs");
5123 let outside_link = temporary.path().join("outside-link.rs");
5124 fs::write(&source, b"fn main() {}").unwrap();
5125 fs::hard_link(&source, &outside_link).unwrap();
5126
5127 let directory = CapabilityDir::open_ambient_dir(&bundle, ambient_authority()).unwrap();
5128 let metadata = directory.symlink_metadata("source.rs").unwrap();
5129 let error =
5130 read_bounded_capability_file(&directory, "source.rs", "source.rs", 1024, &metadata)
5131 .unwrap_err();
5132
5133 assert!(matches!(error, SourceError::HardLink { links: 2, .. }));
5134 }
5135
5136 #[cfg(windows)]
5137 #[test]
5138 fn partial_destination_failure_cleans_exact_root_and_preserves_replacement() {
5139 let temporary = tempfile::tempdir().unwrap();
5140 let destination = Utf8PathBuf::from_path_buf(temporary.path().join("destination")).unwrap();
5141 let moved = temporary.path().join("moved");
5142 let replacement = temporary.path().join("replacement");
5143 let replacement_marker = replacement.join("preserve.txt");
5144 fs::create_dir(&replacement).unwrap();
5145 fs::write(&replacement_marker, b"preserve").unwrap();
5146
5147 let FreshDestination {
5148 path,
5149 parent,
5150 parent_path,
5151 parent_identity,
5152 name,
5153 directory,
5154 identity,
5155 } = create_fresh_destination(&destination, None).unwrap();
5156 directory.create_dir_all("app/src").unwrap();
5157 directory
5158 .write("app/src/partial.rs", b"partial extraction")
5159 .unwrap();
5160 let mut cleanup = PartialDirectoryCleanup::new(
5161 path,
5162 parent,
5163 parent_path,
5164 parent_identity,
5165 name,
5166 directory,
5167 identity,
5168 );
5169
5170 assert!(fs::rename(&destination, &moved).is_err());
5171 assert!(fs::rename(&replacement, &destination).is_err());
5172 let result: Result<(), SourceError> = cleanup.fail(SourceError::InvalidArchive {
5173 reason: "injected partial extraction failure".to_owned(),
5174 });
5175
5176 assert!(matches!(result, Err(SourceError::InvalidArchive { .. })));
5177 assert!(!destination.exists());
5178 assert!(!moved.exists());
5179 assert_eq!(fs::read(&replacement_marker).unwrap(), b"preserve");
5180 }
5181}