feat: complete native execution and Steam discovery
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user