mirror of
https://github.com/st3fan/ewm.git
synced 2026-10-25 02:02:15 +00:00
Disk images over HTTP, downloaded once into a cache
A floppy, hard-drive or 3.5" image path may now be an http(s):// URL: ewm two --set 'machine:slots:6:drive1=https://example.com/Frogger.dsk' ewm/src/fetch.rs downloads it into $XDG_CACHE_HOME/ewm (else ~/.cache/ewm) / <sha1 of the URL> / <file name from the URL>, beside a meta.json holding the ETag and Last-Modified. The digest keys the directory because URLs are not filesystem-safe; the file keeps its own name so the cache is browsable and errors read normally. The hash is the crate's own SHA-1 (ws::sha1) — no dependency for that. Freshness is one *conditional* GET (If-None-Match / If-Modified-Since) rather than HEAD-then-GET: a 304 costs a single round trip. If the request fails and a copy is cached, the cached copy is used, so a machine assembled from URLs still boots offline; a cold cache with no server is a loud error instead. A URL is a source, not a path: resolve_paths leaves it alone (like builtin:), while referenced_files still counts it, so a built-in config may not carry one — builtins must run offline. Downloaded hard drives mount memory-only. This needed a real change, not just leaving the file handle unopened: in hdd.rs an unopenable file means WRITE PROTECTED, which is a different (and still supported) state for a local image. Hdd now carries an explicit `writeback` flag — false for downloads, so ProDOS writes update the in-memory image and never reach the cached file, and revalidating a download cannot destroy them. Floppies already behaved this way. Dependency: ureq (blocking + rustls), owner's decision — the one place the tiny-dependency budget gives way, because TLS is not something to hand-roll. It brings ~25 transitive crates including ring, so the headless server build now compiles native crypto too. Tests: the cache round trip against a real local HTTP server (cold download, conditional revalidation with a 304, offline fallback, and a cold cache with nothing listening failing loudly); URL naming and digest stability including degenerate URLs; memory-only vs write-back block writes at the register level; URLs surviving path resolution; and the wiring gate — a machine builds with DOS 3.3 served over HTTP into drive 1, which only passes if the mount path really fetched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3c7fe06776
commit
9971bfefbc
11 files changed
+856
-22
No files matched your search
Generated
+236
@@ -29,6 +29,12 @@ version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.0"
|
||||
@@ -41,6 +47,12 @@ version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.66"
|
||||
@@ -116,6 +128,7 @@ dependencies = [
|
||||
"sdl3",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -181,6 +194,17 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
@@ -192,6 +216,22 @@ dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
@@ -276,6 +316,12 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
@@ -326,12 +372,61 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rpkg-config"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a2d2f3481209a6b42eec2fbb49063fb4e8d35b57023401495d4fe0f85c817f0"
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.42"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
@@ -529,6 +624,12 @@ version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.118"
|
||||
@@ -552,12 +653,59 @@ version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "ureq"
|
||||
version = "3.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"flate2",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"ureq-proto",
|
||||
"utf8-zero",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ureq-proto"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf8-zero"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.126"
|
||||
@@ -603,6 +751,15 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
@@ -662,6 +819,85 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
|
||||
@@ -328,7 +328,26 @@ default machine has a Language Card (the classic 64K build), and
|
||||
`--set machine:slots:0:card=empty` — for a stock 48K machine.
|
||||
|
||||
Relative paths resolve against their file's directory, so a config —
|
||||
or an overlay — travels with its disks. The committed JSON Schemas
|
||||
or an overlay — travels with its disks.
|
||||
|
||||
A disk image can also be an **`http(s)://` URL**, so a config travels
|
||||
without its disks at all:
|
||||
|
||||
```
|
||||
cargo run --release -- two \
|
||||
--set 'machine:slots:6:drive1=https://example.com/disks/Frogger.dsk'
|
||||
```
|
||||
|
||||
The image is downloaded once into `$XDG_CACHE_HOME/ewm` (default
|
||||
`~/.cache/ewm`), keyed by a hash of the URL and keeping its own file
|
||||
name. Later boots send a conditional request and reuse the cached copy
|
||||
on a `304` — and if the server is unreachable, the cached copy is used
|
||||
anyway, so a downloaded machine still boots offline. Downloaded hard
|
||||
drives mount **memory-only**: ProDOS can write to them, but the writes
|
||||
stay in RAM (exactly as floppies behave) so refreshing the cache can
|
||||
never destroy them.
|
||||
|
||||
The committed JSON Schemas
|
||||
(`schema/ewm-config.schema.json` for complete configs,
|
||||
`schema/ewm-config-overlay.schema.json` for overlays) give editors
|
||||
validation and autocomplete via the `$schema` key;
|
||||
|
||||
@@ -11,6 +11,7 @@ fontdue = "0.9.3"
|
||||
sdl3 = { version = "0.18.4", default-features = false }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
ureq = "3.3.0"
|
||||
|
||||
[features]
|
||||
# Dev/CI builds link the Homebrew/system SDL3 via pkg-config (fast).
|
||||
|
||||
+36
-2
@@ -201,19 +201,23 @@ pub enum SlotCard {
|
||||
/// A Disk ][ controller with up to two drives.
|
||||
Diskii {
|
||||
/// Floppy image for drive 1 (`.dsk`, `.do`, `.po`, `.nib`, `.woz`).
|
||||
/// May be an `http(s)://` URL, downloaded once into the image
|
||||
/// cache and revalidated with ETag / Last-Modified thereafter.
|
||||
drive1: Option<String>,
|
||||
/// Floppy image for drive 2.
|
||||
drive2: Option<String>,
|
||||
},
|
||||
/// A ProDOS-compatible hard-drive controller.
|
||||
Harddrive {
|
||||
/// Block image (`.hdv`, `.po`).
|
||||
/// Block image (`.hdv`, `.po`). May be an `http(s)://` URL —
|
||||
/// a downloaded volume mounts memory-only, so ProDOS writes do
|
||||
/// not persist into the cache.
|
||||
image: String,
|
||||
},
|
||||
/// A UniDisk 3.5 Controller ("Liron") with up to two SmartPort 3.5"
|
||||
/// drives taking .2mg images of 400K or 800K.
|
||||
Liron {
|
||||
/// .2mg image for drive 1.
|
||||
/// .2mg image for drive 1. May be an `http(s)://` URL.
|
||||
drive1: Option<String>,
|
||||
/// .2mg image for drive 2.
|
||||
drive2: Option<String>,
|
||||
@@ -1326,6 +1330,11 @@ fn resolve_paths(config: &mut Config, base: &Path) {
|
||||
}
|
||||
|
||||
fn resolve(base: &Path, p: &mut String) {
|
||||
// An http(s) URL is a source, not a relative path (like builtin:) —
|
||||
// it is fetched into the cache when the machine is built.
|
||||
if crate::fetch::is_url(p) {
|
||||
return;
|
||||
}
|
||||
if Path::new(p).is_relative() {
|
||||
*p = base.join(&*p).to_string_lossy().into_owned();
|
||||
}
|
||||
@@ -1926,6 +1935,31 @@ mod tests {
|
||||
assert_eq!(aux.size.as_deref(), Some("1m"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_urls_are_sources_not_relative_paths() {
|
||||
// A disk image may be an http(s) URL: it must survive per-file
|
||||
// path resolution untouched (like builtin:), to be downloaded
|
||||
// into the cache when the machine is built.
|
||||
let config = parse(
|
||||
r#"{"machine": {"model": "2plus", "slots": {
|
||||
"6": {"card": "diskii", "drive1": "https://x.test/games/Frogger.dsk"},
|
||||
"7": {"card": "harddrive", "image": "http://x.test/Total%20Replay.hdv"}}}}"#,
|
||||
)
|
||||
.expect("URL media parses");
|
||||
let slots = config.machine.as_ref().unwrap().slots.as_ref().unwrap();
|
||||
let SlotCard::Diskii { drive1, .. } = &slots["6"] else {
|
||||
panic!("slot 6 should be a diskii");
|
||||
};
|
||||
assert_eq!(drive1.as_deref(), Some("https://x.test/games/Frogger.dsk"));
|
||||
let SlotCard::Harddrive { image } = &slots["7"] else {
|
||||
panic!("slot 7 should be a harddrive");
|
||||
};
|
||||
assert_eq!(image, "http://x.test/Total%20Replay.hdv");
|
||||
// A URL *is* an external reference, so a built-in config may not
|
||||
// carry one (builtins must run offline).
|
||||
assert_eq!(referenced_files(&config).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_paths_resolve_against_the_config_dir() {
|
||||
let config = parse(
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
//! Disk images over HTTP: a config may point a floppy or hard-drive
|
||||
//! image at an `http(s)://` URL instead of a local path. The image is
|
||||
//! downloaded once into a per-URL cache directory and revalidated with
|
||||
//! `ETag` / `Last-Modified` on later runs, so booting the same machine
|
||||
//! twice costs one conditional request (usually a 304), not a re-download.
|
||||
//!
|
||||
//! Cache layout — `$XDG_CACHE_HOME/ewm` (else `~/.cache/ewm`):
|
||||
//!
|
||||
//! ```text
|
||||
//! <root>/<sha1 of the URL>/Total Replay v6.0.1.hdv
|
||||
//! <root>/<sha1 of the URL>/meta.json # etag / last-modified
|
||||
//! ```
|
||||
//!
|
||||
//! The digest keys the directory (URLs are not filesystem-safe), while the
|
||||
//! file keeps its own name so the cache is browsable and error messages
|
||||
//! read normally. Offline is not fatal: if revalidation fails but a copy
|
||||
//! is cached, the cached copy is used.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Cap on a downloaded image: a ProDOS volume tops out at 32MB, so this
|
||||
/// is generous while still refusing to fill the disk with a bad URL.
|
||||
const MAX_IMAGE_BYTES: u64 = 64 * 1024 * 1024;
|
||||
|
||||
/// Is this source an HTTP(S) URL rather than a local path?
|
||||
pub fn is_url(src: &str) -> bool {
|
||||
src.starts_with("http://") || src.starts_with("https://")
|
||||
}
|
||||
|
||||
/// The local file a source names: a path is itself, a URL is its cached
|
||||
/// download (fetched now if the cache is cold, revalidated if warm).
|
||||
pub fn local_path(src: &str) -> Result<String, String> {
|
||||
if !is_url(src) {
|
||||
return Ok(src.to_string());
|
||||
}
|
||||
let root = cache_root()?;
|
||||
cached_download(&root, src)
|
||||
}
|
||||
|
||||
/// Where downloads live: `$XDG_CACHE_HOME/ewm`, else `~/.cache/ewm`.
|
||||
fn cache_root() -> Result<PathBuf, String> {
|
||||
if let Some(dir) = std::env::var_os("XDG_CACHE_HOME")
|
||||
&& !dir.is_empty()
|
||||
{
|
||||
return Ok(PathBuf::from(dir).join("ewm"));
|
||||
}
|
||||
let home = std::env::var_os("HOME")
|
||||
.ok_or("cannot cache downloads: neither XDG_CACHE_HOME nor HOME is set")?;
|
||||
Ok(PathBuf::from(home).join(".cache").join("ewm"))
|
||||
}
|
||||
|
||||
/// The cache directory a URL downloads into. Exposed so tests can clean
|
||||
/// up after themselves without guessing the layout.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn cache_dir_for(url: &str) -> Result<PathBuf, String> {
|
||||
Ok(cache_root()?.join(url_digest(url)))
|
||||
}
|
||||
|
||||
/// The URL's cache directory name: our own SHA-1, hex — stable across
|
||||
/// runs and filesystem-safe, where the URL is neither.
|
||||
fn url_digest(url: &str) -> String {
|
||||
crate::ws::sha1(url.as_bytes())
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The file name inside the cache directory: the URL's last path
|
||||
/// segment, minus any query or fragment, with separators refused. The
|
||||
/// digest directory already guarantees uniqueness, so this only has to
|
||||
/// be a sane, recognizable name.
|
||||
fn url_filename(url: &str) -> String {
|
||||
let without_query = url.split(['?', '#']).next().unwrap_or(url);
|
||||
// Drop the scheme, then everything up to the first slash — the
|
||||
// authority is not a file name (a URL with no path at all must not
|
||||
// end up named after its host).
|
||||
let after_scheme = without_query
|
||||
.split_once("://")
|
||||
.map_or(without_query, |(_, rest)| rest);
|
||||
let path = after_scheme
|
||||
.split_once('/')
|
||||
.map_or("", |(_authority, path)| path)
|
||||
.trim_end_matches('/');
|
||||
let name = path.rsplit('/').next().unwrap_or("");
|
||||
let name = percent_decode(name);
|
||||
let safe: String = name
|
||||
.chars()
|
||||
.filter(|c| !matches!(c, '/' | '\\' | '\0'))
|
||||
.collect();
|
||||
if safe.is_empty() || safe == "." || safe == ".." {
|
||||
"image".to_string()
|
||||
} else {
|
||||
safe
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode `%20`-style escapes so a cached file reads like its name.
|
||||
fn percent_decode(s: &str) -> String {
|
||||
let bytes = s.as_bytes();
|
||||
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%'
|
||||
&& i + 2 < bytes.len()
|
||||
&& let Ok(byte) = u8::from_str_radix(&s[i + 1..i + 3], 16)
|
||||
{
|
||||
out.push(byte);
|
||||
i += 3;
|
||||
} else {
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
/// The cache validators kept beside a download.
|
||||
#[derive(Default, serde::Serialize, serde::Deserialize)]
|
||||
struct Meta {
|
||||
#[serde(default)]
|
||||
etag: Option<String>,
|
||||
#[serde(default)]
|
||||
last_modified: Option<String>,
|
||||
/// The URL, for a human browsing the cache.
|
||||
#[serde(default)]
|
||||
url: Option<String>,
|
||||
}
|
||||
|
||||
/// What a conditional request came back with.
|
||||
enum Fetched {
|
||||
/// The server confirmed the cached copy is current.
|
||||
NotModified,
|
||||
Body {
|
||||
bytes: Vec<u8>,
|
||||
etag: Option<String>,
|
||||
last_modified: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Download `url` into `root`, reusing (and revalidating) a cached copy.
|
||||
/// Returns the path to the local file.
|
||||
fn cached_download(root: &Path, url: &str) -> Result<String, String> {
|
||||
let dir = root.join(url_digest(url));
|
||||
let file = dir.join(url_filename(url));
|
||||
let meta_path = dir.join("meta.json");
|
||||
let cached = file.is_file();
|
||||
let meta = if cached {
|
||||
std::fs::read_to_string(&meta_path)
|
||||
.ok()
|
||||
.and_then(|text| serde_json::from_str::<Meta>(&text).ok())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Meta::default()
|
||||
};
|
||||
|
||||
let name = || file.to_string_lossy().into_owned();
|
||||
match http_get(url, &meta) {
|
||||
Ok(Fetched::NotModified) if cached => Ok(name()),
|
||||
// A 304 with nothing cached would mean we sent validators we do
|
||||
// not have; treat it as a failed fetch rather than a phantom hit.
|
||||
Ok(Fetched::NotModified) => Err(format!("{url}: server said 304 with nothing cached")),
|
||||
Ok(Fetched::Body {
|
||||
bytes,
|
||||
etag,
|
||||
last_modified,
|
||||
}) => {
|
||||
std::fs::create_dir_all(&dir)
|
||||
.map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
|
||||
std::fs::write(&file, &bytes)
|
||||
.map_err(|e| format!("cannot write {}: {e}", file.display()))?;
|
||||
let meta = Meta {
|
||||
etag,
|
||||
last_modified,
|
||||
url: Some(url.to_string()),
|
||||
};
|
||||
if let Ok(text) = serde_json::to_string_pretty(&meta) {
|
||||
let _ = std::fs::write(&meta_path, text);
|
||||
}
|
||||
eprintln!("[EWM] downloaded {url} ({} bytes)", bytes.len());
|
||||
Ok(name())
|
||||
}
|
||||
// Offline (or the server is down) with a copy in hand: use it.
|
||||
Err(e) if cached => {
|
||||
eprintln!("[EWM] {e}; using the cached copy of {url}");
|
||||
Ok(name())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// One conditional GET: sends whatever validators the cache holds and
|
||||
/// reports either "still current" or the fresh body.
|
||||
fn http_get(url: &str, meta: &Meta) -> Result<Fetched, String> {
|
||||
// Statuses are data here, not errors: a 304 is the good case.
|
||||
let agent = ureq::Agent::config_builder()
|
||||
.http_status_as_error(false)
|
||||
.build()
|
||||
.new_agent();
|
||||
let mut request = agent.get(url);
|
||||
if let Some(etag) = &meta.etag {
|
||||
request = request.header("If-None-Match", etag);
|
||||
}
|
||||
if let Some(modified) = &meta.last_modified {
|
||||
request = request.header("If-Modified-Since", modified);
|
||||
}
|
||||
let mut response = request
|
||||
.call()
|
||||
.map_err(|e| format!("cannot fetch {url}: {e}"))?;
|
||||
|
||||
let status = response.status().as_u16();
|
||||
if status == 304 {
|
||||
return Ok(Fetched::NotModified);
|
||||
}
|
||||
if !(200..300).contains(&status) {
|
||||
return Err(format!("cannot fetch {url}: HTTP {status}"));
|
||||
}
|
||||
let header = |name: &str| {
|
||||
response
|
||||
.headers()
|
||||
.get(name)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_string)
|
||||
};
|
||||
let etag = header("etag");
|
||||
let last_modified = header("last-modified");
|
||||
let bytes = response
|
||||
.body_mut()
|
||||
.with_config()
|
||||
.limit(MAX_IMAGE_BYTES)
|
||||
.read_to_vec()
|
||||
.map_err(|e| format!("cannot read {url}: {e}"))?;
|
||||
Ok(Fetched::Body {
|
||||
bytes,
|
||||
etag,
|
||||
last_modified,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
|
||||
#[test]
|
||||
fn urls_are_recognized_and_paths_pass_through() {
|
||||
assert!(is_url("http://example.com/a.dsk"));
|
||||
assert!(is_url("https://example.com/a.dsk"));
|
||||
assert!(!is_url("/disks/a.dsk"));
|
||||
assert!(!is_url("builtin:WozMon"));
|
||||
assert!(!is_url("ftp://example.com/a.dsk"));
|
||||
// A local path is returned untouched, with no network involved.
|
||||
assert_eq!(local_path("/disks/a.dsk").unwrap(), "/disks/a.dsk");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_names_are_stable_and_filesystem_safe() {
|
||||
// The directory is the URL's digest: stable, and unaffected by
|
||||
// characters no filesystem would take.
|
||||
let a = url_digest("https://example.com/Total Replay.hdv");
|
||||
assert_eq!(a, url_digest("https://example.com/Total Replay.hdv"));
|
||||
assert_ne!(a, url_digest("https://example.com/other.hdv"));
|
||||
assert_eq!(a.len(), 40);
|
||||
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
|
||||
// The file keeps a recognizable name.
|
||||
assert_eq!(
|
||||
url_filename("https://x.test/games/Frogger.dsk"),
|
||||
"Frogger.dsk"
|
||||
);
|
||||
assert_eq!(
|
||||
url_filename("https://x.test/Total%20Replay.hdv"),
|
||||
"Total Replay.hdv"
|
||||
);
|
||||
// Query strings and fragments are not part of the name.
|
||||
assert_eq!(url_filename("https://x.test/a.dsk?token=1#x"), "a.dsk");
|
||||
// Degenerate URLs still yield something safe.
|
||||
assert_eq!(url_filename("https://x.test/"), "image");
|
||||
assert_eq!(url_filename("https://x.test"), "image");
|
||||
}
|
||||
|
||||
/// A one-shot HTTP server: answers `If-None-Match: <etag>` with 304,
|
||||
/// anything else with the body. Returns its port and a handle whose
|
||||
/// join yields how many requests carried a conditional header.
|
||||
fn serve(
|
||||
body: Vec<u8>,
|
||||
etag: &'static str,
|
||||
requests: usize,
|
||||
) -> (u16, std::thread::JoinHandle<usize>) {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
|
||||
let port = listener.local_addr().expect("addr").port();
|
||||
let handle = std::thread::spawn(move || {
|
||||
let mut conditional = 0;
|
||||
for _ in 0..requests {
|
||||
let Ok((stream, _)) = listener.accept() else {
|
||||
break;
|
||||
};
|
||||
let mut reader = BufReader::new(&stream);
|
||||
let mut matched = false;
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" {
|
||||
break;
|
||||
}
|
||||
if line.to_ascii_lowercase().starts_with("if-none-match:") {
|
||||
conditional += 1;
|
||||
matched = line.contains(etag);
|
||||
}
|
||||
}
|
||||
let mut stream = &stream;
|
||||
if matched {
|
||||
let _ =
|
||||
stream.write_all(b"HTTP/1.1 304 Not Modified\r\nConnection: close\r\n\r\n");
|
||||
} else {
|
||||
let head = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nETag: {etag}\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
let _ = stream.write_all(head.as_bytes());
|
||||
let _ = stream.write_all(&body);
|
||||
}
|
||||
let _ = stream.flush();
|
||||
}
|
||||
conditional
|
||||
});
|
||||
(port, handle)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downloads_once_then_revalidates_then_survives_offline() {
|
||||
let body = b"APPLE DISK IMAGE".to_vec();
|
||||
let (port, server) = serve(body.clone(), "\"v1\"", 2);
|
||||
let url = format!("http://127.0.0.1:{port}/games/Frogger.dsk");
|
||||
|
||||
let root = std::env::temp_dir().join(format!("ewm-fetch-test-{port}"));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
// Cold cache: downloads, and the file keeps its name.
|
||||
let first = cached_download(&root, &url).expect("first fetch");
|
||||
assert!(first.ends_with("Frogger.dsk"), "{first}");
|
||||
assert_eq!(std::fs::read(&first).unwrap(), body);
|
||||
|
||||
// Warm cache: revalidates with the stored ETag and reuses the
|
||||
// file (the server answers 304).
|
||||
let second = cached_download(&root, &url).expect("second fetch");
|
||||
assert_eq!(second, first);
|
||||
assert_eq!(std::fs::read(&second).unwrap(), body);
|
||||
assert_eq!(
|
||||
server.join().unwrap(),
|
||||
1,
|
||||
"second request was not conditional"
|
||||
);
|
||||
|
||||
// Server gone: the cached copy still boots the machine.
|
||||
let offline = cached_download(&root, &url).expect("offline falls back to cache");
|
||||
assert_eq!(offline, first);
|
||||
assert_eq!(std::fs::read(&offline).unwrap(), body);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cold_cache_with_no_server_is_an_error() {
|
||||
// Nothing cached and nothing answering: that must fail loudly
|
||||
// rather than hand back a path to a file that does not exist.
|
||||
let root = std::env::temp_dir().join("ewm-fetch-test-cold");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
// Port 1 on localhost: nothing listens there.
|
||||
let err = cached_download(&root, "http://127.0.0.1:1/nope.dsk").unwrap_err();
|
||||
assert!(err.contains("cannot fetch"), "{err}");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
+78
-9
@@ -328,8 +328,15 @@ pub const HDD_DRIVER_ENTRY: u16 = hdd_driver_entry(7);
|
||||
pub struct Hdd {
|
||||
image: Vec<u8>,
|
||||
/// The image file, kept open for write-back; `None` when it could not
|
||||
/// be opened writable — writes then fail with WRITE PROTECTED.
|
||||
/// be opened writable — writes then fail with WRITE PROTECTED — or
|
||||
/// when the volume is deliberately memory-only (see `writeback`).
|
||||
file: Option<std::fs::File>,
|
||||
/// Whether block writes are meant to reach the file at all. False for
|
||||
/// a downloaded volume: writes update `image` only, exactly as
|
||||
/// floppies behave, so revalidating the cached download can never
|
||||
/// destroy what ProDOS wrote. (Distinct from a *local* image that
|
||||
/// simply would not open writable — that stays WRITE PROTECTED.)
|
||||
writeback: bool,
|
||||
block: u16,
|
||||
buf: [u8; 512],
|
||||
index: usize,
|
||||
@@ -340,6 +347,14 @@ impl Hdd {
|
||||
/// 512-byte blocks, at most 65,535 of them (the ProDOS maximum — Total
|
||||
/// Replay's 33,553,920 bytes is exactly that).
|
||||
pub fn new(path: &str) -> Result<Hdd, String> {
|
||||
Hdd::new_with_writeback(path, true)
|
||||
}
|
||||
|
||||
/// `new` with control over whether WRITE_BLOCK reaches the file.
|
||||
/// `writeback = false` keeps writes in memory only — how a
|
||||
/// downloaded (http-sourced) volume mounts, so a cache
|
||||
/// revalidation can never clobber what the machine wrote.
|
||||
pub fn new_with_writeback(path: &str, writeback: bool) -> Result<Hdd, String> {
|
||||
let image = std::fs::read(path).map_err(|e| format!("cannot read {path}: {e}"))?;
|
||||
if image.is_empty() || !image.len().is_multiple_of(512) {
|
||||
return Err(format!(
|
||||
@@ -353,17 +368,22 @@ impl Hdd {
|
||||
image.len() / 512
|
||||
));
|
||||
}
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(path)
|
||||
.ok();
|
||||
if file.is_none() {
|
||||
let file = writeback
|
||||
.then(|| {
|
||||
std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(path)
|
||||
.ok()
|
||||
})
|
||||
.flatten();
|
||||
if file.is_none() && writeback {
|
||||
eprintln!("[HDD] {path} is not writable; mounting read-only");
|
||||
}
|
||||
Ok(Hdd {
|
||||
image,
|
||||
file,
|
||||
writeback,
|
||||
block: 0,
|
||||
buf: [0; 512],
|
||||
index: 0,
|
||||
@@ -391,11 +411,16 @@ impl Hdd {
|
||||
if self.block >= self.blocks() {
|
||||
return ERR_IO;
|
||||
}
|
||||
let Some(file) = &mut self.file else {
|
||||
// A local image that would not open writable is write-protected;
|
||||
// a memory-only volume (a download) takes the write into `image`.
|
||||
if self.file.is_none() && self.writeback {
|
||||
return ERR_WRITE_PROTECTED;
|
||||
};
|
||||
}
|
||||
let off = self.block as usize * 512;
|
||||
self.image[off..off + 512].copy_from_slice(&self.buf);
|
||||
let Some(file) = &mut self.file else {
|
||||
return 0;
|
||||
};
|
||||
let written = file
|
||||
.seek(SeekFrom::Start(off as u64))
|
||||
.and_then(|_| file.write_all(&self.buf))
|
||||
@@ -470,6 +495,50 @@ impl ewm_core::state::Persist for Hdd {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Write block 0 through the controller registers, the way the
|
||||
/// firmware does: block number, 512 bytes into the buffer, then the
|
||||
/// WRITE_BLOCK strobe at $Cn04.
|
||||
fn write_block_zero(hdd: &mut Hdd, fill: u8) -> u8 {
|
||||
hdd.write(0x0, 0, 0); // block low (also resets the buffer index)
|
||||
hdd.write(0x1, 0, 0); // block high
|
||||
for _ in 0..512 {
|
||||
hdd.write(0x3, fill, 0);
|
||||
}
|
||||
hdd.read(0x4, 0) // execute_write; 0 = success
|
||||
}
|
||||
|
||||
/// A downloaded volume mounts read-only (plans: images over HTTP):
|
||||
/// ProDOS writes land in memory but never touch the cached file, so
|
||||
/// revalidating the download cannot destroy them — while a normal
|
||||
/// local image still writes through.
|
||||
#[test]
|
||||
fn writeback_false_keeps_block_writes_out_of_the_file() {
|
||||
let dir = std::env::temp_dir().join("ewm-hdd-writeback-test");
|
||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||
let path = dir.join("volume.hdv");
|
||||
let original = vec![0xa5u8; 1024]; // two blocks
|
||||
std::fs::write(&path, &original).expect("write image");
|
||||
let path = path.to_str().expect("utf-8 path");
|
||||
|
||||
// Read-only: the write succeeds into memory, the file does not move.
|
||||
let mut hdd = Hdd::new_with_writeback(path, false).expect("mounts");
|
||||
assert_eq!(write_block_zero(&mut hdd, 0x5a), 0, "write should succeed");
|
||||
assert_eq!(hdd.image[..512], [0x5a; 512], "memory should have changed");
|
||||
assert_eq!(
|
||||
std::fs::read(path).expect("reread"),
|
||||
original,
|
||||
"the cached file must be untouched"
|
||||
);
|
||||
|
||||
// Write-back (a normal local image): the file follows.
|
||||
let mut hdd = Hdd::new_with_writeback(path, true).expect("mounts");
|
||||
assert_eq!(write_block_zero(&mut hdd, 0x3c), 0);
|
||||
let on_disk = std::fs::read(path).expect("reread");
|
||||
assert_eq!(on_disk[..512], [0x3c; 512], "the file should have changed");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The generator reproduces the original hand-assembled slot-7 firmware
|
||||
/// byte-for-byte (the literal below is the pre-generator static).
|
||||
#[test]
|
||||
|
||||
@@ -16,6 +16,7 @@ pub mod clk;
|
||||
pub mod config;
|
||||
pub mod des;
|
||||
pub mod dsk;
|
||||
pub mod fetch;
|
||||
pub mod hdd;
|
||||
pub mod led;
|
||||
pub mod liron;
|
||||
|
||||
+76
-4
@@ -1563,13 +1563,26 @@ impl Two {
|
||||
/// slot: the card's I/O ports in its DEVSEL range plus its boot/driver
|
||||
/// firmware ROM at $Cn00.
|
||||
pub fn attach_hdd_at(&mut self, slot: u8, path: &str) -> Result<(), String> {
|
||||
self.attach_hdd_at_with_writeback(slot, path, true)
|
||||
}
|
||||
|
||||
/// `attach_hdd_at` with control over whether WRITE_BLOCK reaches the
|
||||
/// file. Downloaded (http) volumes mount with `writeback = false`, so
|
||||
/// writes live only in memory — the same deal floppies get, and it
|
||||
/// keeps a cache revalidation from destroying what ProDOS wrote.
|
||||
pub fn attach_hdd_at_with_writeback(
|
||||
&mut self,
|
||||
slot: u8,
|
||||
path: &str,
|
||||
writeback: bool,
|
||||
) -> Result<(), String> {
|
||||
if !(1..=7).contains(&slot) {
|
||||
return Err(format!("no such slot {slot} (slots are 1 through 7)"));
|
||||
}
|
||||
if self.slot_occupied(slot) {
|
||||
return Err(format!("slot {slot} is already occupied"));
|
||||
}
|
||||
let hdd = Hdd::new(path)?;
|
||||
let hdd = Hdd::new_with_writeback(path, writeback)?;
|
||||
let base = slot_io_base(slot);
|
||||
let handle = self.cpu.mem.add_device(base, base + 0xf, hdd);
|
||||
self.hdds.insert(slot, handle);
|
||||
@@ -2638,7 +2651,10 @@ fn build_machine(options: &Options) -> Result<Two, String> {
|
||||
config::SlotCard::Diskii { drive1, drive2 } => {
|
||||
for (drive, path) in [(0, drive1), (1, drive2)] {
|
||||
if let Some(path) = path {
|
||||
two.load_disk_at(slot, drive, path).map_err(|e| {
|
||||
// An http(s) source is downloaded (and cached)
|
||||
// first; floppy writes are in-memory anyway.
|
||||
let local = crate::fetch::local_path(path)?;
|
||||
two.load_disk_at(slot, drive, &local).map_err(|e| {
|
||||
format!(
|
||||
"cannot load slot {slot} drive {drive} with {path}: {e}",
|
||||
drive = drive + 1
|
||||
@@ -2648,13 +2664,19 @@ fn build_machine(options: &Options) -> Result<Two, String> {
|
||||
}
|
||||
}
|
||||
config::SlotCard::Harddrive { image } => {
|
||||
two.attach_hdd_at(slot, image)
|
||||
// A downloaded volume mounts read-only: writes stay in
|
||||
// memory (as floppies do) so a later revalidation can
|
||||
// never clobber what the machine wrote into the cache.
|
||||
let local = crate::fetch::local_path(image)?;
|
||||
let writeback = !crate::fetch::is_url(image);
|
||||
two.attach_hdd_at_with_writeback(slot, &local, writeback)
|
||||
.map_err(|e| format!("cannot mount slot {slot} hard drive {image}: {e}"))?;
|
||||
}
|
||||
config::SlotCard::Liron { drive1, drive2 } => {
|
||||
for (drive, path) in [(0, drive1), (1, drive2)] {
|
||||
if let Some(path) = path {
|
||||
two.load_2mg_at(slot, drive, path).map_err(|e| {
|
||||
let local = crate::fetch::local_path(path)?;
|
||||
two.load_2mg_at(slot, drive, &local).map_err(|e| {
|
||||
format!(
|
||||
"cannot load slot {slot} 3.5 drive {drive} with {path}: {e}",
|
||||
drive = drive + 1
|
||||
@@ -4647,6 +4669,56 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_drive_is_downloaded_and_mounted() {
|
||||
// The wiring gate for disk images over HTTP: a URL in drive1
|
||||
// reaches the machine as a real file. If the mount path did not
|
||||
// fetch, load_disk_at would try to open "http://..." as a file
|
||||
// and fail — so a machine that builds proves the download.
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
|
||||
let disk = std::fs::read(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../disks/DOS33-SystemMaster.dsk"
|
||||
))
|
||||
.expect("the DOS 3.3 disk must be present");
|
||||
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
|
||||
let port = listener.local_addr().expect("addr").port();
|
||||
let body = disk.clone();
|
||||
let server = std::thread::spawn(move || {
|
||||
if let Ok((stream, _)) = listener.accept() {
|
||||
let mut reader = BufReader::new(&stream);
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut stream = &stream;
|
||||
let head = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nETag: \"dos33\"\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
let _ = stream.write_all(head.as_bytes());
|
||||
let _ = stream.write_all(&body);
|
||||
let _ = stream.flush();
|
||||
}
|
||||
});
|
||||
|
||||
let url = format!("http://127.0.0.1:{port}/DOS33-SystemMaster.dsk");
|
||||
let o = opts(&["--set", &format!("machine:slots:6:drive1={url}")]);
|
||||
let machine = build_machine(&o);
|
||||
let _ = server.join();
|
||||
|
||||
// Clean the cache entry this test created before asserting, so a
|
||||
// failure cannot leave litter behind.
|
||||
if let Ok(dir) = crate::fetch::cache_dir_for(&url) {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
machine.expect("the machine should build from a downloaded disk");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_boots_dos33_like_drive1() {
|
||||
// The boot gate: a config naming the DOS 3.3 disk boots it (its
|
||||
|
||||
@@ -293,6 +293,36 @@ configuration language.
|
||||
`plans/20260719-01-flag-retirement.md`; config memory regions are an
|
||||
upgrade (hex addresses, per-file relative-path resolution).
|
||||
|
||||
## Disk images over HTTP (recorded as built)
|
||||
|
||||
A floppy or hard-drive image path may be an `http(s)://` URL
|
||||
(`ewm/src/fetch.rs`):
|
||||
|
||||
- **Cache**: `$XDG_CACHE_HOME/ewm` (else `~/.cache/ewm`) /
|
||||
`<sha1 of the URL>` / `<file name from the URL>`, with a `meta.json`
|
||||
holding the validators. The digest keys the directory because URLs
|
||||
are not filesystem-safe; the file keeps its own name so the cache is
|
||||
browsable and errors read normally. SHA-1 is the crate's own
|
||||
(`ws::sha1`) — no new dependency for hashing.
|
||||
- **Freshness**: one *conditional* GET (`If-None-Match` /
|
||||
`If-Modified-Since` from the stored validators) rather than HEAD then
|
||||
GET — a `304` costs a single round trip. If the request fails and a
|
||||
copy is cached, the cached copy is used, so a downloaded machine
|
||||
still boots offline.
|
||||
- **A URL is a source, not a path**: `resolve_paths` leaves it alone
|
||||
(like `builtin:`), but `referenced_files` *does* count it, so a
|
||||
built-in config still may not carry one — builtins must run offline.
|
||||
- **Downloaded hard drives mount memory-only** (`writeback = false`):
|
||||
ProDOS writes update the in-memory image and never reach the cached
|
||||
file, so revalidating a download cannot destroy them. This needed a
|
||||
real change, not just leaving the file handle unopened — an
|
||||
unopenable file means *WRITE PROTECTED* in `hdd.rs`, which is a
|
||||
different (and still supported) state for a local image.
|
||||
- **Dependency**: `ureq` (blocking + rustls) — owner's decision, the
|
||||
one place the tiny-dependency budget gives way, because TLS is not
|
||||
something to hand-roll. It brings ~25 transitive crates including
|
||||
`ring`, so the headless server build now compiles native crypto too.
|
||||
|
||||
## What is configurable today (the schema inventory)
|
||||
|
||||
Since `plans/20260719-01-flag-retirement.md`, the CLI column is
|
||||
|
||||
@@ -551,7 +551,7 @@
|
||||
"const": "diskii"
|
||||
},
|
||||
"drive1": {
|
||||
"description": "Floppy image for drive 1 (`.dsk`, `.do`, `.po`, `.nib`, `.woz`).",
|
||||
"description": "Floppy image for drive 1 (`.dsk`, `.do`, `.po`, `.nib`, `.woz`).\nMay be an `http(s)://` URL, downloaded once into the image\ncache and revalidated with ETag / Last-Modified thereafter.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
@@ -579,7 +579,7 @@
|
||||
"const": "harddrive"
|
||||
},
|
||||
"image": {
|
||||
"description": "Block image (`.hdv`, `.po`).",
|
||||
"description": "Block image (`.hdv`, `.po`). May be an `http(s)://` URL —\na downloaded volume mounts memory-only, so ProDOS writes do\nnot persist into the cache.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
@@ -598,7 +598,7 @@
|
||||
"const": "liron"
|
||||
},
|
||||
"drive1": {
|
||||
"description": ".2mg image for drive 1.",
|
||||
"description": ".2mg image for drive 1. May be an `http(s)://` URL.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
|
||||
@@ -557,7 +557,7 @@
|
||||
"const": "diskii"
|
||||
},
|
||||
"drive1": {
|
||||
"description": "Floppy image for drive 1 (`.dsk`, `.do`, `.po`, `.nib`, `.woz`).",
|
||||
"description": "Floppy image for drive 1 (`.dsk`, `.do`, `.po`, `.nib`, `.woz`).\nMay be an `http(s)://` URL, downloaded once into the image\ncache and revalidated with ETag / Last-Modified thereafter.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
@@ -585,7 +585,7 @@
|
||||
"const": "harddrive"
|
||||
},
|
||||
"image": {
|
||||
"description": "Block image (`.hdv`, `.po`).",
|
||||
"description": "Block image (`.hdv`, `.po`). May be an `http(s)://` URL —\na downloaded volume mounts memory-only, so ProDOS writes do\nnot persist into the cache.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
@@ -604,7 +604,7 @@
|
||||
"const": "liron"
|
||||
},
|
||||
"drive1": {
|
||||
"description": ".2mg image for drive 1.",
|
||||
"description": ".2mg image for drive 1. May be an `http(s)://` URL.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
|
||||
Reference in new issue
Block a user