mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-09 02:40:21 +00:00
459 lines
15 KiB
Rust
459 lines
15 KiB
Rust
use std::path::{Path, PathBuf};
|
|
use tauri::{AppHandle, Manager, Runtime};
|
|
|
|
pub const PORTABLE_MARKER: &str = "portable.flag";
|
|
const PORTABLE_DATA_DIR: &str = "data";
|
|
const PORTABLE_LOG_DIR: &str = "logs";
|
|
const PORTABLE_WEBVIEW_DIR: &str = "webview";
|
|
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 MAX_ARIA2_SERVER_STAT_BYTES: u64 = 1024 * 1024;
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum StorageMode {
|
|
Standard,
|
|
Portable { root: PathBuf },
|
|
}
|
|
|
|
impl StorageMode {
|
|
pub fn detect() -> Self {
|
|
let Some(executable) = std::env::current_exe().ok() else {
|
|
return Self::Standard;
|
|
};
|
|
let Some(root) = executable.parent() else {
|
|
return Self::Standard;
|
|
};
|
|
|
|
if root.join(PORTABLE_MARKER).is_file() {
|
|
Self::Portable {
|
|
root: root.to_path_buf(),
|
|
}
|
|
} else {
|
|
Self::Standard
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn detect_from_root(root: &Path) -> Self {
|
|
if root.join(PORTABLE_MARKER).is_file() {
|
|
Self::Portable {
|
|
root: root.to_path_buf(),
|
|
}
|
|
} else {
|
|
Self::Standard
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct StorageLayout {
|
|
mode: StorageMode,
|
|
data_dir: PathBuf,
|
|
log_dir: PathBuf,
|
|
webview_dir: PathBuf,
|
|
}
|
|
|
|
impl StorageLayout {
|
|
pub fn resolve<R: Runtime>(
|
|
app_handle: &AppHandle<R>,
|
|
mode: StorageMode,
|
|
) -> Result<Self, String> {
|
|
let (mode, data_dir, log_dir, webview_dir) = match mode {
|
|
StorageMode::Standard => (
|
|
StorageMode::Standard,
|
|
app_handle
|
|
.path()
|
|
.app_data_dir()
|
|
.map_err(|error| format!("failed to resolve app data directory: {error}"))?,
|
|
app_handle
|
|
.path()
|
|
.app_log_dir()
|
|
.map_err(|error| format!("failed to resolve app log directory: {error}"))?,
|
|
app_handle.path().app_local_data_dir().map_err(|error| {
|
|
format!("failed to resolve app local data directory: {error}")
|
|
})?,
|
|
),
|
|
StorageMode::Portable { root } => {
|
|
let data_dir = root.join(PORTABLE_DATA_DIR);
|
|
(
|
|
StorageMode::Portable { root },
|
|
data_dir.clone(),
|
|
data_dir.join(PORTABLE_LOG_DIR),
|
|
data_dir.join(PORTABLE_WEBVIEW_DIR),
|
|
)
|
|
}
|
|
};
|
|
|
|
Ok(Self {
|
|
mode,
|
|
data_dir: canonicalize_storage_path(&data_dir)?,
|
|
log_dir: canonicalize_storage_path(&log_dir)?,
|
|
webview_dir: canonicalize_storage_path(&webview_dir)?,
|
|
})
|
|
}
|
|
|
|
pub fn is_portable(&self) -> bool {
|
|
matches!(self.mode, StorageMode::Portable { .. })
|
|
}
|
|
|
|
pub fn data_dir(&self) -> &Path {
|
|
&self.data_dir
|
|
}
|
|
|
|
pub fn log_dir(&self) -> &Path {
|
|
&self.log_dir
|
|
}
|
|
|
|
pub fn webview_dir(&self) -> &Path {
|
|
&self.webview_dir
|
|
}
|
|
|
|
pub fn aria2_dht_paths(&self) -> (PathBuf, PathBuf) {
|
|
let directory = self.data_dir.join(ARIA2_DATA_DIR);
|
|
(
|
|
directory.join(ARIA2_DHT_FILE),
|
|
directory.join(ARIA2_DHT6_FILE),
|
|
)
|
|
}
|
|
|
|
pub fn aria2_server_stat_path(&self) -> PathBuf {
|
|
self.data_dir
|
|
.join(ARIA2_DATA_DIR)
|
|
.join(ARIA2_SERVER_STAT_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.
|
|
pub fn prepare_aria2_dht_paths(&self) -> Result<(PathBuf, PathBuf), String> {
|
|
let directory = self.data_dir.join(ARIA2_DATA_DIR);
|
|
if crate::path_has_symlink_component(&directory) {
|
|
return Err(format!(
|
|
"Aria2 state directory contains a symlink: '{}'",
|
|
directory.display()
|
|
));
|
|
}
|
|
|
|
match std::fs::symlink_metadata(&directory) {
|
|
Ok(metadata) if metadata.file_type().is_symlink() => {
|
|
return Err(format!(
|
|
"Aria2 state directory is a symlink: '{}'",
|
|
directory.display()
|
|
));
|
|
}
|
|
Ok(metadata) if !metadata.is_dir() => {
|
|
return Err(format!(
|
|
"Aria2 state path is not a directory: '{}'",
|
|
directory.display()
|
|
));
|
|
}
|
|
Ok(_) => {}
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
|
std::fs::create_dir(&directory).map_err(|error| {
|
|
format!(
|
|
"failed to create Aria2 state directory '{}': {error}",
|
|
directory.display()
|
|
)
|
|
})?;
|
|
}
|
|
Err(error) => {
|
|
return Err(format!(
|
|
"failed to inspect Aria2 state directory '{}': {error}",
|
|
directory.display()
|
|
));
|
|
}
|
|
}
|
|
|
|
Ok(self.aria2_dht_paths())
|
|
}
|
|
|
|
/// Prepare the exact cache file used by Aria2's adaptive URI selector.
|
|
/// The cache is non-authoritative: malformed or oversized contents are
|
|
/// reset to empty, while symlinks and non-files disable the cache instead
|
|
/// of allowing Aria2 to write outside Firelink's storage boundary.
|
|
pub fn prepare_aria2_server_stat_path(&self) -> Result<PathBuf, String> {
|
|
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());
|
|
}
|
|
std::fs::create_dir_all(&directory)
|
|
.map_err(|error| format!("failed to create Aria2 server-stat directory: {error}"))?;
|
|
|
|
let path = self.aria2_server_stat_path();
|
|
match std::fs::symlink_metadata(&path) {
|
|
Ok(metadata) if metadata.file_type().is_symlink() => {
|
|
return Err("Aria2 server-stat cache is a symlink".to_string());
|
|
}
|
|
Ok(metadata) if !metadata.is_file() => {
|
|
return Err("Aria2 server-stat cache is not a regular file".to_string());
|
|
}
|
|
Ok(metadata) => {
|
|
let valid = metadata.len() <= MAX_ARIA2_SERVER_STAT_BYTES
|
|
&& std::fs::read_to_string(&path)
|
|
.ok()
|
|
.is_some_and(|contents| aria2_server_stat_is_valid(&contents));
|
|
if !valid {
|
|
std::fs::OpenOptions::new()
|
|
.write(true)
|
|
.truncate(true)
|
|
.open(&path)
|
|
.map_err(|error| {
|
|
format!("failed to reset Aria2 server-stat cache: {error}")
|
|
})?;
|
|
}
|
|
}
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
|
std::fs::OpenOptions::new()
|
|
.create_new(true)
|
|
.write(true)
|
|
.open(&path)
|
|
.map_err(|error| {
|
|
format!("failed to create Aria2 server-stat cache: {error}")
|
|
})?;
|
|
}
|
|
Err(error) => {
|
|
return Err(format!(
|
|
"failed to inspect Aria2 server-stat cache: {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}"))?;
|
|
}
|
|
Ok(path)
|
|
}
|
|
}
|
|
|
|
fn aria2_server_stat_is_valid(contents: &str) -> bool {
|
|
contents.lines().all(|line| {
|
|
let line = line.trim();
|
|
if line.is_empty() {
|
|
return true;
|
|
}
|
|
if line.chars().any(char::is_control) {
|
|
return false;
|
|
}
|
|
let fields = line
|
|
.split(',')
|
|
.filter_map(|field| field.trim().split_once('='))
|
|
.map(|(name, value)| (name.trim(), value.trim()))
|
|
.collect::<std::collections::HashMap<_, _>>();
|
|
["host", "protocol", "dl_speed", "last_updated", "status"]
|
|
.iter()
|
|
.all(|name| fields.get(name).is_some_and(|value| !value.is_empty()))
|
|
})
|
|
}
|
|
|
|
fn canonicalize_storage_path(path: &Path) -> Result<PathBuf, String> {
|
|
if crate::path_has_symlink_component(path) {
|
|
return Err(format!(
|
|
"storage path contains a symlinked component: '{}'",
|
|
path.display()
|
|
));
|
|
}
|
|
let mut existing = path;
|
|
let mut missing = Vec::new();
|
|
loop {
|
|
match std::fs::symlink_metadata(existing) {
|
|
Ok(metadata) => {
|
|
if metadata.file_type().is_symlink() {
|
|
return Err(format!(
|
|
"storage path contains a symlinked directory: '{}'",
|
|
path.display()
|
|
));
|
|
}
|
|
break;
|
|
}
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
|
Err(error) => {
|
|
return Err(format!(
|
|
"failed to inspect storage path '{}': {error}",
|
|
path.display()
|
|
));
|
|
}
|
|
}
|
|
missing.push(
|
|
existing
|
|
.file_name()
|
|
.ok_or_else(|| format!("storage path has no existing ancestor: '{}'", path.display()))?
|
|
.to_owned(),
|
|
);
|
|
existing = existing
|
|
.parent()
|
|
.ok_or_else(|| format!("storage path has no existing ancestor: '{}'", path.display()))?;
|
|
}
|
|
let mut canonical = std::fs::canonicalize(existing)
|
|
.map_err(|error| format!("failed to canonicalize storage path '{}': {error}", path.display()))?;
|
|
for component in missing.iter().rev() {
|
|
canonical.push(component);
|
|
}
|
|
Ok(canonical)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{canonicalize_storage_path, StorageLayout, StorageMode, PORTABLE_MARKER};
|
|
use std::fs;
|
|
use std::path::Path;
|
|
use tempfile::TempDir;
|
|
|
|
#[test]
|
|
fn marker_selects_portable_mode() {
|
|
let root = TempDir::new().unwrap();
|
|
fs::write(root.path().join(PORTABLE_MARKER), b"portable\n").unwrap();
|
|
|
|
assert_eq!(
|
|
StorageMode::detect_from_root(root.path()),
|
|
StorageMode::Portable {
|
|
root: root.path().to_path_buf()
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn missing_marker_keeps_standard_mode() {
|
|
let root = TempDir::new().unwrap();
|
|
|
|
assert_eq!(
|
|
StorageMode::detect_from_root(root.path()),
|
|
StorageMode::Standard
|
|
);
|
|
}
|
|
|
|
fn test_layout(data_dir: &Path) -> StorageLayout {
|
|
let data_dir = fs::canonicalize(data_dir).unwrap();
|
|
StorageLayout {
|
|
mode: StorageMode::Standard,
|
|
data_dir: data_dir.clone(),
|
|
log_dir: data_dir.join("logs"),
|
|
webview_dir: data_dir.join("webview"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn aria2_dht_paths_are_owned_by_the_selected_data_directory() {
|
|
let root = TempDir::new().unwrap();
|
|
let layout = test_layout(root.path());
|
|
let root_path = fs::canonicalize(root.path()).unwrap();
|
|
|
|
assert_eq!(
|
|
layout.aria2_dht_paths(),
|
|
(
|
|
root_path.join("aria2/dht.dat"),
|
|
root_path.join("aria2/dht6.dat")
|
|
)
|
|
);
|
|
let prepared = layout.prepare_aria2_dht_paths().unwrap();
|
|
assert_eq!(prepared, layout.aria2_dht_paths());
|
|
assert!(root_path.join("aria2").is_dir());
|
|
}
|
|
|
|
#[test]
|
|
fn aria2_dht_preparation_rejects_a_file_at_the_directory_boundary() {
|
|
let root = TempDir::new().unwrap();
|
|
let root_path = fs::canonicalize(root.path()).unwrap();
|
|
fs::write(root_path.join("aria2"), b"not a directory").unwrap();
|
|
|
|
let error = test_layout(root.path())
|
|
.prepare_aria2_dht_paths()
|
|
.unwrap_err();
|
|
assert!(error.contains("not a directory"));
|
|
}
|
|
|
|
#[test]
|
|
fn aria2_server_stat_cache_is_private_and_recovers_from_malformed_data() {
|
|
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();
|
|
assert_eq!(path, layout.aria2_server_stat_path());
|
|
assert_eq!(fs::read_to_string(&path).unwrap(), "");
|
|
|
|
fs::write(&path, "not an aria2 server profile\n").unwrap();
|
|
layout.prepare_aria2_server_stat_path().unwrap();
|
|
assert_eq!(fs::read_to_string(&path).unwrap(), "");
|
|
|
|
let valid =
|
|
"host=mirror.example, protocol=https, dl_speed=1024, last_updated=1, status=OK\n";
|
|
fs::write(&path, valid).unwrap();
|
|
layout.prepare_aria2_server_stat_path().unwrap();
|
|
assert_eq!(fs::read_to_string(&path).unwrap(), valid);
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
assert_eq!(
|
|
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
|
0o600
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn aria2_server_stat_cache_rejects_symlink_output() {
|
|
use std::os::unix::fs::symlink;
|
|
|
|
let root = TempDir::new().unwrap();
|
|
let target = TempDir::new().unwrap();
|
|
let layout = test_layout(root.path());
|
|
layout.prepare_aria2_dht_paths().unwrap();
|
|
symlink(
|
|
target.path().join("outside"),
|
|
layout.aria2_server_stat_path(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(layout.prepare_aria2_server_stat_path().is_err());
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn aria2_dht_preparation_rejects_a_symlinked_directory() {
|
|
use std::os::unix::fs::symlink;
|
|
|
|
let root = TempDir::new().unwrap();
|
|
let target = TempDir::new().unwrap();
|
|
let root_path = fs::canonicalize(root.path()).unwrap();
|
|
symlink(target.path(), root_path.join("aria2")).unwrap();
|
|
|
|
let error = test_layout(root.path())
|
|
.prepare_aria2_dht_paths()
|
|
.unwrap_err();
|
|
assert!(error.contains("symlink"));
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn rejects_symlinked_storage_directories() {
|
|
use std::os::unix::fs::symlink;
|
|
|
|
let root = TempDir::new().unwrap();
|
|
let target = TempDir::new().unwrap();
|
|
let root_path = fs::canonicalize(root.path()).unwrap();
|
|
let redirected = root_path.join("logs");
|
|
symlink(target.path(), &redirected).unwrap();
|
|
|
|
assert!(canonicalize_storage_path(Path::new(&redirected)).is_err());
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn rejects_dangling_symlinked_storage_directories() {
|
|
use std::os::unix::fs::symlink;
|
|
|
|
let root = TempDir::new().unwrap();
|
|
let root_path = fs::canonicalize(root.path()).unwrap();
|
|
let redirected = root_path.join("logs");
|
|
symlink(root_path.join("missing-target"), &redirected).unwrap();
|
|
|
|
assert!(canonicalize_storage_path(Path::new(&redirected)).is_err());
|
|
}
|
|
}
|