Compare commits
2 Commits
514f87dacb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d1077c0cb | |||
| bc0090c63d |
@@ -1,22 +0,0 @@
|
||||
name: baseline
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
- name: Install pinned Rust toolchain
|
||||
run: rustup toolchain install 1.97.1 --profile minimal --component clippy,rustfmt
|
||||
- name: Verify repository
|
||||
run: make verify
|
||||
- name: Install pinned dependency auditor
|
||||
run: cargo install --locked cargo-deny --version 0.20.2
|
||||
- name: Audit advisories, dependencies, licenses, and sources
|
||||
run: make audit
|
||||
@@ -15,13 +15,13 @@
|
||||
|
||||
## Current state
|
||||
|
||||
- Phase 0 is complete. Phase 1 contracts are next.
|
||||
- Phases 0–3 are complete. Steam launch planning and execution is next.
|
||||
- `Project Kiln`, `kiln`, and every `kiln-*` name are private placeholders. Do not publish,
|
||||
globally install, register, or expose them as stable compatibility promises.
|
||||
- The workspace is private, AGPL-3.0-or-later, Rust 2024, MSRV 1.85, with the reviewed
|
||||
development toolchain pinned to Rust 1.97.1.
|
||||
- No real provider implementation starts until the relevant Phase 1 contracts and mock
|
||||
adapter tests pass.
|
||||
- The workspace is private, AGPL-3.0-or-later, Rust 2024, with Rust 1.97.1 as both
|
||||
the minimum supported compiler and reviewed development toolchain.
|
||||
- Every new provider starts with read-only fixture-driven discovery and must reuse the
|
||||
Phase 1 contracts before execution work begins.
|
||||
|
||||
## Required workflow
|
||||
|
||||
@@ -44,11 +44,11 @@
|
||||
Run before handing work back:
|
||||
|
||||
```sh
|
||||
make verify
|
||||
make audit
|
||||
make check
|
||||
```
|
||||
|
||||
Also run focused tests for the changed capability. Do not report a gate as passed unless
|
||||
All compilation and auditing runs locally; Gitea is source storage only. Also run focused
|
||||
tests for the changed capability. Do not report a gate as passed unless
|
||||
it actually ran. Record blocked hardware/VM/provider checks explicitly for later local
|
||||
execution rather than fabricating evidence.
|
||||
|
||||
@@ -56,9 +56,9 @@ execution rather than fabricating evidence.
|
||||
|
||||
- `crates/kiln-core/`: provider-independent contracts and logic
|
||||
- `crates/kiln-cli/`: the single human and machine-facing CLI
|
||||
- `crates/kiln-adapter-steam/`: read-only Steam library and app-manifest discovery
|
||||
- `docs/spec/`: active specification
|
||||
- `docs/adr/`: architecture decisions
|
||||
- `docs/phase-0/`: completed baseline policies and evidence
|
||||
- `docs/handoff/`: bounded Codex CLI task briefs
|
||||
- `scripts/`: repository, toolchain, licensing, and placeholder gates
|
||||
|
||||
|
||||
+28
-1
@@ -12,8 +12,35 @@ relevant schema version is advanced.
|
||||
- Dependency-free core identity contract and `kiln doctor` smoke CLI.
|
||||
- Versioned normalized game records with validated game IDs, installation UUIDs,
|
||||
contract roles, compatibility data, capabilities, and installation paths.
|
||||
- Durable XDG installation identity registry with exact fingerprint matching, move
|
||||
aliases, bounded hostile-input handling, locking, backups, and atomic replacement.
|
||||
- Provider and runtime capability interfaces plus a deterministic mock adapter covering
|
||||
every required Phase 1 outcome.
|
||||
- Versioned argument-safe launch plans, conservative lifecycle policy, and structured
|
||||
machine-readable errors.
|
||||
- Versioned TOML configuration with fixed precedence, merge provenance, commented
|
||||
defaults, and compare-and-swap replacement validation.
|
||||
- Versioned schema reporting from `kiln doctor --json` and structured JSON CLI errors.
|
||||
- Native adapter discovery from bounded per-user manifests and explicitly approved
|
||||
Desktop Entries, with deterministic search and resolution.
|
||||
- Native dry-run planning, owned process execution, private launch logs, and structured
|
||||
launch failures through the shared CLI.
|
||||
- Configuration defaults, validation, explanation, safe scalar edits, and native
|
||||
launch-plan integration.
|
||||
- Bounded redacted process capture and adversarial configuration, path, symlink, UTF-8,
|
||||
environment, working-directory, and failure tests.
|
||||
- Read-only Steam library-folder and app-manifest discovery with stable App ID identity,
|
||||
normalized records, provider-scoped CLI list/search/info/scan commands, hostile fixtures,
|
||||
and local installation acceptance evidence.
|
||||
- Typed native and Flatpak Steam launch plans, shell-free client submission, private logs,
|
||||
structured missing-client failures, and real native/Proton launch acceptance evidence.
|
||||
|
||||
### Corrected
|
||||
|
||||
- Separated the Rust 1.85 MSRV from the exact Rust 1.97.1 development toolchain pin.
|
||||
- Replaced the unused hosted-workflow plan with a complete local `make check` gate and
|
||||
made Rust 1.97.1 the single minimum and reviewed compiler.
|
||||
- Removed the `fs2` compatibility dependency in favor of Rust 1.97 standard-library file
|
||||
locking.
|
||||
- Advanced the private launch-plan schema to version 2 so dry runs carry the exact
|
||||
working directory used for execution.
|
||||
- Enforced the active compiler pin instead of assuming distro Cargo honors Rustup files.
|
||||
|
||||
Generated
+124
-1
@@ -14,6 +14,12 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.33"
|
||||
@@ -38,6 +44,33 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
@@ -56,10 +89,32 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kiln-cli"
|
||||
name = "kiln-adapter-native"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"kiln-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kiln-adapter-steam"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"kiln-core",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kiln-cli"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"kiln-adapter-native",
|
||||
"kiln-adapter-steam",
|
||||
"kiln-core",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -68,9 +123,16 @@ version = "0.0.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
@@ -107,6 +169,12 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
@@ -156,6 +224,15 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
@@ -173,6 +250,45 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
|
||||
dependencies = [
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
@@ -185,6 +301,7 @@ version = "1.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
"wasm-bindgen",
|
||||
@@ -235,6 +352,12 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
[workspace]
|
||||
members = ["crates/kiln-core", "crates/kiln-cli"]
|
||||
members = ["crates/kiln-core", "crates/kiln-cli", "crates/kiln-adapter-native", "crates/kiln-adapter-steam"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.0.1"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
rust-version = "1.97.1"
|
||||
license = "AGPL-3.0-or-later"
|
||||
publish = false
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: verify static rust audit ci
|
||||
.PHONY: verify static rust audit check
|
||||
|
||||
verify: static rust
|
||||
|
||||
@@ -17,4 +17,4 @@ rust:
|
||||
audit:
|
||||
cargo deny check advisories bans licenses sources
|
||||
|
||||
ci: verify audit
|
||||
check: verify audit
|
||||
|
||||
@@ -1,28 +1,51 @@
|
||||
# Project Kiln
|
||||
|
||||
Private Phase 0 scaffold for the CLI-first Arch gaming OS. `Kiln` and every
|
||||
Private implementation of the CLI-first Arch gaming OS. `Kiln` and every
|
||||
`kiln-*` identifier are temporary repository-local names, not public compatibility
|
||||
promises.
|
||||
|
||||
## Status
|
||||
|
||||
Phases 0–4 are complete: repository gates, shared contracts, native discovery/execution,
|
||||
Steam discovery, typed Steam launch planning, and representative native/Proton execution.
|
||||
Phase 5 proves the minimal graphical session and recovery path.
|
||||
|
||||
## Current proof
|
||||
|
||||
The workspace contains a dependency-free Rust core and CLI smoke path. Rust 1.85 is
|
||||
the minimum supported compiler because it introduced Edition 2024; development and CI
|
||||
remain pinned to the reviewed Rust 1.97.1 toolchain:
|
||||
The workspace contains the shared Rust core, native and Steam adapters, and CLI. Local
|
||||
development, verification, and the minimum supported compiler are pinned to
|
||||
Rust 1.97.1:
|
||||
|
||||
```text
|
||||
kiln version
|
||||
kiln doctor
|
||||
kiln doctor --json
|
||||
kiln list
|
||||
kiln search <query>
|
||||
kiln info <id-or-name>
|
||||
kiln launch <id-or-name> --dry-run
|
||||
kiln list --provider steam
|
||||
kiln search <query> --provider steam
|
||||
kiln scan --provider steam
|
||||
kiln info steam:<app-id>
|
||||
kiln launch steam:<app-id> --dry-run
|
||||
kiln launch steam:<app-id>
|
||||
kiln config defaults
|
||||
kiln config validate
|
||||
kiln config explain --json
|
||||
```
|
||||
|
||||
Run deterministic repository and Rust gates with `make verify`. Run only the
|
||||
Run the complete local gate with `make check`. Run deterministic repository and Rust
|
||||
gates without the network-backed audit with `make verify`. Run only the
|
||||
environment-independent repository checks with `make static`. Run the separately
|
||||
versioned, network-backed dependency advisory/license/source audit with `make audit`.
|
||||
Rust 1.97.1 is pinned.
|
||||
|
||||
Gitea stores the source repository but does not compile or test it. Local evidence is
|
||||
recorded before pushes and releases.
|
||||
|
||||
The active specification is under `docs/spec/`. Start with
|
||||
`docs/spec/00_SPEC_INDEX.md`; the archived monolith is intentionally excluded.
|
||||
|
||||
Local Codex CLI work starts with `AGENTS.md`. The initial bounded Phase 1 brief is
|
||||
`docs/handoff/phase-1-codex-cli.md`.
|
||||
Local Codex CLI work starts with `AGENTS.md`. Native manifest setup is documented in
|
||||
`docs/phase-2/native-manifests.md`; completed Steam discovery evidence is under
|
||||
`docs/phase-3/`; completed Steam execution evidence is under `docs/phase-4/`.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "kiln-adapter-native"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
kiln-core = { version = "=0.0.1", path = "../kiln-core" }
|
||||
serde = { version = "=1.0.228", features = ["derive"] }
|
||||
toml = "=1.1.3"
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = "=1.0.150"
|
||||
uuid = { version = "=1.24.0", features = ["v4"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "kiln-adapter-steam"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
kiln-core = { version = "=0.0.1", path = "../kiln-core" }
|
||||
uuid = "=1.24.0"
|
||||
|
||||
[dev-dependencies]
|
||||
uuid = { version = "=1.24.0", features = ["v4"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,885 @@
|
||||
//! Read-only Steam library discovery and normalization.
|
||||
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::env;
|
||||
use std::fmt;
|
||||
use std::fs::{self, File};
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use kiln_core::adapter::ProviderAdapter;
|
||||
use kiln_core::config::{
|
||||
ConfigurationLayerV1, ConfigurationSource, ConfigurationTier, built_in_configuration,
|
||||
configuration_path_for_current_user, load_configuration, resolve_layers,
|
||||
};
|
||||
use kiln_core::contract_error::{AmbiguousGameCandidate, ContractErrorCode, ContractErrorV1};
|
||||
use kiln_core::launch::{
|
||||
CommandArgument, CommandSpec, ControllerState, EnvironmentName, EnvironmentValue, Executable,
|
||||
LaunchPlanInput, LaunchPlanV1, LifecycleConfidence,
|
||||
};
|
||||
use kiln_core::{
|
||||
Capability, ContractName, ContractText, GameId, GameRecordInput, GameRecordV1, InstallPath,
|
||||
InstallationId,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
const MAX_VDF_BYTES: u64 = 1024 * 1024;
|
||||
const MAX_LIBRARIES: usize = 128;
|
||||
const MAX_MANIFESTS: usize = 8192;
|
||||
const CAPABILITIES: &[Capability] = &[Capability::Discover, Capability::Info, Capability::Launch];
|
||||
const RECORD_CAPABILITIES: &[Capability] = &[
|
||||
Capability::Discover,
|
||||
Capability::Info,
|
||||
Capability::Launch,
|
||||
Capability::ClientRequired,
|
||||
];
|
||||
const INSTALLATION_NAMESPACE: u128 = 0x72d8_017f_93a9_47b7_0000_0000_0000_0000;
|
||||
|
||||
/// Read-only adapter backed by Steam's text library and app manifests.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SteamAdapter {
|
||||
name: ContractName,
|
||||
root: PathBuf,
|
||||
client: SteamClient,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum SteamClient {
|
||||
Native,
|
||||
Flatpak,
|
||||
}
|
||||
|
||||
impl SteamAdapter {
|
||||
/// Creates an adapter at an explicit Steam root.
|
||||
#[must_use]
|
||||
pub fn at(root: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
name: name("steam"),
|
||||
root: root.into(),
|
||||
client: SteamClient::Native,
|
||||
}
|
||||
}
|
||||
|
||||
/// Locates the conventional native or Flatpak Steam root for the current user.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a structured error when no absolute home directory is available.
|
||||
pub fn for_current_user() -> Result<Self, ContractErrorV1> {
|
||||
let home = env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.filter(|path| path.is_absolute())
|
||||
.ok_or_else(|| unavailable("no absolute home directory is available"))?;
|
||||
let native = home.join(".local/share/Steam");
|
||||
let flatpak = home.join(".var/app/com.valvesoftware.Steam/data/Steam");
|
||||
if native.is_dir() {
|
||||
Ok(Self::at(native))
|
||||
} else {
|
||||
Ok(Self {
|
||||
name: name("steam"),
|
||||
root: flatpak,
|
||||
client: SteamClient::Flatpak,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns matching records in deterministic App ID order.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a structured error when the query or Steam metadata is invalid.
|
||||
pub fn search(&self, query: &str) -> Result<Vec<GameRecordV1>, ContractErrorV1> {
|
||||
validate_selector(query)?;
|
||||
let needle = query.to_lowercase();
|
||||
Ok(self
|
||||
.discover()?
|
||||
.into_iter()
|
||||
.filter(|record| {
|
||||
record.id().to_string().contains(&needle)
|
||||
|| record.name().as_str().to_lowercase().contains(&needle)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn library_paths(&self) -> Result<Vec<PathBuf>, ContractErrorV1> {
|
||||
if !self.root.is_absolute() {
|
||||
return Err(invalid("Steam root must be absolute"));
|
||||
}
|
||||
let metadata = match fs::symlink_metadata(&self.root) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||
Err(error) => return Err(unavailable(format!("inspect Steam root: {error}"))),
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||
return Err(invalid("Steam root must be a real directory"));
|
||||
}
|
||||
let input = read_text(
|
||||
&self.root.join("steamapps/libraryfolders.vdf"),
|
||||
"library list",
|
||||
)?;
|
||||
let document = parse_vdf(&input)?;
|
||||
let folders = object(&document, "libraryfolders")?;
|
||||
if folders.len() > MAX_LIBRARIES {
|
||||
return Err(invalid("Steam library count exceeds 128"));
|
||||
}
|
||||
let mut paths = Vec::new();
|
||||
for value in folders.values() {
|
||||
let VdfValue::Object(folder) = value else {
|
||||
continue;
|
||||
};
|
||||
let Some(VdfValue::Text(raw)) = folder.get("path") else {
|
||||
continue;
|
||||
};
|
||||
let path: InstallPath = raw
|
||||
.clone()
|
||||
.try_into()
|
||||
.map_err(|_| invalid("Steam library path is invalid"))?;
|
||||
paths.push(path.as_path().to_path_buf());
|
||||
}
|
||||
paths.sort();
|
||||
paths.dedup();
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
fn records(&self) -> Result<Vec<GameRecordV1>, ContractErrorV1> {
|
||||
let mut records = Vec::new();
|
||||
let mut ids = HashSet::new();
|
||||
for library in self.library_paths()? {
|
||||
let metadata = match fs::symlink_metadata(&library) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(error) => return Err(unavailable(format!("inspect Steam library: {error}"))),
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||
return Err(invalid("Steam library must be a real directory"));
|
||||
}
|
||||
let steamapps = library.join("steamapps");
|
||||
let metadata = match fs::symlink_metadata(&steamapps) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(error) => return Err(unavailable(format!("inspect steamapps: {error}"))),
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||
return Err(invalid("steamapps must be a real directory"));
|
||||
}
|
||||
let mut manifests = Vec::new();
|
||||
for entry in fs::read_dir(&steamapps)
|
||||
.map_err(|error| unavailable(format!("read steamapps: {error}")))?
|
||||
{
|
||||
let path = entry
|
||||
.map_err(|error| unavailable(format!("read steamapps entry: {error}")))?
|
||||
.path();
|
||||
let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if file_name.starts_with("appmanifest_")
|
||||
&& path
|
||||
.extension()
|
||||
.is_some_and(|extension| extension.eq_ignore_ascii_case("acf"))
|
||||
{
|
||||
manifests.push(path);
|
||||
}
|
||||
if manifests.len() > MAX_MANIFESTS {
|
||||
return Err(invalid("Steam manifest count exceeds 8192"));
|
||||
}
|
||||
}
|
||||
manifests.sort();
|
||||
for path in manifests {
|
||||
if let Some(record) = load_manifest(&path, &steamapps)? {
|
||||
if !ids.insert(record.id().clone()) {
|
||||
return Err(invalid("duplicate installed Steam App ID"));
|
||||
}
|
||||
records.push(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
records.sort_by_key(|record| record.id().to_string());
|
||||
Ok(records)
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderAdapter for SteamAdapter {
|
||||
fn name(&self) -> &ContractName {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> &[Capability] {
|
||||
CAPABILITIES
|
||||
}
|
||||
|
||||
fn discover(&self) -> Result<Vec<GameRecordV1>, ContractErrorV1> {
|
||||
self.records()
|
||||
}
|
||||
|
||||
fn resolve(&self, selector: &str) -> Result<GameRecordV1, ContractErrorV1> {
|
||||
validate_selector(selector)?;
|
||||
let records = self.records()?;
|
||||
if selector.contains(':') {
|
||||
let id: GameId = selector
|
||||
.parse()
|
||||
.map_err(|_| invalid("invalid Steam game ID"))?;
|
||||
return records
|
||||
.into_iter()
|
||||
.find(|record| record.id() == &id)
|
||||
.ok_or_else(|| not_found("Steam game was not found"));
|
||||
}
|
||||
let normalized = selector.to_lowercase();
|
||||
let mut matches: Vec<_> = records
|
||||
.into_iter()
|
||||
.filter(|record| record.name().as_str().to_lowercase() == normalized)
|
||||
.collect();
|
||||
match matches.len() {
|
||||
0 => Err(not_found("Steam game was not found")),
|
||||
1 => Ok(matches.remove(0)),
|
||||
_ => Err(ContractErrorV1::ambiguous(
|
||||
text("Steam game name is ambiguous"),
|
||||
matches
|
||||
.into_iter()
|
||||
.map(|record| AmbiguousGameCandidate {
|
||||
id: record.id().clone(),
|
||||
store: name("steam"),
|
||||
installation_id: record.installation_id(),
|
||||
label: record.name().clone(),
|
||||
})
|
||||
.collect(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_launch(&self, game_id: &GameId) -> Result<LaunchPlanV1, ContractErrorV1> {
|
||||
let record = self
|
||||
.records()?
|
||||
.into_iter()
|
||||
.find(|record| record.id() == game_id)
|
||||
.ok_or_else(|| not_found("Steam game was not found"))?;
|
||||
let configuration_path = configuration_path_for_current_user()
|
||||
.map_err(|error| invalid(format!("resolve configuration path: {error}")))?;
|
||||
let mut layers = vec![built_in_configuration()];
|
||||
if let Some(user) = load_configuration(
|
||||
&configuration_path,
|
||||
ConfigurationSource {
|
||||
tier: ConfigurationTier::GlobalUser,
|
||||
label: text(&configuration_path.to_string_lossy()),
|
||||
},
|
||||
)
|
||||
.map_err(|error| invalid(format!("invalid user configuration: {error}")))?
|
||||
{
|
||||
layers.push(user);
|
||||
}
|
||||
layers.push(
|
||||
ConfigurationLayerV1::parse_toml(
|
||||
ConfigurationSource {
|
||||
tier: ConfigurationTier::Provider,
|
||||
label: text("Steam-managed runtime"),
|
||||
},
|
||||
"schema_version = 1\n[launch]\nruntime = \"steam-managed\"\n",
|
||||
)
|
||||
.expect("static Steam configuration"),
|
||||
);
|
||||
let resolved = resolve_layers(&layers)
|
||||
.map_err(|error| invalid(format!("resolve Steam configuration: {error}")))?;
|
||||
let mut arguments = match self.client {
|
||||
SteamClient::Native => Vec::new(),
|
||||
SteamClient::Flatpak => vec![argument("run"), argument("com.valvesoftware.Steam")],
|
||||
};
|
||||
arguments.extend([
|
||||
argument("-silent"),
|
||||
argument("-applaunch"),
|
||||
argument(game_id.value()),
|
||||
]);
|
||||
let executable = match self.client {
|
||||
SteamClient::Native => "steam",
|
||||
SteamClient::Flatpak => "flatpak",
|
||||
};
|
||||
let mut environment = minimal_environment();
|
||||
environment.extend(
|
||||
resolved
|
||||
.environment
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), value.value.clone())),
|
||||
);
|
||||
LaunchPlanV1::new(LaunchPlanInput {
|
||||
game_id: record.id().clone(),
|
||||
installation_id: record.installation_id(),
|
||||
store: name("steam"),
|
||||
adapter: name("steam"),
|
||||
configuration_owner: name("steam"),
|
||||
runtime: resolved.runtime.value,
|
||||
session_backend: resolved.session_backend.value,
|
||||
display_mode: resolved.display_mode.map(|mode| mode.value),
|
||||
controller_state: ControllerState::Unknown,
|
||||
working_directory: record.install_path().clone(),
|
||||
environment,
|
||||
wrappers: resolved
|
||||
.wrappers
|
||||
.into_iter()
|
||||
.map(|wrapper| wrapper.value)
|
||||
.collect(),
|
||||
provenance: resolved.provenance,
|
||||
lifecycle_confidence: LifecycleConfidence::Unknown,
|
||||
command: CommandSpec {
|
||||
executable: Executable::Program(name(executable)),
|
||||
arguments,
|
||||
},
|
||||
})
|
||||
.map_err(|_| invalid("Steam launch plan violated its contract"))
|
||||
}
|
||||
}
|
||||
|
||||
fn argument(raw: &str) -> CommandArgument {
|
||||
raw.to_owned().try_into().expect("validated Steam argument")
|
||||
}
|
||||
|
||||
fn minimal_environment() -> BTreeMap<EnvironmentName, EnvironmentValue> {
|
||||
[
|
||||
"DBUS_SESSION_BUS_ADDRESS",
|
||||
"DISPLAY",
|
||||
"HOME",
|
||||
"LANG",
|
||||
"LC_ALL",
|
||||
"PATH",
|
||||
"WAYLAND_DISPLAY",
|
||||
"XAUTHORITY",
|
||||
"XDG_RUNTIME_DIR",
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|key| {
|
||||
let value = env::var(key).ok()?;
|
||||
Some((key.parse().ok()?, value.try_into().ok()?))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn load_manifest(path: &Path, steamapps: &Path) -> Result<Option<GameRecordV1>, ContractErrorV1> {
|
||||
let input = read_text(path, "app manifest")?;
|
||||
let document = parse_vdf(&input)?;
|
||||
let state = object(&document, "AppState")?;
|
||||
let app_id = field(state, "appid")?;
|
||||
let expected = format!("appmanifest_{app_id}.acf");
|
||||
if path.file_name().and_then(|name| name.to_str()) != Some(&expected) {
|
||||
return Err(invalid("Steam manifest filename and App ID differ"));
|
||||
}
|
||||
if app_id.is_empty() || !app_id.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return Err(invalid("Steam App ID is invalid"));
|
||||
}
|
||||
let numeric_app_id: u64 = app_id
|
||||
.parse()
|
||||
.map_err(|_| invalid("Steam App ID exceeds 64 bits"))?;
|
||||
let flags: u32 = field(state, "StateFlags")?
|
||||
.parse()
|
||||
.map_err(|_| invalid("Steam state flags are invalid"))?;
|
||||
if flags & 4 == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let game_name = field(state, "name")?.trim();
|
||||
// ponytail: text manifests lack app type; cover known runtime packages here and parse
|
||||
// binary appinfo only if real libraries show this heuristic hiding or exposing games.
|
||||
if is_component(app_id, game_name) {
|
||||
return Ok(None);
|
||||
}
|
||||
let install_dir = field(state, "installdir")?;
|
||||
if install_dir.is_empty()
|
||||
|| install_dir.chars().any(char::is_control)
|
||||
|| install_dir.contains(['/', '\\'])
|
||||
|| matches!(install_dir, "." | "..")
|
||||
{
|
||||
return Err(invalid("Steam install directory is invalid"));
|
||||
}
|
||||
let path = steamapps.join("common").join(install_dir);
|
||||
let metadata = match fs::symlink_metadata(&path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(unavailable(format!("inspect Steam install: {error}"))),
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||
return Err(invalid("Steam install path must be a real directory"));
|
||||
}
|
||||
let install_path: InstallPath = path
|
||||
.to_str()
|
||||
.ok_or_else(|| invalid("Steam install path is not UTF-8"))?
|
||||
.to_owned()
|
||||
.try_into()
|
||||
.map_err(|_| invalid("Steam install path is invalid"))?;
|
||||
let id: GameId = format!("steam:{app_id}")
|
||||
.parse()
|
||||
.map_err(|_| invalid("Steam App ID violated the game identity contract"))?;
|
||||
let installation_id = InstallationId::new(Uuid::from_u128(
|
||||
INSTALLATION_NAMESPACE | u128::from(numeric_app_id),
|
||||
));
|
||||
let record = GameRecordV1::new(GameRecordInput {
|
||||
id,
|
||||
name: game_name
|
||||
.parse()
|
||||
.map_err(|_| invalid(format!("Steam game name is invalid for App ID {app_id}")))?,
|
||||
store: name("steam"),
|
||||
adapter: name("steam"),
|
||||
installation_id,
|
||||
configuration_owner: name("steam"),
|
||||
execution_backend: name("steam"),
|
||||
installed: true,
|
||||
install_path,
|
||||
compatibility: None,
|
||||
capabilities: RECORD_CAPABILITIES.to_vec(),
|
||||
client_required: true,
|
||||
runtime: name("steam-managed"),
|
||||
})
|
||||
.map_err(|_| invalid("Steam record violated its contract"))?;
|
||||
Ok(Some(record))
|
||||
}
|
||||
|
||||
fn is_component(app_id: &str, game_name: &str) -> bool {
|
||||
app_id == "228980"
|
||||
|| game_name.starts_with("Proton ")
|
||||
|| game_name.starts_with("Steam Linux Runtime")
|
||||
|| game_name == "Steamworks Common Redistributables"
|
||||
}
|
||||
|
||||
fn read_text(path: &Path, label: &str) -> Result<String, ContractErrorV1> {
|
||||
let metadata = fs::symlink_metadata(path)
|
||||
.map_err(|error| unavailable(format!("read Steam {label} metadata: {error}")))?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
return Err(invalid(format!("Steam {label} must be a regular file")));
|
||||
}
|
||||
if metadata.len() > MAX_VDF_BYTES {
|
||||
return Err(invalid(format!("Steam {label} exceeds 1048576 bytes")));
|
||||
}
|
||||
let capacity = usize::try_from(metadata.len())
|
||||
.map_err(|_| invalid(format!("Steam {label} is too large")))?;
|
||||
let mut bytes = Vec::with_capacity(capacity);
|
||||
File::open(path)
|
||||
.and_then(|file| file.take(MAX_VDF_BYTES + 1).read_to_end(&mut bytes))
|
||||
.map_err(|error| unavailable(format!("read Steam {label}: {error}")))?;
|
||||
if bytes.len() as u64 > MAX_VDF_BYTES {
|
||||
return Err(invalid(format!("Steam {label} exceeds 1048576 bytes")));
|
||||
}
|
||||
String::from_utf8(bytes).map_err(|_| invalid(format!("Steam {label} is not UTF-8")))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum VdfValue {
|
||||
Text(String),
|
||||
Object(BTreeMap<String, VdfValue>),
|
||||
}
|
||||
|
||||
fn parse_vdf(input: &str) -> Result<BTreeMap<String, VdfValue>, ContractErrorV1> {
|
||||
let mut parser = Parser { input, offset: 0 };
|
||||
let object = parser.object(false)?;
|
||||
parser.skip_space();
|
||||
if parser.offset != input.len() {
|
||||
return Err(invalid("Steam VDF has trailing data"));
|
||||
}
|
||||
Ok(object)
|
||||
}
|
||||
|
||||
struct Parser<'a> {
|
||||
input: &'a str,
|
||||
offset: usize,
|
||||
}
|
||||
|
||||
impl Parser<'_> {
|
||||
fn object(&mut self, nested: bool) -> Result<BTreeMap<String, VdfValue>, ContractErrorV1> {
|
||||
let mut values = BTreeMap::new();
|
||||
loop {
|
||||
self.skip_space();
|
||||
if nested && self.consume('}') {
|
||||
return Ok(values);
|
||||
}
|
||||
if self.offset == self.input.len() {
|
||||
return if nested {
|
||||
Err(invalid("Steam VDF object is unterminated"))
|
||||
} else {
|
||||
Ok(values)
|
||||
};
|
||||
}
|
||||
let key = self.string()?;
|
||||
self.skip_space();
|
||||
let value = if self.consume('{') {
|
||||
VdfValue::Object(self.object(true)?)
|
||||
} else {
|
||||
VdfValue::Text(self.string()?)
|
||||
};
|
||||
if values.insert(key, value).is_some() {
|
||||
return Err(invalid("Steam VDF contains a duplicate key"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn string(&mut self) -> Result<String, ContractErrorV1> {
|
||||
self.skip_space();
|
||||
if !self.consume('"') {
|
||||
return Err(invalid("Steam VDF expected a quoted string"));
|
||||
}
|
||||
let mut value = String::new();
|
||||
while self.offset < self.input.len() {
|
||||
let character = self
|
||||
.next()
|
||||
.ok_or_else(|| invalid("Steam VDF string ended"))?;
|
||||
match character {
|
||||
'"' => return Ok(value),
|
||||
'\\' => match self.next() {
|
||||
Some('"') => value.push('"'),
|
||||
Some('\\') => value.push('\\'),
|
||||
Some('n') => value.push('\n'),
|
||||
Some('t') => value.push('\t'),
|
||||
_ => return Err(invalid("Steam VDF escape is invalid")),
|
||||
},
|
||||
value_character => value.push(value_character),
|
||||
}
|
||||
}
|
||||
Err(invalid("Steam VDF string is unterminated"))
|
||||
}
|
||||
|
||||
fn skip_space(&mut self) {
|
||||
loop {
|
||||
let remaining = &self.input[self.offset..];
|
||||
if remaining.starts_with("//") {
|
||||
self.offset += remaining.find('\n').unwrap_or(remaining.len());
|
||||
} else if remaining.chars().next().is_some_and(char::is_whitespace) {
|
||||
self.next();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn consume(&mut self, expected: char) -> bool {
|
||||
if self.input[self.offset..].starts_with(expected) {
|
||||
self.offset += expected.len_utf8();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn next(&mut self) -> Option<char> {
|
||||
let character = self.input[self.offset..].chars().next()?;
|
||||
self.offset += character.len_utf8();
|
||||
Some(character)
|
||||
}
|
||||
}
|
||||
|
||||
fn object<'a>(
|
||||
values: &'a BTreeMap<String, VdfValue>,
|
||||
key: &str,
|
||||
) -> Result<&'a BTreeMap<String, VdfValue>, ContractErrorV1> {
|
||||
match values.get(key) {
|
||||
Some(VdfValue::Object(value)) => Ok(value),
|
||||
_ => Err(invalid(format!("Steam VDF is missing {key}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn field<'a>(
|
||||
values: &'a BTreeMap<String, VdfValue>,
|
||||
key: &str,
|
||||
) -> Result<&'a str, ContractErrorV1> {
|
||||
match values.get(key) {
|
||||
Some(VdfValue::Text(value)) => Ok(value),
|
||||
_ => Err(invalid(format!("Steam app manifest is missing {key}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_selector(selector: &str) -> Result<(), ContractErrorV1> {
|
||||
if selector.is_empty() || selector.trim() != selector || selector.chars().any(char::is_control)
|
||||
{
|
||||
Err(invalid("game selector is invalid"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn name(raw: &str) -> ContractName {
|
||||
raw.parse().expect("static contract name")
|
||||
}
|
||||
|
||||
fn text(raw: &str) -> ContractText {
|
||||
raw.parse().expect("static contract text")
|
||||
}
|
||||
|
||||
fn invalid(message: impl fmt::Display) -> ContractErrorV1 {
|
||||
error(ContractErrorCode::InvalidInput, message)
|
||||
}
|
||||
|
||||
fn unavailable(message: impl fmt::Display) -> ContractErrorV1 {
|
||||
error(ContractErrorCode::TemporarilyUnavailable, message)
|
||||
}
|
||||
|
||||
fn not_found(message: &str) -> ContractErrorV1 {
|
||||
ContractErrorV1::new(ContractErrorCode::NotFound, text(message))
|
||||
}
|
||||
|
||||
fn error(code: ContractErrorCode, message: impl fmt::Display) -> ContractErrorV1 {
|
||||
let cleaned: String = message
|
||||
.to_string()
|
||||
.chars()
|
||||
.map(|character| {
|
||||
if character.is_control() {
|
||||
' '
|
||||
} else {
|
||||
character
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
ContractErrorV1::new(
|
||||
code,
|
||||
cleaned
|
||||
.trim()
|
||||
.parse()
|
||||
.unwrap_or_else(|_| text("Steam operation failed")),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fmt::Write as _;
|
||||
use std::fs;
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
use kiln_core::Capability;
|
||||
use kiln_core::adapter::ProviderAdapter;
|
||||
use kiln_core::contract_error::ContractErrorCode;
|
||||
use kiln_core::launch::{CommandArgument, Executable};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{SteamAdapter, SteamClient};
|
||||
|
||||
struct TestDirectory(PathBuf);
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
impl TestDirectory {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!("kiln-steam-test-{}", Uuid::new_v4()));
|
||||
fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn library(&self, name: &str) -> PathBuf {
|
||||
let path = self.0.join(name);
|
||||
fs::create_dir_all(path.join("steamapps/common")).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn folders(&self, libraries: &[&Path]) {
|
||||
let mut input = String::from("\"libraryfolders\"\n{\n");
|
||||
for (index, library) in libraries.iter().enumerate() {
|
||||
writeln!(
|
||||
input,
|
||||
"\"{index}\" {{ \"path\" \"{}\" }}",
|
||||
library.display()
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
input.push_str("}\n");
|
||||
fs::create_dir_all(self.0.join("steamapps")).unwrap();
|
||||
fs::write(self.0.join("steamapps/libraryfolders.vdf"), input).unwrap();
|
||||
}
|
||||
|
||||
fn manifest(library: &Path, app_id: &str, game_name: &str, install: &str) {
|
||||
fs::create_dir_all(library.join("steamapps/common").join(install)).unwrap();
|
||||
fs::write(
|
||||
library
|
||||
.join("steamapps")
|
||||
.join(format!("appmanifest_{app_id}.acf")),
|
||||
format!(
|
||||
"\"AppState\" {{ \"appid\" \"{app_id}\" \"name\" \"{game_name}\" \"StateFlags\" \"4\" \"installdir\" \"{install}\" }}"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDirectory {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn discovers_searches_and_normalizes_installed_games_without_launch() {
|
||||
let directory = TestDirectory::new();
|
||||
let library = directory.library("Library With Spaces");
|
||||
directory.folders(&[&library]);
|
||||
TestDirectory::manifest(&library, "620", "Portal 2 ", "Portal 2");
|
||||
let adapter = SteamAdapter::at(&directory.0);
|
||||
let records = adapter.discover().unwrap();
|
||||
assert_eq!(records.len(), 1);
|
||||
let record = &records[0];
|
||||
assert_eq!(record.id().to_string(), "steam:620");
|
||||
assert_eq!(record.name().as_str(), "Portal 2");
|
||||
assert_eq!(record.store().as_str(), "steam");
|
||||
assert_eq!(record.runtime().as_str(), "steam-managed");
|
||||
assert!(record.installed());
|
||||
assert!(record.client_required());
|
||||
assert_eq!(
|
||||
record.capabilities(),
|
||||
[
|
||||
Capability::Discover,
|
||||
Capability::Info,
|
||||
Capability::Launch,
|
||||
Capability::ClientRequired
|
||||
]
|
||||
);
|
||||
assert_eq!(adapter.search("portal").unwrap().len(), 1);
|
||||
assert_eq!(adapter.resolve("Portal 2").unwrap().id(), record.id());
|
||||
let plan = adapter.plan_launch(record.id()).unwrap();
|
||||
assert_eq!(
|
||||
plan.lifecycle_confidence(),
|
||||
kiln_core::launch::LifecycleConfidence::Unknown
|
||||
);
|
||||
assert!(matches!(
|
||||
&plan.command().executable,
|
||||
Executable::Program(program) if program.as_str() == "steam"
|
||||
));
|
||||
assert_eq!(
|
||||
plan.command()
|
||||
.arguments
|
||||
.iter()
|
||||
.map(CommandArgument::as_str)
|
||||
.collect::<Vec<_>>(),
|
||||
["-silent", "-applaunch", "620"]
|
||||
);
|
||||
|
||||
let mut flatpak = adapter.clone();
|
||||
flatpak.client = SteamClient::Flatpak;
|
||||
let plan = flatpak.plan_launch(record.id()).unwrap();
|
||||
assert!(matches!(
|
||||
&plan.command().executable,
|
||||
Executable::Program(program) if program.as_str() == "flatpak"
|
||||
));
|
||||
assert_eq!(
|
||||
plan.command()
|
||||
.arguments
|
||||
.iter()
|
||||
.map(CommandArgument::as_str)
|
||||
.collect::<Vec<_>>(),
|
||||
[
|
||||
"run",
|
||||
"com.valvesoftware.Steam",
|
||||
"-silent",
|
||||
"-applaunch",
|
||||
"620"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_unmounted_partial_and_known_runtime_entries() {
|
||||
let directory = TestDirectory::new();
|
||||
let library = directory.library("library");
|
||||
let missing = directory.0.join("not-mounted");
|
||||
directory.folders(&[&library, &missing]);
|
||||
TestDirectory::manifest(
|
||||
&library,
|
||||
"1826330",
|
||||
"Proton EasyAntiCheat Runtime",
|
||||
"Proton EAC",
|
||||
);
|
||||
fs::write(
|
||||
library.join("steamapps/appmanifest_10.acf"),
|
||||
"\"AppState\" { \"appid\" \"10\" \"name\" \"Partial\" \"StateFlags\" \"2\" \"installdir\" \"Partial\" }",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
SteamAdapter::at(&directory.0)
|
||||
.discover()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_installation_identity_survives_library_moves() {
|
||||
let directory = TestDirectory::new();
|
||||
let first = directory.library("first");
|
||||
directory.folders(&[&first]);
|
||||
TestDirectory::manifest(&first, "620", "Portal 2", "Portal 2");
|
||||
let adapter = SteamAdapter::at(&directory.0);
|
||||
let before = adapter.discover().unwrap()[0].installation_id();
|
||||
|
||||
let second = directory.library("second");
|
||||
TestDirectory::manifest(&second, "620", "Portal 2", "Moved Portal");
|
||||
directory.folders(&[&second]);
|
||||
let after = adapter.discover().unwrap()[0].installation_id();
|
||||
assert_eq!(before, after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_traversal_non_utf8_oversize_and_symlinked_metadata() {
|
||||
let directory = TestDirectory::new();
|
||||
let first = directory.library("first");
|
||||
let second = directory.library("second");
|
||||
directory.folders(&[&first, &second]);
|
||||
TestDirectory::manifest(&first, "620", "Portal 2", "Portal 2");
|
||||
TestDirectory::manifest(&second, "620", "Portal 2", "Portal 2");
|
||||
assert_eq!(
|
||||
SteamAdapter::at(&directory.0)
|
||||
.discover()
|
||||
.unwrap_err()
|
||||
.code(),
|
||||
ContractErrorCode::InvalidInput
|
||||
);
|
||||
|
||||
directory.folders(&[&first]);
|
||||
fs::write(
|
||||
first.join("steamapps/appmanifest_620.acf"),
|
||||
"\"AppState\" { \"appid\" \"620\" \"name\" \"Portal\" \"StateFlags\" \"4\" \"installdir\" \"../escape\" }",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
SteamAdapter::at(&directory.0)
|
||||
.discover()
|
||||
.unwrap_err()
|
||||
.code(),
|
||||
ContractErrorCode::InvalidInput
|
||||
);
|
||||
|
||||
fs::write(
|
||||
first.join("steamapps/appmanifest_620.acf"),
|
||||
"\"AppState\" { \"appid\" \"620\" \"StateFlags\" \"4\" \"installdir\" \"Portal 2\" }",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
SteamAdapter::at(&directory.0)
|
||||
.discover()
|
||||
.unwrap_err()
|
||||
.code(),
|
||||
ContractErrorCode::InvalidInput
|
||||
);
|
||||
|
||||
fs::write(first.join("steamapps/appmanifest_620.acf"), [0xff]).unwrap();
|
||||
assert_eq!(
|
||||
SteamAdapter::at(&directory.0)
|
||||
.discover()
|
||||
.unwrap_err()
|
||||
.code(),
|
||||
ContractErrorCode::InvalidInput
|
||||
);
|
||||
|
||||
fs::write(
|
||||
first.join("steamapps/appmanifest_620.acf"),
|
||||
vec![b'x'; 1024 * 1024 + 1],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
SteamAdapter::at(&directory.0)
|
||||
.discover()
|
||||
.unwrap_err()
|
||||
.code(),
|
||||
ContractErrorCode::InvalidInput
|
||||
);
|
||||
|
||||
fs::remove_file(first.join("steamapps/appmanifest_620.acf")).unwrap();
|
||||
let target = first.join("target.acf");
|
||||
fs::write(&target, "\"AppState\" {}").unwrap();
|
||||
symlink(&target, first.join("steamapps/appmanifest_620.acf")).unwrap();
|
||||
assert_eq!(
|
||||
SteamAdapter::at(&directory.0)
|
||||
.discover()
|
||||
.unwrap_err()
|
||||
.code(),
|
||||
ContractErrorCode::InvalidInput
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,9 @@ path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
kiln-core = { version = "=0.0.1", path = "../kiln-core" }
|
||||
kiln-adapter-native = { version = "=0.0.1", path = "../kiln-adapter-native" }
|
||||
kiln-adapter-steam = { version = "=0.0.1", path = "../kiln-adapter-steam" }
|
||||
serde_json = "=1.0.150"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
+483
-17
@@ -1,36 +1,502 @@
|
||||
//! Private smoke CLI for the Project Kiln workspace.
|
||||
//! Project Kiln command-line entry point.
|
||||
|
||||
use std::env;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use kiln_core::CONTRACT_SCHEMA_VERSION;
|
||||
use kiln_adapter_native::{NativeAdapter, current_log_path_for, execute, submit};
|
||||
use kiln_adapter_steam::SteamAdapter;
|
||||
use kiln_core::adapter::ProviderAdapter;
|
||||
use kiln_core::config::{
|
||||
COMMENTED_DEFAULT_CONFIG, CONFIG_SCHEMA_VERSION, ConfigurationSource, ConfigurationTier,
|
||||
EditableScalar, built_in_configuration, configuration_path_for_current_user,
|
||||
load_configuration, resolve_layers, set_configuration_scalar,
|
||||
};
|
||||
use kiln_core::contract_error::{
|
||||
CONTRACT_ERROR_SCHEMA_VERSION, ContractErrorCode, ContractErrorV1,
|
||||
};
|
||||
use kiln_core::launch::LAUNCH_PLAN_SCHEMA_VERSION;
|
||||
use kiln_core::registry::INSTALLATION_REGISTRY_SCHEMA_VERSION;
|
||||
use kiln_core::{CONTRACT_SCHEMA_VERSION, ContractName, ContractText, GameRecordV1};
|
||||
use serde_json::json;
|
||||
|
||||
const HELP: &str = "Project Kiln private scaffold\n\nUSAGE:\n kiln <COMMAND>\n\nCOMMANDS:\n version Print the private build version\n doctor [--json] Check the scaffold contract\n help Print this help\n";
|
||||
const HELP: &str = "Project Kiln private development CLI
|
||||
|
||||
Usage:
|
||||
kiln doctor [--json]
|
||||
kiln list [--provider native|steam] [--json]
|
||||
kiln search <query> [--provider native|steam] [--json]
|
||||
kiln info <id-or-name> [--provider native|steam] [--json]
|
||||
kiln scan [--provider native|steam] [--no-cache] [--json]
|
||||
kiln launch <id-or-name> [--provider native|steam] [--dry-run] [--json]
|
||||
kiln config defaults
|
||||
kiln config validate [--json]
|
||||
kiln config explain [--json]
|
||||
kiln config set runtime|session-backend <value> [--json]
|
||||
kiln --help
|
||||
kiln --version";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let mut arguments = env::args().skip(1);
|
||||
match (arguments.next().as_deref(), arguments.next().as_deref()) {
|
||||
(None | Some("help" | "--help" | "-h"), None) => {
|
||||
print!("{HELP}");
|
||||
run(env::args().skip(1).collect())
|
||||
}
|
||||
|
||||
fn run(mut arguments: Vec<String>) -> ExitCode {
|
||||
let json_output = take_flag(&mut arguments, "--json");
|
||||
if arguments.first().is_some_and(|argument| argument == "scan") {
|
||||
let _ = take_flag(&mut arguments, "--no-cache");
|
||||
}
|
||||
if let Some(result) = explicit_provider_command(&arguments, json_output) {
|
||||
return result;
|
||||
}
|
||||
match arguments.as_slice() {
|
||||
[flag] if flag == "--help" || flag == "-h" => {
|
||||
println!("{HELP}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
(Some("version" | "--version" | "-V"), None) => {
|
||||
println!("kiln {} (private placeholder)", env!("CARGO_PKG_VERSION"));
|
||||
[flag] if flag == "--version" || flag == "-V" => {
|
||||
println!("kiln {}", env!("CARGO_PKG_VERSION"));
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
(Some("doctor"), None) => {
|
||||
println!("ok: core contract schema {CONTRACT_SCHEMA_VERSION}");
|
||||
ExitCode::SUCCESS
|
||||
[command] if command == "doctor" => doctor(json_output),
|
||||
[command] if command == "list" => with_adapter(json_output, |adapter| {
|
||||
output_records(adapter.discover()?, json_output);
|
||||
Ok(())
|
||||
}),
|
||||
[command, query] if command == "search" => with_adapter(json_output, |adapter| {
|
||||
output_records(adapter.search(query)?, json_output);
|
||||
Ok(())
|
||||
}),
|
||||
[command, selector] if command == "info" && selector.starts_with("steam:") => {
|
||||
with_steam(json_output, |adapter| {
|
||||
output_record(&adapter.resolve(selector)?, json_output);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
(Some("doctor"), Some("--json")) => {
|
||||
println!(
|
||||
"{{\"schema_version\":{CONTRACT_SCHEMA_VERSION},\"status\":\"ok\",\"private_placeholder\":true}}"
|
||||
);
|
||||
ExitCode::SUCCESS
|
||||
[command, selector] if command == "info" => with_adapter(json_output, |adapter| {
|
||||
output_record(&adapter.resolve(selector)?, json_output);
|
||||
Ok(())
|
||||
}),
|
||||
[command] if command == "scan" => scan_native(json_output),
|
||||
_ if arguments.first().is_some_and(|value| value == "launch") => {
|
||||
launch(arguments, json_output)
|
||||
}
|
||||
_ if arguments.first().is_some_and(|value| value == "config") => {
|
||||
config(&arguments, json_output)
|
||||
}
|
||||
[command, ..] if command == "frontend" && json_output => {
|
||||
json_error(&ContractErrorV1::unsupported_capability(
|
||||
text("frontend capabilities are post-version-1"),
|
||||
name("frontend"),
|
||||
))
|
||||
}
|
||||
_ if json_output => json_error(&ContractErrorV1::new(
|
||||
ContractErrorCode::InvalidInput,
|
||||
text("unsupported arguments"),
|
||||
)),
|
||||
_ => {
|
||||
eprintln!("error: unsupported arguments\n\n{HELP}");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn explicit_provider_command(arguments: &[String], json_output: bool) -> Option<ExitCode> {
|
||||
match arguments {
|
||||
[command, provider, value] if command == "list" && provider == "--provider" => {
|
||||
Some(list_provider(value, json_output))
|
||||
}
|
||||
[command, query, provider, value] if command == "search" && provider == "--provider" => {
|
||||
Some(search_provider(value, query, json_output))
|
||||
}
|
||||
[command, selector, provider, value] if command == "info" && provider == "--provider" => {
|
||||
Some(info_provider(value, selector, json_output))
|
||||
}
|
||||
[command, provider, value] if command == "scan" && provider == "--provider" => {
|
||||
Some(match value.as_str() {
|
||||
"native" => scan_native(json_output),
|
||||
"steam" => scan_steam(json_output),
|
||||
_ => invalid_arguments(json_output),
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn list_provider(provider: &str, json_output: bool) -> ExitCode {
|
||||
match provider {
|
||||
"native" => with_adapter(json_output, |adapter| {
|
||||
output_records(adapter.discover()?, json_output);
|
||||
Ok(())
|
||||
}),
|
||||
"steam" => with_steam(json_output, |adapter| {
|
||||
output_records(adapter.discover()?, json_output);
|
||||
Ok(())
|
||||
}),
|
||||
_ => invalid_arguments(json_output),
|
||||
}
|
||||
}
|
||||
|
||||
fn search_provider(provider: &str, query: &str, json_output: bool) -> ExitCode {
|
||||
match provider {
|
||||
"native" => with_adapter(json_output, |adapter| {
|
||||
output_records(adapter.search(query)?, json_output);
|
||||
Ok(())
|
||||
}),
|
||||
"steam" => with_steam(json_output, |adapter| {
|
||||
output_records(adapter.search(query)?, json_output);
|
||||
Ok(())
|
||||
}),
|
||||
_ => invalid_arguments(json_output),
|
||||
}
|
||||
}
|
||||
|
||||
fn info_provider(provider: &str, selector: &str, json_output: bool) -> ExitCode {
|
||||
match provider {
|
||||
"native" => with_adapter(json_output, |adapter| {
|
||||
output_record(&adapter.resolve(selector)?, json_output);
|
||||
Ok(())
|
||||
}),
|
||||
"steam" => with_steam(json_output, |adapter| {
|
||||
output_record(&adapter.resolve(selector)?, json_output);
|
||||
Ok(())
|
||||
}),
|
||||
_ => invalid_arguments(json_output),
|
||||
}
|
||||
}
|
||||
|
||||
fn config(arguments: &[String], json_output: bool) -> ExitCode {
|
||||
let result = match arguments {
|
||||
[command, operation] if command == "config" && operation == "defaults" => {
|
||||
print!("{COMMENTED_DEFAULT_CONFIG}");
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
[command, operation] if command == "config" && operation == "validate" => {
|
||||
validate_config(json_output)
|
||||
}
|
||||
[command, operation] if command == "config" && operation == "explain" => {
|
||||
explain_config(json_output)
|
||||
}
|
||||
[command, operation, field, value] if command == "config" && operation == "set" => {
|
||||
set_config(field, value, json_output)
|
||||
}
|
||||
_ => return invalid_arguments(json_output),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) if json_output => json_error(&error),
|
||||
Err(error) => {
|
||||
eprintln!("error: {error}");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_config(json_output: bool) -> Result<(), ContractErrorV1> {
|
||||
let path = configuration_path_for_current_user().map_err(configuration_error)?;
|
||||
let layer = load_configuration(
|
||||
&path,
|
||||
ConfigurationSource {
|
||||
tier: ConfigurationTier::GlobalUser,
|
||||
label: text("global user configuration"),
|
||||
},
|
||||
)
|
||||
.map_err(configuration_error)?;
|
||||
if json_output {
|
||||
println!(
|
||||
"{}",
|
||||
json!({"status": "ok", "path": path, "exists": layer.is_some()})
|
||||
);
|
||||
} else if layer.is_some() {
|
||||
println!("ok: {}", path.display());
|
||||
} else {
|
||||
println!("ok: defaults in use; {} does not exist", path.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn explain_config(json_output: bool) -> Result<(), ContractErrorV1> {
|
||||
let path = configuration_path_for_current_user().map_err(configuration_error)?;
|
||||
let mut layers = vec![built_in_configuration()];
|
||||
if let Some(layer) = load_configuration(
|
||||
&path,
|
||||
ConfigurationSource {
|
||||
tier: ConfigurationTier::GlobalUser,
|
||||
label: text("global user configuration"),
|
||||
},
|
||||
)
|
||||
.map_err(configuration_error)?
|
||||
{
|
||||
layers.push(layer);
|
||||
}
|
||||
let resolved = resolve_layers(&layers).map_err(configuration_error)?;
|
||||
let output = if json_output {
|
||||
serde_json::to_string(&resolved)
|
||||
} else {
|
||||
serde_json::to_string_pretty(&resolved)
|
||||
}
|
||||
.expect("configuration explanation serializes");
|
||||
println!("{output}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_config(field: &str, raw: &str, json_output: bool) -> Result<(), ContractErrorV1> {
|
||||
let scalar = match field {
|
||||
"runtime" => EditableScalar::Runtime,
|
||||
"session-backend" => EditableScalar::SessionBackend,
|
||||
_ => {
|
||||
return Err(ContractErrorV1::new(
|
||||
ContractErrorCode::InvalidInput,
|
||||
text("config set supports runtime or session-backend"),
|
||||
));
|
||||
}
|
||||
};
|
||||
let value: ContractName = raw.parse().map_err(|_| {
|
||||
ContractErrorV1::new(
|
||||
ContractErrorCode::InvalidInput,
|
||||
text("configuration value must be a valid contract name"),
|
||||
)
|
||||
})?;
|
||||
let path = configuration_path_for_current_user().map_err(configuration_error)?;
|
||||
set_configuration_scalar(&path, scalar, &value).map_err(configuration_error)?;
|
||||
if json_output {
|
||||
println!(
|
||||
"{}",
|
||||
json!({"status": "updated", "path": path, "field": field, "value": value})
|
||||
);
|
||||
} else {
|
||||
println!("updated {field} in {}", path.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn configuration_error(error: impl std::fmt::Display) -> ContractErrorV1 {
|
||||
let message = format!("configuration error: {error}");
|
||||
ContractErrorV1::new(
|
||||
ContractErrorCode::InvalidInput,
|
||||
message
|
||||
.parse()
|
||||
.unwrap_or_else(|_| text("configuration operation failed")),
|
||||
)
|
||||
}
|
||||
|
||||
fn doctor(json_output: bool) -> ExitCode {
|
||||
if json_output {
|
||||
println!(
|
||||
"{}",
|
||||
json!({
|
||||
"status": "ok",
|
||||
"contracts": {
|
||||
"game_record": CONTRACT_SCHEMA_VERSION,
|
||||
"installation_registry": INSTALLATION_REGISTRY_SCHEMA_VERSION,
|
||||
"launch_plan": LAUNCH_PLAN_SCHEMA_VERSION,
|
||||
"error": CONTRACT_ERROR_SCHEMA_VERSION,
|
||||
"configuration": CONFIG_SCHEMA_VERSION
|
||||
}
|
||||
})
|
||||
);
|
||||
} else {
|
||||
println!("ok: core contract schema {CONTRACT_SCHEMA_VERSION}");
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
fn scan_native(json_output: bool) -> ExitCode {
|
||||
with_adapter(json_output, |adapter| {
|
||||
let records = adapter.discover()?;
|
||||
if json_output {
|
||||
println!(
|
||||
"{}",
|
||||
json!({"provider": "native", "discovered": records.len()})
|
||||
);
|
||||
} else {
|
||||
println!("native: {} installed game(s)", records.len());
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn scan_steam(json_output: bool) -> ExitCode {
|
||||
with_steam(json_output, |adapter| {
|
||||
let records = adapter.discover()?;
|
||||
if json_output {
|
||||
println!(
|
||||
"{}",
|
||||
json!({"provider": "steam", "discovered": records.len()})
|
||||
);
|
||||
} else {
|
||||
println!("steam: {} installed game(s)", records.len());
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn launch(mut arguments: Vec<String>, json_output: bool) -> ExitCode {
|
||||
let dry_run = take_flag(&mut arguments, "--dry-run");
|
||||
let _ = take_flag(&mut arguments, "--no-cache");
|
||||
let (selector, provider) = match arguments.as_slice() {
|
||||
[command, selector] if command == "launch" => (selector, None),
|
||||
[command, selector, flag, provider] if command == "launch" && flag == "--provider" => {
|
||||
(selector, Some(provider.as_str()))
|
||||
}
|
||||
_ => return invalid_arguments(json_output),
|
||||
};
|
||||
let steam = provider == Some("steam") || provider.is_none() && selector.starts_with("steam:");
|
||||
if provider.is_some_and(|value| !matches!(value, "native" | "steam"))
|
||||
|| provider == Some("native") && selector.starts_with("steam:")
|
||||
{
|
||||
return invalid_arguments(json_output);
|
||||
}
|
||||
if steam {
|
||||
with_steam(json_output, |adapter| {
|
||||
launch_with(adapter, selector, "steam", dry_run, json_output)
|
||||
})
|
||||
} else {
|
||||
with_adapter(json_output, |adapter| {
|
||||
launch_with(adapter, selector, "native", dry_run, json_output)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn launch_with(
|
||||
adapter: &impl ProviderAdapter,
|
||||
selector: &str,
|
||||
provider: &str,
|
||||
dry_run: bool,
|
||||
json_output: bool,
|
||||
) -> Result<(), ContractErrorV1> {
|
||||
let record = adapter.resolve(selector)?;
|
||||
let plan = adapter.plan_launch(record.id())?;
|
||||
if dry_run {
|
||||
let output = if json_output {
|
||||
serde_json::to_string(&plan)
|
||||
} else {
|
||||
serde_json::to_string_pretty(&plan)
|
||||
}
|
||||
.expect("launch plan serializes");
|
||||
println!("{output}");
|
||||
return Ok(());
|
||||
}
|
||||
let provider_name = name(provider);
|
||||
let log = current_log_path_for(&provider_name)?;
|
||||
if provider == "steam" {
|
||||
let outcome = submit(&plan, &log)?;
|
||||
if json_output {
|
||||
println!(
|
||||
"{}",
|
||||
json!({"game_id": record.id(), "status": "submitted", "process_id": outcome.process_id, "log": log})
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"{} submitted successfully; log: {}",
|
||||
record.id(),
|
||||
log.display()
|
||||
);
|
||||
}
|
||||
} else {
|
||||
let outcome = execute(&plan, &log)?;
|
||||
if json_output {
|
||||
println!(
|
||||
"{}",
|
||||
json!({"game_id": record.id(), "status": "exited", "exit_code": outcome.exit_code, "log": log})
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"{} exited successfully; log: {}",
|
||||
record.id(),
|
||||
log.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn with_adapter(
|
||||
json_output: bool,
|
||||
operation: impl FnOnce(&NativeAdapter) -> Result<(), ContractErrorV1>,
|
||||
) -> ExitCode {
|
||||
match NativeAdapter::for_current_user().and_then(|adapter| operation(&adapter)) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) if json_output => json_error(&error),
|
||||
Err(error) => {
|
||||
eprintln!("error: {error}");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn with_steam(
|
||||
json_output: bool,
|
||||
operation: impl FnOnce(&SteamAdapter) -> Result<(), ContractErrorV1>,
|
||||
) -> ExitCode {
|
||||
match SteamAdapter::for_current_user().and_then(|adapter| operation(&adapter)) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) if json_output => json_error(&error),
|
||||
Err(error) => {
|
||||
eprintln!("error: {error}");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn output_records(records: Vec<GameRecordV1>, json_output: bool) {
|
||||
if json_output {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&records).expect("records serialize")
|
||||
);
|
||||
} else {
|
||||
for record in records {
|
||||
println!("{}\t{}", record.id(), record.name().as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn output_record(record: &GameRecordV1, json_output: bool) {
|
||||
if json_output {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(record).expect("record serializes")
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"{}\t{}\t{}",
|
||||
record.id(),
|
||||
record.name().as_str(),
|
||||
record.install_path().as_path().display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_arguments(json_output: bool) -> ExitCode {
|
||||
let error = ContractErrorV1::new(
|
||||
ContractErrorCode::InvalidInput,
|
||||
text("unsupported arguments"),
|
||||
);
|
||||
if json_output {
|
||||
json_error(&error)
|
||||
} else {
|
||||
eprintln!("error: unsupported arguments\n\n{HELP}");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
|
||||
fn take_flag(arguments: &mut Vec<String>, flag: &str) -> bool {
|
||||
let found = arguments.iter().any(|argument| argument == flag);
|
||||
arguments.retain(|argument| argument != flag);
|
||||
found
|
||||
}
|
||||
|
||||
fn json_error(error: &ContractErrorV1) -> ExitCode {
|
||||
eprintln!(
|
||||
"{}",
|
||||
serde_json::to_string(error).expect("contract error serializes")
|
||||
);
|
||||
ExitCode::from(2)
|
||||
}
|
||||
|
||||
fn name(raw: &str) -> ContractName {
|
||||
raw.parse().expect("static contract name")
|
||||
}
|
||||
|
||||
fn text(raw: &str) -> ContractText {
|
||||
raw.parse().expect("static contract text")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
//! Isolated CLI contract tests for Phase 1 machine output.
|
||||
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
fn kiln(arguments: &[&str]) -> std::process::Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_kiln"))
|
||||
.args(arguments)
|
||||
.output()
|
||||
.expect("kiln runs")
|
||||
}
|
||||
|
||||
struct TestDirectory(PathBuf);
|
||||
|
||||
impl TestDirectory {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"kiln-cli-test-{}-{}",
|
||||
std::process::id(),
|
||||
std::thread::current().name().unwrap_or("unnamed")
|
||||
));
|
||||
let _ = fs::remove_dir_all(&path);
|
||||
fs::create_dir(&path).expect("test directory");
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn run(&self, arguments: &[&str]) -> std::process::Output {
|
||||
self.run_with_path(
|
||||
arguments,
|
||||
&format!(
|
||||
"{}:{}",
|
||||
self.0.join("bin").display(),
|
||||
std::env::var("PATH").unwrap_or_default()
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn run_with_path(&self, arguments: &[&str], path: &str) -> std::process::Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_kiln"))
|
||||
.args(arguments)
|
||||
.env("XDG_CONFIG_HOME", self.0.join("config"))
|
||||
.env("XDG_STATE_HOME", self.0.join("state"))
|
||||
.env("HOME", self.0.join("home"))
|
||||
.env("PATH", path)
|
||||
.output()
|
||||
.expect("kiln runs")
|
||||
}
|
||||
|
||||
fn add_game(&self, id: &str, name: &str, executable: &Path) {
|
||||
let manifests = self.0.join("config/kiln/native");
|
||||
let install = self.0.join("games").join(id);
|
||||
fs::create_dir_all(&manifests).expect("manifest directory");
|
||||
fs::create_dir_all(&install).expect("install directory");
|
||||
fs::write(
|
||||
manifests.join(format!("{id}.toml")),
|
||||
format!(
|
||||
"schema_version = 1\nid = \"{id}\"\nname = \"{name}\"\ninstall_path = \"{}\"\nexecutable = \"{}\"\narguments = [\"native-output\"]\n",
|
||||
install.display(),
|
||||
executable.display()
|
||||
),
|
||||
)
|
||||
.expect("manifest");
|
||||
}
|
||||
|
||||
fn add_steam_game(&self, app_id: &str, name: &str) {
|
||||
let root = self.0.join("home/.local/share/Steam");
|
||||
let steamapps = root.join("steamapps");
|
||||
let install = steamapps.join("common").join(name);
|
||||
fs::create_dir_all(&install).unwrap();
|
||||
fs::write(
|
||||
steamapps.join("libraryfolders.vdf"),
|
||||
format!(
|
||||
"\"libraryfolders\" {{ \"0\" {{ \"path\" \"{}\" }} }}",
|
||||
root.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let bin = self.0.join("bin");
|
||||
fs::create_dir_all(&bin).unwrap();
|
||||
let steam = bin.join("steam");
|
||||
fs::write(&steam, "#!/bin/sh\nprintf 'steam %s\\n' \"$*\"\n").unwrap();
|
||||
fs::set_permissions(steam, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
fs::write(
|
||||
steamapps.join(format!("appmanifest_{app_id}.acf")),
|
||||
format!(
|
||||
"\"AppState\" {{ \"appid\" \"{app_id}\" \"name\" \"{name}\" \"StateFlags\" \"4\" \"installdir\" \"{name}\" }}"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDirectory {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doctor_reports_every_phase_one_schema() {
|
||||
let output = kiln(&["doctor", "--json"]);
|
||||
assert!(output.status.success());
|
||||
let json: Value = serde_json::from_slice(&output.stdout).expect("doctor JSON");
|
||||
assert_eq!(json["status"], "ok");
|
||||
for contract in [
|
||||
"game_record",
|
||||
"installation_registry",
|
||||
"launch_plan",
|
||||
"error",
|
||||
"configuration",
|
||||
] {
|
||||
let expected = if contract == "launch_plan" { 2 } else { 1 };
|
||||
assert_eq!(json["contracts"][contract], expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_invalid_arguments_return_structured_error() {
|
||||
let output = kiln(&["unknown", "--json"]);
|
||||
assert_eq!(output.status.code(), Some(2));
|
||||
let json: Value = serde_json::from_slice(&output.stderr).expect("error JSON");
|
||||
assert_eq!(json["schema_version"], 1);
|
||||
assert_eq!(json["code"], "invalid-input");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_frontend_returns_unsupported_capability() {
|
||||
let output = kiln(&["frontend", "sync", "--json"]);
|
||||
assert_eq!(output.status.code(), Some(2));
|
||||
let json: Value = serde_json::from_slice(&output.stderr).expect("error JSON");
|
||||
assert_eq!(json["code"], "unsupported-capability");
|
||||
assert_eq!(json["capability"], "frontend");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_cli_completes_discovery_search_plan_execution_and_logging() {
|
||||
let directory = TestDirectory::new();
|
||||
directory.add_game("sample", "Sample Game", Path::new("/usr/bin/printf"));
|
||||
|
||||
let list = directory.run(&["list", "--json"]);
|
||||
assert!(list.status.success());
|
||||
let records: Value = serde_json::from_slice(&list.stdout).expect("list JSON");
|
||||
assert_eq!(records[0]["id"], "native:sample");
|
||||
|
||||
let search = directory.run(&["search", "sample", "--json"]);
|
||||
assert!(search.status.success());
|
||||
let records: Value = serde_json::from_slice(&search.stdout).expect("search JSON");
|
||||
assert_eq!(records.as_array().unwrap().len(), 1);
|
||||
|
||||
let info = directory.run(&["info", "native:sample", "--json"]);
|
||||
assert!(info.status.success());
|
||||
let record: Value = serde_json::from_slice(&info.stdout).expect("info JSON");
|
||||
assert_eq!(record["runtime"], "native");
|
||||
|
||||
let scan = directory.run(&["scan", "--provider", "native", "--no-cache", "--json"]);
|
||||
assert!(scan.status.success());
|
||||
let result: Value = serde_json::from_slice(&scan.stdout).expect("scan JSON");
|
||||
assert_eq!(result["discovered"], 1);
|
||||
|
||||
let dry_run = directory.run(&["launch", "Sample Game", "--dry-run", "--json"]);
|
||||
assert!(dry_run.status.success());
|
||||
let plan: Value = serde_json::from_slice(&dry_run.stdout).expect("plan JSON");
|
||||
assert_eq!(plan["command"]["arguments"][0], "native-output");
|
||||
|
||||
let launch = directory.run(&["launch", "native:sample", "--json"]);
|
||||
assert!(launch.status.success());
|
||||
let outcome: Value = serde_json::from_slice(&launch.stdout).expect("outcome JSON");
|
||||
assert_eq!(outcome["exit_code"], 0);
|
||||
assert!(
|
||||
fs::read_to_string(directory.0.join("state/kiln/logs/native.log"))
|
||||
.expect("launch log")
|
||||
.contains("native-output")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_native_manifest_returns_structured_failure() {
|
||||
let directory = TestDirectory::new();
|
||||
let manifests = directory.0.join("config/kiln/native");
|
||||
fs::create_dir_all(&manifests).unwrap();
|
||||
fs::write(manifests.join("bad.toml"), "schema_version = 99\n").unwrap();
|
||||
let output = directory.run(&["list", "--json"]);
|
||||
assert_eq!(output.status.code(), Some(2));
|
||||
let error: Value = serde_json::from_slice(&output.stderr).expect("error JSON");
|
||||
assert!(matches!(
|
||||
error["code"].as_str(),
|
||||
Some("invalid-input" | "unsupported-version")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_defaults_validate_set_and_explain_are_consistent() {
|
||||
let directory = TestDirectory::new();
|
||||
let defaults = directory.run(&["config", "defaults"]);
|
||||
assert!(defaults.status.success());
|
||||
assert!(
|
||||
String::from_utf8(defaults.stdout)
|
||||
.unwrap()
|
||||
.contains("schema_version = 1")
|
||||
);
|
||||
|
||||
let validate = directory.run(&["config", "validate", "--json"]);
|
||||
assert!(validate.status.success());
|
||||
let validated: Value = serde_json::from_slice(&validate.stdout).unwrap();
|
||||
assert_eq!(validated["exists"], false);
|
||||
|
||||
let runtime = directory.run(&["config", "set", "runtime", "umu", "--json"]);
|
||||
assert!(runtime.status.success());
|
||||
let backend = directory.run(&["config", "set", "session-backend", "wayland", "--json"]);
|
||||
assert!(backend.status.success());
|
||||
let configuration = fs::read_to_string(directory.0.join("config/kiln/config.toml")).unwrap();
|
||||
assert!(configuration.contains("# Project Kiln private configuration"));
|
||||
assert!(configuration.contains("runtime = \"umu\""));
|
||||
assert!(configuration.contains("session_backend = \"wayland\""));
|
||||
|
||||
let explain = directory.run(&["config", "explain", "--json"]);
|
||||
assert!(explain.status.success());
|
||||
let resolved: Value = serde_json::from_slice(&explain.stdout).unwrap();
|
||||
assert_eq!(resolved["runtime"]["value"], "umu");
|
||||
assert_eq!(resolved["runtime"]["source"]["tier"], "global-user");
|
||||
assert_eq!(resolved["session_backend"]["value"], "wayland");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_configuration_and_failed_launch_are_structured() {
|
||||
let directory = TestDirectory::new();
|
||||
fs::create_dir_all(directory.0.join("config/kiln")).unwrap();
|
||||
fs::write(
|
||||
directory.0.join("config/kiln/config.toml"),
|
||||
"schema_version = 99\n",
|
||||
)
|
||||
.unwrap();
|
||||
let invalid = directory.run(&["config", "validate", "--json"]);
|
||||
assert_eq!(invalid.status.code(), Some(2));
|
||||
let error: Value = serde_json::from_slice(&invalid.stderr).unwrap();
|
||||
assert_eq!(error["code"], "invalid-input");
|
||||
|
||||
fs::remove_file(directory.0.join("config/kiln/config.toml")).unwrap();
|
||||
directory.add_game("failure", "Failure", Path::new("/usr/bin/false"));
|
||||
let failed = directory.run(&["launch", "native:failure", "--json"]);
|
||||
assert_eq!(failed.status.code(), Some(2));
|
||||
let error: Value = serde_json::from_slice(&failed.stderr).unwrap();
|
||||
assert_eq!(error["code"], "launch-failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steam_provider_cli_discovers_plans_and_submits_without_a_shell() {
|
||||
let directory = TestDirectory::new();
|
||||
directory.add_steam_game("620", "Portal 2");
|
||||
|
||||
let list = directory.run(&["list", "--provider", "steam", "--json"]);
|
||||
assert!(list.status.success());
|
||||
let records: Value = serde_json::from_slice(&list.stdout).unwrap();
|
||||
assert_eq!(records[0]["id"], "steam:620");
|
||||
assert_eq!(records[0]["runtime"], "steam-managed");
|
||||
assert_eq!(records[0]["client_required"], true);
|
||||
assert!(
|
||||
records[0]["capabilities"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&Value::String("launch".into()))
|
||||
);
|
||||
|
||||
let search = directory.run(&["search", "portal", "--provider", "steam", "--json"]);
|
||||
assert!(search.status.success());
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Value>(&search.stdout).unwrap()[0]["name"],
|
||||
"Portal 2"
|
||||
);
|
||||
|
||||
let info = directory.run(&["info", "steam:620", "--json"]);
|
||||
assert!(info.status.success());
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Value>(&info.stdout).unwrap()["store"],
|
||||
"steam"
|
||||
);
|
||||
|
||||
let scan = directory.run(&["scan", "--provider", "steam", "--json"]);
|
||||
assert!(scan.status.success());
|
||||
let result: Value = serde_json::from_slice(&scan.stdout).unwrap();
|
||||
assert_eq!(result["provider"], "steam");
|
||||
assert_eq!(result["discovered"], 1);
|
||||
|
||||
let dry_run = directory.run(&[
|
||||
"launch",
|
||||
"Portal 2",
|
||||
"--provider",
|
||||
"steam",
|
||||
"--dry-run",
|
||||
"--json",
|
||||
]);
|
||||
assert!(dry_run.status.success());
|
||||
let plan: Value = serde_json::from_slice(&dry_run.stdout).unwrap();
|
||||
assert_eq!(plan["runtime"], "steam-managed");
|
||||
assert_eq!(plan["lifecycle_confidence"], "unknown");
|
||||
assert_eq!(plan["command"]["arguments"][2], "620");
|
||||
|
||||
let launch = directory.run(&["launch", "steam:620", "--json"]);
|
||||
assert!(launch.status.success());
|
||||
let outcome: Value = serde_json::from_slice(&launch.stdout).unwrap();
|
||||
assert_eq!(outcome["status"], "submitted");
|
||||
assert!(outcome["process_id"].as_u64().unwrap() > 0);
|
||||
assert!(
|
||||
fs::read_to_string(directory.0.join("state/kiln/logs/steam.log"))
|
||||
.unwrap()
|
||||
.contains("submitted")
|
||||
);
|
||||
|
||||
fs::remove_file(directory.0.join("bin/steam")).unwrap();
|
||||
let failed = directory.run_with_path(
|
||||
&["launch", "steam:620", "--json"],
|
||||
directory.0.join("bin").to_str().unwrap(),
|
||||
);
|
||||
assert_eq!(failed.status.code(), Some(2));
|
||||
let error: Value = serde_json::from_slice(&failed.stderr).unwrap();
|
||||
assert_eq!(error["code"], "launch-failed");
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -35,8 +35,7 @@ Steam, native, Heroic, Lutris, or UMU integration begins.
|
||||
- Isolated integration tests using only synthetic temporary state and the mock adapter.
|
||||
- Adversarial tests required by R-024 and the threat model where Phase 1 code accepts
|
||||
strings, paths, manifests, environment values, or serialized data.
|
||||
- `make verify` and `make audit` both pass.
|
||||
- `make check` passes.
|
||||
|
||||
Before coding, split this phase into narrow tasks and present any contract choice that
|
||||
would materially constrain future adapters to the project owner.
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
| Rust workspace and component boundaries | root and crate manifests; ADR 0001 | Passed |
|
||||
| Temporary naming and rename path | rename manifest; placeholder scanner | Passed for private development |
|
||||
| Toolchain and lockfile | Rust 1.97.1 checker; desktop evidence; committed lockfile | Passed |
|
||||
| Formatting, linting, tests | `make verify`; desktop evidence | Passed |
|
||||
| CI | pinned workflow invoking `make verify` and `make audit` | Configured; first hosted run pending |
|
||||
| Formatting, linting, tests | local `make check`; desktop evidence | Passed |
|
||||
| Local quality gate | `make check` invokes verification and dependency audits | Passed |
|
||||
| Dependency/license/security audits | workspace auditor; `deny.toml`; cargo-deny 0.20.2 | Passed |
|
||||
| Release/version policy | release policy and changelog | Passed |
|
||||
| ADRs | `docs/adr/` | Passed |
|
||||
@@ -18,6 +18,5 @@
|
||||
| Supported-platform decision | supported-platform record | Passed |
|
||||
| Canonical project license | root GNU AGPL v3 text; hash checked | Passed |
|
||||
|
||||
Phase 0 is closed. The first hosted CI run is operational evidence rather than a blocker
|
||||
to beginning private Phase 1 contract design, but it must pass before accepting external
|
||||
changes or treating CI as enforced.
|
||||
Phase 0 is closed. Gitea stores source only; the recorded local `make check` result is the
|
||||
authoritative quality gate before pushes and releases.
|
||||
|
||||
@@ -64,3 +64,10 @@ fresh `make verify` and `make audit` result is required.
|
||||
- Rustfmt, workspace/all-target check, three unit tests, and Clippy `-D warnings`: passed
|
||||
- cargo-deny 0.20.2: advisories, bans, licenses, and sources all passed
|
||||
- Phase 0 result: complete
|
||||
|
||||
## Local-only verification policy update
|
||||
|
||||
D-067 supersedes the unexercised hosted-workflow plan. Gitea stores source only;
|
||||
`make check` runs the complete verification and audit gate on the development desktop.
|
||||
Rust 1.97.1 is now both the minimum supported and reviewed compiler. Earlier paragraphs
|
||||
retain the original investigation chronology rather than implying that hosted CI ran.
|
||||
|
||||
@@ -36,10 +36,21 @@ kind = "runtime-library"
|
||||
source = "https://crates.io/crates/uuid"
|
||||
version = "1.24.0"
|
||||
license = "MIT OR Apache-2.0"
|
||||
features = ["serde", "v4"]
|
||||
owner = "project maintainers"
|
||||
update_policy = "explicit identity-contract change after changelog, license, and audit review"
|
||||
release_profile = true
|
||||
|
||||
[[components]]
|
||||
name = "toml"
|
||||
kind = "runtime-library"
|
||||
source = "https://crates.io/crates/toml"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
license = "MIT OR Apache-2.0"
|
||||
owner = "project maintainers"
|
||||
update_policy = "explicit configuration-contract change after changelog, license, and audit review"
|
||||
release_profile = true
|
||||
|
||||
# Transitive crate versions, sources, and checksums are pinned in Cargo.lock. Their
|
||||
# licenses and sources are enforced by cargo-deny before release.
|
||||
|
||||
|
||||
@@ -31,13 +31,10 @@ cargo run --locked -p kiln-cli -- doctor --json | jq -e \
|
||||
'.schema_version == 1 and .status == "ok" and .private_placeholder == true'
|
||||
```
|
||||
|
||||
The exact-toolchain gate, canonical AGPL license, dependency-free static audits, and
|
||||
cargo-deny 0.20.2 advisory/bans/licenses/sources audit all pass. Phase 0 is closed.
|
||||
A first hosted CI run remains required before accepting external changes or claiming
|
||||
enforced CI, but does not block private Phase 1 contract design.
|
||||
The exact-toolchain gate, canonical AGPL license, static audits, and cargo-deny 0.20.2
|
||||
advisory/bans/licenses/sources audit all pass. Phase 0 is closed. Local `make check`
|
||||
replaces the unused hosted workflow as the authoritative gate.
|
||||
|
||||
`rust-version = "1.85"` records the minimum supported compiler for Edition 2024. The
|
||||
exact reviewed development and CI toolchain remains pinned separately to Rust 1.97.1 in
|
||||
`rust-toolchain.toml`; these controls must not be conflated. `make verify` explicitly
|
||||
checks the active `rustc` version because distro-provided Rust binaries may ignore the
|
||||
Rustup override file.
|
||||
`rust-version = "1.97.1"` and `rust-toolchain.toml` deliberately name the same minimum
|
||||
and reviewed local compiler. `make verify` checks the active `rustc` version before the
|
||||
Rust quality gates.
|
||||
|
||||
@@ -3,28 +3,28 @@ R-001,10,package reconstruction record,planned
|
||||
R-002,8,session acceptance test,planned
|
||||
R-003,9,performance benchmark record,planned
|
||||
R-004,6,controller compatibility matrix,planned
|
||||
R-005,7,provider contract and acceptance tests,planned
|
||||
R-006,2,CLI integration tests,planned
|
||||
R-005,7,Phase 3 Steam discovery evidence plus later provider execution and acceptance tests,in-progress
|
||||
R-006,2,Phase 2 native list search info scan dry-run and launch CLI tests,complete
|
||||
R-007,8,gaming-tool integration tests,planned
|
||||
R-008,8,session lifecycle acceptance test,planned
|
||||
R-009,1,Phase 1A normalized-record contract tests,in-progress
|
||||
R-009,1,Phase 1 identity registry and provider-independent contract tests,complete
|
||||
R-010,5,TTY recovery acceptance test,planned
|
||||
R-011,0,development workflow record,active
|
||||
R-012,1,Phase 1A role-separation contract tests,in-progress
|
||||
R-012,1,Phase 1 role-separation adapter runtime and CLI contract tests,complete
|
||||
R-013,8,profile policy acceptance tests,planned
|
||||
R-014,12,Pegasus adapter tests,post-v1
|
||||
R-015,8,Lean and Ready policy tests,planned
|
||||
R-016,7,adapter capability contract tests,planned
|
||||
R-016,7,Phase 1 contracts Phase 2 native and Phase 3 Steam capability tests,in-progress
|
||||
R-017,8,window-switching acceptance tests,planned
|
||||
R-018,4,lifecycle confidence tests,planned
|
||||
R-018,4,Phase 1 policy Phase 2 owned native-process lifecycle tests and Phase 4 provider-handoff evidence,in-progress
|
||||
R-019,15,vendor launcher tests,post-v1
|
||||
R-020,6,Bluetooth CLI and TUI tests,planned
|
||||
R-021,11,installer destructive VM tests,planned
|
||||
R-022,10,optional recovery profile tests,optional
|
||||
R-023,14,OpenGamepadUI evaluation,post-v1
|
||||
R-024,1,Phase 1A identifier and path adversarial tests,in-progress
|
||||
R-024,1,Phase 1 contracts plus Phase 2 native and Phase 3 Steam adversarial tests,complete
|
||||
R-025,0,source inventory and release review,active
|
||||
R-026,0,supported-platform record,active
|
||||
R-027,0,path and identity architecture review,active
|
||||
R-028,8,release GPU matrix,planned
|
||||
R-029,1,Phase 1A versioned JSON contract tests,in-progress
|
||||
R-029,1,Phase 1 versioned JSON TOML provenance plus config defaults validate explain edit and launch integration tests,complete
|
||||
|
||||
|
@@ -0,0 +1,19 @@
|
||||
# Phase 1 completion evidence
|
||||
|
||||
- Date: 2026-07-18
|
||||
- Requirement IDs: R-009, R-012, R-016, R-018, R-024, R-029
|
||||
- Phase/gate: phase 1 / provider-independent contract completion
|
||||
- Scope: provider/runtime capability interfaces, typed dry-run launch plans, lifecycle
|
||||
confidence policy, structured versioned errors, fixed-precedence versioned TOML with
|
||||
provenance, deterministic mock scenarios, and versioned CLI machine output
|
||||
- Exclusions: real providers, process execution, daemon or IPC, discovery cache, GUI,
|
||||
session supervision, installation management, public stability, and final naming
|
||||
- Commands: `make verify`; `make audit`; `cargo test --workspace --all-targets --locked`;
|
||||
human and JSON `kiln doctor` smoke paths; structured CLI error smoke paths
|
||||
- Expected result: all required Phase 1 mock outcomes and hostile inputs are covered;
|
||||
schemas reject unknown versions and fields; launch arguments never require a shell;
|
||||
lifecycle effects degrade conservatively; configuration precedence is deterministic
|
||||
- Observed result: 40 unit, contract, adversarial, persistence, concurrency, mock, and
|
||||
isolated CLI integration tests passed; formatting, compilation, Clippy with warnings
|
||||
denied, dependency policy, source inventory, schema reports, and smoke checks passed
|
||||
- Result: pass
|
||||
@@ -0,0 +1,16 @@
|
||||
# Phase 1B installation registry evidence
|
||||
|
||||
- Date: 2026-07-18
|
||||
- Requirement IDs: R-009, R-024, R-027
|
||||
- Phase/gate: phase 1 / durable installation identity registry
|
||||
- Scope: exact adapter-fingerprint matching, persistent UUID generation, move aliases,
|
||||
XDG state resolution, bounded strict JSON, locking, atomic replacement, and backup
|
||||
- Exclusions: adapters, launch plans, configuration precedence, providers, CLI changes,
|
||||
and process execution
|
||||
- Commands: `make verify`; `make audit`; human and JSON `kiln doctor` smoke paths
|
||||
- Expected result: identity survives reloads and moves; ambiguous, corrupted, oversized,
|
||||
and unsafe state fails closed without mutation; concurrent resolution creates one entry
|
||||
- Observed result: 20 unit, contract, adversarial, persistence, and concurrency tests
|
||||
passed; Rustfmt, workspace/all-target compilation, Clippy with warnings denied, and
|
||||
cargo-deny advisories/bans/licenses/sources passed
|
||||
- Result: pass
|
||||
@@ -0,0 +1,22 @@
|
||||
# Phase 1 status
|
||||
|
||||
Phase 1 is complete. Its contracts remain private and may change before 1.0 when the
|
||||
schema version and changelog are updated.
|
||||
|
||||
| Required area | Evidence | Status |
|
||||
|---|---|---|
|
||||
| Game and installation identity | Phase 1A contract tests | Passed |
|
||||
| Durable registry and move aliases | Phase 1B persistence and adversarial tests | Passed |
|
||||
| Provider-independent role separation | Core types and adapter/runtime traits | Passed |
|
||||
| Provider and runtime capabilities | Mock adapter and runtime contract tests | Passed |
|
||||
| Versioned JSON and machine output | Serialization and CLI integration tests | Passed |
|
||||
| Argument-safe launch plans | Typed command/argument tests | Passed |
|
||||
| Lifecycle confidence policy | Conservative effect-matrix tests | Passed |
|
||||
| Structured errors | Ambiguity, capability, and version tests | Passed |
|
||||
| Versioned TOML precedence and provenance | Configuration merge and validation tests | Passed |
|
||||
| All required mock outcomes | Deterministic mock scenario tests | Passed |
|
||||
| Adversarial input handling | Identity, registry, launch, config, and CLI tests | Passed |
|
||||
| Repository gates | `make check` | Passed |
|
||||
|
||||
See `evidence-contracts-2026-07-18.md`, `evidence-registry-2026-07-18.md`, and
|
||||
`evidence-completion-2026-07-18.md` for the dated records.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Phase 2 native vertical-slice evidence
|
||||
|
||||
- Date: 2026-07-18
|
||||
- Requirement IDs: R-006, R-009, R-012, R-016, R-018, R-024, R-029
|
||||
- Phase/gate: phase 2 / native discovery through execution
|
||||
- Scope: bounded opt-in manifests, approved Desktop Entry parsing, normalization,
|
||||
durable identity, search, dry-run plans, global user configuration, owned child
|
||||
execution, bounded redacted logs, and failures
|
||||
- Exclusions: arbitrary application scanning, real graphical games, wrappers, Steam,
|
||||
daemon or IPC, cache, installation management, and public stability
|
||||
- Commands: `cargo test --workspace --all-targets --locked`; `make check`;
|
||||
isolated CLI list/search/info/scan/dry-run/launch integration test
|
||||
- Expected result: hostile metadata fails closed; arguments remain discrete; exact IDs
|
||||
and unique names resolve deterministically; ambiguity is structured; child output is
|
||||
logged privately with sensitive output and home paths redacted; unsuccessful launches
|
||||
return structured errors; concurrent configuration edits fail closed
|
||||
- Observed result: 56 workspace tests and strict repository gates passed; the isolated
|
||||
native process executed through the production CLI path and produced a bounded mode-0600
|
||||
log; configuration CLI edits reached native launch-plan runtime, session, environment,
|
||||
and provenance fields
|
||||
- Result: pass
|
||||
@@ -0,0 +1,51 @@
|
||||
# Native game manifests
|
||||
|
||||
Native discovery is opt-in. Place one schema-versioned TOML file per game under
|
||||
`${XDG_CONFIG_HOME:-$HOME/.config}/kiln/native/`. Discovery is authoritative and
|
||||
read-only; it does not scan arbitrary system applications.
|
||||
|
||||
## Direct executable
|
||||
|
||||
```toml
|
||||
schema_version = 1
|
||||
id = "openmw"
|
||||
name = "OpenMW"
|
||||
install_path = "/games/openmw"
|
||||
executable = "/games/openmw/openmw"
|
||||
arguments = ["--replace"]
|
||||
```
|
||||
|
||||
`id` becomes `native:<id>`. `install_path` and `executable` must be absolute,
|
||||
normalized UTF-8 paths. Arguments are passed directly and are never evaluated by a
|
||||
shell.
|
||||
|
||||
## Approved Desktop Entry
|
||||
|
||||
```toml
|
||||
schema_version = 1
|
||||
id = "supertuxkart"
|
||||
name = "SuperTuxKart"
|
||||
install_path = "/usr/bin"
|
||||
desktop_entry = "/usr/share/applications/supertuxkart.desktop"
|
||||
```
|
||||
|
||||
The explicit path is the approval boundary. The adapter accepts Application entries,
|
||||
parses `Exec=` without a shell, expands `%%`, `%c`, `%i`, and `%k`, and omits file/URL
|
||||
codes because no files or URLs are supplied. Unknown field codes, malformed quoting,
|
||||
symlinks, invalid UTF-8, files over 64 KiB, and mixed executable/Desktop Entry sources
|
||||
fail closed.
|
||||
|
||||
## Commands and state
|
||||
|
||||
```text
|
||||
kiln list [--json]
|
||||
kiln search <query> [--json]
|
||||
kiln info <id-or-name> [--json]
|
||||
kiln scan [--provider native] [--no-cache] [--json]
|
||||
kiln launch <id-or-name> --dry-run [--json]
|
||||
kiln launch <id-or-name> [--json]
|
||||
```
|
||||
|
||||
Installation identity remains in the Phase 1 XDG state registry. Native stdout and
|
||||
stderr append to `${XDG_STATE_HOME:-$HOME/.local/state}/kiln/logs/native.log` with mode
|
||||
`0600`. A non-zero child exit returns a structured `launch-failed` error.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Phase 2 status
|
||||
|
||||
Phase 2 is complete.
|
||||
|
||||
| Exit condition | Evidence | Status |
|
||||
|---|---|---|
|
||||
| Explicit native discovery | Manifest and approved Desktop Entry tests | Passed |
|
||||
| Normalization and durable identity | Game-record and move-preservation tests | Passed |
|
||||
| Search and ambiguity handling | Native adapter and CLI integration tests | Passed |
|
||||
| Dry-run planning | Typed launch-plan CLI test | Passed |
|
||||
| Launch execution | Owned `/usr/bin/printf` isolated integration test | Passed |
|
||||
| Logging | Private-mode, bounded, redacted stdout/stderr log tests | Passed |
|
||||
| Configuration | Defaults, validate, explain, safe edits, provenance, and launch-plan integration tests | Passed |
|
||||
| Failure handling | Malformed/non-UTF-8 input, traversal, symlinks, missing install, spawn, and non-zero exit tests | Passed |
|
||||
| Repository gates | `make check` | Passed |
|
||||
|
||||
Real graphical-game execution is not required for this provider-independent native slice
|
||||
and would interrupt the active desktop session. The isolated process test exercises the
|
||||
same execution path without opening a window.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Phase 3 Steam discovery evidence
|
||||
|
||||
- Date: 2026-07-18
|
||||
- Requirement IDs: R-005, R-009, R-012, R-016, R-024, R-027
|
||||
- Phase/gate: phase 3 / read-only Steam discovery
|
||||
- Scope: native Steam-root selection, bounded `libraryfolders.vdf` and
|
||||
`appmanifest_*.acf` parsing, installed-title normalization, stable App ID and
|
||||
installation identity, provider-scoped CLI discovery, and hostile fixtures
|
||||
- Exclusions: Steam process startup, launch plans, game execution, Proton selection,
|
||||
lifecycle tracking, account data, artwork, and Steam configuration mutation
|
||||
- Commands: `cargo test --workspace --all-targets --locked`; `kiln scan --provider
|
||||
steam --json`; `make check`
|
||||
- Expected result: missing/unmounted libraries are skipped; partial installs and known
|
||||
Steam runtime packages are excluded; malformed, duplicate, oversized, invalid-UTF-8,
|
||||
traversal, and symlinked metadata fail closed; installed games normalize without
|
||||
starting Steam
|
||||
- Observed result: 61 automated tests passed; the production CLI parsed the current
|
||||
local Steam metadata and reported 227 installed titles without starting Steam or a
|
||||
game; a real trailing-space display-name case was normalized and retained as a fixture
|
||||
- Result: pass
|
||||
@@ -0,0 +1,16 @@
|
||||
# Phase 3 status
|
||||
|
||||
Phase 3 is complete.
|
||||
|
||||
| Exit condition | Evidence | Status |
|
||||
|---|---|---|
|
||||
| Steam root and library parsing | Bounded KeyValues fixture tests | Passed |
|
||||
| App-manifest parsing | Installed, partial, malformed, oversized, and invalid-UTF-8 fixtures | Passed |
|
||||
| Normalized identity | Stable `steam:<appid>` and deterministic installation UUID tests | Passed |
|
||||
| Read-only provider surface | Discovery/info-only capability and unsupported-launch tests | Passed |
|
||||
| CLI integration | Provider-scoped list, search, info, and scan test | Passed |
|
||||
| Real-system acceptance | Local Steam metadata scan, no client start or game launch | Passed |
|
||||
| Repository gates | `make check` | Passed |
|
||||
|
||||
Steam launch-plan generation, client startup, Proton behavior, and lifecycle tracking remain
|
||||
Phase 4 work.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Phase 4 Steam execution evidence
|
||||
|
||||
- Date: 2026-07-20
|
||||
- Requirement IDs: R-005, R-016, R-018, R-024
|
||||
- Phase/gate: phase 4 / Steam execution
|
||||
- Scope: typed native and Flatpak Steam launch plans, configuration provenance,
|
||||
argument-safe client submission, private logs, structured errors, and representative
|
||||
native plus Proton execution
|
||||
- Exclusions: direct executable launch, provider-client ownership, strong game-lifetime
|
||||
tracking, automatic Steam shutdown, and minimal-session behavior
|
||||
- Host: Linux 7.1.4-arch1-1; NVIDIA GeForce RTX 5070 using the `nvidia` driver;
|
||||
Steam 1.0.0.87-1
|
||||
- Commands: `kiln launch steam:105600 --dry-run --json`; `kiln launch steam:105600
|
||||
--json`; `kiln launch steam:12810 --dry-run --json`; `kiln launch steam:12810
|
||||
--json`; `make check`
|
||||
- Expected result: Kiln emits a typed `steam -silent -applaunch <appid>` plan, submits
|
||||
it without shell interpolation, returns promptly with unknown lifecycle confidence,
|
||||
launches one native and one Proton title through Steam, and reports missing clients
|
||||
as structured failures
|
||||
- Observed result: Terraria App ID 105600 loaded through Steam Linux Runtime and its
|
||||
native `Terraria.bin.x86_64`; Overlord II App ID 12810 loaded through
|
||||
`GE-Proton11-1` and `Overlord2.exe`; both returned cleanly after user exit
|
||||
- Safety and cleanup: Steam was stopped before the temporary compatibility change and
|
||||
final restore. The original `config.vdf` was restored byte-for-byte with SHA-256
|
||||
`9251c6dbf08602857399916808208bd58dd415b5d5b000acc01f1c9044d89c37`, mode and
|
||||
ownership unchanged, and Steam restarted
|
||||
- Automated result: 61 tests passed with the complete local gate
|
||||
- Result: pass
|
||||
@@ -0,0 +1,16 @@
|
||||
# Phase 4 status
|
||||
|
||||
Phase 4 is complete.
|
||||
|
||||
| Exit condition | Evidence | Status |
|
||||
|---|---|---|
|
||||
| Steam launch plans | Typed native and Flatpak unit tests | Passed |
|
||||
| Safe client submission | Shell-free CLI integration test with private logs | Passed |
|
||||
| Failure handling | Structured missing-client integration assertion | Passed |
|
||||
| Native representative | Terraria App ID 105600 through Steam Linux Runtime | Passed |
|
||||
| Proton representative | Overlord II App ID 12810 through GE-Proton11-1 | Passed |
|
||||
| Host cleanup | Original Steam config restored byte-for-byte; Steam restarted | Passed |
|
||||
| Repository gates | `make check` | Passed |
|
||||
|
||||
Full provider-client lifecycle tracking remains later session-hardening work. Phase 5
|
||||
can now prove the minimal compositor, terminal, login/session, and recovery path.
|
||||
@@ -33,4 +33,4 @@ This directory is the starting point for project planning and later implementati
|
||||
|
||||
## Integrity note
|
||||
|
||||
The numbered topic files are the current v0.15 source of truth. `99_FULL_SPEC_ARCHIVE.md` preserves the complete monolithic v0.4 specification for historical and integrity checks, so it no longer matches the evolving numbered files byte-for-byte.
|
||||
The numbered topic files are the current v0.23 source of truth. `99_FULL_SPEC_ARCHIVE.md` preserves the complete monolithic v0.4 specification for historical and integrity checks, so it no longer matches the evolving numbered files byte-for-byte.
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
> Performance-first Linux gaming appliance with a unified launcher backend
|
||||
|
||||
| **Status** | Implementation — phase 0 complete; phase 1 ready |
|
||||
| **Status** | Implementation — phases 0–4 complete; phase 5 ready |
|
||||
|--------------------|----------------------------------------------------|
|
||||
| **Version** | 0.18 |
|
||||
| **Version** | 0.23 |
|
||||
| **Created** | July 17, 2026 |
|
||||
| **Document type** | Living project specification |
|
||||
| **Current target** | Purpose-built, performance-focused gaming distribution |
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
- Do not split components into separate Git repositories during early development. Avoid submodules and multi-repository coordination while interfaces are still changing.
|
||||
- A component may later move to its own repository with history preserved when it has a stable public contract, independent tests, clear external value, and a genuinely independent release lifecycle.
|
||||
- The repository and task documentation must allow one Codex CLI session started at the repository root to work across the complete project without manual context handoffs.
|
||||
- Bootstrap the repository before feature work with a pinned Rust toolchain, committed dependency lockfile, formatting and lint policy, deny-by-default CI checks, license/security auditing, fixture conventions, changelog/version policy, and an architecture-decision-record directory. The first vertical slice must not invent these piecemeal.
|
||||
- Bootstrap the repository before feature work with a pinned Rust toolchain, committed dependency lockfile, formatting and lint policy, deny-by-default local quality gates, license/security auditing, fixture conventions, changelog/version policy, and an architecture-decision-record directory. The first vertical slice must not invent these piecemeal.
|
||||
|
||||
### 5.1 Layer model
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ Development will use a layered workflow: fast, repeatable testing in virtual mac
|
||||
- A capability advances through four gates: unit tests for pure logic and parsers; contract tests for schemas and adapter behavior; isolated integration tests using fixtures or temporary state; and real-system or VM acceptance tests where applicable.
|
||||
- Work may proceed in parallel only when tasks are demonstrably independent and do not edit the same files or depend on unsettled contracts.
|
||||
- Failures at any gate block the next dependent phase. Exceptions require an explicit documented decision rather than silent scope expansion.
|
||||
- Treat the workspace MSRV and the reviewed development toolchain as separate controls: the MSRV describes compatible source, while local and CI gates verify the exact active compiler selected for reproducible development.
|
||||
- Rust 1.97.1 is both the minimum supported compiler and the reviewed local toolchain. The complete local gate rejects any different active compiler before compilation.
|
||||
- Every internal path dependency declares an exact compatible version as well as its path. Dependency changes update the lockfile, source inventory where applicable, license policy, and advisory/license/source audit evidence in the same task.
|
||||
- Do not preapprove dependency licenses that are not present. Add a license to the allowlist only with the reviewed dependency that requires it.
|
||||
- Each real-system gate records the exact package versions, hardware/driver/kernel tuple, fixture or test account, commands, expected result, observed result, logs, and cleanup. "Launch one game" without a reproducible evidence record is not an acceptance test.
|
||||
@@ -54,7 +54,7 @@ Development will use a layered workflow: fast, repeatable testing in virtual mac
|
||||
|
||||
| **Phase** | **Focus** | **Exit condition** |
|
||||
|-----------|-----------------|------------------------------------------------------------------------------------------------|
|
||||
| 0 | Repository and evidence baseline | Create the Rust workspace and component boundaries using the approved temporary, repository-local Kiln family; establish a rename manifest and automated placeholder scan; pin the toolchain and lockfile; configure formatting, linting, tests, CI, dependency/license/security audits, release/version policy, ADRs, fixture rules, threat model, package/source inventory, test-evidence template, requirement-to-test traceability matrix, and supported-platform decision record. Final collision-checked project/package/crate/executable/configuration/IPC namespaces are required before any public, published, or globally installed artifact, but do not block private implementation. No provider implementation begins before the remaining baseline passes. |
|
||||
| 0 | Repository and evidence baseline | Create the Rust workspace and component boundaries using the approved temporary, repository-local Kiln family; establish a rename manifest and automated placeholder scan; pin the local toolchain and lockfile; configure formatting, linting, tests, a complete local verification entrypoint, dependency/license/security audits, release/version policy, ADRs, fixture rules, threat model, package/source inventory, test-evidence template, requirement-to-test traceability matrix, and supported-platform decision record. Final collision-checked project/package/crate/executable/configuration/IPC namespaces are required before any public, published, or globally installed artifact, but do not block private implementation. No provider implementation begins before the remaining baseline passes. |
|
||||
| 1 | Contracts | Define and aggressively test versioned game/installation identity, the durable installation registry, store/adapter/configuration-owner/execution-backend separation, provider and runtime interfaces, JSON records, launch plans, lifecycle confidence, structured errors, and configuration precedence/provenance using a mock adapter. |
|
||||
| 2 | Native vertical slice | Complete native discovery, normalization, search, dry-run planning, launch execution, logging, and failure handling before beginning Steam execution. |
|
||||
| 3 | Steam discovery | Parse Steam installation and library state through read-only, fixture-driven tests; normalize installed games without launching them. |
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
| D-047 | 2026-07-18 | Confirmed | Reference or reuse ChimeraOS gamescope-session for dedicated frontend sessions where appropriate, but not as the Lean/Ready general multi-window shell. |
|
||||
| D-048 | 2026-07-18 | Confirmed | Add OpenGamepadUI as a post-v1 frontend candidate beside Pegasus without granting its internal catalog or plugins backend authority. |
|
||||
| D-049 | 2026-07-18 | Confirmed | Use Labwc as the leading stacking-compositor prototype while keeping final selection conditional on application, hardware, portal, multi-monitor, and nested-Gamescope tests. |
|
||||
| D-050 | 2026-07-18 | Confirmed | Add a phase 0 repository/evidence baseline before feature work: pinned toolchain and lockfile, CI quality gates, audits, ADRs, fixture rules, threat model, package/source inventory, test-evidence format, and supported-platform record. |
|
||||
| D-050 | 2026-07-18 | Superseded by D-067 | Originally required hosted CI quality gates as part of the phase 0 repository/evidence baseline. D-067 replaces hosted execution with mandatory local verification. |
|
||||
| D-051 | 2026-07-18 | Confirmed | Start with on-demand authoritative discovery and no persistent database; add only a disposable cache or SQLite index when measured latency or live-state requirements justify it, preserving a no-cache path. |
|
||||
| D-052 | 2026-07-18 | Confirmed | Store IDs remain storefront-qualified (`epic:`, `gog:`, `amazon:`) even when Heroic owns configuration; the management client is not part of canonical game identity. |
|
||||
| D-053 | 2026-07-18 | Confirmed | Treat window focus as a compositor/session-adapter capability. Labwc keybindings are the baseline because Labwc intentionally exposes no D-Bus or custom IPC; unsupported programmatic focus is reported honestly. |
|
||||
@@ -117,6 +117,7 @@
|
||||
| D-064 | 2026-07-18 | Confirmed | Use the Ryzen 9 7900X3D/RTX 5070 desktop with a dedicated external USB project drive as the initial bare-metal reference, and require Xbox-family and PlayStation-family controller coverage over both USB and Bluetooth. |
|
||||
| D-065 | 2026-07-18 | Confirmed | Defer technical names until the final public project name can define one related naming system. Permit only clearly temporary repository-local labels during private development, and require the rename manifest, collision screen, and final rename before anything is published or globally installed. |
|
||||
| D-066 | 2026-07-18 | Confirmed | Use the temporary Project Kiln naming family for private development: one repository-local `kiln` CLI with subcommands, plus functional `kiln-core`, `kiln-cli`, `kiln-session`, and `kiln-adapter-*` component labels. Do not create separate `play` or administrative executables. |
|
||||
| D-067 | 2026-07-18 | Confirmed | Keep Gitea as source storage only. Run compilation, tests, Clippy, dependency audits, VM work, and release verification locally on the development desktop through one recorded gate. Rust 1.97.1 is both the minimum supported and reviewed compiler; hosted workflows are not required. |
|
||||
|
||||
## 20. Change log
|
||||
|
||||
@@ -140,6 +141,11 @@
|
||||
| 0.16 | 2026-07-18 | Began private phase 0 implementation; pinned Rust 1.97.1 after the upstream miscompilation-fix release, established the initial workspace/evidence baseline, and changed project status from planning to implementation. |
|
||||
| 0.17 | 2026-07-18 | Closed phase 0 after the exact Rust toolchain, formatting, compilation, unit tests, Clippy, canonical AGPL license, workspace/source checks, temporary-name scan, and cargo-deny advisory/bans/license/source gates all passed. |
|
||||
| 0.18 | 2026-07-18 | Promoted Phase 0 lessons into binding development rules: distinguish MSRV from the enforced toolchain pin, version local path dependencies, keep license allowlists evidence-based, audit pinned development tools, and use Phase 0 completion as the Codex CLI handoff point with a root `AGENTS.md`. |
|
||||
| 0.19 | 2026-07-18 | Completed phases 1 and 2: versioned provider-independent contracts, durable installation identity, native manifest and approved Desktop Entry discovery, normalized search and resolution, dry-run planning, owned process execution, private logging, structured failures, and their automated evidence. |
|
||||
| 0.20 | 2026-07-18 | Replaced the unused hosted-CI plan with mandatory local verification, made Rust 1.97.1 the minimum and reviewed compiler, and scheduled closure of the remaining configuration and adversarial-test gaps before phase 3. |
|
||||
| 0.21 | 2026-07-18 | Closed the pre-phase-3 gaps with local configuration defaults, validation, explanation and safe edits; native launch-plan configuration integration; removal of the obsolete file-lock dependency; bounded redacted process logs; and expanded adversarial tests. |
|
||||
| 0.22 | 2026-07-18 | Completed Phase 3 with bounded read-only Steam library and app-manifest parsing, stable App ID identities, provider-scoped CLI discovery, adversarial fixtures, and a non-launching check against the local Steam installation. |
|
||||
| 0.23 | 2026-07-20 | Completed Phase 4 with typed native and Flatpak Steam launch plans, shell-free client submission, structured failures, and real native Terraria plus GE-Proton Overlord II acceptance tests. |
|
||||
|
||||
## 21. Reference notes
|
||||
|
||||
|
||||
@@ -29,8 +29,18 @@ def main() -> None:
|
||||
fail("workspace package license is not AGPL-3.0-or-later")
|
||||
if package_defaults.get("publish") is not False:
|
||||
fail("private workspace packages must set publish = false")
|
||||
if package_defaults.get("rust-version") != "1.97.1":
|
||||
fail("workspace minimum Rust version must match the reviewed local toolchain")
|
||||
if "repository" in package_defaults:
|
||||
fail("private scaffold must not advertise a placeholder repository URL")
|
||||
expected_members = {
|
||||
"crates/kiln-core",
|
||||
"crates/kiln-cli",
|
||||
"crates/kiln-adapter-native",
|
||||
"crates/kiln-adapter-steam",
|
||||
}
|
||||
if set(workspace["workspace"].get("members", [])) != expected_members:
|
||||
fail("workspace members do not match the reviewed private components")
|
||||
|
||||
cli_manifest = load(ROOT / "crates" / "kiln-cli" / "Cargo.toml")
|
||||
core_dependency = cli_manifest.get("dependencies", {}).get("kiln-core", {})
|
||||
@@ -38,6 +48,37 @@ def main() -> None:
|
||||
fail("internal kiln-core dependency must use the exact workspace release version")
|
||||
if core_dependency.get("path") != "../kiln-core":
|
||||
fail("internal kiln-core dependency must resolve through the reviewed workspace path")
|
||||
if cli_manifest.get("dependencies", {}).get("serde_json") != "=1.0.150":
|
||||
fail("kiln-cli must use the reviewed serde_json contract version")
|
||||
if cli_manifest.get("dependencies", {}).get("kiln-adapter-steam") != {
|
||||
"version": "=0.0.1",
|
||||
"path": "../kiln-adapter-steam",
|
||||
}:
|
||||
fail("kiln-cli Steam adapter dependency must use the reviewed workspace path")
|
||||
native_manifest = load(ROOT / "crates" / "kiln-adapter-native" / "Cargo.toml")
|
||||
native_dependencies = native_manifest.get("dependencies", {})
|
||||
expected_native = {
|
||||
"kiln-core": {"version": "=0.0.1", "path": "../kiln-core"},
|
||||
"serde": {"version": "=1.0.228", "features": ["derive"]},
|
||||
"toml": "=1.1.3",
|
||||
}
|
||||
if native_dependencies != expected_native:
|
||||
fail("native adapter dependencies differ from the reviewed exact set")
|
||||
if native_manifest.get("dev-dependencies", {}) != {
|
||||
"serde_json": "=1.0.150",
|
||||
"uuid": {"version": "=1.24.0", "features": ["v4"]},
|
||||
}:
|
||||
fail("native adapter test dependencies differ from the reviewed exact set")
|
||||
steam_manifest = load(ROOT / "crates" / "kiln-adapter-steam" / "Cargo.toml")
|
||||
if steam_manifest.get("dependencies", {}) != {
|
||||
"kiln-core": {"version": "=0.0.1", "path": "../kiln-core"},
|
||||
"uuid": "=1.24.0",
|
||||
}:
|
||||
fail("Steam adapter dependencies differ from the reviewed exact set")
|
||||
if steam_manifest.get("dev-dependencies", {}) != {
|
||||
"uuid": {"version": "=1.24.0", "features": ["v4"]}
|
||||
}:
|
||||
fail("Steam adapter test dependencies differ from the reviewed exact set")
|
||||
|
||||
license_hash = hashlib.sha256((ROOT / "LICENSE").read_bytes()).hexdigest()
|
||||
if license_hash != EXPECTED_LICENSE_SHA256:
|
||||
@@ -55,6 +96,7 @@ def main() -> None:
|
||||
expected_dependencies = {
|
||||
"serde": "=1.0.228",
|
||||
"serde_json": "=1.0.150",
|
||||
"toml": "=1.1.3",
|
||||
"uuid": "=1.24.0",
|
||||
}
|
||||
dependencies = core_manifest.get("dependencies", {})
|
||||
@@ -63,9 +105,18 @@ def main() -> None:
|
||||
configured_version = configured if isinstance(configured, str) else configured.get("version")
|
||||
if configured_version != version:
|
||||
fail(f"{name} must remain pinned to reviewed version {version}")
|
||||
if components.get(name, {}).get("version") != version.removeprefix("="):
|
||||
inventory_version = (
|
||||
"1.1.3+spec-1.1.0" if name == "toml" else version.removeprefix("=")
|
||||
)
|
||||
if components.get(name, {}).get("version") != inventory_version:
|
||||
fail(f"{name} source inventory does not match its manifest version")
|
||||
|
||||
uuid_features = set(dependencies["uuid"].get("features", []))
|
||||
if uuid_features != {"serde", "v4"}:
|
||||
fail("uuid must enable only the reviewed serde and v4 features")
|
||||
if set(components["uuid"].get("features", [])) != uuid_features:
|
||||
fail("uuid source inventory does not match its reviewed features")
|
||||
|
||||
lock = load(ROOT / "Cargo.lock")
|
||||
external = [package for package in lock["package"] if "source" in package]
|
||||
unexpected_sources = [
|
||||
|
||||
+11
-1
@@ -43,7 +43,7 @@ def check_spec() -> set[str]:
|
||||
if duplicates:
|
||||
fail(f"duplicate {label} IDs: {duplicates}")
|
||||
versions = re.findall(r"^\| \*\*Version\*\*\s+\| ([0-9.]+)", combined, re.MULTILINE)
|
||||
if versions != ["0.18"]:
|
||||
if versions != ["0.23"]:
|
||||
fail(f"active specification version markers are stale or ambiguous: {versions}")
|
||||
return set(requirements)
|
||||
|
||||
@@ -100,6 +100,16 @@ def check_verification_entrypoints() -> None:
|
||||
missing = [command for command in required if command not in makefile]
|
||||
if missing:
|
||||
fail(f"Makefile is missing verification gates: {missing}")
|
||||
if "check: verify audit" not in makefile:
|
||||
fail("Makefile must expose the complete local check entrypoint")
|
||||
hosted = [
|
||||
*ROOT.glob(".github/workflows/*.yml"),
|
||||
*ROOT.glob(".github/workflows/*.yaml"),
|
||||
*ROOT.glob(".gitea/workflows/*.yml"),
|
||||
*ROOT.glob(".gitea/workflows/*.yaml"),
|
||||
]
|
||||
if hosted:
|
||||
fail(f"hosted workflows conflict with the local-only verification policy: {hosted}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
Reference in New Issue
Block a user