Files
Starlight_Launcher/packages/app-lib/src/api/servers/files.rs
T
Disy 159f9e3f38 fix(security): backport upstream modpack path traversal fixes
Backport Mystic-Stars/Axolotl b2f81391e584aebf3ef07e8db2fc9cdad2ffb4c7. Adapt validation to this fork and protect CurseForge archive paths and MRPack uninstall cleanup.
2026-10-20 17:04:46 +08:00

187 lines
5.6 KiB
Rust

//! Reading, writing, and downloading files inside a server's directory.
use std::path::{Path, PathBuf};
use futures::StreamExt;
use sha1_smol::Sha1;
use crate::event::ServerPayloadType;
use crate::event::emit::emit_server;
use crate::util::io::{self, IOError};
use crate::{ErrorKind, Result};
use super::manifest::server_path;
pub(super) use crate::util::io::safe_relative_join;
const DOWNLOAD_PROGRESS_STEP: u64 = 512 * 1024;
pub async fn read_file(server_id: &str, file: &str) -> Result<String> {
let path = resolve_server_file(server_id, file).await?;
let bytes = io::read(&path).await?;
let text = String::from_utf8_lossy(&bytes).into_owned();
Ok(text)
}
pub async fn write_file(
server_id: &str,
file: &str,
contents: &str,
) -> Result<()> {
let path = resolve_server_file(server_id, file).await?;
io::write(&path, contents).await?;
Ok(())
}
pub async fn download_file(
server_id: &str,
url: &str,
filename: &str,
expected_sha1: Option<String>,
) -> Result<()> {
let dir = server_path(server_id).await?;
download_to_dir(server_id, &dir, url, filename, expected_sha1).await
}
/// Downloads a URL into a nested, relative path inside the server directory
/// (for example `mods/sodium.jar`), creating parent directories as needed.
/// The relative path is validated against traversal; a plain filename behaves
/// identically to [`download_file`].
pub(super) async fn download_to_dir(
server_id: &str,
dir: &Path,
url: &str,
rel_path: &str,
expected_sha1: Option<String>,
) -> Result<()> {
let destination = safe_relative_join(dir, rel_path)?;
let partial = destination.with_extension("part");
if let Some(parent) = destination.parent() {
io::create_dir_all(parent).await?;
}
let client = reqwest::Client::builder()
.user_agent(crate::launcher_user_agent())
.build()
.map_err(|e| ErrorKind::NetworkError(e.to_string()))?;
let response = client
.get(url)
.send()
.await
.and_then(|r| r.error_for_status())?;
let total = response.content_length();
let mut stream = response.bytes_stream();
let mut file = tokio::fs::File::create(&partial)
.await
.map_err(|e| IOError::with_path(e, &partial))?;
let mut hasher = Sha1::new();
let mut downloaded: u64 = 0;
let mut last_reported: u64 = 0;
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
tokio::io::AsyncWriteExt::write_all(&mut file, &chunk)
.await
.map_err(|e| IOError::with_path(e, &partial))?;
hasher.update(&chunk);
downloaded += chunk.len() as u64;
if downloaded - last_reported >= DOWNLOAD_PROGRESS_STEP {
last_reported = downloaded;
emit_server(
server_id,
ServerPayloadType::DownloadProgress { downloaded, total },
)
.await
.ok();
}
}
drop(file);
if let Some(expected) = expected_sha1.as_deref() {
let actual = hasher.digest().to_string();
if !actual.eq_ignore_ascii_case(expected) {
let _ = tokio::fs::remove_file(&partial).await;
return Err(ErrorKind::NetworkError(format!(
"Download checksum mismatch for {rel_path}: expected {expected}, got {actual}"
))
.as_error());
}
}
tokio::fs::rename(&partial, &destination)
.await
.map_err(|e| IOError::with_path(e, &destination))?;
emit_server(
server_id,
ServerPayloadType::DownloadProgress {
downloaded,
total: Some(downloaded.max(total.unwrap_or(0))),
},
)
.await
.ok();
Ok(())
}
async fn resolve_server_file(server_id: &str, file: &str) -> Result<PathBuf> {
let dir = server_path(server_id).await?;
safe_relative_join(&dir, file)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn safe_relative_join_rejects_unsafe_paths() {
let dir = Path::new("/tmp/servers/a");
for path in [
"../secret",
"mods/../../secret",
r"..\secret",
r"mods/..\../secret",
"/etc/passwd",
"C:/windows/system32",
r"C:\windows\system32",
"C:secret",
r"\\server\share\secret",
r"\secret",
r"\\?\C:\secret",
"a//b",
"a/./b",
".",
"",
"mods/.. /secret",
"mods/secret.",
"mods/secret ",
"mods/mod.jar:payload",
"NUL/secret",
"mods/CON.jar",
"mods/mod\0.jar",
] {
assert!(safe_relative_join(dir, path).is_err(), "{path:?}");
}
}
#[test]
fn safe_relative_join_allows_nested_paths() {
let dir = Path::new("/tmp/servers/a");
assert_eq!(
safe_relative_join(dir, "mods/sodium.jar").unwrap(),
dir.join("mods/sodium.jar")
);
assert_eq!(
safe_relative_join(dir, "config/foo/bar.toml").unwrap(),
dir.join("config/foo/bar.toml")
);
assert!(safe_relative_join(dir, "mods/../secret").is_err());
assert!(safe_relative_join(dir, "../secret").is_err());
assert!(safe_relative_join(dir, "/etc/passwd").is_err());
assert!(safe_relative_join(dir, "C:/windows/system32").is_err());
assert!(safe_relative_join(dir, "mods//double.jar").is_err());
assert!(safe_relative_join(dir, "mods/./dot.jar").is_err());
assert!(safe_relative_join(dir, "").is_err());
}
}