Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **MAT-133 hoster download planning**: MediaFire, PixelDrain, and Gofile page
URLs are resolved through their hoster plugins before transfer; stable source
URLs and plugin metadata reach the queue while ephemeral direct URLs and
request headers stay backend-only and are refreshed on retry. Unexpected HTML
responses and typed hoster failures no longer appear as successful files.
- **MAT-133 review hardening**: built-in HTTP downloads keep their normal online
probe, while current hoster plugin failures map to safe typed errors without
exposing upstream diagnostics.
- **MAT-133 review follow-up**: protected capabilities now refresh once during
an active failed transfer, preserve plugin headers and file metadata, reject
ambiguous HTML and size mismatches, and clean up only Vortex-owned artifacts.
- **MAT-133 adversarial coverage**: added regressions for hostile stable URLs,
blank capabilities, response fan-out, disguised HTML, destination collisions,
exact error classification, and recoverable online probes.
- **MAT-133 protected-source hardening**: plugin payloads and per-file metadata
are bounded, mono-file hosters retain the user-supplied stable URL, Gofile
child identifiers stay on validated official origins, and protected transfers
now reject disguised HTML without deleting or overwriting unowned destinations.
- **MAT-132 PR review hardening**: premium selection now excludes free
accounts, serializes persisted cooldowns with rotation, revalidates download
associations on every JIT resolution, and lets cancellation win without late
Expand Down
187 changes: 182 additions & 5 deletions src-tauri/src/adapters/driven/filesystem/file_storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,34 @@ impl FileStorage for FsFileStorage {
})?;
// set_len creates a sparse file — the OS only allocates blocks
// as data is actually written, so a 1 GB file uses ~0 bytes on disk.
file.set_len(size).map_err(|e| {
DomainError::StorageError(format!(
"failed to pre-allocate {} ({size} bytes): {e}",
if let Err(error) = file.set_len(size) {
let n = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let staged_path = path.with_extension(format!("vortex-preallocation.{n}.delete"));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
match fs::rename(path, &staged_path) {
Ok(()) => {
drop(file);
if let Err(cleanup_error) = fs::remove_file(&staged_path) {
warn!(
path = %staged_path.display(),
error = %cleanup_error,
"failed to delete an isolated unsuccessful file reservation"
);
}
}
Err(stage_error) => {
drop(file);
warn!(
path = %path.display(),
error = %stage_error,
"failed to isolate an unsuccessful file reservation; leaving it in place"
);
}
}
return Err(DomainError::StorageError(format!(
"failed to pre-allocate {} ({size} bytes): {error}",
path.display()
))
})?;
)));
}
debug!(path = %path.display(), size, "pre-allocated download file");
Ok(())
}
Expand Down Expand Up @@ -121,6 +143,57 @@ impl FileStorage for FsFileStorage {
Ok(())
}

fn write_growing_segment(
&self,
path: &Path,
offset: u64,
data: &[u8],
) -> Result<(), DomainError> {
let mut file = OpenOptions::new().write(true).open(path).map_err(|error| {
DomainError::StorageError(format!(
"failed to open {} for writing: {error}",
path.display()
))
})?;
file.seek(SeekFrom::Start(offset)).map_err(|error| {
DomainError::StorageError(format!(
"failed to seek to offset {offset} in {}: {error}",
path.display()
))
})?;
file.write_all(data).map_err(|error| {
DomainError::StorageError(format!(
"failed to write {} bytes at offset {offset} in {}: {error}",
data.len(),
path.display()
))
})
}

fn grow_file(&self, path: &Path, minimum_size: u64) -> Result<(), DomainError> {
let file = OpenOptions::new().write(true).open(path).map_err(|e| {
DomainError::StorageError(format!("failed to open {} for growth: {e}", path.display()))
})?;
let current_size = file
.metadata()
.map(|metadata| metadata.len())
.map_err(|e| {
DomainError::StorageError(format!(
"failed to read metadata for {}: {e}",
path.display()
))
})?;
if current_size < minimum_size {
file.set_len(minimum_size).map_err(|e| {
DomainError::StorageError(format!(
"failed to grow {} to {minimum_size} bytes: {e}",
path.display()
))
})?;
}
Ok(())
}

