mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
fix(core): preserve typed transfer failures
This commit is contained in:
@@ -8,12 +8,24 @@ pub enum VnidropError {
|
||||
Ticket { reason: String },
|
||||
#[error("filesystem error: {reason}")]
|
||||
Filesystem { reason: String },
|
||||
#[error("filesystem permission denied: {reason}")]
|
||||
FilesystemPermission { reason: String },
|
||||
#[error("destination already exists: {reason}")]
|
||||
DestinationExists { reason: String },
|
||||
#[error("storage is full: {reason}")]
|
||||
StorageFull { reason: String },
|
||||
#[error("network error: {reason}")]
|
||||
Network { reason: String },
|
||||
#[error("transfer error: {reason}")]
|
||||
Transfer { reason: String },
|
||||
#[error("permission error: {reason}")]
|
||||
Permission { reason: String },
|
||||
#[error("repository error: {reason}")]
|
||||
Repository { reason: String },
|
||||
#[error("operation cancelled: {reason}")]
|
||||
Cancelled { reason: String },
|
||||
#[error("invalid input: {reason}")]
|
||||
InvalidInput { reason: String },
|
||||
#[error("internal error: {reason}")]
|
||||
Internal { reason: String },
|
||||
}
|
||||
@@ -32,42 +44,138 @@ impl VnidropError {
|
||||
}
|
||||
|
||||
pub(crate) fn filesystem(error: impl Into<anyhow::Error>) -> Self {
|
||||
Self::Filesystem {
|
||||
reason: error.into().to_string(),
|
||||
}
|
||||
let error = error.into();
|
||||
Self::classify(error, |reason| Self::Filesystem { reason })
|
||||
}
|
||||
|
||||
pub(crate) fn network(error: impl Into<anyhow::Error>) -> Self {
|
||||
Self::from_error(error.into(), |reason| Self::Network { reason })
|
||||
}
|
||||
|
||||
pub(crate) fn transfer(error: impl Into<anyhow::Error>) -> Self {
|
||||
Self::Transfer {
|
||||
reason: error.into().to_string(),
|
||||
}
|
||||
let error = error.into();
|
||||
Self::classify(error, |reason| Self::Transfer { reason })
|
||||
}
|
||||
|
||||
pub(crate) fn permission(error: impl Into<anyhow::Error>) -> Self {
|
||||
Self::Permission {
|
||||
reason: error.into().to_string(),
|
||||
}
|
||||
Self::classify(error.into(), |reason| Self::Permission { reason })
|
||||
}
|
||||
|
||||
pub(crate) fn repository(error: impl Into<anyhow::Error>) -> Self {
|
||||
Self::Repository {
|
||||
reason: error.into().to_string(),
|
||||
Self::from_error(error.into(), |reason| Self::Repository { reason })
|
||||
}
|
||||
|
||||
pub(crate) fn cancelled(reason: impl Into<String>) -> Self {
|
||||
Self::Cancelled {
|
||||
reason: reason.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn invalid_input(error: impl Into<anyhow::Error>) -> Self {
|
||||
Self::from_error(error.into(), |reason| Self::InvalidInput { reason })
|
||||
}
|
||||
|
||||
pub(crate) fn internal(error: impl Into<anyhow::Error>) -> Self {
|
||||
Self::from_error(error.into(), |reason| Self::Internal { reason })
|
||||
}
|
||||
|
||||
pub(crate) fn code(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Initialization { .. } => "initialization",
|
||||
Self::Ticket { .. } => "invalid_ticket",
|
||||
Self::Filesystem { .. } => "filesystem",
|
||||
Self::FilesystemPermission { .. } => "filesystem_permission_denied",
|
||||
Self::DestinationExists { .. } => "destination_exists",
|
||||
Self::StorageFull { .. } => "storage_full",
|
||||
Self::Network { .. } => "network",
|
||||
Self::Transfer { .. } => "transfer",
|
||||
Self::Permission { .. } => "permission_denied",
|
||||
Self::Repository { .. } => "repository",
|
||||
Self::Cancelled { .. } => "cancelled",
|
||||
Self::InvalidInput { .. } => "invalid_input",
|
||||
Self::Internal { .. } => "internal",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn reason(&self) -> &str {
|
||||
match self {
|
||||
Self::Initialization { reason }
|
||||
| Self::Ticket { reason }
|
||||
| Self::Filesystem { reason }
|
||||
| Self::FilesystemPermission { reason }
|
||||
| Self::DestinationExists { reason }
|
||||
| Self::StorageFull { reason }
|
||||
| Self::Network { reason }
|
||||
| Self::Transfer { reason }
|
||||
| Self::Permission { reason }
|
||||
| Self::Repository { reason }
|
||||
| Self::Cancelled { reason }
|
||||
| Self::InvalidInput { reason }
|
||||
| Self::Internal { reason } => reason,
|
||||
}
|
||||
}
|
||||
|
||||
fn classify(error: anyhow::Error, fallback: impl FnOnce(String) -> Self) -> Self {
|
||||
let reason = error.to_string();
|
||||
if let Some(existing) = error.chain().find_map(|cause| cause.downcast_ref::<Self>()) {
|
||||
return existing.with_reason(reason);
|
||||
}
|
||||
if let Some(io_error) = error
|
||||
.chain()
|
||||
.find_map(|cause| cause.downcast_ref::<io::Error>())
|
||||
{
|
||||
return match io_error.kind() {
|
||||
io::ErrorKind::AlreadyExists => Self::DestinationExists { reason },
|
||||
io::ErrorKind::PermissionDenied => Self::FilesystemPermission { reason },
|
||||
io::ErrorKind::StorageFull => Self::StorageFull { reason },
|
||||
_ => Self::Filesystem { reason },
|
||||
};
|
||||
}
|
||||
if error
|
||||
.chain()
|
||||
.any(|cause| cause.downcast_ref::<sqlx::Error>().is_some())
|
||||
{
|
||||
return Self::Repository { reason };
|
||||
}
|
||||
fallback(reason)
|
||||
}
|
||||
|
||||
fn from_error(error: anyhow::Error, fallback: impl FnOnce(String) -> Self) -> Self {
|
||||
let reason = error.to_string();
|
||||
if let Some(existing) = error.chain().find_map(|cause| cause.downcast_ref::<Self>()) {
|
||||
existing.with_reason(reason)
|
||||
} else {
|
||||
fallback(reason)
|
||||
}
|
||||
}
|
||||
|
||||
fn with_reason(&self, reason: String) -> Self {
|
||||
match self {
|
||||
Self::Initialization { .. } => Self::Initialization { reason },
|
||||
Self::Ticket { .. } => Self::Ticket { reason },
|
||||
Self::Filesystem { .. } => Self::Filesystem { reason },
|
||||
Self::FilesystemPermission { .. } => Self::FilesystemPermission { reason },
|
||||
Self::DestinationExists { .. } => Self::DestinationExists { reason },
|
||||
Self::StorageFull { .. } => Self::StorageFull { reason },
|
||||
Self::Network { .. } => Self::Network { reason },
|
||||
Self::Transfer { .. } => Self::Transfer { reason },
|
||||
Self::Permission { .. } => Self::Permission { reason },
|
||||
Self::Repository { .. } => Self::Repository { reason },
|
||||
Self::Cancelled { .. } => Self::Cancelled { reason },
|
||||
Self::InvalidInput { .. } => Self::InvalidInput { reason },
|
||||
Self::Internal { .. } => Self::Internal { reason },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for VnidropError {
|
||||
fn from(error: anyhow::Error) -> Self {
|
||||
Self::Internal {
|
||||
reason: error.to_string(),
|
||||
}
|
||||
Self::classify(error, |reason| Self::Internal { reason })
|
||||
}
|
||||
}
|
||||
|
||||
impl From<io::Error> for VnidropError {
|
||||
fn from(error: io::Error) -> Self {
|
||||
Self::Filesystem {
|
||||
reason: error.to_string(),
|
||||
}
|
||||
Self::filesystem(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,11 @@ impl AtomicOutputFile {
|
||||
}
|
||||
cleanup_stale_temporary_files(parent, STALE_PART_AGE)?;
|
||||
if std::fs::symlink_metadata(&target).is_ok() {
|
||||
anyhow::bail!("destination already exists: {}", target.display());
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
format!("destination already exists: {}", target.display()),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let final_name = target
|
||||
|
||||
@@ -21,6 +21,7 @@ use crate::{
|
||||
ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedLocatorKind, TransferAccessMode,
|
||||
TransferMetadata,
|
||||
},
|
||||
error::VnidropError,
|
||||
filesystem::{
|
||||
validated_relative_string, wait_for_writer, write_stream_to_blocking_writer,
|
||||
AtomicOutputFile,
|
||||
@@ -52,7 +53,7 @@ pub(super) struct OutputSinkFile<'a> {
|
||||
impl<'a> OutputSinkFile<'a> {
|
||||
fn start(sink: &'a dyn ReceiveOutputSink, relative_path: String) -> Result<Self> {
|
||||
sink.start_file(relative_path.clone())
|
||||
.map_err(|error| anyhow::anyhow!(error.to_string()))?;
|
||||
.map_err(anyhow::Error::new)?;
|
||||
Ok(Self {
|
||||
sink,
|
||||
relative_path,
|
||||
@@ -63,7 +64,7 @@ impl<'a> OutputSinkFile<'a> {
|
||||
fn write(&self, bytes: Vec<u8>) -> Result<()> {
|
||||
self.sink
|
||||
.write_chunk(self.relative_path.clone(), bytes)
|
||||
.map_err(|error| anyhow::anyhow!(error.to_string()))
|
||||
.map_err(anyhow::Error::new)
|
||||
}
|
||||
|
||||
fn finish(mut self) -> Result<()> {
|
||||
@@ -73,7 +74,7 @@ impl<'a> OutputSinkFile<'a> {
|
||||
self.terminal = true;
|
||||
self.sink
|
||||
.finish_file(self.relative_path.clone())
|
||||
.map_err(|error| anyhow::anyhow!(error.to_string()))
|
||||
.map_err(anyhow::Error::new)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +105,7 @@ pub(super) struct OutputSinkFileV2<'a> {
|
||||
impl<'a> OutputSinkFileV2<'a> {
|
||||
fn start(sink: &'a dyn ReceiveOutputSinkV2, relative_path: String) -> Result<Self> {
|
||||
sink.start_file(relative_path.clone())
|
||||
.map_err(|error| anyhow::anyhow!(error.to_string()))?;
|
||||
.map_err(anyhow::Error::new)?;
|
||||
Ok(Self {
|
||||
sink,
|
||||
relative_path,
|
||||
@@ -115,14 +116,14 @@ impl<'a> OutputSinkFileV2<'a> {
|
||||
fn write(&self, bytes: Vec<u8>) -> Result<()> {
|
||||
self.sink
|
||||
.write_chunk(self.relative_path.clone(), bytes)
|
||||
.map_err(|error| anyhow::anyhow!(error.to_string()))
|
||||
.map_err(anyhow::Error::new)
|
||||
}
|
||||
|
||||
fn finish(mut self) -> Result<crate::api::PublishedOutput> {
|
||||
self.terminal = true;
|
||||
self.sink
|
||||
.finish_file(self.relative_path.clone())
|
||||
.map_err(|error| anyhow::anyhow!(error.to_string()))
|
||||
.map_err(anyhow::Error::new)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +190,8 @@ impl CoreInner {
|
||||
.transfer_slots
|
||||
.acquire()
|
||||
.await
|
||||
.context("transfer limiter is closed")?;
|
||||
.context("transfer limiter is closed")
|
||||
.map_err(VnidropError::internal)?;
|
||||
let parsed = match parse_transfer_ticket_with_limits(&ticket, &self.limits)
|
||||
.context("failed to parse transfer ticket")
|
||||
{
|
||||
@@ -205,7 +207,8 @@ impl CoreInner {
|
||||
};
|
||||
let transfer_id = parsed.metadata.transfer_id;
|
||||
self.persist_receive_start(transfer_id, &parsed, receiver_name.as_deref())
|
||||
.await?;
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
// Cancellation is cooperative: it stops our receive future and marks
|
||||
// local state while lower-level Iroh work unwinds naturally.
|
||||
let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
|
||||
@@ -221,8 +224,10 @@ impl CoreInner {
|
||||
);
|
||||
|
||||
let (result, cancelled) = tokio::select! {
|
||||
result = self.receive_inner(transfer_id, parsed, target, receiver_name) => (result, false),
|
||||
_ = &mut shutdown_rx => (Err(anyhow::anyhow!("transfer cancelled")), true),
|
||||
result = self.receive_inner(transfer_id, parsed, target, receiver_name) => {
|
||||
(result.map_err(VnidropError::transfer), false)
|
||||
},
|
||||
_ = &mut shutdown_rx => (Err(VnidropError::cancelled("transfer cancelled")), true),
|
||||
};
|
||||
|
||||
self.active_transfers
|
||||
@@ -238,7 +243,7 @@ impl CoreInner {
|
||||
"receive",
|
||||
"error",
|
||||
"failed",
|
||||
json!({ "reason": error.to_string() }),
|
||||
json!({ "code": error.code(), "reason": error.reason() }),
|
||||
);
|
||||
let _ = self
|
||||
.repository
|
||||
@@ -250,7 +255,7 @@ impl CoreInner {
|
||||
.await;
|
||||
}
|
||||
}
|
||||
result
|
||||
result.map_err(anyhow::Error::new)
|
||||
}
|
||||
|
||||
pub(super) async fn receive_inner(
|
||||
@@ -261,7 +266,9 @@ impl CoreInner {
|
||||
receiver_name: Option<String>,
|
||||
) -> Result<()> {
|
||||
if let ReceiveTarget::Directory(output_dir) = &target {
|
||||
tokio::fs::create_dir_all(output_dir).await?;
|
||||
tokio::fs::create_dir_all(output_dir)
|
||||
.await
|
||||
.map_err(VnidropError::filesystem)?;
|
||||
}
|
||||
let sender_addr = parsed.blob_ticket.addr().clone();
|
||||
|
||||
@@ -278,14 +285,16 @@ impl CoreInner {
|
||||
let connection = self
|
||||
.endpoint
|
||||
.connect(sender_addr.clone(), iroh_blobs::ALPN)
|
||||
.await?;
|
||||
.await
|
||||
.map_err(VnidropError::network)?;
|
||||
self.emit_transfer(transfer_id, "receive", "network", "connected", json!({}));
|
||||
|
||||
let hash_and_format = parsed.blob_ticket.hash_and_format();
|
||||
let (_hash_seq, sizes) =
|
||||
get_hash_seq_and_sizes(&connection, &hash_and_format.hash, 1024 * 1024 * 32, None)
|
||||
.await
|
||||
.context("failed to get file sizes")?;
|
||||
.context("failed to get file sizes")
|
||||
.map_err(VnidropError::network)?;
|
||||
let total_size = sizes
|
||||
.iter()
|
||||
.try_fold(0u64, |total, size| total.checked_add(*size))
|
||||
@@ -331,7 +340,11 @@ impl CoreInner {
|
||||
);
|
||||
}
|
||||
GetProgressItem::Done(_) => break,
|
||||
GetProgressItem::Error(error) => anyhow::bail!("download failed: {error}"),
|
||||
GetProgressItem::Error(error) => {
|
||||
return Err(
|
||||
VnidropError::network(anyhow::anyhow!("download failed: {error}")).into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,7 +357,8 @@ impl CoreInner {
|
||||
TransferStatus::Receiving,
|
||||
TransferStatus::Done,
|
||||
)
|
||||
.await?;
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
drop(download_tag);
|
||||
self.emit_transfer(transfer_id, "receive", "lifecycle", "done", json!({}));
|
||||
let sender_transfer_id = delivery_receipt.transfer_id;
|
||||
@@ -395,7 +409,8 @@ impl CoreInner {
|
||||
total_size: parsed.metadata.total_size,
|
||||
access_mode: mode_to_storage(&TransferAccessMode::ApprovalRequired),
|
||||
})
|
||||
.await?;
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
let metadata_json =
|
||||
serde_json::to_value(&parsed.metadata).unwrap_or(serde_json::Value::Null);
|
||||
self.emit_transfer(
|
||||
@@ -433,8 +448,9 @@ impl CoreInner {
|
||||
match client
|
||||
.request_transfer(metadata, receiver_name)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("handshake request failed: {error}"))?
|
||||
{
|
||||
.map_err(|error| {
|
||||
VnidropError::network(anyhow::anyhow!("handshake request failed: {error}"))
|
||||
})? {
|
||||
HandshakeResponse::Approved {
|
||||
request_id,
|
||||
token,
|
||||
@@ -456,9 +472,10 @@ impl CoreInner {
|
||||
token,
|
||||
})
|
||||
}
|
||||
HandshakeResponse::Denied { reason } => {
|
||||
anyhow::bail!("transfer request was denied by sender: {reason}")
|
||||
}
|
||||
HandshakeResponse::Denied { reason } => Err(VnidropError::permission(anyhow::anyhow!(
|
||||
"transfer request was denied by sender: {reason}"
|
||||
))
|
||||
.into()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,7 +486,11 @@ impl CoreInner {
|
||||
target: ReceiveTarget,
|
||||
collection: Collection,
|
||||
) -> Result<()> {
|
||||
let transfer_local_id = self.repository.transfer_local_id(transfer_id).await?;
|
||||
let transfer_local_id = self
|
||||
.repository
|
||||
.transfer_local_id(transfer_id)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
let received_transfer = ReceivedTransfer {
|
||||
protocol_id: transfer_id,
|
||||
local_id: &transfer_local_id,
|
||||
@@ -529,7 +550,8 @@ impl CoreInner {
|
||||
self.limits.max_path_bytes
|
||||
);
|
||||
}
|
||||
let (pending_file, writer) = AtomicOutputFile::create(output_dir, relative_path)?;
|
||||
let (pending_file, writer) = AtomicOutputFile::create(output_dir, relative_path)
|
||||
.map_err(VnidropError::filesystem)?;
|
||||
let (tx, rx) = async_channel::bounded::<io::Result<Option<Bytes>>>(2);
|
||||
let writer_task = std::thread::spawn(move || write_stream_to_blocking_writer(writer, rx));
|
||||
let mut stream = self.store.export_ranges(hash, 0..u64::MAX).stream();
|
||||
@@ -572,10 +594,16 @@ impl CoreInner {
|
||||
tx.send(Ok(None))
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("export writer closed for {relative_path}"))?;
|
||||
let writer = wait_for_writer(writer_task).await??;
|
||||
tokio::task::spawn_blocking(move || writer.sync_all()).await??;
|
||||
let writer = wait_for_writer(writer_task)
|
||||
.await
|
||||
.map_err(VnidropError::internal)?
|
||||
.map_err(VnidropError::filesystem)?;
|
||||
tokio::task::spawn_blocking(move || writer.sync_all())
|
||||
.await
|
||||
.map_err(VnidropError::internal)?
|
||||
.map_err(VnidropError::filesystem)?;
|
||||
let locator = pending_file.target().to_string_lossy().to_string();
|
||||
pending_file.commit()?;
|
||||
pending_file.commit().map_err(VnidropError::filesystem)?;
|
||||
self.repository
|
||||
.record_received_artifact(ReceivedArtifactInsert {
|
||||
transfer_local_id: transfer.local_id,
|
||||
@@ -585,7 +613,8 @@ impl CoreInner {
|
||||
locator: &locator,
|
||||
logical_size: exported,
|
||||
})
|
||||
.await?;
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -712,7 +741,8 @@ impl CoreInner {
|
||||
locator: &published.locator,
|
||||
logical_size: exported,
|
||||
})
|
||||
.await?;
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use crate::{
|
||||
access_policy::mode_to_storage,
|
||||
api::TransferMetadata,
|
||||
api::{ShareMetadataInput, ShareResult, ShareSource},
|
||||
error::VnidropError,
|
||||
filesystem::{
|
||||
collect_import_files_with_limits, default_collection_name,
|
||||
read_stream_from_blocking_reader, TransferImport,
|
||||
@@ -37,22 +38,29 @@ impl CoreInner {
|
||||
.transfer_slots
|
||||
.acquire()
|
||||
.await
|
||||
.context("transfer limiter is closed")?;
|
||||
.context("transfer limiter is closed")
|
||||
.map_err(VnidropError::internal)?;
|
||||
let transfer_id = metadata.transfer_id;
|
||||
if sources.is_empty() {
|
||||
anyhow::bail!("at least one source is required");
|
||||
return Err(VnidropError::invalid_input(anyhow::anyhow!(
|
||||
"at least one source is required"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
if sources.len() as u64 > self.limits.max_sources {
|
||||
anyhow::bail!(
|
||||
return Err(VnidropError::invalid_input(anyhow::anyhow!(
|
||||
"source count {} exceeds limit {}",
|
||||
sources.len(),
|
||||
self.limits.max_sources
|
||||
);
|
||||
))
|
||||
.into());
|
||||
}
|
||||
self.limits
|
||||
.validate_metadata_text("transfer name", metadata.transfer_name.as_deref())?;
|
||||
.validate_metadata_text("transfer name", metadata.transfer_name.as_deref())
|
||||
.map_err(VnidropError::invalid_input)?;
|
||||
self.limits
|
||||
.validate_metadata_text("sender name", metadata.sender_name.as_deref())?;
|
||||
.validate_metadata_text("sender name", metadata.sender_name.as_deref())
|
||||
.map_err(VnidropError::invalid_input)?;
|
||||
self.repository
|
||||
.insert_transfer(TransferUpsert {
|
||||
transfer_id,
|
||||
@@ -66,7 +74,8 @@ impl CoreInner {
|
||||
total_size: 0,
|
||||
access_mode: mode_to_storage(&metadata.access_mode),
|
||||
})
|
||||
.await?;
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
let (cancel, mut cancelled) = oneshot::channel();
|
||||
self.active_transfers
|
||||
.lock()
|
||||
@@ -79,8 +88,10 @@ impl CoreInner {
|
||||
},
|
||||
);
|
||||
let (result, was_cancelled) = tokio::select! {
|
||||
result = self.share_files_inner(sources, metadata) => (result, false),
|
||||
_ = &mut cancelled => (Err(anyhow::anyhow!("transfer cancelled")), true),
|
||||
result = self.share_files_inner(sources, metadata) => {
|
||||
(result.map_err(VnidropError::transfer), false)
|
||||
},
|
||||
_ = &mut cancelled => (Err(VnidropError::cancelled("transfer cancelled")), true),
|
||||
};
|
||||
self.active_transfers
|
||||
.lock()
|
||||
@@ -95,7 +106,7 @@ impl CoreInner {
|
||||
"send",
|
||||
"error",
|
||||
"failed",
|
||||
json!({ "reason": error.to_string() }),
|
||||
json!({ "code": error.code(), "reason": error.reason() }),
|
||||
);
|
||||
let _ = self
|
||||
.repository
|
||||
@@ -107,7 +118,7 @@ impl CoreInner {
|
||||
.await;
|
||||
}
|
||||
}
|
||||
result
|
||||
result.map_err(anyhow::Error::new)
|
||||
}
|
||||
|
||||
pub(super) async fn share_files_inner(
|
||||
@@ -145,7 +156,8 @@ impl CoreInner {
|
||||
let local_id = self
|
||||
.repository
|
||||
.transfer_local_id(metadata.transfer_id)
|
||||
.await?;
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
let tag_name = share_tag_name(&local_id);
|
||||
self.store
|
||||
.tags()
|
||||
@@ -175,7 +187,7 @@ impl CoreInner {
|
||||
.await
|
||||
{
|
||||
let _ = self.store.tags().delete(&tag_name).await;
|
||||
return Err(error);
|
||||
return Err(VnidropError::repository(error).into());
|
||||
}
|
||||
// Map root + every collection member so provider ACL cannot fail-open
|
||||
// on child blob hashes that are not the collection root.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#[path = "tests/access_policy.rs"]
|
||||
mod access_policy_tests;
|
||||
#[path = "tests/error.rs"]
|
||||
mod error_tests;
|
||||
#[path = "tests/filesystem.rs"]
|
||||
mod filesystem_tests;
|
||||
#[path = "tests/handshake.rs"]
|
||||
|
||||
54
crates/vnidrop/src/tests/error.rs
Normal file
54
crates/vnidrop/src/tests/error.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use std::io;
|
||||
|
||||
use crate::VnidropError;
|
||||
|
||||
#[test]
|
||||
fn transfer_boundary_classifies_operating_system_failures() {
|
||||
let already_exists = VnidropError::transfer(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
"target exists",
|
||||
));
|
||||
let permission = VnidropError::transfer(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"access denied",
|
||||
));
|
||||
let storage = VnidropError::transfer(io::Error::new(io::ErrorKind::StorageFull, "disk full"));
|
||||
let filesystem =
|
||||
VnidropError::transfer(io::Error::new(io::ErrorKind::NotFound, "folder missing"));
|
||||
|
||||
assert!(matches!(
|
||||
already_exists,
|
||||
VnidropError::DestinationExists { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
permission,
|
||||
VnidropError::FilesystemPermission { .. }
|
||||
));
|
||||
assert!(matches!(storage, VnidropError::StorageFull { .. }));
|
||||
assert!(matches!(filesystem, VnidropError::Filesystem { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transfer_boundary_preserves_typed_errors_through_context() {
|
||||
let error = anyhow::Error::new(VnidropError::Network {
|
||||
reason: "connection reset".to_string(),
|
||||
})
|
||||
.context("download failed");
|
||||
|
||||
let classified = VnidropError::transfer(error);
|
||||
|
||||
assert!(matches!(
|
||||
classified,
|
||||
VnidropError::Network { ref reason } if reason == "download failed"
|
||||
));
|
||||
assert_eq!(classified.code(), "network");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transfer_boundary_classifies_database_failures() {
|
||||
let transfer = VnidropError::transfer(sqlx::Error::RowNotFound);
|
||||
let approval = VnidropError::permission(sqlx::Error::RowNotFound);
|
||||
|
||||
assert!(matches!(transfer, VnidropError::Repository { .. }));
|
||||
assert!(matches!(approval, VnidropError::Repository { .. }));
|
||||
}
|
||||
@@ -8,6 +8,29 @@ use support::{
|
||||
receive_with_sink_response, receive_with_sink_v2_response, share_path,
|
||||
wait_for_receiver_request, MemoryOutputSink, TestNode,
|
||||
};
|
||||
use vnidrop::{PublishedOutput, ReceiveOutputSinkV2, VnidropError};
|
||||
|
||||
struct ExistingDestinationSink;
|
||||
|
||||
impl ReceiveOutputSinkV2 for ExistingDestinationSink {
|
||||
fn start_file(&self, relative_path: String) -> Result<(), VnidropError> {
|
||||
Err(VnidropError::DestinationExists {
|
||||
reason: format!("destination already exists: {relative_path}"),
|
||||
})
|
||||
}
|
||||
|
||||
fn write_chunk(&self, _relative_path: String, _bytes: Vec<u8>) -> Result<(), VnidropError> {
|
||||
unreachable!("a rejected file must not be written")
|
||||
}
|
||||
|
||||
fn finish_file(&self, _relative_path: String) -> Result<PublishedOutput, VnidropError> {
|
||||
unreachable!("a rejected file must not be finished")
|
||||
}
|
||||
|
||||
fn abort_file(&self, _relative_path: String, _reason: String) -> Result<(), VnidropError> {
|
||||
unreachable!("start_file failure must not be aborted")
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versioned_sink_records_published_locator() {
|
||||
@@ -89,10 +112,45 @@ fn reports_output_sink_write_failure() {
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.contains("sink write failed"));
|
||||
assert!(matches!(
|
||||
error,
|
||||
VnidropError::Filesystem { ref reason } if reason.contains("sink write failed")
|
||||
));
|
||||
assert_eq!(output_sink.terminal_state("hello.txt"), Some("aborted"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_typed_output_sink_failure() {
|
||||
let source_dir = tempfile::tempdir().unwrap();
|
||||
let source_path = source_dir.path().join("existing.txt");
|
||||
std::fs::write(&source_path, b"new content").unwrap();
|
||||
let sender = TestNode::new();
|
||||
let receiver = TestNode::new();
|
||||
let share = share_path(&sender.core, &source_path, 39, "existing.txt", false);
|
||||
|
||||
let error = receive_with_sink_v2_response(
|
||||
&sender.core,
|
||||
share.transfer_id,
|
||||
receiver.core.arc(),
|
||||
share.ticket,
|
||||
Arc::new(ExistingDestinationSink),
|
||||
true,
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(error, VnidropError::DestinationExists { .. }));
|
||||
assert!(receiver
|
||||
.core
|
||||
.list_events(Some(39))
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|event| {
|
||||
event.phase == "error"
|
||||
&& event.kind == "failed"
|
||||
&& event.data_json.contains("\"code\":\"destination_exists\"")
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_during_export_aborts_open_sink_file() {
|
||||
// Gate the first write so export is mid-file when cancel runs. A large
|
||||
|
||||
@@ -292,12 +292,10 @@ pub fn receive_with_response(
|
||||
ticket: String,
|
||||
output_dir: &Path,
|
||||
accepted: bool,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<(), VnidropError> {
|
||||
let output_dir = output_dir.to_string_lossy().to_string();
|
||||
let handle = std::thread::spawn(move || {
|
||||
receiver
|
||||
.receive(ticket, output_dir, Some("receiver".to_string()))
|
||||
.map_err(|error| error.to_string())
|
||||
receiver.receive(ticket, output_dir, Some("receiver".to_string()))
|
||||
});
|
||||
respond_to_pending_request(sender, transfer_id, accepted);
|
||||
handle.join().unwrap()
|
||||
@@ -310,11 +308,9 @@ pub fn receive_with_sink_response(
|
||||
ticket: String,
|
||||
output_sink: Arc<dyn ReceiveOutputSink>,
|
||||
accepted: bool,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<(), VnidropError> {
|
||||
let handle = std::thread::spawn(move || {
|
||||
receiver
|
||||
.receive_with_output_sink(ticket, output_sink, Some("receiver".to_string()))
|
||||
.map_err(|error| error.to_string())
|
||||
receiver.receive_with_output_sink(ticket, output_sink, Some("receiver".to_string()))
|
||||
});
|
||||
respond_to_pending_request(sender, transfer_id, accepted);
|
||||
handle.join().unwrap()
|
||||
@@ -327,11 +323,9 @@ pub fn receive_with_sink_v2_response(
|
||||
ticket: String,
|
||||
output_sink: Arc<dyn ReceiveOutputSinkV2>,
|
||||
accepted: bool,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<(), VnidropError> {
|
||||
let handle = std::thread::spawn(move || {
|
||||
receiver
|
||||
.receive_with_output_sink_v2(ticket, output_sink, Some("receiver".to_string()))
|
||||
.map_err(|error| error.to_string())
|
||||
receiver.receive_with_output_sink_v2(ticket, output_sink, Some("receiver".to_string()))
|
||||
});
|
||||
respond_to_pending_request(sender, transfer_id, accepted);
|
||||
handle.join().unwrap()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod support;
|
||||
|
||||
use support::{receive_with_response, share_path, TestNode};
|
||||
use vnidrop::VnidropError;
|
||||
|
||||
#[test]
|
||||
fn transfers_file_between_two_cores() {
|
||||
@@ -98,7 +99,7 @@ fn receive_refuses_to_overwrite_existing_destination() {
|
||||
let receiver = TestNode::new();
|
||||
let share = share_path(&sender.core, &source_path, 27, "existing.txt", false);
|
||||
|
||||
assert!(receive_with_response(
|
||||
let error = receive_with_response(
|
||||
&sender.core,
|
||||
share.transfer_id,
|
||||
receiver.core.arc(),
|
||||
@@ -106,7 +107,8 @@ fn receive_refuses_to_overwrite_existing_destination() {
|
||||
output_dir.path(),
|
||||
true,
|
||||
)
|
||||
.is_err());
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, VnidropError::DestinationExists { .. }));
|
||||
assert_eq!(std::fs::read(&output_path).unwrap(), b"keep content");
|
||||
assert!(std::fs::read_dir(output_dir.path())
|
||||
.unwrap()
|
||||
@@ -115,4 +117,22 @@ fn receive_refuses_to_overwrite_existing_destination() {
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.contains(".part")));
|
||||
let transfer = receiver
|
||||
.core
|
||||
.list_transfers()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|transfer| transfer.transfer_id == 27)
|
||||
.unwrap();
|
||||
assert_eq!(transfer.status, "failed");
|
||||
assert!(receiver
|
||||
.core
|
||||
.list_events(Some(27))
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|event| {
|
||||
event.phase == "error"
|
||||
&& event.kind == "failed"
|
||||
&& event.data_json.contains("\"code\":\"destination_exists\"")
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user