feat: complete native execution and Steam discovery

This commit is contained in:
2026-07-18 20:11:52 -04:00
parent 514f87dacb
commit bc0090c63d
42 changed files with 6403 additions and 97 deletions
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "kiln-adapter-steam"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
kiln-core = { version = "=0.0.1", path = "../kiln-core" }
uuid = "=1.24.0"
[dev-dependencies]
uuid = { version = "=1.24.0", features = ["v4"] }
[lints]
workspace = true
+729
View File
@@ -0,0 +1,729 @@
//! Read-only Steam library discovery and normalization.
use std::collections::{BTreeMap, HashSet};
use std::env;
use std::fmt;
use std::fs::{self, File};
use std::io::Read;
use std::path::{Path, PathBuf};
use kiln_core::adapter::ProviderAdapter;
use kiln_core::contract_error::{AmbiguousGameCandidate, ContractErrorCode, ContractErrorV1};
use kiln_core::launch::LaunchPlanV1;
use kiln_core::{
Capability, ContractName, ContractText, GameId, GameRecordInput, GameRecordV1, InstallPath,
InstallationId,
};
use uuid::Uuid;
const MAX_VDF_BYTES: u64 = 1024 * 1024;
const MAX_LIBRARIES: usize = 128;
const MAX_MANIFESTS: usize = 8192;
const CAPABILITIES: &[Capability] = &[Capability::Discover, Capability::Info];
const RECORD_CAPABILITIES: &[Capability] = &[
Capability::Discover,
Capability::Info,
Capability::ClientRequired,
];
const INSTALLATION_NAMESPACE: u128 = 0x72d8_017f_93a9_47b7_0000_0000_0000_0000;
/// Read-only adapter backed by Steam's text library and app manifests.
#[derive(Clone, Debug)]
pub struct SteamAdapter {
name: ContractName,
root: PathBuf,
}
impl SteamAdapter {
/// Creates an adapter at an explicit Steam root.
#[must_use]
pub fn at(root: impl Into<PathBuf>) -> Self {
Self {
name: name("steam"),
root: root.into(),
}
}
/// Locates the conventional native or Flatpak Steam root for the current user.
///
/// # Errors
///
/// Returns a structured error when no absolute home directory is available.
pub fn for_current_user() -> Result<Self, ContractErrorV1> {
let home = env::var_os("HOME")
.map(PathBuf::from)
.filter(|path| path.is_absolute())
.ok_or_else(|| unavailable("no absolute home directory is available"))?;
let native = home.join(".local/share/Steam");
let flatpak = home.join(".var/app/com.valvesoftware.Steam/data/Steam");
let root = if native.is_dir() { native } else { flatpak };
Ok(Self::at(root))
}
/// Returns matching records in deterministic App ID order.
///
/// # Errors
///
/// Returns a structured error when the query or Steam metadata is invalid.
pub fn search(&self, query: &str) -> Result<Vec<GameRecordV1>, ContractErrorV1> {
validate_selector(query)?;
let needle = query.to_lowercase();
Ok(self
.discover()?
.into_iter()
.filter(|record| {
record.id().to_string().contains(&needle)
|| record.name().as_str().to_lowercase().contains(&needle)
})
.collect())
}
fn library_paths(&self) -> Result<Vec<PathBuf>, ContractErrorV1> {
if !self.root.is_absolute() {
return Err(invalid("Steam root must be absolute"));
}
let metadata = match fs::symlink_metadata(&self.root) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => return Err(unavailable(format!("inspect Steam root: {error}"))),
};
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err(invalid("Steam root must be a real directory"));
}
let input = read_text(
&self.root.join("steamapps/libraryfolders.vdf"),
"library list",
)?;
let document = parse_vdf(&input)?;
let folders = object(&document, "libraryfolders")?;
if folders.len() > MAX_LIBRARIES {
return Err(invalid("Steam library count exceeds 128"));
}
let mut paths = Vec::new();
for value in folders.values() {
let VdfValue::Object(folder) = value else {
continue;
};
let Some(VdfValue::Text(raw)) = folder.get("path") else {
continue;
};
let path: InstallPath = raw
.clone()
.try_into()
.map_err(|_| invalid("Steam library path is invalid"))?;
paths.push(path.as_path().to_path_buf());
}
paths.sort();
paths.dedup();
Ok(paths)
}
fn records(&self) -> Result<Vec<GameRecordV1>, ContractErrorV1> {
let mut records = Vec::new();
let mut ids = HashSet::new();
for library in self.library_paths()? {
let metadata = match fs::symlink_metadata(&library) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(unavailable(format!("inspect Steam library: {error}"))),
};
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err(invalid("Steam library must be a real directory"));
}
let steamapps = library.join("steamapps");
let metadata = match fs::symlink_metadata(&steamapps) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(unavailable(format!("inspect steamapps: {error}"))),
};
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err(invalid("steamapps must be a real directory"));
}
let mut manifests = Vec::new();
for entry in fs::read_dir(&steamapps)
.map_err(|error| unavailable(format!("read steamapps: {error}")))?
{
let path = entry
.map_err(|error| unavailable(format!("read steamapps entry: {error}")))?
.path();
let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
continue;
};
if file_name.starts_with("appmanifest_")
&& path
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("acf"))
{
manifests.push(path);
}
if manifests.len() > MAX_MANIFESTS {
return Err(invalid("Steam manifest count exceeds 8192"));
}
}
manifests.sort();
for path in manifests {
if let Some(record) = load_manifest(&path, &steamapps)? {
if !ids.insert(record.id().clone()) {
return Err(invalid("duplicate installed Steam App ID"));
}
records.push(record);
}
}
}
records.sort_by_key(|record| record.id().to_string());
Ok(records)
}
}
impl ProviderAdapter for SteamAdapter {
fn name(&self) -> &ContractName {
&self.name
}
fn capabilities(&self) -> &[Capability] {
CAPABILITIES
}
fn discover(&self) -> Result<Vec<GameRecordV1>, ContractErrorV1> {
self.records()
}
fn resolve(&self, selector: &str) -> Result<GameRecordV1, ContractErrorV1> {
validate_selector(selector)?;
let records = self.records()?;
if selector.contains(':') {
let id: GameId = selector
.parse()
.map_err(|_| invalid("invalid Steam game ID"))?;
return records
.into_iter()
.find(|record| record.id() == &id)
.ok_or_else(|| not_found("Steam game was not found"));
}
let normalized = selector.to_lowercase();
let mut matches: Vec<_> = records
.into_iter()
.filter(|record| record.name().as_str().to_lowercase() == normalized)
.collect();
match matches.len() {
0 => Err(not_found("Steam game was not found")),
1 => Ok(matches.remove(0)),
_ => Err(ContractErrorV1::ambiguous(
text("Steam game name is ambiguous"),
matches
.into_iter()
.map(|record| AmbiguousGameCandidate {
id: record.id().clone(),
store: name("steam"),
installation_id: record.installation_id(),
label: record.name().clone(),
})
.collect(),
)),
}
}
fn plan_launch(&self, _game_id: &GameId) -> Result<LaunchPlanV1, ContractErrorV1> {
Err(ContractErrorV1::unsupported_capability(
text("Steam launch planning begins in Phase 4"),
name("launch"),
))
}
}
fn load_manifest(path: &Path, steamapps: &Path) -> Result<Option<GameRecordV1>, ContractErrorV1> {
let input = read_text(path, "app manifest")?;
let document = parse_vdf(&input)?;
let state = object(&document, "AppState")?;
let app_id = field(state, "appid")?;
let expected = format!("appmanifest_{app_id}.acf");
if path.file_name().and_then(|name| name.to_str()) != Some(&expected) {
return Err(invalid("Steam manifest filename and App ID differ"));
}
if app_id.is_empty() || !app_id.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(invalid("Steam App ID is invalid"));
}
let numeric_app_id: u64 = app_id
.parse()
.map_err(|_| invalid("Steam App ID exceeds 64 bits"))?;
let flags: u32 = field(state, "StateFlags")?
.parse()
.map_err(|_| invalid("Steam state flags are invalid"))?;
if flags & 4 == 0 {
return Ok(None);
}
let game_name = field(state, "name")?.trim();
// ponytail: text manifests lack app type; cover known runtime packages here and parse
// binary appinfo only if real libraries show this heuristic hiding or exposing games.
if is_component(app_id, game_name) {
return Ok(None);
}
let install_dir = field(state, "installdir")?;
if install_dir.is_empty()
|| install_dir.chars().any(char::is_control)
|| install_dir.contains(['/', '\\'])
|| matches!(install_dir, "." | "..")
{
return Err(invalid("Steam install directory is invalid"));
}
let path = steamapps.join("common").join(install_dir);
let metadata = match fs::symlink_metadata(&path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(unavailable(format!("inspect Steam install: {error}"))),
};
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err(invalid("Steam install path must be a real directory"));
}
let install_path: InstallPath = path
.to_str()
.ok_or_else(|| invalid("Steam install path is not UTF-8"))?
.to_owned()
.try_into()
.map_err(|_| invalid("Steam install path is invalid"))?;
let id: GameId = format!("steam:{app_id}")
.parse()
.map_err(|_| invalid("Steam App ID violated the game identity contract"))?;
let installation_id = InstallationId::new(Uuid::from_u128(
INSTALLATION_NAMESPACE | u128::from(numeric_app_id),
));
let record = GameRecordV1::new(GameRecordInput {
id,
name: game_name
.parse()
.map_err(|_| invalid(format!("Steam game name is invalid for App ID {app_id}")))?,
store: name("steam"),
adapter: name("steam"),
installation_id,
configuration_owner: name("steam"),
execution_backend: name("steam"),
installed: true,
install_path,
compatibility: None,
capabilities: RECORD_CAPABILITIES.to_vec(),
client_required: true,
runtime: name("steam-managed"),
})
.map_err(|_| invalid("Steam record violated its contract"))?;
Ok(Some(record))
}
fn is_component(app_id: &str, game_name: &str) -> bool {
app_id == "228980"
|| game_name.starts_with("Proton ")
|| game_name.starts_with("Steam Linux Runtime")
|| game_name == "Steamworks Common Redistributables"
}
fn read_text(path: &Path, label: &str) -> Result<String, ContractErrorV1> {
let metadata = fs::symlink_metadata(path)
.map_err(|error| unavailable(format!("read Steam {label} metadata: {error}")))?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err(invalid(format!("Steam {label} must be a regular file")));
}
if metadata.len() > MAX_VDF_BYTES {
return Err(invalid(format!("Steam {label} exceeds 1048576 bytes")));
}
let capacity = usize::try_from(metadata.len())
.map_err(|_| invalid(format!("Steam {label} is too large")))?;
let mut bytes = Vec::with_capacity(capacity);
File::open(path)
.and_then(|file| file.take(MAX_VDF_BYTES + 1).read_to_end(&mut bytes))
.map_err(|error| unavailable(format!("read Steam {label}: {error}")))?;
if bytes.len() as u64 > MAX_VDF_BYTES {
return Err(invalid(format!("Steam {label} exceeds 1048576 bytes")));
}
String::from_utf8(bytes).map_err(|_| invalid(format!("Steam {label} is not UTF-8")))
}
#[derive(Debug)]
enum VdfValue {
Text(String),
Object(BTreeMap<String, VdfValue>),
}
fn parse_vdf(input: &str) -> Result<BTreeMap<String, VdfValue>, ContractErrorV1> {
let mut parser = Parser { input, offset: 0 };
let object = parser.object(false)?;
parser.skip_space();
if parser.offset != input.len() {
return Err(invalid("Steam VDF has trailing data"));
}
Ok(object)
}
struct Parser<'a> {
input: &'a str,
offset: usize,
}
impl Parser<'_> {
fn object(&mut self, nested: bool) -> Result<BTreeMap<String, VdfValue>, ContractErrorV1> {
let mut values = BTreeMap::new();
loop {
self.skip_space();
if nested && self.consume('}') {
return Ok(values);
}
if self.offset == self.input.len() {
return if nested {
Err(invalid("Steam VDF object is unterminated"))
} else {
Ok(values)
};
}
let key = self.string()?;
self.skip_space();
let value = if self.consume('{') {
VdfValue::Object(self.object(true)?)
} else {
VdfValue::Text(self.string()?)
};
if values.insert(key, value).is_some() {
return Err(invalid("Steam VDF contains a duplicate key"));
}
}
}
fn string(&mut self) -> Result<String, ContractErrorV1> {
self.skip_space();
if !self.consume('"') {
return Err(invalid("Steam VDF expected a quoted string"));
}
let mut value = String::new();
while self.offset < self.input.len() {
let character = self
.next()
.ok_or_else(|| invalid("Steam VDF string ended"))?;
match character {
'"' => return Ok(value),
'\\' => match self.next() {
Some('"') => value.push('"'),
Some('\\') => value.push('\\'),
Some('n') => value.push('\n'),
Some('t') => value.push('\t'),
_ => return Err(invalid("Steam VDF escape is invalid")),
},
value_character => value.push(value_character),
}
}
Err(invalid("Steam VDF string is unterminated"))
}
fn skip_space(&mut self) {
loop {
let remaining = &self.input[self.offset..];
if remaining.starts_with("//") {
self.offset += remaining.find('\n').unwrap_or(remaining.len());
} else if remaining.chars().next().is_some_and(char::is_whitespace) {
self.next();
} else {
break;
}
}
}
fn consume(&mut self, expected: char) -> bool {
if self.input[self.offset..].starts_with(expected) {
self.offset += expected.len_utf8();
true
} else {
false
}
}
fn next(&mut self) -> Option<char> {
let character = self.input[self.offset..].chars().next()?;
self.offset += character.len_utf8();
Some(character)
}
}
fn object<'a>(
values: &'a BTreeMap<String, VdfValue>,
key: &str,
) -> Result<&'a BTreeMap<String, VdfValue>, ContractErrorV1> {
match values.get(key) {
Some(VdfValue::Object(value)) => Ok(value),
_ => Err(invalid(format!("Steam VDF is missing {key}"))),
}
}
fn field<'a>(
values: &'a BTreeMap<String, VdfValue>,
key: &str,
) -> Result<&'a str, ContractErrorV1> {
match values.get(key) {
Some(VdfValue::Text(value)) => Ok(value),
_ => Err(invalid(format!("Steam app manifest is missing {key}"))),
}
}
fn validate_selector(selector: &str) -> Result<(), ContractErrorV1> {
if selector.is_empty() || selector.trim() != selector || selector.chars().any(char::is_control)
{
Err(invalid("game selector is invalid"))
} else {
Ok(())
}
}
fn name(raw: &str) -> ContractName {
raw.parse().expect("static contract name")
}
fn text(raw: &str) -> ContractText {
raw.parse().expect("static contract text")
}
fn invalid(message: impl fmt::Display) -> ContractErrorV1 {
error(ContractErrorCode::InvalidInput, message)
}
fn unavailable(message: impl fmt::Display) -> ContractErrorV1 {
error(ContractErrorCode::TemporarilyUnavailable, message)
}
fn not_found(message: &str) -> ContractErrorV1 {
ContractErrorV1::new(ContractErrorCode::NotFound, text(message))
}
fn error(code: ContractErrorCode, message: impl fmt::Display) -> ContractErrorV1 {
let cleaned: String = message
.to_string()
.chars()
.map(|character| {
if character.is_control() {
' '
} else {
character
}
})
.collect();
ContractErrorV1::new(
code,
cleaned
.trim()
.parse()
.unwrap_or_else(|_| text("Steam operation failed")),
)
}
#[cfg(test)]
mod tests {
use std::fmt::Write as _;
use std::fs;
use std::os::unix::fs::symlink;
use kiln_core::Capability;
use kiln_core::adapter::ProviderAdapter;
use kiln_core::contract_error::ContractErrorCode;
use uuid::Uuid;
use super::SteamAdapter;
struct TestDirectory(PathBuf);
use std::path::PathBuf;
impl TestDirectory {
fn new() -> Self {
let path = std::env::temp_dir().join(format!("kiln-steam-test-{}", Uuid::new_v4()));
fs::create_dir(&path).unwrap();
Self(path)
}
fn library(&self, name: &str) -> PathBuf {
let path = self.0.join(name);
fs::create_dir_all(path.join("steamapps/common")).unwrap();
path
}
fn folders(&self, libraries: &[&Path]) {
let mut input = String::from("\"libraryfolders\"\n{\n");
for (index, library) in libraries.iter().enumerate() {
writeln!(
input,
"\"{index}\" {{ \"path\" \"{}\" }}",
library.display()
)
.unwrap();
}
input.push_str("}\n");
fs::create_dir_all(self.0.join("steamapps")).unwrap();
fs::write(self.0.join("steamapps/libraryfolders.vdf"), input).unwrap();
}
fn manifest(library: &Path, app_id: &str, game_name: &str, install: &str) {
fs::create_dir_all(library.join("steamapps/common").join(install)).unwrap();
fs::write(
library
.join("steamapps")
.join(format!("appmanifest_{app_id}.acf")),
format!(
"\"AppState\" {{ \"appid\" \"{app_id}\" \"name\" \"{game_name}\" \"StateFlags\" \"4\" \"installdir\" \"{install}\" }}"
),
)
.unwrap();
}
}
impl Drop for TestDirectory {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
use std::path::Path;
#[test]
fn discovers_searches_and_normalizes_installed_games_without_launch() {
let directory = TestDirectory::new();
let library = directory.library("Library With Spaces");
directory.folders(&[&library]);
TestDirectory::manifest(&library, "620", "Portal 2 ", "Portal 2");
let adapter = SteamAdapter::at(&directory.0);
let records = adapter.discover().unwrap();
assert_eq!(records.len(), 1);
let record = &records[0];
assert_eq!(record.id().to_string(), "steam:620");
assert_eq!(record.name().as_str(), "Portal 2");
assert_eq!(record.store().as_str(), "steam");
assert_eq!(record.runtime().as_str(), "steam-managed");
assert!(record.installed());
assert!(record.client_required());
assert_eq!(
record.capabilities(),
[
Capability::Discover,
Capability::Info,
Capability::ClientRequired
]
);
assert_eq!(adapter.search("portal").unwrap().len(), 1);
assert_eq!(adapter.resolve("Portal 2").unwrap().id(), record.id());
assert_eq!(
adapter.plan_launch(record.id()).unwrap_err().code(),
ContractErrorCode::UnsupportedCapability
);
}
#[test]
fn skips_unmounted_partial_and_known_runtime_entries() {
let directory = TestDirectory::new();
let library = directory.library("library");
let missing = directory.0.join("not-mounted");
directory.folders(&[&library, &missing]);
TestDirectory::manifest(
&library,
"1826330",
"Proton EasyAntiCheat Runtime",
"Proton EAC",
);
fs::write(
library.join("steamapps/appmanifest_10.acf"),
"\"AppState\" { \"appid\" \"10\" \"name\" \"Partial\" \"StateFlags\" \"2\" \"installdir\" \"Partial\" }",
)
.unwrap();
assert!(
SteamAdapter::at(&directory.0)
.discover()
.unwrap()
.is_empty()
);
}
#[test]
fn stable_installation_identity_survives_library_moves() {
let directory = TestDirectory::new();
let first = directory.library("first");
directory.folders(&[&first]);
TestDirectory::manifest(&first, "620", "Portal 2", "Portal 2");
let adapter = SteamAdapter::at(&directory.0);
let before = adapter.discover().unwrap()[0].installation_id();
let second = directory.library("second");
TestDirectory::manifest(&second, "620", "Portal 2", "Moved Portal");
directory.folders(&[&second]);
let after = adapter.discover().unwrap()[0].installation_id();
assert_eq!(before, after);
}
#[test]
fn rejects_duplicate_traversal_non_utf8_oversize_and_symlinked_metadata() {
let directory = TestDirectory::new();
let first = directory.library("first");
let second = directory.library("second");
directory.folders(&[&first, &second]);
TestDirectory::manifest(&first, "620", "Portal 2", "Portal 2");
TestDirectory::manifest(&second, "620", "Portal 2", "Portal 2");
assert_eq!(
SteamAdapter::at(&directory.0)
.discover()
.unwrap_err()
.code(),
ContractErrorCode::InvalidInput
);
directory.folders(&[&first]);
fs::write(
first.join("steamapps/appmanifest_620.acf"),
"\"AppState\" { \"appid\" \"620\" \"name\" \"Portal\" \"StateFlags\" \"4\" \"installdir\" \"../escape\" }",
)
.unwrap();
assert_eq!(
SteamAdapter::at(&directory.0)
.discover()
.unwrap_err()
.code(),
ContractErrorCode::InvalidInput
);
fs::write(
first.join("steamapps/appmanifest_620.acf"),
"\"AppState\" { \"appid\" \"620\" \"StateFlags\" \"4\" \"installdir\" \"Portal 2\" }",
)
.unwrap();
assert_eq!(
SteamAdapter::at(&directory.0)
.discover()
.unwrap_err()
.code(),
ContractErrorCode::InvalidInput
);
fs::write(first.join("steamapps/appmanifest_620.acf"), [0xff]).unwrap();
assert_eq!(
SteamAdapter::at(&directory.0)
.discover()
.unwrap_err()
.code(),
ContractErrorCode::InvalidInput
);
fs::write(
first.join("steamapps/appmanifest_620.acf"),
vec![b'x'; 1024 * 1024 + 1],
)
.unwrap();
assert_eq!(
SteamAdapter::at(&directory.0)
.discover()
.unwrap_err()
.code(),
ContractErrorCode::InvalidInput
);
fs::remove_file(first.join("steamapps/appmanifest_620.acf")).unwrap();
let target = first.join("target.acf");
fs::write(&target, "\"AppState\" {}").unwrap();
symlink(&target, first.join("steamapps/appmanifest_620.acf")).unwrap();
assert_eq!(
SteamAdapter::at(&directory.0)
.discover()
.unwrap_err()
.code(),
ContractErrorCode::InvalidInput
);
}
}