mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-19 15:46:17 +00:00
fix(downloads): promote adaptive mirror history safely
This commit is contained in:
@@ -216,6 +216,7 @@ const payload = Buffer.alloc(4 * 1024 * 1024, 0x5a);
|
|||||||
const checksum = crypto.createHash('sha256').update(payload).digest('hex');
|
const checksum = crypto.createHash('sha256').update(payload).digest('hex');
|
||||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-aria2-transfers-'));
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-aria2-transfers-'));
|
||||||
const serverStatPath = path.join(tempRoot, 'server-stat.txt');
|
const serverStatPath = path.join(tempRoot, 'server-stat.txt');
|
||||||
|
const serverStatOutputPath = path.join(tempRoot, 'server-stat.next');
|
||||||
fs.writeFileSync(serverStatPath, '', { mode: 0o600 });
|
fs.writeFileSync(serverStatPath, '', { mode: 0o600 });
|
||||||
let finalRequests = 0;
|
let finalRequests = 0;
|
||||||
let finalCredentials = [];
|
let finalCredentials = [];
|
||||||
@@ -321,7 +322,7 @@ const child = spawn(binaryPath, [
|
|||||||
'--console-log-level=error',
|
'--console-log-level=error',
|
||||||
'--quiet=true',
|
'--quiet=true',
|
||||||
`--server-stat-if=${serverStatPath}`,
|
`--server-stat-if=${serverStatPath}`,
|
||||||
`--server-stat-of=${serverStatPath}`,
|
`--server-stat-of=${serverStatOutputPath}`,
|
||||||
], { env: environment, stdio: ['ignore', 'ignore', 'pipe'] });
|
], { env: environment, stdio: ['ignore', 'ignore', 'pipe'] });
|
||||||
let stderr = '';
|
let stderr = '';
|
||||||
child.stderr.on('data', chunk => { stderr += chunk.toString(); });
|
child.stderr.on('data', chunk => { stderr += chunk.toString(); });
|
||||||
@@ -460,10 +461,9 @@ try {
|
|||||||
smokeFailure = new Error(`${error.message}${detail ? `\n${detail}` : ''}`);
|
smokeFailure = new Error(`${error.message}${detail ? `\n${detail}` : ''}`);
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
if (process.platform === 'win32') fs.rmSync(serverStatPath, { force: true });
|
|
||||||
await stop(child, rpcPort, secret);
|
await stop(child, rpcPort, secret);
|
||||||
if (smokePassed) {
|
if (smokePassed) {
|
||||||
const stat = fs.readFileSync(serverStatPath, 'utf8');
|
const stat = fs.readFileSync(serverStatOutputPath, 'utf8');
|
||||||
if (!stat.includes('host=127.0.0.1')) {
|
if (!stat.includes('host=127.0.0.1')) {
|
||||||
throw new Error(`Aria2 did not persist adaptive mirror statistics: ${JSON.stringify(stat)}`);
|
throw new Error(`Aria2 did not persist adaptive mirror statistics: ${JSON.stringify(stat)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-11
@@ -3115,10 +3115,6 @@ async fn shutdown_aria2_daemon(app_handle: tauri::AppHandle) {
|
|||||||
if let Some(state) = app_handle.try_state::<AppState>() {
|
if let Some(state) = app_handle.try_state::<AppState>() {
|
||||||
let port = state.aria2_port.load(Ordering::Relaxed);
|
let port = state.aria2_port.load(Ordering::Relaxed);
|
||||||
if port != 0 {
|
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(
|
let shutdown = tokio::time::timeout(
|
||||||
std::time::Duration::from_secs(2),
|
std::time::Duration::from_secs(2),
|
||||||
rpc_call(port, &state.aria2_secret, "aria2.shutdown", serde_json::json!([])),
|
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;
|
.await;
|
||||||
}
|
}
|
||||||
|
if let Some(state) = app_handle.try_state::<AppState>() {
|
||||||
|
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 {
|
impl Drop for Aria2DaemonGuard {
|
||||||
@@ -9305,14 +9306,15 @@ fn apply_aria2_torrent_dht_options(
|
|||||||
|
|
||||||
fn apply_aria2_server_stat_options(
|
fn apply_aria2_server_stat_options(
|
||||||
command: &mut std::process::Command,
|
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;
|
return;
|
||||||
};
|
};
|
||||||
command
|
command
|
||||||
.arg(format!("--server-stat-if={}", path.display()))
|
.arg(format!("--server-stat-if={}", input_path.display()))
|
||||||
.arg(format!("--server-stat-of={}", path.display()))
|
.arg(format!("--server-stat-of={}", output_path.display()))
|
||||||
.arg("--server-stat-timeout=86400");
|
.arg("--server-stat-timeout=86400");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11095,8 +11097,9 @@ mod tests {
|
|||||||
fn aria2_adaptive_mirror_history_is_private_and_launch_scoped() {
|
fn aria2_adaptive_mirror_history_is_private_and_launch_scoped() {
|
||||||
let root = tempfile::tempdir().unwrap();
|
let root = tempfile::tempdir().unwrap();
|
||||||
let path = root.path().join("server-stat.txt");
|
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");
|
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!(
|
assert_eq!(
|
||||||
command
|
command
|
||||||
.get_args()
|
.get_args()
|
||||||
@@ -11104,13 +11107,13 @@ mod tests {
|
|||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
vec![
|
vec![
|
||||||
format!("--server-stat-if={}", path.display()),
|
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(),
|
"--server-stat-timeout=86400".to_string(),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut disabled = std::process::Command::new("aria2c");
|
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);
|
assert_eq!(disabled.get_args().count(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13888,6 +13891,9 @@ pub fn run() {
|
|||||||
None
|
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()) {
|
if let Err(error) = crate::torrent::remove_orphaned_probe_dirs(app.handle()) {
|
||||||
log::warn!("could not remove orphaned torrent probes: {error}");
|
log::warn!("could not remove orphaned torrent probes: {error}");
|
||||||
}
|
}
|
||||||
@@ -14171,6 +14177,7 @@ pub fn run() {
|
|||||||
apply_aria2_server_stat_options(
|
apply_aria2_server_stat_options(
|
||||||
&mut cmd,
|
&mut cmd,
|
||||||
aria2_server_stat_path.as_deref(),
|
aria2_server_stat_path.as_deref(),
|
||||||
|
aria2_server_stat_output_path.as_deref(),
|
||||||
);
|
);
|
||||||
|
|
||||||
apply_aria2_torrent_peer_discovery_options(
|
apply_aria2_torrent_peer_discovery_options(
|
||||||
|
|||||||
+114
-19
@@ -9,6 +9,7 @@ const ARIA2_DATA_DIR: &str = "aria2";
|
|||||||
const ARIA2_DHT_FILE: &str = "dht.dat";
|
const ARIA2_DHT_FILE: &str = "dht.dat";
|
||||||
const ARIA2_DHT6_FILE: &str = "dht6.dat";
|
const ARIA2_DHT6_FILE: &str = "dht6.dat";
|
||||||
const ARIA2_SERVER_STAT_FILE: &str = "server-stat.txt";
|
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;
|
const MAX_ARIA2_SERVER_STAT_BYTES: u64 = 1024 * 1024;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -124,6 +125,12 @@ impl StorageLayout {
|
|||||||
.join(ARIA2_SERVER_STAT_FILE)
|
.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
|
/// Create and validate only Firelink's Aria2 state directory. Aria2 owns
|
||||||
/// the table contents; Firelink owns this exact location and must never
|
/// the table contents; Firelink owns this exact location and must never
|
||||||
/// fall back to a user-global default when it cannot establish it.
|
/// 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))
|
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
|
||||||
.map_err(|error| format!("failed to protect Aria2 server-stat cache: {error}"))?;
|
.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)
|
Ok(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The MSVC build of Aria2 uses C `rename`, which cannot replace an
|
/// Promote Aria2's session-only output after the daemon has exited. Keeping
|
||||||
/// existing destination on Windows. Remove only the already-validated,
|
/// the input and output paths distinct avoids the locked MSVC engine's
|
||||||
/// app-owned regular cache immediately before graceful shutdown so
|
/// inability to replace an existing destination with C `rename`.
|
||||||
/// Aria2's `__temp` file can be renamed into place.
|
pub fn promote_aria2_server_stat_output(&self) -> Result<(), String> {
|
||||||
pub fn prepare_aria2_server_stat_for_replace(&self) -> Result<(), String> {
|
|
||||||
let path = self.aria2_server_stat_path();
|
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) {
|
match std::fs::symlink_metadata(&path) {
|
||||||
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
|
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]
|
#[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 root = TempDir::new().unwrap();
|
||||||
let layout = test_layout(root.path());
|
let layout = test_layout(root.path());
|
||||||
layout.prepare_aria2_dht_paths().unwrap();
|
layout.prepare_aria2_dht_paths().unwrap();
|
||||||
let path = layout.prepare_aria2_server_stat_path().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(
|
fs::write(
|
||||||
&path,
|
&output_path,
|
||||||
"host=mirror.example, protocol=https, dl_speed=1, last_updated=1, status=OK\n",
|
"host=mirror.example, protocol=https, dl_speed=1, last_updated=1, status=OK\n",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
layout.prepare_aria2_server_stat_for_replace().unwrap();
|
layout.promote_aria2_server_stat_output().unwrap();
|
||||||
assert!(!path.exists());
|
assert!(fs::read_to_string(&path)
|
||||||
layout.prepare_aria2_server_stat_for_replace().unwrap();
|
.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)]
|
#[cfg(unix)]
|
||||||
@@ -448,7 +538,12 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(layout.prepare_aria2_server_stat_path().is_err());
|
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)]
|
#[cfg(unix)]
|
||||||
|
|||||||
Reference in New Issue
Block a user