diff --git a/CHANGELOG.md b/CHANGELOG.md index 41f0fb6..05e10fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,10 @@ relevant schema version is advanced. - Private Project Kiln Phase 0 workspace and evidence baseline. - 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. ### Corrected - Separated the Rust 1.85 MSRV from the exact Rust 1.97.1 development toolchain pin. - Enforced the active compiler pin instead of assuming distro Cargo honors Rustup files. - diff --git a/Cargo.lock b/Cargo.lock index f8d6e27..016c75a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,59 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "kiln-cli" version = "0.0.1" @@ -12,4 +65,178 @@ dependencies = [ [[package]] name = "kiln-core" version = "0.0.1" +dependencies = [ + "serde", + "serde_json", + "uuid", +] +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/kiln-core/Cargo.toml b/crates/kiln-core/Cargo.toml index 16a8d77..7434ffc 100644 --- a/crates/kiln-core/Cargo.toml +++ b/crates/kiln-core/Cargo.toml @@ -6,6 +6,10 @@ rust-version.workspace = true license.workspace = true publish.workspace = true +[dependencies] +serde = { version = "=1.0.228", features = ["derive"] } +serde_json = "=1.0.150" +uuid = { version = "=1.24.0", features = ["serde"] } + [lints] workspace = true - diff --git a/crates/kiln-core/src/lib.rs b/crates/kiln-core/src/lib.rs index e306152..3db7058 100644 --- a/crates/kiln-core/src/lib.rs +++ b/crates/kiln-core/src/lib.rs @@ -1,26 +1,31 @@ //! Shared, provider-independent contracts for Project Kiln. use std::fmt; +use std::path::{Component, Path, PathBuf}; use std::str::FromStr; -/// Schema version emitted by the initial private machine-readable smoke path. +use serde::{Deserialize, Deserializer, Serialize}; +use uuid::Uuid; + +/// Schema version emitted by the initial private machine-readable contract. pub const CONTRACT_SCHEMA_VERSION: u32 = 1; /// A store-qualified game identity such as `steam:620` or `native:supertuxkart`. -#[derive(Clone, Debug, Eq, Hash, PartialEq)] +#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[serde(try_from = "String", into = "String")] pub struct GameId { namespace: String, value: String, } impl GameId { - /// Returns the provider/store namespace. + /// Returns the canonical store/source namespace. #[must_use] pub fn namespace(&self) -> &str { &self.namespace } - /// Returns the provider-owned identity value. + /// Returns the store-owned identity value. #[must_use] pub fn value(&self) -> &str { &self.value @@ -33,6 +38,12 @@ impl fmt::Display for GameId { } } +impl From for String { + fn from(id: GameId) -> Self { + id.to_string() + } +} + /// Why a game identity could not be parsed. #[derive(Clone, Debug, Eq, PartialEq)] pub enum GameIdError { @@ -40,20 +51,19 @@ pub enum GameIdError { MissingSeparator, /// The namespace is empty or contains unsupported characters. InvalidNamespace, - /// The provider-owned value is empty or contains control characters. + /// The store-owned value is empty, padded, or contains control characters. InvalidValue, } impl fmt::Display for GameIdError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - let message = match self { + formatter.write_str(match self { Self::MissingSeparator => "game ID must contain a ':' separator", Self::InvalidNamespace => "game ID namespace must be lowercase ASCII", Self::InvalidValue => { - "game ID value must be non-empty and contain no control characters" + "game ID value must be non-empty, unpadded, and contain no control characters" } - }; - formatter.write_str(message) + }) } } @@ -64,14 +74,10 @@ impl FromStr for GameId { fn from_str(raw: &str) -> Result { let (namespace, value) = raw.split_once(':').ok_or(GameIdError::MissingSeparator)?; - if namespace.is_empty() - || !namespace - .bytes() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') - { + if !valid_contract_name(namespace) { return Err(GameIdError::InvalidNamespace); } - if value.is_empty() || value.chars().any(char::is_control) { + if !valid_text(value) { return Err(GameIdError::InvalidValue); } Ok(Self { @@ -80,16 +86,513 @@ impl FromStr for GameId { }) } } + +impl TryFrom for GameId { + type Error = GameIdError; + + fn try_from(raw: String) -> Result { + raw.parse() + } +} + +/// A lowercase contract identifier used for stores, adapters, owners, backends, and runtimes. +#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[serde(try_from = "String", into = "String")] +pub struct ContractName(String); + +impl ContractName { + /// Returns the validated identifier. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for ContractName { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl From for String { + fn from(name: ContractName) -> Self { + name.0 + } +} + +impl FromStr for ContractName { + type Err = ContractNameError; + + fn from_str(raw: &str) -> Result { + if valid_contract_name(raw) { + Ok(Self(raw.to_owned())) + } else { + Err(ContractNameError) + } + } +} + +impl TryFrom for ContractName { + type Error = ContractNameError; + + fn try_from(raw: String) -> Result { + raw.parse() + } +} + +/// A contract name was empty or contained unsupported characters. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ContractNameError; + +impl fmt::Display for ContractNameError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("contract name must be lowercase ASCII") + } +} + +impl std::error::Error for ContractNameError {} + +/// A non-empty, unpadded UTF-8 value without control characters. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(try_from = "String", into = "String")] +pub struct ContractText(String); + +impl ContractText { + /// Returns the validated text. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl From for String { + fn from(text: ContractText) -> Self { + text.0 + } +} + +impl FromStr for ContractText { + type Err = ContractTextError; + + fn from_str(raw: &str) -> Result { + if valid_text(raw) { + Ok(Self(raw.to_owned())) + } else { + Err(ContractTextError) + } + } +} + +impl TryFrom for ContractText { + type Error = ContractTextError; + + fn try_from(raw: String) -> Result { + raw.parse() + } +} + +/// Contract text was empty, padded, or contained control characters. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ContractTextError; + +impl fmt::Display for ContractTextError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str( + "contract text must be non-empty, unpadded, and contain no control characters", + ) + } +} + +impl std::error::Error for ContractTextError {} + +/// The persistent identity of one installed copy of a game. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize)] +#[serde(transparent)] +pub struct InstallationId(Uuid); + +impl InstallationId { + /// Wraps an existing UUID. + #[must_use] + pub const fn new(id: Uuid) -> Self { + Self(id) + } + + /// Returns the underlying UUID. + #[must_use] + pub const fn as_uuid(&self) -> &Uuid { + &self.0 + } +} + +impl fmt::Display for InstallationId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } +} + +impl FromStr for InstallationId { + type Err = uuid::Error; + + fn from_str(raw: &str) -> Result { + Uuid::parse_str(raw).map(Self) + } +} + +impl<'de> Deserialize<'de> for InstallationId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = String::deserialize(deserializer)?; + raw.parse().map_err(serde::de::Error::custom) + } +} + +/// A validated absolute installation path with no lexical traversal components. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(try_from = "String", into = "String")] +pub struct InstallPath(PathBuf); + +impl InstallPath { + /// Returns the validated path. + #[must_use] + pub fn as_path(&self) -> &Path { + &self.0 + } +} + +impl From for String { + fn from(path: InstallPath) -> Self { + path.0 + .into_os_string() + .into_string() + .expect("InstallPath is validated as UTF-8") + } +} + +impl TryFrom for InstallPath { + type Error = InstallPathError; + + fn try_from(raw: String) -> Result { + if raw.chars().any(char::is_control) { + return Err(InstallPathError); + } + if raw.split('/').any(|part| matches!(part, "." | "..")) { + return Err(InstallPathError); + } + let path = PathBuf::from(raw); + if !path.is_absolute() + || path + .components() + .any(|component| matches!(component, Component::CurDir | Component::ParentDir)) + { + return Err(InstallPathError); + } + Ok(Self(path)) + } +} + +/// An installation path was relative, non-normalized, or contained control characters. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct InstallPathError; + +impl fmt::Display for InstallPathError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str( + "install path must be absolute, normalized, and contain no control characters", + ) + } +} + +impl std::error::Error for InstallPathError {} + +/// A compatibility layer associated with an installation. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Compatibility { + /// Compatibility implementation category, such as `proton` or `wine`. + #[serde(rename = "type")] + pub kind: ContractName, + /// Provider-visible compatibility version. + pub version: ContractText, +} + +/// A capability advertised by an adapter or normalized record. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Capability { + /// Enumerate installed games and metadata. + Discover, + /// Produce or execute a launch plan. + Launch, + /// Return installation and runtime information. + Info, + /// Open the provider's management interface. + Manage, + /// Launches do not require the provider UI. + UiOptional, + /// A provider client is required. + ClientRequired, + /// The adapter can report lifecycle state. + LifecycleState, + /// The adapter can safely request client exit. + ClientExit, + /// Deferred installation capability. + Install, + /// Deferred update capability. + Update, + /// Deferred verification capability. + Verify, + /// Deferred authentication capability. + Authenticate, + /// Deferred removal capability. + Uninstall, +} + +/// Validated data used to construct a version 1 normalized game record. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GameRecordInput { + /// Store-qualified game identity. + pub id: GameId, + /// Human-readable game name. + pub name: ContractText, + /// Canonical store/source namespace. + pub store: ContractName, + /// Adapter responsible for discovery and launch planning. + pub adapter: ContractName, + /// Persistent installed-copy identity. + pub installation_id: InstallationId, + /// Owner of mutable provider configuration. + pub configuration_owner: ContractName, + /// Client or runtime path used for execution. + pub execution_backend: ContractName, + /// Whether this installation is currently available. + pub installed: bool, + /// Validated installation location. + pub install_path: InstallPath, + /// Optional compatibility-layer details. + pub compatibility: Option, + /// Advertised behavior for this record. + pub capabilities: Vec, + /// Whether normal launch requires a provider client. + pub client_required: bool, + /// Selected compatibility or execution runtime. + pub runtime: ContractName, +} + +/// Version 1 normalized machine-readable game record. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct GameRecordV1 { + schema_version: u32, + id: GameId, + name: ContractText, + store: ContractName, + adapter: ContractName, + installation_id: InstallationId, + configuration_owner: ContractName, + execution_backend: ContractName, + installed: bool, + install_path: InstallPath, + compatibility: Option, + capabilities: Vec, + client_required: bool, + runtime: ContractName, +} + +impl GameRecordV1 { + /// Constructs a record after enforcing cross-field invariants. + /// + /// # Errors + /// + /// Returns [`GameRecordError::StoreMismatch`] when the record's store and game ID + /// namespace differ. + pub fn new(input: GameRecordInput) -> Result { + if input.store.as_str() != input.id.namespace() { + return Err(GameRecordError::StoreMismatch); + } + Ok(Self { + schema_version: CONTRACT_SCHEMA_VERSION, + id: input.id, + name: input.name, + store: input.store, + adapter: input.adapter, + installation_id: input.installation_id, + configuration_owner: input.configuration_owner, + execution_backend: input.execution_backend, + installed: input.installed, + install_path: input.install_path, + compatibility: input.compatibility, + capabilities: input.capabilities, + client_required: input.client_required, + runtime: input.runtime, + }) + } + + /// Returns the schema version carried by the record. + #[must_use] + pub const fn schema_version(&self) -> u32 { + self.schema_version + } + + /// Returns the game identity. + #[must_use] + pub const fn id(&self) -> &GameId { + &self.id + } + + /// Returns the installation identity. + #[must_use] + pub const fn installation_id(&self) -> InstallationId { + self.installation_id + } + + /// Returns the canonical store/source namespace. + #[must_use] + pub const fn store(&self) -> &ContractName { + &self.store + } + + /// Returns the adapter identifier. + #[must_use] + pub const fn adapter(&self) -> &ContractName { + &self.adapter + } + + /// Returns the configuration owner identifier. + #[must_use] + pub const fn configuration_owner(&self) -> &ContractName { + &self.configuration_owner + } + + /// Returns the execution backend identifier. + #[must_use] + pub const fn execution_backend(&self) -> &ContractName { + &self.execution_backend + } + + /// Returns the runtime identifier. + #[must_use] + pub const fn runtime(&self) -> &ContractName { + &self.runtime + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct WireGameRecord { + schema_version: u32, + id: GameId, + name: ContractText, + store: ContractName, + adapter: ContractName, + installation_id: InstallationId, + configuration_owner: ContractName, + execution_backend: ContractName, + installed: bool, + install_path: InstallPath, + compatibility: Option, + capabilities: Vec, + client_required: bool, + runtime: ContractName, +} + +impl<'de> Deserialize<'de> for GameRecordV1 { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let wire = WireGameRecord::deserialize(deserializer)?; + if wire.schema_version != CONTRACT_SCHEMA_VERSION { + return Err(serde::de::Error::custom( + GameRecordError::UnsupportedSchemaVersion(wire.schema_version), + )); + } + Self::new(GameRecordInput { + id: wire.id, + name: wire.name, + store: wire.store, + adapter: wire.adapter, + installation_id: wire.installation_id, + configuration_owner: wire.configuration_owner, + execution_backend: wire.execution_backend, + installed: wire.installed, + install_path: wire.install_path, + compatibility: wire.compatibility, + capabilities: wire.capabilities, + client_required: wire.client_required, + runtime: wire.runtime, + }) + .map_err(serde::de::Error::custom) + } +} + +/// A normalized record violated a version or cross-field contract. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GameRecordError { + /// The input schema version is not supported by this record type. + UnsupportedSchemaVersion(u32), + /// The record's store does not match the namespace in its game ID. + StoreMismatch, +} + +impl fmt::Display for GameRecordError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnsupportedSchemaVersion(version) => { + write!( + formatter, + "unsupported game record schema version: {version}" + ) + } + Self::StoreMismatch => formatter.write_str("game ID namespace must match store"), + } + } +} + +impl std::error::Error for GameRecordError {} + +fn valid_contract_name(raw: &str) -> bool { + !raw.is_empty() + && raw + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') +} + +fn valid_text(raw: &str) -> bool { + !raw.is_empty() && raw.trim() == raw && !raw.chars().any(char::is_control) +} + #[cfg(test)] mod tests { - use super::{GameId, GameIdError}; + use serde_json::{Value, json}; + + use super::{CONTRACT_SCHEMA_VERSION, GameId, GameIdError, GameRecordV1, InstallationId}; + + fn record_json() -> Value { + json!({ + "schema_version": 1, + "id": "steam:1716740", + "name": "Starfield", + "store": "steam", + "adapter": "steam", + "installation_id": "7aa62d69-8f8e-4cab-8514-275ce1f87748", + "configuration_owner": "steam", + "execution_backend": "steam", + "installed": true, + "install_path": "/games/steam/steamapps/common/Starfield", + "compatibility": {"type": "proton", "version": "GE-Proton10-8"}, + "capabilities": ["discover", "launch", "client-required"], + "client_required": true, + "runtime": "steam-proton" + }) + } #[test] - fn accepts_store_qualified_identity() { - let id: GameId = "steam:620".parse().expect("valid ID"); - assert_eq!(id.namespace(), "steam"); - assert_eq!(id.value(), "620"); - assert_eq!(id.to_string(), "steam:620"); + fn accepts_documented_store_qualified_identities() { + for raw in ["steam:620", "epic:Salt", "native:openmw"] { + let id: GameId = raw.parse().expect("valid ID"); + assert_eq!(id.to_string(), raw); + } } #[test] @@ -98,10 +601,84 @@ mod tests { } #[test] - fn rejects_shell_hostile_namespace() { + fn rejects_hostile_or_padded_identity_parts() { assert_eq!( "steam;touch:620".parse::(), Err(GameIdError::InvalidNamespace) ); + assert_eq!( + "steam: 620".parse::(), + Err(GameIdError::InvalidValue) + ); + } + + #[test] + fn rejects_malformed_installation_uuid() { + assert!("not-a-uuid".parse::().is_err()); + } + + #[test] + fn round_trips_the_normalized_json_record() { + let input = record_json(); + let record: GameRecordV1 = serde_json::from_value(input.clone()).expect("valid record"); + assert_eq!(record.schema_version(), CONTRACT_SCHEMA_VERSION); + assert_eq!(record.id().to_string(), "steam:1716740"); + assert_eq!(record.store().as_str(), "steam"); + assert_eq!(record.adapter().as_str(), "steam"); + assert_eq!(record.configuration_owner().as_str(), "steam"); + assert_eq!(record.execution_backend().as_str(), "steam"); + assert_eq!(record.runtime().as_str(), "steam-proton"); + assert_eq!(serde_json::to_value(record).expect("serializable"), input); + } + + #[test] + fn rejects_unsupported_schema_version() { + let mut input = record_json(); + input["schema_version"] = json!(2); + let error = serde_json::from_value::(input).expect_err("unsupported"); + assert!( + error + .to_string() + .contains("unsupported game record schema version: 2") + ); + } + + #[test] + fn rejects_missing_required_fields() { + let mut input = record_json(); + input.as_object_mut().expect("object").remove("adapter"); + assert!(serde_json::from_value::(input).is_err()); + } + + #[test] + fn rejects_unknown_fields() { + let mut input = record_json(); + input["future_field"] = json!(true); + assert!(serde_json::from_value::(input).is_err()); + } + + #[test] + fn rejects_control_characters_and_unsafe_paths() { + let mut control = record_json(); + control["name"] = json!("bad\nname"); + assert!(serde_json::from_value::(control).is_err()); + + for path in ["relative/game", "/games/../private/game", "/games/./game"] { + let mut input = record_json(); + input["install_path"] = json!(path); + assert!(serde_json::from_value::(input).is_err()); + } + } + + #[test] + fn rejects_store_and_identity_namespace_mismatch() { + let mut input = record_json(); + input["store"] = json!("epic"); + let error = serde_json::from_value::(input).expect_err("mismatch"); + assert!( + error + .to_string() + .contains("game ID namespace must match store") + ); } } diff --git a/deny.toml b/deny.toml index 92adc3b..975f145 100644 --- a/deny.toml +++ b/deny.toml @@ -11,6 +11,10 @@ version = 2 confidence-threshold = 0.8 allow = [ "AGPL-3.0-or-later", + "Apache-2.0", + "MIT", + "Unicode-3.0", + "Unlicense", ] exceptions = [] diff --git a/docs/phase-0/source-inventory.toml b/docs/phase-0/source-inventory.toml index 9b2f8bf..0b3cdf5 100644 --- a/docs/phase-0/source-inventory.toml +++ b/docs/phase-0/source-inventory.toml @@ -10,6 +10,39 @@ owner = "project maintainers" update_policy = "explicit pull request after release and security review" release_profile = false +[[components]] +name = "serde" +kind = "runtime-library" +source = "https://crates.io/crates/serde" +version = "1.0.228" +license = "MIT OR Apache-2.0" +owner = "project maintainers" +update_policy = "explicit contract change after changelog, license, and audit review" +release_profile = true + +[[components]] +name = "serde_json" +kind = "runtime-library" +source = "https://crates.io/crates/serde_json" +version = "1.0.150" +license = "MIT OR Apache-2.0" +owner = "project maintainers" +update_policy = "explicit contract change after changelog, license, and audit review" +release_profile = true + +[[components]] +name = "uuid" +kind = "runtime-library" +source = "https://crates.io/crates/uuid" +version = "1.24.0" +license = "MIT OR Apache-2.0" +owner = "project maintainers" +update_policy = "explicit identity-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. + [[components]] name = "cargo-deny" kind = "development-audit-tool" diff --git a/docs/phase-0/traceability.csv b/docs/phase-0/traceability.csv index 438242d..6d397d7 100644 --- a/docs/phase-0/traceability.csv +++ b/docs/phase-0/traceability.csv @@ -7,10 +7,10 @@ R-005,7,provider contract and acceptance tests,planned R-006,2,CLI integration tests,planned R-007,8,gaming-tool integration tests,planned R-008,8,session lifecycle acceptance test,planned -R-009,1,core contract tests,planned +R-009,1,Phase 1A normalized-record contract tests,in-progress R-010,5,TTY recovery acceptance test,planned R-011,0,development workflow record,active -R-012,1,shared-backend architecture tests,planned +R-012,1,Phase 1A role-separation contract tests,in-progress 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 @@ -22,10 +22,9 @@ 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,adversarial contract tests,planned +R-024,1,Phase 1A identifier and path adversarial tests,in-progress 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,TOML and JSON contract tests,planned - +R-029,1,Phase 1A versioned JSON contract tests,in-progress diff --git a/docs/phase-1/evidence-contracts-2026-07-18.md b/docs/phase-1/evidence-contracts-2026-07-18.md new file mode 100644 index 0000000..e390740 --- /dev/null +++ b/docs/phase-1/evidence-contracts-2026-07-18.md @@ -0,0 +1,16 @@ +# Phase 1A contract evidence + +- Date: 2026-07-18 +- Requirement IDs: R-009, R-012, R-024, R-029 +- Phase/gate: phase 1 / identity and normalized-record contracts +- Scope: provider-independent game identity, installation identity, role separation, + versioned JSON records, and lexical installation-path validation +- Exclusions: persistence, adapters, launch plans, configuration precedence, providers, + and process execution +- Commands: `make verify`; `make audit`; human and JSON `kiln doctor` smoke paths +- Expected result: contract and adversarial tests pass; unsupported schemas and malformed + input are rejected; existing smoke behavior remains unchanged +- Observed result: 10 contract and adversarial tests passed; Rustfmt, workspace/all-target + compilation, Clippy with warnings denied, cargo-deny advisories/bans/licenses/sources, + and both doctor smoke paths passed +- Result: pass diff --git a/scripts/audit_workspace.py b/scripts/audit_workspace.py index bb03cbe..5c3fc7e 100755 --- a/scripts/audit_workspace.py +++ b/scripts/audit_workspace.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Audit the dependency-free bootstrap, licensing, and source inventory.""" +"""Audit workspace dependencies, licensing, and source inventory.""" from __future__ import annotations @@ -43,12 +43,6 @@ def main() -> None: if license_hash != EXPECTED_LICENSE_SHA256: fail(f"root LICENSE is not the canonical AGPL-3.0 text: {license_hash}") - lock = load(ROOT / "Cargo.lock") - external = [package for package in lock["package"] if "source" in package] - if external: - names = [f"{item['name']}@{item['version']}" for item in external] - fail(f"unreviewed external Rust dependencies entered the bootstrap: {names}") - inventory = load(ROOT / "docs" / "phase-0" / "source-inventory.toml") components = {item["name"]: item for item in inventory["components"]} toolchain = load(ROOT / "rust-toolchain.toml")["toolchain"]["channel"] @@ -57,6 +51,31 @@ def main() -> None: if components.get("cargo-deny", {}).get("version") != "0.20.2": fail("cargo-deny is absent or not pinned to the reviewed version") + core_manifest = load(ROOT / "crates" / "kiln-core" / "Cargo.toml") + expected_dependencies = { + "serde": "=1.0.228", + "serde_json": "=1.0.150", + "uuid": "=1.24.0", + } + dependencies = core_manifest.get("dependencies", {}) + for name, version in expected_dependencies.items(): + configured = dependencies.get(name, {}) + 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("="): + fail(f"{name} source inventory does not match its manifest version") + + lock = load(ROOT / "Cargo.lock") + external = [package for package in lock["package"] if "source" in package] + unexpected_sources = [ + f"{item['name']}@{item['version']}" + for item in external + if item["source"] != "registry+https://github.com/rust-lang/crates.io-index" + ] + if unexpected_sources: + fail(f"unreviewed external Rust sources entered the workspace: {unexpected_sources}") + load(ROOT / "deny.toml") print("PASS: workspace dependency, license, and source baseline")