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:
Stefan ArentzandClaude Fable 5 committed 2026-07-19 20:49:16 -04:00
1 parent 3c7fe06776
commit 9971bfefbc
11 files changed
+856 -22

No files matched your search

Generated
+236
View File
@@ -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"
+20 -1
View File
@@ -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;
+1
View File
@@ -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
View File
@@ -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(
+372
View File
@@ -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
View File
@@ -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]
+1
View File
@@ -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
View File
@@ -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
+30
View File
@@ -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
+3 -3
View File
@@ -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"
+3 -3
View File
@@ -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"