This commit is contained in:
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user