use std::{
collections::{BTreeMap, BTreeSet},
error::Error,
fmt,
fs::{self, File, Metadata},
io::{self, Read, Seek, SeekFrom, Write},
path::Path,
};
#[cfg(windows)]
use std::{fs::OpenOptions, os::windows::io::AsHandle as _};
use camino::{Utf8Path, Utf8PathBuf};
use cap_std::{
ambient_authority,
fs::{Dir as CapabilityDir, OpenOptions as CapabilityOpenOptions},
};
use same_file::Handle as FileIdentityHandle;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use unicode_normalization::UnicodeNormalization;
use uuid::Uuid;
use zip::{CompressionMethod, DateTime, ZipArchive, ZipWriter, write::SimpleFileOptions};
#[cfg(windows)]
use rustferry_core::{RegularFileStreamErrorKind, verify_regular_file_has_no_named_streams};
#[cfg(windows)]
use rustferry_core::windows_private_directory::{
regular_file_link_count, remove_private_directory_tree_handle, verify_private_directory_handle,
};
const MANIFEST_SCHEMA_VERSION: u32 = 1;
const MANIFEST_DOMAIN: &[u8] = b"rustferry-source-manifest-v1\0";
pub const SOURCE_BUNDLE_DESCRIPTOR_SCHEMA_VERSION: u32 = 1;
pub const MAX_SOURCE_BUNDLE_DESCRIPTOR_BYTES: u64 = 128 * 1024 * 1024;
pub const MAX_GIT_SNAPSHOT_BYTES: u64 = 90 * 1024 * 1024;
pub const MAX_GIT_SNAPSHOT_DESCRIPTOR_BYTES: u64 = 8 * 1024 * 1024;
pub const GIT_SNAPSHOT_DESCRIPTOR_SCHEMA_VERSION: u32 = 1;
pub const GIT_SNAPSHOT_ARCHIVE_PATH: &str = ".rustferry/goal3/source.zip";
pub const GIT_SNAPSHOT_DESCRIPTOR_PATH: &str = ".rustferry/goal3/source.json";
pub const GIT_SNAPSHOT_TREE_PATHS: [&str; 2] =
[GIT_SNAPSHOT_DESCRIPTOR_PATH, GIT_SNAPSHOT_ARCHIVE_PATH];
pub const GIT_SNAPSHOT_REF_PREFIX: &str = "refs/rustferry/goal3/snapshots";
const MAX_PORTABLE_PATH_BYTES: usize = 4_096;
const MAX_PORTABLE_SEGMENT_BYTES: usize = 255;
const MAX_IGNORE_PATTERN_BYTES: usize = 1_024;
const MAX_SUPPORTED_DEPTH: usize = 256;
pub const MAX_SOURCE_TRAVERSAL_ENTRIES: usize = 100_000;
const SOURCE_ARCHIVE_TEMP_ATTEMPTS: u64 = 128;
const SOURCE_ARCHIVE_BUFFER_SIZE: usize = 64 * 1024;
#[cfg(windows)]
const WINDOWS_FILE_SHARE_READ: u32 = 0x0000_0001;
#[cfg(windows)]
const WINDOWS_FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
#[cfg(windows)]
const WINDOWS_FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
const WORKSPACE_BUILD_INPUTS: &[&str] = &[
"Cargo.toml",
"Cargo.lock",
"rust-toolchain",
"rust-toolchain.toml",
".cargo/config",
".cargo/config.toml",
];
const SENSITIVE_DIRECTORIES: &[&str] = &[
".aws",
".azure",
".git",
".gnupg",
".hg",
".kube",
".svn",
".goal2",
".goal3",
".cache",
".ssh",
".tmp",
".venv",
"__pycache__",
"build",
"credentials",
"deriveddata",
"device-pairing",
"dist",
"keychains",
"node_modules",
"out",
"provisioning",
"secrets",
"signing",
"target",
];
const SENSITIVE_FILE_NAMES: &[&str] = &[
".envrc",
".git-credentials",
".netrc",
".npmrc",
".pypirc",
"application_default_credentials.json",
"credentials",
"credentials.json",
"credentials.toml",
"credentials.yaml",
"credentials.yml",
"id_dsa",
"id_ecdsa",
"id_ed25519",
"id_rsa",
"keystore.properties",
"service-account.json",
"service_account.json",
];
const SENSITIVE_EXTENSIONS: &[&str] = &[
"cer",
"crd",
"crt",
"der",
"ipa",
"jks",
"key",
"keystore",
"mobileprovision",
"p12",
"p8",
"pem",
"ppk",
"provisionprofile",
"xcarchive",
];
#[derive(
Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
)]
#[serde(rename_all = "snake_case")]
pub enum SourceMode {
Git,
GitSnapshot,
Snapshot,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SourceLimits {
pub max_file_count: usize,
pub max_file_size: u64,
pub max_total_size: u64,
pub max_depth: usize,
pub max_ignore_file_size: u64,
pub max_ignore_rules: usize,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SourceArchiveLimits {
pub source: SourceLimits,
pub max_archive_size: u64,
pub max_compression_ratio: u64,
}
impl Default for SourceArchiveLimits {
fn default() -> Self {
Self {
source: SourceLimits::default(),
max_archive_size: 640 * 1024 * 1024,
max_compression_ratio: 100,
}
}
}
impl Default for SourceLimits {
fn default() -> Self {
Self {
max_file_count: 20_000,
max_file_size: 64 * 1024 * 1024,
max_total_size: 512 * 1024 * 1024,
max_depth: 64,
max_ignore_file_size: 64 * 1024,
max_ignore_rules: 1_024,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[must_use]
pub struct SourceBundleRequest {
workspace_root: Utf8PathBuf,
project_root: Utf8PathBuf,
workspace_inputs: Vec<Utf8PathBuf>,
workspace_exclusions: Vec<Utf8PathBuf>,
executable_modes: Vec<(Utf8PathBuf, bool)>,
max_traversal_entries: usize,
limits: SourceLimits,
}
impl SourceBundleRequest {
pub fn new(
workspace_root: impl Into<Utf8PathBuf>,
project_root: impl Into<Utf8PathBuf>,
) -> Self {
Self {
workspace_root: workspace_root.into(),
project_root: project_root.into(),
workspace_inputs: Vec::new(),
workspace_exclusions: Vec::new(),
executable_modes: Vec::new(),
max_traversal_entries: MAX_SOURCE_TRAVERSAL_ENTRIES,
limits: SourceLimits::default(),
}
}
pub fn include_workspace_path(mut self, path: impl Into<Utf8PathBuf>) -> Self {
self.workspace_inputs.push(path.into());
self
}
pub fn exclude_workspace_path(mut self, path: impl Into<Utf8PathBuf>) -> Self {
self.workspace_exclusions.push(path.into());
self
}
pub fn with_executable_mode(mut self, path: impl Into<Utf8PathBuf>, executable: bool) -> Self {
self.executable_modes.push((path.into(), executable));
self
}
pub fn with_max_traversal_entries(mut self, maximum: usize) -> Self {
self.max_traversal_entries = maximum;
self
}
pub fn with_limits(mut self, limits: SourceLimits) -> Self {
self.limits = limits;
self
}
pub fn workspace_root(&self) -> &Utf8Path {
&self.workspace_root
}
pub fn project_root(&self) -> &Utf8Path {
&self.project_root
}
pub fn workspace_inputs(&self) -> &[Utf8PathBuf] {
&self.workspace_inputs
}
pub fn workspace_exclusions(&self) -> &[Utf8PathBuf] {
&self.workspace_exclusions
}
pub fn executable_modes(&self) -> &[(Utf8PathBuf, bool)] {
&self.executable_modes
}
pub fn max_traversal_entries(&self) -> usize {
self.max_traversal_entries
}
pub fn limits(&self) -> SourceLimits {
self.limits
}
}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct SourceManifestEntry {
pub path: String,
pub size: u64,
pub sha256: String,
pub executable: bool,
}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct SourceManifest {
pub schema_version: u32,
pub project_path: String,
pub entries: Vec<SourceManifestEntry>,
pub total_size: u64,
pub sha256: String,
}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct SourceArchive {
pub size: u64,
pub sha256: String,
}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct SourceBundleDescriptor {
pub schema_version: u32,
pub archive: SourceArchive,
pub manifest: SourceManifest,
}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct GitSnapshotDescriptor {
pub schema_version: u32,
pub operation_id: String,
pub source_repository: String,
pub snapshot_ref: String,
pub request_template_sha256: String,
pub bundle: SourceBundleDescriptor,
}
impl GitSnapshotDescriptor {
pub fn from_request(
request: &crate::protocol::IosDeviceBuildRequest,
bundle: SourceBundleDescriptor,
) -> Result<Self, SourceError> {
if request.source_mode != SourceMode::GitSnapshot {
return Err(invalid_git_snapshot(
"descriptor requires Git snapshot source mode",
));
}
if bundle.manifest != request.source {
return Err(invalid_git_snapshot(
"descriptor manifest does not match request source",
));
}
let source_repository = request
.source_repository
.clone()
.ok_or_else(|| invalid_git_snapshot("request has no source repository"))?;
let snapshot_ref = git_snapshot_ref(&request.operation_id)?;
let request_template_sha256 =
crate::protocol::canonical_git_snapshot_request_template_sha256(request)
.map_err(|_| invalid_git_snapshot("request template is invalid"))?;
let descriptor = Self {
schema_version: GIT_SNAPSHOT_DESCRIPTOR_SCHEMA_VERSION,
operation_id: request.operation_id.clone(),
source_repository,
snapshot_ref,
request_template_sha256,
bundle,
};
descriptor.validate(git_snapshot_archive_limits())?;
Ok(descriptor)
}
pub fn validate(&self, limits: SourceArchiveLimits) -> Result<(), SourceError> {
if self.schema_version != GIT_SNAPSHOT_DESCRIPTOR_SCHEMA_VERSION {
return Err(invalid_git_snapshot(
"unsupported Git snapshot descriptor schema version",
));
}
let expected_ref = git_snapshot_ref(&self.operation_id)?;
if self.snapshot_ref != expected_ref {
return Err(invalid_git_snapshot(
"snapshot ref is not derived from the operation identifier",
));
}
crate::protocol::validate_github_repository(&self.source_repository)
.map_err(|_| invalid_git_snapshot("source repository is not canonical"))?;
validate_sha256(&self.request_template_sha256)
.map_err(|_| invalid_git_snapshot("request template SHA-256 is invalid"))?;
self.bundle.validate(limits)?;
if self.bundle.archive.size > MAX_GIT_SNAPSHOT_BYTES
|| self.bundle.manifest.total_size > MAX_GIT_SNAPSHOT_BYTES
{
return Err(limit_error(
SourceLimitKind::GitSnapshotSize,
None,
MAX_GIT_SNAPSHOT_BYTES,
self.bundle
.archive
.size
.max(self.bundle.manifest.total_size),
));
}
Ok(())
}
pub fn validate_for_request(
&self,
request: &crate::protocol::IosDeviceBuildRequest,
limits: SourceArchiveLimits,
) -> Result<(), SourceError> {
request
.validate()
.map_err(|_| invalid_git_snapshot("final request is invalid"))?;
self.validate(limits)?;
if request.source_mode != SourceMode::GitSnapshot
|| request.operation_id != self.operation_id
|| request.source_repository.as_deref() != Some(self.source_repository.as_str())
|| request.source != self.bundle.manifest
{
return Err(invalid_git_snapshot(
"descriptor does not match the final request",
));
}
let template_sha256 =
crate::protocol::canonical_git_snapshot_request_template_sha256(request)
.map_err(|_| invalid_git_snapshot("final request template is invalid"))?;
if template_sha256 != self.request_template_sha256 {
return Err(invalid_git_snapshot(
"request template SHA-256 does not match the descriptor",
));
}
Ok(())
}
}
pub fn git_snapshot_archive_limits() -> SourceArchiveLimits {
let mut limits = SourceArchiveLimits::default();
limits.source.max_total_size = MAX_GIT_SNAPSHOT_BYTES;
limits.max_archive_size = MAX_GIT_SNAPSHOT_BYTES;
limits
}
pub fn git_snapshot_ref(operation_id: &str) -> Result<String, SourceError> {
if operation_id.is_empty()
|| operation_id.len() > 128
|| operation_id.starts_with('.')
|| operation_id.ends_with('.')
|| operation_id
.get(operation_id.len().saturating_sub(5)..)
.is_some_and(|suffix| suffix.eq_ignore_ascii_case(".lock"))
|| operation_id.contains("..")
|| !operation_id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
{
return Err(invalid_git_snapshot(
"operation identifier is not one safe Git-ref segment",
));
}
Ok(format!("{GIT_SNAPSHOT_REF_PREFIX}/{operation_id}"))
}
pub fn canonical_git_snapshot_descriptor_bytes(
descriptor: &GitSnapshotDescriptor,
) -> Result<Vec<u8>, SourceError> {
descriptor.validate(git_snapshot_archive_limits())?;
let mut bytes =
serde_json::to_vec_pretty(descriptor).map_err(|error| SourceError::InvalidGitSnapshot {
reason: format!("descriptor JSON serialization failed: {error}"),
})?;
bytes.push(b'\n');
let size = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
if size > MAX_GIT_SNAPSHOT_DESCRIPTOR_BYTES {
return Err(limit_error(
SourceLimitKind::DescriptorSize,
Some(GIT_SNAPSHOT_DESCRIPTOR_PATH.to_owned()),
MAX_GIT_SNAPSHOT_DESCRIPTOR_BYTES,
size,
));
}
let combined = descriptor
.bundle
.archive
.size
.checked_add(size)
.ok_or_else(|| {
limit_error(
SourceLimitKind::GitSnapshotSize,
None,
MAX_GIT_SNAPSHOT_BYTES,
u64::MAX,
)
})?;
if combined > MAX_GIT_SNAPSHOT_BYTES {
return Err(limit_error(
SourceLimitKind::GitSnapshotSize,
None,
MAX_GIT_SNAPSHOT_BYTES,
combined,
));
}
Ok(bytes)
}
impl SourceBundleDescriptor {
pub fn new(archive: SourceArchive, manifest: SourceManifest) -> Self {
Self {
schema_version: SOURCE_BUNDLE_DESCRIPTOR_SCHEMA_VERSION,
archive,
manifest,
}
}
pub fn validate(&self, limits: SourceArchiveLimits) -> Result<(), SourceError> {
if self.schema_version != SOURCE_BUNDLE_DESCRIPTOR_SCHEMA_VERSION {
return Err(SourceError::InvalidArchive {
reason: "unsupported source-bundle descriptor schema version".to_owned(),
});
}
validate_archive_limits(limits)?;
if self.archive.size == 0 {
return Err(SourceError::InvalidArchive {
reason: "source archive size must be positive".to_owned(),
});
}
validate_sha256(&self.archive.sha256).map_err(|_| SourceError::InvalidArchive {
reason: "source archive SHA-256 is not 64 lowercase hexadecimal characters".to_owned(),
})?;
if self.archive.size > limits.max_archive_size {
return Err(limit_error(
SourceLimitKind::ArchiveSize,
None,
limits.max_archive_size,
self.archive.size,
));
}
validate_source_manifest(&self.manifest, limits.source)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PlannedSourceFile {
source_path: Utf8PathBuf,
bundle_path: Utf8PathBuf,
}
impl PlannedSourceFile {
pub fn source_path(&self) -> &Utf8Path {
&self.source_path
}
pub fn bundle_path(&self) -> &Utf8Path {
&self.bundle_path
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SourceBundlePlan {
request: SourceBundleRequest,
manifest: SourceManifest,
files: Vec<PlannedSourceFile>,
executable_modes: BTreeMap<String, bool>,
excluded_sensitive_paths: Vec<String>,
}
impl SourceBundlePlan {
pub fn manifest(&self) -> &SourceManifest {
&self.manifest
}
pub fn files(&self) -> &[PlannedSourceFile] {
&self.files
}
pub fn excluded_sensitive_paths(&self) -> &[String] {
&self.excluded_sensitive_paths
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PortablePathReason {
Empty,
Absolute,
DrivePrefix,
UncPrefix,
Backslash,
DotComponent,
EmptyComponent,
InvalidCharacter,
TrailingDotOrSpace,
NonNfc,
ReservedName,
PathTooLong,
SegmentTooLong,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SourceLimitKind {
FileCount,
FileSize,
TotalSize,
Depth,
IgnoreFileSize,
IgnoreRuleCount,
TraversalEntryCount,
ArchiveSize,
DescriptorSize,
GitSnapshotSize,
CompressionRatio,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum IgnoreRuleReason {
Negation,
Glob,
EdgeWhitespace,
PatternTooLong,
EmptyDirectoryPattern,
NonPortable(PortablePathReason),
}
#[derive(Debug)]
pub enum SourceError {
InvalidRoot {
root: &'static str,
reason: &'static str,
},
ProjectOutsideWorkspace,
NonUtf8Path,
NonPortablePath {
path: String,
reason: PortablePathReason,
},
SensitivePath {
path: String,
},
SensitiveContent {
path: String,
},
ReparsePoint {
path: String,
},
AlternateDataStream {
path: String,
},
StreamInspectionUnavailable {
path: String,
},
MissingInput {
path: String,
},
Symlink {
path: String,
},
HardLink {
path: String,
links: u64,
},
HardLinkInspectionUnavailable {
path: String,
},
UnsupportedFileType {
path: String,
},
CaseCollision {
first: String,
second: String,
},
LimitExceeded {
kind: SourceLimitKind,
path: Option<String>,
maximum: u64,
actual: u64,
},
UnsupportedDepthLimit {
requested: usize,
maximum: usize,
},
UnsupportedTraversalLimit {
requested: usize,
maximum: usize,
},
DuplicateExecutableMode {
path: String,
},
ExecutableModePathNotSelected {
path: String,
},
InvalidIgnoreRule {
file: String,
line: usize,
reason: IgnoreRuleReason,
},
ChangedDuringRead {
path: String,
},
InvalidManifest {
reason: &'static str,
},
ManifestMismatch,
OutputExists {
path: String,
},
DestinationExists {
path: String,
},
InvalidDestination {
reason: &'static str,
},
InvalidArchive {
reason: String,
},
InvalidGitSnapshot {
reason: String,
},
ArchiveIntegrityMismatch {
reason: &'static str,
},
CleanupFailed {
path: String,
original: String,
source: io::Error,
},
Zip {
operation: &'static str,
message: String,
},
Io {
operation: &'static str,
path: String,
source: io::Error,
},
}
impl fmt::Display for SourceError {
#[allow(clippy::too_many_lines)]
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidRoot { root, reason } => write!(formatter, "invalid {root}: {reason}"),
Self::ProjectOutsideWorkspace => {
formatter.write_str("project root resolves outside workspace root")
}
Self::NonUtf8Path => formatter.write_str("source path is not valid UTF-8"),
Self::NonPortablePath { path, reason } => {
write!(formatter, "non-portable source path {path:?}: {reason:?}")
}
Self::SensitivePath { path } => {
write!(formatter, "sensitive source path is excluded: {path}")
}
Self::SensitiveContent { path } => {
write!(
formatter,
"credential-like source contents are excluded: {path}"
)
}
Self::ReparsePoint { path } => {
write!(formatter, "Windows reparse points are not allowed: {path}")
}
Self::AlternateDataStream { path } => {
write!(formatter, "alternate data streams are not allowed: {path}")
}
Self::StreamInspectionUnavailable { path } => {
write!(
formatter,
"cannot inspect data streams for source file: {path}"
)
}
Self::MissingInput { path } => write!(formatter, "source input does not exist: {path}"),
Self::Symlink { path } => write!(formatter, "symbolic links are not allowed: {path}"),
Self::HardLink { path, links } => {
write!(
formatter,
"hard-linked source file is not allowed: {path} ({links} links)"
)
}
Self::HardLinkInspectionUnavailable { path } => {
write!(
formatter,
"cannot inspect hard-link count for source file: {path}"
)
}
Self::UnsupportedFileType { path } => {
write!(formatter, "unsupported source file type: {path}")
}
Self::CaseCollision { first, second } => {
write!(
formatter,
"case-insensitive path collision: {first} and {second}"
)
}
Self::LimitExceeded {
kind,
path,
maximum,
actual,
} => write!(
formatter,
"source {kind:?} limit exceeded{}: {actual} > {maximum}",
path.as_deref()
.map_or_else(String::new, |path| format!(" at {path}"))
),
Self::UnsupportedDepthLimit { requested, maximum } => write!(
formatter,
"source depth limit {requested} exceeds supported maximum {maximum}"
),
Self::UnsupportedTraversalLimit { requested, maximum } => write!(
formatter,
"source traversal-entry limit {requested} exceeds supported maximum {maximum}"
),
Self::DuplicateExecutableMode { path } => {
write!(formatter, "duplicate executable-mode metadata for {path}")
}
Self::ExecutableModePathNotSelected { path } => {
write!(
formatter,
"executable-mode metadata path is not selected: {path}"
)
}
Self::InvalidIgnoreRule { file, line, reason } => {
write!(formatter, "invalid {file} rule on line {line}: {reason:?}")
}
Self::ChangedDuringRead { path } => {
write!(formatter, "source file changed while being read: {path}")
}
Self::InvalidManifest { reason } => {
write!(formatter, "invalid source manifest: {reason}")
}
Self::ManifestMismatch => formatter.write_str("source manifest does not match exactly"),
Self::OutputExists { path } => {
write!(formatter, "source archive output already exists: {path}")
}
Self::DestinationExists { path } => {
write!(
formatter,
"source extraction destination already exists: {path}"
)
}
Self::InvalidDestination { reason } => {
write!(formatter, "invalid source archive destination: {reason}")
}
Self::InvalidArchive { reason } => {
write!(formatter, "invalid source archive: {reason}")
}
Self::InvalidGitSnapshot { reason } => {
write!(formatter, "invalid Git source snapshot: {reason}")
}
Self::ArchiveIntegrityMismatch { reason } => {
write!(formatter, "source archive integrity mismatch: {reason}")
}
Self::CleanupFailed {
path,
original,
source,
} => write!(
formatter,
"failed to clean operation-created path {path} after {original}: {source}"
),
Self::Zip { operation, message } => {
write!(formatter, "failed to {operation} source ZIP: {message}")
}
Self::Io {
operation,
path,
source,
} => write!(formatter, "failed to {operation} {path}: {source}"),
}
}
}
impl Error for SourceError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Io { source, .. } | Self::CleanupFailed { source, .. } => Some(source),
_ => None,
}
}
}
pub fn plan_source_bundle(request: &SourceBundleRequest) -> Result<SourceBundlePlan, SourceError> {
check_supported_depth(request.limits)?;
let mut traversal = TraversalBudget::new(request.max_traversal_entries)?;
let roots = ResolvedRoots::resolve(request)?;
let project_path = display_relative_path(&roots.project_relative);
let executable_modes = validate_executable_modes(request, &mut traversal)?;
let workspace_exclusions = validate_workspace_exclusions(request, &mut traversal)?;
let ignore_rules = load_ignore_rules(&roots, &workspace_exclusions, request.limits)?;
let include_roots =
collect_include_roots(request, &roots, &workspace_exclusions, &mut traversal)?;
let mut scan = scan_paths(
&roots.workspace,
include_roots,
&workspace_exclusions,
&ignore_rules,
request.limits,
&mut traversal,
)?;
apply_executable_modes(&mut scan.entries, &executable_modes)?;
consume_planned_manifest_traversal(&mut traversal, &scan.entries)?;
let manifest = build_manifest(project_path, scan.entries);
validate_source_manifest(&manifest, request.limits)?;
let files = manifest
.entries
.iter()
.map(|entry| PlannedSourceFile {
source_path: roots.workspace.join(&entry.path),
bundle_path: Utf8PathBuf::from(&entry.path),
})
.collect();
Ok(SourceBundlePlan {
request: request.clone(),
manifest,
files,
executable_modes,
excluded_sensitive_paths: scan.excluded_sensitive_paths,
})
}
pub fn verify_source_bundle_plan(plan: &SourceBundlePlan) -> Result<(), SourceError> {
verify_source_manifest(&plan.request, &plan.manifest)
}
pub fn verify_source_manifest(
request: &SourceBundleRequest,
expected: &SourceManifest,
) -> Result<(), SourceError> {
validate_source_manifest(expected, request.limits)?;
let actual = plan_source_bundle(request)?;
if actual.manifest == *expected {
Ok(())
} else {
Err(SourceError::ManifestMismatch)
}
}
pub fn verify_materialized_bundle(
bundle_root: &Utf8Path,
expected: &SourceManifest,
limits: SourceLimits,
) -> Result<(), SourceError> {
validate_source_manifest(expected, limits)?;
check_supported_depth(limits)?;
reject_root_symlink(bundle_root, "bundle root")?;
let root = canonical_directory(bundle_root, "bundle root")?;
let directory = CapabilityDir::open_ambient_dir(root.as_std_path(), ambient_authority())
.map_err(|source| SourceError::Io {
operation: "open verified bundle root",
path: bundle_root.as_str().to_owned(),
source,
})?;
let identity = capability_directory_identity(&directory).map_err(|source| SourceError::Io {
operation: "capture verified bundle root identity",
path: bundle_root.as_str().to_owned(),
source,
})?;
ensure_capability_directory_path(&directory, &root, &identity).map_err(|source| {
SourceError::Io {
operation: "bind verified bundle root handle to path",
path: bundle_root.as_str().to_owned(),
source,
}
})?;
verify_materialized_capability(&directory, expected, limits)?;
ensure_capability_directory_path(&directory, &root, &identity).map_err(|source| {
SourceError::Io {
operation: "rebind verified bundle root handle to path",
path: bundle_root.as_str().to_owned(),
source,
}
})
}
fn verify_materialized_capability(
directory: &CapabilityDir,
expected: &SourceManifest,
limits: SourceLimits,
) -> Result<(), SourceError> {
let mut scan = scan_capability_paths(directory, limits)?;
normalize_unobservable_executable_modes(&mut scan.entries, &expected.entries);
let unexpected_directory = has_unexpected_directory(&scan.directories, expected);
let actual = build_manifest(expected.project_path.clone(), scan.entries);
if actual == *expected && !unexpected_directory {
Ok(())
} else {
Err(SourceError::ManifestMismatch)
}
}
pub fn validate_source_manifest(
manifest: &SourceManifest,
limits: SourceLimits,
) -> Result<(), SourceError> {
if manifest.schema_version != MANIFEST_SCHEMA_VERSION {
return Err(SourceError::InvalidManifest {
reason: "unsupported schema version",
});
}
validate_portable_path_str(&manifest.project_path, true).map_err(|reason| {
SourceError::NonPortablePath {
path: manifest.project_path.clone(),
reason,
}
})?;
if manifest.project_path != "." {
reject_sensitive(&manifest.project_path)?;
}
check_supported_depth(limits)?;
if manifest.entries.len() > limits.max_file_count {
return Err(limit_error(
SourceLimitKind::FileCount,
None,
limits.max_file_count as u64,
manifest.entries.len() as u64,
));
}
let mut previous: Option<&str> = None;
let mut collisions = BTreeMap::new();
let mut file_paths = BTreeSet::new();
let mut total_size = 0_u64;
let mut traversal_entries = 0_u64;
for entry in &manifest.entries {
validate_portable_path_str(&entry.path, false).map_err(|reason| {
SourceError::NonPortablePath {
path: entry.path.clone(),
reason,
}
})?;
reject_sensitive(&entry.path)?;
check_depth(&entry.path, limits.max_depth)?;
consume_manifest_traversal(&mut traversal_entries, &entry.path)?;
register_path_components(&entry.path, &mut collisions)?;
if entry.path == manifest.project_path {
return Err(SourceError::InvalidManifest {
reason: "project path is a file entry",
});
}
if has_file_ancestor(&entry.path, &file_paths) {
return Err(SourceError::InvalidManifest {
reason: "a file entry is also used as a directory",
});
}
if previous.is_some_and(|previous| previous >= entry.path.as_str()) {
return Err(SourceError::InvalidManifest {
reason: "entries are not strictly sorted by path",
});
}
previous = Some(&entry.path);
file_paths.insert(entry.path.clone());
if entry.size > limits.max_file_size {
return Err(limit_error(
SourceLimitKind::FileSize,
Some(entry.path.clone()),
limits.max_file_size,
entry.size,
));
}
validate_sha256(&entry.sha256)?;
total_size = total_size
.checked_add(entry.size)
.ok_or(SourceError::InvalidManifest {
reason: "total size overflow",
})?;
if total_size > limits.max_total_size {
return Err(limit_error(
SourceLimitKind::TotalSize,
Some(entry.path.clone()),
limits.max_total_size,
total_size,
));
}
}
if total_size != manifest.total_size {
return Err(SourceError::InvalidManifest {
reason: "total size does not equal entry sizes",
});
}
validate_sha256(&manifest.sha256)?;
if manifest_digest(
&manifest.project_path,
&manifest.entries,
manifest.total_size,
) != manifest.sha256
{
return Err(SourceError::InvalidManifest {
reason: "manifest digest does not match canonical fields",
});
}
Ok(())
}
pub fn write_source_bundle_descriptor_file(
descriptor: &SourceBundleDescriptor,
output: &Utf8Path,
limits: SourceArchiveLimits,
) -> Result<(), SourceError> {
descriptor.validate(limits)?;
let temporary = create_archive_temporary(output)?;
let operation_parent = match temporary.parent.try_clone() {
Ok(parent) => parent,
Err(source) => {
let original = SourceError::Io {
operation: "clone descriptor output parent handle",
path: output.as_str().to_owned(),
source,
};
drop(temporary.file);
return match remove_owned_capability_file(
&temporary.parent,
&temporary.temporary_name,
&temporary.identity,
) {
Ok(()) => Err(original),
Err(source) => Err(SourceError::CleanupFailed {
path: temporary.temporary_path.as_str().to_owned(),
original: original.to_string(),
source,
}),
};
}
};
let mut cleanup = PartialFileCleanup::new(
temporary.temporary_path.clone(),
temporary.parent,
temporary.temporary_name.clone(),
temporary.identity,
);
let publication = (|| {
let mut writer =
DescriptorSizeLimitedWriter::new(temporary.file, MAX_SOURCE_BUNDLE_DESCRIPTOR_BYTES);
if let Err(error) = serde_json::to_writer_pretty(&mut writer, descriptor) {
return Err(descriptor_json_error(writer.exceeded(), output, &error));
}
if let Err(source) = writer.write_all(b"\n") {
return Err(descriptor_write_error(
writer.exceeded(),
output,
"write source bundle descriptor",
source,
));
}
let file = writer.into_inner();
file.sync_all().map_err(|source| SourceError::Io {
operation: "synchronize source bundle descriptor",
path: output.as_str().to_owned(),
source,
})?;
drop(file);
ensure_capability_directory_path(
&operation_parent,
&temporary.parent_path,
&temporary.parent_identity,
)
.map_err(|source| SourceError::Io {
operation: "bind descriptor output parent handle to path",
path: output.as_str().to_owned(),
source,
})?;
publish_archive_no_clobber(
&operation_parent,
&temporary.temporary_name,
&temporary.output_name,
&cleanup.identity,
output,
)?;
cleanup.mark_published(&temporary.output_name);
cleanup.remove_temporary_link_after_publish()?;
ensure_capability_directory_path(
&operation_parent,
&temporary.parent_path,
&temporary.parent_identity,
)
.map_err(|source| SourceError::Io {
operation: "rebind descriptor output parent handle to path",
path: output.as_str().to_owned(),
source,
})?;
ensure_named_capability_file(&operation_parent, &temporary.output_name, &cleanup.identity)
.map_err(|source| SourceError::Io {
operation: "bind published descriptor handle to output path",
path: output.as_str().to_owned(),
source,
})?;
cleanup.keep_published();
Ok(())
})();
match publication {
Ok(()) => Ok(()),
Err(error) => cleanup.fail(error),
}
}
pub fn create_source_bundle_archive(
plan: &SourceBundlePlan,
output: &Utf8Path,
limits: SourceArchiveLimits,
) -> Result<SourceArchive, SourceError> {
create_source_bundle_archive_with_temporary_hook(plan, output, limits, || {})
}
#[allow(clippy::too_many_lines)]
fn create_source_bundle_archive_with_temporary_hook(
plan: &SourceBundlePlan,
output: &Utf8Path,
limits: SourceArchiveLimits,
temporary_created: impl FnOnce(),
) -> Result<SourceArchive, SourceError> {
validate_archive_limits(limits)?;
validate_source_manifest(&plan.manifest, limits.source)?;
if plan.manifest.entries.len() != plan.files.len() {
return Err(SourceError::ManifestMismatch);
}
verify_source_bundle_plan(plan)?;
let temporary = create_archive_temporary(output)?;
temporary_created();
let cleanup_parent = match temporary.parent.try_clone() {
Ok(parent) => parent,
Err(source) => {
let original = SourceError::Io {
operation: "clone archive output parent handle",
path: output.as_str().to_owned(),
source,
};
drop(temporary.file);
return match remove_owned_capability_file(
&temporary.parent,
&temporary.temporary_name,
&temporary.identity,
) {
Ok(()) => Err(original),
Err(source) => Err(SourceError::CleanupFailed {
path: temporary.temporary_path.as_str().to_owned(),
original: original.to_string(),
source,
}),
};
}
};
let mut cleanup = PartialFileCleanup::new(
temporary.temporary_path.clone(),
cleanup_parent,
temporary.temporary_name.clone(),
temporary.identity,
);
let creation = (|| {
let mut writer = ZipWriter::new(ArchiveSizeLimitedWriter::new(
temporary.file,
limits.max_archive_size,
));
for (planned, expected) in plan.files.iter().zip(&plan.manifest.entries) {
if planned.bundle_path.as_str() != expected.path {
return Err(SourceError::ManifestMismatch);
}
write_verified_zip_entry(
&mut writer,
planned,
expected,
plan.executable_modes.get(&expected.path).copied(),
)?;
}
let mut file = writer
.finish()
.map_err(|error| archive_zip_error("finish", error))?
.into_inner();
file.sync_all().map_err(|source| SourceError::Io {
operation: "synchronize",
path: temporary.temporary_path.as_str().to_owned(),
source,
})?;
verify_source_bundle_plan(plan)?;
let descriptor = describe_open_archive(&mut file, limits.max_archive_size)?;
drop(file);
ensure_capability_directory_path(
&temporary.parent,
&temporary.parent_path,
&temporary.parent_identity,
)
.map_err(|source| SourceError::Io {
operation: "bind archive output parent handle to path",
path: output.as_str().to_owned(),
source,
})?;
publish_archive_no_clobber(
&temporary.parent,
&temporary.temporary_name,
&temporary.output_name,
&cleanup.identity,
output,
)?;
cleanup.mark_published(&temporary.output_name);
cleanup.remove_temporary_link_after_publish()?;
ensure_capability_directory_path(
&temporary.parent,
&temporary.parent_path,
&temporary.parent_identity,
)
.map_err(|source| SourceError::Io {
operation: "rebind archive output parent handle to path",
path: output.as_str().to_owned(),
source,
})?;
ensure_named_capability_file(&temporary.parent, &temporary.output_name, &cleanup.identity)
.map_err(|source| SourceError::Io {
operation: "bind published archive handle to output path",
path: output.as_str().to_owned(),
source,
})?;
cleanup.keep_published();
Ok(descriptor)
})();
match creation {
Ok(descriptor) => Ok(descriptor),
Err(error) => cleanup.fail(error),
}
}
pub fn verify_and_extract_source_bundle(
archive_path: &Utf8Path,
expected_archive: &SourceArchive,
expected_manifest: &SourceManifest,
destination: &Utf8Path,
limits: SourceArchiveLimits,
) -> Result<SourceArchive, SourceError> {
verify_and_extract_source_bundle_inner(
archive_path,
expected_archive,
expected_manifest,
destination,
None,
limits,
)
}
pub fn verify_and_extract_source_bundle_with_parent_handle(
archive_path: &Utf8Path,
expected_archive: &SourceArchive,
expected_manifest: &SourceManifest,
destination: &Utf8Path,
destination_parent: File,
limits: SourceArchiveLimits,
) -> Result<SourceArchive, SourceError> {
verify_and_extract_source_bundle_inner(
archive_path,
expected_archive,
expected_manifest,
destination,
Some(destination_parent),
limits,
)
}
fn verify_and_extract_source_bundle_inner(
archive_path: &Utf8Path,
expected_archive: &SourceArchive,
expected_manifest: &SourceManifest,
destination: &Utf8Path,
destination_parent: Option<File>,
limits: SourceArchiveLimits,
) -> Result<SourceArchive, SourceError> {
validate_archive_limits(limits)?;
validate_source_manifest(expected_manifest, limits.source)?;
validate_sha256(&expected_archive.sha256)?;
if expected_archive.size > limits.max_archive_size {
return Err(limit_error(
SourceLimitKind::ArchiveSize,
None,
limits.max_archive_size,
expected_archive.size,
));
}
let (mut archive, initial_metadata, actual_descriptor) =
open_verified_archive(archive_path, expected_archive, limits.max_archive_size)?;
preflight_archive(&mut archive, expected_manifest, limits)?;
let FreshDestination {
path: actual_destination,
parent: destination_parent,
parent_path: destination_parent_path,
parent_identity: destination_parent_identity,
name: destination_name,
directory: destination_directory,
identity,
} = create_fresh_destination(destination, destination_parent)?;
let mut cleanup = PartialDirectoryCleanup::new(
actual_destination,
destination_parent,
destination_parent_path,
destination_parent_identity,
destination_name,
destination_directory,
identity,
);
let extraction = (|| {
for (index, expected) in expected_manifest.entries.iter().enumerate() {
extract_verified_entry(&mut archive, index, expected, cleanup.directory()?)?;
}
let mut archive_file = archive.into_inner();
let final_descriptor = describe_open_archive(&mut archive_file, limits.max_archive_size)?;
ensure_archive_file_stable(archive_path, &initial_metadata, &archive_file)?;
if final_descriptor != actual_descriptor {
return Err(SourceError::ArchiveIntegrityMismatch {
reason: "archive changed during extraction",
});
}
verify_materialized_capability(cleanup.directory()?, expected_manifest, limits.source)?;
cleanup.keep()?;
Ok(actual_descriptor)
})();
match extraction {
Ok(descriptor) => Ok(descriptor),
Err(error) => cleanup.fail(error),
}
}
#[derive(Debug)]
struct PartialFileCleanup {
path: Utf8PathBuf,
parent: CapabilityDir,
name: String,
identity: FileIdentityHandle,
published_name: Option<String>,
active: bool,
}
#[derive(Debug)]
struct PartialDirectoryCleanup {
path: Utf8PathBuf,
parent: CapabilityDir,
parent_path: Utf8PathBuf,
parent_identity: FileIdentityHandle,
#[cfg(not(windows))]
name: String,
directory: Option<CapabilityDir>,
identity: Option<FileIdentityHandle>,
active: bool,
}
#[derive(Debug)]
struct FreshDestination {
path: Utf8PathBuf,
parent: CapabilityDir,
parent_path: Utf8PathBuf,
parent_identity: FileIdentityHandle,
name: String,
directory: CapabilityDir,
identity: FileIdentityHandle,
}
impl PartialFileCleanup {
fn new(
path: Utf8PathBuf,
parent: CapabilityDir,
name: String,
identity: FileIdentityHandle,
) -> Self {
Self {
path,
parent,
name,
identity,
published_name: None,
active: true,
}
}
fn mark_published(&mut self, output_name: &str) {
self.published_name = Some(output_name.to_owned());
}
fn remove_temporary_link_after_publish(&mut self) -> Result<(), SourceError> {
match remove_owned_capability_file(&self.parent, &self.name, &self.identity) {
Ok(()) => Ok(()),
Err(source) => Err(SourceError::CleanupFailed {
path: self.path.as_str().to_owned(),
original: "published archive temporary-link cleanup failed".to_owned(),
source,
}),
}
}
fn keep_published(&mut self) {
self.published_name = None;
self.active = false;
}
fn fail<T>(&mut self, error: SourceError) -> Result<T, SourceError> {
if !self.active {
return Err(error);
}
let published_cleanup = self.published_name.as_ref().map_or(Ok(()), |name| {
remove_owned_capability_file(&self.parent, name, &self.identity)
});
let temporary_cleanup =
remove_owned_capability_file(&self.parent, &self.name, &self.identity);
match published_cleanup.and(temporary_cleanup) {
Ok(()) => {
self.published_name = None;
self.active = false;
Err(error)
}
Err(source) if source.kind() == io::ErrorKind::NotFound => {
self.active = false;
Err(error)
}
Err(source) => Err(SourceError::CleanupFailed {
path: self.path.as_str().to_owned(),
original: error.to_string(),
source,
}),
}
}
}
impl Drop for PartialFileCleanup {
fn drop(&mut self) {
if self.active {
if let Some(output_name) = &self.published_name {
let _ = remove_owned_capability_file(&self.parent, output_name, &self.identity);
}
let _ = remove_owned_capability_file(&self.parent, &self.name, &self.identity);
}
}
}
impl PartialDirectoryCleanup {
fn new(
path: Utf8PathBuf,
parent: CapabilityDir,
parent_path: Utf8PathBuf,
parent_identity: FileIdentityHandle,
name: String,
directory: CapabilityDir,
identity: FileIdentityHandle,
) -> Self {
#[cfg(windows)]
drop(name);
Self {
path,
parent,
parent_path,
parent_identity,
#[cfg(not(windows))]
name,
directory: Some(directory),
identity: Some(identity),
active: true,
}
}
fn directory(&self) -> Result<&CapabilityDir, SourceError> {
self.directory
.as_ref()
.ok_or(SourceError::InvalidDestination {
reason: "extraction destination handle is unavailable",
})
}
fn keep(&mut self) -> Result<(), SourceError> {
ensure_capability_directory_path(&self.parent, &self.parent_path, &self.parent_identity)
.map_err(|source| SourceError::Io {
operation: "bind extraction destination parent handle to path",
path: self.path.as_str().to_owned(),
source,
})?;
#[cfg(windows)]
verify_private_directory_handle(
self.directory()?
.try_clone()
.map_err(|source| SourceError::Io {
operation: "clone extraction destination handle",
path: self.path.as_str().to_owned(),
source,
})?
.into_std_file()
.as_handle(),
)
.map_err(|source| SourceError::Io {
operation: "verify extraction destination handle",
path: self.path.as_str().to_owned(),
source: io::Error::other(source),
})?;
#[cfg(not(windows))]
{
let identity = self
.identity
.as_ref()
.ok_or(SourceError::InvalidDestination {
reason: "extraction destination identity is unavailable",
})?;
ensure_named_capability_directory(&self.parent, &self.name, identity).map_err(
|source| SourceError::Io {
operation: "bind extracted destination handle to final path",
path: self.path.as_str().to_owned(),
source,
},
)?;
}
drop(self.identity.take());
drop(self.directory.take());
self.active = false;
Ok(())
}
fn fail<T>(&mut self, error: SourceError) -> Result<T, SourceError> {
if !self.active {
return Err(error);
}
let Some(directory) = self.directory.take() else {
return Err(error);
};
drop(self.identity.take());
match remove_created_directory_tree(directory) {
Ok(()) => {
self.active = false;
Err(error)
}
Err(source) if source.kind() == io::ErrorKind::NotFound => {
self.active = false;
Err(error)
}
Err(source) => Err(SourceError::CleanupFailed {
path: self.path.as_str().to_owned(),
original: error.to_string(),
source,
}),
}
}
}
impl Drop for PartialDirectoryCleanup {
fn drop(&mut self) {
if self.active {
drop(self.identity.take());
if let Some(directory) = self.directory.take() {
let _ = remove_created_directory_tree(directory);
}
}
}
}
fn remove_owned_capability_file(
parent: &CapabilityDir,
name: &str,
identity: &FileIdentityHandle,
) -> io::Result<()> {
let file = match parent.open(name) {
Ok(file) => file,
Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(source) => return Err(source),
};
let current = FileIdentityHandle::from_file(file.into_std())?;
if ¤t != identity {
return Err(io::Error::other(
"cleanup target no longer identifies the operation-created file",
));
}
parent.remove_file(name)
}
fn validate_archive_limits(limits: SourceArchiveLimits) -> Result<(), SourceError> {
check_supported_depth(limits.source)?;
if limits.max_compression_ratio == 0 {
return Err(SourceError::InvalidArchive {
reason: "maximum compression ratio must be positive".to_owned(),
});
}
Ok(())
}
#[derive(Debug)]
struct ArchiveSizeExceeded {
maximum: u64,
actual: u64,
}
impl fmt::Display for ArchiveSizeExceeded {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"source archive size would exceed {} bytes: {}",
self.maximum, self.actual
)
}
}
impl Error for ArchiveSizeExceeded {}
#[derive(Debug)]
struct ArchiveSizeLimitedWriter {
file: File,
maximum: u64,
}
impl ArchiveSizeLimitedWriter {
const fn new(file: File, maximum: u64) -> Self {
Self { file, maximum }
}
fn into_inner(self) -> File {
self.file
}
fn limit_error(&self, actual: u64) -> io::Error {
io::Error::new(
io::ErrorKind::FileTooLarge,
ArchiveSizeExceeded {
maximum: self.maximum,
actual,
},
)
}
}
impl Write for ArchiveSizeLimitedWriter {
fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
let position = self.file.stream_position()?;
let end = position.saturating_add(buffer.len() as u64);
if end > self.maximum {
return Err(self.limit_error(end));
}
self.file.write(buffer)
}
fn flush(&mut self) -> io::Result<()> {
self.file.flush()
}
}
#[derive(Debug)]
struct DescriptorSizeLimitedWriter {
file: File,
maximum: u64,
written: u64,
exceeded: Option<u64>,
}
impl DescriptorSizeLimitedWriter {
const fn new(file: File, maximum: u64) -> Self {
Self {
file,
maximum,
written: 0,
exceeded: None,
}
}
const fn exceeded(&self) -> Option<u64> {
self.exceeded
}
fn into_inner(self) -> File {
self.file
}
}
impl Write for DescriptorSizeLimitedWriter {
fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
let actual = self.written.saturating_add(buffer.len() as u64);
if actual > self.maximum {
self.exceeded = Some(actual);
return Err(io::Error::new(
io::ErrorKind::FileTooLarge,
"source bundle descriptor exceeds its fixed size limit",
));
}
let written = self.file.write(buffer)?;
self.written = self.written.saturating_add(written as u64);
Ok(written)
}
fn flush(&mut self) -> io::Result<()> {
self.file.flush()
}
}
fn descriptor_json_error(
exceeded: Option<u64>,
output: &Utf8Path,
error: &serde_json::Error,
) -> SourceError {
if let Some(actual) = exceeded {
return limit_error(
SourceLimitKind::DescriptorSize,
None,
MAX_SOURCE_BUNDLE_DESCRIPTOR_BYTES,
actual,
);
}
if let Some(kind) = error.io_error_kind() {
return SourceError::Io {
operation: "serialize source bundle descriptor",
path: output.as_str().to_owned(),
source: io::Error::new(kind, error.to_string()),
};
}
SourceError::InvalidArchive {
reason: "source bundle descriptor could not be serialized".to_owned(),
}
}
fn descriptor_write_error(
exceeded: Option<u64>,
output: &Utf8Path,
operation: &'static str,
source: io::Error,
) -> SourceError {
if let Some(actual) = exceeded {
limit_error(
SourceLimitKind::DescriptorSize,
None,
MAX_SOURCE_BUNDLE_DESCRIPTOR_BYTES,
actual,
)
} else {
SourceError::Io {
operation,
path: output.as_str().to_owned(),
source,
}
}
}
impl Seek for ArchiveSizeLimitedWriter {
fn seek(&mut self, position: SeekFrom) -> io::Result<u64> {
let original = self.file.stream_position()?;
let actual = self.file.seek(position)?;
if actual > self.maximum {
self.file.seek(SeekFrom::Start(original))?;
return Err(self.limit_error(actual));
}
Ok(actual)
}
}
fn archive_write_error(operation: &'static str, source: io::Error) -> SourceError {
if let Some(exceeded) = source
.get_ref()
.and_then(|error| error.downcast_ref::<ArchiveSizeExceeded>())
{
limit_error(
SourceLimitKind::ArchiveSize,
None,
exceeded.maximum,
exceeded.actual,
)
} else {
SourceError::Io {
operation,
path: "source archive".to_owned(),
source,
}
}
}
fn archive_zip_error(operation: &'static str, error: zip::result::ZipError) -> SourceError {
match error {
zip::result::ZipError::Io(source) => archive_write_error(operation, source),
error => SourceError::Zip {
operation,
message: error.to_string(),
},
}
}
#[derive(Debug)]
struct ArchiveTemporary {
parent: CapabilityDir,
parent_path: Utf8PathBuf,
parent_identity: FileIdentityHandle,
output_name: String,
temporary_path: Utf8PathBuf,
temporary_name: String,
file: File,
identity: FileIdentityHandle,
}
#[allow(clippy::too_many_lines)]
fn create_archive_temporary(output: &Utf8Path) -> Result<ArchiveTemporary, SourceError> {
let file_name = output.file_name().filter(|name| !name.is_empty()).ok_or(
SourceError::InvalidDestination {
reason: "archive output has no file name",
},
)?;
let parent = output
.parent()
.filter(|path| !path.as_str().is_empty())
.unwrap_or_else(|| Utf8Path::new("."));
let canonical_parent = canonical_directory(parent, "archive output parent")?;
let parent_directory =
CapabilityDir::open_ambient_dir(canonical_parent.as_std_path(), ambient_authority())
.map_err(|source| SourceError::Io {
operation: "open archive output parent",
path: parent.as_str().to_owned(),
source,
})?;
let parent_identity =
capability_directory_identity(&parent_directory).map_err(|source| SourceError::Io {
operation: "capture archive output parent identity",
path: parent.as_str().to_owned(),
source,
})?;
ensure_capability_directory_path(&parent_directory, &canonical_parent, &parent_identity)
.map_err(|source| SourceError::Io {
operation: "bind archive output parent handle to path",
path: parent.as_str().to_owned(),
source,
})?;
match parent_directory.symlink_metadata(file_name) {
Ok(_) => {
return Err(SourceError::OutputExists {
path: output.as_str().to_owned(),
});
}
Err(source) if source.kind() == io::ErrorKind::NotFound => {}
Err(source) => {
return Err(SourceError::Io {
operation: "inspect archive output",
path: output.as_str().to_owned(),
source,
});
}
}
for _ in 0..SOURCE_ARCHIVE_TEMP_ATTEMPTS {
let temporary_name = format!(".{file_name}.rustferry-partial-{}", Uuid::new_v4());
let temporary_path = canonical_parent.join(&temporary_name);
match open_new_private_capability_file(&parent_directory, &temporary_name) {
Ok(file) => {
let file = file.into_std();
let identity = match file.try_clone().and_then(FileIdentityHandle::from_file) {
Ok(identity) => identity,
Err(source) => {
drop(file);
let cleanup = parent_directory.remove_file(&temporary_name);
if let Err(cleanup_source) = cleanup {
return Err(SourceError::CleanupFailed {
path: temporary_path.as_str().to_owned(),
original: source.to_string(),
source: cleanup_source,
});
}
return Err(SourceError::Io {
operation: "inspect archive temporary file",
path: temporary_path.as_str().to_owned(),
source,
});
}
};
let binding = ensure_capability_directory_path(
&parent_directory,
&canonical_parent,
&parent_identity,
)
.and_then(|()| {
ensure_named_capability_file(&parent_directory, &temporary_name, &identity)
});
if let Err(source) = binding {
drop(file);
let original = SourceError::Io {
operation: "bind archive temporary handle to path",
path: temporary_path.as_str().to_owned(),
source,
};
return match remove_owned_capability_file(
&parent_directory,
&temporary_name,
&identity,
) {
Ok(()) => Err(original),
Err(source) => Err(SourceError::CleanupFailed {
path: temporary_path.as_str().to_owned(),
original: original.to_string(),
source,
}),
};
}
return Ok(ArchiveTemporary {
parent: parent_directory,
parent_path: canonical_parent,
parent_identity,
output_name: file_name.to_owned(),
temporary_path,
temporary_name,
file,
identity,
});
}
Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {}
Err(source) => {
return Err(SourceError::Io {
operation: "create archive temporary file",
path: temporary_path.as_str().to_owned(),
source,
});
}
}
}
Err(SourceError::InvalidDestination {
reason: "could not allocate a unique archive temporary file",
})
}
#[cfg(windows)]
fn open_read_stable(path: &Utf8Path) -> io::Result<File> {
use std::os::windows::fs::OpenOptionsExt;
OpenOptions::new()
.read(true)
.share_mode(WINDOWS_FILE_SHARE_READ)
.custom_flags(WINDOWS_FILE_FLAG_OPEN_REPARSE_POINT)
.open(path)
}
#[cfg(not(windows))]
fn open_read_stable(path: &Utf8Path) -> io::Result<File> {
File::open(path)
}
fn publish_archive_no_clobber(
parent: &CapabilityDir,
temporary_name: &str,
output_name: &str,
identity: &FileIdentityHandle,
output: &Utf8Path,
) -> Result<(), SourceError> {
ensure_named_capability_file(parent, temporary_name, identity).map_err(|source| {
SourceError::Io {
operation: "bind archive temporary handle to path",
path: output.as_str().to_owned(),
source,
}
})?;
parent
.hard_link(temporary_name, parent, output_name)
.map_err(|source| {
if source.kind() == io::ErrorKind::AlreadyExists {
SourceError::OutputExists {
path: output.as_str().to_owned(),
}
} else {
SourceError::Io {
operation: "atomically publish archive without clobbering",
path: output.as_str().to_owned(),
source,
}
}
})?;
if let Err(source) = ensure_named_capability_file(parent, output_name, identity) {
let original = SourceError::Io {
operation: "bind published archive handle to output path",
path: output.as_str().to_owned(),
source,
};
return match remove_owned_capability_file(parent, output_name, identity) {
Ok(()) => Err(original),
Err(source) => Err(SourceError::CleanupFailed {
path: output.as_str().to_owned(),
original: original.to_string(),
source,
}),
};
}
Ok(())
}
#[allow(clippy::too_many_lines)]
fn write_verified_zip_entry(
writer: &mut ZipWriter<ArchiveSizeLimitedWriter>,
planned: &PlannedSourceFile,
expected: &SourceManifestEntry,
portable_executable: Option<bool>,
) -> Result<(), SourceError> {
let path = planned.source_path();
let path_display = expected.path.clone();
let initial = fs::symlink_metadata(path).map_err(|source| SourceError::Io {
operation: "inspect planned source",
path: path_display.clone(),
source,
})?;
if initial.file_type().is_symlink() {
return Err(SourceError::Symlink { path: path_display });
}
if !initial.is_file() {
return Err(SourceError::UnsupportedFileType { path: path_display });
}
#[cfg(not(windows))]
reject_hard_link(&initial, &expected.path)?;
if initial.len() != expected.size
|| resolved_executable_mode(executable_bit(&initial), portable_executable)
!= expected.executable
{
return Err(SourceError::ManifestMismatch);
}
let mut source_file = open_read_stable(path).map_err(|source| SourceError::Io {
operation: "reopen planned source",
path: expected.path.clone(),
source,
})?;
let opened = source_file.metadata().map_err(|source| SourceError::Io {
operation: "inspect reopened source",
path: expected.path.clone(),
source,
})?;
reject_open_hard_link(&source_file, &expected.path)?;
#[cfg(windows)]
reject_named_streams(&source_file, &expected.path)?;
if !opened.is_file()
|| !same_file_identity(&initial, &opened)
|| opened.len() != expected.size
|| resolved_executable_mode(executable_bit(&opened), portable_executable)
!= expected.executable
{
return Err(SourceError::ChangedDuringRead {
path: expected.path.clone(),
});
}
let mode = if expected.executable { 0o755 } else { 0o644 };
let options = SimpleFileOptions::default()
.compression_method(CompressionMethod::Stored)
.compression_level(None)
.last_modified_time(DateTime::default())
.unix_permissions(mode)
.large_file(false);
writer
.start_file(&expected.path, options)
.map_err(|error| archive_zip_error("start entry", error))?;
let initial_modified = initial.modified().ok();
let mut digest = Sha256::new();
let mut count = 0_u64;
let mut buffer = vec![0_u8; SOURCE_ARCHIVE_BUFFER_SIZE];
loop {
let read = source_file
.read(&mut buffer)
.map_err(|source| SourceError::Io {
operation: "read planned source",
path: expected.path.clone(),
source,
})?;
if read == 0 {
break;
}
count = count
.checked_add(read as u64)
.ok_or(SourceError::ManifestMismatch)?;
if count > expected.size {
return Err(SourceError::ChangedDuringRead {
path: expected.path.clone(),
});
}
digest.update(&buffer[..read]);
writer
.write_all(&buffer[..read])
.map_err(|source| archive_write_error("write source ZIP entry", source))?;
}
let open_final = source_file.metadata().map_err(|source| SourceError::Io {
operation: "reinspect open source",
path: expected.path.clone(),
source,
})?;
let path_final = fs::symlink_metadata(path).map_err(|source| SourceError::Io {
operation: "reinspect source path",
path: expected.path.clone(),
source,
})?;
reject_open_hard_link(&source_file, &expected.path)?;
#[cfg(windows)]
reject_named_streams(&source_file, &expected.path)?;
#[cfg(not(windows))]
reject_hard_link(&path_final, &expected.path)?;
ensure_source_metadata_stable(
expected,
&opened,
&open_final,
&path_final,
initial_modified,
portable_executable,
)?;
if count != expected.size || hex_digest(digest.finalize()) != expected.sha256 {
return Err(SourceError::ManifestMismatch);
}
Ok(())
}
fn ensure_source_metadata_stable(
expected: &SourceManifestEntry,
opened: &Metadata,
open_final: &Metadata,
path_final: &Metadata,
initial_modified: Option<std::time::SystemTime>,
portable_executable: Option<bool>,
) -> Result<(), SourceError> {
let stable = !path_final.file_type().is_symlink()
&& open_final.is_file()
&& path_final.is_file()
&& same_file_identity(opened, open_final)
&& same_file_identity(opened, path_final)
&& open_final.len() == expected.size
&& path_final.len() == expected.size
&& open_final.modified().ok() == initial_modified
&& path_final.modified().ok() == initial_modified
&& resolved_executable_mode(executable_bit(open_final), portable_executable)
== expected.executable
&& resolved_executable_mode(executable_bit(path_final), portable_executable)
== expected.executable;
if stable {
Ok(())
} else {
Err(SourceError::ChangedDuringRead {
path: expected.path.clone(),
})
}
}
fn describe_open_archive(file: &mut File, maximum_size: u64) -> Result<SourceArchive, SourceError> {
let metadata = file.metadata().map_err(|source| SourceError::Io {
operation: "inspect source archive",
path: "source archive".to_owned(),
source,
})?;
if metadata.len() > maximum_size {
return Err(limit_error(
SourceLimitKind::ArchiveSize,
None,
maximum_size,
metadata.len(),
));
}
file.seek(SeekFrom::Start(0))
.map_err(|source| SourceError::Io {
operation: "rewind source archive",
path: "source archive".to_owned(),
source,
})?;
let mut digest = Sha256::new();
let mut size = 0_u64;
let mut buffer = vec![0_u8; SOURCE_ARCHIVE_BUFFER_SIZE];
loop {
let read = file.read(&mut buffer).map_err(|source| SourceError::Io {
operation: "hash source archive",
path: "source archive".to_owned(),
source,
})?;
if read == 0 {
break;
}
size = size.checked_add(read as u64).ok_or_else(|| {
limit_error(SourceLimitKind::ArchiveSize, None, maximum_size, u64::MAX)
})?;
if size > maximum_size {
return Err(limit_error(
SourceLimitKind::ArchiveSize,
None,
maximum_size,
size,
));
}
digest.update(&buffer[..read]);
}
file.seek(SeekFrom::Start(0))
.map_err(|source| SourceError::Io {
operation: "rewind source archive",
path: "source archive".to_owned(),
source,
})?;
if size != metadata.len() {
return Err(SourceError::ArchiveIntegrityMismatch {
reason: "archive size changed while hashing",
});
}
Ok(SourceArchive {
size,
sha256: hex_digest(digest.finalize()),
})
}
fn open_verified_archive(
archive_path: &Utf8Path,
expected: &SourceArchive,
maximum_size: u64,
) -> Result<(ZipArchive<File>, Metadata, SourceArchive), SourceError> {
let path_metadata = fs::symlink_metadata(archive_path).map_err(|source| SourceError::Io {
operation: "inspect source archive",
path: archive_path.as_str().to_owned(),
source,
})?;
if path_metadata.file_type().is_symlink() || !path_metadata.is_file() {
return Err(SourceError::InvalidArchive {
reason: "archive path is not a regular file".to_owned(),
});
}
if path_metadata.len() != expected.size {
return Err(SourceError::ArchiveIntegrityMismatch {
reason: "archive size differs from transport descriptor",
});
}
let mut file = open_read_stable(archive_path).map_err(|source| SourceError::Io {
operation: "open source archive",
path: archive_path.as_str().to_owned(),
source,
})?;
let opened = file.metadata().map_err(|source| SourceError::Io {
operation: "inspect open source archive",
path: archive_path.as_str().to_owned(),
source,
})?;
if !opened.is_file() || !same_file_identity(&path_metadata, &opened) {
return Err(SourceError::ArchiveIntegrityMismatch {
reason: "archive path changed while opening",
});
}
let descriptor = describe_open_archive(&mut file, maximum_size)?;
ensure_archive_file_stable(archive_path, &opened, &file)?;
if descriptor != *expected {
return Err(SourceError::ArchiveIntegrityMismatch {
reason: "archive SHA-256 differs from transport descriptor",
});
}
let archive = ZipArchive::new(file).map_err(|error| SourceError::Zip {
operation: "open",
message: error.to_string(),
})?;
Ok((archive, opened, descriptor))
}
fn ensure_archive_file_stable(
archive_path: &Utf8Path,
initial: &Metadata,
file: &File,
) -> Result<(), SourceError> {
let open_final = file.metadata().map_err(|source| SourceError::Io {
operation: "reinspect open source archive",
path: archive_path.as_str().to_owned(),
source,
})?;
let path_final = fs::symlink_metadata(archive_path).map_err(|source| SourceError::Io {
operation: "reinspect source archive path",
path: archive_path.as_str().to_owned(),
source,
})?;
let stable = !path_final.file_type().is_symlink()
&& open_final.is_file()
&& path_final.is_file()
&& same_file_identity(initial, &open_final)
&& same_file_identity(initial, &path_final)
&& open_final.len() == initial.len()
&& path_final.len() == initial.len()
&& open_final.modified().ok() == initial.modified().ok()
&& path_final.modified().ok() == initial.modified().ok();
if stable {
Ok(())
} else {
Err(SourceError::ArchiveIntegrityMismatch {
reason: "archive changed while being read",
})
}
}
#[derive(Debug)]
struct ArchiveEntryMetadata {
path: String,
size: u64,
executable: bool,
}
#[allow(clippy::too_many_lines)]
fn preflight_archive(
archive: &mut ZipArchive<File>,
expected_manifest: &SourceManifest,
limits: SourceArchiveLimits,
) -> Result<(), SourceError> {
if !archive.comment().is_empty() {
return Err(SourceError::InvalidArchive {
reason: "archive comment is forbidden".to_owned(),
});
}
if archive.len() > limits.source.max_file_count {
return Err(limit_error(
SourceLimitKind::FileCount,
None,
limits.source.max_file_count as u64,
archive.len() as u64,
));
}
let mut entries = Vec::with_capacity(archive.len());
let mut exact_paths = BTreeSet::new();
let mut collisions = BTreeMap::new();
let mut total_size = 0_u64;
for index in 0..archive.len() {
let entry = archive.by_index(index).map_err(|error| SourceError::Zip {
operation: "read entry metadata",
message: error.to_string(),
})?;
let path = std::str::from_utf8(entry.name_raw())
.map_err(|_| SourceError::InvalidArchive {
reason: "entry name is not UTF-8".to_owned(),
})?
.to_owned();
validate_portable_path_str(&path, false).map_err(|reason| {
SourceError::NonPortablePath {
path: path.clone(),
reason,
}
})?;
reject_sensitive(&path)?;
check_depth(&path, limits.source.max_depth)?;
if !exact_paths.insert(path.clone()) {
return Err(SourceError::InvalidArchive {
reason: format!("duplicate ZIP entry {path:?}"),
});
}
register_path_components(&path, &mut collisions)?;
if entry.encrypted() {
return Err(SourceError::InvalidArchive {
reason: format!("encrypted ZIP entry is forbidden: {path:?}"),
});
}
if entry.is_dir() || entry.is_symlink() {
return Err(SourceError::InvalidArchive {
reason: format!("link or directory ZIP entry is forbidden: {path:?}"),
});
}
let mode = entry
.unix_mode()
.ok_or_else(|| SourceError::InvalidArchive {
reason: format!("ZIP entry has no normalized Unix mode: {path:?}"),
})?;
if mode & 0o170_000 != 0o100_000 {
return Err(SourceError::InvalidArchive {
reason: format!("special ZIP entry is forbidden: {path:?}"),
});
}
let permissions = mode & 0o777;
if !matches!(permissions, 0o644 | 0o755) {
return Err(SourceError::InvalidArchive {
reason: format!("ZIP entry has non-canonical permissions: {path:?}"),
});
}
if entry.last_modified() != Some(DateTime::default()) {
return Err(SourceError::InvalidArchive {
reason: format!("ZIP entry has a non-canonical timestamp: {path:?}"),
});
}
if !entry.comment().is_empty() || entry.extra_data().is_some_and(|data| !data.is_empty()) {
return Err(SourceError::InvalidArchive {
reason: format!("ZIP entry has forbidden auxiliary metadata: {path:?}"),
});
}
if entry.size() > limits.source.max_file_size {
return Err(limit_error(
SourceLimitKind::FileSize,
Some(path),
limits.source.max_file_size,
entry.size(),
));
}
total_size =
total_size
.checked_add(entry.size())
.ok_or_else(|| SourceError::InvalidArchive {
reason: "uncompressed source ZIP size overflow".to_owned(),
})?;
if total_size > limits.source.max_total_size {
return Err(limit_error(
SourceLimitKind::TotalSize,
Some(path),
limits.source.max_total_size,
total_size,
));
}
if entry.size() > 0 {
let compressed = entry.compressed_size();
let ratio = if compressed == 0 {
u64::MAX
} else {
entry.size().div_ceil(compressed)
};
if ratio > limits.max_compression_ratio {
return Err(limit_error(
SourceLimitKind::CompressionRatio,
Some(path),
limits.max_compression_ratio,
ratio,
));
}
}
if entry.compression() == CompressionMethod::Stored
&& entry.compressed_size() != entry.size()
{
return Err(SourceError::InvalidArchive {
reason: format!("stored ZIP entry size is inconsistent: {path:?}"),
});
}
if entry.compression() != CompressionMethod::Stored {
return Err(SourceError::InvalidArchive {
reason: format!("ZIP entry uses non-canonical compression: {path:?}"),
});
}
entries.push(ArchiveEntryMetadata {
path,
size: entry.size(),
executable: permissions == 0o755,
});
}
if entries.len() != expected_manifest.entries.len() {
return Err(SourceError::ManifestMismatch);
}
for (actual, expected) in entries.iter().zip(&expected_manifest.entries) {
if actual.path != expected.path
|| actual.size != expected.size
|| actual.executable != expected.executable
{
return Err(SourceError::ManifestMismatch);
}
}
Ok(())
}
#[allow(clippy::too_many_lines)]
fn create_fresh_destination(
destination: &Utf8Path,
retained_parent: Option<File>,
) -> Result<FreshDestination, SourceError> {
let name = destination
.file_name()
.filter(|name| !name.is_empty())
.ok_or(SourceError::InvalidDestination {
reason: "extraction destination has no directory name",
})?;
let parent = destination
.parent()
.filter(|path| !path.as_str().is_empty())
.unwrap_or_else(|| Utf8Path::new("."));
let canonical_parent = canonical_directory(parent, "extraction destination parent")?;
let actual = canonical_parent.join(name);
let retained_parent_supplied = retained_parent.is_some();
let parent_directory = if let Some(parent) = retained_parent {
CapabilityDir::from_std_file(parent)
} else {
CapabilityDir::open_ambient_dir(canonical_parent.as_std_path(), ambient_authority())
.map_err(|source| SourceError::Io {
operation: "open extraction destination parent",
path: parent.as_str().to_owned(),
source,
})?
};
let parent_identity =
capability_directory_identity(&parent_directory).map_err(|source| SourceError::Io {
operation: "capture extraction destination parent identity",
path: parent.as_str().to_owned(),
source,
})?;
ensure_capability_directory_path(&parent_directory, &canonical_parent, &parent_identity)
.map_err(|source| SourceError::Io {
operation: "bind extraction destination parent handle to path",
path: parent.as_str().to_owned(),
source,
})?;
#[cfg(windows)]
if retained_parent_supplied {
let parent_handle = parent_directory
.try_clone()
.map_err(|source| SourceError::Io {
operation: "clone retained extraction destination parent",
path: parent.as_str().to_owned(),
source,
})?;
verify_private_directory_handle(parent_handle.into_std_file().as_handle()).map_err(
|source| SourceError::Io {
operation: "verify retained extraction destination parent",
path: parent.as_str().to_owned(),
source: io::Error::other(source),
},
)?;
}
#[cfg(not(windows))]
let _ = retained_parent_supplied;
match parent_directory.symlink_metadata(name) {
Ok(_) => {
return Err(SourceError::DestinationExists {
path: destination.as_str().to_owned(),
});
}
Err(source) if source.kind() == io::ErrorKind::NotFound => {}
Err(source) => {
return Err(SourceError::Io {
operation: "inspect extraction destination",
path: destination.as_str().to_owned(),
source,
});
}
}
#[cfg(windows)]
let private_directory =
rustferry_core::windows_private_directory::create_private_directory(actual.as_std_path())
.map_err(|error| {
if error.kind()
== rustferry_core::windows_private_directory::PrivateDirectoryErrorKind::AlreadyExists
{
SourceError::DestinationExists {
path: destination.as_str().to_owned(),
}
} else {
SourceError::Io {
operation: "create private extraction destination",
path: destination.as_str().to_owned(),
source: io::Error::other(error),
}
}
})?;
#[cfg(not(windows))]
create_private_capability_directory(&parent_directory, name).map_err(|source| {
if source.kind() == io::ErrorKind::AlreadyExists {
SourceError::DestinationExists {
path: destination.as_str().to_owned(),
}
} else {
SourceError::Io {
operation: "create extraction destination",
path: destination.as_str().to_owned(),
source,
}
}
})?;
#[cfg(windows)]
let directory = CapabilityDir::from_std_file(private_directory);
#[cfg(not(windows))]
let directory = match parent_directory.open_dir(name) {
Ok(directory) => directory,
Err(source) => {
let cleanup = parent_directory.remove_dir(name);
if let Err(cleanup_source) = cleanup {
return Err(SourceError::CleanupFailed {
path: actual.as_str().to_owned(),
original: source.to_string(),
source: cleanup_source,
});
}
return Err(SourceError::Io {
operation: "inspect created extraction destination",
path: destination.as_str().to_owned(),
source,
});
}
};
let opened_metadata = match directory.dir_metadata() {
Ok(metadata) => metadata,
Err(source) => {
return cleanup_created_directory(
directory,
&actual,
SourceError::Io {
operation: "inspect created extraction destination handle",
path: destination.as_str().to_owned(),
source,
},
);
}
};
if opened_metadata.is_symlink() || !opened_metadata.is_dir() {
return cleanup_created_directory(
directory,
&actual,
SourceError::InvalidDestination {
reason: "created extraction destination is not a directory",
},
);
}
#[cfg(not(windows))]
{
let named_metadata = match parent_directory.symlink_metadata(name) {
Ok(metadata) => metadata,
Err(source) => {
return cleanup_created_directory(
directory,
&actual,
SourceError::Io {
operation: "reinspect created extraction destination",
path: destination.as_str().to_owned(),
source,
},
);
}
};
if named_metadata.is_symlink() || !named_metadata.is_dir() {
return cleanup_created_directory(
directory,
&actual,
SourceError::InvalidDestination {
reason: "created extraction destination is not a directory",
},
);
}
}
let identity = match capability_directory_identity(&directory) {
Ok(identity) => identity,
Err(source) => {
return cleanup_created_directory(
directory,
&actual,
SourceError::Io {
operation: "capture extraction destination identity",
path: destination.as_str().to_owned(),
source,
},
);
}
};
let binding =
ensure_capability_directory_path(&parent_directory, &canonical_parent, &parent_identity);
#[cfg(windows)]
let binding = binding.and_then(|()| {
verify_private_directory_handle(directory.try_clone()?.into_std_file().as_handle())
.map_err(io::Error::other)
});
#[cfg(not(windows))]
let binding = binding
.and_then(|()| ensure_named_capability_directory(&parent_directory, name, &identity));
if let Err(source) = binding {
drop(identity);
return cleanup_created_directory(
directory,
&actual,
SourceError::Io {
operation: "bind extraction destination handle to path",
path: destination.as_str().to_owned(),
source,
},
);
}
Ok(FreshDestination {
path: actual,
parent: parent_directory,
parent_path: canonical_parent,
parent_identity,
name: name.to_owned(),
directory,
identity,
})
}
fn cleanup_created_directory<T>(
directory: CapabilityDir,
path: &Utf8Path,
original: SourceError,
) -> Result<T, SourceError> {
match remove_created_directory_tree(directory) {
Ok(()) => Err(original),
Err(source) => Err(SourceError::CleanupFailed {
path: path.as_str().to_owned(),
original: original.to_string(),
source,
}),
}
}
#[cfg(windows)]
fn remove_created_directory_tree(directory: CapabilityDir) -> io::Result<()> {
remove_private_directory_tree_handle(directory.into_std_file()).map_err(io::Error::other)
}
#[cfg(not(windows))]
fn remove_created_directory_tree(directory: CapabilityDir) -> io::Result<()> {
directory.remove_open_dir_all()
}
fn capability_directory_identity(directory: &CapabilityDir) -> io::Result<FileIdentityHandle> {
FileIdentityHandle::from_file(directory.try_clone()?.into_std_file())
}
fn ensure_capability_directory_path(
directory: &CapabilityDir,
path: &Utf8Path,
expected: &FileIdentityHandle,
) -> io::Result<()> {
let open = capability_directory_identity(directory)?;
let named = FileIdentityHandle::from_path(path.as_std_path())?;
if &open == expected && &named == expected {
Ok(())
} else {
Err(io::Error::other(
"directory path no longer identifies the capability directory",
))
}
}
#[cfg(not(windows))]
fn ensure_named_capability_directory(
parent: &CapabilityDir,
name: &str,
expected: &FileIdentityHandle,
) -> io::Result<()> {
let named = parent.open_dir(name)?;
let actual = capability_directory_identity(&named)?;
if &actual == expected {
Ok(())
} else {
Err(io::Error::other(
"destination path no longer identifies the operation-created directory",
))
}
}
fn ensure_named_capability_file(
parent: &CapabilityDir,
name: &str,
expected: &FileIdentityHandle,
) -> io::Result<()> {
let metadata = parent.symlink_metadata(name)?;
if metadata.is_symlink() || !metadata.is_file() {
return Err(io::Error::other(
"archive path no longer identifies a regular file",
));
}
let file = parent.open(name)?;
let actual = FileIdentityHandle::from_file(file.into_std())?;
if &actual == expected {
Ok(())
} else {
Err(io::Error::other(
"archive path no longer identifies the operation-created file",
))
}
}
#[cfg(unix)]
fn create_private_capability_directory(parent: &CapabilityDir, name: &str) -> io::Result<()> {
use cap_std::fs::DirBuilderExt as _;
let mut builder = cap_std::fs::DirBuilder::new();
builder.mode(0o700);
parent.create_dir_with(name, &builder)
}
#[cfg(not(unix))]
fn create_private_capability_directory(parent: &CapabilityDir, name: &str) -> io::Result<()> {
parent.create_dir(name)
}
fn extract_verified_entry(
archive: &mut ZipArchive<File>,
index: usize,
expected: &SourceManifestEntry,
destination: &CapabilityDir,
) -> Result<(), SourceError> {
let relative = Utf8Path::new(&expected.path);
let parent = create_safe_parent_directories(destination, relative)?;
let file_name = relative.file_name().ok_or(SourceError::ManifestMismatch)?;
let mut output =
open_new_private_capability_file(&parent, file_name).map_err(|source| SourceError::Io {
operation: "create extracted source file",
path: expected.path.clone(),
source,
})?;
let mut entry = archive.by_index(index).map_err(|error| SourceError::Zip {
operation: "open entry for extraction",
message: error.to_string(),
})?;
if entry.name_raw() != expected.path.as_bytes() {
return Err(SourceError::ManifestMismatch);
}
let mut digest = Sha256::new();
let mut size = 0_u64;
let mut buffer = vec![0_u8; SOURCE_ARCHIVE_BUFFER_SIZE];
loop {
let read = entry
.read(&mut buffer)
.map_err(|error| SourceError::InvalidArchive {
reason: format!(
"ZIP entry {:?} failed integrity verification: {error}",
expected.path
),
})?;
if read == 0 {
break;
}
size = size
.checked_add(read as u64)
.ok_or(SourceError::ManifestMismatch)?;
if size > expected.size {
return Err(SourceError::ManifestMismatch);
}
digest.update(&buffer[..read]);
output
.write_all(&buffer[..read])
.map_err(|source| SourceError::Io {
operation: "write extracted source file",
path: expected.path.clone(),
source,
})?;
}
if size != expected.size || hex_digest(digest.finalize()) != expected.sha256 {
return Err(SourceError::ManifestMismatch);
}
output.sync_all().map_err(|source| SourceError::Io {
operation: "synchronize extracted source file",
path: expected.path.clone(),
source,
})?;
drop(entry);
#[cfg(unix)]
set_extracted_permissions(&output, expected.executable).map_err(|source| SourceError::Io {
operation: "set extracted source permissions",
path: expected.path.clone(),
source,
})?;
Ok(())
}
fn create_safe_parent_directories(
destination: &CapabilityDir,
relative: &Utf8Path,
) -> Result<CapabilityDir, SourceError> {
let mut current = destination.try_clone().map_err(|source| SourceError::Io {
operation: "clone extraction destination handle",
path: "extraction destination".to_owned(),
source,
})?;
let Some(parent) = relative.parent() else {
return Ok(current);
};
for component in parent {
match current.symlink_metadata(component) {
Ok(metadata) if metadata.is_symlink() || !metadata.is_dir() => {
return Err(SourceError::InvalidArchive {
reason: "extraction path encountered a non-directory component".to_owned(),
});
}
Ok(_) => {}
Err(source) if source.kind() == io::ErrorKind::NotFound => {
create_private_capability_directory(¤t, component).map_err(|source| {
SourceError::Io {
operation: "create extracted source directory",
path: display_relative_path(parent),
source,
}
})?;
}
Err(source) => {
return Err(SourceError::Io {
operation: "inspect extracted source directory",
path: display_relative_path(parent),
source,
});
}
}
let next = current
.open_dir(component)
.map_err(|source| SourceError::Io {
operation: "open extracted source directory",
path: display_relative_path(parent),
source,
})?;
if current
.symlink_metadata(component)
.map_err(|source| SourceError::Io {
operation: "reinspect extracted source directory",
path: display_relative_path(parent),
source,
})?
.is_symlink()
{
return Err(SourceError::InvalidArchive {
reason: "extraction path encountered a symbolic link".to_owned(),
});
}
current = next;
}
Ok(current)
}
#[cfg(unix)]
fn set_extracted_permissions(file: &cap_std::fs::File, executable: bool) -> io::Result<()> {
use cap_std::fs::PermissionsExt as _;
let mode = if executable { 0o755 } else { 0o644 };
file.set_permissions(cap_std::fs::Permissions::from_mode(mode))
}
fn open_new_private_capability_file(
parent: &CapabilityDir,
name: &str,
) -> io::Result<cap_std::fs::File> {
let mut options = CapabilityOpenOptions::new();
options.read(true).write(true).create_new(true);
#[cfg(unix)]
{
use cap_std::fs::OpenOptionsExt as _;
options.mode(0o600);
}
parent.open_with(name, &options)
}
#[allow(clippy::too_many_lines)]
fn scan_capability_paths(
root: &CapabilityDir,
limits: SourceLimits,
) -> Result<ScanResult, SourceError> {
let root = root.try_clone().map_err(|source| SourceError::Io {
operation: "clone verified bundle root handle",
path: "bundle root".to_owned(),
source,
})?;
let mut stack = vec![(root, Utf8PathBuf::from("."))];
let mut directories = BTreeSet::new();
let mut entries = BTreeMap::new();
let mut collisions = BTreeMap::new();
let mut total_size = 0_u64;
let mut traversal = TraversalBudget::new(MAX_SOURCE_TRAVERSAL_ENTRIES)?;
while let Some((directory, relative)) = stack.pop() {
let display = display_relative_path(&relative);
let iterator = directory.entries().map_err(|source| SourceError::Io {
operation: "read verified bundle directory",
path: display.clone(),
source,
})?;
let mut children = Vec::new();
for child in iterator {
traversal.consume(1, Some(&display))?;
let child = child.map_err(|source| SourceError::Io {
operation: "read verified bundle entry",
path: display.clone(),
source,
})?;
let name = child
.file_name()
.into_string()
.map_err(|_| SourceError::NonUtf8Path)?;
children.push(name);
}
children.sort();
for name in children.into_iter().rev() {
let child_relative = if relative.as_str() == "." {
Utf8PathBuf::from(&name)
} else {
relative.join(&name)
};
let child_display = display_relative_path(&child_relative);
validate_portable_path_str(&child_display, false).map_err(|reason| {
SourceError::NonPortablePath {
path: child_display.clone(),
reason,
}
})?;
reject_sensitive(&child_display)?;
check_depth(&child_display, limits.max_depth)?;
let metadata = directory
.symlink_metadata(&name)
.map_err(|source| SourceError::Io {
operation: "inspect verified bundle entry",
path: child_display.clone(),
source,
})?;
if metadata.is_symlink() {
return Err(SourceError::Symlink {
path: child_display,
});
}
register_collision_key(&child_display, &mut collisions)?;
if metadata.is_dir() {
directories.insert(child_display);
let child = directory
.open_dir(&name)
.map_err(|source| SourceError::Io {
operation: "open verified bundle directory",
path: display_relative_path(&child_relative),
source,
})?;
stack.push((child, child_relative));
continue;
}
if !metadata.is_file() {
return Err(SourceError::UnsupportedFileType {
path: child_display,
});
}
#[cfg(not(windows))]
reject_capability_hard_link(&metadata, &child_display)?;
if entries.len() >= limits.max_file_count {
return Err(limit_error(
SourceLimitKind::FileCount,
Some(child_display),
limits.max_file_count as u64,
(entries.len() + 1) as u64,
));
}
if metadata.len() > limits.max_file_size {
return Err(limit_error(
SourceLimitKind::FileSize,
Some(child_display),
limits.max_file_size,
metadata.len(),
));
}
let next_total = total_size.checked_add(metadata.len()).ok_or_else(|| {
limit_error(
SourceLimitKind::TotalSize,
Some(child_display.clone()),
limits.max_total_size,
u64::MAX,
)
})?;
if next_total > limits.max_total_size {
return Err(limit_error(
SourceLimitKind::TotalSize,
Some(child_display.clone()),
limits.max_total_size,
next_total,
));
}
let contents = read_bounded_capability_file(
&directory,
&name,
&child_display,
limits.max_file_size,
&metadata,
)?;
reject_sensitive_contents(&child_display, &contents)?;
let executable = capability_executable_bit(&metadata);
total_size = next_total;
entries.insert(
child_display.clone(),
SourceManifestEntry {
path: child_display,
size: metadata.len(),
sha256: hex_digest(Sha256::digest(contents)),
executable,
},
);
}
}
Ok(ScanResult {
entries: entries.into_values().collect(),
directories: directories.into_iter().collect(),
excluded_sensitive_paths: Vec::new(),
})
}
fn read_bounded_capability_file(
directory: &CapabilityDir,
name: &str,
display: &str,
maximum: u64,
initial: &cap_std::fs::Metadata,
) -> Result<Vec<u8>, SourceError> {
let file = directory.open(name).map_err(|source| SourceError::Io {
operation: "open verified bundle file",
path: display.to_owned(),
source,
})?;
let opened = file.metadata().map_err(|source| SourceError::Io {
operation: "inspect open verified bundle file",
path: display.to_owned(),
source,
})?;
reject_open_capability_hard_link(&file, display)?;
if !opened.is_file() || !same_capability_file_identity(initial, &opened) {
return Err(SourceError::ChangedDuringRead {
path: display.to_owned(),
});
}
let initial_modified = initial.modified().ok();
let mut reader = file.take(maximum.saturating_add(1));
let mut contents =
Vec::with_capacity(usize::try_from(initial.len().min(64 * 1024)).unwrap_or(64 * 1024));
reader
.read_to_end(&mut contents)
.map_err(|source| SourceError::Io {
operation: "read verified bundle file",
path: display.to_owned(),
source,
})?;
if contents.len() as u64 > maximum {
return Err(limit_error(
SourceLimitKind::FileSize,
Some(display.to_owned()),
maximum,
contents.len() as u64,
));
}
let opened_final = reader
.get_ref()
.metadata()
.map_err(|source| SourceError::Io {
operation: "reinspect open verified bundle file",
path: display.to_owned(),
source,
})?;
let path_final = directory
.symlink_metadata(name)
.map_err(|source| SourceError::Io {
operation: "reinspect verified bundle file",
path: display.to_owned(),
source,
})?;
reject_open_capability_hard_link(reader.get_ref(), display)?;
#[cfg(not(windows))]
reject_capability_hard_link(&path_final, display)?;
if path_final.is_symlink()
|| !path_final.is_file()
|| !same_capability_file_identity(&opened, &opened_final)
|| !same_capability_file_identity(&opened, &path_final)
|| opened_final.len() != initial.len()
|| path_final.len() != initial.len()
|| contents.len() as u64 != initial.len()
|| opened_final.modified().ok() != initial_modified
|| path_final.modified().ok() != initial_modified
|| capability_executable_bit(&opened_final) != capability_executable_bit(initial)
|| capability_executable_bit(&path_final) != capability_executable_bit(initial)
{
return Err(SourceError::ChangedDuringRead {
path: display.to_owned(),
});
}
Ok(contents)
}
fn reject_link_count(links: u64, path: &str) -> Result<(), SourceError> {
match links {
1 => Ok(()),
2.. => Err(SourceError::HardLink {
path: path.to_owned(),
links,
}),
_ => Err(SourceError::HardLinkInspectionUnavailable {
path: path.to_owned(),
}),
}
}
#[cfg(windows)]
fn reject_open_capability_hard_link(
file: &cap_std::fs::File,
path: &str,
) -> Result<(), SourceError> {
let links = regular_file_link_count(file.as_handle()).map_err(|_| {
SourceError::HardLinkInspectionUnavailable {
path: path.to_owned(),
}
})?;
reject_link_count(u64::from(links), path)
}
#[cfg(not(windows))]
fn reject_open_capability_hard_link(
file: &cap_std::fs::File,
path: &str,
) -> Result<(), SourceError> {
let metadata = file.metadata().map_err(|source| SourceError::Io {
operation: "inspect open file hard-link count",
path: path.to_owned(),
source,
})?;
reject_capability_hard_link(&metadata, path)
}
#[cfg(unix)]
fn reject_capability_hard_link(
metadata: &cap_std::fs::Metadata,
path: &str,
) -> Result<(), SourceError> {
use cap_std::fs::MetadataExt as _;
reject_link_count(metadata.nlink(), path)
}
#[cfg(not(any(unix, windows)))]
fn reject_capability_hard_link(
_metadata: &cap_std::fs::Metadata,
path: &str,
) -> Result<(), SourceError> {
Err(SourceError::HardLinkInspectionUnavailable {
path: path.to_owned(),
})
}
#[cfg(unix)]
fn same_capability_file_identity(
first: &cap_std::fs::Metadata,
second: &cap_std::fs::Metadata,
) -> bool {
use cap_std::fs::MetadataExt as _;
first.dev() == second.dev() && first.ino() == second.ino()
}
#[cfg(windows)]
fn same_capability_file_identity(
first: &cap_std::fs::Metadata,
second: &cap_std::fs::Metadata,
) -> bool {
use cap_std::fs::MetadataExt as _;
first.file_attributes() == second.file_attributes()
&& first.creation_time() == second.creation_time()
&& first.last_write_time() == second.last_write_time()
&& first.file_size() == second.file_size()
}
#[cfg(not(any(unix, windows)))]
fn same_capability_file_identity(
_first: &cap_std::fs::Metadata,
_second: &cap_std::fs::Metadata,
) -> bool {
false
}
#[cfg(unix)]
fn capability_executable_bit(metadata: &cap_std::fs::Metadata) -> bool {
use cap_std::fs::MetadataExt as _;
metadata.mode() & 0o111 != 0
}
#[cfg(not(unix))]
fn capability_executable_bit(_metadata: &cap_std::fs::Metadata) -> bool {
false
}
#[cfg(unix)]
fn normalize_unobservable_executable_modes(
_actual: &mut [SourceManifestEntry],
_expected: &[SourceManifestEntry],
) {
}
#[cfg(not(unix))]
fn normalize_unobservable_executable_modes(
actual: &mut [SourceManifestEntry],
expected: &[SourceManifestEntry],
) {
for entry in actual {
if let Ok(index) = expected.binary_search_by(|candidate| candidate.path.cmp(&entry.path)) {
entry.executable = expected[index].executable;
}
}
}
#[derive(Debug)]
struct ResolvedRoots {
workspace: Utf8PathBuf,
project: Utf8PathBuf,
project_relative: Utf8PathBuf,
}
impl ResolvedRoots {
fn resolve(request: &SourceBundleRequest) -> Result<Self, SourceError> {
reject_root_symlink(&request.workspace_root, "workspace root")?;
reject_root_symlink(&request.project_root, "project root")?;
let workspace = canonical_directory(&request.workspace_root, "workspace root")?;
let project = canonical_directory(&request.project_root, "project root")?;
let relative = project
.strip_prefix(&workspace)
.map_err(|_| SourceError::ProjectOutsideWorkspace)?;
let project_relative = if relative.as_str().is_empty() {
Utf8PathBuf::from(".")
} else {
relative.to_owned()
};
let display = display_relative_path(&project_relative);
validate_portable_path_str(&display, true).map_err(|reason| {
SourceError::NonPortablePath {
path: display.clone(),
reason,
}
})?;
if display != "." {
reject_sensitive(&display)?;
}
Ok(Self {
workspace,
project,
project_relative,
})
}
}
#[derive(Debug)]
struct ScanResult {
entries: Vec<SourceManifestEntry>,
directories: Vec<String>,
excluded_sensitive_paths: Vec<String>,
}
#[derive(Debug)]
struct TraversalBudget {
maximum: usize,
visited: usize,
}
impl TraversalBudget {
fn new(maximum: usize) -> Result<Self, SourceError> {
if maximum > MAX_SOURCE_TRAVERSAL_ENTRIES {
return Err(SourceError::UnsupportedTraversalLimit {
requested: maximum,
maximum: MAX_SOURCE_TRAVERSAL_ENTRIES,
});
}
Ok(Self {
maximum,
visited: 0,
})
}
fn consume(&mut self, count: usize, path: Option<&str>) -> Result<(), SourceError> {
let actual = self.visited.saturating_add(count);
if actual > self.maximum {
return Err(limit_error(
SourceLimitKind::TraversalEntryCount,
path.map(str::to_owned),
self.maximum as u64,
actual as u64,
));
}
self.visited = actual;
Ok(())
}
}
fn consume_manifest_traversal(total: &mut u64, path: &str) -> Result<(), SourceError> {
*total = total.saturating_add(path.split('/').count() as u64);
if *total > MAX_SOURCE_TRAVERSAL_ENTRIES as u64 {
Err(limit_error(
SourceLimitKind::TraversalEntryCount,
Some(path.to_owned()),
MAX_SOURCE_TRAVERSAL_ENTRIES as u64,
*total,
))
} else {
Ok(())
}
}
fn consume_planned_manifest_traversal(
traversal: &mut TraversalBudget,
entries: &[SourceManifestEntry],
) -> Result<(), SourceError> {
for entry in entries {
traversal.consume(entry.path.split('/').count(), Some(&entry.path))?;
}
Ok(())
}
#[derive(Clone, Debug)]
struct IgnoreRule {
base: String,
path: String,
}
fn validate_executable_modes(
request: &SourceBundleRequest,
traversal: &mut TraversalBudget,
) -> Result<BTreeMap<String, bool>, SourceError> {
let mut modes = BTreeMap::new();
let mut collisions = BTreeMap::new();
for (path, executable) in request.executable_modes() {
let display = path.as_str().to_owned();
traversal.consume(path.components().count(), Some(&display))?;
validate_portable_path_str(&display, false).map_err(|reason| {
SourceError::NonPortablePath {
path: display.clone(),
reason,
}
})?;
reject_sensitive(&display)?;
check_depth(&display, request.limits.max_depth)?;
register_path_components(&display, &mut collisions)?;
if modes.insert(display.clone(), *executable).is_some() {
return Err(SourceError::DuplicateExecutableMode { path: display });
}
}
Ok(modes)
}
fn validate_workspace_exclusions(
request: &SourceBundleRequest,
traversal: &mut TraversalBudget,
) -> Result<Vec<String>, SourceError> {
let mut requested = BTreeSet::new();
let mut collisions = BTreeMap::new();
for path in request.workspace_exclusions() {
let display = path.as_str().to_owned();
validate_portable_path_str(&display, false).map_err(|reason| {
SourceError::NonPortablePath {
path: display.clone(),
reason,
}
})?;
check_depth(&display, request.limits.max_depth)?;
register_path_components(&display, &mut collisions)?;
traversal.consume(display.split('/').count(), Some(&display))?;
requested.insert(display);
}
let mut exclusions: Vec<String> = Vec::new();
for display in requested {
if exclusions
.iter()
.any(|ancestor| path_is_within(&display, ancestor))
{
continue;
}
exclusions.push(display);
}
Ok(exclusions)
}
fn apply_executable_modes(
entries: &mut [SourceManifestEntry],
modes: &BTreeMap<String, bool>,
) -> Result<(), SourceError> {
for (path, portable) in modes {
let index = entries
.binary_search_by(|entry| entry.path.as_str().cmp(path))
.map_err(|_| SourceError::ExecutableModePathNotSelected { path: path.clone() })?;
entries[index].executable =
resolved_executable_mode(entries[index].executable, Some(*portable));
}
Ok(())
}
#[cfg(unix)]
fn resolved_executable_mode(filesystem: bool, portable: Option<bool>) -> bool {
filesystem || portable.unwrap_or(false)
}
#[cfg(not(unix))]
fn resolved_executable_mode(_filesystem: bool, portable: Option<bool>) -> bool {
portable.unwrap_or(false)
}
fn check_supported_depth(limits: SourceLimits) -> Result<(), SourceError> {
if limits.max_depth > MAX_SUPPORTED_DEPTH {
Err(SourceError::UnsupportedDepthLimit {
requested: limits.max_depth,
maximum: MAX_SUPPORTED_DEPTH,
})
} else {
Ok(())
}
}
fn canonical_directory(path: &Utf8Path, root: &'static str) -> Result<Utf8PathBuf, SourceError> {
let canonical = fs::canonicalize(path.as_std_path()).map_err(|source| SourceError::Io {
operation: "canonicalize",
path: root.to_owned(),
source,
})?;
let canonical = Utf8PathBuf::from_path_buf(canonical).map_err(|_| SourceError::NonUtf8Path)?;
let metadata = fs::metadata(canonical.as_std_path()).map_err(|source| SourceError::Io {
operation: "inspect",
path: root.to_owned(),
source,
})?;
if !metadata.is_dir() {
return Err(SourceError::InvalidRoot {
root,
reason: "not a directory",
});
}
Ok(canonical)
}
fn reject_root_symlink(path: &Utf8Path, root: &'static str) -> Result<(), SourceError> {
match fs::symlink_metadata(path.as_std_path()) {
Ok(metadata) if metadata.file_type().is_symlink() => Err(SourceError::InvalidRoot {
root,
reason: "root is a symbolic link",
}),
Ok(metadata) => reject_reparse_point(&metadata, root),
Err(source) => Err(SourceError::Io {
operation: "inspect",
path: root.to_owned(),
source,
}),
}
}
fn collect_include_roots(
request: &SourceBundleRequest,
roots: &ResolvedRoots,
workspace_exclusions: &[String],
traversal: &mut TraversalBudget,
) -> Result<Vec<Utf8PathBuf>, SourceError> {
let mut include_roots = BTreeSet::new();
include_roots.insert(roots.project_relative.clone());
for path in WORKSPACE_BUILD_INPUTS {
let relative = Utf8PathBuf::from(path);
if roots
.workspace
.join(&relative)
.try_exists()
.map_err(|source| SourceError::Io {
operation: "inspect",
path: (*path).to_owned(),
source,
})?
{
include_roots.insert(relative);
}
}
for input in &request.workspace_inputs {
let display = input.as_str().to_owned();
traversal.consume(input.components().count(), Some(&display))?;
validate_portable_path_str(&display, false).map_err(|reason| {
SourceError::NonPortablePath {
path: display.clone(),
reason,
}
})?;
reject_sensitive(&display)?;
if is_workspace_excluded(&display, workspace_exclusions) {
continue;
}
ensure_no_symlink_components(&roots.workspace, input)?;
if !roots
.workspace
.join(input)
.try_exists()
.map_err(|source| SourceError::Io {
operation: "inspect",
path: display.clone(),
source,
})?
{
return Err(SourceError::MissingInput { path: display });
}
include_roots.insert(input.clone());
}
Ok(include_roots.into_iter().collect())
}
fn ensure_no_symlink_components(root: &Utf8Path, relative: &Utf8Path) -> Result<(), SourceError> {
let mut current = root.to_owned();
let mut display = Utf8PathBuf::new();
for component in relative {
current.push(component);
display.push(component);
match fs::symlink_metadata(current.as_std_path()) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err(SourceError::Symlink {
path: display.as_str().to_owned(),
});
}
Ok(metadata) => reject_reparse_point(&metadata, display.as_str())?,
Err(source) if source.kind() == io::ErrorKind::NotFound => {
return Err(SourceError::MissingInput {
path: relative.as_str().to_owned(),
});
}
Err(source) => {
return Err(SourceError::Io {
operation: "inspect",
path: display.as_str().to_owned(),
source,
});
}
}
}
Ok(())
}
fn load_ignore_rules(
roots: &ResolvedRoots,
workspace_exclusions: &[String],
limits: SourceLimits,
) -> Result<Vec<IgnoreRule>, SourceError> {
let mut locations = vec![(Utf8PathBuf::from("."), roots.workspace.join(".ferryignore"))];
if roots.project != roots.workspace {
locations.push((
roots.project_relative.clone(),
roots.project.join(".ferryignore"),
));
}
let mut rules = Vec::new();
for (base, source_path) in locations {
let display = relative_ignore_path(&base);
if is_workspace_excluded(&display, workspace_exclusions) {
continue;
}
let metadata = match fs::symlink_metadata(source_path.as_std_path()) {
Ok(metadata) => metadata,
Err(source) if source.kind() == io::ErrorKind::NotFound => continue,
Err(source) => {
return Err(SourceError::Io {
operation: "inspect",
path: display,
source,
});
}
};
if metadata.file_type().is_symlink() {
return Err(SourceError::Symlink { path: display });
}
reject_reparse_point(&metadata, &display)?;
if !metadata.is_file() {
return Err(SourceError::UnsupportedFileType { path: display });
}
#[cfg(not(windows))]
reject_hard_link(&metadata, &display)?;
if metadata.len() > limits.max_ignore_file_size {
return Err(limit_error(
SourceLimitKind::IgnoreFileSize,
Some(display),
limits.max_ignore_file_size,
metadata.len(),
));
}
let bytes = read_bounded_file(
&source_path,
&display,
limits.max_ignore_file_size,
&metadata,
SourceLimitKind::IgnoreFileSize,
)?;
let text = std::str::from_utf8(&bytes).map_err(|_| SourceError::InvalidIgnoreRule {
file: display.clone(),
line: 1,
reason: IgnoreRuleReason::NonPortable(PortablePathReason::InvalidCharacter),
})?;
parse_ignore_file(text, &display, &base, limits, &mut rules)?;
}
Ok(rules)
}
fn parse_ignore_file(
text: &str,
file: &str,
base: &Utf8Path,
limits: SourceLimits,
rules: &mut Vec<IgnoreRule>,
) -> Result<(), SourceError> {
for (index, raw_line) in text.lines().enumerate() {
let line_number = index + 1;
let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
if line.is_empty() || line.starts_with('#') {
continue;
}
let invalid = |reason| SourceError::InvalidIgnoreRule {
file: file.to_owned(),
line: line_number,
reason,
};
if line.len() > MAX_IGNORE_PATTERN_BYTES {
return Err(invalid(IgnoreRuleReason::PatternTooLong));
}
if line.trim_matches([' ', '\t']) != line {
return Err(invalid(IgnoreRuleReason::EdgeWhitespace));
}
if line.starts_with('!') {
return Err(invalid(IgnoreRuleReason::Negation));
}
if line.contains(['*', '?', '[', ']']) {
return Err(invalid(IgnoreRuleReason::Glob));
}
let pattern = line.strip_suffix('/').unwrap_or(line);
if pattern.is_empty() {
return Err(invalid(IgnoreRuleReason::EmptyDirectoryPattern));
}
validate_portable_path_str(pattern, false)
.map_err(|reason| invalid(IgnoreRuleReason::NonPortable(reason)))?;
if rules.len() >= limits.max_ignore_rules {
return Err(limit_error(
SourceLimitKind::IgnoreRuleCount,
Some(file.to_owned()),
limits.max_ignore_rules as u64,
(rules.len() + 1) as u64,
));
}
rules.push(IgnoreRule {
base: display_relative_path(base),
path: pattern.to_owned(),
});
}
Ok(())
}
#[allow(clippy::too_many_lines)]
fn scan_paths(
root: &Utf8Path,
include_roots: Vec<Utf8PathBuf>,
workspace_exclusions: &[String],
ignore_rules: &[IgnoreRule],
limits: SourceLimits,
traversal: &mut TraversalBudget,
) -> Result<ScanResult, SourceError> {
let mut stack: Vec<_> = include_roots.into_iter().rev().collect();
let mut visited_directories = BTreeSet::new();
let mut directories = BTreeSet::new();
let mut entries = BTreeMap::new();
let mut collisions = BTreeMap::new();
let mut excluded_sensitive_paths = BTreeSet::new();
let mut total_size = 0_u64;
while let Some(relative) = stack.pop() {
let display = display_relative_path(&relative);
if display != "." {
validate_portable_path_str(&display, false).map_err(|reason| {
SourceError::NonPortablePath {
path: display.clone(),
reason,
}
})?;
check_depth(&display, limits.max_depth)?;
if is_sensitive_path(&display) {
excluded_sensitive_paths.insert(display);
continue;
}
if is_workspace_excluded(&display, workspace_exclusions) {
continue;
}
if is_ignored(&display, ignore_rules) {
continue;
}
}
let source_path = root.join(&relative);
let metadata = match fs::symlink_metadata(source_path.as_std_path()) {
Ok(metadata) => metadata,
Err(source) if source.kind() == io::ErrorKind::NotFound => {
return Err(SourceError::MissingInput { path: display });
}
Err(source) => {
return Err(SourceError::Io {
operation: "inspect",
path: display,
source,
});
}
};
if metadata.file_type().is_symlink() {
return Err(SourceError::Symlink { path: display });
}
reject_reparse_point(&metadata, &display)?;
if metadata.is_dir() {
if display != "." {
register_collision_key(&display, &mut collisions)?;
directories.insert(display.clone());
}
if !visited_directories.insert(display.clone()) {
continue;
}
let mut children = read_directory_children(&source_path, &display, traversal)?;
children.sort();
for child in children.into_iter().rev() {
let child_relative = if relative.as_str() == "." {
Utf8PathBuf::from(child)
} else {
relative.join(child)
};
stack.push(child_relative);
}
continue;
}
if !metadata.is_file() {
return Err(SourceError::UnsupportedFileType { path: display });
}
#[cfg(not(windows))]
reject_hard_link(&metadata, &display)?;
register_collision_key(&display, &mut collisions)?;
if entries.contains_key(&display) {
continue;
}
if entries.len() >= limits.max_file_count {
return Err(limit_error(
SourceLimitKind::FileCount,
Some(display),
limits.max_file_count as u64,
(entries.len() + 1) as u64,
));
}
if metadata.len() > limits.max_file_size {
return Err(limit_error(
SourceLimitKind::FileSize,
Some(display),
limits.max_file_size,
metadata.len(),
));
}
let next_total = total_size.checked_add(metadata.len()).ok_or_else(|| {
limit_error(
SourceLimitKind::TotalSize,
Some(display.clone()),
limits.max_total_size,
u64::MAX,
)
})?;
if next_total > limits.max_total_size {
return Err(limit_error(
SourceLimitKind::TotalSize,
Some(display.clone()),
limits.max_total_size,
next_total,
));
}
let contents = read_bounded_file(
&source_path,
&display,
limits.max_file_size,
&metadata,
SourceLimitKind::FileSize,
)?;
reject_sensitive_contents(&display, &contents)?;
let digest = hex_digest(Sha256::digest(&contents));
let executable = executable_bit(&metadata);
total_size = next_total;
entries.insert(
display.clone(),
SourceManifestEntry {
path: display,
size: metadata.len(),
sha256: digest,
executable,
},
);
}
Ok(ScanResult {
entries: entries.into_values().collect(),
directories: directories.into_iter().collect(),
excluded_sensitive_paths: excluded_sensitive_paths.into_iter().collect(),
})
}
fn read_directory_children(
directory: &Utf8Path,
display: &str,
traversal: &mut TraversalBudget,
) -> Result<Vec<String>, SourceError> {
let iterator = fs::read_dir(directory.as_std_path()).map_err(|source| SourceError::Io {
operation: "read directory",
path: display.to_owned(),
source,
})?;
let mut children = Vec::new();
for entry in iterator {
traversal.consume(1, Some(display))?;
let entry = entry.map_err(|source| SourceError::Io {
operation: "read directory entry",
path: display.to_owned(),
source,
})?;
let name = entry
.file_name()
.into_string()
.map_err(|_| SourceError::NonUtf8Path)?;
children.push(name);
}
Ok(children)
}
fn read_bounded_file(
path: &Utf8Path,
display: &str,
maximum: u64,
initial_metadata: &Metadata,
limit_kind: SourceLimitKind,
) -> Result<Vec<u8>, SourceError> {
reject_reparse_point(initial_metadata, display)?;
let file = open_read_stable(path).map_err(|source| SourceError::Io {
operation: "open",
path: display.to_owned(),
source,
})?;
let opened_metadata = file.metadata().map_err(|source| SourceError::Io {
operation: "inspect open file",
path: display.to_owned(),
source,
})?;
reject_open_hard_link(&file, display)?;
#[cfg(windows)]
reject_named_streams(&file, display)?;
if !opened_metadata.is_file() || !same_file_identity(initial_metadata, &opened_metadata) {
return Err(SourceError::ChangedDuringRead {
path: display.to_owned(),
});
}
let initial_size = initial_metadata.len();
let initial_modified = initial_metadata.modified().ok();
let mut reader = file.take(maximum.saturating_add(1));
let initial_capacity = usize::try_from(initial_size.min(64 * 1024)).unwrap_or(64 * 1024);
let mut contents = Vec::with_capacity(initial_capacity);
reader
.read_to_end(&mut contents)
.map_err(|source| SourceError::Io {
operation: "read",
path: display.to_owned(),
source,
})?;
let actual_size = contents.len() as u64;
if actual_size > maximum {
return Err(limit_error(
limit_kind,
Some(display.to_owned()),
maximum,
actual_size,
));
}
let open_final_metadata = reader
.get_ref()
.metadata()
.map_err(|source| SourceError::Io {
operation: "reinspect open file",
path: display.to_owned(),
source,
})?;
let path_final_metadata =
fs::symlink_metadata(path.as_std_path()).map_err(|source| SourceError::Io {
operation: "reinspect",
path: display.to_owned(),
source,
})?;
if path_final_metadata.file_type().is_symlink() {
return Err(SourceError::ChangedDuringRead {
path: display.to_owned(),
});
}
reject_reparse_point(&open_final_metadata, display)?;
reject_reparse_point(&path_final_metadata, display)?;
reject_open_hard_link(reader.get_ref(), display)?;
#[cfg(windows)]
reject_named_streams(reader.get_ref(), display)?;
#[cfg(not(windows))]
{
reject_hard_link(&open_final_metadata, display)?;
reject_hard_link(&path_final_metadata, display)?;
}
let stable = open_final_metadata.is_file()
&& path_final_metadata.is_file()
&& same_file_identity(&opened_metadata, &open_final_metadata)
&& same_file_identity(&opened_metadata, &path_final_metadata)
&& open_final_metadata.len() == initial_size
&& path_final_metadata.len() == initial_size
&& actual_size == initial_size
&& open_final_metadata.modified().ok() == initial_modified
&& path_final_metadata.modified().ok() == initial_modified
&& executable_bit(&open_final_metadata) == executable_bit(initial_metadata)
&& executable_bit(&path_final_metadata) == executable_bit(initial_metadata);
if !stable {
return Err(SourceError::ChangedDuringRead {
path: display.to_owned(),
});
}
Ok(contents)
}
#[cfg(windows)]
fn reject_open_hard_link(file: &File, path: &str) -> Result<(), SourceError> {
let links = regular_file_link_count(file.as_handle()).map_err(|_| {
SourceError::HardLinkInspectionUnavailable {
path: path.to_owned(),
}
})?;
reject_link_count(u64::from(links), path)
}
#[cfg(windows)]
fn reject_named_streams(file: &File, path: &str) -> Result<(), SourceError> {
verify_regular_file_has_no_named_streams(file).map_err(|error| match error.kind() {
RegularFileStreamErrorKind::NamedStream => SourceError::AlternateDataStream {
path: path.to_owned(),
},
_ => SourceError::StreamInspectionUnavailable {
path: path.to_owned(),
},
})
}
#[cfg(not(windows))]
fn reject_open_hard_link(file: &File, path: &str) -> Result<(), SourceError> {
let metadata = file.metadata().map_err(|source| SourceError::Io {
operation: "inspect open file hard-link count",
path: path.to_owned(),
source,
})?;
reject_hard_link(&metadata, path)
}
#[cfg(unix)]
fn reject_hard_link(metadata: &Metadata, path: &str) -> Result<(), SourceError> {
use std::os::unix::fs::MetadataExt;
reject_link_count(metadata.nlink(), path)
}
#[cfg(not(any(unix, windows)))]
fn reject_hard_link(_metadata: &Metadata, path: &str) -> Result<(), SourceError> {
Err(SourceError::HardLinkInspectionUnavailable {
path: path.to_owned(),
})
}
#[cfg(unix)]
fn same_file_identity(first: &Metadata, second: &Metadata) -> bool {
use std::os::unix::fs::MetadataExt;
first.dev() == second.dev() && first.ino() == second.ino()
}
#[cfg(windows)]
fn same_file_identity(first: &Metadata, second: &Metadata) -> bool {
use std::os::windows::fs::MetadataExt;
first.file_attributes() == second.file_attributes()
&& first.creation_time() == second.creation_time()
&& first.last_write_time() == second.last_write_time()
&& first.file_size() == second.file_size()
}
#[cfg(not(any(unix, windows)))]
fn same_file_identity(_first: &Metadata, _second: &Metadata) -> bool {
false
}
#[cfg(unix)]
fn executable_bit(metadata: &Metadata) -> bool {
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
fn executable_bit(_metadata: &Metadata) -> bool {
false
}
fn build_manifest(project_path: String, entries: Vec<SourceManifestEntry>) -> SourceManifest {
let total_size = entries.iter().map(|entry| entry.size).sum();
let sha256 = manifest_digest(&project_path, &entries, total_size);
SourceManifest {
schema_version: MANIFEST_SCHEMA_VERSION,
project_path,
entries,
total_size,
sha256,
}
}
fn manifest_digest(project_path: &str, entries: &[SourceManifestEntry], total_size: u64) -> String {
let mut digest = Sha256::new();
digest.update(MANIFEST_DOMAIN);
digest_string(&mut digest, project_path);
digest.update((entries.len() as u64).to_be_bytes());
for entry in entries {
digest_string(&mut digest, &entry.path);
digest.update(entry.size.to_be_bytes());
digest_string(&mut digest, &entry.sha256);
digest.update([u8::from(entry.executable)]);
}
digest.update(total_size.to_be_bytes());
hex_digest(digest.finalize())
}
fn digest_string(digest: &mut Sha256, value: &str) {
digest.update((value.len() as u64).to_be_bytes());
digest.update(value.as_bytes());
}
fn hex_digest(bytes: impl AsRef<[u8]>) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let bytes = bytes.as_ref();
let mut encoded = String::with_capacity(bytes.len() * 2);
for byte in bytes {
encoded.push(char::from(HEX[usize::from(byte >> 4)]));
encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
encoded
}
fn validate_sha256(value: &str) -> Result<(), SourceError> {
if value.len() == 64
&& value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
Ok(())
} else {
Err(SourceError::InvalidManifest {
reason: "SHA-256 digest is not 64 lowercase hexadecimal characters",
})
}
}
fn validate_portable_path_str(path: &str, allow_dot: bool) -> Result<(), PortablePathReason> {
if allow_dot && path == "." {
return Ok(());
}
if path.is_empty() {
return Err(PortablePathReason::Empty);
}
if path.len() > MAX_PORTABLE_PATH_BYTES {
return Err(PortablePathReason::PathTooLong);
}
if path.starts_with("//") || path.starts_with("\\\\") {
return Err(PortablePathReason::UncPrefix);
}
let bytes = path.as_bytes();
if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
return Err(PortablePathReason::DrivePrefix);
}
if Path::new(path).is_absolute() || path.starts_with('/') {
return Err(PortablePathReason::Absolute);
}
if path.contains('\\') {
return Err(PortablePathReason::Backslash);
}
for segment in path.split('/') {
if segment.is_empty() {
return Err(PortablePathReason::EmptyComponent);
}
if segment == "." || segment == ".." {
return Err(PortablePathReason::DotComponent);
}
if segment.len() > MAX_PORTABLE_SEGMENT_BYTES {
return Err(PortablePathReason::SegmentTooLong);
}
if segment.ends_with(['.', ' ']) {
return Err(PortablePathReason::TrailingDotOrSpace);
}
if !segment.nfc().eq(segment.chars()) {
return Err(PortablePathReason::NonNfc);
}
if segment.chars().any(|character| {
character.is_control()
|| matches!(character, '<' | '>' | ':' | '"' | '|' | '?' | '*')
|| is_unsafe_unicode_format(character)
}) {
return Err(PortablePathReason::InvalidCharacter);
}
let stem = segment.split('.').next().unwrap_or(segment);
if is_windows_reserved_name(stem) {
return Err(PortablePathReason::ReservedName);
}
}
Ok(())
}
fn is_windows_reserved_name(stem: &str) -> bool {
let upper = stem.to_ascii_uppercase();
matches!(
upper.as_str(),
"CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$"
) || upper.strip_prefix("COM").is_some_and(|suffix| {
matches!(
suffix,
"1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³"
)
}) || upper.strip_prefix("LPT").is_some_and(|suffix| {
matches!(
suffix,
"1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³"
)
})
}
fn reject_sensitive(path: &str) -> Result<(), SourceError> {
if is_sensitive_path(path) {
Err(SourceError::SensitivePath {
path: path.to_owned(),
})
} else {
Ok(())
}
}
fn reject_sensitive_contents(path: &str, contents: &[u8]) -> Result<(), SourceError> {
if contains_private_key(contents) || contains_access_token(contents) {
Err(SourceError::SensitiveContent {
path: path.to_owned(),
})
} else {
Ok(())
}
}
fn contains_private_key(contents: &[u8]) -> bool {
if contains_putty_private_key(contents) {
return true;
}
let begin = b"-----BEGIN ";
let key_suffix = b"PRIVATE KEY-----";
let end = b"-----END ";
let mut offset = 0;
while let Some(relative_begin) = find_bytes(&contents[offset..], begin) {
let header_start = offset + relative_begin + begin.len();
let header_window_end = header_start.saturating_add(64).min(contents.len());
let Some(relative_suffix) =
find_bytes(&contents[header_start..header_window_end], key_suffix)
else {
offset = header_start;
continue;
};
let body_start = header_start + relative_suffix + key_suffix.len();
let Some(relative_end) = find_bytes(&contents[body_start..], end) else {
offset = body_start;
continue;
};
let body = &contents[body_start..body_start + relative_end];
if body
.iter()
.filter(|byte| byte.is_ascii_alphanumeric())
.count()
>= 128
{
return true;
}
offset = body_start;
}
false
}
fn contains_putty_private_key(contents: &[u8]) -> bool {
let mut lines = contents
.split(|byte| matches!(*byte, b'\r' | b'\n'))
.filter(|line| !line.is_empty());
let mut saw_header = false;
while let Some(line) = lines.next() {
if is_putty_private_key_header(line) {
saw_header = true;
continue;
}
if !saw_header {
continue;
}
let Some(private_line_count) = putty_private_line_count(line) else {
continue;
};
let mut encoded_bytes = 0_usize;
let mut complete = private_line_count > 0;
let mut next_header = false;
for _ in 0..private_line_count {
let Some(line) = lines.next() else {
complete = false;
break;
};
if is_putty_private_key_header(line) {
complete = false;
next_header = true;
break;
}
if line.is_empty() || !line.iter().all(|byte| is_base64_byte(*byte)) {
complete = false;
break;
}
encoded_bytes = encoded_bytes
.saturating_add(line.iter().filter(|byte| !matches!(**byte, b'=')).count());
}
if complete && encoded_bytes >= 32 {
return true;
}
saw_header = next_header;
}
false
}
fn is_putty_private_key_header(line: &[u8]) -> bool {
const PREFIX: &[u8] = b"PuTTY-User-Key-File-";
let Some(rest) = line.strip_prefix(PREFIX) else {
return false;
};
rest.len() > 3
&& matches!(rest[0], b'1' | b'2' | b'3')
&& &rest[1..3] == b": "
&& rest[3..].iter().all(u8::is_ascii_graphic)
}
fn putty_private_line_count(line: &[u8]) -> Option<usize> {
let digits = line.strip_prefix(b"Private-Lines: ")?;
if digits.is_empty() {
return None;
}
digits.iter().try_fold(0_usize, |count, byte| {
if !byte.is_ascii_digit() {
return None;
}
count
.checked_mul(10)?
.checked_add(usize::from(*byte - b'0'))
})
}
const fn is_base64_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'=')
}
fn contains_access_token(contents: &[u8]) -> bool {
const TOKEN_SIGNATURES: &[(&[u8], usize, usize)] = &[
(b"\x67hp_", 36, 64),
(b"\x67ithub_pat_", 40, 128),
(b"\x67ho_", 36, 128),
(b"\x67hu_", 36, 128),
(b"\x67hs_", 36, 128),
(b"\x67hr_", 36, 128),
(b"\x6epm_", 36, 64),
(b"\x73k_live_", 24, 96),
(b"\x78oxb-", 20, 160),
(b"\x78oxp-", 20, 160),
(b"\x79a29.", 20, 160),
(b"\x41KIA", 16, 16),
(b"\x41SIA", 16, 16),
];
TOKEN_SIGNATURES.iter().any(|(prefix, minimum, maximum)| {
let mut offset = 0;
while let Some(relative) = find_bytes(&contents[offset..], prefix) {
let start = offset + relative;
let before_ok = start == 0 || !is_token_byte(contents[start - 1]);
let tail_start = start + prefix.len();
let tail_length = contents[tail_start..]
.iter()
.take_while(|byte| is_token_byte(**byte))
.count();
let after = tail_start.saturating_add(tail_length);
let after_ok = after == contents.len() || !is_token_byte(contents[after]);
if before_ok && after_ok && (*minimum..=*maximum).contains(&tail_length) {
return true;
}
offset = tail_start;
}
false
})
}
fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack
.windows(needle.len())
.position(|window| window == needle)
}
const fn is_token_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')
}
#[cfg_attr(
not(windows),
expect(
clippy::unnecessary_wraps,
reason = "Windows reparse validation has no non-Windows equivalent"
)
)]
fn reject_reparse_point(metadata: &Metadata, path: &str) -> Result<(), SourceError> {
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt as _;
if metadata.file_attributes() & WINDOWS_FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return Err(SourceError::ReparsePoint {
path: path.to_owned(),
});
}
}
#[cfg(not(windows))]
let _ = (metadata, path);
Ok(())
}
fn is_sensitive_path(path: &str) -> bool {
let mut components = path.split('/');
let Some(file_name) = components.next_back() else {
return false;
};
let parent_sensitive = components.any(|component| {
let lower = component.to_lowercase();
SENSITIVE_DIRECTORIES.contains(&lower.as_str())
});
if parent_sensitive {
return true;
}
let lower = file_name.to_lowercase();
if SENSITIVE_DIRECTORIES.contains(&lower.as_str())
|| SENSITIVE_FILE_NAMES.contains(&lower.as_str())
|| lower == ".env"
|| lower.starts_with(".env.")
{
return true;
}
lower
.rsplit_once('.')
.is_some_and(|(_, extension)| SENSITIVE_EXTENSIONS.contains(&extension))
}
fn is_ignored(path: &str, rules: &[IgnoreRule]) -> bool {
rules.iter().any(|rule| {
let relative = if rule.base == "." {
Some(path)
} else {
path.strip_prefix(&rule.base)
.and_then(|rest| rest.strip_prefix('/'))
};
relative.is_some_and(|relative| {
relative == rule.path
|| relative
.strip_prefix(&rule.path)
.is_some_and(|rest| rest.starts_with('/'))
})
})
}
fn is_workspace_excluded(path: &str, exclusions: &[String]) -> bool {
exclusions
.iter()
.any(|excluded| path_is_within(path, excluded))
}
fn path_is_within(path: &str, root: &str) -> bool {
path == root
|| path
.strip_prefix(root)
.is_some_and(|rest| rest.starts_with('/'))
}
fn register_collision_key(
path: &str,
collisions: &mut BTreeMap<String, String>,
) -> Result<(), SourceError> {
let lowercase: String = path.nfc().flat_map(char::to_lowercase).collect();
let key: String = lowercase.nfc().collect();
if let Some(existing) = collisions.get(&key) {
if existing != path {
return Err(SourceError::CaseCollision {
first: existing.clone(),
second: path.to_owned(),
});
}
} else {
collisions.insert(key, path.to_owned());
}
Ok(())
}
fn register_path_components(
path: &str,
collisions: &mut BTreeMap<String, String>,
) -> Result<(), SourceError> {
let mut prefix = String::new();
for component in path.split('/') {
if !prefix.is_empty() {
prefix.push('/');
}
prefix.push_str(component);
register_collision_key(&prefix, collisions)?;
}
Ok(())
}
fn has_file_ancestor(path: &str, file_paths: &BTreeSet<String>) -> bool {
let mut prefix = String::new();
let component_count = path.split('/').count();
for component in path.split('/').take(component_count.saturating_sub(1)) {
if !prefix.is_empty() {
prefix.push('/');
}
prefix.push_str(component);
if file_paths.contains(&prefix) {
return true;
}
}
false
}
fn is_unsafe_unicode_format(character: char) -> bool {
matches!(
character,
'\u{061c}'
| '\u{200b}'
| '\u{200c}'
| '\u{200d}'
| '\u{200e}'
| '\u{200f}'
| '\u{202a}'..='\u{202e}'
| '\u{2060}'
| '\u{2066}'..='\u{2069}'
| '\u{feff}'
)
}
fn check_depth(path: &str, maximum: usize) -> Result<(), SourceError> {
let actual = path.split('/').count();
if actual > maximum {
Err(limit_error(
SourceLimitKind::Depth,
Some(path.to_owned()),
maximum as u64,
actual as u64,
))
} else {
Ok(())
}
}
fn limit_error(
kind: SourceLimitKind,
path: Option<String>,
maximum: u64,
actual: u64,
) -> SourceError {
SourceError::LimitExceeded {
kind,
path,
maximum,
actual,
}
}
fn invalid_git_snapshot(reason: impl Into<String>) -> SourceError {
SourceError::InvalidGitSnapshot {
reason: reason.into(),
}
}
fn display_relative_path(path: &Utf8Path) -> String {
if path.as_str().is_empty() || path.as_str() == "." {
return ".".to_owned();
}
path.iter()
.filter(|component| *component != ".")
.collect::<Vec<_>>()
.join("/")
}
fn relative_ignore_path(base: &Utf8Path) -> String {
let base = display_relative_path(base);
if base == "." {
".ferryignore".to_owned()
} else {
format!("{base}/.ferryignore")
}
}
fn has_unexpected_directory(directories: &[String], expected: &SourceManifest) -> bool {
directories.iter().any(|directory| {
!expected.entries.iter().any(|entry| {
entry
.path
.strip_prefix(directory)
.is_some_and(|rest| rest.starts_with('/'))
})
})
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn archive_parent_swap_never_publishes_into_replacement_directory() {
use std::{sync::mpsc, thread};
let temporary = tempfile::tempdir().unwrap();
let workspace = Utf8PathBuf::from_path_buf(temporary.path().join("workspace")).unwrap();
let project = workspace.join("app");
fs::create_dir_all(project.join("src")).unwrap();
fs::write(workspace.join("Cargo.toml"), b"[workspace]\n").unwrap();
fs::write(workspace.join("Cargo.lock"), b"").unwrap();
fs::write(project.join("Cargo.toml"), b"[package]\nname = \"app\"\n").unwrap();
fs::write(project.join("src/lib.rs"), b"pub fn value() -> u8 { 1 }\n").unwrap();
let request = SourceBundleRequest::new(workspace.clone(), project);
let plan = plan_source_bundle(&request).unwrap();
let root = workspace.parent().unwrap();
let output_parent = root.join("publish-parent");
let moved_parent = root.join("publish-parent-owned");
fs::create_dir(&output_parent).unwrap();
let output = output_parent.join("source.zip");
let output_for_creator = output.clone();
let (created_sender, created_receiver) = mpsc::sync_channel(0);
let (continue_sender, continue_receiver) = mpsc::sync_channel(0);
let creator = thread::spawn(move || {
create_source_bundle_archive_with_temporary_hook(
&plan,
&output_for_creator,
SourceArchiveLimits::default(),
|| {
created_sender.send(()).unwrap();
continue_receiver.recv().unwrap();
},
)
});
created_receiver.recv().unwrap();
fs::rename(&output_parent, &moved_parent).unwrap();
fs::create_dir(&output_parent).unwrap();
fs::write(output_parent.join("sentinel"), b"unchanged").unwrap();
continue_sender.send(()).unwrap();
let result = creator.join().unwrap();
assert!(result.is_err());
assert_eq!(
fs::read(output_parent.join("sentinel")).unwrap(),
b"unchanged"
);
assert!(!output.exists());
assert!(fs::read_dir(&moved_parent).unwrap().all(|entry| {
!entry
.unwrap()
.file_name()
.to_string_lossy()
.starts_with(".source.zip.rustferry-partial-")
}));
}
#[cfg(unix)]
#[test]
fn capability_root_binding_rejects_named_replacement() {
let temporary = tempfile::tempdir().unwrap();
let root = Utf8PathBuf::from_path_buf(temporary.path().join("bundle")).unwrap();
let detached = Utf8PathBuf::from_path_buf(temporary.path().join("detached")).unwrap();
fs::create_dir(&root).unwrap();
let canonical_root = Utf8PathBuf::from_path_buf(fs::canonicalize(&root).unwrap()).unwrap();
let directory =
CapabilityDir::open_ambient_dir(canonical_root.as_std_path(), ambient_authority())
.unwrap();
let identity = capability_directory_identity(&directory).unwrap();
ensure_capability_directory_path(&directory, &canonical_root, &identity).unwrap();
fs::rename(&root, &detached).unwrap();
fs::create_dir(&root).unwrap();
assert!(ensure_capability_directory_path(&directory, &canonical_root, &identity).is_err());
}
#[cfg(windows)]
#[test]
fn bounded_read_rejects_a_hard_link_outside_the_selected_tree() {
let temporary = tempfile::tempdir().unwrap();
let selected = temporary.path().join("selected");
fs::create_dir(&selected).unwrap();
let source = selected.join("source.rs");
let outside_link = temporary.path().join("outside-link.rs");
fs::write(&source, b"fn main() {}").unwrap();
fs::hard_link(&source, &outside_link).unwrap();
let source = Utf8PathBuf::from_path_buf(source).unwrap();
let metadata = fs::symlink_metadata(&source).unwrap();
let error = read_bounded_file(
&source,
"selected/source.rs",
1024,
&metadata,
SourceLimitKind::FileSize,
)
.unwrap_err();
assert!(matches!(error, SourceError::HardLink { links: 2, .. }));
}
#[cfg(windows)]
#[test]
fn capability_read_rejects_a_hard_link_outside_the_bundle() {
let temporary = tempfile::tempdir().unwrap();
let bundle = temporary.path().join("bundle");
fs::create_dir(&bundle).unwrap();
let source = bundle.join("source.rs");
let outside_link = temporary.path().join("outside-link.rs");
fs::write(&source, b"fn main() {}").unwrap();
fs::hard_link(&source, &outside_link).unwrap();
let directory = CapabilityDir::open_ambient_dir(&bundle, ambient_authority()).unwrap();
let metadata = directory.symlink_metadata("source.rs").unwrap();
let error =
read_bounded_capability_file(&directory, "source.rs", "source.rs", 1024, &metadata)
.unwrap_err();
assert!(matches!(error, SourceError::HardLink { links: 2, .. }));
}
#[cfg(windows)]
#[test]
fn partial_destination_failure_cleans_exact_root_and_preserves_replacement() {
let temporary = tempfile::tempdir().unwrap();
let destination = Utf8PathBuf::from_path_buf(temporary.path().join("destination")).unwrap();
let moved = temporary.path().join("moved");
let replacement = temporary.path().join("replacement");
let replacement_marker = replacement.join("preserve.txt");
fs::create_dir(&replacement).unwrap();
fs::write(&replacement_marker, b"preserve").unwrap();
let FreshDestination {
path,
parent,
parent_path,
parent_identity,
name,
directory,
identity,
} = create_fresh_destination(&destination, None).unwrap();
directory.create_dir_all("app/src").unwrap();
directory
.write("app/src/partial.rs", b"partial extraction")
.unwrap();
let mut cleanup = PartialDirectoryCleanup::new(
path,
parent,
parent_path,
parent_identity,
name,
directory,
identity,
);
assert!(fs::rename(&destination, &moved).is_err());
assert!(fs::rename(&replacement, &destination).is_err());
let result: Result<(), SourceError> = cleanup.fail(SourceError::InvalidArchive {
reason: "injected partial extraction failure".to_owned(),
});
assert!(matches!(result, Err(SourceError::InvalidArchive { .. })));
assert!(!destination.exists());
assert!(!moved.exists());
assert_eq!(fs::read(&replacement_marker).unwrap(), b"preserve");
}
}