feat: complete native execution and Steam discovery
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "kiln-adapter-native"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
kiln-core = { version = "=0.0.1", path = "../kiln-core" }
|
||||
serde = { version = "=1.0.228", features = ["derive"] }
|
||||
toml = "=1.1.3"
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = "=1.0.150"
|
||||
uuid = { version = "=1.24.0", features = ["v4"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "kiln-adapter-steam"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
kiln-core = { version = "=0.0.1", path = "../kiln-core" }
|
||||
uuid = "=1.24.0"
|
||||
|
||||
[dev-dependencies]
|
||||
uuid = { version = "=1.24.0", features = ["v4"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,729 @@
|
||||
//! Read-only Steam library discovery and normalization.
|
||||
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::env;
|
||||
use std::fmt;
|
||||
use std::fs::{self, File};
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use kiln_core::adapter::ProviderAdapter;
|
||||
use kiln_core::contract_error::{AmbiguousGameCandidate, ContractErrorCode, ContractErrorV1};
|
||||
use kiln_core::launch::LaunchPlanV1;
|
||||
use kiln_core::{
|
||||
Capability, ContractName, ContractText, GameId, GameRecordInput, GameRecordV1, InstallPath,
|
||||
InstallationId,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
const MAX_VDF_BYTES: u64 = 1024 * 1024;
|
||||
const MAX_LIBRARIES: usize = 128;
|
||||
const MAX_MANIFESTS: usize = 8192;
|
||||
const CAPABILITIES: &[Capability] = &[Capability::Discover, Capability::Info];
|
||||
const RECORD_CAPABILITIES: &[Capability] = &[
|
||||
Capability::Discover,
|
||||
Capability::Info,
|
||||
Capability::ClientRequired,
|
||||
];
|
||||
const INSTALLATION_NAMESPACE: u128 = 0x72d8_017f_93a9_47b7_0000_0000_0000_0000;
|
||||
|
||||
/// Read-only adapter backed by Steam's text library and app manifests.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SteamAdapter {
|
||||
name: ContractName,
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl SteamAdapter {
|
||||
/// Creates an adapter at an explicit Steam root.
|
||||
#[must_use]
|
||||
pub fn at(root: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
name: name("steam"),
|
||||
root: root.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Locates the conventional native or Flatpak Steam root for the current user.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a structured error when no absolute home directory is available.
|
||||
pub fn for_current_user() -> Result<Self, ContractErrorV1> {
|
||||
let home = env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.filter(|path| path.is_absolute())
|
||||
.ok_or_else(|| unavailable("no absolute home directory is available"))?;
|
||||
let native = home.join(".local/share/Steam");
|
||||
let flatpak = home.join(".var/app/com.valvesoftware.Steam/data/Steam");
|
||||
let root = if native.is_dir() { native } else { flatpak };
|
||||
Ok(Self::at(root))
|
||||
}
|
||||
|
||||
/// Returns matching records in deterministic App ID order.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a structured error when the query or Steam metadata is invalid.
|
||||
pub fn search(&self, query: &str) -> Result<Vec<GameRecordV1>, ContractErrorV1> {
|
||||
validate_selector(query)?;
|
||||
let needle = query.to_lowercase();
|
||||
Ok(self
|
||||
.discover()?
|
||||
.into_iter()
|
||||
.filter(|record| {
|
||||
record.id().to_string().contains(&needle)
|
||||
|| record.name().as_str().to_lowercase().contains(&needle)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn library_paths(&self) -> Result<Vec<PathBuf>, ContractErrorV1> {
|
||||
if !self.root.is_absolute() {
|
||||
return Err(invalid("Steam root must be absolute"));
|
||||
}
|
||||
let metadata = match fs::symlink_metadata(&self.root) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||
Err(error) => return Err(unavailable(format!("inspect Steam root: {error}"))),
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||
return Err(invalid("Steam root must be a real directory"));
|
||||
}
|
||||
let input = read_text(
|
||||
&self.root.join("steamapps/libraryfolders.vdf"),
|
||||
"library list",
|
||||
)?;
|
||||
let document = parse_vdf(&input)?;
|
||||
let folders = object(&document, "libraryfolders")?;
|
||||
if folders.len() > MAX_LIBRARIES {
|
||||
return Err(invalid("Steam library count exceeds 128"));
|
||||
}
|
||||
let mut paths = Vec::new();
|
||||
for value in folders.values() {
|
||||
let VdfValue::Object(folder) = value else {
|
||||
continue;
|
||||
};
|
||||
let Some(VdfValue::Text(raw)) = folder.get("path") else {
|
||||
continue;
|
||||
};
|
||||
let path: InstallPath = raw
|
||||
.clone()
|
||||
.try_into()
|
||||
.map_err(|_| invalid("Steam library path is invalid"))?;
|
||||
paths.push(path.as_path().to_path_buf());
|
||||
}
|
||||
paths.sort();
|
||||
paths.dedup();
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
fn records(&self) -> Result<Vec<GameRecordV1>, ContractErrorV1> {
|
||||
let mut records = Vec::new();
|
||||
let mut ids = HashSet::new();
|
||||
for library in self.library_paths()? {
|
||||
let metadata = match fs::symlink_metadata(&library) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(error) => return Err(unavailable(format!("inspect Steam library: {error}"))),
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||
return Err(invalid("Steam library must be a real directory"));
|
||||
}
|
||||
let steamapps = library.join("steamapps");
|
||||
let metadata = match fs::symlink_metadata(&steamapps) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(error) => return Err(unavailable(format!("inspect steamapps: {error}"))),
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||
return Err(invalid("steamapps must be a real directory"));
|
||||
}
|
||||
let mut manifests = Vec::new();
|
||||
for entry in fs::read_dir(&steamapps)
|
||||
.map_err(|error| unavailable(format!("read steamapps: {error}")))?
|
||||
{
|
||||
let path = entry
|
||||
.map_err(|error| unavailable(format!("read steamapps entry: {error}")))?
|
||||
.path();
|
||||
let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if file_name.starts_with("appmanifest_")
|
||||
&& path
|
||||
.extension()
|
||||
.is_some_and(|extension| extension.eq_ignore_ascii_case("acf"))
|
||||
{
|
||||
manifests.push(path);
|
||||
}
|
||||
if manifests.len() > MAX_MANIFESTS {
|
||||
return Err(invalid("Steam manifest count exceeds 8192"));
|
||||
}
|
||||
}
|
||||
manifests.sort();
|
||||
for path in manifests {
|
||||
if let Some(record) = load_manifest(&path, &steamapps)? {
|
||||
if !ids.insert(record.id().clone()) {
|
||||
return Err(invalid("duplicate installed Steam App ID"));
|
||||
}
|
||||
records.push(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
records.sort_by_key(|record| record.id().to_string());
|
||||
Ok(records)
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderAdapter for SteamAdapter {
|
||||
fn name(&self) -> &ContractName {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> &[Capability] {
|
||||
CAPABILITIES
|
||||
}
|
||||
|
||||
fn discover(&self) -> Result<Vec<GameRecordV1>, ContractErrorV1> {
|
||||
self.records()
|
||||
}
|
||||
|
||||
fn resolve(&self, selector: &str) -> Result<GameRecordV1, ContractErrorV1> {
|
||||
validate_selector(selector)?;
|
||||
let records = self.records()?;
|
||||
if selector.contains(':') {
|
||||
let id: GameId = selector
|
||||
.parse()
|
||||
.map_err(|_| invalid("invalid Steam game ID"))?;
|
||||
return records
|
||||
.into_iter()
|
||||
.find(|record| record.id() == &id)
|
||||
.ok_or_else(|| not_found("Steam game was not found"));
|
||||
}
|
||||
let normalized = selector.to_lowercase();
|
||||
let mut matches: Vec<_> = records
|
||||
.into_iter()
|
||||
.filter(|record| record.name().as_str().to_lowercase() == normalized)
|
||||
.collect();
|
||||
match matches.len() {
|
||||
0 => Err(not_found("Steam game was not found")),
|
||||
1 => Ok(matches.remove(0)),
|
||||
_ => Err(ContractErrorV1::ambiguous(
|
||||
text("Steam game name is ambiguous"),
|
||||
matches
|
||||
.into_iter()
|
||||
.map(|record| AmbiguousGameCandidate {
|
||||
id: record.id().clone(),
|
||||
store: name("steam"),
|
||||
installation_id: record.installation_id(),
|
||||
label: record.name().clone(),
|
||||
})
|
||||
.collect(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_launch(&self, _game_id: &GameId) -> Result<LaunchPlanV1, ContractErrorV1> {
|
||||
Err(ContractErrorV1::unsupported_capability(
|
||||
text("Steam launch planning begins in Phase 4"),
|
||||
name("launch"),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn load_manifest(path: &Path, steamapps: &Path) -> Result<Option<GameRecordV1>, ContractErrorV1> {
|
||||
let input = read_text(path, "app manifest")?;
|
||||
let document = parse_vdf(&input)?;
|
||||
let state = object(&document, "AppState")?;
|
||||
let app_id = field(state, "appid")?;
|
||||
let expected = format!("appmanifest_{app_id}.acf");
|
||||
if path.file_name().and_then(|name| name.to_str()) != Some(&expected) {
|
||||
return Err(invalid("Steam manifest filename and App ID differ"));
|
||||
}
|
||||
if app_id.is_empty() || !app_id.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return Err(invalid("Steam App ID is invalid"));
|
||||
}
|
||||
let numeric_app_id: u64 = app_id
|
||||
.parse()
|
||||
.map_err(|_| invalid("Steam App ID exceeds 64 bits"))?;
|
||||
let flags: u32 = field(state, "StateFlags")?
|
||||
.parse()
|
||||
.map_err(|_| invalid("Steam state flags are invalid"))?;
|
||||
if flags & 4 == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let game_name = field(state, "name")?.trim();
|
||||
// ponytail: text manifests lack app type; cover known runtime packages here and parse
|
||||
// binary appinfo only if real libraries show this heuristic hiding or exposing games.
|
||||
if is_component(app_id, game_name) {
|
||||
return Ok(None);
|
||||
}
|
||||
let install_dir = field(state, "installdir")?;
|
||||
if install_dir.is_empty()
|
||||
|| install_dir.chars().any(char::is_control)
|
||||
|| install_dir.contains(['/', '\\'])
|
||||
|| matches!(install_dir, "." | "..")
|
||||
{
|
||||
return Err(invalid("Steam install directory is invalid"));
|
||||
}
|
||||
let path = steamapps.join("common").join(install_dir);
|
||||
let metadata = match fs::symlink_metadata(&path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(unavailable(format!("inspect Steam install: {error}"))),
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||
return Err(invalid("Steam install path must be a real directory"));
|
||||
}
|
||||
let install_path: InstallPath = path
|
||||
.to_str()
|
||||
.ok_or_else(|| invalid("Steam install path is not UTF-8"))?
|
||||
.to_owned()
|
||||
.try_into()
|
||||
.map_err(|_| invalid("Steam install path is invalid"))?;
|
||||
let id: GameId = format!("steam:{app_id}")
|
||||
.parse()
|
||||
.map_err(|_| invalid("Steam App ID violated the game identity contract"))?;
|
||||
let installation_id = InstallationId::new(Uuid::from_u128(
|
||||
INSTALLATION_NAMESPACE | u128::from(numeric_app_id),
|
||||
));
|
||||
let record = GameRecordV1::new(GameRecordInput {
|
||||
id,
|
||||
name: game_name
|
||||
.parse()
|
||||
.map_err(|_| invalid(format!("Steam game name is invalid for App ID {app_id}")))?,
|
||||
store: name("steam"),
|
||||
adapter: name("steam"),
|
||||
installation_id,
|
||||
configuration_owner: name("steam"),
|
||||
execution_backend: name("steam"),
|
||||
installed: true,
|
||||
install_path,
|
||||
compatibility: None,
|
||||
capabilities: RECORD_CAPABILITIES.to_vec(),
|
||||
client_required: true,
|
||||
runtime: name("steam-managed"),
|
||||
})
|
||||
.map_err(|_| invalid("Steam record violated its contract"))?;
|
||||
Ok(Some(record))
|
||||
}
|
||||
|
||||
fn is_component(app_id: &str, game_name: &str) -> bool {
|
||||
app_id == "228980"
|
||||
|| game_name.starts_with("Proton ")
|
||||
|| game_name.starts_with("Steam Linux Runtime")
|
||||
|| game_name == "Steamworks Common Redistributables"
|
||||
}
|
||||
|
||||
fn read_text(path: &Path, label: &str) -> Result<String, ContractErrorV1> {
|
||||
let metadata = fs::symlink_metadata(path)
|
||||
.map_err(|error| unavailable(format!("read Steam {label} metadata: {error}")))?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
return Err(invalid(format!("Steam {label} must be a regular file")));
|
||||
}
|
||||
if metadata.len() > MAX_VDF_BYTES {
|
||||
return Err(invalid(format!("Steam {label} exceeds 1048576 bytes")));
|
||||
}
|
||||
let capacity = usize::try_from(metadata.len())
|
||||
.map_err(|_| invalid(format!("Steam {label} is too large")))?;
|
||||
let mut bytes = Vec::with_capacity(capacity);
|
||||
File::open(path)
|
||||
.and_then(|file| file.take(MAX_VDF_BYTES + 1).read_to_end(&mut bytes))
|
||||
.map_err(|error| unavailable(format!("read Steam {label}: {error}")))?;
|
||||
if bytes.len() as u64 > MAX_VDF_BYTES {
|
||||
return Err(invalid(format!("Steam {label} exceeds 1048576 bytes")));
|
||||
}
|
||||
String::from_utf8(bytes).map_err(|_| invalid(format!("Steam {label} is not UTF-8")))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum VdfValue {
|
||||
Text(String),
|
||||
Object(BTreeMap<String, VdfValue>),
|
||||
}
|
||||
|
||||
fn parse_vdf(input: &str) -> Result<BTreeMap<String, VdfValue>, ContractErrorV1> {
|
||||
let mut parser = Parser { input, offset: 0 };
|
||||
let object = parser.object(false)?;
|
||||
parser.skip_space();
|
||||
if parser.offset != input.len() {
|
||||
return Err(invalid("Steam VDF has trailing data"));
|
||||
}
|
||||
Ok(object)
|
||||
}
|
||||
|
||||
struct Parser<'a> {
|
||||
input: &'a str,
|
||||
offset: usize,
|
||||
}
|
||||
|
||||
impl Parser<'_> {
|
||||
fn object(&mut self, nested: bool) -> Result<BTreeMap<String, VdfValue>, ContractErrorV1> {
|
||||
let mut values = BTreeMap::new();
|
||||
loop {
|
||||
self.skip_space();
|
||||
if nested && self.consume('}') {
|
||||
return Ok(values);
|
||||
}
|
||||
if self.offset == self.input.len() {
|
||||
return if nested {
|
||||
Err(invalid("Steam VDF object is unterminated"))
|
||||
} else {
|
||||
Ok(values)
|
||||
};
|
||||
}
|
||||
let key = self.string()?;
|
||||
self.skip_space();
|
||||
let value = if self.consume('{') {
|
||||
VdfValue::Object(self.object(true)?)
|
||||
} else {
|
||||
VdfValue::Text(self.string()?)
|
||||
};
|
||||
if values.insert(key, value).is_some() {
|
||||
return Err(invalid("Steam VDF contains a duplicate key"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn string(&mut self) -> Result<String, ContractErrorV1> {
|
||||
self.skip_space();
|
||||
if !self.consume('"') {
|
||||
return Err(invalid("Steam VDF expected a quoted string"));
|
||||
}
|
||||
let mut value = String::new();
|
||||
while self.offset < self.input.len() {
|
||||
let character = self
|
||||
.next()
|
||||
.ok_or_else(|| invalid("Steam VDF string ended"))?;
|
||||
match character {
|
||||
'"' => return Ok(value),
|
||||
'\\' => match self.next() {
|
||||
Some('"') => value.push('"'),
|
||||
Some('\\') => value.push('\\'),
|
||||
Some('n') => value.push('\n'),
|
||||
Some('t') => value.push('\t'),
|
||||
_ => return Err(invalid("Steam VDF escape is invalid")),
|
||||
},
|
||||
value_character => value.push(value_character),
|
||||
}
|
||||
}
|
||||
Err(invalid("Steam VDF string is unterminated"))
|
||||
}
|
||||
|
||||
fn skip_space(&mut self) {
|
||||
loop {
|
||||
let remaining = &self.input[self.offset..];
|
||||
if remaining.starts_with("//") {
|
||||
self.offset += remaining.find('\n').unwrap_or(remaining.len());
|
||||
} else if remaining.chars().next().is_some_and(char::is_whitespace) {
|
||||
self.next();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn consume(&mut self, expected: char) -> bool {
|
||||
if self.input[self.offset..].starts_with(expected) {
|
||||
self.offset += expected.len_utf8();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn next(&mut self) -> Option<char> {
|
||||
let character = self.input[self.offset..].chars().next()?;
|
||||
self.offset += character.len_utf8();
|
||||
Some(character)
|
||||
}
|
||||
}
|
||||
|
||||
fn object<'a>(
|
||||
values: &'a BTreeMap<String, VdfValue>,
|
||||
key: &str,
|
||||
) -> Result<&'a BTreeMap<String, VdfValue>, ContractErrorV1> {
|
||||
match values.get(key) {
|
||||
Some(VdfValue::Object(value)) => Ok(value),
|
||||
_ => Err(invalid(format!("Steam VDF is missing {key}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn field<'a>(
|
||||
values: &'a BTreeMap<String, VdfValue>,
|
||||
key: &str,
|
||||
) -> Result<&'a str, ContractErrorV1> {
|
||||
match values.get(key) {
|
||||
Some(VdfValue::Text(value)) => Ok(value),
|
||||
_ => Err(invalid(format!("Steam app manifest is missing {key}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_selector(selector: &str) -> Result<(), ContractErrorV1> {
|
||||
if selector.is_empty() || selector.trim() != selector || selector.chars().any(char::is_control)
|
||||
{
|
||||
Err(invalid("game selector is invalid"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn name(raw: &str) -> ContractName {
|
||||
raw.parse().expect("static contract name")
|
||||
}
|
||||
|
||||
fn text(raw: &str) -> ContractText {
|
||||
raw.parse().expect("static contract text")
|
||||
}
|
||||
|
||||
fn invalid(message: impl fmt::Display) -> ContractErrorV1 {
|
||||
error(ContractErrorCode::InvalidInput, message)
|
||||
}
|
||||
|
||||
fn unavailable(message: impl fmt::Display) -> ContractErrorV1 {
|
||||
error(ContractErrorCode::TemporarilyUnavailable, message)
|
||||
}
|
||||
|
||||
fn not_found(message: &str) -> ContractErrorV1 {
|
||||
ContractErrorV1::new(ContractErrorCode::NotFound, text(message))
|
||||
}
|
||||
|
||||
fn error(code: ContractErrorCode, message: impl fmt::Display) -> ContractErrorV1 {
|
||||
let cleaned: String = message
|
||||
.to_string()
|
||||
.chars()
|
||||
.map(|character| {
|
||||
if character.is_control() {
|
||||
' '
|
||||
} else {
|
||||
character
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
ContractErrorV1::new(
|
||||
code,
|
||||
cleaned
|
||||
.trim()
|
||||
.parse()
|
||||
.unwrap_or_else(|_| text("Steam operation failed")),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fmt::Write as _;
|
||||
use std::fs;
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
use kiln_core::Capability;
|
||||
use kiln_core::adapter::ProviderAdapter;
|
||||
use kiln_core::contract_error::ContractErrorCode;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::SteamAdapter;
|
||||
|
||||
struct TestDirectory(PathBuf);
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
impl TestDirectory {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!("kiln-steam-test-{}", Uuid::new_v4()));
|
||||
fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn library(&self, name: &str) -> PathBuf {
|
||||
let path = self.0.join(name);
|
||||
fs::create_dir_all(path.join("steamapps/common")).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn folders(&self, libraries: &[&Path]) {
|
||||
let mut input = String::from("\"libraryfolders\"\n{\n");
|
||||
for (index, library) in libraries.iter().enumerate() {
|
||||
writeln!(
|
||||
input,
|
||||
"\"{index}\" {{ \"path\" \"{}\" }}",
|
||||
library.display()
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
input.push_str("}\n");
|
||||
fs::create_dir_all(self.0.join("steamapps")).unwrap();
|
||||
fs::write(self.0.join("steamapps/libraryfolders.vdf"), input).unwrap();
|
||||
}
|
||||
|
||||
fn manifest(library: &Path, app_id: &str, game_name: &str, install: &str) {
|
||||
fs::create_dir_all(library.join("steamapps/common").join(install)).unwrap();
|
||||
fs::write(
|
||||
library
|
||||
.join("steamapps")
|
||||
.join(format!("appmanifest_{app_id}.acf")),
|
||||
format!(
|
||||
"\"AppState\" {{ \"appid\" \"{app_id}\" \"name\" \"{game_name}\" \"StateFlags\" \"4\" \"installdir\" \"{install}\" }}"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDirectory {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn discovers_searches_and_normalizes_installed_games_without_launch() {
|
||||
let directory = TestDirectory::new();
|
||||
let library = directory.library("Library With Spaces");
|
||||
directory.folders(&[&library]);
|
||||
TestDirectory::manifest(&library, "620", "Portal 2 ", "Portal 2");
|
||||
let adapter = SteamAdapter::at(&directory.0);
|
||||
let records = adapter.discover().unwrap();
|
||||
assert_eq!(records.len(), 1);
|
||||
let record = &records[0];
|
||||
assert_eq!(record.id().to_string(), "steam:620");
|
||||
assert_eq!(record.name().as_str(), "Portal 2");
|
||||
assert_eq!(record.store().as_str(), "steam");
|
||||
assert_eq!(record.runtime().as_str(), "steam-managed");
|
||||
assert!(record.installed());
|
||||
assert!(record.client_required());
|
||||
assert_eq!(
|
||||
record.capabilities(),
|
||||
[
|
||||
Capability::Discover,
|
||||
Capability::Info,
|
||||
Capability::ClientRequired
|
||||
]
|
||||
);
|
||||
assert_eq!(adapter.search("portal").unwrap().len(), 1);
|
||||
assert_eq!(adapter.resolve("Portal 2").unwrap().id(), record.id());
|
||||
assert_eq!(
|
||||
adapter.plan_launch(record.id()).unwrap_err().code(),
|
||||
ContractErrorCode::UnsupportedCapability
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_unmounted_partial_and_known_runtime_entries() {
|
||||
let directory = TestDirectory::new();
|
||||
let library = directory.library("library");
|
||||
let missing = directory.0.join("not-mounted");
|
||||
directory.folders(&[&library, &missing]);
|
||||
TestDirectory::manifest(
|
||||
&library,
|
||||
"1826330",
|
||||
"Proton EasyAntiCheat Runtime",
|
||||
"Proton EAC",
|
||||
);
|
||||
fs::write(
|
||||
library.join("steamapps/appmanifest_10.acf"),
|
||||
"\"AppState\" { \"appid\" \"10\" \"name\" \"Partial\" \"StateFlags\" \"2\" \"installdir\" \"Partial\" }",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
SteamAdapter::at(&directory.0)
|
||||
.discover()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_installation_identity_survives_library_moves() {
|
||||
let directory = TestDirectory::new();
|
||||
let first = directory.library("first");
|
||||
directory.folders(&[&first]);
|
||||
TestDirectory::manifest(&first, "620", "Portal 2", "Portal 2");
|
||||
let adapter = SteamAdapter::at(&directory.0);
|
||||
let before = adapter.discover().unwrap()[0].installation_id();
|
||||
|
||||
let second = directory.library("second");
|
||||
TestDirectory::manifest(&second, "620", "Portal 2", "Moved Portal");
|
||||
directory.folders(&[&second]);
|
||||
let after = adapter.discover().unwrap()[0].installation_id();
|
||||
assert_eq!(before, after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_traversal_non_utf8_oversize_and_symlinked_metadata() {
|
||||
let directory = TestDirectory::new();
|
||||
let first = directory.library("first");
|
||||
let second = directory.library("second");
|
||||
directory.folders(&[&first, &second]);
|
||||
TestDirectory::manifest(&first, "620", "Portal 2", "Portal 2");
|
||||
TestDirectory::manifest(&second, "620", "Portal 2", "Portal 2");
|
||||
assert_eq!(
|
||||
SteamAdapter::at(&directory.0)
|
||||
.discover()
|
||||
.unwrap_err()
|
||||
.code(),
|
||||
ContractErrorCode::InvalidInput
|
||||
);
|
||||
|
||||
directory.folders(&[&first]);
|
||||
fs::write(
|
||||
first.join("steamapps/appmanifest_620.acf"),
|
||||
"\"AppState\" { \"appid\" \"620\" \"name\" \"Portal\" \"StateFlags\" \"4\" \"installdir\" \"../escape\" }",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
SteamAdapter::at(&directory.0)
|
||||
.discover()
|
||||
.unwrap_err()
|
||||
.code(),
|
||||
ContractErrorCode::InvalidInput
|
||||
);
|
||||
|
||||
fs::write(
|
||||
first.join("steamapps/appmanifest_620.acf"),
|
||||
"\"AppState\" { \"appid\" \"620\" \"StateFlags\" \"4\" \"installdir\" \"Portal 2\" }",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
SteamAdapter::at(&directory.0)
|
||||
.discover()
|
||||
.unwrap_err()
|
||||
.code(),
|
||||
ContractErrorCode::InvalidInput
|
||||
);
|
||||
|
||||
fs::write(first.join("steamapps/appmanifest_620.acf"), [0xff]).unwrap();
|
||||
assert_eq!(
|
||||
SteamAdapter::at(&directory.0)
|
||||
.discover()
|
||||
.unwrap_err()
|
||||
.code(),
|
||||
ContractErrorCode::InvalidInput
|
||||
);
|
||||
|
||||
fs::write(
|
||||
first.join("steamapps/appmanifest_620.acf"),
|
||||
vec![b'x'; 1024 * 1024 + 1],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
SteamAdapter::at(&directory.0)
|
||||
.discover()
|
||||
.unwrap_err()
|
||||
.code(),
|
||||
ContractErrorCode::InvalidInput
|
||||
);
|
||||
|
||||
fs::remove_file(first.join("steamapps/appmanifest_620.acf")).unwrap();
|
||||
let target = first.join("target.acf");
|
||||
fs::write(&target, "\"AppState\" {}").unwrap();
|
||||
symlink(&target, first.join("steamapps/appmanifest_620.acf")).unwrap();
|
||||
assert_eq!(
|
||||
SteamAdapter::at(&directory.0)
|
||||
.discover()
|
||||
.unwrap_err()
|
||||
.code(),
|
||||
ContractErrorCode::InvalidInput
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,9 @@ path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
kiln-core = { version = "=0.0.1", path = "../kiln-core" }
|
||||
kiln-adapter-native = { version = "=0.0.1", path = "../kiln-adapter-native" }
|
||||
kiln-adapter-steam = { version = "=0.0.1", path = "../kiln-adapter-steam" }
|
||||
serde_json = "=1.0.150"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
+447
-17
@@ -1,36 +1,466 @@
|
||||
//! Private smoke CLI for the Project Kiln workspace.
|
||||
//! Project Kiln command-line entry point.
|
||||
|
||||
use std::env;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use kiln_core::CONTRACT_SCHEMA_VERSION;
|
||||
use kiln_adapter_native::{NativeAdapter, current_log_path, execute};
|
||||
use kiln_adapter_steam::SteamAdapter;
|
||||
use kiln_core::adapter::ProviderAdapter;
|
||||
use kiln_core::config::{
|
||||
COMMENTED_DEFAULT_CONFIG, CONFIG_SCHEMA_VERSION, ConfigurationSource, ConfigurationTier,
|
||||
EditableScalar, built_in_configuration, configuration_path_for_current_user,
|
||||
load_configuration, resolve_layers, set_configuration_scalar,
|
||||
};
|
||||
use kiln_core::contract_error::{
|
||||
CONTRACT_ERROR_SCHEMA_VERSION, ContractErrorCode, ContractErrorV1,
|
||||
};
|
||||
use kiln_core::launch::LAUNCH_PLAN_SCHEMA_VERSION;
|
||||
use kiln_core::registry::INSTALLATION_REGISTRY_SCHEMA_VERSION;
|
||||
use kiln_core::{CONTRACT_SCHEMA_VERSION, ContractName, ContractText, GameRecordV1};
|
||||
use serde_json::json;
|
||||
|
||||
const HELP: &str = "Project Kiln private scaffold\n\nUSAGE:\n kiln <COMMAND>\n\nCOMMANDS:\n version Print the private build version\n doctor [--json] Check the scaffold contract\n help Print this help\n";
|
||||
const HELP: &str = "Project Kiln private development CLI
|
||||
|
||||
Usage:
|
||||
kiln doctor [--json]
|
||||
kiln list [--provider native|steam] [--json]
|
||||
kiln search <query> [--provider native|steam] [--json]
|
||||
kiln info <id-or-name> [--provider native|steam] [--json]
|
||||
kiln scan [--provider native|steam] [--no-cache] [--json]
|
||||
kiln launch <id-or-name> [--dry-run] [--json]
|
||||
kiln config defaults
|
||||
kiln config validate [--json]
|
||||
kiln config explain [--json]
|
||||
kiln config set runtime|session-backend <value> [--json]
|
||||
kiln --help
|
||||
kiln --version";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let mut arguments = env::args().skip(1);
|
||||
match (arguments.next().as_deref(), arguments.next().as_deref()) {
|
||||
(None | Some("help" | "--help" | "-h"), None) => {
|
||||
print!("{HELP}");
|
||||
run(env::args().skip(1).collect())
|
||||
}
|
||||
|
||||
fn run(mut arguments: Vec<String>) -> ExitCode {
|
||||
let json_output = take_flag(&mut arguments, "--json");
|
||||
if arguments.first().is_some_and(|argument| argument == "scan") {
|
||||
let _ = take_flag(&mut arguments, "--no-cache");
|
||||
}
|
||||
if let Some(result) = explicit_provider_command(&arguments, json_output) {
|
||||
return result;
|
||||
}
|
||||
match arguments.as_slice() {
|
||||
[flag] if flag == "--help" || flag == "-h" => {
|
||||
println!("{HELP}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
(Some("version" | "--version" | "-V"), None) => {
|
||||
println!("kiln {} (private placeholder)", env!("CARGO_PKG_VERSION"));
|
||||
[flag] if flag == "--version" || flag == "-V" => {
|
||||
println!("kiln {}", env!("CARGO_PKG_VERSION"));
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
(Some("doctor"), None) => {
|
||||
println!("ok: core contract schema {CONTRACT_SCHEMA_VERSION}");
|
||||
ExitCode::SUCCESS
|
||||
[command] if command == "doctor" => doctor(json_output),
|
||||
[command] if command == "list" => with_adapter(json_output, |adapter| {
|
||||
output_records(adapter.discover()?, json_output);
|
||||
Ok(())
|
||||
}),
|
||||
[command, query] if command == "search" => with_adapter(json_output, |adapter| {
|
||||
output_records(adapter.search(query)?, json_output);
|
||||
Ok(())
|
||||
}),
|
||||
[command, selector] if command == "info" && selector.starts_with("steam:") => {
|
||||
with_steam(json_output, |adapter| {
|
||||
output_record(&adapter.resolve(selector)?, json_output);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
(Some("doctor"), Some("--json")) => {
|
||||
println!(
|
||||
"{{\"schema_version\":{CONTRACT_SCHEMA_VERSION},\"status\":\"ok\",\"private_placeholder\":true}}"
|
||||
);
|
||||
ExitCode::SUCCESS
|
||||
[command, selector] if command == "info" => with_adapter(json_output, |adapter| {
|
||||
output_record(&adapter.resolve(selector)?, json_output);
|
||||
Ok(())
|
||||
}),
|
||||
[command] if command == "scan" => scan_native(json_output),
|
||||
_ if arguments.first().is_some_and(|value| value == "launch") => {
|
||||
launch(arguments, json_output)
|
||||
}
|
||||
_ if arguments.first().is_some_and(|value| value == "config") => {
|
||||
config(&arguments, json_output)
|
||||
}
|
||||
[command, ..] if command == "frontend" && json_output => {
|
||||
json_error(&ContractErrorV1::unsupported_capability(
|
||||
text("frontend capabilities are post-version-1"),
|
||||
name("frontend"),
|
||||
))
|
||||
}
|
||||
_ if json_output => json_error(&ContractErrorV1::new(
|
||||
ContractErrorCode::InvalidInput,
|
||||
text("unsupported arguments"),
|
||||
)),
|
||||
_ => {
|
||||
eprintln!("error: unsupported arguments\n\n{HELP}");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn explicit_provider_command(arguments: &[String], json_output: bool) -> Option<ExitCode> {
|
||||
match arguments {
|
||||
[command, provider, value] if command == "list" && provider == "--provider" => {
|
||||
Some(list_provider(value, json_output))
|
||||
}
|
||||
[command, query, provider, value] if command == "search" && provider == "--provider" => {
|
||||
Some(search_provider(value, query, json_output))
|
||||
}
|
||||
[command, selector, provider, value] if command == "info" && provider == "--provider" => {
|
||||
Some(info_provider(value, selector, json_output))
|
||||
}
|
||||
[command, provider, value] if command == "scan" && provider == "--provider" => {
|
||||
Some(match value.as_str() {
|
||||
"native" => scan_native(json_output),
|
||||
"steam" => scan_steam(json_output),
|
||||
_ => invalid_arguments(json_output),
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn list_provider(provider: &str, json_output: bool) -> ExitCode {
|
||||
match provider {
|
||||
"native" => with_adapter(json_output, |adapter| {
|
||||
output_records(adapter.discover()?, json_output);
|
||||
Ok(())
|
||||
}),
|
||||
"steam" => with_steam(json_output, |adapter| {
|
||||
output_records(adapter.discover()?, json_output);
|
||||
Ok(())
|
||||
}),
|
||||
_ => invalid_arguments(json_output),
|
||||
}
|
||||
}
|
||||
|
||||
fn search_provider(provider: &str, query: &str, json_output: bool) -> ExitCode {
|
||||
match provider {
|
||||
"native" => with_adapter(json_output, |adapter| {
|
||||
output_records(adapter.search(query)?, json_output);
|
||||
Ok(())
|
||||
}),
|
||||
"steam" => with_steam(json_output, |adapter| {
|
||||
output_records(adapter.search(query)?, json_output);
|
||||
Ok(())
|
||||
}),
|
||||
_ => invalid_arguments(json_output),
|
||||
}
|
||||
}
|
||||
|
||||
fn info_provider(provider: &str, selector: &str, json_output: bool) -> ExitCode {
|
||||
match provider {
|
||||
"native" => with_adapter(json_output, |adapter| {
|
||||
output_record(&adapter.resolve(selector)?, json_output);
|
||||
Ok(())
|
||||
}),
|
||||
"steam" => with_steam(json_output, |adapter| {
|
||||
output_record(&adapter.resolve(selector)?, json_output);
|
||||
Ok(())
|
||||
}),
|
||||
_ => invalid_arguments(json_output),
|
||||
}
|
||||
}
|
||||
|
||||
fn config(arguments: &[String], json_output: bool) -> ExitCode {
|
||||
let result = match arguments {
|
||||
[command, operation] if command == "config" && operation == "defaults" => {
|
||||
print!("{COMMENTED_DEFAULT_CONFIG}");
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
[command, operation] if command == "config" && operation == "validate" => {
|
||||
validate_config(json_output)
|
||||
}
|
||||
[command, operation] if command == "config" && operation == "explain" => {
|
||||
explain_config(json_output)
|
||||
}
|
||||
[command, operation, field, value] if command == "config" && operation == "set" => {
|
||||
set_config(field, value, json_output)
|
||||
}
|
||||
_ => return invalid_arguments(json_output),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) if json_output => json_error(&error),
|
||||
Err(error) => {
|
||||
eprintln!("error: {error}");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_config(json_output: bool) -> Result<(), ContractErrorV1> {
|
||||
let path = configuration_path_for_current_user().map_err(configuration_error)?;
|
||||
let layer = load_configuration(
|
||||
&path,
|
||||
ConfigurationSource {
|
||||
tier: ConfigurationTier::GlobalUser,
|
||||
label: text("global user configuration"),
|
||||
},
|
||||
)
|
||||
.map_err(configuration_error)?;
|
||||
if json_output {
|
||||
println!(
|
||||
"{}",
|
||||
json!({"status": "ok", "path": path, "exists": layer.is_some()})
|
||||
);
|
||||
} else if layer.is_some() {
|
||||
println!("ok: {}", path.display());
|
||||
} else {
|
||||
println!("ok: defaults in use; {} does not exist", path.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn explain_config(json_output: bool) -> Result<(), ContractErrorV1> {
|
||||
let path = configuration_path_for_current_user().map_err(configuration_error)?;
|
||||
let mut layers = vec![built_in_configuration()];
|
||||
if let Some(layer) = load_configuration(
|
||||
&path,
|
||||
ConfigurationSource {
|
||||
tier: ConfigurationTier::GlobalUser,
|
||||
label: text("global user configuration"),
|
||||
},
|
||||
)
|
||||
.map_err(configuration_error)?
|
||||
{
|
||||
layers.push(layer);
|
||||
}
|
||||
let resolved = resolve_layers(&layers).map_err(configuration_error)?;
|
||||
let output = if json_output {
|
||||
serde_json::to_string(&resolved)
|
||||
} else {
|
||||
serde_json::to_string_pretty(&resolved)
|
||||
}
|
||||
.expect("configuration explanation serializes");
|
||||
println!("{output}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_config(field: &str, raw: &str, json_output: bool) -> Result<(), ContractErrorV1> {
|
||||
let scalar = match field {
|
||||
"runtime" => EditableScalar::Runtime,
|
||||
"session-backend" => EditableScalar::SessionBackend,
|
||||
_ => {
|
||||
return Err(ContractErrorV1::new(
|
||||
ContractErrorCode::InvalidInput,
|
||||
text("config set supports runtime or session-backend"),
|
||||
));
|
||||
}
|
||||
};
|
||||
let value: ContractName = raw.parse().map_err(|_| {
|
||||
ContractErrorV1::new(
|
||||
ContractErrorCode::InvalidInput,
|
||||
text("configuration value must be a valid contract name"),
|
||||
)
|
||||
})?;
|
||||
let path = configuration_path_for_current_user().map_err(configuration_error)?;
|
||||
set_configuration_scalar(&path, scalar, &value).map_err(configuration_error)?;
|
||||
if json_output {
|
||||
println!(
|
||||
"{}",
|
||||
json!({"status": "updated", "path": path, "field": field, "value": value})
|
||||
);
|
||||
} else {
|
||||
println!("updated {field} in {}", path.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn configuration_error(error: impl std::fmt::Display) -> ContractErrorV1 {
|
||||
let message = format!("configuration error: {error}");
|
||||
ContractErrorV1::new(
|
||||
ContractErrorCode::InvalidInput,
|
||||
message
|
||||
.parse()
|
||||
.unwrap_or_else(|_| text("configuration operation failed")),
|
||||
)
|
||||
}
|
||||
|
||||
fn doctor(json_output: bool) -> ExitCode {
|
||||
if json_output {
|
||||
println!(
|
||||
"{}",
|
||||
json!({
|
||||
"status": "ok",
|
||||
"contracts": {
|
||||
"game_record": CONTRACT_SCHEMA_VERSION,
|
||||
"installation_registry": INSTALLATION_REGISTRY_SCHEMA_VERSION,
|
||||
"launch_plan": LAUNCH_PLAN_SCHEMA_VERSION,
|
||||
"error": CONTRACT_ERROR_SCHEMA_VERSION,
|
||||
"configuration": CONFIG_SCHEMA_VERSION
|
||||
}
|
||||
})
|
||||
);
|
||||
} else {
|
||||
println!("ok: core contract schema {CONTRACT_SCHEMA_VERSION}");
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
fn scan_native(json_output: bool) -> ExitCode {
|
||||
with_adapter(json_output, |adapter| {
|
||||
let records = adapter.discover()?;
|
||||
if json_output {
|
||||
println!(
|
||||
"{}",
|
||||
json!({"provider": "native", "discovered": records.len()})
|
||||
);
|
||||
} else {
|
||||
println!("native: {} installed game(s)", records.len());
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn scan_steam(json_output: bool) -> ExitCode {
|
||||
with_steam(json_output, |adapter| {
|
||||
let records = adapter.discover()?;
|
||||
if json_output {
|
||||
println!(
|
||||
"{}",
|
||||
json!({"provider": "steam", "discovered": records.len()})
|
||||
);
|
||||
} else {
|
||||
println!("steam: {} installed game(s)", records.len());
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn launch(mut arguments: Vec<String>, json_output: bool) -> ExitCode {
|
||||
let dry_run = take_flag(&mut arguments, "--dry-run");
|
||||
let _ = take_flag(&mut arguments, "--no-cache");
|
||||
let [command, selector] = arguments.as_slice() else {
|
||||
return invalid_arguments(json_output);
|
||||
};
|
||||
if command != "launch" {
|
||||
return invalid_arguments(json_output);
|
||||
}
|
||||
with_adapter(json_output, |adapter| {
|
||||
let record = adapter.resolve(selector)?;
|
||||
let plan = adapter.plan_launch(record.id())?;
|
||||
if dry_run {
|
||||
if json_output {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&plan).expect("launch plan serializes")
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&plan).expect("launch plan serializes")
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let log = current_log_path()?;
|
||||
let outcome = execute(&plan, &log)?;
|
||||
if json_output {
|
||||
println!(
|
||||
"{}",
|
||||
json!({"game_id": record.id(), "status": "exited", "exit_code": outcome.exit_code, "log": log})
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"{} exited successfully; log: {}",
|
||||
record.id(),
|
||||
log.display()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn with_adapter(
|
||||
json_output: bool,
|
||||
operation: impl FnOnce(&NativeAdapter) -> Result<(), ContractErrorV1>,
|
||||
) -> ExitCode {
|
||||
match NativeAdapter::for_current_user().and_then(|adapter| operation(&adapter)) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) if json_output => json_error(&error),
|
||||
Err(error) => {
|
||||
eprintln!("error: {error}");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn with_steam(
|
||||
json_output: bool,
|
||||
operation: impl FnOnce(&SteamAdapter) -> Result<(), ContractErrorV1>,
|
||||
) -> ExitCode {
|
||||
match SteamAdapter::for_current_user().and_then(|adapter| operation(&adapter)) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) if json_output => json_error(&error),
|
||||
Err(error) => {
|
||||
eprintln!("error: {error}");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn output_records(records: Vec<GameRecordV1>, json_output: bool) {
|
||||
if json_output {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&records).expect("records serialize")
|
||||
);
|
||||
} else {
|
||||
for record in records {
|
||||
println!("{}\t{}", record.id(), record.name().as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn output_record(record: &GameRecordV1, json_output: bool) {
|
||||
if json_output {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(record).expect("record serializes")
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"{}\t{}\t{}",
|
||||
record.id(),
|
||||
record.name().as_str(),
|
||||
record.install_path().as_path().display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_arguments(json_output: bool) -> ExitCode {
|
||||
let error = ContractErrorV1::new(
|
||||
ContractErrorCode::InvalidInput,
|
||||
text("unsupported arguments"),
|
||||
);
|
||||
if json_output {
|
||||
json_error(&error)
|
||||
} else {
|
||||
eprintln!("error: unsupported arguments\n\n{HELP}");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
|
||||
fn take_flag(arguments: &mut Vec<String>, flag: &str) -> bool {
|
||||
let found = arguments.iter().any(|argument| argument == flag);
|
||||
arguments.retain(|argument| argument != flag);
|
||||
found
|
||||
}
|
||||
|
||||
fn json_error(error: &ContractErrorV1) -> ExitCode {
|
||||
eprintln!(
|
||||
"{}",
|
||||
serde_json::to_string(error).expect("contract error serializes")
|
||||
);
|
||||
ExitCode::from(2)
|
||||
}
|
||||
|
||||
fn name(raw: &str) -> ContractName {
|
||||
raw.parse().expect("static contract name")
|
||||
}
|
||||
|
||||
fn text(raw: &str) -> ContractText {
|
||||
raw.parse().expect("static contract text")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
//! Isolated CLI contract tests for Phase 1 machine output.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
fn kiln(arguments: &[&str]) -> std::process::Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_kiln"))
|
||||
.args(arguments)
|
||||
.output()
|
||||
.expect("kiln runs")
|
||||
}
|
||||
|
||||
struct TestDirectory(PathBuf);
|
||||
|
||||
impl TestDirectory {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"kiln-cli-test-{}-{}",
|
||||
std::process::id(),
|
||||
std::thread::current().name().unwrap_or("unnamed")
|
||||
));
|
||||
let _ = fs::remove_dir_all(&path);
|
||||
fs::create_dir(&path).expect("test directory");
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn run(&self, arguments: &[&str]) -> std::process::Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_kiln"))
|
||||
.args(arguments)
|
||||
.env("XDG_CONFIG_HOME", self.0.join("config"))
|
||||
.env("XDG_STATE_HOME", self.0.join("state"))
|
||||
.env("HOME", self.0.join("home"))
|
||||
.output()
|
||||
.expect("kiln runs")
|
||||
}
|
||||
|
||||
fn add_game(&self, id: &str, name: &str, executable: &Path) {
|
||||
let manifests = self.0.join("config/kiln/native");
|
||||
let install = self.0.join("games").join(id);
|
||||
fs::create_dir_all(&manifests).expect("manifest directory");
|
||||
fs::create_dir_all(&install).expect("install directory");
|
||||
fs::write(
|
||||
manifests.join(format!("{id}.toml")),
|
||||
format!(
|
||||
"schema_version = 1\nid = \"{id}\"\nname = \"{name}\"\ninstall_path = \"{}\"\nexecutable = \"{}\"\narguments = [\"native-output\"]\n",
|
||||
install.display(),
|
||||
executable.display()
|
||||
),
|
||||
)
|
||||
.expect("manifest");
|
||||
}
|
||||
|
||||
fn add_steam_game(&self, app_id: &str, name: &str) {
|
||||
let root = self.0.join("home/.local/share/Steam");
|
||||
let steamapps = root.join("steamapps");
|
||||
let install = steamapps.join("common").join(name);
|
||||
fs::create_dir_all(&install).unwrap();
|
||||
fs::write(
|
||||
steamapps.join("libraryfolders.vdf"),
|
||||
format!(
|
||||
"\"libraryfolders\" {{ \"0\" {{ \"path\" \"{}\" }} }}",
|
||||
root.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
steamapps.join(format!("appmanifest_{app_id}.acf")),
|
||||
format!(
|
||||
"\"AppState\" {{ \"appid\" \"{app_id}\" \"name\" \"{name}\" \"StateFlags\" \"4\" \"installdir\" \"{name}\" }}"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDirectory {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doctor_reports_every_phase_one_schema() {
|
||||
let output = kiln(&["doctor", "--json"]);
|
||||
assert!(output.status.success());
|
||||
let json: Value = serde_json::from_slice(&output.stdout).expect("doctor JSON");
|
||||
assert_eq!(json["status"], "ok");
|
||||
for contract in [
|
||||
"game_record",
|
||||
"installation_registry",
|
||||
"launch_plan",
|
||||
"error",
|
||||
"configuration",
|
||||
] {
|
||||
let expected = if contract == "launch_plan" { 2 } else { 1 };
|
||||
assert_eq!(json["contracts"][contract], expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_invalid_arguments_return_structured_error() {
|
||||
let output = kiln(&["unknown", "--json"]);
|
||||
assert_eq!(output.status.code(), Some(2));
|
||||
let json: Value = serde_json::from_slice(&output.stderr).expect("error JSON");
|
||||
assert_eq!(json["schema_version"], 1);
|
||||
assert_eq!(json["code"], "invalid-input");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_frontend_returns_unsupported_capability() {
|
||||
let output = kiln(&["frontend", "sync", "--json"]);
|
||||
assert_eq!(output.status.code(), Some(2));
|
||||
let json: Value = serde_json::from_slice(&output.stderr).expect("error JSON");
|
||||
assert_eq!(json["code"], "unsupported-capability");
|
||||
assert_eq!(json["capability"], "frontend");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_cli_completes_discovery_search_plan_execution_and_logging() {
|
||||
let directory = TestDirectory::new();
|
||||
directory.add_game("sample", "Sample Game", Path::new("/usr/bin/printf"));
|
||||
|
||||
let list = directory.run(&["list", "--json"]);
|
||||
assert!(list.status.success());
|
||||
let records: Value = serde_json::from_slice(&list.stdout).expect("list JSON");
|
||||
assert_eq!(records[0]["id"], "native:sample");
|
||||
|
||||
let search = directory.run(&["search", "sample", "--json"]);
|
||||
assert!(search.status.success());
|
||||
let records: Value = serde_json::from_slice(&search.stdout).expect("search JSON");
|
||||
assert_eq!(records.as_array().unwrap().len(), 1);
|
||||
|
||||
let info = directory.run(&["info", "native:sample", "--json"]);
|
||||
assert!(info.status.success());
|
||||
let record: Value = serde_json::from_slice(&info.stdout).expect("info JSON");
|
||||
assert_eq!(record["runtime"], "native");
|
||||
|
||||
let scan = directory.run(&["scan", "--provider", "native", "--no-cache", "--json"]);
|
||||
assert!(scan.status.success());
|
||||
let result: Value = serde_json::from_slice(&scan.stdout).expect("scan JSON");
|
||||
assert_eq!(result["discovered"], 1);
|
||||
|
||||
let dry_run = directory.run(&["launch", "Sample Game", "--dry-run", "--json"]);
|
||||
assert!(dry_run.status.success());
|
||||
let plan: Value = serde_json::from_slice(&dry_run.stdout).expect("plan JSON");
|
||||
assert_eq!(plan["command"]["arguments"][0], "native-output");
|
||||
|
||||
let launch = directory.run(&["launch", "native:sample", "--json"]);
|
||||
assert!(launch.status.success());
|
||||
let outcome: Value = serde_json::from_slice(&launch.stdout).expect("outcome JSON");
|
||||
assert_eq!(outcome["exit_code"], 0);
|
||||
assert!(
|
||||
fs::read_to_string(directory.0.join("state/kiln/logs/native.log"))
|
||||
.expect("launch log")
|
||||
.contains("native-output")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_native_manifest_returns_structured_failure() {
|
||||
let directory = TestDirectory::new();
|
||||
let manifests = directory.0.join("config/kiln/native");
|
||||
fs::create_dir_all(&manifests).unwrap();
|
||||
fs::write(manifests.join("bad.toml"), "schema_version = 99\n").unwrap();
|
||||
let output = directory.run(&["list", "--json"]);
|
||||
assert_eq!(output.status.code(), Some(2));
|
||||
let error: Value = serde_json::from_slice(&output.stderr).expect("error JSON");
|
||||
assert!(matches!(
|
||||
error["code"].as_str(),
|
||||
Some("invalid-input" | "unsupported-version")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_defaults_validate_set_and_explain_are_consistent() {
|
||||
let directory = TestDirectory::new();
|
||||
let defaults = directory.run(&["config", "defaults"]);
|
||||
assert!(defaults.status.success());
|
||||
assert!(
|
||||
String::from_utf8(defaults.stdout)
|
||||
.unwrap()
|
||||
.contains("schema_version = 1")
|
||||
);
|
||||
|
||||
let validate = directory.run(&["config", "validate", "--json"]);
|
||||
assert!(validate.status.success());
|
||||
let validated: Value = serde_json::from_slice(&validate.stdout).unwrap();
|
||||
assert_eq!(validated["exists"], false);
|
||||
|
||||
let runtime = directory.run(&["config", "set", "runtime", "umu", "--json"]);
|
||||
assert!(runtime.status.success());
|
||||
let backend = directory.run(&["config", "set", "session-backend", "wayland", "--json"]);
|
||||
assert!(backend.status.success());
|
||||
let configuration = fs::read_to_string(directory.0.join("config/kiln/config.toml")).unwrap();
|
||||
assert!(configuration.contains("# Project Kiln private configuration"));
|
||||
assert!(configuration.contains("runtime = \"umu\""));
|
||||
assert!(configuration.contains("session_backend = \"wayland\""));
|
||||
|
||||
let explain = directory.run(&["config", "explain", "--json"]);
|
||||
assert!(explain.status.success());
|
||||
let resolved: Value = serde_json::from_slice(&explain.stdout).unwrap();
|
||||
assert_eq!(resolved["runtime"]["value"], "umu");
|
||||
assert_eq!(resolved["runtime"]["source"]["tier"], "global-user");
|
||||
assert_eq!(resolved["session_backend"]["value"], "wayland");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_configuration_and_failed_launch_are_structured() {
|
||||
let directory = TestDirectory::new();
|
||||
fs::create_dir_all(directory.0.join("config/kiln")).unwrap();
|
||||
fs::write(
|
||||
directory.0.join("config/kiln/config.toml"),
|
||||
"schema_version = 99\n",
|
||||
)
|
||||
.unwrap();
|
||||
let invalid = directory.run(&["config", "validate", "--json"]);
|
||||
assert_eq!(invalid.status.code(), Some(2));
|
||||
let error: Value = serde_json::from_slice(&invalid.stderr).unwrap();
|
||||
assert_eq!(error["code"], "invalid-input");
|
||||
|
||||
fs::remove_file(directory.0.join("config/kiln/config.toml")).unwrap();
|
||||
directory.add_game("failure", "Failure", Path::new("/usr/bin/false"));
|
||||
let failed = directory.run(&["launch", "native:failure", "--json"]);
|
||||
assert_eq!(failed.status.code(), Some(2));
|
||||
let error: Value = serde_json::from_slice(&failed.stderr).unwrap();
|
||||
assert_eq!(error["code"], "launch-failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steam_provider_cli_discovers_searches_and_resolves_without_launching() {
|
||||
let directory = TestDirectory::new();
|
||||
directory.add_steam_game("620", "Portal 2");
|
||||
|
||||
let list = directory.run(&["list", "--provider", "steam", "--json"]);
|
||||
assert!(list.status.success());
|
||||
let records: Value = serde_json::from_slice(&list.stdout).unwrap();
|
||||
assert_eq!(records[0]["id"], "steam:620");
|
||||
assert_eq!(records[0]["runtime"], "steam-managed");
|
||||
assert_eq!(records[0]["client_required"], true);
|
||||
|
||||
let search = directory.run(&["search", "portal", "--provider", "steam", "--json"]);
|
||||
assert!(search.status.success());
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Value>(&search.stdout).unwrap()[0]["name"],
|
||||
"Portal 2"
|
||||
);
|
||||
|
||||
let info = directory.run(&["info", "steam:620", "--json"]);
|
||||
assert!(info.status.success());
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Value>(&info.stdout).unwrap()["store"],
|
||||
"steam"
|
||||
);
|
||||
|
||||
let scan = directory.run(&["scan", "--provider", "steam", "--json"]);
|
||||
assert!(scan.status.success());
|
||||
let result: Value = serde_json::from_slice(&scan.stdout).unwrap();
|
||||
assert_eq!(result["provider"], "steam");
|
||||
assert_eq!(result["discovered"], 1);
|
||||
}
|
||||
@@ -9,7 +9,8 @@ publish.workspace = true
|
||||
[dependencies]
|
||||
serde = { version = "=1.0.228", features = ["derive"] }
|
||||
serde_json = "=1.0.150"
|
||||
uuid = { version = "=1.24.0", features = ["serde"] }
|
||||
toml = "=1.1.3"
|
||||
uuid = { version = "=1.24.0", features = ["serde", "v4"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
//! Provider and runtime capability interfaces with a deterministic Phase 1 mock.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::{ConfigurationLayerV1, ConfigurationSource, ConfigurationTier, resolve_layers};
|
||||
use crate::contract_error::{AmbiguousGameCandidate, ContractErrorCode, ContractErrorV1};
|
||||
use crate::launch::{
|
||||
CommandArgument, CommandSpec, ControllerState, EnvironmentName, EnvironmentValue, Executable,
|
||||
LaunchPlanInput, LaunchPlanV1, LifecycleConfidence,
|
||||
};
|
||||
use crate::{
|
||||
Capability, Compatibility, ContractName, ContractText, GameId, GameRecordInput, GameRecordV1,
|
||||
};
|
||||
|
||||
/// Provider behavior consumed by the shared core and all clients.
|
||||
pub trait ProviderAdapter {
|
||||
/// Returns the adapter's stable contract name.
|
||||
fn name(&self) -> &ContractName;
|
||||
|
||||
/// Returns actual advertised capabilities.
|
||||
fn capabilities(&self) -> &[Capability];
|
||||
|
||||
/// Discovers normalized installed games without mutating provider state.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a structured contract error when discovery cannot complete safely.
|
||||
fn discover(&self) -> Result<Vec<GameRecordV1>, ContractErrorV1>;
|
||||
|
||||
/// Resolves an exact stable ID or unique normalized display name.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns structured invalid-input, not-found, or ambiguity errors.
|
||||
fn resolve(&self, selector: &str) -> Result<GameRecordV1, ContractErrorV1>;
|
||||
|
||||
/// Produces an argument-safe dry-run launch plan without starting a process.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns structured unsupported-capability, not-found, or planning errors.
|
||||
fn plan_launch(&self, game_id: &GameId) -> Result<LaunchPlanV1, ContractErrorV1>;
|
||||
}
|
||||
|
||||
/// Capability advertised by a compatibility or execution runtime.
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum RuntimeCapability {
|
||||
/// Produce an argument-safe command specification.
|
||||
Plan,
|
||||
/// Report whether required runtime payloads are locally available.
|
||||
OfflineStatus,
|
||||
/// Execute an already validated plan; implementation is deferred beyond Phase 1.
|
||||
Execute,
|
||||
}
|
||||
|
||||
/// Provider-independent input to a runtime command planner.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct RuntimeRequest {
|
||||
/// Game identity retained across runtime selection.
|
||||
pub game_id: GameId,
|
||||
/// Executable selected by the provider or custom manifest.
|
||||
pub executable: Executable,
|
||||
/// Ordered arguments passed without shell evaluation.
|
||||
pub arguments: Vec<CommandArgument>,
|
||||
/// Minimal child environment resolved by configuration policy.
|
||||
pub environment: BTreeMap<EnvironmentName, EnvironmentValue>,
|
||||
}
|
||||
|
||||
/// Runtime behavior used by provider-independent launch planning.
|
||||
pub trait RuntimeBackend {
|
||||
/// Returns the runtime's stable contract name.
|
||||
fn name(&self) -> &ContractName;
|
||||
|
||||
/// Returns actual advertised runtime capabilities.
|
||||
fn capabilities(&self) -> &[RuntimeCapability];
|
||||
|
||||
/// Produces the final typed command without executing it.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a structured error when planning is unsupported or fails validation.
|
||||
fn plan_command(&self, request: &RuntimeRequest) -> Result<CommandSpec, ContractErrorV1>;
|
||||
}
|
||||
|
||||
/// Deterministic Phase 1 provider behavior used by contract and integration tests.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum MockScenario {
|
||||
/// Discovery, resolution, and launch planning succeed.
|
||||
Success,
|
||||
/// Discovery is empty and resolution reports absence.
|
||||
Absence,
|
||||
/// A display name resolves to multiple deterministic candidates.
|
||||
Ambiguity,
|
||||
/// Invalid selectors are rejected before matching.
|
||||
MalformedInput,
|
||||
/// Launch planning is not advertised and returns a capability error.
|
||||
UnsupportedCapability,
|
||||
/// The fixture reports the same persistent UUID at a moved path.
|
||||
MovedInstallation,
|
||||
/// Discovery succeeds but launch planning fails.
|
||||
LaunchPlanningFailure,
|
||||
}
|
||||
|
||||
/// Deterministic mock provider covering all required Phase 1 contract outcomes.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MockAdapter {
|
||||
name: ContractName,
|
||||
scenario: MockScenario,
|
||||
}
|
||||
|
||||
impl MockAdapter {
|
||||
/// Creates a mock adapter for one explicit scenario.
|
||||
#[must_use]
|
||||
pub fn new(scenario: MockScenario) -> Self {
|
||||
Self {
|
||||
name: contract_name("mock"),
|
||||
scenario,
|
||||
}
|
||||
}
|
||||
|
||||
fn records(&self) -> Vec<GameRecordV1> {
|
||||
match self.scenario {
|
||||
MockScenario::Absence => Vec::new(),
|
||||
MockScenario::Ambiguity => vec![
|
||||
mock_record(
|
||||
"native:portal",
|
||||
"Portal",
|
||||
"native",
|
||||
"b3da0c09-c57e-465d-9cb7-d0177c34eb5c",
|
||||
"/games/native/Portal",
|
||||
"native",
|
||||
),
|
||||
mock_record(
|
||||
"steam:620",
|
||||
"Portal",
|
||||
"steam",
|
||||
"7aa62d69-8f8e-4cab-8514-275ce1f87748",
|
||||
"/games/steam/Portal",
|
||||
"steam-proton",
|
||||
),
|
||||
],
|
||||
MockScenario::MovedInstallation => vec![mock_record(
|
||||
"steam:620",
|
||||
"Portal",
|
||||
"steam",
|
||||
"7aa62d69-8f8e-4cab-8514-275ce1f87748",
|
||||
"/games/moved/Portal",
|
||||
"steam-proton",
|
||||
)],
|
||||
_ => vec![mock_record(
|
||||
"steam:620",
|
||||
"Portal",
|
||||
"steam",
|
||||
"7aa62d69-8f8e-4cab-8514-275ce1f87748",
|
||||
"/games/steam/Portal",
|
||||
"steam-proton",
|
||||
)],
|
||||
}
|
||||
}
|
||||
|
||||
fn missing() -> ContractErrorV1 {
|
||||
ContractErrorV1::new(ContractErrorCode::NotFound, text("game was not found"))
|
||||
}
|
||||
}
|
||||
|
||||
const FULL_CAPABILITIES: &[Capability] = &[
|
||||
Capability::Discover,
|
||||
Capability::Info,
|
||||
Capability::Launch,
|
||||
Capability::UiOptional,
|
||||
Capability::LifecycleState,
|
||||
];
|
||||
const DISCOVERY_CAPABILITIES: &[Capability] = &[Capability::Discover, Capability::Info];
|
||||
|
||||
impl ProviderAdapter for MockAdapter {
|
||||
fn name(&self) -> &ContractName {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> &[Capability] {
|
||||
if self.scenario == MockScenario::UnsupportedCapability {
|
||||
DISCOVERY_CAPABILITIES
|
||||
} else {
|
||||
FULL_CAPABILITIES
|
||||
}
|
||||
}
|
||||
|
||||
fn discover(&self) -> Result<Vec<GameRecordV1>, ContractErrorV1> {
|
||||
Ok(self.records())
|
||||
}
|
||||
|
||||
fn resolve(&self, selector: &str) -> Result<GameRecordV1, ContractErrorV1> {
|
||||
if selector.is_empty()
|
||||
|| selector.trim() != selector
|
||||
|| selector.chars().any(char::is_control)
|
||||
{
|
||||
return Err(ContractErrorV1::new(
|
||||
ContractErrorCode::InvalidInput,
|
||||
text("game selector is invalid"),
|
||||
));
|
||||
}
|
||||
let records = self.records();
|
||||
if selector.contains(':') {
|
||||
let id = selector.parse::<GameId>().map_err(|_| {
|
||||
ContractErrorV1::new(ContractErrorCode::InvalidInput, text("game ID is invalid"))
|
||||
})?;
|
||||
return records
|
||||
.into_iter()
|
||||
.find(|record| record.id() == &id)
|
||||
.ok_or_else(Self::missing);
|
||||
}
|
||||
|
||||
let normalized = selector.to_lowercase();
|
||||
let matches: Vec<_> = records
|
||||
.into_iter()
|
||||
.filter(|record| record.name().as_str().to_lowercase() == normalized)
|
||||
.collect();
|
||||
match matches.as_slice() {
|
||||
[] => Err(Self::missing()),
|
||||
[record] => Ok(record.clone()),
|
||||
_ => Err(ContractErrorV1::ambiguous(
|
||||
text("game selector is ambiguous"),
|
||||
matches
|
||||
.into_iter()
|
||||
.map(|record| AmbiguousGameCandidate {
|
||||
id: record.id().clone(),
|
||||
store: record.store().clone(),
|
||||
installation_id: record.installation_id(),
|
||||
label: format!(
|
||||
"{} at {}",
|
||||
record.name().as_str(),
|
||||
record.install_path().as_path().display()
|
||||
)
|
||||
.parse()
|
||||
.expect("mock label is valid"),
|
||||
})
|
||||
.collect(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_launch(&self, game_id: &GameId) -> Result<LaunchPlanV1, ContractErrorV1> {
|
||||
if !self.capabilities().contains(&Capability::Launch) {
|
||||
return Err(ContractErrorV1::unsupported_capability(
|
||||
text("mock adapter does not support launch"),
|
||||
contract_name("launch"),
|
||||
));
|
||||
}
|
||||
if self.scenario == MockScenario::LaunchPlanningFailure {
|
||||
return Err(ContractErrorV1::new(
|
||||
ContractErrorCode::LaunchPlanningFailed,
|
||||
text("mock launch planning failed"),
|
||||
));
|
||||
}
|
||||
let record = self
|
||||
.records()
|
||||
.into_iter()
|
||||
.find(|record| record.id() == game_id)
|
||||
.ok_or_else(Self::missing)?;
|
||||
|
||||
let configuration_toml = format!(
|
||||
"schema_version = 1\n[launch]\nruntime = \"{}\"\nsession_backend = \"wayland\"",
|
||||
record.runtime().as_str()
|
||||
);
|
||||
let layer = ConfigurationLayerV1::parse_toml(
|
||||
ConfigurationSource {
|
||||
tier: ConfigurationTier::BuiltIn,
|
||||
label: text("mock defaults"),
|
||||
},
|
||||
&configuration_toml,
|
||||
)
|
||||
.map_err(|_| {
|
||||
ContractErrorV1::new(
|
||||
ContractErrorCode::LaunchPlanningFailed,
|
||||
text("mock configuration failed"),
|
||||
)
|
||||
})?;
|
||||
let resolved = resolve_layers(&[layer]).map_err(|_| {
|
||||
ContractErrorV1::new(
|
||||
ContractErrorCode::LaunchPlanningFailed,
|
||||
text("mock configuration resolution failed"),
|
||||
)
|
||||
})?;
|
||||
|
||||
LaunchPlanV1::new(LaunchPlanInput {
|
||||
game_id: record.id().clone(),
|
||||
installation_id: record.installation_id(),
|
||||
store: record.store().clone(),
|
||||
adapter: self.name.clone(),
|
||||
configuration_owner: record.configuration_owner().clone(),
|
||||
runtime: resolved.runtime.value,
|
||||
session_backend: resolved.session_backend.value,
|
||||
display_mode: resolved.display_mode.map(|value| value.value),
|
||||
controller_state: ControllerState::Unknown,
|
||||
working_directory: record.install_path().clone(),
|
||||
environment: resolved
|
||||
.environment
|
||||
.into_iter()
|
||||
.map(|(name, value)| (name, value.value))
|
||||
.collect(),
|
||||
wrappers: resolved
|
||||
.wrappers
|
||||
.into_iter()
|
||||
.map(|value| value.value)
|
||||
.collect(),
|
||||
provenance: resolved.provenance,
|
||||
lifecycle_confidence: LifecycleConfidence::ProviderReported,
|
||||
command: CommandSpec {
|
||||
executable: Executable::Program(contract_name("steam")),
|
||||
arguments: vec![
|
||||
command_argument("-applaunch"),
|
||||
command_argument(record.id().value()),
|
||||
],
|
||||
},
|
||||
})
|
||||
.map_err(|_| {
|
||||
ContractErrorV1::new(
|
||||
ContractErrorCode::LaunchPlanningFailed,
|
||||
text("mock launch plan violated its contract"),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic runtime planner used to verify the runtime interface.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MockRuntime {
|
||||
name: ContractName,
|
||||
supports_planning: bool,
|
||||
}
|
||||
|
||||
impl MockRuntime {
|
||||
/// Creates a mock runtime that either supports or rejects planning.
|
||||
#[must_use]
|
||||
pub fn new(supports_planning: bool) -> Self {
|
||||
Self {
|
||||
name: contract_name("mock-runtime"),
|
||||
supports_planning,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const RUNTIME_PLAN_CAPABILITIES: &[RuntimeCapability] = &[RuntimeCapability::Plan];
|
||||
const NO_RUNTIME_CAPABILITIES: &[RuntimeCapability] = &[];
|
||||
|
||||
impl RuntimeBackend for MockRuntime {
|
||||
fn name(&self) -> &ContractName {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> &[RuntimeCapability] {
|
||||
if self.supports_planning {
|
||||
RUNTIME_PLAN_CAPABILITIES
|
||||
} else {
|
||||
NO_RUNTIME_CAPABILITIES
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_command(&self, request: &RuntimeRequest) -> Result<CommandSpec, ContractErrorV1> {
|
||||
if !self.supports_planning {
|
||||
return Err(ContractErrorV1::new(
|
||||
ContractErrorCode::UnsupportedCapability,
|
||||
text("mock runtime does not support planning"),
|
||||
));
|
||||
}
|
||||
Ok(CommandSpec {
|
||||
executable: request.executable.clone(),
|
||||
arguments: request.arguments.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn mock_record(
|
||||
id: &str,
|
||||
name: &str,
|
||||
store: &str,
|
||||
installation_id: &str,
|
||||
install_path: &str,
|
||||
runtime: &str,
|
||||
) -> GameRecordV1 {
|
||||
GameRecordV1::new(GameRecordInput {
|
||||
id: id.parse().expect("mock game ID"),
|
||||
name: text(name),
|
||||
store: contract_name(store),
|
||||
adapter: contract_name("mock"),
|
||||
installation_id: installation_id.parse().expect("mock installation ID"),
|
||||
configuration_owner: contract_name(store),
|
||||
execution_backend: contract_name(store),
|
||||
installed: true,
|
||||
install_path: install_path.to_owned().try_into().expect("mock path"),
|
||||
compatibility: (runtime != "native").then(|| Compatibility {
|
||||
kind: contract_name("proton"),
|
||||
version: text("mock-version"),
|
||||
}),
|
||||
capabilities: FULL_CAPABILITIES.to_vec(),
|
||||
client_required: store == "steam",
|
||||
runtime: contract_name(runtime),
|
||||
})
|
||||
.expect("mock record")
|
||||
}
|
||||
|
||||
fn contract_name(raw: &str) -> ContractName {
|
||||
raw.parse().expect("static contract name")
|
||||
}
|
||||
|
||||
fn text(raw: &str) -> ContractText {
|
||||
raw.parse().expect("static contract text")
|
||||
}
|
||||
|
||||
fn command_argument(raw: &str) -> CommandArgument {
|
||||
raw.to_owned().try_into().expect("static command argument")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::{
|
||||
MockAdapter, MockRuntime, MockScenario, ProviderAdapter, RuntimeBackend, RuntimeCapability,
|
||||
RuntimeRequest,
|
||||
};
|
||||
use crate::contract_error::ContractErrorCode;
|
||||
use crate::launch::{CommandArgument, Executable};
|
||||
use crate::{Capability, ContractName, GameId};
|
||||
|
||||
#[test]
|
||||
fn mock_success_discovers_resolves_and_plans() {
|
||||
let adapter = MockAdapter::new(MockScenario::Success);
|
||||
assert!(adapter.capabilities().contains(&Capability::Launch));
|
||||
assert_eq!(adapter.discover().expect("discovery").len(), 1);
|
||||
let record = adapter.resolve("portal").expect("unique name");
|
||||
assert_eq!(record.id().to_string(), "steam:620");
|
||||
let plan = adapter.plan_launch(record.id()).expect("plan");
|
||||
assert_eq!(plan.game_id(), record.id());
|
||||
assert_eq!(plan.command().arguments[1].as_str(), "620");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_covers_absence_ambiguity_and_malformed_input() {
|
||||
let absent = MockAdapter::new(MockScenario::Absence);
|
||||
assert!(absent.discover().expect("discovery").is_empty());
|
||||
assert_eq!(
|
||||
absent.resolve("Portal").expect_err("missing").code(),
|
||||
ContractErrorCode::NotFound
|
||||
);
|
||||
|
||||
let ambiguous = MockAdapter::new(MockScenario::Ambiguity)
|
||||
.resolve("PORTAL")
|
||||
.expect_err("ambiguous");
|
||||
assert_eq!(ambiguous.code(), ContractErrorCode::AmbiguousGame);
|
||||
assert_eq!(ambiguous.candidates().len(), 2);
|
||||
assert_eq!(ambiguous.candidates()[0].id.to_string(), "native:portal");
|
||||
|
||||
let malformed = MockAdapter::new(MockScenario::MalformedInput)
|
||||
.resolve("bad\nselector")
|
||||
.expect_err("invalid");
|
||||
assert_eq!(malformed.code(), ContractErrorCode::InvalidInput);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_covers_unsupported_moved_and_planning_failure() {
|
||||
let unsupported = MockAdapter::new(MockScenario::UnsupportedCapability)
|
||||
.plan_launch(&"steam:620".parse().expect("ID"))
|
||||
.expect_err("unsupported");
|
||||
assert_eq!(unsupported.code(), ContractErrorCode::UnsupportedCapability);
|
||||
|
||||
let moved = MockAdapter::new(MockScenario::MovedInstallation)
|
||||
.resolve("steam:620")
|
||||
.expect("moved record");
|
||||
assert_eq!(
|
||||
moved.install_path().as_path().to_str(),
|
||||
Some("/games/moved/Portal")
|
||||
);
|
||||
|
||||
let failed = MockAdapter::new(MockScenario::LaunchPlanningFailure)
|
||||
.plan_launch(&"steam:620".parse().expect("ID"))
|
||||
.expect_err("planning failure");
|
||||
assert_eq!(failed.code(), ContractErrorCode::LaunchPlanningFailed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_interface_plans_without_execution() {
|
||||
let runtime = MockRuntime::new(true);
|
||||
assert_eq!(runtime.capabilities(), &[RuntimeCapability::Plan]);
|
||||
let request = RuntimeRequest {
|
||||
game_id: "native:openmw".parse::<GameId>().expect("ID"),
|
||||
executable: Executable::Program("openmw".parse::<ContractName>().expect("program")),
|
||||
arguments: vec![CommandArgument::try_from("--skip-menu".to_owned()).expect("argument")],
|
||||
environment: BTreeMap::new(),
|
||||
};
|
||||
let command = runtime.plan_command(&request).expect("command");
|
||||
assert_eq!(command.arguments[0].as_str(), "--skip-menu");
|
||||
|
||||
let unsupported = MockRuntime::new(false)
|
||||
.plan_command(&request)
|
||||
.expect_err("unsupported");
|
||||
assert_eq!(unsupported.code(), ContractErrorCode::UnsupportedCapability);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,981 @@
|
||||
//! Versioned TOML launch configuration, precedence, and provenance.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::env;
|
||||
use std::ffi::OsStr;
|
||||
use std::fmt;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::launch::{CommandSpec, DisplayMode, EnvironmentName, EnvironmentValue};
|
||||
use crate::{ContractName, ContractText};
|
||||
|
||||
const MAX_CONFIG_BYTES: u64 = 1024 * 1024;
|
||||
const DIRECTORY_MODE: u32 = 0o700;
|
||||
const FILE_MODE: u32 = 0o600;
|
||||
|
||||
/// Complete lowest-precedence launch defaults shared by planning and explanation.
|
||||
pub const BUILT_IN_LAUNCH_CONFIG: &str =
|
||||
"schema_version = 1\n[launch]\nruntime = \"native\"\nsession_backend = \"current\"\n";
|
||||
|
||||
/// Schema version for user-editable launch configuration.
|
||||
pub const CONFIG_SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
/// Fully commented safe defaults suitable for writing to a new user configuration.
|
||||
pub const COMMENTED_DEFAULT_CONFIG: &str = r#"# Project Kiln private configuration
|
||||
schema_version = 1
|
||||
|
||||
[launch]
|
||||
# runtime = "native"
|
||||
# session_backend = "wayland"
|
||||
# wrapper_mode = "append" # append, replace, or clear
|
||||
|
||||
# [launch.environment]
|
||||
# MANGOHUD = "1" # set or replace a value
|
||||
# OLD_VARIABLE = false # remove a value inherited from a lower layer
|
||||
"#;
|
||||
|
||||
/// One scalar supported by the conservative configuration editor.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum EditableScalar {
|
||||
/// Launch runtime.
|
||||
Runtime,
|
||||
/// Desktop session backend.
|
||||
SessionBackend,
|
||||
}
|
||||
|
||||
impl EditableScalar {
|
||||
fn key(self) -> &'static str {
|
||||
match self {
|
||||
Self::Runtime => "runtime",
|
||||
Self::SessionBackend => "session_backend",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the private global user configuration path.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when neither `XDG_CONFIG_HOME` nor `HOME` provides an absolute path.
|
||||
pub fn configuration_path_for_current_user() -> Result<PathBuf, ConfigurationError> {
|
||||
configuration_path(
|
||||
env::var_os("XDG_CONFIG_HOME").as_deref(),
|
||||
env::var_os("HOME").as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Resolves a configuration path from explicit environment values.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when neither value provides an absolute base path.
|
||||
pub fn configuration_path(
|
||||
xdg_config_home: Option<&OsStr>,
|
||||
home: Option<&OsStr>,
|
||||
) -> Result<PathBuf, ConfigurationError> {
|
||||
xdg_config_home
|
||||
.map(Path::new)
|
||||
.filter(|path| path.is_absolute())
|
||||
.map(Path::to_path_buf)
|
||||
.or_else(|| {
|
||||
home.map(Path::new)
|
||||
.filter(|path| path.is_absolute())
|
||||
.map(|path| path.join(".config"))
|
||||
})
|
||||
.map(|path| path.join("kiln/config.toml"))
|
||||
.ok_or(ConfigurationError::NoConfigurationHome)
|
||||
}
|
||||
|
||||
/// Reads one bounded, regular, UTF-8 configuration file. Missing files are valid.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error for I/O failures, unsafe paths, oversized files, or invalid UTF-8.
|
||||
pub fn read_configuration(path: &Path) -> Result<Option<String>, ConfigurationError> {
|
||||
if !path.is_absolute() {
|
||||
return Err(ConfigurationError::UnsafePath);
|
||||
}
|
||||
reject_symlink_components(path)?;
|
||||
let metadata = match fs::symlink_metadata(path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(ConfigurationError::Io(error)),
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
return Err(ConfigurationError::UnsafePath);
|
||||
}
|
||||
if metadata.len() > MAX_CONFIG_BYTES {
|
||||
return Err(ConfigurationError::TooLarge);
|
||||
}
|
||||
let capacity = usize::try_from(metadata.len()).map_err(|_| ConfigurationError::TooLarge)?;
|
||||
let mut bytes = Vec::with_capacity(capacity);
|
||||
File::open(path)?
|
||||
.take(MAX_CONFIG_BYTES + 1)
|
||||
.read_to_end(&mut bytes)?;
|
||||
if bytes.len() as u64 > MAX_CONFIG_BYTES {
|
||||
return Err(ConfigurationError::TooLarge);
|
||||
}
|
||||
String::from_utf8(bytes)
|
||||
.map(Some)
|
||||
.map_err(|_| ConfigurationError::InvalidUtf8)
|
||||
}
|
||||
|
||||
/// Loads the optional global configuration as a validated layer.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns the file-reading and strict configuration parsing errors.
|
||||
pub fn load_configuration(
|
||||
path: &Path,
|
||||
source: ConfigurationSource,
|
||||
) -> Result<Option<ConfigurationLayerV1>, ConfigurationError> {
|
||||
read_configuration(path)?
|
||||
.map(|input| ConfigurationLayerV1::parse_toml(source, &input))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// Returns the shared built-in configuration layer.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics only if the compile-time constant or its static label becomes invalid.
|
||||
#[must_use]
|
||||
pub fn built_in_configuration() -> ConfigurationLayerV1 {
|
||||
ConfigurationLayerV1::parse_toml(
|
||||
ConfigurationSource {
|
||||
tier: ConfigurationTier::BuiltIn,
|
||||
label: "native defaults".parse().expect("static label"),
|
||||
},
|
||||
BUILT_IN_LAUNCH_CONFIG,
|
||||
)
|
||||
.expect("static configuration")
|
||||
}
|
||||
|
||||
/// Updates one supported launch scalar while preserving unrelated text and comments.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when the original or edited configuration is invalid.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics only if the compile-time static source label becomes invalid.
|
||||
pub fn edit_configuration_scalar(
|
||||
input: &str,
|
||||
scalar: EditableScalar,
|
||||
value: &ContractName,
|
||||
) -> Result<String, ConfigurationError> {
|
||||
ConfigurationLayerV1::parse_toml(
|
||||
ConfigurationSource {
|
||||
tier: ConfigurationTier::GlobalUser,
|
||||
label: "user configuration".parse().expect("static label"),
|
||||
},
|
||||
input,
|
||||
)?;
|
||||
let key = scalar.key();
|
||||
let mut output = Vec::new();
|
||||
let mut in_launch = false;
|
||||
let mut saw_launch = false;
|
||||
let mut replaced = false;
|
||||
for line in input.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with('[') {
|
||||
if in_launch && !replaced {
|
||||
output.push(format!("{key} = \"{}\"", value.as_str()));
|
||||
replaced = true;
|
||||
}
|
||||
in_launch = trimmed == "[launch]";
|
||||
saw_launch |= in_launch;
|
||||
}
|
||||
if in_launch
|
||||
&& !trimmed.starts_with('#')
|
||||
&& let Some((candidate, _)) = trimmed.split_once('=')
|
||||
&& candidate.trim() == key
|
||||
{
|
||||
output.push(format!("{key} = \"{}\"", value.as_str()));
|
||||
replaced = true;
|
||||
continue;
|
||||
}
|
||||
output.push(line.to_owned());
|
||||
}
|
||||
if in_launch && !replaced {
|
||||
output.push(format!("{key} = \"{}\"", value.as_str()));
|
||||
} else if !saw_launch {
|
||||
if !output.last().is_none_or(String::is_empty) {
|
||||
output.push(String::new());
|
||||
}
|
||||
output.push("[launch]".to_owned());
|
||||
output.push(format!("{key} = \"{}\"", value.as_str()));
|
||||
}
|
||||
let mut replacement = output.join("\n");
|
||||
replacement.push('\n');
|
||||
ConfigurationLayerV1::parse_toml(
|
||||
ConfigurationSource {
|
||||
tier: ConfigurationTier::GlobalUser,
|
||||
label: "user configuration".parse().expect("static label"),
|
||||
},
|
||||
&replacement,
|
||||
)?;
|
||||
Ok(replacement)
|
||||
}
|
||||
|
||||
/// Atomically applies one conservative scalar edit using a locked compare-and-swap write.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns validation, path-safety, concurrency, or filesystem errors.
|
||||
pub fn set_configuration_scalar(
|
||||
path: &Path,
|
||||
scalar: EditableScalar,
|
||||
value: &ContractName,
|
||||
) -> Result<(), ConfigurationError> {
|
||||
let expected = read_configuration(path)?;
|
||||
let base = expected.as_deref().unwrap_or(COMMENTED_DEFAULT_CONFIG);
|
||||
let replacement = edit_configuration_scalar(base, scalar, value)?;
|
||||
replace_configuration_file(path, expected.as_deref(), &replacement)
|
||||
}
|
||||
|
||||
/// Atomically replaces a configuration when its on-disk snapshot still matches `expected`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns validation, path-safety, concurrency, or filesystem errors.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics only if the compile-time static source label becomes invalid.
|
||||
pub fn replace_configuration_file(
|
||||
path: &Path,
|
||||
expected: Option<&str>,
|
||||
replacement: &str,
|
||||
) -> Result<(), ConfigurationError> {
|
||||
if !path.is_absolute() {
|
||||
return Err(ConfigurationError::UnsafePath);
|
||||
}
|
||||
ConfigurationLayerV1::parse_toml(
|
||||
ConfigurationSource {
|
||||
tier: ConfigurationTier::GlobalUser,
|
||||
label: "user configuration".parse().expect("static label"),
|
||||
},
|
||||
replacement,
|
||||
)?;
|
||||
let parent = path.parent().ok_or(ConfigurationError::UnsafePath)?;
|
||||
reject_symlink_components(parent)?;
|
||||
fs::create_dir_all(parent)?;
|
||||
reject_symlink_components(parent)?;
|
||||
fs::set_permissions(parent, fs::Permissions::from_mode(DIRECTORY_MODE))?;
|
||||
let lock_path = path.with_extension("lock");
|
||||
let lock = OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.mode(FILE_MODE)
|
||||
.open(lock_path)?;
|
||||
lock.lock()?;
|
||||
let current = read_configuration(path)?;
|
||||
if current.as_deref() != expected {
|
||||
return Err(ConfigurationError::ConcurrentExternalEdit);
|
||||
}
|
||||
if path.exists() {
|
||||
let backup = path.with_extension("toml.bak");
|
||||
fs::copy(path, &backup)?;
|
||||
fs::set_permissions(backup, fs::Permissions::from_mode(FILE_MODE))?;
|
||||
}
|
||||
let temporary = parent.join(format!(".config-{}.tmp", uuid::Uuid::new_v4()));
|
||||
let mut file = OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.mode(FILE_MODE)
|
||||
.open(&temporary)?;
|
||||
file.write_all(replacement.as_bytes())?;
|
||||
file.sync_all()?;
|
||||
fs::rename(&temporary, path)?;
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(FILE_MODE))?;
|
||||
File::open(parent)?.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reject_symlink_components(path: &Path) -> Result<(), ConfigurationError> {
|
||||
let mut current = PathBuf::new();
|
||||
for component in path.components() {
|
||||
current.push(component);
|
||||
match fs::symlink_metadata(¤t) {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||
return Err(ConfigurationError::UnsafePath);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(ConfigurationError::Io(error)),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ordered precedence tier from lowest to highest priority.
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ConfigurationTier {
|
||||
/// Built-in safe defaults.
|
||||
BuiltIn,
|
||||
/// Hardware-specific profile.
|
||||
Hardware,
|
||||
/// Global user profile.
|
||||
GlobalUser,
|
||||
/// Provider-owned defaults.
|
||||
Provider,
|
||||
/// Runtime-specific defaults, applied after provider defaults.
|
||||
Runtime,
|
||||
/// Per-game profile.
|
||||
Game,
|
||||
/// Explicit one-launch CLI override.
|
||||
Cli,
|
||||
}
|
||||
|
||||
/// One explicit configuration source used in dry-run provenance.
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ConfigurationSource {
|
||||
/// Precedence tier.
|
||||
pub tier: ConfigurationTier,
|
||||
/// Human-readable source label or path.
|
||||
pub label: ContractText,
|
||||
}
|
||||
|
||||
/// How one layer affected an earlier value.
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum MergeOperation {
|
||||
/// Set a value that had no earlier source.
|
||||
Set,
|
||||
/// Replace a scalar, environment value, or complete list.
|
||||
Replaced,
|
||||
/// Append ordered values to an existing list.
|
||||
Appended,
|
||||
/// Remove an inherited value or clear a list.
|
||||
Removed,
|
||||
}
|
||||
|
||||
/// One configuration-resolution event retained for dry-run explanation.
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ProvenanceEvent {
|
||||
/// Stable field path such as `runtime`, `environment.MANGOHUD`, or `wrappers`.
|
||||
pub field: ContractText,
|
||||
/// Layer that performed the operation.
|
||||
pub source: ConfigurationSource,
|
||||
/// Merge behavior applied at this layer.
|
||||
pub operation: MergeOperation,
|
||||
}
|
||||
|
||||
/// A final resolved scalar and the source that supplied it.
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ResolvedValue<T> {
|
||||
/// Final value.
|
||||
pub value: T,
|
||||
/// Winning source.
|
||||
pub source: ConfigurationSource,
|
||||
}
|
||||
|
||||
/// Complete resolved launch configuration and its ordered provenance.
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ResolvedLaunchConfiguration {
|
||||
/// Selected runtime.
|
||||
pub runtime: ResolvedValue<ContractName>,
|
||||
/// Selected session backend.
|
||||
pub session_backend: ResolvedValue<ContractName>,
|
||||
/// Optional selected display mode.
|
||||
pub display_mode: Option<ResolvedValue<DisplayMode>>,
|
||||
/// Final child environment after deletions.
|
||||
pub environment: BTreeMap<EnvironmentName, ResolvedValue<EnvironmentValue>>,
|
||||
/// Final ordered wrapper commands with their supplying sources.
|
||||
pub wrappers: Vec<ResolvedValue<CommandSpec>>,
|
||||
/// Every set, replace, append, and removal in application order.
|
||||
pub provenance: Vec<ProvenanceEvent>,
|
||||
}
|
||||
|
||||
/// Wrapper-list behavior for one configuration layer.
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum WrapperMode {
|
||||
/// Append wrappers in declared order.
|
||||
#[default]
|
||||
Append,
|
||||
/// Replace all inherited wrappers.
|
||||
Replace,
|
||||
/// Remove all inherited wrappers; this layer must declare no wrappers.
|
||||
Clear,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum EnvironmentPatch {
|
||||
Set(String),
|
||||
Remove(bool),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct LaunchPatch {
|
||||
runtime: Option<ContractName>,
|
||||
session_backend: Option<ContractName>,
|
||||
display_mode: Option<DisplayMode>,
|
||||
#[serde(default)]
|
||||
environment: BTreeMap<EnvironmentName, EnvironmentPatch>,
|
||||
#[serde(default)]
|
||||
wrapper_mode: WrapperMode,
|
||||
#[serde(default)]
|
||||
wrappers: Vec<CommandSpec>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct WireConfiguration {
|
||||
schema_version: u32,
|
||||
#[serde(default)]
|
||||
launch: LaunchPatch,
|
||||
}
|
||||
|
||||
/// One parsed and validated TOML configuration layer.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ConfigurationLayerV1 {
|
||||
source: ConfigurationSource,
|
||||
launch: LaunchPatch,
|
||||
}
|
||||
|
||||
impl ConfigurationLayerV1 {
|
||||
/// Parses one strict, schema-versioned TOML layer.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a structured configuration error for malformed TOML, unsupported versions,
|
||||
/// invalid environment deletion markers, or contradictory wrapper operations.
|
||||
pub fn parse_toml(
|
||||
source: ConfigurationSource,
|
||||
input: &str,
|
||||
) -> Result<Self, ConfigurationError> {
|
||||
let wire: WireConfiguration = toml::from_str(input).map_err(ConfigurationError::Toml)?;
|
||||
if wire.schema_version != CONFIG_SCHEMA_VERSION {
|
||||
return Err(ConfigurationError::UnsupportedSchemaVersion(
|
||||
wire.schema_version,
|
||||
));
|
||||
}
|
||||
if wire
|
||||
.launch
|
||||
.environment
|
||||
.values()
|
||||
.any(|patch| matches!(patch, EnvironmentPatch::Remove(true)))
|
||||
{
|
||||
return Err(ConfigurationError::InvalidEnvironmentDeletion);
|
||||
}
|
||||
if wire.launch.wrapper_mode == WrapperMode::Clear && !wire.launch.wrappers.is_empty() {
|
||||
return Err(ConfigurationError::ClearWithWrappers);
|
||||
}
|
||||
Ok(Self {
|
||||
source,
|
||||
launch: wire.launch,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns this layer's precedence source.
|
||||
#[must_use]
|
||||
pub const fn source(&self) -> &ConfigurationSource {
|
||||
&self.source
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates a replacement only when the configuration has not changed externally.
|
||||
///
|
||||
/// This is the compare-and-swap contract used by future CLI editing: callers read a byte
|
||||
/// snapshot, prepare a replacement, then re-read immediately before mutation. The write
|
||||
/// must not proceed when `current` differs from `expected`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`ConfigurationError::ConcurrentExternalEdit`] when the current bytes differ,
|
||||
/// or the same parsing errors as [`ConfigurationLayerV1::parse_toml`] for the replacement.
|
||||
pub fn validate_replacement(
|
||||
source: ConfigurationSource,
|
||||
expected: &str,
|
||||
current: &str,
|
||||
replacement: &str,
|
||||
) -> Result<ConfigurationLayerV1, ConfigurationError> {
|
||||
if expected.as_bytes() != current.as_bytes() {
|
||||
return Err(ConfigurationError::ConcurrentExternalEdit);
|
||||
}
|
||||
ConfigurationLayerV1::parse_toml(source, replacement)
|
||||
}
|
||||
|
||||
/// Resolves ordered configuration layers with full provenance.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a structured error when layers are out of precedence order, a required final
|
||||
/// value is absent, or a configured environment value is invalid.
|
||||
pub fn resolve_layers(
|
||||
layers: &[ConfigurationLayerV1],
|
||||
) -> Result<ResolvedLaunchConfiguration, ConfigurationError> {
|
||||
if layers
|
||||
.windows(2)
|
||||
.any(|pair| pair[0].source.tier >= pair[1].source.tier)
|
||||
{
|
||||
return Err(ConfigurationError::LayerOrder);
|
||||
}
|
||||
|
||||
let mut runtime: Option<ResolvedValue<ContractName>> = None;
|
||||
let mut session_backend: Option<ResolvedValue<ContractName>> = None;
|
||||
let mut display_mode = None;
|
||||
let mut environment = BTreeMap::new();
|
||||
let mut wrappers = Vec::new();
|
||||
let mut provenance = Vec::new();
|
||||
|
||||
for layer in layers {
|
||||
if let Some(value) = &layer.launch.runtime {
|
||||
record_scalar("runtime", &layer.source, runtime.is_some(), &mut provenance);
|
||||
runtime = Some(ResolvedValue {
|
||||
value: value.clone(),
|
||||
source: layer.source.clone(),
|
||||
});
|
||||
}
|
||||
if let Some(value) = &layer.launch.session_backend {
|
||||
record_scalar(
|
||||
"session_backend",
|
||||
&layer.source,
|
||||
session_backend.is_some(),
|
||||
&mut provenance,
|
||||
);
|
||||
session_backend = Some(ResolvedValue {
|
||||
value: value.clone(),
|
||||
source: layer.source.clone(),
|
||||
});
|
||||
}
|
||||
if let Some(value) = layer.launch.display_mode {
|
||||
record_scalar(
|
||||
"display_mode",
|
||||
&layer.source,
|
||||
display_mode.is_some(),
|
||||
&mut provenance,
|
||||
);
|
||||
display_mode = Some(ResolvedValue {
|
||||
value,
|
||||
source: layer.source.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
apply_environment(layer, &mut environment, &mut provenance)?;
|
||||
apply_wrappers(layer, &mut wrappers, &mut provenance);
|
||||
}
|
||||
|
||||
Ok(ResolvedLaunchConfiguration {
|
||||
runtime: runtime.ok_or(ConfigurationError::MissingRequiredValue("runtime"))?,
|
||||
session_backend: session_backend
|
||||
.ok_or(ConfigurationError::MissingRequiredValue("session_backend"))?,
|
||||
display_mode,
|
||||
environment,
|
||||
wrappers,
|
||||
provenance,
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_environment(
|
||||
layer: &ConfigurationLayerV1,
|
||||
environment: &mut BTreeMap<EnvironmentName, ResolvedValue<EnvironmentValue>>,
|
||||
provenance: &mut Vec<ProvenanceEvent>,
|
||||
) -> Result<(), ConfigurationError> {
|
||||
for (name, patch) in &layer.launch.environment {
|
||||
let field = format!("environment.{}", name.as_str());
|
||||
match patch {
|
||||
EnvironmentPatch::Set(raw) => {
|
||||
let value = EnvironmentValue::try_from(raw.clone())
|
||||
.map_err(|_| ConfigurationError::InvalidEnvironmentValue(name.clone()))?;
|
||||
let operation = if environment.contains_key(name) {
|
||||
MergeOperation::Replaced
|
||||
} else {
|
||||
MergeOperation::Set
|
||||
};
|
||||
environment.insert(
|
||||
name.clone(),
|
||||
ResolvedValue {
|
||||
value,
|
||||
source: layer.source.clone(),
|
||||
},
|
||||
);
|
||||
provenance.push(event(&field, &layer.source, operation));
|
||||
}
|
||||
EnvironmentPatch::Remove(false) => {
|
||||
environment.remove(name);
|
||||
provenance.push(event(&field, &layer.source, MergeOperation::Removed));
|
||||
}
|
||||
EnvironmentPatch::Remove(true) => unreachable!("validated while parsing"),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_wrappers(
|
||||
layer: &ConfigurationLayerV1,
|
||||
wrappers: &mut Vec<ResolvedValue<CommandSpec>>,
|
||||
provenance: &mut Vec<ProvenanceEvent>,
|
||||
) {
|
||||
let sourced = || {
|
||||
layer
|
||||
.launch
|
||||
.wrappers
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|value| ResolvedValue {
|
||||
value,
|
||||
source: layer.source.clone(),
|
||||
})
|
||||
};
|
||||
match layer.launch.wrapper_mode {
|
||||
WrapperMode::Append if !layer.launch.wrappers.is_empty() => {
|
||||
wrappers.extend(sourced());
|
||||
provenance.push(event("wrappers", &layer.source, MergeOperation::Appended));
|
||||
}
|
||||
WrapperMode::Append => {}
|
||||
WrapperMode::Replace => {
|
||||
*wrappers = sourced().collect();
|
||||
provenance.push(event("wrappers", &layer.source, MergeOperation::Replaced));
|
||||
}
|
||||
WrapperMode::Clear => {
|
||||
wrappers.clear();
|
||||
provenance.push(event("wrappers", &layer.source, MergeOperation::Removed));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A configuration layer or resolution failed validation.
|
||||
#[derive(Debug)]
|
||||
pub enum ConfigurationError {
|
||||
/// Filesystem access failed.
|
||||
Io(std::io::Error),
|
||||
/// No absolute XDG configuration or home directory is available.
|
||||
NoConfigurationHome,
|
||||
/// A configuration path was relative, non-regular, or contained a symlink.
|
||||
UnsafePath,
|
||||
/// The configuration exceeded the fixed one-megabyte limit.
|
||||
TooLarge,
|
||||
/// Configuration bytes were not valid UTF-8.
|
||||
InvalidUtf8,
|
||||
/// TOML syntax or a typed value was invalid.
|
||||
Toml(toml::de::Error),
|
||||
/// The configuration schema version is unsupported.
|
||||
UnsupportedSchemaVersion(u32),
|
||||
/// Environment deletion must use `false`; `true` is not meaningful.
|
||||
InvalidEnvironmentDeletion,
|
||||
/// `wrapper_mode = "clear"` cannot also declare wrappers.
|
||||
ClearWithWrappers,
|
||||
/// Layers were not strictly ordered from lowest to highest precedence.
|
||||
LayerOrder,
|
||||
/// The file changed after the caller's snapshot and must not be overwritten.
|
||||
ConcurrentExternalEdit,
|
||||
/// Resolution ended without a required scalar.
|
||||
MissingRequiredValue(&'static str),
|
||||
/// A configured environment value contained a control character.
|
||||
InvalidEnvironmentValue(EnvironmentName),
|
||||
}
|
||||
|
||||
impl fmt::Display for ConfigurationError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Io(error) => write!(formatter, "configuration I/O failed: {error}"),
|
||||
Self::NoConfigurationHome => {
|
||||
formatter.write_str("no absolute XDG config or home directory is available")
|
||||
}
|
||||
Self::UnsafePath => formatter.write_str("configuration path is unsafe"),
|
||||
Self::TooLarge => formatter.write_str("configuration exceeds 1048576 bytes"),
|
||||
Self::InvalidUtf8 => formatter.write_str("configuration is not UTF-8"),
|
||||
Self::Toml(error) => write!(formatter, "invalid configuration TOML: {error}"),
|
||||
Self::UnsupportedSchemaVersion(version) => {
|
||||
write!(
|
||||
formatter,
|
||||
"unsupported configuration schema version: {version}"
|
||||
)
|
||||
}
|
||||
Self::InvalidEnvironmentDeletion => {
|
||||
formatter.write_str("environment deletion must use false")
|
||||
}
|
||||
Self::ClearWithWrappers => {
|
||||
formatter.write_str("wrapper clear cannot include wrapper values")
|
||||
}
|
||||
Self::LayerOrder => formatter.write_str("configuration layers are out of order"),
|
||||
Self::ConcurrentExternalEdit => {
|
||||
formatter.write_str("configuration changed after it was read")
|
||||
}
|
||||
Self::MissingRequiredValue(field) => {
|
||||
write!(formatter, "resolved configuration is missing {field}")
|
||||
}
|
||||
Self::InvalidEnvironmentValue(name) => {
|
||||
write!(formatter, "invalid environment value for {}", name.as_str())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ConfigurationError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Io(error) => Some(error),
|
||||
Self::Toml(error) => Some(error),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for ConfigurationError {
|
||||
fn from(error: std::io::Error) -> Self {
|
||||
Self::Io(error)
|
||||
}
|
||||
}
|
||||
|
||||
fn record_scalar(
|
||||
field: &str,
|
||||
source: &ConfigurationSource,
|
||||
existed: bool,
|
||||
provenance: &mut Vec<ProvenanceEvent>,
|
||||
) {
|
||||
provenance.push(event(
|
||||
field,
|
||||
source,
|
||||
if existed {
|
||||
MergeOperation::Replaced
|
||||
} else {
|
||||
MergeOperation::Set
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
fn event(field: &str, source: &ConfigurationSource, operation: MergeOperation) -> ProvenanceEvent {
|
||||
ProvenanceEvent {
|
||||
field: field.parse().expect("static or validated provenance field"),
|
||||
source: source.clone(),
|
||||
operation,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{
|
||||
COMMENTED_DEFAULT_CONFIG, ConfigurationError, ConfigurationLayerV1, ConfigurationSource,
|
||||
ConfigurationTier, EditableScalar, MergeOperation, edit_configuration_scalar,
|
||||
read_configuration, replace_configuration_file, resolve_layers, validate_replacement,
|
||||
};
|
||||
|
||||
fn source(tier: ConfigurationTier, label: &str) -> ConfigurationSource {
|
||||
ConfigurationSource {
|
||||
tier,
|
||||
label: label.parse().expect("label"),
|
||||
}
|
||||
}
|
||||
|
||||
fn layer(tier: ConfigurationTier, label: &str, input: &str) -> ConfigurationLayerV1 {
|
||||
ConfigurationLayerV1::parse_toml(source(tier, label), input).expect("layer")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_full_precedence_with_environment_and_wrapper_provenance() {
|
||||
let defaults = layer(
|
||||
ConfigurationTier::BuiltIn,
|
||||
"built-in",
|
||||
r#"
|
||||
schema_version = 1
|
||||
[launch]
|
||||
runtime = "native"
|
||||
session_backend = "wayland"
|
||||
wrapper_mode = "replace"
|
||||
wrappers = [{ executable = { kind = "program", value = "base-wrapper" }, arguments = [] }]
|
||||
[launch.environment]
|
||||
BASE = "1"
|
||||
REMOVE_ME = "yes"
|
||||
"#,
|
||||
);
|
||||
let user = layer(
|
||||
ConfigurationTier::GlobalUser,
|
||||
"user-profile",
|
||||
r#"
|
||||
schema_version = 1
|
||||
[launch]
|
||||
runtime = "umu"
|
||||
wrappers = [{ executable = { kind = "program", value = "user-wrapper" }, arguments = [] }]
|
||||
[launch.environment]
|
||||
BASE = "2"
|
||||
REMOVE_ME = false
|
||||
"#,
|
||||
);
|
||||
let cli = layer(
|
||||
ConfigurationTier::Cli,
|
||||
"one-launch",
|
||||
r#"
|
||||
schema_version = 1
|
||||
[launch]
|
||||
wrapper_mode = "clear"
|
||||
"#,
|
||||
);
|
||||
|
||||
let resolved = resolve_layers(&[defaults, user, cli]).expect("resolved");
|
||||
assert_eq!(resolved.runtime.value.as_str(), "umu");
|
||||
assert_eq!(resolved.runtime.source.tier, ConfigurationTier::GlobalUser);
|
||||
assert_eq!(
|
||||
resolved.environment[&"BASE".parse().expect("name")]
|
||||
.value
|
||||
.as_str(),
|
||||
"2"
|
||||
);
|
||||
assert!(
|
||||
!resolved
|
||||
.environment
|
||||
.contains_key(&"REMOVE_ME".parse().expect("name"))
|
||||
);
|
||||
assert!(resolved.wrappers.is_empty());
|
||||
assert!(resolved.provenance.iter().any(|event| {
|
||||
event.field.as_str() == "environment.REMOVE_ME"
|
||||
&& event.operation == MergeOperation::Removed
|
||||
}));
|
||||
assert_eq!(
|
||||
resolved.provenance.last().expect("event").operation,
|
||||
MergeOperation::Removed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_then_runtime_defaults_have_fixed_suborder() {
|
||||
let provider = layer(
|
||||
ConfigurationTier::Provider,
|
||||
"steam-defaults",
|
||||
"schema_version = 1\n[launch]\nruntime = \"steam-proton\"\nsession_backend = \"wayland\"",
|
||||
);
|
||||
let runtime = layer(
|
||||
ConfigurationTier::Runtime,
|
||||
"proton-defaults",
|
||||
"schema_version = 1\n[launch]\nruntime = \"ge-proton\"",
|
||||
);
|
||||
let resolved = resolve_layers(&[provider, runtime]).expect("resolved");
|
||||
assert_eq!(resolved.runtime.value.as_str(), "ge-proton");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_versions_unknown_fields_invalid_deletion_and_wrapper_conflicts() {
|
||||
assert!(matches!(
|
||||
ConfigurationLayerV1::parse_toml(
|
||||
source(ConfigurationTier::BuiltIn, "test"),
|
||||
"schema_version = 2"
|
||||
),
|
||||
Err(ConfigurationError::UnsupportedSchemaVersion(2))
|
||||
));
|
||||
assert!(
|
||||
ConfigurationLayerV1::parse_toml(
|
||||
source(ConfigurationTier::BuiltIn, "test"),
|
||||
"schema_version = 1\nunknown = true"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(matches!(
|
||||
ConfigurationLayerV1::parse_toml(
|
||||
source(ConfigurationTier::BuiltIn, "test"),
|
||||
"schema_version = 1\n[launch.environment]\nBAD = true"
|
||||
),
|
||||
Err(ConfigurationError::InvalidEnvironmentDeletion)
|
||||
));
|
||||
assert!(matches!(
|
||||
ConfigurationLayerV1::parse_toml(
|
||||
source(ConfigurationTier::BuiltIn, "test"),
|
||||
"schema_version = 1\n[launch]\nwrapper_mode = \"clear\"\nwrappers = [{ executable = { kind = \"program\", value = \"x\" }, arguments = [] }]"
|
||||
),
|
||||
Err(ConfigurationError::ClearWithWrappers)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_out_of_order_and_missing_required_values() {
|
||||
let high = layer(ConfigurationTier::Cli, "cli", "schema_version = 1");
|
||||
let low = layer(ConfigurationTier::BuiltIn, "built-in", "schema_version = 1");
|
||||
assert!(matches!(
|
||||
resolve_layers(&[high, low]),
|
||||
Err(ConfigurationError::LayerOrder)
|
||||
));
|
||||
assert!(matches!(
|
||||
resolve_layers(&[]),
|
||||
Err(ConfigurationError::MissingRequiredValue("runtime"))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commented_defaults_are_valid_toml() {
|
||||
let parsed = ConfigurationLayerV1::parse_toml(
|
||||
source(ConfigurationTier::BuiltIn, "generated-defaults"),
|
||||
COMMENTED_DEFAULT_CONFIG,
|
||||
)
|
||||
.expect("defaults");
|
||||
assert_eq!(parsed.source().tier, ConfigurationTier::BuiltIn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_and_swap_rejects_concurrent_external_edits() {
|
||||
let expected = "schema_version = 1\n[launch]\nruntime = \"native\"";
|
||||
let current = "schema_version = 1\n[launch]\nruntime = \"umu\"";
|
||||
assert!(matches!(
|
||||
validate_replacement(
|
||||
source(ConfigurationTier::GlobalUser, "user"),
|
||||
expected,
|
||||
current,
|
||||
expected
|
||||
),
|
||||
Err(ConfigurationError::ConcurrentExternalEdit)
|
||||
));
|
||||
assert!(
|
||||
validate_replacement(
|
||||
source(ConfigurationTier::GlobalUser, "user"),
|
||||
expected,
|
||||
expected,
|
||||
"schema_version = 1\n[launch]\nruntime = \"umu\""
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalar_edits_preserve_comments_and_other_configuration() {
|
||||
let input = "# keep me\nschema_version = 1\n\n[launch]\nruntime = \"native\" # old\nsession_backend = \"current\"\n\n[launch.environment]\nMANGOHUD = \"1\"\n";
|
||||
let value = "umu".parse().expect("name");
|
||||
let edited = edit_configuration_scalar(input, EditableScalar::Runtime, &value).unwrap();
|
||||
assert!(edited.contains("# keep me"));
|
||||
assert!(edited.contains("runtime = \"umu\""));
|
||||
assert!(edited.contains("session_backend = \"current\""));
|
||||
assert!(edited.contains("MANGOHUD = \"1\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_replacement_rejects_a_changed_snapshot_and_secures_writes() {
|
||||
let directory = std::env::temp_dir().join(format!("kiln-config-{}", Uuid::new_v4()));
|
||||
let path = directory.join("kiln/config.toml");
|
||||
let original = "schema_version = 1\n[launch]\nruntime = \"native\"\n";
|
||||
replace_configuration_file(&path, None, original).unwrap();
|
||||
fs::write(
|
||||
&path,
|
||||
"schema_version = 1\n[launch]\nruntime = \"external\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
replace_configuration_file(&path, Some(original), original),
|
||||
Err(ConfigurationError::ConcurrentExternalEdit)
|
||||
));
|
||||
assert!(
|
||||
read_configuration(&path)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.contains("external")
|
||||
);
|
||||
let _ = fs::remove_dir_all(directory);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
//! Versioned structured errors for machine-readable clients.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
use crate::{ContractName, ContractText, GameId, InstallationId};
|
||||
|
||||
/// Schema version for structured contract errors.
|
||||
pub const CONTRACT_ERROR_SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
/// Stable machine-readable error code.
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ContractErrorCode {
|
||||
/// No matching game or installation exists.
|
||||
NotFound,
|
||||
/// A human-facing selector matched multiple games.
|
||||
AmbiguousGame,
|
||||
/// The requested adapter or runtime capability is unavailable.
|
||||
UnsupportedCapability,
|
||||
/// A serialized contract version is unsupported.
|
||||
UnsupportedVersion,
|
||||
/// Untrusted input failed validation.
|
||||
InvalidInput,
|
||||
/// A valid request could not produce a launch plan.
|
||||
LaunchPlanningFailed,
|
||||
/// A planned child process could not start or exited unsuccessfully.
|
||||
LaunchFailed,
|
||||
/// Provider state exists but is temporarily unavailable.
|
||||
TemporarilyUnavailable,
|
||||
/// Durable identity state conflicts with an observation.
|
||||
RegistryConflict,
|
||||
}
|
||||
|
||||
/// One deterministic candidate returned for an ambiguous game selector.
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AmbiguousGameCandidate {
|
||||
/// Stable game identity.
|
||||
pub id: GameId,
|
||||
/// Store/source namespace.
|
||||
pub store: ContractName,
|
||||
/// Persistent installation identity.
|
||||
pub installation_id: InstallationId,
|
||||
/// Human-readable installation label.
|
||||
pub label: ContractText,
|
||||
}
|
||||
|
||||
/// Version 1 structured contract error.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
pub struct ContractErrorV1 {
|
||||
schema_version: u32,
|
||||
code: ContractErrorCode,
|
||||
message: ContractText,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
candidates: Vec<AmbiguousGameCandidate>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
capability: Option<ContractName>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
supported_version: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
received_version: Option<u32>,
|
||||
}
|
||||
|
||||
impl ContractErrorV1 {
|
||||
/// Creates a structured error with no optional details.
|
||||
#[must_use]
|
||||
pub const fn new(code: ContractErrorCode, message: ContractText) -> Self {
|
||||
Self {
|
||||
schema_version: CONTRACT_ERROR_SCHEMA_VERSION,
|
||||
code,
|
||||
message,
|
||||
candidates: Vec::new(),
|
||||
capability: None,
|
||||
supported_version: None,
|
||||
received_version: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an ambiguity error with deterministic candidates.
|
||||
#[must_use]
|
||||
pub fn ambiguous(message: ContractText, mut candidates: Vec<AmbiguousGameCandidate>) -> Self {
|
||||
candidates.sort_by(|left, right| {
|
||||
left.id
|
||||
.to_string()
|
||||
.cmp(&right.id.to_string())
|
||||
.then_with(|| {
|
||||
left.installation_id
|
||||
.to_string()
|
||||
.cmp(&right.installation_id.to_string())
|
||||
})
|
||||
});
|
||||
let mut error = Self::new(ContractErrorCode::AmbiguousGame, message);
|
||||
error.candidates = candidates;
|
||||
error
|
||||
}
|
||||
|
||||
/// Creates an unsupported-capability error.
|
||||
#[must_use]
|
||||
pub fn unsupported_capability(message: ContractText, capability: ContractName) -> Self {
|
||||
let mut error = Self::new(ContractErrorCode::UnsupportedCapability, message);
|
||||
error.capability = Some(capability);
|
||||
error
|
||||
}
|
||||
|
||||
/// Creates an unsupported-version error.
|
||||
#[must_use]
|
||||
pub const fn unsupported_version(
|
||||
message: ContractText,
|
||||
supported_version: u32,
|
||||
received_version: u32,
|
||||
) -> Self {
|
||||
let mut error = Self::new(ContractErrorCode::UnsupportedVersion, message);
|
||||
error.supported_version = Some(supported_version);
|
||||
error.received_version = Some(received_version);
|
||||
error
|
||||
}
|
||||
|
||||
/// Returns the machine-readable error code.
|
||||
#[must_use]
|
||||
pub const fn code(&self) -> ContractErrorCode {
|
||||
self.code
|
||||
}
|
||||
|
||||
/// Returns ambiguity candidates, if any.
|
||||
#[must_use]
|
||||
pub fn candidates(&self) -> &[AmbiguousGameCandidate] {
|
||||
&self.candidates
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct WireContractError {
|
||||
schema_version: u32,
|
||||
code: ContractErrorCode,
|
||||
message: ContractText,
|
||||
#[serde(default)]
|
||||
candidates: Vec<AmbiguousGameCandidate>,
|
||||
capability: Option<ContractName>,
|
||||
supported_version: Option<u32>,
|
||||
received_version: Option<u32>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ContractErrorV1 {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let wire = WireContractError::deserialize(deserializer)?;
|
||||
if wire.schema_version != CONTRACT_ERROR_SCHEMA_VERSION {
|
||||
return Err(serde::de::Error::custom(format_args!(
|
||||
"unsupported contract error schema version: {}",
|
||||
wire.schema_version
|
||||
)));
|
||||
}
|
||||
Ok(Self {
|
||||
schema_version: wire.schema_version,
|
||||
code: wire.code,
|
||||
message: wire.message,
|
||||
candidates: wire.candidates,
|
||||
capability: wire.capability,
|
||||
supported_version: wire.supported_version,
|
||||
received_version: wire.received_version,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ContractErrorV1 {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(self.message.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ContractErrorV1 {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{CONTRACT_ERROR_SCHEMA_VERSION, ContractErrorCode, ContractErrorV1};
|
||||
use crate::{ContractName, ContractText};
|
||||
|
||||
fn text(raw: &str) -> ContractText {
|
||||
raw.parse().expect("contract text")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_optional_details_only_when_present() {
|
||||
let error = ContractErrorV1::unsupported_capability(
|
||||
text("not supported"),
|
||||
"launch".parse::<ContractName>().expect("capability"),
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(error).expect("JSON"),
|
||||
json!({
|
||||
"schema_version": CONTRACT_ERROR_SCHEMA_VERSION,
|
||||
"code": "unsupported-capability",
|
||||
"message": "not supported",
|
||||
"capability": "launch"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_fields_and_versions() {
|
||||
let unknown = json!({
|
||||
"schema_version": 1, "code": "not-found", "message": "missing", "future": true
|
||||
});
|
||||
assert!(serde_json::from_value::<ContractErrorV1>(unknown).is_err());
|
||||
let version = json!({"schema_version": 2, "code": "not-found", "message": "missing"});
|
||||
assert!(serde_json::from_value::<ContractErrorV1>(version).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_a_basic_error() {
|
||||
let error = ContractErrorV1::new(ContractErrorCode::NotFound, text("missing"));
|
||||
let encoded = serde_json::to_vec(&error).expect("JSON");
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<ContractErrorV1>(&encoded).expect("error"),
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
//! Argument-safe launch plans and lifecycle policy.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
use crate::config::ProvenanceEvent;
|
||||
use crate::{ContractName, GameId, InstallationId};
|
||||
|
||||
/// Schema version for launch plans.
|
||||
pub const LAUNCH_PLAN_SCHEMA_VERSION: u32 = 2;
|
||||
|
||||
/// One command argument passed directly without shell evaluation.
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(try_from = "String", into = "String")]
|
||||
pub struct CommandArgument(String);
|
||||
|
||||
impl CommandArgument {
|
||||
/// Returns the argument exactly as it will be passed to the child process.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CommandArgument> for String {
|
||||
fn from(argument: CommandArgument) -> Self {
|
||||
argument.0
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for CommandArgument {
|
||||
type Error = CommandValueError;
|
||||
|
||||
fn try_from(raw: String) -> Result<Self, Self::Error> {
|
||||
if raw.chars().any(char::is_control) {
|
||||
Err(CommandValueError)
|
||||
} else {
|
||||
Ok(Self(raw))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A child-process environment variable name.
|
||||
#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
|
||||
#[serde(try_from = "String", into = "String")]
|
||||
pub struct EnvironmentName(String);
|
||||
|
||||
impl EnvironmentName {
|
||||
/// Returns the validated environment key.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<EnvironmentName> for String {
|
||||
fn from(name: EnvironmentName) -> Self {
|
||||
name.0
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for EnvironmentName {
|
||||
type Err = CommandValueError;
|
||||
|
||||
fn from_str(raw: &str) -> Result<Self, Self::Err> {
|
||||
let mut bytes = raw.bytes();
|
||||
let valid_first = bytes
|
||||
.next()
|
||||
.is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_');
|
||||
if valid_first && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') {
|
||||
Ok(Self(raw.to_owned()))
|
||||
} else {
|
||||
Err(CommandValueError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for EnvironmentName {
|
||||
type Error = CommandValueError;
|
||||
|
||||
fn try_from(raw: String) -> Result<Self, Self::Error> {
|
||||
raw.parse()
|
||||
}
|
||||
}
|
||||
|
||||
/// A child-process environment value passed without shell evaluation.
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(try_from = "String", into = "String")]
|
||||
pub struct EnvironmentValue(String);
|
||||
|
||||
impl EnvironmentValue {
|
||||
/// Returns the exact environment value.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<EnvironmentValue> for String {
|
||||
fn from(value: EnvironmentValue) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for EnvironmentValue {
|
||||
type Error = CommandValueError;
|
||||
|
||||
fn try_from(raw: String) -> Result<Self, Self::Error> {
|
||||
if raw.chars().any(char::is_control) {
|
||||
Err(CommandValueError)
|
||||
} else {
|
||||
Ok(Self(raw))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A command value contained a control character or invalid name.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct CommandValueError;
|
||||
|
||||
impl fmt::Display for CommandValueError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("command value contains unsupported characters")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CommandValueError {}
|
||||
|
||||
/// An executable resolved either by absolute path or by a fixed program name.
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(tag = "kind", content = "value", rename_all = "kebab-case")]
|
||||
pub enum Executable {
|
||||
/// Validated absolute executable path.
|
||||
Absolute(crate::InstallPath),
|
||||
/// Fixed program name resolved by the documented minimal environment.
|
||||
Program(ContractName),
|
||||
}
|
||||
|
||||
/// A typed executable and argument vector; no shell string exists in this contract.
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CommandSpec {
|
||||
/// Executable path or fixed program name.
|
||||
pub executable: Executable,
|
||||
/// Ordered arguments passed directly to the executable.
|
||||
pub arguments: Vec<CommandArgument>,
|
||||
}
|
||||
|
||||
/// Display mode requested for the launch.
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(try_from = "WireDisplayMode")]
|
||||
pub struct DisplayMode {
|
||||
/// Horizontal pixels.
|
||||
pub width: u32,
|
||||
/// Vertical pixels.
|
||||
pub height: u32,
|
||||
/// Refresh rate in millihertz.
|
||||
pub refresh_millihertz: u32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct WireDisplayMode {
|
||||
width: u32,
|
||||
height: u32,
|
||||
refresh_millihertz: u32,
|
||||
}
|
||||
|
||||
impl TryFrom<WireDisplayMode> for DisplayMode {
|
||||
type Error = DisplayModeError;
|
||||
|
||||
fn try_from(wire: WireDisplayMode) -> Result<Self, Self::Error> {
|
||||
if wire.width == 0 || wire.height == 0 || wire.refresh_millihertz == 0 {
|
||||
return Err(DisplayModeError);
|
||||
}
|
||||
Ok(Self {
|
||||
width: wire.width,
|
||||
height: wire.height,
|
||||
refresh_millihertz: wire.refresh_millihertz,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A display mode contained a zero dimension or refresh rate.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct DisplayModeError;
|
||||
|
||||
impl fmt::Display for DisplayModeError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("display dimensions and refresh rate must be positive")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for DisplayModeError {}
|
||||
|
||||
/// Controller availability captured when the plan was resolved.
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ControllerState {
|
||||
/// A controller was available.
|
||||
Connected,
|
||||
/// No controller was available.
|
||||
Disconnected,
|
||||
/// Controller state could not be determined.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Confidence in game-lifecycle observation.
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum LifecycleConfidence {
|
||||
/// The game runs as an owned process or scope.
|
||||
Tracked,
|
||||
/// The provider reports lifecycle state directly.
|
||||
ProviderReported,
|
||||
/// Process correlation suggests state without authoritative ownership.
|
||||
Probable,
|
||||
/// Lifecycle state cannot be determined reliably.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Temporary policy effect that may depend on lifecycle confidence.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum LifecycleEffect {
|
||||
/// Keep the system awake while the game is known to run.
|
||||
SleepInhibition,
|
||||
/// Temporarily suppress optional background work.
|
||||
BackgroundSuppression,
|
||||
/// Record lifecycle diagnostics.
|
||||
Logging,
|
||||
/// Expose lifecycle state to clients.
|
||||
StateReporting,
|
||||
/// Restore temporary settings conservatively.
|
||||
SafeCleanup,
|
||||
}
|
||||
|
||||
impl LifecycleConfidence {
|
||||
/// Reports whether this confidence is strong enough for an automatic policy effect.
|
||||
///
|
||||
/// `GameMode` is intentionally absent: it requires a real wrapper request bound to game
|
||||
/// execution and is never authorized by lifecycle confidence alone.
|
||||
#[must_use]
|
||||
pub const fn allows(self, effect: LifecycleEffect) -> bool {
|
||||
match self {
|
||||
Self::Tracked | Self::ProviderReported => true,
|
||||
Self::Probable => matches!(
|
||||
effect,
|
||||
LifecycleEffect::Logging
|
||||
| LifecycleEffect::StateReporting
|
||||
| LifecycleEffect::SafeCleanup
|
||||
),
|
||||
Self::Unknown => matches!(
|
||||
effect,
|
||||
LifecycleEffect::Logging | LifecycleEffect::SafeCleanup
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Version 1 dry-run and execution launch plan.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
pub struct LaunchPlanV1 {
|
||||
schema_version: u32,
|
||||
game_id: GameId,
|
||||
installation_id: InstallationId,
|
||||
store: ContractName,
|
||||
adapter: ContractName,
|
||||
configuration_owner: ContractName,
|
||||
runtime: ContractName,
|
||||
session_backend: ContractName,
|
||||
display_mode: Option<DisplayMode>,
|
||||
controller_state: ControllerState,
|
||||
working_directory: crate::InstallPath,
|
||||
environment: BTreeMap<EnvironmentName, EnvironmentValue>,
|
||||
wrappers: Vec<CommandSpec>,
|
||||
provenance: Vec<ProvenanceEvent>,
|
||||
lifecycle_confidence: LifecycleConfidence,
|
||||
command: CommandSpec,
|
||||
}
|
||||
|
||||
/// Validated fields used to construct a launch plan.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct LaunchPlanInput {
|
||||
/// Stable game identity.
|
||||
pub game_id: GameId,
|
||||
/// Persistent installation identity.
|
||||
pub installation_id: InstallationId,
|
||||
/// Canonical store/source namespace.
|
||||
pub store: ContractName,
|
||||
/// Adapter producing the plan.
|
||||
pub adapter: ContractName,
|
||||
/// Owner of mutable configuration.
|
||||
pub configuration_owner: ContractName,
|
||||
/// Runtime backend selected for execution.
|
||||
pub runtime: ContractName,
|
||||
/// Graphical or TTY session backend.
|
||||
pub session_backend: ContractName,
|
||||
/// Optional requested display mode.
|
||||
pub display_mode: Option<DisplayMode>,
|
||||
/// Controller state captured during planning.
|
||||
pub controller_state: ControllerState,
|
||||
/// Absolute directory selected as the child process working directory.
|
||||
pub working_directory: crate::InstallPath,
|
||||
/// Complete minimal child environment.
|
||||
pub environment: BTreeMap<EnvironmentName, EnvironmentValue>,
|
||||
/// Ordered wrapper commands.
|
||||
pub wrappers: Vec<CommandSpec>,
|
||||
/// Ordered configuration events explaining all resolved launch settings.
|
||||
pub provenance: Vec<ProvenanceEvent>,
|
||||
/// Planned lifecycle observation method.
|
||||
pub lifecycle_confidence: LifecycleConfidence,
|
||||
/// Final executable and argument vector.
|
||||
pub command: CommandSpec,
|
||||
}
|
||||
|
||||
impl LaunchPlanV1 {
|
||||
/// Creates a launch plan after cross-field validation.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`LaunchPlanError::StoreMismatch`] when the game ID namespace differs from
|
||||
/// the plan's canonical store.
|
||||
pub fn new(input: LaunchPlanInput) -> Result<Self, LaunchPlanError> {
|
||||
if input.store.as_str() != input.game_id.namespace() {
|
||||
return Err(LaunchPlanError::StoreMismatch);
|
||||
}
|
||||
Ok(Self {
|
||||
schema_version: LAUNCH_PLAN_SCHEMA_VERSION,
|
||||
game_id: input.game_id,
|
||||
installation_id: input.installation_id,
|
||||
store: input.store,
|
||||
adapter: input.adapter,
|
||||
configuration_owner: input.configuration_owner,
|
||||
runtime: input.runtime,
|
||||
session_backend: input.session_backend,
|
||||
display_mode: input.display_mode,
|
||||
controller_state: input.controller_state,
|
||||
working_directory: input.working_directory,
|
||||
environment: input.environment,
|
||||
wrappers: input.wrappers,
|
||||
provenance: input.provenance,
|
||||
lifecycle_confidence: input.lifecycle_confidence,
|
||||
command: input.command,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the game identity.
|
||||
#[must_use]
|
||||
pub const fn game_id(&self) -> &GameId {
|
||||
&self.game_id
|
||||
}
|
||||
|
||||
/// Returns the final argument-safe command.
|
||||
#[must_use]
|
||||
pub const fn command(&self) -> &CommandSpec {
|
||||
&self.command
|
||||
}
|
||||
|
||||
/// Returns the persistent installation identity used by the plan.
|
||||
#[must_use]
|
||||
pub const fn installation_id(&self) -> InstallationId {
|
||||
self.installation_id
|
||||
}
|
||||
|
||||
/// Returns the minimal environment passed to the child process.
|
||||
#[must_use]
|
||||
pub const fn environment(&self) -> &BTreeMap<EnvironmentName, EnvironmentValue> {
|
||||
&self.environment
|
||||
}
|
||||
|
||||
/// Returns the child process working directory.
|
||||
#[must_use]
|
||||
pub const fn working_directory(&self) -> &crate::InstallPath {
|
||||
&self.working_directory
|
||||
}
|
||||
|
||||
/// Returns wrapper commands in execution order.
|
||||
#[must_use]
|
||||
pub fn wrappers(&self) -> &[CommandSpec] {
|
||||
&self.wrappers
|
||||
}
|
||||
|
||||
/// Returns ordered configuration-resolution provenance.
|
||||
#[must_use]
|
||||
pub fn provenance(&self) -> &[ProvenanceEvent] {
|
||||
&self.provenance
|
||||
}
|
||||
|
||||
/// Returns planned lifecycle confidence.
|
||||
#[must_use]
|
||||
pub const fn lifecycle_confidence(&self) -> LifecycleConfidence {
|
||||
self.lifecycle_confidence
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct WireLaunchPlan {
|
||||
schema_version: u32,
|
||||
game_id: GameId,
|
||||
installation_id: InstallationId,
|
||||
store: ContractName,
|
||||
adapter: ContractName,
|
||||
configuration_owner: ContractName,
|
||||
runtime: ContractName,
|
||||
session_backend: ContractName,
|
||||
display_mode: Option<DisplayMode>,
|
||||
controller_state: ControllerState,
|
||||
working_directory: crate::InstallPath,
|
||||
environment: BTreeMap<EnvironmentName, EnvironmentValue>,
|
||||
wrappers: Vec<CommandSpec>,
|
||||
provenance: Vec<ProvenanceEvent>,
|
||||
lifecycle_confidence: LifecycleConfidence,
|
||||
command: CommandSpec,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for LaunchPlanV1 {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let wire = WireLaunchPlan::deserialize(deserializer)?;
|
||||
if wire.schema_version != LAUNCH_PLAN_SCHEMA_VERSION {
|
||||
return Err(serde::de::Error::custom(format_args!(
|
||||
"unsupported launch plan schema version: {}",
|
||||
wire.schema_version
|
||||
)));
|
||||
}
|
||||
Self::new(LaunchPlanInput {
|
||||
game_id: wire.game_id,
|
||||
installation_id: wire.installation_id,
|
||||
store: wire.store,
|
||||
adapter: wire.adapter,
|
||||
configuration_owner: wire.configuration_owner,
|
||||
runtime: wire.runtime,
|
||||
session_backend: wire.session_backend,
|
||||
display_mode: wire.display_mode,
|
||||
controller_state: wire.controller_state,
|
||||
working_directory: wire.working_directory,
|
||||
environment: wire.environment,
|
||||
wrappers: wire.wrappers,
|
||||
provenance: wire.provenance,
|
||||
lifecycle_confidence: wire.lifecycle_confidence,
|
||||
command: wire.command,
|
||||
})
|
||||
.map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
/// A launch plan violated a cross-field contract.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum LaunchPlanError {
|
||||
/// Game identity namespace and canonical store differ.
|
||||
StoreMismatch,
|
||||
}
|
||||
|
||||
impl fmt::Display for LaunchPlanError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("launch plan game ID namespace must match store")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for LaunchPlanError {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
CommandArgument, EnvironmentName, LaunchPlanV1, LifecycleConfidence, LifecycleEffect,
|
||||
};
|
||||
|
||||
fn plan_json() -> Value {
|
||||
json!({
|
||||
"schema_version": 2,
|
||||
"game_id": "steam:620",
|
||||
"installation_id": "7aa62d69-8f8e-4cab-8514-275ce1f87748",
|
||||
"store": "steam",
|
||||
"adapter": "steam",
|
||||
"configuration_owner": "steam",
|
||||
"runtime": "steam-proton",
|
||||
"session_backend": "wayland",
|
||||
"display_mode": {"width": 1920, "height": 1080, "refresh_millihertz": 60000},
|
||||
"controller_state": "connected",
|
||||
"working_directory": "/games/steam/steamapps/common/Portal",
|
||||
"environment": {"STEAM_COMPAT_DATA_PATH": "/games/prefixes/620"},
|
||||
"wrappers": [{
|
||||
"executable": {"kind": "program", "value": "gamemoderun"},
|
||||
"arguments": []
|
||||
}],
|
||||
"provenance": [
|
||||
{"field": "runtime", "source": {"tier": "built-in", "label": "defaults"}, "operation": "set"},
|
||||
{"field": "session_backend", "source": {"tier": "built-in", "label": "defaults"}, "operation": "set"},
|
||||
{"field": "environment.STEAM_COMPAT_DATA_PATH", "source": {"tier": "game", "label": "steam:620"}, "operation": "set"},
|
||||
{"field": "wrappers", "source": {"tier": "game", "label": "steam:620"}, "operation": "appended"}
|
||||
],
|
||||
"lifecycle_confidence": "provider-reported",
|
||||
"command": {
|
||||
"executable": {"kind": "program", "value": "steam"},
|
||||
"arguments": ["-applaunch", "620; touch /tmp/not-executed"]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_argument_safe_plan_without_shell_parsing() {
|
||||
let input = plan_json();
|
||||
let plan: LaunchPlanV1 = serde_json::from_value(input.clone()).expect("plan");
|
||||
assert_eq!(
|
||||
plan.command().arguments[1].as_str(),
|
||||
"620; touch /tmp/not-executed"
|
||||
);
|
||||
assert_eq!(serde_json::to_value(plan).expect("JSON"), input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_environment_names_and_control_characters() {
|
||||
assert!("BAD=NAME".parse::<EnvironmentName>().is_err());
|
||||
assert!(CommandArgument::try_from("bad\narg".to_owned()).is_err());
|
||||
let mut plan = plan_json();
|
||||
plan["environment"] = json!({"BAD=NAME": "value"});
|
||||
assert!(serde_json::from_value::<LaunchPlanV1>(plan).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_fields_versions_and_zero_display_values() {
|
||||
let mut unknown = plan_json();
|
||||
unknown["future"] = json!(true);
|
||||
assert!(serde_json::from_value::<LaunchPlanV1>(unknown).is_err());
|
||||
let mut version = plan_json();
|
||||
version["schema_version"] = json!(3);
|
||||
assert!(serde_json::from_value::<LaunchPlanV1>(version).is_err());
|
||||
let mut display = plan_json();
|
||||
display["display_mode"]["width"] = json!(0);
|
||||
assert!(serde_json::from_value::<LaunchPlanV1>(display).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_policy_degrades_conservatively() {
|
||||
assert!(LifecycleConfidence::Tracked.allows(LifecycleEffect::BackgroundSuppression));
|
||||
assert!(LifecycleConfidence::ProviderReported.allows(LifecycleEffect::SleepInhibition));
|
||||
assert!(!LifecycleConfidence::Probable.allows(LifecycleEffect::SleepInhibition));
|
||||
assert!(LifecycleConfidence::Probable.allows(LifecycleEffect::SafeCleanup));
|
||||
assert!(!LifecycleConfidence::Unknown.allows(LifecycleEffect::StateReporting));
|
||||
assert!(LifecycleConfidence::Unknown.allows(LifecycleEffect::Logging));
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,17 @@ use std::str::FromStr;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Provider and runtime capability interfaces, plus the deterministic Phase 1 mock.
|
||||
pub mod adapter;
|
||||
/// Versioned TOML launch configuration, precedence, and provenance.
|
||||
pub mod config;
|
||||
/// Versioned structured machine-readable contract errors.
|
||||
pub mod contract_error;
|
||||
/// Argument-safe launch plan and lifecycle contracts.
|
||||
pub mod launch;
|
||||
/// Durable installation identity state.
|
||||
pub mod registry;
|
||||
|
||||
/// Schema version emitted by the initial private machine-readable contract.
|
||||
pub const CONTRACT_SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
@@ -153,7 +164,7 @@ impl fmt::Display for ContractNameError {
|
||||
impl std::error::Error for ContractNameError {}
|
||||
|
||||
/// A non-empty, unpadded UTF-8 value without control characters.
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
|
||||
#[serde(try_from = "String", into = "String")]
|
||||
pub struct ContractText(String);
|
||||
|
||||
@@ -440,6 +451,12 @@ impl GameRecordV1 {
|
||||
&self.id
|
||||
}
|
||||
|
||||
/// Returns the human-readable game name.
|
||||
#[must_use]
|
||||
pub const fn name(&self) -> &ContractText {
|
||||
&self.name
|
||||
}
|
||||
|
||||
/// Returns the installation identity.
|
||||
#[must_use]
|
||||
pub const fn installation_id(&self) -> InstallationId {
|
||||
@@ -475,6 +492,30 @@ impl GameRecordV1 {
|
||||
pub const fn runtime(&self) -> &ContractName {
|
||||
&self.runtime
|
||||
}
|
||||
|
||||
/// Returns whether the installation is currently available.
|
||||
#[must_use]
|
||||
pub const fn installed(&self) -> bool {
|
||||
self.installed
|
||||
}
|
||||
|
||||
/// Returns the installation path.
|
||||
#[must_use]
|
||||
pub const fn install_path(&self) -> &InstallPath {
|
||||
&self.install_path
|
||||
}
|
||||
|
||||
/// Returns the advertised capabilities.
|
||||
#[must_use]
|
||||
pub fn capabilities(&self) -> &[Capability] {
|
||||
&self.capabilities
|
||||
}
|
||||
|
||||
/// Returns whether a provider client is required for launch.
|
||||
#[must_use]
|
||||
pub const fn client_required(&self) -> bool {
|
||||
self.client_required
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
||||
@@ -0,0 +1,832 @@
|
||||
//! Durable installation identity state.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
use std::ffi::OsStr;
|
||||
use std::fmt;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{ContractName, ContractText, ContractTextError, GameId, InstallPath, InstallationId};
|
||||
|
||||
/// Schema version for the durable installation registry.
|
||||
pub const INSTALLATION_REGISTRY_SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
// ponytail: 1 MiB keeps corrupted state bounded; raise or shard after real libraries exceed it.
|
||||
const MAX_REGISTRY_BYTES: u64 = 1024 * 1024;
|
||||
const STATE_DIRECTORY_MODE: u32 = 0o700;
|
||||
const STATE_FILE_MODE: u32 = 0o600;
|
||||
|
||||
/// An adapter-owned stable fingerprint used to match an installation across moves.
|
||||
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
|
||||
#[serde(try_from = "String", into = "String")]
|
||||
pub struct InstallationFingerprint(ContractText);
|
||||
|
||||
impl InstallationFingerprint {
|
||||
/// Returns the validated adapter-owned value.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
self.0.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for InstallationFingerprint {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<InstallationFingerprint> for String {
|
||||
fn from(fingerprint: InstallationFingerprint) -> Self {
|
||||
fingerprint.0.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for InstallationFingerprint {
|
||||
type Err = ContractTextError;
|
||||
|
||||
fn from_str(raw: &str) -> Result<Self, Self::Err> {
|
||||
raw.parse().map(Self)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for InstallationFingerprint {
|
||||
type Error = ContractTextError;
|
||||
|
||||
fn try_from(raw: String) -> Result<Self, Self::Error> {
|
||||
raw.parse()
|
||||
}
|
||||
}
|
||||
|
||||
/// One adapter observation to resolve against durable installation identity state.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct InstallationObservation {
|
||||
game_id: GameId,
|
||||
adapter: ContractName,
|
||||
fingerprint: InstallationFingerprint,
|
||||
path: InstallPath,
|
||||
}
|
||||
|
||||
impl InstallationObservation {
|
||||
/// Creates a validated installation observation.
|
||||
#[must_use]
|
||||
pub const fn new(
|
||||
game_id: GameId,
|
||||
adapter: ContractName,
|
||||
fingerprint: InstallationFingerprint,
|
||||
path: InstallPath,
|
||||
) -> Self {
|
||||
Self {
|
||||
game_id,
|
||||
adapter,
|
||||
fingerprint,
|
||||
path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of resolving an installation observation.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum RegistryResolution {
|
||||
/// A new persistent installation identity was created.
|
||||
Created(InstallationId),
|
||||
/// The existing identity and path already matched.
|
||||
Existing(InstallationId),
|
||||
/// The existing identity was preserved while its path changed.
|
||||
Moved {
|
||||
/// Persistent identity retained across the move.
|
||||
installation_id: InstallationId,
|
||||
/// Location replaced by the newly observed path.
|
||||
previous_path: InstallPath,
|
||||
},
|
||||
}
|
||||
|
||||
impl RegistryResolution {
|
||||
/// Returns the persistent installation identity.
|
||||
#[must_use]
|
||||
pub const fn installation_id(&self) -> InstallationId {
|
||||
match self {
|
||||
Self::Created(id) | Self::Existing(id) => *id,
|
||||
Self::Moved {
|
||||
installation_id, ..
|
||||
} => *installation_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RegistryEntry {
|
||||
installation_id: InstallationId,
|
||||
game_id: GameId,
|
||||
adapter: ContractName,
|
||||
fingerprint: InstallationFingerprint,
|
||||
current_path: InstallPath,
|
||||
aliases: Vec<InstallPath>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RegistryDocument {
|
||||
schema_version: u32,
|
||||
entries: Vec<RegistryEntry>,
|
||||
}
|
||||
|
||||
impl Default for RegistryDocument {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
schema_version: INSTALLATION_REGISTRY_SCHEMA_VERSION,
|
||||
entries: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RegistryDocument {
|
||||
fn validate(&self) -> Result<(), RegistryError> {
|
||||
if self.schema_version != INSTALLATION_REGISTRY_SCHEMA_VERSION {
|
||||
return Err(RegistryError::UnsupportedSchemaVersion(self.schema_version));
|
||||
}
|
||||
|
||||
let mut installation_ids = HashSet::new();
|
||||
let mut fingerprints = HashSet::new();
|
||||
for entry in &self.entries {
|
||||
if !installation_ids.insert(entry.installation_id) {
|
||||
return Err(RegistryError::DuplicateInstallationId(
|
||||
entry.installation_id,
|
||||
));
|
||||
}
|
||||
let key = (entry.adapter.clone(), entry.fingerprint.clone());
|
||||
if !fingerprints.insert(key) {
|
||||
return Err(RegistryError::DuplicateFingerprint {
|
||||
adapter: entry.adapter.clone(),
|
||||
fingerprint: entry.fingerprint.clone(),
|
||||
});
|
||||
}
|
||||
let mut paths = HashSet::new();
|
||||
paths.insert(entry.current_path.as_path());
|
||||
if entry
|
||||
.aliases
|
||||
.iter()
|
||||
.any(|alias| !paths.insert(alias.as_path()))
|
||||
{
|
||||
return Err(RegistryError::DuplicatePathAlias(entry.installation_id));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve(
|
||||
&mut self,
|
||||
observation: &InstallationObservation,
|
||||
) -> Result<RegistryResolution, RegistryError> {
|
||||
if let Some(entry) = self.entries.iter_mut().find(|entry| {
|
||||
entry.adapter == observation.adapter && entry.fingerprint == observation.fingerprint
|
||||
}) {
|
||||
if entry.game_id != observation.game_id {
|
||||
return Err(RegistryError::FingerprintConflict {
|
||||
adapter: observation.adapter.clone(),
|
||||
fingerprint: observation.fingerprint.clone(),
|
||||
});
|
||||
}
|
||||
if entry.current_path == observation.path {
|
||||
return Ok(RegistryResolution::Existing(entry.installation_id));
|
||||
}
|
||||
|
||||
let previous_path = entry.current_path.clone();
|
||||
entry
|
||||
.aliases
|
||||
.retain(|alias| alias != &observation.path && alias != &previous_path);
|
||||
entry.aliases.push(previous_path.clone());
|
||||
entry.current_path = observation.path.clone();
|
||||
return Ok(RegistryResolution::Moved {
|
||||
installation_id: entry.installation_id,
|
||||
previous_path,
|
||||
});
|
||||
}
|
||||
|
||||
let installation_id = InstallationId::new(Uuid::new_v4());
|
||||
self.entries.push(RegistryEntry {
|
||||
installation_id,
|
||||
game_id: observation.game_id.clone(),
|
||||
adapter: observation.adapter.clone(),
|
||||
fingerprint: observation.fingerprint.clone(),
|
||||
current_path: observation.path.clone(),
|
||||
aliases: Vec::new(),
|
||||
});
|
||||
Ok(RegistryResolution::Created(installation_id))
|
||||
}
|
||||
}
|
||||
|
||||
/// File-backed installation registry with exclusive updates and atomic replacement.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct InstallationRegistryStore {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl InstallationRegistryStore {
|
||||
/// Creates a store at an explicit path.
|
||||
#[must_use]
|
||||
pub fn at(path: impl Into<PathBuf>) -> Self {
|
||||
Self { path: path.into() }
|
||||
}
|
||||
|
||||
/// Resolves the default registry from the current process environment.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RegistryError::StateDirectoryUnavailable`] when neither an absolute
|
||||
/// `XDG_STATE_HOME` nor an absolute `HOME` value is available.
|
||||
pub fn for_current_user() -> Result<Self, RegistryError> {
|
||||
Self::from_environment(
|
||||
env::var_os("XDG_STATE_HOME").as_deref(),
|
||||
env::var_os("HOME").as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Resolves a registry path from explicit XDG environment values.
|
||||
///
|
||||
/// Relative `XDG_STATE_HOME` values are ignored as required by the XDG base-directory
|
||||
/// rules; an absolute home then falls back to `$HOME/.local/state`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RegistryError::StateDirectoryUnavailable`] when no absolute base exists.
|
||||
pub fn from_environment(
|
||||
xdg_state_home: Option<&OsStr>,
|
||||
home: Option<&OsStr>,
|
||||
) -> Result<Self, RegistryError> {
|
||||
let xdg = xdg_state_home
|
||||
.map(Path::new)
|
||||
.filter(|path| path.is_absolute());
|
||||
let base = match xdg {
|
||||
Some(path) => path.to_path_buf(),
|
||||
None => home
|
||||
.map(Path::new)
|
||||
.filter(|path| path.is_absolute())
|
||||
.map(|path| path.join(".local/state"))
|
||||
.ok_or(RegistryError::StateDirectoryUnavailable)?,
|
||||
};
|
||||
Ok(Self::at(base.join("kiln/installations-v1.json")))
|
||||
}
|
||||
|
||||
/// Returns the registry file path.
|
||||
#[must_use]
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Resolves or creates durable identity for an installation observation.
|
||||
///
|
||||
/// The registry is loaded only after obtaining its exclusive lock. Existing state is
|
||||
/// left untouched when parsing, validation, matching, or writing fails.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a structured [`RegistryError`] for unsafe paths, malformed state,
|
||||
/// conflicts, unsupported versions, oversized input, or filesystem failures.
|
||||
pub fn resolve(
|
||||
&self,
|
||||
observation: &InstallationObservation,
|
||||
) -> Result<RegistryResolution, RegistryError> {
|
||||
if !self.path.is_absolute() {
|
||||
return Err(RegistryError::UnsafeStatePath(self.path.clone()));
|
||||
}
|
||||
let parent = self
|
||||
.path
|
||||
.parent()
|
||||
.ok_or_else(|| RegistryError::UnsafeStatePath(self.path.clone()))?;
|
||||
ensure_state_directory(parent)?;
|
||||
|
||||
let lock_path = sibling_path(&self.path, "lock")?;
|
||||
reject_symlink(&lock_path)?;
|
||||
let lock = open_state_file(&lock_path, true)?;
|
||||
lock.lock().map_err(RegistryError::Io)?;
|
||||
|
||||
let mut document = load_registry(&self.path)?;
|
||||
let resolution = document.resolve(observation)?;
|
||||
if matches!(resolution, RegistryResolution::Existing(_)) {
|
||||
return Ok(resolution);
|
||||
}
|
||||
save_registry(&self.path, &document)?;
|
||||
Ok(resolution)
|
||||
}
|
||||
}
|
||||
|
||||
/// A durable registry operation failed without silently repairing state.
|
||||
#[derive(Debug)]
|
||||
pub enum RegistryError {
|
||||
/// No absolute XDG state or home directory was available.
|
||||
StateDirectoryUnavailable,
|
||||
/// The selected state path was unsafe or structurally unusable.
|
||||
UnsafeStatePath(PathBuf),
|
||||
/// A state file or project-owned directory was a symbolic link.
|
||||
Symlink(PathBuf),
|
||||
/// The registry exceeded the bounded input size.
|
||||
RegistryTooLarge(u64),
|
||||
/// The registry schema version is unsupported.
|
||||
UnsupportedSchemaVersion(u32),
|
||||
/// A persistent installation UUID appeared more than once.
|
||||
DuplicateInstallationId(InstallationId),
|
||||
/// An adapter and fingerprint pair appeared more than once.
|
||||
DuplicateFingerprint {
|
||||
/// Adapter participating in the duplicate key.
|
||||
adapter: ContractName,
|
||||
/// Adapter-owned fingerprint participating in the duplicate key.
|
||||
fingerprint: InstallationFingerprint,
|
||||
},
|
||||
/// An entry repeated its current path or an alias.
|
||||
DuplicatePathAlias(InstallationId),
|
||||
/// An exact fingerprint match referred to a different game identity.
|
||||
FingerprintConflict {
|
||||
/// Adapter participating in the conflicting key.
|
||||
adapter: ContractName,
|
||||
/// Adapter-owned fingerprint participating in the conflicting key.
|
||||
fingerprint: InstallationFingerprint,
|
||||
},
|
||||
/// Registry JSON was malformed or violated a field contract.
|
||||
Json(serde_json::Error),
|
||||
/// A filesystem or locking operation failed.
|
||||
Io(std::io::Error),
|
||||
}
|
||||
|
||||
impl fmt::Display for RegistryError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::StateDirectoryUnavailable => {
|
||||
formatter.write_str("no absolute XDG state or home directory is available")
|
||||
}
|
||||
Self::UnsafeStatePath(path) => {
|
||||
write!(formatter, "unsafe state path: {}", path.display())
|
||||
}
|
||||
Self::Symlink(path) => write!(formatter, "state path is a symlink: {}", path.display()),
|
||||
Self::RegistryTooLarge(size) => {
|
||||
write!(formatter, "registry exceeds 1 MiB: {size} bytes")
|
||||
}
|
||||
Self::UnsupportedSchemaVersion(version) => {
|
||||
write!(
|
||||
formatter,
|
||||
"unsupported installation registry schema version: {version}"
|
||||
)
|
||||
}
|
||||
Self::DuplicateInstallationId(id) => {
|
||||
write!(formatter, "duplicate installation ID in registry: {id}")
|
||||
}
|
||||
Self::DuplicateFingerprint {
|
||||
adapter,
|
||||
fingerprint,
|
||||
} => write!(
|
||||
formatter,
|
||||
"duplicate registry fingerprint: {adapter}:{fingerprint}"
|
||||
),
|
||||
Self::DuplicatePathAlias(id) => {
|
||||
write!(
|
||||
formatter,
|
||||
"duplicate current path or alias for installation: {id}"
|
||||
)
|
||||
}
|
||||
Self::FingerprintConflict {
|
||||
adapter,
|
||||
fingerprint,
|
||||
} => write!(
|
||||
formatter,
|
||||
"registry fingerprint changed game identity: {adapter}:{fingerprint}"
|
||||
),
|
||||
Self::Json(error) => write!(formatter, "invalid installation registry: {error}"),
|
||||
Self::Io(error) => write!(formatter, "installation registry I/O failed: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RegistryError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Json(error) => Some(error),
|
||||
Self::Io(error) => Some(error),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for RegistryError {
|
||||
fn from(error: serde_json::Error) -> Self {
|
||||
Self::Json(error)
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_state_directory(path: &Path) -> Result<(), RegistryError> {
|
||||
reject_symlink(path)?;
|
||||
fs::create_dir_all(path).map_err(RegistryError::Io)?;
|
||||
reject_symlink(path)?;
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(STATE_DIRECTORY_MODE))
|
||||
.map_err(RegistryError::Io)
|
||||
}
|
||||
|
||||
fn reject_symlink(path: &Path) -> Result<(), RegistryError> {
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||
Err(RegistryError::Symlink(path.to_path_buf()))
|
||||
}
|
||||
Ok(_) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(RegistryError::Io(error)),
|
||||
}
|
||||
}
|
||||
|
||||
fn open_state_file(path: &Path, create: bool) -> Result<File, RegistryError> {
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(create)
|
||||
.mode(STATE_FILE_MODE)
|
||||
.open(path)
|
||||
.map_err(RegistryError::Io)?;
|
||||
file.set_permissions(fs::Permissions::from_mode(STATE_FILE_MODE))
|
||||
.map_err(RegistryError::Io)?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
fn load_registry(path: &Path) -> Result<RegistryDocument, RegistryError> {
|
||||
reject_symlink(path)?;
|
||||
let file = match OpenOptions::new().read(true).open(path) {
|
||||
Ok(file) => file,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(RegistryDocument::default());
|
||||
}
|
||||
Err(error) => return Err(RegistryError::Io(error)),
|
||||
};
|
||||
let size = file.metadata().map_err(RegistryError::Io)?.len();
|
||||
if size > MAX_REGISTRY_BYTES {
|
||||
return Err(RegistryError::RegistryTooLarge(size));
|
||||
}
|
||||
let capacity = usize::try_from(size).map_err(|_| RegistryError::RegistryTooLarge(size))?;
|
||||
let mut bytes = Vec::with_capacity(capacity);
|
||||
file.take(MAX_REGISTRY_BYTES + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(RegistryError::Io)?;
|
||||
if bytes.len() as u64 > MAX_REGISTRY_BYTES {
|
||||
return Err(RegistryError::RegistryTooLarge(bytes.len() as u64));
|
||||
}
|
||||
let document: RegistryDocument = serde_json::from_slice(&bytes)?;
|
||||
document.validate()?;
|
||||
Ok(document)
|
||||
}
|
||||
|
||||
fn save_registry(path: &Path, document: &RegistryDocument) -> Result<(), RegistryError> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| RegistryError::UnsafeStatePath(path.to_path_buf()))?;
|
||||
let temporary = unique_sibling(path, "tmp")?;
|
||||
let backup = sibling_path(path, "bak")?;
|
||||
let backup_temporary = unique_sibling(path, "bak-tmp")?;
|
||||
|
||||
let result = (|| {
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(STATE_FILE_MODE)
|
||||
.open(&temporary)
|
||||
.map_err(RegistryError::Io)?;
|
||||
serde_json::to_writer(&mut file, document)?;
|
||||
file.write_all(b"\n").map_err(RegistryError::Io)?;
|
||||
file.sync_all().map_err(RegistryError::Io)?;
|
||||
|
||||
if path.exists() {
|
||||
reject_symlink(path)?;
|
||||
fs::copy(path, &backup_temporary).map_err(RegistryError::Io)?;
|
||||
let backup_file = open_state_file(&backup_temporary, false)?;
|
||||
backup_file.sync_all().map_err(RegistryError::Io)?;
|
||||
fs::rename(&backup_temporary, &backup).map_err(RegistryError::Io)?;
|
||||
}
|
||||
|
||||
fs::rename(&temporary, path).map_err(RegistryError::Io)?;
|
||||
File::open(parent)
|
||||
.and_then(|directory| directory.sync_all())
|
||||
.map_err(RegistryError::Io)
|
||||
})();
|
||||
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
let _ = fs::remove_file(&backup_temporary);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn sibling_path(path: &Path, suffix: &str) -> Result<PathBuf, RegistryError> {
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(OsStr::to_str)
|
||||
.ok_or_else(|| RegistryError::UnsafeStatePath(path.to_path_buf()))?;
|
||||
Ok(path.with_file_name(format!("{name}.{suffix}")))
|
||||
}
|
||||
|
||||
fn unique_sibling(path: &Path, suffix: &str) -> Result<PathBuf, RegistryError> {
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(OsStr::to_str)
|
||||
.ok_or_else(|| RegistryError::UnsafeStatePath(path.to_path_buf()))?;
|
||||
Ok(path.with_file_name(format!(".{name}.{suffix}-{}", Uuid::new_v4())))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::ffi::OsStr;
|
||||
use std::fs;
|
||||
use std::os::unix::fs::{MetadataExt, symlink};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Barrier};
|
||||
use std::thread;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{
|
||||
InstallationFingerprint, InstallationObservation, InstallationRegistryStore, RegistryError,
|
||||
RegistryResolution,
|
||||
};
|
||||
use crate::{ContractName, GameId};
|
||||
|
||||
struct TestDirectory(std::path::PathBuf);
|
||||
|
||||
impl TestDirectory {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!("kiln-registry-test-{}", Uuid::new_v4()));
|
||||
fs::create_dir(&path).expect("test directory");
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn store(&self) -> InstallationRegistryStore {
|
||||
InstallationRegistryStore::at(self.0.join("state/installations-v1.json"))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDirectory {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn observation(path: &str) -> InstallationObservation {
|
||||
InstallationObservation::new(
|
||||
"steam:620".parse::<GameId>().expect("game ID"),
|
||||
"steam".parse::<ContractName>().expect("adapter"),
|
||||
"library-folder-1/app-620"
|
||||
.parse::<InstallationFingerprint>()
|
||||
.expect("fingerprint"),
|
||||
path.to_owned().try_into().expect("path"),
|
||||
)
|
||||
}
|
||||
|
||||
fn read_json(store: &InstallationRegistryStore) -> Value {
|
||||
serde_json::from_slice(&fs::read(store.path()).expect("registry")).expect("valid JSON")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_and_reloads_stable_identity_without_rewriting() {
|
||||
let directory = TestDirectory::new();
|
||||
let store = directory.store();
|
||||
let created = store
|
||||
.resolve(&observation("/games/portal"))
|
||||
.expect("created");
|
||||
let existing = store
|
||||
.resolve(&observation("/games/portal"))
|
||||
.expect("existing");
|
||||
assert!(matches!(created, RegistryResolution::Created(_)));
|
||||
assert_eq!(created.installation_id(), existing.installation_id());
|
||||
assert!(
|
||||
!store
|
||||
.path()
|
||||
.with_file_name("installations-v1.json.bak")
|
||||
.exists()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_identity_and_aliases_across_moves() {
|
||||
let directory = TestDirectory::new();
|
||||
let store = directory.store();
|
||||
let created = store.resolve(&observation("/games/one")).expect("created");
|
||||
let moved = store.resolve(&observation("/games/two")).expect("moved");
|
||||
assert_eq!(created.installation_id(), moved.installation_id());
|
||||
assert!(matches!(moved, RegistryResolution::Moved { .. }));
|
||||
assert_eq!(
|
||||
read_json(&store)["entries"][0]["aliases"],
|
||||
json!(["/games/one"])
|
||||
);
|
||||
|
||||
store
|
||||
.resolve(&observation("/games/one"))
|
||||
.expect("moved back");
|
||||
let record = read_json(&store);
|
||||
assert_eq!(record["entries"][0]["current_path"], "/games/one");
|
||||
assert_eq!(record["entries"][0]["aliases"], json!(["/games/two"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retains_previous_valid_registry_as_backup() {
|
||||
let directory = TestDirectory::new();
|
||||
let store = directory.store();
|
||||
store.resolve(&observation("/games/one")).expect("created");
|
||||
let before = fs::read(store.path()).expect("before");
|
||||
store.resolve(&observation("/games/two")).expect("moved");
|
||||
let backup = store.path().with_file_name("installations-v1.json.bak");
|
||||
assert_eq!(fs::read(backup).expect("backup"), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_fingerprint_reuse_for_another_game_without_mutating() {
|
||||
let directory = TestDirectory::new();
|
||||
let store = directory.store();
|
||||
store
|
||||
.resolve(&observation("/games/portal"))
|
||||
.expect("created");
|
||||
let before = fs::read(store.path()).expect("before");
|
||||
let conflicting = InstallationObservation::new(
|
||||
"steam:400".parse().expect("game ID"),
|
||||
"steam".parse().expect("adapter"),
|
||||
"library-folder-1/app-620".parse().expect("fingerprint"),
|
||||
"/games/portal-2".to_owned().try_into().expect("path"),
|
||||
);
|
||||
assert!(matches!(
|
||||
store.resolve(&conflicting),
|
||||
Err(RegistryError::FingerprintConflict { .. })
|
||||
));
|
||||
assert_eq!(fs::read(store.path()).expect("after"), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_registry_shapes_without_mutating() {
|
||||
let directory = TestDirectory::new();
|
||||
let store = directory.store();
|
||||
fs::create_dir_all(store.path().parent().expect("parent")).expect("state directory");
|
||||
for invalid in [
|
||||
json!({"schema_version": 2, "entries": []}),
|
||||
json!({"schema_version": 1, "entries": [], "unknown": true}),
|
||||
json!({"schema_version": 1}),
|
||||
] {
|
||||
let bytes = serde_json::to_vec(&invalid).expect("fixture");
|
||||
fs::write(store.path(), &bytes).expect("fixture");
|
||||
assert!(store.resolve(&observation("/games/portal")).is_err());
|
||||
assert_eq!(fs::read(store.path()).expect("unchanged"), bytes);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_ids_fingerprints_and_aliases() {
|
||||
let directory = TestDirectory::new();
|
||||
let store = directory.store();
|
||||
fs::create_dir_all(store.path().parent().expect("parent")).expect("state directory");
|
||||
let entry = json!({
|
||||
"installation_id": "7aa62d69-8f8e-4cab-8514-275ce1f87748",
|
||||
"game_id": "steam:620",
|
||||
"adapter": "steam",
|
||||
"fingerprint": "library-1/app-620",
|
||||
"current_path": "/games/portal",
|
||||
"aliases": []
|
||||
});
|
||||
|
||||
let duplicate_id = json!({"schema_version": 1, "entries": [entry.clone(), {
|
||||
"installation_id": "7aa62d69-8f8e-4cab-8514-275ce1f87748",
|
||||
"game_id": "steam:400", "adapter": "steam", "fingerprint": "library-1/app-400",
|
||||
"current_path": "/games/portal-2", "aliases": []
|
||||
}]});
|
||||
fs::write(
|
||||
store.path(),
|
||||
serde_json::to_vec(&duplicate_id).expect("fixture"),
|
||||
)
|
||||
.expect("write");
|
||||
assert!(matches!(
|
||||
store.resolve(&observation("/games/portal")),
|
||||
Err(RegistryError::DuplicateInstallationId(_))
|
||||
));
|
||||
|
||||
let duplicate_fingerprint = json!({"schema_version": 1, "entries": [entry.clone(), {
|
||||
"installation_id": "b3da0c09-c57e-465d-9cb7-d0177c34eb5c",
|
||||
"game_id": "steam:620", "adapter": "steam", "fingerprint": "library-1/app-620",
|
||||
"current_path": "/games/portal-2", "aliases": []
|
||||
}]});
|
||||
fs::write(
|
||||
store.path(),
|
||||
serde_json::to_vec(&duplicate_fingerprint).expect("fixture"),
|
||||
)
|
||||
.expect("write");
|
||||
assert!(matches!(
|
||||
store.resolve(&observation("/games/portal")),
|
||||
Err(RegistryError::DuplicateFingerprint { .. })
|
||||
));
|
||||
|
||||
let duplicate_alias = json!({"schema_version": 1, "entries": [{
|
||||
"installation_id": "7aa62d69-8f8e-4cab-8514-275ce1f87748",
|
||||
"game_id": "steam:620", "adapter": "steam", "fingerprint": "library-1/app-620",
|
||||
"current_path": "/games/portal", "aliases": ["/games/portal"]
|
||||
}]});
|
||||
fs::write(
|
||||
store.path(),
|
||||
serde_json::to_vec(&duplicate_alias).expect("fixture"),
|
||||
)
|
||||
.expect("write");
|
||||
assert!(matches!(
|
||||
store.resolve(&observation("/games/portal")),
|
||||
Err(RegistryError::DuplicatePathAlias(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_oversized_and_symlinked_state() {
|
||||
let directory = TestDirectory::new();
|
||||
let store = directory.store();
|
||||
fs::create_dir_all(store.path().parent().expect("parent")).expect("state directory");
|
||||
fs::write(store.path(), vec![b' '; 1024 * 1024 + 1]).expect("oversized fixture");
|
||||
assert!(matches!(
|
||||
store.resolve(&observation("/games/portal")),
|
||||
Err(RegistryError::RegistryTooLarge(_))
|
||||
));
|
||||
|
||||
fs::remove_file(store.path()).expect("remove fixture");
|
||||
let target = directory.0.join("target.json");
|
||||
fs::write(&target, b"{}").expect("target");
|
||||
symlink(&target, store.path()).expect("symlink");
|
||||
assert!(matches!(
|
||||
store.resolve(&observation("/games/portal")),
|
||||
Err(RegistryError::Symlink(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_concurrent_resolutions_to_one_identity() {
|
||||
let directory = TestDirectory::new();
|
||||
let store = Arc::new(directory.store());
|
||||
let barrier = Arc::new(Barrier::new(4));
|
||||
let threads: Vec<_> = (0..4)
|
||||
.map(|_| {
|
||||
let store = Arc::clone(&store);
|
||||
let barrier = Arc::clone(&barrier);
|
||||
thread::spawn(move || {
|
||||
barrier.wait();
|
||||
store
|
||||
.resolve(&observation("/games/portal"))
|
||||
.expect("resolved")
|
||||
.installation_id()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let ids: Vec<_> = threads
|
||||
.into_iter()
|
||||
.map(|thread| thread.join().expect("thread"))
|
||||
.collect();
|
||||
assert!(ids.windows(2).all(|pair| pair[0] == pair[1]));
|
||||
assert_eq!(
|
||||
read_json(&store)["entries"]
|
||||
.as_array()
|
||||
.expect("entries")
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_absolute_xdg_state_or_home_fallback() {
|
||||
let xdg = InstallationRegistryStore::from_environment(Some(OsStr::new("/state")), None)
|
||||
.expect("XDG state");
|
||||
assert_eq!(xdg.path(), Path::new("/state/kiln/installations-v1.json"));
|
||||
|
||||
let home = InstallationRegistryStore::from_environment(
|
||||
Some(OsStr::new("relative")),
|
||||
Some(OsStr::new("/home/test")),
|
||||
)
|
||||
.expect("home fallback");
|
||||
assert_eq!(
|
||||
home.path(),
|
||||
Path::new("/home/test/.local/state/kiln/installations-v1.json")
|
||||
);
|
||||
assert!(matches!(
|
||||
InstallationRegistryStore::from_environment(Some(OsStr::new("relative")), None),
|
||||
Err(RegistryError::StateDirectoryUnavailable)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_private_state_permissions() {
|
||||
let directory = TestDirectory::new();
|
||||
let store = directory.store();
|
||||
store
|
||||
.resolve(&observation("/games/portal"))
|
||||
.expect("created");
|
||||
assert_eq!(
|
||||
fs::metadata(store.path()).expect("file").mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
assert_eq!(
|
||||
fs::metadata(store.path().parent().expect("parent"))
|
||||
.expect("directory")
|
||||
.mode()
|
||||
& 0o777,
|
||||
0o700
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user