feat: complete native execution and Steam discovery

This commit is contained in:
2026-07-18 20:11:52 -04:00
parent 514f87dacb
commit bc0090c63d
42 changed files with 6403 additions and 97 deletions
+19
View File
@@ -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
+17
View File
@@ -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
+729
View File
@@ -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
);
}
}
+3
View File
@@ -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
View File
@@ -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")
}
+263
View File
@@ -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);
}
+2 -1
View File
@@ -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
+502
View File
@@ -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);
}
}
+981
View File
@@ -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(&current) {
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);
}
}
+225
View File
@@ -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
);
}
}
+550
View File
@@ -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));
}
}
+42 -1
View File
@@ -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)]
+832
View File
@@ -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
);
}
}