pinready 0.27.5

Cross-platform configurator and launcher supporting the adoption of Visual Pinball 10.8.1 — for testers and early adopters, not a replacement for a stable production setup
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
//! VBS patching pipeline for VPX Standalone compatibility.
//!
//! Some VPX tables use VBScript idioms that worked under the legacy
//! Windows-only `vpinball.exe` (which used the native `vbscript.dll`)
//! but break on the Wine-derived VBScript reimplementation embedded
//! in VPX Standalone 10.8+ — and that reimplementation is the same on
//! Linux, macOS *and* Windows, so these bugs hit every Standalone
//! user. PinReady consumes its catalog from
//! [Le-Syl21/vpx-standalone-scripts](https://github.com/Le-Syl21/vpx-standalone-scripts),
//! a curated fork of upstream `jsm174/vpx-standalone-scripts` that
//! integrates patches jsm174 won't accept (e.g. `vpminit me` overlay
//! for the B2S polling race, vpinball/vpinball#1783) plus pending
//! community PRs. At scan time we fingerprint each table's embedded
//! VBS, consult the catalog, and — when a patched version exists —
//! install it as a sidecar `.vbs` alongside the `.vpx`. VPX auto-loads
//! sidecar scripts in preference to the embedded one.
//!
//! See also: src/db.rs (`vbs_catalog`, `vbs_patches` tables).
//!
//! This module only concerns itself with the catalog fetcher. The
//! extraction + classification logic lives in a later commit.

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;

/// Process-wide ureq agent — see `mediadb::http_agent` for rationale.
/// Connection pooling matters even more here because the catalog has
/// 586 entries to download in the worst case.
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()
    })
}

/// AsciiSet covering everything that is NOT safe inside a URL path
/// segment: spaces + the standard RFC 3986 "query" delimiters + the
/// few characters GitHub's CDN occasionally rejects. Alphanumerics,
/// `-._~`, and RFC 3986 sub-delims (including `(`, `)`, `'`) are left
/// as-is — GitHub serves them fine, and re-encoding them would break
/// the few catalog URLs that rely on literal parens.
const URL_PATH_SEGMENT: &AsciiSet = &CONTROLS
    .add(b' ')
    .add(b'"')
    .add(b'#')
    .add(b'<')
    .add(b'>')
    .add(b'?')
    .add(b'`')
    .add(b'{')
    .add(b'}');

/// GitHub repo coordinates. PinReady consumes its catalog from our
/// curated fork (`Le-Syl21/vpx-standalone-scripts`) — diverged from
/// upstream `jsm174/vpx-standalone-scripts` so we can integrate
/// patches jsm174 won't accept (e.g. `vpminit me` overlay for B2S
/// polling race, vpinball/vpinball#1783) and cherry-pick community
/// PRs still pending upstream. Kept as constants so tests can
/// substitute fixtures if we ever add integration tests against a
/// mock server.
const REPO: &str = "Le-Syl21/vpx-standalone-scripts";
const BRANCH: &str = "master";

/// A single entry in `hashes.json`. We only decode the fields
/// we actually use — `serde(default)` keeps extra fields from breaking
/// the deserializer if upstream adds new ones.
#[derive(Debug, Clone, Deserialize)]
pub struct CatalogEntry {
    /// The ORIGINAL (unpatched) script's filename. Not strictly needed
    /// for matching (we key on sha256), but kept so log output can
    /// reference the human-readable name when we classify a table.
    #[allow(dead_code)]
    pub file: String,
    /// SHA256 of the original script — matched against embedded VBS.
    pub sha256: String,
    /// The patched script we'd install as a sidecar.
    pub patched: CatalogPatchedInfo,
}

#[derive(Debug, Clone, Deserialize)]
pub struct CatalogPatchedInfo {
    /// Filename in the catalog repo. Informational — we don't use it
    /// for lookup. Kept for parity with the upstream JSON shape.
    #[allow(dead_code)]
    pub file: String,
    /// SHA256 of the patched file — verified post-download.
    pub sha256: String,
    /// Raw URL on github.com. Downloaded as-is and written to the
    /// table's sidecar path after SHA verification.
    pub url: String,
}

