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