fn read_meta(&self, path: &Path) -> Result<Option<DownloadMeta>, DomainError> {
let mp = meta_path(path);
// Open directly and handle NotFound — avoids TOCTOU race with delete_meta.
Expand Down Expand Up @@ -208,6 +281,48 @@ impl FileStorage for FsFileStorage {
}
}

fn delete_download_artifacts(&self, path: &Path) -> Result<(), DomainError> {
let mp = meta_path(path);
let n = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let staged_meta = mp.with_extension(format!("vortex-meta.{n}.delete"));
let metadata_staged = match fs::rename(&mp, &staged_meta) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
Ok(()) => true,
Err(error) if error.kind() == io::ErrorKind::NotFound => false,
Err(error) => {
return Err(DomainError::StorageError(format!(
"failed to stage {} for deletion: {error}",
mp.display()
)));
}
};
match fs::remove_file(path) {
Ok(()) => debug!(path = %path.display(), "deleted download body"),
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => {
if metadata_staged && let Err(restore_error) = fs::rename(&staged_meta, &mp) {
return Err(DomainError::StorageError(format!(
"failed to delete {}: {error}; failed to restore {}: {restore_error}",
path.display(),
mp.display()
)));
}
return Err(DomainError::StorageError(format!(
"failed to delete {}: {error}",
path.display()
)));
}
}
if metadata_staged {
fs::remove_file(&staged_meta).map_err(|error| {
DomainError::StorageError(format!(
"failed to delete staged metadata {}: {error}",
staged_meta.display()
))
})?;
}
Ok(())
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