/// Fetch the SHA of master's HEAD commit. Cheap call (~200 bytes of
/// JSON) so we can gate `hashes.json` re-download on it.
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")
}

/// Fetch `hashes.json` from master, or from the user-configured mirror
/// when `base_url` is set. The mirror server already rewrites every
/// `patched.url` field inside the JSON to point back at itself, so
/// nothing else changes on the client side once we land on the right
/// `hashes.json`. Returns the raw JSON string; the caller stores it
/// verbatim in `vbs_catalog.hashes_json` so future parse tweaks don't
/// require a re-fetch.
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")?;
    // Validate it parses as JSON before caching — avoids storing a
    // corrupt/redirected response.
    let _: serde_json::Value =
        serde_json::from_str(&body).context("hashes.json is not valid JSON")?;
    Ok(body)
}

/// Parse a cached `hashes_json` blob into the typed catalog list.
/// Kept separate from the fetch so offline-booted PinReady can still
/// drive classification off the cached payload.
pub fn parse_catalog(hashes_json: &str) -> Result<Vec<CatalogEntry>> {
    serde_json::from_str(hashes_json).context("Failed to parse cached hashes.json")
}

/// Lowercase hex SHA256 of arbitrary bytes. The catalog `hashes.json`
/// uses 64-char lowercase hex, so we match that convention directly.
///
/// Manual byte-per-byte hex encode instead of `format!("{:x}", …)` on the
/// digest: since sha2 0.11 the digest returned by `finalize()` is a
/// `hybrid_array::Array<u8, N>` (renamed from the old `GenericArray`) which
/// no longer implements `LowerHex`. Adding the `hex` crate for a one-liner
/// wasn't worth it — this loop is unrolled and inlined by the compiler.
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
}

/// Read the VBScript embedded inside a `.vpx` file. Uses the `vpin`
/// crate (same one we use for backglass image extraction) — no
/// external tool required. Returns the script as a UTF-8 `String`;
/// `vpin` stores it that way internally, and the catalog's hashes are
/// computed over the same UTF-8 byte stream that `-extractvbs` writes
/// to disk, so our hash can be compared directly without any
/// normalization step.
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)
}

/// The scanner's verdict for a single table. Bound tightly to the
/// action the patch-applier will take — we classify once, act once,
/// record once in `vbs_patches`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PatchDecision {
    /// Embedded VBS hash is unknown to the catalog — either the table
    /// is fine as-is, or it has a standalone incompat that hasn't been
    /// catalogued. Either way, we do nothing. DB status: `NotInCatalog`.
    NotInCatalog,

    /// A sidecar `.vbs` already exists and matches the catalog's
    /// `patched.sha256`. Nothing to do. DB status: `AlreadyPatched`.
    AlreadyPatched,

    /// No sidecar yet — download and install the patched version.
    /// DB status (on success): `Applied`.
    NoSidecar {
        patched_url: String,
        patched_sha: String,
    },

    /// Sidecar exists but matches the embedded (= catalog's original)
    /// hash — redundant copy, user did `-extractvbs` but never patched.
    /// Safe to delete before installing the patched version, since the
    /// bytes live inside the `.vpx` and can be re-extracted any time.
    /// DB status (on success): `Applied`.
    SidecarIsRedundant {
        patched_url: String,
        patched_sha: String,
    },

    /// Sidecar exists with content that matches neither the original
    /// nor the patched version — the user has customized it. We rename
    /// it to `<table>.pre_standalone.vbs` (VPX ignores that name) and
    /// install the patched version, so the user gets a working table
    /// and keeps their backup. DB status (on success): `CustomPreserved`.
    SidecarIsCustom {
        patched_url: String,
        patched_sha: String,
    },
}

