diff --git a/Cargo.lock b/Cargo.lock index 52670ee..14a23dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/README.md b/README.md index 21cde14..d7e1b44 100644 --- a/README.md +++ b/README.md @@ -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; diff --git a/ewm/Cargo.toml b/ewm/Cargo.toml index 3e1fbc3..d776396 100644 --- a/ewm/Cargo.toml +++ b/ewm/Cargo.toml @@ -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). diff --git a/ewm/src/config.rs b/ewm/src/config.rs index 0cf76a3..6c8e463 100644 --- a/ewm/src/config.rs +++ b/ewm/src/config.rs @@ -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, /// Floppy image for drive 2. drive2: Option, }, /// 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, /// .2mg image for drive 2. drive2: Option, @@ -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( diff --git a/ewm/src/fetch.rs b/ewm/src/fetch.rs new file mode 100644 index 0000000..ab5884e --- /dev/null +++ b/ewm/src/fetch.rs @@ -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 +//! //Total Replay v6.0.1.hdv +//! //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 { + 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 { + 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 { + 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 = 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, + #[serde(default)] + last_modified: Option, + /// The URL, for a human browsing the cache. + #[serde(default)] + url: Option, +} + +/// What a conditional request came back with. +enum Fetched { + /// The server confirmed the cached copy is current. + NotModified, + Body { + bytes: Vec, + etag: Option, + last_modified: Option, + }, +} + +/// 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 { + 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::(&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 { + // 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: ` 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, + etag: &'static str, + requests: usize, + ) -> (u16, std::thread::JoinHandle) { + 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); + } +} diff --git a/ewm/src/hdd.rs b/ewm/src/hdd.rs index edc58a5..6243c82 100644 --- a/ewm/src/hdd.rs +++ b/ewm/src/hdd.rs @@ -328,8 +328,15 @@ pub const HDD_DRIVER_ENTRY: u16 = hdd_driver_entry(7); pub struct Hdd { image: Vec, /// 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, + /// 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::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 { 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] diff --git a/ewm/src/lib.rs b/ewm/src/lib.rs index 60f4139..c0a64e4 100644 --- a/ewm/src/lib.rs +++ b/ewm/src/lib.rs @@ -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; diff --git a/ewm/src/two.rs b/ewm/src/two.rs index 7fd8256..0b2c210 100644 --- a/ewm/src/two.rs +++ b/ewm/src/two.rs @@ -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 { 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 { } } 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 diff --git a/notes/JSON_CONFIG.md b/notes/JSON_CONFIG.md index 12932b6..ebaaae5 100644 --- a/notes/JSON_CONFIG.md +++ b/notes/JSON_CONFIG.md @@ -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`) / + `` / ``, 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 diff --git a/schema/ewm-config-overlay.schema.json b/schema/ewm-config-overlay.schema.json index 3598a30..1c0f4ae 100644 --- a/schema/ewm-config-overlay.schema.json +++ b/schema/ewm-config-overlay.schema.json @@ -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" diff --git a/schema/ewm-config.schema.json b/schema/ewm-config.schema.json index 7270296..d01f1d9 100644 --- a/schema/ewm-config.schema.json +++ b/schema/ewm-config.schema.json @@ -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"