use super::{
manifest::{ConfigField, Kind, Manifest, Phase, StatusRule},
planner::{self, GameIdentity, Selection},
store::{self, ArchiveEntry, ValidatedPackage},
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::{
collections::{BTreeMap, BTreeSet},
fs,
io::{self, BufRead, BufReader, Read},
path::{Path, PathBuf},
};
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HostPlan {
pub format: u32,
pub run_id: String,
pub game_version: String,
pub events: String,
pub plugins: Vec<HostPlugin>,
}
pub const MAX_PLAN_BYTES: usize = 1024 * 1024;
const MAX_EVENT_RECORD_BYTES: usize = 1024 * 1024;
fn wire_bytes(plan: &HostPlan) -> io::Result<Vec<u8>> {
plan.validate().map_err(io::Error::other)?;
let bytes = serde_json::to_vec_pretty(plan)?;
if bytes.len() > MAX_PLAN_BYTES {
return Err(io::Error::other("host plan exceeds the 1 MiB limit"));
}
Ok(bytes)
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HostPlugin {
pub id: String,
pub version: String,
pub kind: Kind,
pub phase: Phase,
pub path: String,
pub sha256: String,
pub config_json: String,
pub dirs: BTreeMap<String, String>,
pub status: Option<StatusRule>,
pub abi_major: Option<u32>,
}
impl HostPlan {
pub fn validate(&self) -> Result<(), String> {
if self.format != 1
|| self.run_id.is_empty()
|| self.run_id.len() > 256
|| self.run_id.contains('\0')
|| !absolute_windows_path(&self.events)
{
return Err("invalid host plan format, run identity or events path".into());
}
let mut ids = BTreeSet::new();
let mut names = BTreeSet::new();
let mut phase = Phase::Entry;
for plugin in &self.plugins {
let path = plugin.path.replace('/', "\\").to_lowercase();
let name = path.rsplit('\\').next().unwrap_or_default();
if plugin.id.is_empty()
|| plugin.id.len() > 256
|| plugin.id.contains('\0')
|| plugin.version.is_empty()
|| plugin.version.contains('\0')
|| !ids.insert(&plugin.id)
|| !absolute_windows_path(&plugin.path)
|| !path.ends_with(".dll")
|| !names.insert(name.to_string())
|| plugin.phase < phase
|| plugin.phase == Phase::ProcessAttach
|| (plugin.kind == Kind::Native && plugin.abi_major != Some(1))
|| plugin.sha256.len() != 64
|| !plugin.sha256.bytes().all(|b| b.is_ascii_hexdigit())
|| !serde_json::from_str::<Value>(&plugin.config_json)
.is_ok_and(|value| value.is_object())
|| plugin.dirs.iter().any(|(kind, path)| {
!matches!(kind.as_str(), "data" | "cache" | "log" | "plugin")
|| !absolute_windows_path(path)
}) {
return Err(format!("{}: invalid host plan entry", plugin.id));
}
if let Some(status) = &plugin.status {
if plugin.kind != Kind::Legacy
|| status.export.is_empty()
|| !status.export.is_ascii()
|| status.export.contains('\0')
|| (status.mode == super::manifest::StatusMode::Poll
&& (status.timeout_ms == 0
|| status.timeout_ms > 120_000
|| status.pending.is_empty()))
{
return Err(format!("{}: invalid host status rule", plugin.id));
}
let mut values = BTreeSet::new();
if status.values.iter().any(|value| {
!values.insert(value.value)
|| !matches!(
value.state.as_str(),
"active" | "inactive" | "refused" | "failed" | "unknown"
)
}) {
return Err(format!("{}: invalid host status mapping", plugin.id));
}
}
phase = plugin.phase;
}
Ok(())
}
}
fn absolute_windows_path(path: &str) -> bool {
if path.contains('\0') {
return false;
}
let normalized = path.replace('/', "\\");
let bytes = normalized.as_bytes();
let drive =
bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'\\';
let unc = normalized.starts_with(r"\\")
&& normalized[2..]
.split('\\')
.filter(|p| !p.is_empty())
.count() >= 2;
(drive || unc)
&& !normalized
.split('\\')
.any(|part| matches!(part, "." | ".." | "?"))
}
struct Package {
validated: ValidatedPackage,
config: Value,
}
pub struct Deployment {
game_version: String,
packages: Vec<Package>,
resolution: planner::Resolution,
}
#[derive(Debug, Serialize)]
pub struct Prepared {
pub plan: HostPlan,
pub plan_sha256: String,
pub artifacts: Vec<Artifact>,
}
#[derive(Debug, Serialize)]
pub struct Artifact {
pub id: String,
pub version: String,
pub package_sha256: String,
}
pub fn resolve(
game: &GameIdentity,
store_root: &Path,
selection: &[Selection],
) -> Result<Deployment, String> {
let (installed, _) = store::installed_versions(store_root);
let mut catalog = BTreeMap::new();
let mut packages = BTreeMap::new();
for choice in selection.iter().filter(|item| item.enabled) {
if packages.contains_key(&choice.id) {
return Err(format!("duplicate selection {}", choice.id));
}
let matching: Vec<_> = installed
.iter()
.filter(|item| {
item.manifest.plugin.id == choice.id
&& item.manifest.plugin.version == choice.version
})
.collect();
if matching.len() != 1 {
return Err(format!(
"{} {}: expected one installed artifact, found {} (import the selected release or remove ambiguity)",
choice.id,
choice.version,
matching.len()
));
}
let canonical_store = store_root
.canonicalize()
.map_err(|error| error.to_string())?;
let directory = &matching[0].dir;
store::checked_directory(directory.parent().unwrap(), &canonical_store)
.map_err(|error| format!("{}: {error}", choice.id))?;
let directory = store::checked_directory(directory, &canonical_store)
.map_err(|error| format!("{}: {error}", choice.id))?;
let entries =
read_package(&directory).map_err(|error| format!("{}: {error}", choice.id))?;
let validated =
store::validate(entries).map_err(|errors| format!("{}: {errors:?}", choice.id))?;
if directory.file_name().and_then(|name| name.to_str())
!= Some(validated.version_dir_name().as_str())
{
return Err(format!(
"{}: installed artifact identity changed",
choice.id
));
}
let mut manifest = validated.manifest.clone();
manifest.files = validated
.entries()
.iter()
.filter(|entry| entry.path != super::manifest::FILE_NAME)
.map(|entry| super::manifest::FileEntry {
path: entry.path.clone(),
sha256: hash(&entry.data),
})
.collect();
let config = effective_config(&manifest, &choice.config)?;
if manifest.entry.phase == Phase::ProcessAttach {
return Err(format!(
"{}: process_attach is not verified by this host",
choice.id
));
}
catalog
.entry(choice.id.clone())
.or_insert_with(BTreeMap::new)
.insert(choice.version.clone(), manifest);
packages.insert(choice.id.clone(), Package { validated, config });
}
let resolution = planner::plan(game, &catalog, selection);
if !resolution.is_launchable() {
return Err(format!(
"plugin plan is not launchable: {:?}",
resolution.errors
));
}
let packages = resolution
.order
.iter()
.map(|item| packages.remove(&item.id).unwrap())
.collect();
Ok(Deployment {
game_version: game.version.to_string(),
packages,
resolution,
})
}
fn read_package(root: &Path) -> io::Result<Vec<ArchiveEntry>> {
let root = root.canonicalize()?;
fn walk(root: &Path, directory: &Path, entries: &mut Vec<ArchiveEntry>) -> io::Result<()> {
for item in fs::read_dir(directory)? {
let item = item?;
let path = item.path();
if !path.canonicalize()?.starts_with(root) {
return Err(io::Error::other(
"package file escapes the installed artifact",
));
}
if item.file_type()?.is_symlink() {
return Err(io::Error::other("package links are not supported"));
}
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
if fs::symlink_metadata(&path)?.file_attributes() & 0x400 != 0 {
return Err(io::Error::other("package reparse points are not supported"));
}
}
if item.file_type()?.is_dir() {
walk(root, &path, entries)?;
} else {
entries.push(ArchiveEntry {
path: path
.strip_prefix(root)
.unwrap()
.to_string_lossy()
.replace('\\', "/"),
data: store::read_package_file(&path)?,
});
}
}
Ok(())
}
let mut entries = Vec::new();
walk(&root, &root, &mut entries)?;
Ok(entries)
}
pub fn effective_config(manifest: &Manifest, chosen: &Value) -> Result<Value, String> {
let chosen = chosen.as_object().ok_or_else(|| {
format!(
"{}: configuration must be a JSON object",
manifest.plugin.id
)
})?;
let mut config = manifest.default_config();
for (name, value) in chosen {
if !manifest.config.contains_key(name) {
return Err(format!(
"{}: unknown configuration field {name}",
manifest.plugin.id
));
}
config[name] = value.clone();
}
for (name, value) in config.as_object().unwrap() {
let valid = match manifest.config.get(name) {
Some(ConfigField::Bool { .. }) => value.is_boolean(),
Some(ConfigField::Int { min, max, .. }) => value.as_i64().is_some_and(|number| {
min.zip(*max).is_none_or(|(min, max)| min <= max)
&& min.is_none_or(|min| number >= min)
&& max.is_none_or(|max| number <= max)
}),
Some(ConfigField::String { .. }) => value.is_string(),
None => false,
};
if !valid {
return Err(format!(
"{}: invalid configuration field {name}",
manifest.plugin.id
));
}
}
Ok(config)
}
impl Deployment {
pub fn resolution(&self) -> &planner::Resolution {
&self.resolution
}
pub fn stage(
&self,
runtime: &Path,
data_root: &Path,
run_id: &str,
game_root: &Path,
) -> io::Result<Prepared> {
if !store::ordinary_metadata(runtime)?.is_dir() {
return Err(io::Error::other(
"runtime layer is not an ordinary directory",
));
}
let runtime = runtime.canonicalize()?;
if !store::ordinary_metadata(&runtime.join("foch-runtime"))?.is_file() {
return Err(io::Error::other("not a prepared Foch runtime layer"));
}
ensure_outside_game(game_root, &runtime)?;
ensure_outside_game(game_root, data_root)?;
fs::create_dir(runtime.join("plugins"))?;
fs::create_dir(runtime.join("foch-host"))?;
fs::create_dir_all(data_root)?;
let data_root = data_root.canonicalize()?;
let mut plan = HostPlan {
format: 1,
run_id: run_id.into(),
game_version: self.game_version.clone(),
events: path_text(&runtime.join("foch-host/events.jsonl"))?,
plugins: Vec::new(),
};
let mut artifacts = Vec::new();
for package in &self.packages {
let manifest = &package.validated.manifest;
let identity = hash(manifest.plugin.id.as_bytes());
let root = runtime.join("plugins").join(&identity[..12]);
fs::create_dir(&root)?;
for file in package.validated.entries() {
if store::is_proxy_name(file.path.rsplit('/').next().unwrap()) {
return Err(io::Error::other(
"plugin packages may not deploy another proxy loader",
));
}
let destination = root.join(&file.path);
if path_text(&destination)?.encode_utf16().count() >= 260 {
return Err(io::Error::other(
"plugin resource exceeds the Windows path budget",
));
}
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
store::create_package_file(&destination, &file.data)?;
}
write_config(&root, manifest, &package.config)?;
let entry = package
.validated
.entries()
.iter()
.find(|file| file.path == manifest.entry.path)
.ok_or_else(|| io::Error::other("validated package is missing its entry"))?;
let path = root.join(&manifest.entry.path);
let plugin_dir = path.parent().unwrap();
let state = data_directory(
&data_root,
game_root,
&format!("plugins/state/{}", &identity[..12]),
)?;
let cache = data_directory(
&data_root,
game_root,
&format!("plugins/cache/{}", package.validated.digest),
)?;
let dirs = BTreeMap::from([
("data".into(), path_text(&state)?),
("cache".into(), path_text(&cache)?),
("log".into(), path_text(plugin_dir)?),
("plugin".into(), path_text(plugin_dir)?),
]);
plan.plugins.push(HostPlugin {
id: manifest.plugin.id.clone(),
version: manifest.plugin.version.to_string(),
kind: manifest.entry.kind.clone(),
phase: manifest.entry.phase,
path: path_text(&path)?,
sha256: hash(&entry.data),
config_json: serde_json::to_string(&package.config)?,
dirs,
status: manifest.status.clone(),
abi_major: manifest.target.abi_major,
});
artifacts.push(Artifact {
id: manifest.plugin.id.clone(),
version: manifest.plugin.version.to_string(),
package_sha256: package.validated.digest.clone(),
});
}
let bytes = wire_bytes(&plan)?;
let plan_sha256 = hash(&bytes);
store::create_package_file(&runtime.join("foch-host/plan.json"), &bytes)?;
store::create_package_file(
&runtime.join("foch-host/artifacts.json"),
&serde_json::to_vec_pretty(&artifacts)?,
)?;
Ok(Prepared {
plan,
plan_sha256,
artifacts,
})
}
pub fn prepare_font_cache(
&self,
data_root: &Path,
base_fonts_sha256: &str,
game_root: &Path,
) -> io::Result<Option<PathBuf>> {
let writers: Vec<_> = self
.packages
.iter()
.filter(|package| {
package
.validated
.manifest
.writes
.contains(&super::manifest::WriteKind::FontCache)
})
.map(|package| (&package.validated.digest, &package.config))
.collect();
if writers.is_empty() {
return Ok(None);
}
let identity = hash(&serde_json::to_vec(&(
self.game_version.as_str(),
base_fonts_sha256,
writers,
))?);
data_directory(
data_root,
game_root,
&format!("plugins/font-cache/{identity}"),
)
.map(Some)
}
}
fn data_directory(data_root: &Path, game_root: &Path, relative: &str) -> io::Result<PathBuf> {
let relative = store::safe_relative(relative).map_err(io::Error::other)?;
ensure_outside_game(game_root, data_root)?;
fs::create_dir_all(data_root)?;
let root = data_root.canonicalize()?;
ensure_outside_game(game_root, &root)?;
ensure_outside_game(game_root, &root.join(&relative))?;
store::prepare_data_directory(&root, &relative)
}
pub fn path_text(path: &Path) -> io::Result<String> {
let text = path
.to_str()
.ok_or_else(|| io::Error::other("runtime path is not Unicode"))?;
if let Some(unc) = text.strip_prefix(r"\\?\UNC\") {
return Ok(format!(r"\\{unc}"));
}
Ok(text.strip_prefix(r"\\?\").unwrap_or(text).into())
}
pub fn ensure_outside_game(game_root: &Path, destination: &Path) -> io::Result<()> {
if destination
.components()
.any(|part| part == std::path::Component::ParentDir)
{
return Err(io::Error::other(
"writable paths cannot contain parent traversal",
));
}
let source = game_root.canonicalize()?;
let mut existing = destination;
loop {
match fs::symlink_metadata(existing) {
Ok(_) => break,
Err(error) if error.kind() == io::ErrorKind::NotFound => {
existing = match existing
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
Some(parent) => parent,
None if !existing.is_absolute() && existing != Path::new(".") => Path::new("."),
None => return Err(error),
};
}
Err(error) => return Err(error),
}
}
let existing = existing.canonicalize()?;
#[cfg(windows)]
let inside = {
let source = source.to_string_lossy().replace('/', "\\").to_lowercase();
let existing = existing.to_string_lossy().replace('/', "\\").to_lowercase();
existing == source || existing.starts_with(&format!("{}\\", source.trim_end_matches('\\')))
};
#[cfg(not(windows))]
let inside = existing.starts_with(source);
if inside {
return Err(io::Error::other(
"writable paths must be outside the game installation",
));
}
Ok(())
}
pub fn hash(bytes: &[u8]) -> String {
format!("{:x}", Sha256::digest(bytes))
}
fn write_config(root: &Path, manifest: &Manifest, config: &Value) -> io::Result<()> {
let mut changes: BTreeMap<String, BTreeMap<(String, String), String>> = BTreeMap::new();
for (name, field) in &manifest.config {
let adapter = match field {
ConfigField::Bool { adapter, .. }
| ConfigField::Int { adapter, .. }
| ConfigField::String { adapter, .. } => adapter,
};
let Some(adapter) = adapter else {
continue;
};
let ini = store::safe_relative(&adapter.ini).map_err(io::Error::other)?;
if [&adapter.section, &adapter.key]
.iter()
.any(|text| text.is_empty() || text.contains(['\r', '\n', '\0', '[', ']', '=']))
{
return Err(io::Error::other("invalid INI adapter section/key"));
}
let value = match &config[name] {
Value::String(value) => value.clone(),
Value::Bool(value) => if *value { "1" } else { "0" }.into(),
other => other.to_string(),
};
if value.contains(['\r', '\n', '\0']) {
return Err(io::Error::other("INI value must be a single line"));
}
changes
.entry(ini)
.or_default()
.insert((adapter.section.clone(), adapter.key.clone()), value);
}
for (relative, changes) in changes {
let path = root.join(relative);
let text = match store::read_package_file(&path) {
Ok(bytes) => String::from_utf8(bytes)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?,
Err(error) if error.kind() == io::ErrorKind::NotFound => String::new(),
Err(error) => return Err(error),
};
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
store::replace_package_file(&path, update_ini(&text, changes).as_bytes())?;
}
Ok(())
}
fn update_ini(text: &str, changes: BTreeMap<(String, String), String>) -> String {
fn missing(
out: &mut String,
section: &str,
changes: &BTreeMap<(String, String), String>,
written: &mut BTreeSet<(String, String)>,
) {
for ((name, key), value) in changes {
if name.eq_ignore_ascii_case(section) && written.insert((name.clone(), key.clone())) {
out.push_str(&format!("{key}={value}\n"));
}
}
}
let mut out = String::new();
let mut section = String::new();
let mut written = BTreeSet::new();
for line in text.lines() {
let trimmed = line.trim();
if let Some(name) = trimmed
.strip_prefix('[')
.and_then(|name| name.strip_suffix(']'))
{
missing(&mut out, §ion, &changes, &mut written);
section = name.into();
}
if let Some((key, _)) = trimmed.split_once('=')
&& let Some((identity, value)) = changes.iter().find(|((name, candidate), _)| {
name.eq_ignore_ascii_case(§ion) && candidate.eq_ignore_ascii_case(key.trim())
}) {
written.insert(identity.clone());
out.push_str(&format!("{}={value}\n", key.trim()));
} else {
out.push_str(line);
out.push('\n');
}
}
missing(&mut out, §ion, &changes, &mut written);
let mut previous = None;
for ((section, key), value) in changes {
if written.contains(&(section.clone(), key.clone())) {
continue;
}
if previous.as_ref() != Some(§ion) {
out.push_str(&format!("\n[{section}]\n"));
previous = Some(section);
}
out.push_str(&format!("{key}={value}\n"));
}
out
}
pub fn states(plan: &HostPlan, runtime: &Path) -> io::Result<BTreeMap<String, Value>> {
plan.validate().map_err(io::Error::other)?;
let directory = status_directory(runtime)?;
let declared = Path::new(&plan.events);
if !declared
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.eq_ignore_ascii_case("events.jsonl"))
|| declared
.parent()
.and_then(|parent| parent.canonicalize().ok())
.as_deref()
!= Some(directory.as_path())
{
return Err(io::Error::other(
"status events must belong to the prepared runtime",
));
}
let mut states: BTreeMap<_, _> = plan
.plugins
.iter()
.map(|plugin| (plugin.id.clone(), json!({"state":"not_loaded"})))
.collect();
let path = directory.join("events.jsonl");
let file = match open_status_file(&path) {
Ok(file) => file,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(states),
Err(error) => return Err(error),
};
let snapshot_len = file.metadata()?.len();
let mut reader = BufReader::new(file.take(snapshot_len));
let mut line = Vec::new();
loop {
line.clear();
let count = reader
.by_ref()
.take((MAX_EVENT_RECORD_BYTES + 1) as u64)
.read_until(b'\n', &mut line)?;
if count > MAX_EVENT_RECORD_BYTES {
return Err(io::Error::other(
"host event record exceeds the 1 MiB limit",
));
}
if line.last() != Some(&b'\n') {
break;
}
let record: Value = serde_json::from_slice(&line)?;
if record["run_id"] == plan.run_id
&& record["event"] == "state"
&& let Some(id) = record["plugin_id"].as_str()
&& states.contains_key(id)
{
states.insert(id.into(), record);
}
}
Ok(states)
}
pub fn read_plan(runtime: &Path) -> io::Result<HostPlan> {
let directory = status_directory(runtime)?;
let file = open_status_file(&directory.join("plan.json"))?;
let mut bytes = Vec::new();
file.take((MAX_PLAN_BYTES + 1) as u64)
.read_to_end(&mut bytes)?;
if bytes.len() > MAX_PLAN_BYTES {
return Err(io::Error::other("host plan exceeds the 1 MiB limit"));
}
let plan: HostPlan = serde_json::from_slice(&bytes)?;
plan.validate().map_err(io::Error::other)?;
Ok(plan)
}
fn status_directory(runtime: &Path) -> io::Result<PathBuf> {
if !store::ordinary_metadata(runtime)?.is_dir()
|| !store::ordinary_metadata(&runtime.join("foch-runtime"))?.is_file()
{
return Err(io::Error::other("status requires a prepared runtime layer"));
}
let runtime = runtime.canonicalize()?;
let directory = runtime.join("foch-host");
if !store::ordinary_metadata(&directory)?.is_dir() {
return Err(io::Error::other("host directory is not ordinary"));
}
let directory = directory.canonicalize()?;
if directory.parent() != Some(runtime.as_path()) {
return Err(io::Error::other(
"host directory resolves outside the runtime",
));
}
Ok(directory)
}
fn status_file_options() -> fs::OpenOptions {
let mut options = fs::OpenOptions::new();
options.read(true);
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
use windows_sys::Win32::Storage::FileSystem::{
FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ, FILE_SHARE_WRITE,
};
options
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
}
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(rustix::fs::OFlags::NOFOLLOW.bits() as i32);
}
options
}
fn open_status_file(path: &Path) -> io::Result<fs::File> {
if !store::ordinary_metadata(path)?.is_file() {
return Err(io::Error::other("host file is not a regular file"));
}
let file = status_file_options().open(path)?;
if !file.metadata()?.is_file() {
return Err(io::Error::other("host file is not a regular file"));
}
#[cfg(windows)]
{
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::Storage::FileSystem::{
BY_HANDLE_FILE_INFORMATION, FILE_ATTRIBUTE_REPARSE_POINT, GetFileInformationByHandle,
};
let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut info) } == 0 {
return Err(io::Error::last_os_error());
}
if info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 || info.nNumberOfLinks != 1 {
return Err(io::Error::other("host file cannot be linked"));
}
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if file.metadata()?.nlink() != 1 {
return Err(io::Error::other("host file cannot be linked"));
}
}
Ok(file)
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn status_file_open_refuses_a_leaf_symlink_swap_after_metadata_validation() {
let scratch = tempfile::tempdir().unwrap();
let host = scratch.path().join("foch-host");
fs::create_dir(&host).unwrap();
let external = scratch.path().join("external");
fs::write(&external, "external bytes").unwrap();
for name in ["plan.json", "events.jsonl"] {
let path = host.join(name);
fs::write(&path, "owned bytes").unwrap();
assert!(store::ordinary_metadata(&path).unwrap().is_file());
let options = status_file_options();
fs::remove_file(&path).unwrap();
std::os::unix::fs::symlink(&external, &path).unwrap();
assert!(options.open(&path).is_err(), "followed a swapped {name}");
assert_eq!(fs::read_to_string(&external).unwrap(), "external bytes");
fs::remove_file(&path).unwrap();
fs::write(&path, "owned bytes").unwrap();
let mut bytes = String::new();
open_status_file(&path)
.unwrap()
.read_to_string(&mut bytes)
.unwrap();
assert_eq!(bytes, "owned bytes");
}
}
#[cfg(windows)]
fn status_fixture() -> (tempfile::TempDir, HostPlan) {
let root = tempfile::tempdir().unwrap();
fs::write(root.path().join("foch-runtime"), "1\n").unwrap();
fs::create_dir(root.path().join("foch-host")).unwrap();
let plan = HostPlan {
format: 1,
run_id: "status".into(),
game_version: "1.37.5".into(),
events: path_text(&root.path().join("foch-host/events.jsonl")).unwrap(),
plugins: ["a", "b"]
.into_iter()
.map(|id| HostPlugin {
id: id.into(),
version: "1.0.0".into(),
kind: Kind::Native,
phase: Phase::Entry,
path: path_text(&root.path().join(format!("{id}.dll"))).unwrap(),
sha256: "a".repeat(64),
config_json: "{}".into(),
dirs: BTreeMap::new(),
status: None,
abi_major: Some(1),
})
.collect(),
};
(root, plan)
}
#[test]
#[cfg(windows)]
fn oversized_status_records_are_rejected_even_without_a_final_newline() {
let (root, plan) = status_fixture();
for complete in [false, true] {
let mut bytes = vec![b' '; MAX_EVENT_RECORD_BYTES + 1];
if complete {
bytes.push(b'\n');
}
fs::write(root.path().join("foch-host/events.jsonl"), bytes).unwrap();
assert!(
states(&plan, root.path())
.unwrap_err()
.to_string()
.contains("event record exceeds")
);
}
}
#[test]
#[cfg(windows)]
fn status_stream_retains_latest_states_across_long_history_and_partial_records() {
use std::io::Write;
let (root, plan) = status_fixture();
let mut file = fs::File::create(root.path().join("foch-host/events.jsonl")).unwrap();
writeln!(
file,
"{}",
json!({"run_id":"status", "event":"state", "plugin_id":"a", "state":"active"})
)
.unwrap();
let log = json!({"run_id":"status", "event":"log", "message":"x".repeat(4096)}).to_string();
for _ in 0..1024 {
writeln!(file, "{log}").unwrap();
}
for (run, state) in [
("status", "active"),
("other", "refused"),
("status", "failed"),
] {
writeln!(
file,
"{}",
json!({"run_id":run, "event":"state", "plugin_id":"b", "state":state})
)
.unwrap();
}
write!(
file,
"{}",
json!({"run_id":"status", "event":"state", "plugin_id":"a", "state":"refused"})
)
.unwrap();
drop(file);
let result = states(&plan, root.path()).unwrap();
assert_eq!(result["a"]["state"], "active");
assert_eq!(result["b"]["state"], "failed");
}
#[test]
fn ini_updates_preserve_hard_link_targets_and_refuse_symbolic_links() {
let scratch = tempfile::tempdir().unwrap();
let root = scratch.path().join("package");
fs::create_dir(&root).unwrap();
let manifest =
super::super::builtin::adapter("io.github.yozoratempest.eu4-unicode-patch").unwrap();
let ConfigField::Int {
adapter: Some(adapter),
..
} = &manifest.config["typo_tolerance"]
else {
panic!("missing adapter")
};
let external = scratch.path().join("external.ini");
let original = "[search]\nother=yes\ntypo_tolerance=0\n";
fs::write(&external, original).unwrap();
let path = root.join(&adapter.ini);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::hard_link(&external, &path).unwrap();
let config = effective_config(&manifest, &json!({"typo_tolerance":2})).unwrap();
write_config(&root, &manifest, &config).unwrap();
assert_eq!(fs::read_to_string(&external).unwrap(), original);
let updated = fs::read_to_string(&path).unwrap();
assert!(updated.contains("other=yes"));
assert!(updated.contains("typo_tolerance=2"));
fs::remove_file(&path).unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(&external, &path).unwrap();
#[cfg(windows)]
std::os::windows::fs::symlink_file(&external, &path).unwrap();
#[cfg(any(unix, windows))]
{
assert!(write_config(&root, &manifest, &config).is_err());
assert_eq!(fs::read_to_string(&external).unwrap(), original);
}
}
#[test]
fn ini_paths_are_checked_before_any_configuration_write() {
let root = tempfile::tempdir().unwrap();
for path in [
".. /outside.ini",
"config.ini.",
"NUL.ini",
"config.ini:extra",
] {
let mut manifest = super::super::builtin::adapters().remove(0);
let ConfigField::Int {
adapter: Some(adapter),
..
} = manifest.config.get_mut("typo_tolerance").unwrap()
else {
panic!("missing adapter")
};
adapter.ini = path.into();
assert!(
write_config(root.path(), &manifest, &manifest.default_config()).is_err(),
"{path}"
);
assert_eq!(fs::read_dir(root.path()).unwrap().count(), 0, "{path}");
}
}
#[test]
fn adapter_config_merges_defaults_checks_types_and_preserves_other_ini_keys() {
let manifest =
super::super::builtin::adapter("io.github.yozoratempest.eu4-unicode-patch").unwrap();
assert_eq!(
effective_config(&manifest, &json!({"typo_tolerance":2})).unwrap(),
json!({"typo_tolerance":2,"fuzzy_pinyin":0})
);
for bad in [
json!({"typo_tolerance":3}),
json!({"fuzzy_pinyin":true}),
json!({"unknown":1}),
] {
assert!(effective_config(&manifest, &bad).is_err());
}
let text = update_ini(
"[search]\nother=yes\ntypo_tolerance=0\n",
BTreeMap::from([(("search".into(), "typo_tolerance".into()), "2".into())]),
);
assert!(text.contains("other=yes\n"));
assert!(text.contains("typo_tolerance=2\n"));
assert!(!text.contains("typo_tolerance=0"));
}
#[test]
fn font_cache_identity_tracks_every_writer_and_its_effective_inputs() {
fn writer(id: &str, resource: u8) -> Package {
let mut manifest = Manifest::parse("schema=1\n[plugin]\nid=\"fixture\"\nname=\"Fixture\"\nversion=\"1.0.0\"\n[target]\ngame=\"eu4\"\nplatform=\"windows-x86_64\"\ngame_versions=\"*\"\nabi_major=1\n[entry]\nkind=\"native\"\nphase=\"entry\"\npath=\"fixture.dll\"").unwrap();
manifest.plugin.id = id.into();
manifest.entry.path = format!("{id}.dll");
manifest
.writes
.push(super::super::manifest::WriteKind::FontCache);
manifest.config.insert(
"scale".into(),
ConfigField::Int {
default: 1,
min: Some(1),
max: Some(2),
description: None,
adapter: None,
},
);
let mut dll = vec![0; 0x80];
dll[..2].copy_from_slice(b"MZ");
dll[0x3c..0x40].copy_from_slice(&0x40u32.to_le_bytes());
dll[0x40..0x44].copy_from_slice(b"PE\0\0");
dll[0x44..0x46].copy_from_slice(&store::MACHINE_AMD64.to_le_bytes());
let validated = store::adapt(
manifest,
vec![
ArchiveEntry {
path: format!("{id}.dll"),
data: dll,
},
ArchiveEntry {
path: format!("{id}.font"),
data: vec![resource],
},
],
)
.unwrap();
let config = effective_config(&validated.manifest, &json!({})).unwrap();
Package { validated, config }
}
let scratch = tempfile::tempdir().unwrap();
let game = scratch.path().join("game");
fs::create_dir(&game).unwrap();
let data = scratch.path().join("data");
let mut deployment = Deployment {
game_version: "1.37.5".into(),
packages: Vec::new(),
resolution: Default::default(),
};
assert!(
deployment
.prepare_font_cache(&data, "fonts", &game)
.unwrap()
.is_none()
);
assert!(!data.exists());
deployment.packages = vec![writer("first", 1), writer("second", 2)];
let original = deployment
.prepare_font_cache(&data, "fonts", &game)
.unwrap()
.unwrap();
fs::write(original.join("generated"), "cached fonts").unwrap();
assert_eq!(
deployment
.prepare_font_cache(&data, "fonts", &game)
.unwrap()
.unwrap(),
original
);
deployment.packages[1] = writer("second", 3);
let changed = deployment
.prepare_font_cache(&data, "fonts", &game)
.unwrap()
.unwrap();
assert_ne!(
changed, original,
"second writer changed but stale fonts were reused"
);
assert!(!changed.join("generated").exists());
deployment.packages[1] = writer("second", 2);
for index in 0..2 {
deployment.packages[index].config = json!({"scale":2});
assert_ne!(
deployment
.prepare_font_cache(&data, "fonts", &game)
.unwrap()
.unwrap(),
original
);
deployment.packages[index].config = json!({"scale":1});
}
deployment.packages.reverse();
assert_ne!(
deployment
.prepare_font_cache(&data, "fonts", &game)
.unwrap()
.unwrap(),
original
);
deployment.packages.reverse();
assert_ne!(
deployment
.prepare_font_cache(&data, "new-fonts", &game)
.unwrap()
.unwrap(),
original
);
deployment.game_version = "1.38.0".into();
assert_ne!(
deployment
.prepare_font_cache(&data, "fonts", &game)
.unwrap()
.unwrap(),
original
);
deployment.game_version = "1.37.5".into();
assert_eq!(
deployment
.prepare_font_cache(&data, "fonts", &game)
.unwrap()
.unwrap(),
original
);
assert_eq!(
fs::read_to_string(original.join("generated")).unwrap(),
"cached fonts"
);
}
#[test]
fn missing_real_artifacts_are_refused_even_when_an_adapter_exists() {
let root = tempfile::tempdir().unwrap();
let adapter = super::super::builtin::adapters().remove(0);
let game = GameIdentity {
game: "eu4".into(),
version: "1.37.5".parse().unwrap(),
platform: planner::WINDOWS_X64.into(),
};
let chosen = Selection {
id: adapter.plugin.id,
version: adapter.plugin.version,
enabled: true,
config: json!({}),
};
assert!(resolve(&game, root.path(), &[chosen]).is_err());
}
#[test]
fn missing_ini_keys_are_added_to_the_existing_section() {
let text = update_ini(
"[search]\ntypo_tolerance=0\n[other]\nkeep=yes\n",
BTreeMap::from([
(("search".into(), "typo_tolerance".into()), "2".into()),
(("search".into(), "fuzzy_pinyin".into()), "1".into()),
]),
);
assert_eq!(text.matches("[search]").count(), 1);
assert!(text.find("fuzzy_pinyin=1").unwrap() < text.find("[other]").unwrap());
}
#[test]
fn configuration_defaults_and_bounds_are_validated() {
let mut manifest = super::super::builtin::adapters().remove(0);
manifest.config.insert(
"bad".into(),
ConfigField::Int {
default: 9,
min: Some(0),
max: Some(2),
description: None,
adapter: None,
},
);
assert!(effective_config(&manifest, &json!({})).is_err());
manifest.config.insert(
"bad".into(),
ConfigField::Int {
default: 1,
min: Some(2),
max: Some(0),
description: None,
adapter: None,
},
);
assert!(effective_config(&manifest, &json!({"bad":1})).is_err());
}
#[test]
fn oversized_configuration_cannot_produce_an_unreadable_host_plan() {
let mut plan = HostPlan {
format: 1,
run_id: "size".into(),
game_version: "1.37.5".into(),
events: r"C:\rt\events.jsonl".into(),
plugins: vec![HostPlugin {
id: "sample".into(),
version: "1.0.0".into(),
kind: Kind::Native,
phase: Phase::Entry,
path: r"C:\rt\sample.dll".into(),
sha256: "a".repeat(64),
config_json: "{}".into(),
dirs: BTreeMap::new(),
status: None,
abi_major: Some(1),
}],
};
assert!(wire_bytes(&plan).is_ok());
plan.plugins[0].config_json = json!({"text":"x".repeat(MAX_PLAN_BYTES)}).to_string();
assert!(wire_bytes(&plan).is_err());
}
}