fn move_file(&self, from: &Path, to: &Path) -> Result<(), DomainError> {
if from == to {
return Ok(());
Expand Down Expand Up @@ -476,6 +591,19 @@ mod tests {
);
}

#[test]
fn test_create_file_rolls_back_when_preallocation_fails() {
let dir = tempfile::tempdir().expect("tempdir");
let file_path = dir.path().join("too-large.bin");
let storage = FsFileStorage::new();

let result = storage.create_file(&file_path, u64::MAX);

assert!(result.is_err());
assert!(!file_path.exists());
assert_eq!(dir.path().read_dir().unwrap().count(), 0);
}

#[test]
fn test_write_segment_at_offset() {
let dir = tempfile::tempdir().expect("tempdir");
Expand Down Expand Up @@ -577,6 +705,41 @@ mod tests {
.expect("delete_meta on missing file should succeed");
}

#[test]
fn test_delete_download_artifacts_removes_body_and_metadata_idempotently() {
let dir = tempfile::tempdir().expect("tempdir");
let file_path = dir.path().join("download.bin");
let storage = FsFileStorage::new();
storage.create_file(&file_path, 4).expect("create body");
storage
.write_meta(&file_path, &make_meta())
.expect("create metadata");

storage
.delete_download_artifacts(&file_path)
.expect("delete artifacts");
storage
.delete_download_artifacts(&file_path)
.expect("repeated deletion remains safe");

assert!(!file_path.exists());
assert!(!meta_path(&file_path).exists());
}

#[test]
fn test_delete_download_artifacts_preserves_metadata_when_body_removal_fails() {
let dir = tempfile::tempdir().expect("tempdir");
let body_path = dir.path().join("body-directory");
fs::create_dir(&body_path).expect("create body directory");
let storage = FsFileStorage::new();
storage
.write_meta(&body_path, &make_meta())
.expect("create ownership metadata");

assert!(storage.delete_download_artifacts(&body_path).is_err());
assert!(meta_path(&body_path).exists());
}

#[test]
fn test_write_segment_multiple_offsets() {
let dir = tempfile::tempdir().expect("tempdir");
Expand All @@ -601,6 +764,20 @@ mod tests {
assert_eq!(&data[200..300], &[0xCC; 100]);
}

#[test]
fn test_write_growing_segment_extends_an_unknown_length_file() {
let dir = tempfile::tempdir().expect("tempdir");
let file_path = dir.path().join("unknown.bin");
let storage = FsFileStorage::new();
storage.create_file(&file_path, 0).expect("reserve file");

storage
.write_growing_segment(&file_path, 0, b"data")
.expect("write unknown-length chunk");

assert_eq!(fs::read(file_path).unwrap(), b"data");
}

#[test]
fn test_read_meta_corrupted_returns_none() {
let dir = tempfile::tempdir().expect("tempdir");
Expand Down
121 changes: 121 additions & 0 deletions src-tauri/src/adapters/driven/network/download_artifact_lifecycle.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
use std::path::Path;
use std::sync::Arc;

use crate::domain::error::DomainError;
use crate::domain::model::download::DownloadId;
use crate::domain::model::meta::DownloadMeta;
use crate::domain::ports::driven::FileStorage;

pub(super) struct AttemptFailure {
pub(super) message: String,
pub(super) owns_artifacts: bool,
pub(super) retryable_with_mirror: bool,
}

pub(super) enum AttemptOutcome {
Completed,
Cancelled,
Failed(AttemptFailure),
}

impl AttemptFailure {
pub(super) fn retryable(message: String, owns_artifacts: bool) -> Self {
Self {
message,
owns_artifacts,
retryable_with_mirror: true,
}
}

pub(super) fn terminal(message: String, owns_artifacts: bool) -> Self {
Self {
message,
owns_artifacts,
retryable_with_mirror: false,
}
}
}

pub(super) fn resume_metadata_matches(
metadata: &DownloadMeta,
download_id: DownloadId,
stable_url: &str,
destination: &Path,
total_size: u64,
) -> bool {
let filename_matches = destination
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|filename| metadata.file_name == filename);
let size_matches = total_size == 0 || metadata.total_bytes == Some(total_size);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
metadata.download_id == download_id
&& metadata.url == stable_url
&& filename_matches
&& size_matches
}

pub(super) fn ownership_metadata(
download_id: DownloadId,
stable_url: String,
destination: &Path,
total_size: u64,
) -> DownloadMeta {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0);
DownloadMeta {
download_id,
url: stable_url,
file_name: destination
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default()
.to_string(),
total_bytes: (total_size > 0).then_some(total_size),
segments: Vec::new(),
Comment thread
mpiton marked this conversation as resolved.
Outdated
checksum_expected: None,
created_at: now,
updated_at: now,
}
}

pub(super) async fn cleanup_download_artifacts(
file_storage: &Arc<dyn FileStorage>,
dest_path: &Path,
) -> Result<(), DomainError> {
let storage = file_storage.clone();
let path = dest_path.to_path_buf();
tokio::task::spawn_blocking(move || storage.delete_download_artifacts(&path))
.await
.map_err(|_| DomainError::StorageError("download artifact cleanup stopped".into()))?
}

#[cfg(test)]
mod tests {
use super::*;

fn metadata(total_bytes: Option<u64>) -> DownloadMeta {
DownloadMeta {
download_id: DownloadId(7),
url: "https://example.com/file.bin".into(),
file_name: "file.bin".into(),
total_bytes,
segments: Vec::new(),
checksum_expected: None,
created_at: 0,
updated_at: 0,
}
}

#[test]
fn unknown_remote_size_does_not_invalidate_same_owner_metadata() {
assert!(resume_metadata_matches(
&metadata(Some(42)),
DownloadId(7),
"https://example.com/file.bin",
Path::new("/tmp/file.bin"),
0,
));
}
}
Loading
Loading