From 4cd3d50d15ae9fc91cda8fc762754e21b2079e0d Mon Sep 17 00:00:00 2001 From: NimBold Date: Sun, 9 Aug 2026 04:40:07 +0330 Subject: [PATCH] fix(downloads): promote adaptive mirror history safely --- scripts/smoke-aria2-transfers.js | 6 +- src-tauri/src/lib.rs | 29 ++++--- src-tauri/src/storage.rs | 133 ++++++++++++++++++++++++++----- 3 files changed, 135 insertions(+), 33 deletions(-) diff --git a/scripts/smoke-aria2-transfers.js b/scripts/smoke-aria2-transfers.js index fa7e91d..cc429bd 100644 --- a/scripts/smoke-aria2-transfers.js +++ b/scripts/smoke-aria2-transfers.js @@ -216,6 +216,7 @@ const payload = Buffer.alloc(4 * 1024 * 1024, 0x5a); const checksum = crypto.createHash('sha256').update(payload).digest('hex'); const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-aria2-transfers-')); const serverStatPath = path.join(tempRoot, 'server-stat.txt'); +const serverStatOutputPath = path.join(tempRoot, 'server-stat.next'); fs.writeFileSync(serverStatPath, '', { mode: 0o600 }); let finalRequests = 0; let finalCredentials = []; @@ -321,7 +322,7 @@ const child = spawn(binaryPath, [ '--console-log-level=error', '--quiet=true', `--server-stat-if=${serverStatPath}`, - `--server-stat-of=${serverStatPath}`, + `--server-stat-of=${serverStatOutputPath}`, ], { env: environment, stdio: ['ignore', 'ignore', 'pipe'] }); let stderr = ''; child.stderr.on('data', chunk => { stderr += chunk.toString(); }); @@ -460,10 +461,9 @@ try { smokeFailure = new Error(`${error.message}${detail ? `\n${detail}` : ''}`); } finally { try { - if (process.platform === 'win32') fs.rmSync(serverStatPath, { force: true }); await stop(child, rpcPort, secret); if (smokePassed) { - const stat = fs.readFileSync(serverStatPath, 'utf8'); + const stat = fs.readFileSync(serverStatOutputPath, 'utf8'); if (!stat.includes('host=127.0.0.1')) { throw new Error(`Aria2 did not persist adaptive mirror statistics: ${JSON.stringify(stat)}`); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 19cac14..27561f0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3115,10 +3115,6 @@ async fn shutdown_aria2_daemon(app_handle: tauri::AppHandle) { if let Some(state) = app_handle.try_state::() { let port = state.aria2_port.load(Ordering::Relaxed); if port != 0 { - #[cfg(target_os = "windows")] - if let Err(error) = state.storage_layout.prepare_aria2_server_stat_for_replace() { - log::warn!("adaptive mirror history cannot be replaced on shutdown: {error}"); - } let shutdown = tokio::time::timeout( std::time::Duration::from_secs(2), rpc_call(port, &state.aria2_secret, "aria2.shutdown", serde_json::json!([])), @@ -3152,6 +3148,11 @@ async fn shutdown_aria2_daemon(app_handle: tauri::AppHandle) { }) .await; } + if let Some(state) = app_handle.try_state::() { + if let Err(error) = state.storage_layout.promote_aria2_server_stat_output() { + log::warn!("adaptive mirror history could not be promoted: {error}"); + } + } } impl Drop for Aria2DaemonGuard { @@ -9305,14 +9306,15 @@ fn apply_aria2_torrent_dht_options( fn apply_aria2_server_stat_options( command: &mut std::process::Command, - path: Option<&std::path::Path>, + input_path: Option<&std::path::Path>, + output_path: Option<&std::path::Path>, ) { - let Some(path) = path else { + let (Some(input_path), Some(output_path)) = (input_path, output_path) else { return; }; command - .arg(format!("--server-stat-if={}", path.display())) - .arg(format!("--server-stat-of={}", path.display())) + .arg(format!("--server-stat-if={}", input_path.display())) + .arg(format!("--server-stat-of={}", output_path.display())) .arg("--server-stat-timeout=86400"); } @@ -11095,8 +11097,9 @@ mod tests { fn aria2_adaptive_mirror_history_is_private_and_launch_scoped() { let root = tempfile::tempdir().unwrap(); let path = root.path().join("server-stat.txt"); + let output_path = root.path().join("server-stat.next"); let mut command = std::process::Command::new("aria2c"); - apply_aria2_server_stat_options(&mut command, Some(&path)); + apply_aria2_server_stat_options(&mut command, Some(&path), Some(&output_path)); assert_eq!( command .get_args() @@ -11104,13 +11107,13 @@ mod tests { .collect::>(), vec![ format!("--server-stat-if={}", path.display()), - format!("--server-stat-of={}", path.display()), + format!("--server-stat-of={}", output_path.display()), "--server-stat-timeout=86400".to_string(), ] ); let mut disabled = std::process::Command::new("aria2c"); - apply_aria2_server_stat_options(&mut disabled, None); + apply_aria2_server_stat_options(&mut disabled, None, None); assert_eq!(disabled.get_args().count(), 0); } @@ -13888,6 +13891,9 @@ pub fn run() { None } }; + let aria2_server_stat_output_path = aria2_server_stat_path + .as_ref() + .map(|_| storage_layout.aria2_server_stat_output_path()); if let Err(error) = crate::torrent::remove_orphaned_probe_dirs(app.handle()) { log::warn!("could not remove orphaned torrent probes: {error}"); } @@ -14171,6 +14177,7 @@ pub fn run() { apply_aria2_server_stat_options( &mut cmd, aria2_server_stat_path.as_deref(), + aria2_server_stat_output_path.as_deref(), ); apply_aria2_torrent_peer_discovery_options( diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs index ca92b88..9a8061a 100644 --- a/src-tauri/src/storage.rs +++ b/src-tauri/src/storage.rs @@ -9,6 +9,7 @@ const ARIA2_DATA_DIR: &str = "aria2"; const ARIA2_DHT_FILE: &str = "dht.dat"; const ARIA2_DHT6_FILE: &str = "dht6.dat"; const ARIA2_SERVER_STAT_FILE: &str = "server-stat.txt"; +const ARIA2_SERVER_STAT_OUTPUT_FILE: &str = "server-stat.next"; const MAX_ARIA2_SERVER_STAT_BYTES: u64 = 1024 * 1024; #[derive(Debug, Clone, PartialEq, Eq)] @@ -124,6 +125,12 @@ impl StorageLayout { .join(ARIA2_SERVER_STAT_FILE) } + pub fn aria2_server_stat_output_path(&self) -> PathBuf { + self.data_dir + .join(ARIA2_DATA_DIR) + .join(ARIA2_SERVER_STAT_OUTPUT_FILE) + } + /// Create and validate only Firelink's Aria2 state directory. Aria2 owns /// the table contents; Firelink owns this exact location and must never /// fall back to a user-global default when it cannot establish it. @@ -226,27 +233,99 @@ impl StorageLayout { std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) .map_err(|error| format!("failed to protect Aria2 server-stat cache: {error}"))?; } + let output_path = self.aria2_server_stat_output_path(); + match std::fs::symlink_metadata(&output_path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + return Err("Aria2 server-stat session output is not a regular file".to_string()); + } + Ok(_) => std::fs::remove_file(&output_path).map_err(|error| { + format!("failed to reset Aria2 server-stat session output: {error}") + })?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "failed to inspect Aria2 server-stat session output: {error}" + )); + } + } + Ok(path) } - /// The MSVC build of Aria2 uses C `rename`, which cannot replace an - /// existing destination on Windows. Remove only the already-validated, - /// app-owned regular cache immediately before graceful shutdown so - /// Aria2's `__temp` file can be renamed into place. - pub fn prepare_aria2_server_stat_for_replace(&self) -> Result<(), String> { + /// Promote Aria2's session-only output after the daemon has exited. Keeping + /// the input and output paths distinct avoids the locked MSVC engine's + /// inability to replace an existing destination with C `rename`. + pub fn promote_aria2_server_stat_output(&self) -> Result<(), String> { let path = self.aria2_server_stat_path(); + let output_path = self.aria2_server_stat_output_path(); + let directory = self.data_dir.join(ARIA2_DATA_DIR); + if crate::path_has_symlink_component(&directory) { + return Err("Aria2 server-stat directory contains a symlink".to_string()); + } + + let output_metadata = match std::fs::symlink_metadata(&output_path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + return Err("Aria2 server-stat session output is not a regular file".to_string()); + } + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(format!( + "failed to inspect Aria2 server-stat session output: {error}" + )); + } + }; + let output = if output_metadata.len() <= MAX_ARIA2_SERVER_STAT_BYTES { + std::fs::read_to_string(&output_path).ok() + } else { + None + }; + if !output + .as_deref() + .is_some_and(aria2_server_stat_is_valid) + { + std::fs::remove_file(&output_path).map_err(|error| { + format!("failed to discard invalid Aria2 server-stat session output: {error}") + })?; + return Ok(()); + } + match std::fs::symlink_metadata(&path) { Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { - Err("Aria2 server-stat cache replacement target is not a regular file".to_string()) + return Err( + "Aria2 server-stat cache replacement target is not a regular file".to_string(), + ); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "failed to inspect Aria2 server-stat replacement target: {error}" + )); } - Ok(_) => std::fs::remove_file(&path).map_err(|error| { - format!("failed to prepare Aria2 server-stat replacement: {error}") - }), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(format!( - "failed to inspect Aria2 server-stat replacement target: {error}" - )), } + + std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&path) + .and_then(|mut file| { + use std::io::Write; + file.write_all(output.as_deref().unwrap_or_default().as_bytes())?; + file.sync_all() + }) + .map_err(|error| { + format!("failed to promote Aria2 server-stat session output: {error}") + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) + .map_err(|error| format!("failed to protect Aria2 server-stat cache: {error}"))?; + } + std::fs::remove_file(&output_path) + .map_err(|error| format!("failed to remove Aria2 server-stat session output: {error}")) } } @@ -416,20 +495,31 @@ mod tests { } #[test] - fn aria2_server_stat_replacement_removes_only_the_managed_regular_cache() { + fn aria2_server_stat_output_is_promoted_only_after_validation() { let root = TempDir::new().unwrap(); let layout = test_layout(root.path()); layout.prepare_aria2_dht_paths().unwrap(); let path = layout.prepare_aria2_server_stat_path().unwrap(); + let output_path = layout.aria2_server_stat_output_path(); + fs::write(&path, "").unwrap(); fs::write( - &path, + &output_path, "host=mirror.example, protocol=https, dl_speed=1, last_updated=1, status=OK\n", ) .unwrap(); - layout.prepare_aria2_server_stat_for_replace().unwrap(); - assert!(!path.exists()); - layout.prepare_aria2_server_stat_for_replace().unwrap(); + layout.promote_aria2_server_stat_output().unwrap(); + assert!(fs::read_to_string(&path) + .unwrap() + .contains("host=mirror.example")); + assert!(!output_path.exists()); + + fs::write(&output_path, "invalid\n").unwrap(); + layout.promote_aria2_server_stat_output().unwrap(); + assert!(fs::read_to_string(&path) + .unwrap() + .contains("host=mirror.example")); + assert!(!output_path.exists()); } #[cfg(unix)] @@ -448,7 +538,12 @@ mod tests { .unwrap(); assert!(layout.prepare_aria2_server_stat_path().is_err()); - assert!(layout.prepare_aria2_server_stat_for_replace().is_err()); + fs::write( + layout.aria2_server_stat_output_path(), + "host=mirror.example, protocol=https, dl_speed=1, last_updated=1, status=OK\n", + ) + .unwrap(); + assert!(layout.promote_aria2_server_stat_output().is_err()); } #[cfg(unix)]