This commit is contained in:
+2
-1
@@ -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.
|
||||
|
||||
|
||||
Generated
+227
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+599
-22
@@ -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<GameId> 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<Self, Self::Err> {
|
||||
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<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)]
|
||||
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::<GameId>(),
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user