Skip to main content

rustferry_remote/
source.rs

1//! Deterministic source selection for remote builds.
2//!
3//! Snapshot inputs are limited to the selected project tree, a small set of
4//! workspace build files, and caller-selected workspace-relative paths. Source
5//! paths must use slash separators and cannot be absolute, drive-prefixed, UNC,
6//! traversing, Windows-reserved, or normalization/case-colliding. Original
7//! UTF-8 names are retained while NFC-plus-lowercase collision keys protect
8//! materialization on normalizing or case-insensitive filesystems.
9//!
10//! .ferryignore is read only at the workspace and project roots. Its safe
11//! subset accepts blank lines, hash-prefixed comments, and literal relative
12//! paths. A trailing slash is accepted; every rule excludes both the literal
13//! path and its descendants. Negation, glob syntax, escaping, absolute paths,
14//! whitespace at line edges, and non-portable names are rejected. Ignore rules
15//! can never re-include built-in sensitive paths.
16//! Caller exclusions use the same portable workspace-relative path model and
17//! take precedence over selected roots and ignore rules. The workspace root
18//! itself (`.`) cannot be excluded. Sensitive paths take precedence over all
19//! caller policy and are reported without traversing their contents.
20//!
21//! Source ZIPs contain manifest files only: no embedded manifest, directory
22//! entries, links, or platform metadata beyond one normalized executable bit.
23//! ZIP metadata is fixed and entries follow manifest order, producing identical
24//! bytes for identical inputs. The separately transported manifest remains the
25//! worker's source of truth.
26//! Hard-linked source files are refused on Unix and Windows. Windows link
27//! counts are queried from retained file handles so path replacement cannot
28//! substitute the object whose link state is inspected.
29//! Callers may supply portable executable-mode metadata for selected files;
30//! this carries executable intent from filesystems which do not expose Unix
31//! mode bits. A live Unix executable bit is never cleared by portable metadata.
32
33use 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";
68/// Current source-bundle descriptor schema.
69pub const SOURCE_BUNDLE_DESCRIPTOR_SCHEMA_VERSION: u32 = 1;
70/// Maximum canonical source-bundle descriptor size, including its final newline.
71pub const MAX_SOURCE_BUNDLE_DESCRIPTOR_BYTES: u64 = 128 * 1024 * 1024;
72/// Maximum complete payload of one GitHub dirty-source snapshot orphan commit.
73pub const MAX_GIT_SNAPSHOT_BYTES: u64 = 90 * 1024 * 1024;
74/// Maximum canonical Git snapshot descriptor size, including its final newline.
75pub const MAX_GIT_SNAPSHOT_DESCRIPTOR_BYTES: u64 = 8 * 1024 * 1024;
76/// Current Git snapshot descriptor schema.
77pub const GIT_SNAPSHOT_DESCRIPTOR_SCHEMA_VERSION: u32 = 1;
78/// Fixed source archive path in a Git snapshot orphan commit.
79pub const GIT_SNAPSHOT_ARCHIVE_PATH: &str = ".rustferry/goal3/source.zip";
80/// Fixed source descriptor path in a Git snapshot orphan commit.
81pub const GIT_SNAPSHOT_DESCRIPTOR_PATH: &str = ".rustferry/goal3/source.json";
82/// Exact sorted regular-file tree required in a Git snapshot orphan commit.
83pub const GIT_SNAPSHOT_TREE_PATHS: [&str; 2] =
84    [GIT_SNAPSHOT_DESCRIPTOR_PATH, GIT_SNAPSHOT_ARCHIVE_PATH];
85/// Private custom namespace for operation-scoped source snapshot commits.
86pub 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;
91/// Defensive ceiling for selected directory entries and manifest path nodes.
92pub 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/// How a remote provider obtains source input.
180#[derive(
181    Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
182)]
183#[serde(rename_all = "snake_case")]
184pub enum SourceMode {
185    /// The worker checks out a pinned Git revision.
186    Git,
187    /// The worker checks out an operation-scoped orphan commit containing a verified source ZIP.
188    GitSnapshot,
189    /// The client sends a verified content snapshot.
190    Snapshot,
191}
192
193/// Resource bounds applied while selecting or verifying snapshot source.
194#[derive(Clone, Copy, Debug, Eq, PartialEq)]
195pub struct SourceLimits {
196    /// Maximum number of regular files in one snapshot.
197    pub max_file_count: usize,
198    /// Maximum size of one regular file, in bytes.
199    pub max_file_size: u64,
200    /// Maximum sum of all regular-file sizes, in bytes.
201    pub max_total_size: u64,
202    /// Maximum number of portable path components below the bundle root.
203    pub max_depth: usize,
204    /// Maximum size of one root .ferryignore, in bytes.
205    pub max_ignore_file_size: u64,
206    /// Maximum total number of ignore rules.
207    pub max_ignore_rules: usize,
208}
209
210/// Bounds enforced for source ZIP transport and extraction.
211#[derive(Clone, Copy, Debug, Eq, PartialEq)]
212pub struct SourceArchiveLimits {
213    /// Limits for the uncompressed source represented by the manifest.
214    pub source: SourceLimits,
215    /// Maximum ZIP file size in bytes.
216    pub max_archive_size: u64,
217    /// Maximum allowed uncompressed-to-compressed ratio for one non-empty entry.
218    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/// Local request used to select source for a snapshot.
245///
246/// Roots are local paths. Additional inputs are portable paths relative to the
247/// workspace and cannot escape it.
248#[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    /// Create a request for one project contained by a workspace.
262    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    /// Add an allowlisted workspace-relative file or directory.
278    pub fn include_workspace_path(mut self, path: impl Into<Utf8PathBuf>) -> Self {
279        self.workspace_inputs.push(path.into());
280        self
281    }
282
283    /// Exclude one portable workspace-relative path and all descendants.
284    ///
285    /// Exclusions take precedence over the project root, built-in workspace
286    /// inputs, explicit includes, and `.ferryignore`. The workspace root (`.`)
287    /// is not a valid exclusion. Built-in sensitive exclusions remain
288    /// non-overridable and are audited separately.
289    pub fn exclude_workspace_path(mut self, path: impl Into<Utf8PathBuf>) -> Self {
290        self.workspace_exclusions.push(path.into());
291        self
292    }
293
294    /// Supply portable executable-mode metadata for one workspace-relative file.
295    ///
296    /// The path must be selected into the final manifest. On non-Unix hosts the
297    /// supplied value is authoritative because filesystem mode bits are not
298    /// available. On Unix, a true value adds executable intent while a live
299    /// filesystem executable bit remains authoritative and is never cleared.
300    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    /// Apply a stricter combined traversal-entry limit than the defensive default.
306    ///
307    /// Values above [`MAX_SOURCE_TRAVERSAL_ENTRIES`] are rejected during
308    /// planning rather than weakening the global defensive ceiling. The one
309    /// budget counts components in explicit request paths, filesystem directory
310    /// entries read during selection, and components in final manifest paths.
311    pub fn with_max_traversal_entries(mut self, maximum: usize) -> Self {
312        self.max_traversal_entries = maximum;
313        self
314    }
315
316    /// Replace the default resource limits.
317    pub fn with_limits(mut self, limits: SourceLimits) -> Self {
318        self.limits = limits;
319        self
320    }
321
322    /// Return the configured workspace root.
323    pub fn workspace_root(&self) -> &Utf8Path {
324        &self.workspace_root
325    }
326
327    /// Return the configured project root.
328    pub fn project_root(&self) -> &Utf8Path {
329        &self.project_root
330    }
331
332    /// Return caller-selected workspace-relative inputs.
333    pub fn workspace_inputs(&self) -> &[Utf8PathBuf] {
334        &self.workspace_inputs
335    }
336
337    /// Return caller-selected workspace-relative exclusions.
338    pub fn workspace_exclusions(&self) -> &[Utf8PathBuf] {
339        &self.workspace_exclusions
340    }
341
342    /// Return caller-supplied portable executable-mode metadata.
343    pub fn executable_modes(&self) -> &[(Utf8PathBuf, bool)] {
344        &self.executable_modes
345    }
346
347    /// Return the configured combined traversal-entry limit.
348    ///
349    /// The budget covers request path components, filesystem directory entries,
350    /// and every component of every final manifest path.
351    pub fn max_traversal_entries(&self) -> usize {
352        self.max_traversal_entries
353    }
354
355    /// Return the configured resource limits.
356    pub fn limits(&self) -> SourceLimits {
357        self.limits
358    }
359}
360
361/// One content-addressed regular file in a source manifest.
362#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
363#[serde(deny_unknown_fields)]
364pub struct SourceManifestEntry {
365    /// Portable workspace-relative bundle path using slash separators.
366    pub path: String,
367    /// Exact file length in bytes.
368    pub size: u64,
369    /// Lowercase hexadecimal SHA-256 digest of the file contents.
370    pub sha256: String,
371    /// Whether extraction must restore an executable bit, from Unix mode or portable metadata.
372    pub executable: bool,
373}
374
375/// Canonical description of a source snapshot.
376#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
377#[serde(deny_unknown_fields)]
378pub struct SourceManifest {
379    /// Manifest schema version.
380    pub schema_version: u32,
381    /// Portable workspace-relative project location, or dot for the workspace.
382    pub project_path: String,
383    /// Files sorted bytewise by portable path.
384    pub entries: Vec<SourceManifestEntry>,
385    /// Sum of all entry sizes.
386    pub total_size: u64,
387    /// Digest of the canonical manifest fields other than this value.
388    pub sha256: String,
389}
390
391/// Integrity descriptor for the deterministic source ZIP transport.
392#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
393#[serde(deny_unknown_fields)]
394pub struct SourceArchive {
395    /// Exact ZIP size in bytes.
396    pub size: u64,
397    /// Lowercase hexadecimal SHA-256 digest of the complete ZIP.
398    pub sha256: String,
399}
400
401/// Versioned binding between one deterministic source ZIP and its manifest.
402#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
403#[serde(deny_unknown_fields)]
404pub struct SourceBundleDescriptor {
405    /// Source-bundle descriptor schema version.
406    pub schema_version: u32,
407    /// Exact ZIP size and SHA-256 digest.
408    pub archive: SourceArchive,
409    /// Exact paths, sizes, modes, and digests represented by the ZIP.
410    pub manifest: SourceManifest,
411}
412
413/// Strict binding for one operation-scoped GitHub dirty-source snapshot.
414///
415/// The orphan commit containing this descriptor has exactly the two paths in
416/// [`GIT_SNAPSHOT_TREE_PATHS`]. The full request hash cannot be embedded because
417/// its `source_revision` is the SHA of that commit; `request_template_sha256`
418/// therefore covers the canonical request with only `source_revision` omitted.
419#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
420#[serde(deny_unknown_fields)]
421pub struct GitSnapshotDescriptor {
422    /// Git snapshot descriptor schema version.
423    pub schema_version: u32,
424    /// Exact operation identifier used to derive `snapshot_ref`.
425    pub operation_id: String,
426    /// Exact normalized credential-free HTTPS GitHub source repository.
427    pub source_repository: String,
428    /// Exact operation-derived heads ref carrying the orphan commit.
429    pub snapshot_ref: String,
430    /// Canonical request SHA-256 with only `source_revision` omitted.
431    pub request_template_sha256: String,
432    /// Deterministic ZIP transport and exact source manifest.
433    pub bundle: SourceBundleDescriptor,
434}
435
436impl GitSnapshotDescriptor {
437    /// Construct a descriptor from a pre-publication or final Git snapshot request.
438    ///
439    /// # Errors
440    ///
441    /// Returns an error when request fields, manifest binding, repository, ref, digest, or source
442    /// size violates the Git snapshot contract.
443    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    /// Validate a standalone descriptor against strict Git snapshot bounds.
478    ///
479    /// # Errors
480    ///
481    /// Returns an error for any unsupported, ambiguous, non-canonical, or oversized field.
482    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    /// Require exact binding to the final published request.
516    ///
517    /// # Errors
518    ///
519    /// Returns an error for request drift in operation, repository, template, mode, or manifest.
520    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
550/// Return strict source and ZIP limits for GitHub dirty-source snapshots.
551pub 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
558/// Derive the only allowed Git snapshot ref for an operation.
559///
560/// # Errors
561///
562/// Returns an error when the operation is not one safe Git-ref path segment.
563pub 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
583/// Encode a validated Git snapshot descriptor in its one canonical byte representation.
584///
585/// # Errors
586///
587/// Returns an error for invalid fields, serialization failure, descriptor overflow, or a combined
588/// two-file orphan payload above the fixed 90 MiB ceiling.
589pub 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    /// Bind an archive descriptor to its canonical manifest.
633    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    /// Validate the complete descriptor without reading archive bytes.
642    ///
643    /// Byte-level integrity and ZIP-to-manifest correspondence are enforced by
644    /// [`verify_and_extract_source_bundle`].
645    ///
646    /// # Errors
647    ///
648    /// Returns an error for an unsupported descriptor version, malformed or
649    /// oversized archive descriptor, invalid limits, or non-canonical manifest.
650    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/// One local file selected by a bundle plan.
678///
679/// Deliberately not serializable: local absolute paths must not cross the
680/// remote protocol boundary.
681#[derive(Clone, Debug, Eq, PartialEq)]
682pub struct PlannedSourceFile {
683    source_path: Utf8PathBuf,
684    bundle_path: Utf8PathBuf,
685}
686
687impl PlannedSourceFile {
688    /// Return the local canonical source path.
689    pub fn source_path(&self) -> &Utf8Path {
690        &self.source_path
691    }
692
693    /// Return the portable workspace-relative bundle path.
694    pub fn bundle_path(&self) -> &Utf8Path {
695        &self.bundle_path
696    }
697}
698
699/// Deterministic source selection and local inputs needed to materialize it.
700#[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    /// Return the serializable source manifest.
711    pub fn manifest(&self) -> &SourceManifest {
712        &self.manifest
713    }
714
715    /// Return local files in the same order as manifest entries.
716    pub fn files(&self) -> &[PlannedSourceFile] {
717        &self.files
718    }
719
720    /// Return sensitive roots and files skipped during source selection.
721    ///
722    /// Paths are portable, sorted, unique, and name-only. Contents below a
723    /// skipped sensitive directory are never traversed for this audit.
724    pub fn excluded_sensitive_paths(&self) -> &[String] {
725        &self.excluded_sensitive_paths
726    }
727}
728
729/// Portable-path validation failure.
730#[derive(Clone, Copy, Debug, Eq, PartialEq)]
731pub enum PortablePathReason {
732    /// The path was empty.
733    Empty,
734    /// The path was absolute or had a root/prefix.
735    Absolute,
736    /// A Windows drive prefix was present.
737    DrivePrefix,
738    /// A UNC prefix was present.
739    UncPrefix,
740    /// A backslash separator was present.
741    Backslash,
742    /// A dot or dot-dot component was present.
743    DotComponent,
744    /// A component was empty.
745    EmptyComponent,
746    /// A character forbidden by portable filesystems was present.
747    InvalidCharacter,
748    /// A component ended in a dot or space.
749    TrailingDotOrSpace,
750    /// A component was not in Unicode NFC form.
751    NonNfc,
752    /// A Windows-reserved device name was present.
753    ReservedName,
754    /// The complete path was too long.
755    PathTooLong,
756    /// One component was too long.
757    SegmentTooLong,
758}
759
760/// Resource limit that was exceeded.
761#[derive(Clone, Copy, Debug, Eq, PartialEq)]
762pub enum SourceLimitKind {
763    /// Number of files.
764    FileCount,
765    /// Size of one file.
766    FileSize,
767    /// Aggregate file size.
768    TotalSize,
769    /// Directory nesting depth.
770    Depth,
771    /// Size of one ignore file.
772    IgnoreFileSize,
773    /// Total ignore rules.
774    IgnoreRuleCount,
775    /// Directory entries and portable manifest path nodes traversed.
776    TraversalEntryCount,
777    /// ZIP transport size.
778    ArchiveSize,
779    /// Serialized source-bundle descriptor size.
780    DescriptorSize,
781    /// Complete Git snapshot orphan payload size.
782    GitSnapshotSize,
783    /// ZIP entry compression ratio.
784    CompressionRatio,
785}
786
787/// Reason a .ferryignore line was rejected.
788#[derive(Clone, Copy, Debug, Eq, PartialEq)]
789pub enum IgnoreRuleReason {
790    /// Negation is outside the supported subset.
791    Negation,
792    /// Glob syntax is outside the supported subset.
793    Glob,
794    /// Leading or trailing whitespace is ambiguous.
795    EdgeWhitespace,
796    /// The pattern exceeded its byte limit.
797    PatternTooLong,
798    /// A directory marker had no preceding path.
799    EmptyDirectoryPattern,
800    /// The path was not portable.
801    NonPortable(PortablePathReason),
802}
803
804/// Source planning or verification failure.
805#[derive(Debug)]
806pub enum SourceError {
807    /// A configured root did not resolve to a real directory.
808    InvalidRoot {
809        /// Which root was invalid.
810        root: &'static str,
811        /// Stable reason without file contents.
812        reason: &'static str,
813    },
814    /// The project resolved outside the configured workspace.
815    ProjectOutsideWorkspace,
816    /// A filesystem path could not be represented as UTF-8.
817    NonUtf8Path,
818    /// A relative bundle path was not cross-platform portable.
819    NonPortablePath {
820        /// Rejected path.
821        path: String,
822        /// Validation reason.
823        reason: PortablePathReason,
824    },
825    /// A built-in sensitive path was explicitly selected or found in a bundle.
826    SensitivePath {
827        /// Portable path; secret contents are never included.
828        path: String,
829    },
830    /// A selected regular file contained a conservative credential signature.
831    SensitiveContent {
832        /// Portable source path; credential contents are never included.
833        path: String,
834    },
835    /// A Windows reparse point or junction was found in selected source.
836    ReparsePoint {
837        /// Portable workspace-relative path or root role.
838        path: String,
839    },
840    /// A selected Windows regular file has a named alternate data stream.
841    AlternateDataStream {
842        /// Portable workspace-relative path; stream names are never retained.
843        path: String,
844    },
845    /// A selected Windows regular file's streams could not be inspected safely.
846    StreamInspectionUnavailable {
847        /// Portable workspace-relative path.
848        path: String,
849    },
850    /// A selected path did not exist.
851    MissingInput {
852        /// Portable workspace-relative path.
853        path: String,
854    },
855    /// A symbolic link was found in selected source.
856    Symlink {
857        /// Portable workspace-relative path.
858        path: String,
859    },
860    /// A regular file had more than one filesystem link.
861    HardLink {
862        /// Portable workspace-relative path.
863        path: String,
864        /// Observed link count.
865        links: u64,
866    },
867    /// Link-count inspection is unavailable on this platform or filesystem.
868    HardLinkInspectionUnavailable {
869        /// Portable workspace-relative path.
870        path: String,
871    },
872    /// A socket, device, FIFO, or other unsupported file type was selected.
873    UnsupportedFileType {
874        /// Portable workspace-relative path.
875        path: String,
876    },
877    /// Two paths would alias on a case-insensitive filesystem.
878    CaseCollision {
879        /// First portable path.
880        first: String,
881        /// Conflicting portable path.
882        second: String,
883    },
884    /// A resource bound was exceeded.
885    LimitExceeded {
886        /// Bound that was exceeded.
887        kind: SourceLimitKind,
888        /// Related portable path, when applicable.
889        path: Option<String>,
890        /// Configured maximum.
891        maximum: u64,
892        /// Observed value.
893        actual: u64,
894    },
895    /// The configured depth exceeds the defensive ceiling.
896    UnsupportedDepthLimit {
897        /// Requested depth.
898        requested: usize,
899        /// Defensive maximum.
900        maximum: usize,
901    },
902    /// The requested traversal-entry limit exceeds the defensive ceiling.
903    UnsupportedTraversalLimit {
904        /// Requested traversal entries.
905        requested: usize,
906        /// Defensive maximum.
907        maximum: usize,
908    },
909    /// Portable executable metadata repeated the same path.
910    DuplicateExecutableMode {
911        /// Duplicated workspace-relative path.
912        path: String,
913    },
914    /// Portable executable metadata named a file outside the final selection.
915    ExecutableModePathNotSelected {
916        /// Workspace-relative path absent from the manifest.
917        path: String,
918    },
919    /// A .ferryignore line was outside the documented safe subset.
920    InvalidIgnoreRule {
921        /// Portable ignore-file path.
922        file: String,
923        /// One-based line number.
924        line: usize,
925        /// Rejection reason.
926        reason: IgnoreRuleReason,
927    },
928    /// A source file changed while it was being read.
929    ChangedDuringRead {
930        /// Portable workspace-relative path.
931        path: String,
932    },
933    /// A manifest was structurally invalid or non-canonical.
934    InvalidManifest {
935        /// Stable validation reason.
936        reason: &'static str,
937    },
938    /// Current source or materialized contents did not exactly match a manifest.
939    ManifestMismatch,
940    /// An output path already exists and was not modified.
941    OutputExists {
942        /// Existing output path.
943        path: String,
944    },
945    /// An extraction destination already exists and was not modified.
946    DestinationExists {
947        /// Existing destination path.
948        path: String,
949    },
950    /// An archive or extraction destination path was unusable.
951    InvalidDestination {
952        /// Stable validation reason.
953        reason: &'static str,
954    },
955    /// ZIP structure or metadata violated the source transport contract.
956    InvalidArchive {
957        /// Stable or safely quoted rejection reason.
958        reason: String,
959    },
960    /// Git snapshot fields or cross-document bindings were invalid.
961    InvalidGitSnapshot {
962        /// Stable rejection reason without request or source contents.
963        reason: String,
964    },
965    /// ZIP bytes did not match their transport descriptor.
966    ArchiveIntegrityMismatch {
967        /// Stable validation reason.
968        reason: &'static str,
969    },
970    /// Cleanup of an operation-created partial path failed.
971    CleanupFailed {
972        /// Exact operation-created path.
973        path: String,
974        /// Original failure that triggered cleanup.
975        original: String,
976        /// Cleanup failure.
977        source: io::Error,
978    },
979    /// ZIP processing failed.
980    Zip {
981        /// ZIP operation being performed.
982        operation: &'static str,
983        /// Library error without source file contents.
984        message: String,
985    },
986    /// A filesystem operation failed.
987    Io {
988        /// Operation being performed.
989        operation: &'static str,
990        /// Portable path or root role; never file contents.
991        path: String,
992        /// Underlying operating-system error.
993        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
1141/// Select allowed source files and compute a deterministic manifest.
1142///
1143/// # Errors
1144///
1145/// Returns an error for invalid roots, unsafe paths or file types, malformed
1146/// ignore rules, filesystem races, or exceeded resource limits.
1147pub 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
1187/// Re-scan a plan's original inputs and require an exact manifest match.
1188///
1189/// # Errors
1190///
1191/// Returns [`SourceError::ManifestMismatch`] for additions, removals, metadata
1192/// changes, or
1193/// content changes, and propagates source validation errors.
1194pub fn verify_source_bundle_plan(plan: &SourceBundlePlan) -> Result<(), SourceError> {
1195    verify_source_manifest(&plan.request, &plan.manifest)
1196}
1197
1198/// Re-scan a request and require an exact match with an expected manifest.
1199///
1200/// # Errors
1201///
1202/// Returns an error when the expected manifest is invalid, source is unsafe,
1203/// or any selected path, size, executable bit, or digest differs.
1204pub 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
1217/// Verify an already materialized bundle directory against a manifest.
1218///
1219/// Every regular file must be listed and every directory must lead to a listed
1220/// file. Symlinks, hard links, sensitive paths, special files, extra empty
1221/// directories, and normalization/case aliases are rejected.
1222///
1223/// # Errors
1224///
1225/// Returns an error when the manifest is invalid or the directory does not
1226/// match it exactly.
1227pub 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
1280/// Validate canonical ordering, bounds, paths, and digests in a manifest.
1281///
1282/// # Errors
1283///
1284/// Returns an error for malformed or non-canonical input.
1285pub 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
1388/// Validate and atomically publish a source-bundle descriptor as canonical JSON.
1389///
1390/// The output is pretty JSON followed by one newline. Publication is create-only:
1391/// an existing output is never replaced. The temporary file, destination parent,
1392/// and published link are bound to captured filesystem identities so cleanup
1393/// cannot remove an attacker-substituted path.
1394///
1395/// # Errors
1396///
1397/// Returns an error for an invalid descriptor, an existing output, descriptor
1398/// size overflow, unsafe destination changes, or filesystem failures. Any file
1399/// created by this operation is removed on failure when it still has the
1400/// operation-owned identity.
1401pub 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
1504/// Create and atomically publish a deterministic source ZIP.
1505///
1506/// Existing output is never replaced. Planned files are reopened and streamed
1507/// through SHA-256 while ZIP bytes are written.
1508///
1509/// # Errors
1510///
1511/// Returns an error for changed source, unsafe output, exceeded bounds, ZIP
1512/// failure, or inability to publish without clobbering.
1513pub 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
1639/// Verify and extract an untrusted source ZIP below a fresh destination.
1640///
1641/// ZIP structure and metadata are fully preflighted before destination
1642/// creation. Extracted bytes, hashes, sizes, paths, order, and executable bits
1643/// must exactly match the separately transported manifest.
1644///
1645/// # Errors
1646///
1647/// Returns an error for transport-integrity mismatch, unsafe ZIP metadata,
1648/// exceeded bounds, extraction races, or any manifest mismatch. Only a
1649/// destination created by this call is removed on failure.
1650pub 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
1667/// Verify and extract an untrusted source ZIP using an already retained exact destination-parent
1668/// handle instead of reopening that parent by pathname.
1669///
1670/// Callers should pass a cloned handle and retain their original capability for the complete
1671/// operation. The supplied handle is rebound to `destination.parent()` before any destination is
1672/// created and is consumed by this call.
1673///
1674/// # Errors
1675///
1676/// Returns the same strict extraction failures as [`verify_and_extract_source_bundle`], plus an
1677/// error when the supplied parent handle is not bound to the destination parent or, on Windows,
1678/// does not satisfy the strict private-directory policy.
1679pub 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 &current != 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(&current, 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}