Include a self-contained Windows build and portable profiles
This commit is contained in:
1 parent
64e59fda1f
commit
1b52466f4c
20 files changed
+729
-34
No files matched your search
+28
-8
@@ -20,7 +20,7 @@ use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
path::{Component, Path, PathBuf},
|
||||
};
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -108,6 +108,25 @@ fn read_package_file(root: &Path, relative: &Path) -> Result<Vec<u8>> {
|
||||
);
|
||||
Ok(data)
|
||||
}
|
||||
fn package_hash_name(relative: &Path) -> Result<String> {
|
||||
let mut parts = Vec::new();
|
||||
for component in relative.components() {
|
||||
let Component::Normal(name) = component else {
|
||||
anyhow::bail!("package hash paths must contain only relative filename components");
|
||||
};
|
||||
let name = name.to_str().context("package filenames must be UTF-8")?;
|
||||
// A literal backslash is a Unix filename but a Windows separator;
|
||||
// a colon can address a Windows drive or alternate data stream. Do not
|
||||
// turn either into an alias when generating portable hash keys.
|
||||
ensure!(
|
||||
!name.contains(['\\', ':']),
|
||||
"package filenames contain an ambiguous Windows separator or colon: {name}"
|
||||
);
|
||||
parts.push(name);
|
||||
}
|
||||
ensure!(!parts.is_empty(), "empty package hash path");
|
||||
Ok(parts.join("/"))
|
||||
}
|
||||
fn load_package(path: &Path) -> Result<InstalledMod> {
|
||||
ensure!(
|
||||
fs::symlink_metadata(path)?.is_dir(),
|
||||
@@ -117,7 +136,10 @@ fn load_package(path: &Path) -> Result<InstalledMod> {
|
||||
let mut files = Vec::new();
|
||||
let mut bytes = 0;
|
||||
walk(&root, &root, &mut files, &mut bytes, 0)?;
|
||||
files.sort();
|
||||
// Compare UTF-8 component strings on every platform. Whole-path string
|
||||
// ordering would place art-qa.json before art/... and invalidate existing
|
||||
// Linux saves, whose v1 hashes used PathBuf's component ordering.
|
||||
files.sort_by(|(a, _), (b, _)| a.split('/').cmp(b.split('/')));
|
||||
let manifest_data = read_package_file(&root, Path::new("mod.toml"))?;
|
||||
ensure!(
|
||||
manifest_data.len() <= 64 * 1024,
|
||||
@@ -174,10 +196,7 @@ fn load_package(path: &Path) -> Result<InstalledMod> {
|
||||
}
|
||||
let mut hash = Sha256::new();
|
||||
hash.update(b"raptor-mod-package-v1\0");
|
||||
for relative in files {
|
||||
let name = relative
|
||||
.to_str()
|
||||
.context("package filenames must be UTF-8")?;
|
||||
for (name, relative) in files {
|
||||
let data = read_package_file(&root, &relative)?;
|
||||
hash.update((name.len() as u64).to_le_bytes());
|
||||
hash.update(name.as_bytes());
|
||||
@@ -250,7 +269,7 @@ pub fn discover(root: &Path) -> Discovery {
|
||||
fn walk(
|
||||
root: &Path,
|
||||
current: &Path,
|
||||
files: &mut Vec<PathBuf>,
|
||||
files: &mut Vec<(String, PathBuf)>,
|
||||
bytes: &mut u64,
|
||||
depth: usize,
|
||||
) -> Result<()> {
|
||||
@@ -261,6 +280,7 @@ fn walk(
|
||||
for e in fs::read_dir(current)? {
|
||||
let e = e?;
|
||||
let relative = e.path().strip_prefix(root)?.to_path_buf();
|
||||
let name = package_hash_name(&relative)?;
|
||||
ensure!(
|
||||
!e.file_type()?.is_symlink(),
|
||||
"mod packages may not contain symlinks"
|
||||
@@ -280,7 +300,7 @@ fn walk(
|
||||
.checked_add(e.metadata()?.len())
|
||||
.context("package size overflow")?;
|
||||
ensure!(*bytes <= MAX_PACKAGE_BYTES, "mod package exceeds 256 MiB");
|
||||
files.push(relative);
|
||||
files.push((name, relative));
|
||||
ensure!(files.len() <= MAX_FILES, "mod package has too many files");
|
||||
}
|
||||
}
|
||||
|
||||
+199
-4
@@ -20,9 +20,16 @@ pub struct Paths {
|
||||
}
|
||||
impl Paths {
|
||||
pub fn discover() -> Result<Self> {
|
||||
let home = PathBuf::from(std::env::var_os("HOME").context("HOME is unset")?);
|
||||
let dir = |name: &str, fallback: &str| {
|
||||
std::env::var_os(name)
|
||||
#[cfg(windows)]
|
||||
return Self::windows_from_env(|name| std::env::var_os(name));
|
||||
#[cfg(not(windows))]
|
||||
Self::unix_from_env(|name| std::env::var_os(name))
|
||||
}
|
||||
#[cfg(any(not(windows), test))]
|
||||
fn unix_from_env(mut env: impl FnMut(&str) -> Option<std::ffi::OsString>) -> Result<Self> {
|
||||
let home = PathBuf::from(env("HOME").context("HOME is unset")?);
|
||||
let mut dir = |name: &str, fallback: &str| {
|
||||
env(name)
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| home.join(fallback))
|
||||
};
|
||||
@@ -33,6 +40,31 @@ impl Paths {
|
||||
legacy: home.join(".local/share/Raptor"),
|
||||
})
|
||||
}
|
||||
#[cfg(any(windows, test))]
|
||||
fn windows_from_env(mut env: impl FnMut(&str) -> Option<std::ffi::OsString>) -> Result<Self> {
|
||||
let mut dir = |name: &str| {
|
||||
env(name)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from)
|
||||
};
|
||||
let profile = dir("USERPROFILE");
|
||||
// Respect redirected application-data folders. A normal Windows login
|
||||
// provides these variables; USERPROFILE also supports stripped-down
|
||||
// launch environments without relying on Unix HOME or XDG variables.
|
||||
let roaming = dir("APPDATA")
|
||||
.or_else(|| profile.as_ref().map(|p| p.join("AppData/Roaming")))
|
||||
.context("APPDATA and USERPROFILE are unset; cannot locate Windows saves")?;
|
||||
let local = dir("LOCALAPPDATA")
|
||||
.or_else(|| profile.as_ref().map(|p| p.join("AppData/Local")))
|
||||
.context("LOCALAPPDATA and USERPROFILE are unset; cannot locate Windows cache")?;
|
||||
Ok(Self {
|
||||
data: roaming.join("RaptorReplica"),
|
||||
config: roaming.join("RaptorReplica"),
|
||||
cache: local.join("RaptorReplica/cache"),
|
||||
// The legacy SDL_GetPrefPath("", "Raptor") location on Windows.
|
||||
legacy: roaming.join("Raptor"),
|
||||
})
|
||||
}
|
||||
pub fn mods(&self) -> PathBuf {
|
||||
self.data.join("mods")
|
||||
}
|
||||
@@ -452,7 +484,15 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
|
||||
let result = (|| -> Result<()> {
|
||||
file.write_all(data)?;
|
||||
file.sync_all()?;
|
||||
fs::rename(&temp, path)?;
|
||||
// Release the temporary handle before replacement. rename replaces an
|
||||
// existing destination on Windows as well as Unix; never remove the
|
||||
// previous save first or retry by copying over it.
|
||||
drop(file);
|
||||
fs::rename(&temp, path)
|
||||
.with_context(|| format!("cannot atomically replace {}", path.display()))?;
|
||||
// Directory handles opened by ordinary File::open cannot be synced on
|
||||
// Windows. The file itself was synced before its name was committed.
|
||||
#[cfg(unix)]
|
||||
fs::File::open(parent)?.sync_all()?;
|
||||
Ok(())
|
||||
})();
|
||||
@@ -1167,6 +1207,112 @@ fn decrypt(bytes: &[u8], key: &[u8]) -> Vec<u8> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn environment(values: &[(&str, &str)]) -> impl FnMut(&str) -> Option<std::ffi::OsString> {
|
||||
let values = values
|
||||
.iter()
|
||||
.map(|(key, value)| (key.to_string(), std::ffi::OsString::from(value)))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
move |key| values.get(key).cloned()
|
||||
}
|
||||
#[test]
|
||||
fn windows_paths_respect_redirected_data_folders_without_home() {
|
||||
let paths = Paths::windows_from_env(environment(&[
|
||||
("APPDATA", "redirected-roaming"),
|
||||
("LOCALAPPDATA", "redirected-local"),
|
||||
("HOME", "unused-unix-home"),
|
||||
("XDG_DATA_HOME", "unused-xdg-data"),
|
||||
("XDG_CONFIG_HOME", "unused-xdg-config"),
|
||||
("XDG_CACHE_HOME", "unused-xdg-cache"),
|
||||
]))
|
||||
.unwrap();
|
||||
let roaming = Path::new("redirected-roaming");
|
||||
assert_eq!(paths.data, roaming.join("RaptorReplica"));
|
||||
assert_eq!(paths.config, roaming.join("RaptorReplica"));
|
||||
assert_eq!(paths.mods(), roaming.join("RaptorReplica/mods"));
|
||||
assert_eq!(
|
||||
paths.save_path(3),
|
||||
roaming.join("RaptorReplica/pilot-0003.json")
|
||||
);
|
||||
assert_eq!(paths.legacy, roaming.join("Raptor"));
|
||||
assert_eq!(
|
||||
paths.cache,
|
||||
Path::new("redirected-local").join("RaptorReplica/cache")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn windows_paths_fall_back_to_unicode_user_profile() {
|
||||
let profile = "profile-Quintine-é-雪";
|
||||
let paths = Paths::windows_from_env(environment(&[("USERPROFILE", profile)])).unwrap();
|
||||
let profile = Path::new(profile);
|
||||
assert_eq!(paths.data, profile.join("AppData/Roaming/RaptorReplica"));
|
||||
assert_eq!(paths.config, paths.data);
|
||||
assert_eq!(
|
||||
paths.cache,
|
||||
profile.join("AppData/Local/RaptorReplica/cache")
|
||||
);
|
||||
assert_eq!(paths.legacy, profile.join("AppData/Roaming/Raptor"));
|
||||
}
|
||||
#[test]
|
||||
fn windows_missing_or_empty_application_paths_fall_back_independently() {
|
||||
let paths = Paths::windows_from_env(environment(&[
|
||||
("USERPROFILE", "profile"),
|
||||
("APPDATA", "roaming-redirect"),
|
||||
("LOCALAPPDATA", ""),
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
paths.data,
|
||||
Path::new("roaming-redirect").join("RaptorReplica")
|
||||
);
|
||||
assert_eq!(
|
||||
paths.cache,
|
||||
Path::new("profile").join("AppData/Local/RaptorReplica/cache")
|
||||
);
|
||||
let paths = Paths::windows_from_env(environment(&[
|
||||
("USERPROFILE", "profile"),
|
||||
("APPDATA", ""),
|
||||
("LOCALAPPDATA", "local-redirect"),
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
paths.data,
|
||||
Path::new("profile").join("AppData/Roaming/RaptorReplica")
|
||||
);
|
||||
assert_eq!(
|
||||
paths.cache,
|
||||
Path::new("local-redirect").join("RaptorReplica/cache")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn windows_missing_profile_has_an_actionable_error() {
|
||||
let error = Paths::windows_from_env(environment(&[("HOME", "unix-home")])).unwrap_err();
|
||||
assert!(error.to_string().contains("APPDATA and USERPROFILE"));
|
||||
let error = Paths::windows_from_env(environment(&[("APPDATA", "roaming")])).unwrap_err();
|
||||
assert!(error.to_string().contains("LOCALAPPDATA and USERPROFILE"));
|
||||
}
|
||||
#[test]
|
||||
fn unix_paths_keep_home_and_xdg_behavior() {
|
||||
let paths = Paths::unix_from_env(environment(&[("HOME", "unix-home")])).unwrap();
|
||||
let home = Path::new("unix-home");
|
||||
assert_eq!(paths.data, home.join(".local/share/RaptorReplica"));
|
||||
assert_eq!(paths.config, home.join(".config/RaptorReplica"));
|
||||
assert_eq!(paths.cache, home.join(".cache/RaptorReplica"));
|
||||
assert_eq!(paths.legacy, home.join(".local/share/Raptor"));
|
||||
let paths = Paths::unix_from_env(environment(&[
|
||||
("HOME", "unix-home"),
|
||||
("XDG_DATA_HOME", "custom-data"),
|
||||
("XDG_CONFIG_HOME", "custom-config"),
|
||||
("XDG_CACHE_HOME", "custom-cache"),
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(paths.data, Path::new("custom-data").join("RaptorReplica"));
|
||||
assert_eq!(
|
||||
paths.config,
|
||||
Path::new("custom-config").join("RaptorReplica")
|
||||
);
|
||||
assert_eq!(paths.cache, Path::new("custom-cache").join("RaptorReplica"));
|
||||
assert!(Paths::unix_from_env(environment(&[("APPDATA", "roaming")])).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn writes_replace_atomically() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -1176,4 +1322,53 @@ mod tests {
|
||||
assert_eq!(fs::read(p).unwrap(), b"second");
|
||||
assert_eq!(fs::read_dir(tmp.path()).unwrap().count(), 1);
|
||||
}
|
||||
#[test]
|
||||
fn replacement_failure_preserves_the_destination_and_cleans_the_temporary() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let p = tmp.path().join("save.json");
|
||||
fs::create_dir(&p).unwrap();
|
||||
let sentinel = p.join("keep-original");
|
||||
fs::write(&sentinel, b"original contents").unwrap();
|
||||
let error = atomic_write(&p, b"new save").unwrap_err();
|
||||
assert!(error.to_string().contains("cannot atomically replace"));
|
||||
assert_eq!(fs::read(sentinel).unwrap(), b"original contents");
|
||||
assert_eq!(fs::read_dir(tmp.path()).unwrap().count(), 1);
|
||||
}
|
||||
#[test]
|
||||
fn temporary_name_collision_and_unicode_backup_are_preserved() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let p = tmp.path().join("pilot-é-雪.json");
|
||||
let stale = tmp
|
||||
.path()
|
||||
.join(format!(".pilot-é-雪.json.{}.0.tmp", std::process::id()));
|
||||
fs::write(&stale, b"interrupted write").unwrap();
|
||||
atomic_write(&p, b"original pilot").unwrap();
|
||||
let backup = tmp.path().join("pilot-é-雪.original.json");
|
||||
atomic_write(&backup, &fs::read(&p).unwrap()).unwrap();
|
||||
atomic_write(&p, b"updated pilot").unwrap();
|
||||
assert_eq!(fs::read(&p).unwrap(), b"updated pilot");
|
||||
assert_eq!(fs::read(&backup).unwrap(), b"original pilot");
|
||||
assert_eq!(fs::read(&stale).unwrap(), b"interrupted write");
|
||||
assert_eq!(fs::read_dir(tmp.path()).unwrap().count(), 3);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn locked_windows_save_is_never_deleted_before_replacement() {
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let p = tmp.path().join("save.json");
|
||||
atomic_write(&p, b"original pilot").unwrap();
|
||||
let locked = OpenOptions::new()
|
||||
.read(true)
|
||||
.share_mode(0)
|
||||
.open(&p)
|
||||
.unwrap();
|
||||
assert!(atomic_write(&p, b"updated pilot").is_err());
|
||||
assert!(p.exists());
|
||||
assert_eq!(fs::read_dir(tmp.path()).unwrap().count(), 1);
|
||||
drop(locked);
|
||||
assert_eq!(fs::read(&p).unwrap(), b"original pilot");
|
||||
atomic_write(&p, b"updated pilot").unwrap();
|
||||
assert_eq!(fs::read(&p).unwrap(), b"updated pilot");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user