mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
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.
606 lines
20 KiB
Rust
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) })
|
|
}
|