Files
vnidrop/crates/vnidrop/src/filesystem.rs
Hammed Abass d0c8d8774a fix(security): address medium findings for ACL, limits, and UX
Tighten approve-endpoint to active shares with TTL sessions, reject non-file
FDs, lower default ticket/approval/size caps, show endpoint IDs and Public-mode
warnings, harden Android receive path checks, and run cargo-audit in CI.
2026-07-13 18:54:28 +02:00

606 lines
20 KiB
Rust

#[cfg(unix)]
use std::os::fd::{FromRawFd, OwnedFd};
use std::{
fs::{File, OpenOptions},
io::{self, Read, Write},
path::{Component, Path, PathBuf},
time::{Duration, SystemTime},
};
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use anyhow::{Context, Result};
use bytes::Bytes;
use iroh_blobs::{api::TempTag, Hash};
use uuid::Uuid;
use crate::{
api::{CoreLimits, ShareSource, SourceKind},
util::non_empty,
};
const STREAM_BUFFER_LEN: usize = 1024 * 1024;
const STALE_PART_AGE: Duration = Duration::from_secs(24 * 60 * 60);
#[derive(Debug)]
pub(crate) struct TransferImport {
pub(crate) tag: TempTag,
pub(crate) root_hash: Hash,
/// Per-file content hashes in the collection. Provider ACL maps these too
/// so child blob gets are not fail-open when only the root is tracked.
pub(crate) member_hashes: Vec<Hash>,
pub(crate) total_size: u64,
pub(crate) file_count: u64,
pub(crate) default_name: String,
}
#[derive(Debug)]
pub(crate) struct ImportSourceFile {
pub(crate) source: ImportSource,
pub(crate) collection_name: String,
}
#[derive(Debug)]
pub(crate) enum ImportSource {
Path(PathBuf),
#[cfg(unix)]
FileDescriptor(OwnedFd),
}
pub(crate) struct AtomicOutputFile {
target: PathBuf,
temporary: PathBuf,
committed: bool,
}
impl AtomicOutputFile {
pub(crate) fn create(output_dir: &Path, relative_path: &str) -> Result<(Self, File)> {
let target = safe_output_path(output_dir, relative_path)?;
let parent = target
.parent()
.context("output file must have a parent directory")?;
std::fs::create_dir_all(parent)?;
let canonical_root = std::fs::canonicalize(output_dir)?;
let canonical_parent = std::fs::canonicalize(parent)?;
if !canonical_parent.starts_with(&canonical_root) {
anyhow::bail!("output path escapes the selected directory");
}
cleanup_stale_temporary_files(parent, STALE_PART_AGE)?;
if std::fs::symlink_metadata(&target).is_ok() {
anyhow::bail!("destination already exists: {}", target.display());
}
let final_name = target
.file_name()
.and_then(|name| name.to_str())
.context("output filename is not valid UTF-8")?;
let temporary = parent.join(format!(".{final_name}.vnidrop-{}.part", Uuid::new_v4()));
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
options.custom_flags(libc::O_NOFOLLOW);
let file = options
.open(&temporary)
.with_context(|| format!("failed to create {}", temporary.display()))?;
// Recheck after opening the temporary file so a swapped ancestor is
// detected before bytes are published to the final destination.
let canonical_parent_after_open = std::fs::canonicalize(parent)?;
if canonical_parent_after_open != canonical_parent {
let _ = std::fs::remove_file(&temporary);
anyhow::bail!("output directory changed while opening destination");
}
Ok((
Self {
target,
temporary,
committed: false,
},
file,
))
}
pub(crate) fn commit(mut self) -> Result<()> {
publish_temp_as_final(&self.temporary, &self.target)
.with_context(|| format!("failed to commit {}", self.target.display()))?;
self.committed = true;
Ok(())
}
}
/// Publish a fully written temporary file as the final destination without
/// clobbering an existing peer.
///
/// Prefer a same-directory hard link (atomic no-clobber on most Unix volumes).
/// Android's emulated external storage and some FUSE mounts reject hard links
/// even when ordinary create/write/rename work, so fall back to an exclusive
/// rename when the link is unsupported.
fn publish_temp_as_final(temporary: &Path, target: &Path) -> io::Result<()> {
match std::fs::hard_link(temporary, target) {
Ok(()) => {
if let Err(error) = std::fs::remove_file(temporary) {
tracing::warn!(
%error,
path = %temporary.display(),
"failed to remove committed temporary file"
);
}
Ok(())
}
Err(error) if is_hard_link_unsupported(&error) => rename_no_replace(temporary, target),
Err(error) => Err(error),
}
}
fn is_hard_link_unsupported(error: &io::Error) -> bool {
match error.raw_os_error() {
Some(code)
if code == libc::EPERM
|| code == libc::EACCES
|| code == libc::EOPNOTSUPP
|| code == libc::ENOTSUP
|| code == libc::EXDEV
|| code == libc::EINVAL
|| code == libc::ENOSYS =>
{
true
}
_ => matches!(
error.kind(),
io::ErrorKind::Unsupported | io::ErrorKind::PermissionDenied
),
}
}
fn rename_no_replace(from: &Path, to: &Path) -> io::Result<()> {
#[cfg(any(target_os = "linux", target_os = "android"))]
{
match renameat2_noreplace(from, to) {
Ok(()) => return Ok(()),
Err(error)
if error.raw_os_error() == Some(libc::ENOSYS)
|| error.raw_os_error() == Some(libc::EINVAL) => {}
Err(error) => return Err(error),
}
}
#[cfg(target_os = "macos")]
{
match renamex_np_excl(from, to) {
Ok(()) => return Ok(()),
Err(error) if error.raw_os_error() == Some(libc::ENOTSUP) => {}
Err(error) => return Err(error),
}
}
// Last resort: refuse an existing destination, then rename. There is a
// small race versus concurrent writers, but this path only runs when the
// platform lacks both hard links and exclusive rename.
if std::fs::symlink_metadata(to).is_ok() {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("destination already exists: {}", to.display()),
));
}
std::fs::rename(from, to)
}
#[cfg(any(target_os = "linux", target_os = "android"))]
fn renameat2_noreplace(from: &Path, to: &Path) -> io::Result<()> {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let from_c = CString::new(from.as_os_str().as_bytes())
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
let to_c = CString::new(to.as_os_str().as_bytes())
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
// Android defines RENAME_NOREPLACE as c_int while renameat2 takes c_uint.
let flags = libc::RENAME_NOREPLACE as libc::c_uint;
let rc = unsafe {
libc::renameat2(
libc::AT_FDCWD,
from_c.as_ptr(),
libc::AT_FDCWD,
to_c.as_ptr(),
flags,
)
};
if rc == 0 {
Ok(())
} else {
Err(io::Error::last_os_error())
}
}
#[cfg(target_os = "macos")]
fn renamex_np_excl(from: &Path, to: &Path) -> io::Result<()> {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let from_c = CString::new(from.as_os_str().as_bytes())
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
let to_c = CString::new(to.as_os_str().as_bytes())
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
let rc = unsafe { libc::renamex_np(from_c.as_ptr(), to_c.as_ptr(), libc::RENAME_EXCL) };
if rc == 0 {
Ok(())
} else {
Err(io::Error::last_os_error())
}
}
pub(crate) fn cleanup_stale_temporary_files(
directory: &Path,
minimum_age: Duration,
) -> Result<u64> {
let now = SystemTime::now();
let mut removed = 0;
for entry in std::fs::read_dir(directory)? {
let entry = entry?;
let name = entry.file_name();
let name = name.to_string_lossy();
if !(name.starts_with('.') && name.contains(".vnidrop-") && name.ends_with(".part")) {
continue;
}
let metadata = entry.metadata()?;
let age = metadata
.modified()
.ok()
.and_then(|modified| now.duration_since(modified).ok())
.unwrap_or_default();
if age >= minimum_age && metadata.is_file() {
std::fs::remove_file(entry.path())?;
removed += 1;
}
}
Ok(removed)
}
impl Drop for AtomicOutputFile {
fn drop(&mut self) {
if !self.committed {
let _ = std::fs::remove_file(&self.temporary);
}
}
}
impl ImportSource {
pub(crate) fn open(self) -> Result<File> {
match self {
Self::Path(path) => {
File::open(&path).with_context(|| format!("failed to open {}", path.display()))
}
#[cfg(unix)]
Self::FileDescriptor(fd) => Ok(File::from(fd)),
}
}
}
#[cfg(test)]
pub(crate) fn collect_import_files(sources: Vec<ShareSource>) -> Result<Vec<ImportSourceFile>> {
collect_import_files_with_limits(sources, &CoreLimits::default())
}
pub(crate) fn collect_import_files_with_limits(
sources: Vec<ShareSource>,
limits: &CoreLimits,
) -> Result<Vec<ImportSourceFile>> {
if sources.len() as u64 > limits.max_sources {
anyhow::bail!(
"source count {} exceeds limit {}",
sources.len(),
limits.max_sources
);
}
let mut files = Vec::new();
for source in sources {
match source.kind {
SourceKind::Path | SourceKind::IosSecurityScopedUrl => {
// iOS security-scoped resources are still ordinary paths once
// the platform side has started the lease. Rust deliberately
// does not try to own that lease; it only streams while Kotlin
// keeps the URL accessible.
let path = source_path(&source)?;
let display_name = source
.display_name
.clone()
.and_then(non_empty)
.or_else(|| {
path.file_name()
.and_then(|name| name.to_str())
.map(ToOwned::to_owned)
})
.unwrap_or_else(|| "transfer".to_string());
if source.is_directory || path.is_dir() {
collect_dir_files(&path, &display_name, &mut files)?;
} else {
files.push(ImportSourceFile {
source: ImportSource::Path(path),
collection_name: validated_relative_string(&display_name)?,
});
}
}
SourceKind::FileDescriptor => {
#[cfg(not(unix))]
{
anyhow::bail!(
"file descriptor sources are only supported on Unix-like targets"
);
}
#[cfg(unix)]
{
if source.is_directory {
anyhow::bail!("file descriptor sources cannot represent directories yet");
}
let display_name = source
.display_name
.clone()
.and_then(non_empty)
.unwrap_or_else(|| "transfer".to_string());
// Android SAF/content URIs are not paths. Platform code opens
// the URI as a ParcelFileDescriptor, then Rust duplicates the
// borrowed fd here and streams from its owned duplicate.
files.push(ImportSourceFile {
source: ImportSource::FileDescriptor(duplicate_file_descriptor(
&source.value,
)?),
collection_name: validated_relative_string(&display_name)?,
});
}
}
SourceKind::AndroidContentUri => {
anyhow::bail!(
"Android content URIs are not filesystem paths; open a ParcelFileDescriptor in Kotlin and pass its fd as SourceKind.FileDescriptor"
);
}
}
}
if files.is_empty() {
anyhow::bail!("no files found in selected sources");
}
if files.len() as u64 > limits.max_collection_files {
anyhow::bail!(
"collection file count {} exceeds limit {}",
files.len(),
limits.max_collection_files
);
}
let mut known_total = 0u64;
for file in &files {
if file.collection_name.len() as u64 > limits.max_path_bytes {
anyhow::bail!(
"collection path exceeds {} bytes: {}",
limits.max_path_bytes,
file.collection_name
);
}
if let ImportSource::Path(path) = &file.source {
known_total = known_total
.checked_add(std::fs::metadata(path)?.len())
.context("collection size overflow")?;
if known_total > limits.max_total_bytes {
anyhow::bail!(
"collection size {known_total} exceeds limit {}",
limits.max_total_bytes
);
}
}
}
Ok(files)
}
fn source_path(source: &ShareSource) -> Result<PathBuf> {
if source.value.trim().is_empty() {
anyhow::bail!("source path must not be empty");
}
platform_path(&source.value)
}
pub(crate) fn platform_path(value: &str) -> Result<PathBuf> {
if value.trim().is_empty() {
anyhow::bail!("path must not be empty");
}
if let Some(without_scheme) = value.strip_prefix("file://") {
return Ok(PathBuf::from(percent_decode_file_url_path(without_scheme)?));
}
Ok(PathBuf::from(value))
}
fn collect_dir_files(
root: &Path,
display_name: &str,
files: &mut Vec<ImportSourceFile>,
) -> Result<()> {
for entry in walkdir::WalkDir::new(root).follow_links(false) {
let entry = entry?;
if !entry.file_type().is_file() {
continue;
}
let relative = entry
.path()
.strip_prefix(root)
.context("failed to compute relative path")?;
let collection_name = path_to_string(Path::new(display_name).join(relative), true)?;
files.push(ImportSourceFile {
source: ImportSource::Path(entry.path().to_path_buf()),
collection_name,
});
}
Ok(())
}
pub(crate) fn default_collection_name(files: &[ImportSourceFile]) -> String {
files
.first()
.and_then(|file| file.collection_name.split('/').next())
.filter(|name| !name.is_empty())
.unwrap_or("transfer")
.to_string()
}
pub(crate) fn safe_output_path(output_dir: &Path, relative_path: &str) -> Result<PathBuf> {
let relative = Path::new(relative_path);
path_to_string(relative, true)?;
Ok(output_dir.join(relative))
}
pub(crate) fn read_stream_from_blocking_reader<R>(
mut reader: R,
) -> impl futures::Stream<Item = io::Result<Bytes>> + Send + Sync + 'static
where
R: Read + Send + 'static,
{
// Keep the FFI boundary out of the data path: blocking OS reads feed a
// small bounded channel, so large files are streamed into iroh-blobs
// without copying the whole file through Kotlin memory.
let (tx, rx) = async_channel::bounded(2);
std::thread::spawn(move || {
let mut buffer = vec![0; STREAM_BUFFER_LEN];
loop {
match reader.read(&mut buffer) {
Ok(0) => break,
Ok(read) => {
if tx
.send_blocking(Ok(Bytes::copy_from_slice(&buffer[..read])))
.is_err()
{
break;
}
}
Err(error) => {
let _ = tx.send_blocking(Err(error));
break;
}
}
}
});
rx
}
pub(crate) fn write_stream_to_blocking_writer<W>(
mut writer: W,
rx: async_channel::Receiver<io::Result<Option<Bytes>>>,
) -> io::Result<W>
where
W: Write,
{
while let Ok(item) = rx.recv_blocking() {
match item? {
Some(bytes) => writer.write_all(&bytes)?,
None => break,
}
}
writer.flush()?;
Ok(writer)
}
pub(crate) async fn wait_for_writer<T: Send + 'static>(
task: std::thread::JoinHandle<io::Result<T>>,
) -> Result<io::Result<T>> {
tokio::task::spawn_blocking(move || {
task.join()
.map_err(|_| anyhow::anyhow!("export writer thread panicked"))
})
.await?
}
pub(crate) fn validated_relative_string(name: &str) -> Result<String> {
let value = path_to_string(Path::new(name), true)?;
if value.trim().is_empty() {
anyhow::bail!("relative path must not be empty");
}
Ok(value)
}
pub(crate) fn path_to_string(path: impl AsRef<Path>, must_be_relative: bool) -> Result<String> {
let mut path_str = String::new();
let parts = path
.as_ref()
.components()
.filter_map(|component| match component {
Component::Normal(x) => {
let Some(component) = x.to_str() else {
return Some(Err(anyhow::anyhow!("invalid character in path")));
};
if !component.contains('/') && !component.contains('\\') {
Some(Ok(component))
} else {
Some(Err(anyhow::anyhow!("invalid path component {component:?}")))
}
}
Component::RootDir => {
if must_be_relative {
Some(Err(anyhow::anyhow!("invalid root path component")))
} else {
path_str.push('/');
None
}
}
other => Some(Err(anyhow::anyhow!("invalid path component {other:?}"))),
})
.collect::<Result<Vec<_>>>()?;
path_str.push_str(&parts.join("/"));
Ok(path_str)
}
pub(crate) fn percent_decode_file_url_path(value: &str) -> Result<String> {
let bytes = value.as_bytes();
let mut output = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
if i + 2 >= bytes.len() {
anyhow::bail!("invalid percent escape in file URL");
}
let hex = std::str::from_utf8(&bytes[i + 1..i + 3])?;
output.push(u8::from_str_radix(hex, 16).context("invalid percent escape in file URL")?);
i += 3;
} else {
output.push(bytes[i]);
i += 1;
}
}
Ok(String::from_utf8(output)?)
}
#[cfg(unix)]
fn duplicate_file_descriptor(value: &str) -> Result<OwnedFd> {
let fd = value
.parse::<i32>()
.context("file descriptor source value must be an integer fd")?;
if fd < 0 {
anyhow::bail!("file descriptor must be non-negative");
}
// Android's ParcelFileDescriptor remains owned by Kotlin. Rust duplicates
// it immediately so the blocking import thread can close its own handle
// without racing or invalidating the platform owner.
let duplicated = unsafe { libc::dup(fd) };
if duplicated < 0 {
return Err(io::Error::last_os_error()).context("failed to duplicate file descriptor");
}
// Only regular files are valid share sources. Reject directories, sockets,
// and other fd types that a compromised UI could pass by integer.
let mut stat = std::mem::MaybeUninit::<libc::stat>::uninit();
let rc = unsafe { libc::fstat(duplicated, stat.as_mut_ptr()) };
if rc != 0 {
let error = io::Error::last_os_error();
unsafe {
libc::close(duplicated);
}
return Err(error).context("failed to fstat file descriptor");
}
let mode = unsafe { stat.assume_init() }.st_mode;
if mode & libc::S_IFMT != libc::S_IFREG {
unsafe {
libc::close(duplicated);
}
anyhow::bail!("file descriptor must refer to a regular file");
}
Ok(unsafe { OwnedFd::from_raw_fd(duplicated) })
}