use anyhow::{Context, Result};
use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::io::Read;
use std::path::Path;
use std::sync::OnceLock;
use std::time::Duration;
fn http_agent() -> &'static ureq::Agent {
static AGENT: OnceLock<ureq::Agent> = OnceLock::new();
AGENT.get_or_init(|| {
ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_secs(30)))
.build()
.new_agent()
})
}
const URL_PATH_SEGMENT: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'#')
.add(b'<')
.add(b'>')
.add(b'?')
.add(b'`')
.add(b'{')
.add(b'}');
const REPO: &str = "Le-Syl21/vpx-standalone-scripts";
const BRANCH: &str = "master";
#[derive(Debug, Clone, Deserialize)]
pub struct CatalogEntry {
#[allow(dead_code)]
pub file: String,
pub sha256: String,
pub patched: CatalogPatchedInfo,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CatalogPatchedInfo {
#[allow(dead_code)]
pub file: String,
pub sha256: String,
pub url: String,
}
pub fn fetch_latest_commit_sha() -> Result<String> {
let url = format!("https://api.github.com/repos/{REPO}/commits/{BRANCH}");
let response = http_agent()
.get(&url)
.header("User-Agent", "PinReady")
.header("Accept", "application/vnd.github.v3+json")
.call()
.context("Failed to query VBS catalog latest commit")?;
let body = response.into_body().read_to_string()?;
let json: serde_json::Value =
serde_json::from_str(&body).context("Failed to parse commit JSON")?;
json["sha"]
.as_str()
.map(|s| s.to_string())
.context("Missing 'sha' in commit response")
}
pub fn fetch_hashes_json(base_url: Option<&str>) -> Result<String> {
let url = match base_url {
Some(base) => format!("{base}/vpx-standalone-scripts/hashes.json"),
None => format!("https://raw.githubusercontent.com/{REPO}/refs/heads/{BRANCH}/hashes.json"),
};
let response = http_agent()
.get(&url)
.header("User-Agent", "PinReady")
.call()
.context("Failed to fetch VBS catalog hashes.json")?;
let body = response
.into_body()
.read_to_string()
.context("Failed to read hashes.json body")?;
let _: serde_json::Value =
serde_json::from_str(&body).context("hashes.json is not valid JSON")?;
Ok(body)
}
pub fn parse_catalog(hashes_json: &str) -> Result<Vec<CatalogEntry>> {
serde_json::from_str(hashes_json).context("Failed to parse cached hashes.json")
}
pub fn sha256_hex(bytes: &[u8]) -> String {
use std::fmt::Write;
let mut hasher = Sha256::new();
hasher.update(bytes);
let digest = hasher.finalize();
let mut out = String::with_capacity(digest.len() * 2);
for b in digest {
let _ = write!(out, "{b:02x}");
}
out
}
pub fn extract_embedded_vbs(vpx_path: &Path) -> Result<String> {
let mut vpx = vpin::vpx::open(vpx_path)
.with_context(|| format!("Failed to open .vpx: {}", vpx_path.display()))?;
let gamedata = vpx
.read_gamedata()
.with_context(|| format!("Failed to read GameData from {}", vpx_path.display()))?;
Ok(gamedata.code.string)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PatchDecision {
NotInCatalog,
AlreadyPatched,
NoSidecar {
patched_url: String,
patched_sha: String,
},
SidecarIsRedundant {
patched_url: String,
patched_sha: String,
},
SidecarIsCustom {
patched_url: String,
patched_sha: String,
},
}
#[derive(Debug, Clone)]
pub struct Classification {
pub embedded_sha: String,
pub sidecar_sha: Option<String>,
pub decision: PatchDecision,
}
pub fn classify(vpx_path: &Path, catalog: &[CatalogEntry]) -> Result<Classification> {
let embedded = extract_embedded_vbs(vpx_path)?;
let embedded_sha = sha256_hex(embedded.as_bytes());
let sidecar_path = vpx_path.with_extension("vbs");
let sidecar_sha = if sidecar_path.is_file() {
let bytes = std::fs::read(&sidecar_path)
.with_context(|| format!("Failed to read sidecar {}", sidecar_path.display()))?;
Some(sha256_hex(&bytes))
} else {
None
};
let decision = decide(&embedded_sha, sidecar_sha.as_deref(), catalog);
Ok(Classification {
embedded_sha,
sidecar_sha,
decision,
})
}
pub fn decide(
embedded_sha: &str,
sidecar_sha: Option<&str>,
catalog: &[CatalogEntry],
) -> PatchDecision {
let Some(entry) = catalog.iter().find(|e| e.sha256 == embedded_sha) else {
return PatchDecision::NotInCatalog;
};
let patched_url = entry.patched.url.clone();
let patched_sha = entry.patched.sha256.clone();
match sidecar_sha {
None => PatchDecision::NoSidecar {
patched_url,
patched_sha,
},
Some(sha) if sha == entry.patched.sha256 => PatchDecision::AlreadyPatched,
Some(sha) if sha == entry.sha256 => PatchDecision::SidecarIsRedundant {
patched_url,
patched_sha,
},
Some(_) => PatchDecision::SidecarIsCustom {
patched_url,
patched_sha,
},
}
}
pub mod status {
pub const NOT_IN_CATALOG: &str = "NotInCatalog";
pub const ALREADY_PATCHED: &str = "AlreadyPatched";
pub const APPLIED: &str = "Applied";
pub const CUSTOM_PRESERVED: &str = "CustomPreserved";
pub const FAILED: &str = "Failed";
}
pub fn decision_status(decision: &PatchDecision) -> &'static str {
match decision {
PatchDecision::NotInCatalog => status::NOT_IN_CATALOG,
PatchDecision::AlreadyPatched => status::ALREADY_PATCHED,
PatchDecision::NoSidecar { .. } | PatchDecision::SidecarIsRedundant { .. } => {
status::APPLIED
}
PatchDecision::SidecarIsCustom { .. } => status::CUSTOM_PRESERVED,
}
}
fn encode_url(url: &str) -> String {
let Some((scheme_end, _)) = url.match_indices("://").next() else {
return url.to_string();
};
let (scheme, rest) = url.split_at(scheme_end + 3);
let Some(first_slash) = rest.find('/') else {
return url.to_string();
};
let (host, path_with_query) = rest.split_at(first_slash);
let (path, tail) = match path_with_query.find(['?', '#']) {
Some(i) => path_with_query.split_at(i),
None => (path_with_query, ""),
};
let encoded_path: String = path
.split('/')
.map(|seg| utf8_percent_encode(seg, URL_PATH_SEGMENT).to_string())
.collect::<Vec<_>>()
.join("/");
format!("{scheme}{host}{encoded_path}{tail}")
}
fn normalize_to_crlf(bytes: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(bytes.len() + bytes.len() / 50);
let mut prev = 0u8;
for &b in bytes {
if b == b'\n' && prev != b'\r' {
out.push(b'\r');
}
out.push(b);
prev = b;
}
out
}
pub fn download_and_verify(url: &str, expected_sha: &str) -> Result<Vec<u8>> {
let encoded = encode_url(url);
let response = http_agent()
.get(&encoded)
.header("User-Agent", "PinReady")
.call()
.with_context(|| format!("Failed to GET {url}"))?;
let mut raw = Vec::new();
response
.into_body()
.into_reader()
.read_to_end(&mut raw)
.context("Failed to read patched .vbs body")?;
let bytes = normalize_to_crlf(&raw);
let got = sha256_hex(&bytes);
if got != expected_sha {
anyhow::bail!("Patched VBS SHA mismatch: expected {expected_sha}, got {got} (url: {url})");
}
Ok(bytes)
}
pub fn apply_patch(vpx_path: &Path, decision: &PatchDecision) -> Result<()> {
let (url, expected_sha) = match decision {
PatchDecision::NotInCatalog | PatchDecision::AlreadyPatched => return Ok(()),
PatchDecision::NoSidecar {
patched_url,
patched_sha,
}
| PatchDecision::SidecarIsRedundant {
patched_url,
patched_sha,
}
| PatchDecision::SidecarIsCustom {
patched_url,
patched_sha,
} => (patched_url.as_str(), patched_sha.as_str()),
};
let bytes = download_and_verify(url, expected_sha)?;
let sidecar = vpx_path.with_extension("vbs");
if matches!(decision, PatchDecision::SidecarIsCustom { .. }) && sidecar.is_file() {
let backup = vpx_path.with_extension("pre_standalone.vbs");
std::fs::rename(&sidecar, &backup).with_context(|| {
format!(
"Failed to preserve existing sidecar as {}",
backup.display()
)
})?;
log::info!(
"vbs_patches: preserved custom sidecar as {}",
backup.display()
);
}
std::fs::write(&sidecar, &bytes)
.with_context(|| format!("Failed to write patched sidecar {}", sidecar.display()))?;
log::info!(
"vbs_patches: installed patched sidecar {}",
sidecar.display()
);
Ok(())
}
pub fn refresh_catalog_if_stale(db: &crate::db::Database) -> Result<()> {
let mirror = db.mirror_base_url();
let cached_sha = db.get_vbs_catalog().map(|(sha, _)| sha);
let (remote_sha, json) = match mirror.as_deref() {
None => {
let sha = match fetch_latest_commit_sha() {
Ok(s) => s,
Err(e) => {
log::warn!(
"vbs_patches: offline or GitHub unreachable ({e}); using cached catalog"
);
return Ok(());
}
};
if cached_sha.as_deref() == Some(sha.as_str()) {
log::debug!("vbs_patches: catalog up-to-date (commit {sha})");
return Ok(());
}
let body = match fetch_hashes_json(None) {
Ok(j) => j,
Err(e) => {
log::warn!("vbs_patches: failed to fetch hashes.json ({e}); keeping old cache");
return Ok(());
}
};
(sha, body)
}
Some(base) => {
let body = match fetch_hashes_json(Some(base)) {
Ok(j) => j,
Err(e) => {
log::warn!(
"vbs_patches: failed to fetch hashes.json from mirror ({e}); using cached catalog"
);
return Ok(());
}
};
let body_sha = sha256_hex(body.as_bytes());
if cached_sha.as_deref() == Some(body_sha.as_str()) {
log::debug!("vbs_patches: catalog up-to-date (mirror body sha {body_sha})");
return Ok(());
}
(body_sha, body)
}
};
log::info!(
"vbs_patches: refreshing catalog (cached={:?}, remote={})",
cached_sha.as_deref().unwrap_or("<none>"),
&remote_sha[..8.min(remote_sha.len())]
);
db.set_vbs_catalog(&remote_sha, &json)?;
log::info!(
"vbs_patches: catalog refreshed, {} bytes stored",
json.len()
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_catalog_happy_path() {
let sample = r#"[
{
"file": "Power Play (Bally 1977).vbs.original",
"sha256": "ef9b7f6c10256d74ce8928c056ff138807a7859c8a27b70e9e2f70f57fb61dfd",
"url": "https://raw.example/original.vbs",
"patched": {
"file": "Power Play (Bally 1977).vbs",
"sha256": "7fb40259669996f5bc14e64306aa9609e669a71bb459dee0a65f8bd8e9dd4d00",
"url": "https://raw.example/patched.vbs"
}
}
]"#;
let catalog = parse_catalog(sample).unwrap();
assert_eq!(catalog.len(), 1);
assert_eq!(catalog[0].sha256.len(), 64);
assert_eq!(catalog[0].patched.sha256.len(), 64);
assert!(catalog[0].patched.url.starts_with("https://"));
}
#[test]
fn parse_catalog_tolerates_unknown_fields() {
let sample = r#"[
{
"file": "Foo.vbs.original",
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
"url": "https://x/y",
"unknown_future_field": 42,
"patched": {
"file": "Foo.vbs",
"sha256": "1111111111111111111111111111111111111111111111111111111111111111",
"url": "https://x/y"
}
}
]"#;
let catalog = parse_catalog(sample).unwrap();
assert_eq!(catalog.len(), 1);
}
#[test]
fn parse_catalog_rejects_malformed() {
assert!(parse_catalog("not json").is_err());
assert!(parse_catalog("{}").is_err());
}
#[test]
fn encode_url_spaces_get_percent_encoded() {
let raw =
"https://raw.githubusercontent.com/foo/bar/master/AC-DC LUCI Premium VR/AC-DC LUCI.vbs";
let encoded = encode_url(raw);
assert!(
encoded.contains("AC-DC%20LUCI%20Premium%20VR"),
"got: {encoded}"
);
assert!(encoded.contains("AC-DC%20LUCI.vbs"), "got: {encoded}");
assert!(encoded.starts_with("https://raw.githubusercontent.com/foo/bar/"));
}
#[test]
fn encode_url_preserves_parens_apostrophes_unreserved() {
let raw = "https://x.example/folder/Baby's (2023) v1.0.vbs";
let encoded = encode_url(raw);
assert!(encoded.contains("Baby's"));
assert!(encoded.contains("(2023)"));
assert!(encoded.contains(".vbs"));
}
#[test]
fn encode_url_no_path_is_passthrough() {
assert_eq!(encode_url("https://example.com"), "https://example.com");
}
#[test]
fn normalize_to_crlf_adds_cr_before_lone_lf() {
assert_eq!(normalize_to_crlf(b"a\nb"), b"a\r\nb");
assert_eq!(normalize_to_crlf(b"line1\nline2\n"), b"line1\r\nline2\r\n");
}
#[test]
fn normalize_to_crlf_preserves_existing_crlf() {
assert_eq!(normalize_to_crlf(b"a\r\nb"), b"a\r\nb");
assert_eq!(
normalize_to_crlf(b"line1\r\nline2\r\n"),
b"line1\r\nline2\r\n"
);
}
#[test]
fn normalize_to_crlf_mixed_input() {
assert_eq!(normalize_to_crlf(b"a\nb\r\nc\nd"), b"a\r\nb\r\nc\r\nd");
}
#[test]
fn normalize_to_crlf_no_newlines_passthrough() {
assert_eq!(normalize_to_crlf(b"abc"), b"abc");
assert_eq!(normalize_to_crlf(b""), b"");
}
#[test]
fn normalize_to_crlf_lone_cr_untouched() {
assert_eq!(normalize_to_crlf(b"a\rb"), b"a\rb");
}
#[test]
fn encode_url_idempotent_on_already_encoded() {
let already = "https://x/foo/bar%20baz.vbs";
let out = encode_url(already);
assert_eq!(out, already);
}
#[test]
fn sha256_matches_known_vector() {
assert_eq!(
sha256_hex(b"abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
fn fake_entry(orig_sha: &str, patched_sha: &str) -> CatalogEntry {
CatalogEntry {
file: "Foo.vbs.original".into(),
sha256: orig_sha.into(),
patched: CatalogPatchedInfo {
file: "Foo.vbs".into(),
sha256: patched_sha.into(),
url: "https://example.test/Foo.vbs".into(),
},
}
}
#[test]
fn decide_not_in_catalog() {
let catalog = vec![fake_entry("aaaa", "bbbb")];
assert_eq!(decide("9999", None, &catalog), PatchDecision::NotInCatalog);
assert_eq!(
decide("9999", Some("anything"), &catalog),
PatchDecision::NotInCatalog
);
}
#[test]
fn decide_no_sidecar_triggers_download() {
let catalog = vec![fake_entry("aaaa", "bbbb")];
match decide("aaaa", None, &catalog) {
PatchDecision::NoSidecar {
patched_url,
patched_sha,
} => {
assert_eq!(patched_sha, "bbbb");
assert!(patched_url.ends_with("/Foo.vbs"));
}
other => panic!("expected NoSidecar, got {other:?}"),
}
}
#[test]
fn decide_already_patched_skips() {
let catalog = vec![fake_entry("aaaa", "bbbb")];
assert_eq!(
decide("aaaa", Some("bbbb"), &catalog),
PatchDecision::AlreadyPatched
);
}
#[test]
fn decide_sidecar_is_redundant_copy() {
let catalog = vec![fake_entry("aaaa", "bbbb")];
match decide("aaaa", Some("aaaa"), &catalog) {
PatchDecision::SidecarIsRedundant { patched_sha, .. } => {
assert_eq!(patched_sha, "bbbb");
}
other => panic!("expected SidecarIsRedundant, got {other:?}"),
}
}
#[test]
fn decide_custom_sidecar_gets_preserved() {
let catalog = vec![fake_entry("aaaa", "bbbb")];
match decide("aaaa", Some("custom_sha"), &catalog) {
PatchDecision::SidecarIsCustom { patched_sha, .. } => {
assert_eq!(patched_sha, "bbbb");
}
other => panic!("expected SidecarIsCustom, got {other:?}"),
}
}
#[test]
fn decide_scan_finds_first_matching_entry() {
let catalog = vec![
fake_entry("dup", "first_patch"),
fake_entry("dup", "second_patch"),
];
match decide("dup", None, &catalog) {
PatchDecision::NoSidecar { patched_sha, .. } => {
assert_eq!(patched_sha, "first_patch");
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn decision_status_strings_match_schema() {
let catalog = vec![fake_entry("a", "b")];
assert_eq!(
decision_status(&decide("x", None, &catalog)),
status::NOT_IN_CATALOG
);
assert_eq!(
decision_status(&decide("a", Some("b"), &catalog)),
status::ALREADY_PATCHED
);
assert_eq!(
decision_status(&decide("a", None, &catalog)),
status::APPLIED
);
assert_eq!(
decision_status(&decide("a", Some("a"), &catalog)),
status::APPLIED
);
assert_eq!(
decision_status(&decide("a", Some("custom"), &catalog)),
status::CUSTOM_PRESERVED
);
}
#[test]
fn apply_patch_noop_for_not_in_catalog() {
let tmp = tempfile::tempdir().unwrap();
let vpx = tmp.path().join("Foo.vpx");
apply_patch(&vpx, &PatchDecision::NotInCatalog).unwrap();
assert!(!vpx.with_extension("vbs").exists());
}
#[test]
fn apply_patch_noop_for_already_patched() {
let tmp = tempfile::tempdir().unwrap();
let vpx = tmp.path().join("Foo.vpx");
let sidecar = vpx.with_extension("vbs");
std::fs::write(&sidecar, b"already-patched-body").unwrap();
apply_patch(&vpx, &PatchDecision::AlreadyPatched).unwrap();
let after = std::fs::read(&sidecar).unwrap();
assert_eq!(after, b"already-patched-body");
}
#[test]
fn apply_patch_preserves_custom_sidecar_on_rename_step() {
let tmp = tempfile::tempdir().unwrap();
let vpx = tmp.path().join("Foo.vpx");
let sidecar = vpx.with_extension("vbs");
std::fs::write(&sidecar, b"user-custom-content").unwrap();
let decision = PatchDecision::SidecarIsCustom {
patched_url: "http://127.0.0.1:1/nope".to_string(),
patched_sha: "0".repeat(64),
};
let err = apply_patch(&vpx, &decision).unwrap_err();
assert!(
format!("{err:#}").contains("Failed to GET") || format!("{err:#}").contains("nope"),
"unexpected error: {err:#}"
);
let after = std::fs::read(&sidecar).unwrap();
assert_eq!(after, b"user-custom-content");
assert!(!vpx.with_extension("pre_standalone.vbs").exists());
}
}