feat: add versioned game identity contracts
baseline / verify (push) Has been cancelled

This commit is contained in:
2026-07-18 18:29:03 -04:00
parent afc6bf57a4
commit 514f87dacb
9 changed files with 916 additions and 36 deletions
+599 -22
View File
@@ -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")
);
}
}