/// Classification output passed through the scan pipeline. Holds the
/// raw SHAs so the caller can persist them in `vbs_patches` regardless
/// of whether any file operation happens.
#[derive(Debug, Clone)]
pub struct Classification {
    pub embedded_sha: String,
    pub sidecar_sha: Option<String>,
    pub decision: PatchDecision,
}

/// Classify a single table's VBS state against the catalog. Pure
/// function over inputs (reads the `.vpx` + optional sidecar from disk,
/// but makes no network calls and does not mutate anything). Split
/// from the apply step so unit tests can drive the decision matrix
/// with synthetic hashes.
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,
    })
}

/// Pure decision function — no I/O. Extracted so we can unit-test the
/// decision matrix exhaustively without faking `.vpx` files on disk.
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,
        },
    }
}

/// DB `status` strings — kept in sync with the `PatchDecision` variants
/// and documented in the `vbs_patches` schema comment. Use these
/// constants rather than stringifying enum names so we have a single
/// source of truth if we rename anything.
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";
}

/// DB status string for a given decision — before considering apply
/// outcome. Use `status::FAILED` explicitly when `apply_patch` errors.
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,
    }
}

/// Percent-encode each `/`-separated segment of a URL's path so spaces
/// and similar unsafe characters inside the table-folder name don't
/// trip up the HTTP client. Catalog filenames frequently contain
/// spaces ("AC-DC LUCI Premium VR") and those reach us raw in the
/// catalog's `url` field. ureq's HTTP client refuses a raw space in
/// the request line and returns a "malformed URL" error — we pre-
/// encode to avoid that.
///
/// Input is expected to be `scheme://host/path`; the scheme+host are
/// passed through verbatim, only path segments are encoded.
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);
    // Split path from query/fragment — encode only the path.
    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}")
}

/// Rewrite a byte buffer so every lone `\n` (not already preceded by
/// `\r`) becomes `\r\n`. Single-pass, preserves existing CRLF.
///
/// Required because the catalog's `hashes.json` hashes are computed
/// over the CRLF version of each `.vbs` file — the repo's
/// `.gitattributes` has `*.vbs text eol=crlf`, so the Linux CI
/// checkout materializes files as CRLF before `sha256sum` runs.
/// GitHub's raw CDN however serves files as they are stored
/// internally (LF), so without normalization our downloaded bytes
/// never match the catalog's declared `patched.sha256`. As a bonus,
/// the installed sidecar ends up in CRLF — the traditional,
/// Windows-native format for `.vbs`.
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
}

/// Download the patched `.vbs` from the catalog repo + verify its
/// SHA256 against `expected_sha`. Fails loudly (returns `Err`) on
/// network failure or hash mismatch — callers must not write anything
/// to disk in either case. Catalog files are small (a few KB each),
/// so we buffer the whole body in memory before hashing. Bytes are
/// normalized LF→CRLF before hashing so the SHA matches the
/// catalog's recorded value (see `normalize_to_crlf`).
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)
}

/// Apply the classifier's verdict to disk. Atomic for the file that
/// actually matters (the sidecar): we download + verify in memory
/// first, so disk state is never half-written. The `SidecarIsCustom`
/// branch preserves the user's original as `<table>.pre_standalone.vbs`
/// (a filename VPX does NOT auto-load) before installing the patch —
/// the user keeps their backup and gets a working table.
///
/// Network + hash verification happen before any mutation, so a failed
/// download leaves the table's existing state untouched.
pub fn apply_patch(vpx_path: &Path, decision: &PatchDecision) -> Result<()> {
    // Fast path: nothing to do.
    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()),
    };

    // Step 1: fetch + verify. No disk ops until this succeeds.
    let bytes = download_and_verify(url, expected_sha)?;

    let sidecar = vpx_path.with_extension("vbs");

    // Step 2: if the user has a CUSTOM sidecar, preserve it. Redundant
    // sidecars (= embedded) don't need preservation — the bytes live
    // inside the .vpx and can be recovered via `-extractvbs`.
    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()
        );
    }

    // Step 3: write patched bytes. For SidecarIsRedundant, this
    // overwrites the redundant copy in a single syscall — no explicit
    // delete needed.
    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(())
}

