Compare commits

..

2 Commits

Author SHA1 Message Date
44r0n7 514f87dacb feat: add versioned game identity contracts
baseline / verify (push) Has been cancelled
2026-07-18 18:29:03 -04:00
44r0n7 afc6bf57a4 fix: allow repeatable repository verification 2026-07-18 18:29:03 -04:00
10 changed files with 917 additions and 37 deletions
+2 -1
View File
@@ -10,9 +10,10 @@ relevant schema version is advanced.
- Private Project Kiln Phase 0 workspace and evidence baseline. - Private Project Kiln Phase 0 workspace and evidence baseline.
- Dependency-free core identity contract and `kiln doctor` smoke CLI. - 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 ### Corrected
- Separated the Rust 1.85 MSRV from the exact Rust 1.97.1 development toolchain pin. - 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. - Enforced the active compiler pin instead of assuming distro Cargo honors Rustup files.
Generated
+227
View File
@@ -2,6 +2,59 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 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]] [[package]]
name = "kiln-cli" name = "kiln-cli"
version = "0.0.1" version = "0.0.1"
@@ -12,4 +65,178 @@ dependencies = [
[[package]] [[package]]
name = "kiln-core" name = "kiln-core"
version = "0.0.1" 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"
+5 -1
View File
@@ -6,6 +6,10 @@ rust-version.workspace = true
license.workspace = true license.workspace = true
publish.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] [lints]
workspace = true workspace = true
+599 -22
View File
@@ -1,26 +1,31 @@
//! Shared, provider-independent contracts for Project Kiln. //! Shared, provider-independent contracts for Project Kiln.
use std::fmt; use std::fmt;
use std::path::{Component, Path, PathBuf};
use std::str::FromStr; 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; pub const CONTRACT_SCHEMA_VERSION: u32 = 1;
/// A store-qualified game identity such as `steam:620` or `native:supertuxkart`. /// 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 { pub struct GameId {
namespace: String, namespace: String,
value: String, value: String,
} }
impl GameId { impl GameId {
/// Returns the provider/store namespace. /// Returns the canonical store/source namespace.
#[must_use] #[must_use]
pub fn namespace(&self) -> &str { pub fn namespace(&self) -> &str {
&self.namespace &self.namespace
} }
/// Returns the provider-owned identity value. /// Returns the store-owned identity value.
#[must_use] #[must_use]
pub fn value(&self) -> &str { pub fn value(&self) -> &str {
&self.value &self.value
@@ -33,6 +38,12 @@ impl fmt::Display for GameId {
} }
} }
impl From<GameId> for String {
fn from(id: GameId) -> Self {
id.to_string()
}
}
/// Why a game identity could not be parsed. /// Why a game identity could not be parsed.
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub enum GameIdError { pub enum GameIdError {
@@ -40,20 +51,19 @@ pub enum GameIdError {
MissingSeparator, MissingSeparator,
/// The namespace is empty or contains unsupported characters. /// The namespace is empty or contains unsupported characters.
InvalidNamespace, InvalidNamespace,
/// The provider-owned value is empty or contains control characters. /// The store-owned value is empty, padded, or contains control characters.
InvalidValue, InvalidValue,
} }
impl fmt::Display for GameIdError { impl fmt::Display for GameIdError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 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::MissingSeparator => "game ID must contain a ':' separator",
Self::InvalidNamespace => "game ID namespace must be lowercase ASCII", Self::InvalidNamespace => "game ID namespace must be lowercase ASCII",
Self::InvalidValue => { 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<Self, Self::Err> { fn from_str(raw: &str) -> Result<Self, Self::Err> {
let (namespace, value) = raw.split_once(':').ok_or(GameIdError::MissingSeparator)?; let (namespace, value) = raw.split_once(':').ok_or(GameIdError::MissingSeparator)?;
if namespace.is_empty() if !valid_contract_name(namespace) {
|| !namespace
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
{
return Err(GameIdError::InvalidNamespace); return Err(GameIdError::InvalidNamespace);
} }
if value.is_empty() || value.chars().any(char::is_control) { if !valid_text(value) {
return Err(GameIdError::InvalidValue); return Err(GameIdError::InvalidValue);
} }
Ok(Self { Ok(Self {
@@ -80,16 +86,513 @@ impl FromStr for GameId {
}) })
} }
} }
impl TryFrom<String> for GameId {
type Error = GameIdError;
fn try_from(raw: String) -> Result<Self, Self::Error> {
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<ContractName> for String {
fn from(name: ContractName) -> Self {
name.0
}
}
impl FromStr for ContractName {
type Err = ContractNameError;
fn from_str(raw: &str) -> Result<Self, Self::Err> {
if valid_contract_name(raw) {
Ok(Self(raw.to_owned()))
} else {
Err(ContractNameError)
}
}
}
impl TryFrom<String> for ContractName {
type Error = ContractNameError;
fn try_from(raw: String) -> Result<Self, Self::Error> {
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<ContractText> for String {
fn from(text: ContractText) -> Self {
text.0
}
}
impl FromStr for ContractText {
type Err = ContractTextError;
fn from_str(raw: &str) -> Result<Self, Self::Err> {
if valid_text(raw) {
Ok(Self(raw.to_owned()))
} else {
Err(ContractTextError)
}
}
}
impl TryFrom<String> for ContractText {
type Error = ContractTextError;
fn try_from(raw: String) -> Result<Self, Self::Error> {
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<Self, Self::Err> {
Uuid::parse_str(raw).map(Self)
}
}
impl<'de> Deserialize<'de> for InstallationId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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<InstallPath> for String {
fn from(path: InstallPath) -> Self {
path.0
.into_os_string()
.into_string()
.expect("InstallPath is validated as UTF-8")
}
}
impl TryFrom<String> for InstallPath {
type Error = InstallPathError;
fn try_from(raw: String) -> Result<Self, Self::Error> {
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<Compatibility>,
/// Advertised behavior for this record.
pub capabilities: Vec<Capability>,
/// 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<Compatibility>,
capabilities: Vec<Capability>,
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<Self, GameRecordError> {
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<Compatibility>,
capabilities: Vec<Capability>,
client_required: bool,
runtime: ContractName,
}
impl<'de> Deserialize<'de> for GameRecordV1 {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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)] #[cfg(test)]
mod tests { 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] #[test]
fn accepts_store_qualified_identity() { fn accepts_documented_store_qualified_identities() {
let id: GameId = "steam:620".parse().expect("valid ID"); for raw in ["steam:620", "epic:Salt", "native:openmw"] {
assert_eq!(id.namespace(), "steam"); let id: GameId = raw.parse().expect("valid ID");
assert_eq!(id.value(), "620"); assert_eq!(id.to_string(), raw);
assert_eq!(id.to_string(), "steam:620"); }
} }
#[test] #[test]
@@ -98,10 +601,84 @@ mod tests {
} }
#[test] #[test]
fn rejects_shell_hostile_namespace() { fn rejects_hostile_or_padded_identity_parts() {
assert_eq!( assert_eq!(
"steam;touch:620".parse::<GameId>(), "steam;touch:620".parse::<GameId>(),
Err(GameIdError::InvalidNamespace) Err(GameIdError::InvalidNamespace)
); );
assert_eq!(
"steam: 620".parse::<GameId>(),
Err(GameIdError::InvalidValue)
);
}
#[test]
fn rejects_malformed_installation_uuid() {
assert!("not-a-uuid".parse::<InstallationId>().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::<GameRecordV1>(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::<GameRecordV1>(input).is_err());
}
#[test]
fn rejects_unknown_fields() {
let mut input = record_json();
input["future_field"] = json!(true);
assert!(serde_json::from_value::<GameRecordV1>(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::<GameRecordV1>(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::<GameRecordV1>(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::<GameRecordV1>(input).expect_err("mismatch");
assert!(
error
.to_string()
.contains("game ID namespace must match store")
);
} }
} }
+4
View File
@@ -11,6 +11,10 @@ version = 2
confidence-threshold = 0.8 confidence-threshold = 0.8
allow = [ allow = [
"AGPL-3.0-or-later", "AGPL-3.0-or-later",
"Apache-2.0",
"MIT",
"Unicode-3.0",
"Unlicense",
] ]
exceptions = [] exceptions = []
+33
View File
@@ -10,6 +10,39 @@ owner = "project maintainers"
update_policy = "explicit pull request after release and security review" update_policy = "explicit pull request after release and security review"
release_profile = false 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]] [[components]]
name = "cargo-deny" name = "cargo-deny"
kind = "development-audit-tool" kind = "development-audit-tool"
+4 -5
View File
@@ -7,10 +7,10 @@ R-005,7,provider contract and acceptance tests,planned
R-006,2,CLI integration tests,planned R-006,2,CLI integration tests,planned
R-007,8,gaming-tool integration tests,planned R-007,8,gaming-tool integration tests,planned
R-008,8,session lifecycle acceptance test,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-010,5,TTY recovery acceptance test,planned
R-011,0,development workflow record,active 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-013,8,profile policy acceptance tests,planned
R-014,12,Pegasus adapter tests,post-v1 R-014,12,Pegasus adapter tests,post-v1
R-015,8,Lean and Ready policy tests,planned 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-021,11,installer destructive VM tests,planned
R-022,10,optional recovery profile tests,optional R-022,10,optional recovery profile tests,optional
R-023,14,OpenGamepadUI evaluation,post-v1 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-025,0,source inventory and release review,active
R-026,0,supported-platform record,active R-026,0,supported-platform record,active
R-027,0,path and identity architecture review,active R-027,0,path and identity architecture review,active
R-028,8,release GPU matrix,planned 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
1 requirement_id owner_phase initial_evidence status
7 R-006 2 CLI integration tests planned
8 R-007 8 gaming-tool integration tests planned
9 R-008 8 session lifecycle acceptance test planned
10 R-009 1 core contract tests Phase 1A normalized-record contract tests planned in-progress
11 R-010 5 TTY recovery acceptance test planned
12 R-011 0 development workflow record active
13 R-012 1 shared-backend architecture tests Phase 1A role-separation contract tests planned in-progress
14 R-013 8 profile policy acceptance tests planned
15 R-014 12 Pegasus adapter tests post-v1
16 R-015 8 Lean and Ready policy tests planned
22 R-021 11 installer destructive VM tests planned
23 R-022 10 optional recovery profile tests optional
24 R-023 14 OpenGamepadUI evaluation post-v1
25 R-024 1 adversarial contract tests Phase 1A identifier and path adversarial tests planned in-progress
26 R-025 0 source inventory and release review active
27 R-026 0 supported-platform record active
28 R-027 0 path and identity architecture review active
29 R-028 8 release GPU matrix planned
30 R-029 1 TOML and JSON contract tests Phase 1A versioned JSON contract tests planned in-progress
@@ -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
+26 -7
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Audit the dependency-free bootstrap, licensing, and source inventory.""" """Audit workspace dependencies, licensing, and source inventory."""
from __future__ import annotations from __future__ import annotations
@@ -43,12 +43,6 @@ def main() -> None:
if license_hash != EXPECTED_LICENSE_SHA256: if license_hash != EXPECTED_LICENSE_SHA256:
fail(f"root LICENSE is not the canonical AGPL-3.0 text: {license_hash}") 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") inventory = load(ROOT / "docs" / "phase-0" / "source-inventory.toml")
components = {item["name"]: item for item in inventory["components"]} components = {item["name"]: item for item in inventory["components"]}
toolchain = load(ROOT / "rust-toolchain.toml")["toolchain"]["channel"] 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": if components.get("cargo-deny", {}).get("version") != "0.20.2":
fail("cargo-deny is absent or not pinned to the reviewed version") 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") load(ROOT / "deny.toml")
print("PASS: workspace dependency, license, and source baseline") print("PASS: workspace dependency, license, and source baseline")
+1 -1
View File
@@ -75,7 +75,7 @@ def check_markdown_links() -> None:
def check_placeholders() -> None: def check_placeholders() -> None:
allowed_roots = {"crates", "docs", "scripts", ".github"} allowed_roots = {"crates", "docs", "scripts", "target", ".github"}
for child in ROOT.iterdir(): for child in ROOT.iterdir():
if child.is_dir() and not child.name.startswith(".") and child.name not in allowed_roots: if child.is_dir() and not child.name.startswith(".") and child.name not in allowed_roots:
fail(f"unexpected top-level directory: {child.name}") fail(f"unexpected top-level directory: {child.name}")