/// Refresh the cached catalog if the upstream commit has moved. Strategy:
///
/// 1. Pull current master HEAD SHA (~200 bytes).
/// 2. Compare with the cached `last_commit_sha`.
/// 3. If they differ (or no cache exists), fetch + persist `hashes.json`.
/// 4. Otherwise, leave the cache intact.
///
/// Network failures during step 1 or 3 are logged but non-fatal — the
/// caller keeps using the last-known catalog, which is the right
/// behavior for an offline pincab.
///
/// Returns `Ok(())` on any outcome where the cache is usable (either
/// fresh or previously-populated). Returns `Err` only on DB errors.
///
/// Two staleness-check strategies depending on whether the user
/// configured a mirror:
///   * Direct GitHub: fetch the commits API to get the latest commit
///     SHA, compare to the cached one, only download `hashes.json` if
///     they differ. Cheap (~200 bytes API response) but rate-limited
///     to 60 req/h unauthenticated.
///   * Mirror: GitHub commits API is not exposed by the mirror server.
///     Fetch `hashes.json` directly (it's small enough), use a hash of
///     its body as the cache identity. The mirror runs `git pull` once
///     an hour, so worst-case staleness is bounded.
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 => {
            // GitHub direct: cheap commit-SHA check first.
            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) => {
            // Mirror: download hashes.json and SHA it ourselves as the
            // cache identity. No commit-API hit needed.
            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() {
        // The catalog might add fields later — we must not reject the
        // whole catalog because of one unrecognized key.
        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() {
        // GitHub raw CDN serves these fine without encoding; keep verbatim
        // so we don't double-encode characters the catalog URLs already accept.
        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() {
        // Some \n, some \r\n — result is all \r\n.
        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() {
        // A bare \r (classic Mac style, extremely rare) stays bare.
        // Not something the catalog produces; just a sanity check.
        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);
        // %20 should remain %20, not become %2520.
        assert_eq!(out, already);
    }

    #[test]
    fn sha256_matches_known_vector() {
        // "abc" → SHA-256 from the test vector appendix of FIPS 180-4.
        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() {
        // Sidecar is a byte-identical copy of the embedded (= original)
        // VBS. Safe to delete because the bytes are preserved in the .vpx.
        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() {
        // Sidecar matches neither original nor patched — user has
        // customized it. We patch and rename their backup so the table
        // works instead of staying broken.
        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() {
        // Sanity: if two entries share the same `sha256` (shouldn't
        // happen but let's not explode), we pick the first one.
        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() {
        // No network, no file I/O — must return Ok immediately.
        let tmp = tempfile::tempdir().unwrap();
        let vpx = tmp.path().join("Foo.vpx");
        // apply_patch only touches `vpx.with_extension("vbs")` — we
        // don't need a real .vpx for this branch.
        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();
        // Untouched.
        let after = std::fs::read(&sidecar).unwrap();
        assert_eq!(after, b"already-patched-body");
    }

    #[test]
    fn apply_patch_preserves_custom_sidecar_on_rename_step() {
        // We can't easily unit-test the full download path without
        // standing up a local HTTP server. What we CAN verify is that
        // the rename step happens BEFORE any write — so if we craft a
        // decision whose download will fail (bogus URL), the sidecar
        // must remain untouched.
        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();
        // Error from the download step.
        assert!(
            format!("{err:#}").contains("Failed to GET") || format!("{err:#}").contains("nope"),
            "unexpected error: {err:#}"
        );
        // CRITICAL: sidecar is untouched because we short-circuit
        // before mutating anything.
        let after = std::fs::read(&sidecar).unwrap();
        assert_eq!(after, b"user-custom-content");
        assert!(!vpx.with_extension("pre_standalone.vbs").exists());
    }
}