diff --git a/HELP.md b/HELP.md index 584c854..5d4beee 100644 --- a/HELP.md +++ b/HELP.md @@ -188,22 +188,15 @@ grep -v '^#' /etc/inetd.conf | grep -E 'tcp|udp' ## NFS file sharing -IRIS can export a host directory to IRIX over NFS using -[unfs3](https://github.com/unfs3/unfs3) as the NFS server. The emulator -handles portmap (port 111) internally and NATs NFS/mountd traffic to a -localhost unfsd instance — no root privileges required. +IRIS exports a host directory to IRIX over NFS using a **built-in, pure-Rust NFS +server** (`src/nfsudp.rs`). It runs entirely inside the NAT — the emulator +answers portmap (port 111) and the MOUNT/NFS RPC itself and injects the replies +as virtual-network frames. **Nothing to install** (no external `unfsd`), **no +host sockets**, and it works the same on Linux, macOS, and Windows. The only +host interaction is reading/writing files in the folder you export. -### Requirements - -Install unfs3 and make sure `unfsd` is in your `PATH`: - -```bash -# Debian / Ubuntu -apt install unfs3 - -# Or build from source -git clone https://github.com/unfs3/unfs3 && cd unfs3 && ./autogen.sh && ./configure && make -``` +It speaks **NFSv2 (IRIX 5.3)** and **NFSv3 (IRIX 6.x)** and answers whichever the +guest mounts with. ### Configuration @@ -211,39 +204,30 @@ Add an `[nfs]` section to `iris.toml`: ```toml [nfs] -shared_dir = "./shared" # directory to export (resolved to absolute path at startup) -# unfsd = "unfsd" # path to unfsd binary [default: unfsd] -# nfs_host_port = 12049 # host port for NFS [default: 12049] -# mountd_host_port = 11234 # host port for mountd [default: 11234] +shared_dir = "./shared" # directory to export (created on demand) +# version = "auto" # "auto" (default), "v2", or "v3" ``` -Or use the command-line flag to enable it without editing the config: +Or enable it from the command line: ```bash iris --nfs-dir /path/to/share -iris --nfs-dir /path/to/share --nfs-port 12049 --mountd-port 11234 --unfsd /usr/sbin/unfsd ``` -IRIS will start unfsd automatically on launch and kill it on exit. The shared -directory must exist before starting the emulator. +(The GUI exposes the same under Configuration → Networking → NFS share.) ### Mounting from IRIX -The emulator prints the export path at startup: - -``` -iris: unfsd started (pid 1234) nfs=127.0.0.1:12049 mountd=127.0.0.1:11234 dir=/absolute/path/to/shared -``` - -From IRIX, mount using that absolute path: +The export is a single root, so mount it as `/`: ``` # mkdir /shared -# mount 192.168.0.1:/absolute/path/to/shared /shared +# mount 192.168.0.1:/ /shared # ls /shared ``` -NFS version 3 is used by default; IRIX will fall back to version 2 automatically if needed. +Use the gateway address shown in the GUI (it tracks your NAT subnet). The server +fakes uid/gid/mode so the export behaves the same regardless of the host OS. ### Checking network status from the monitor diff --git a/docs/cow-chd-sync-plan.md b/docs/cow-chd-sync-plan.md new file mode 100644 index 0000000..431b1f7 --- /dev/null +++ b/docs/cow-chd-sync-plan.md @@ -0,0 +1,295 @@ +# Plan: copy-on-write protection + "Syncing CHD file…" apply-on-shutdown + +Status: **flatten-on-exit (Phases 2–5) implemented 2026-06-18; Phase 1 COW toggle +still pending.** What ships now folds an existing `.diff.chd` back into its base +on a clean app exit, with a "Synchronizing disks…" modal — covering the +compressed-CHD case (which always auto-creates a diff) without the per-disk COW +toggle. See "Implemented (v1)" below. + +## Implemented (v1) — flatten-on-exit + +- **Core** (`src/chd_disk.rs`): `ChdHd` tracks `base_path` / `diff_path` / `dirty` + (dirty = wrote this session, or reattached a pre-existing diff). `pending_sync()` + returns `(base, diff)` when a fold-back is owed. `flatten_diff(base, diff, + progress, cancel)` rebuilds the base from the merged `reopen_diff` view via + `create_from_reader` (preserving codecs/geometry/hunk+unit → compressed stays + compressed) into a `.synctmp.chd`, fsyncs, **atomic-renames over the base**, then + deletes the diff. Any error/cancel leaves base+diff intact. Unit-tested + (`flatten_folds_diff_into_compressed_base`, `cancelled_flatten_preserves_base_and_diff`). +- **Plumbing**: `ScsiDevice::{pending_chd_sync, take_pending_chd_sync}` → + `Wd33c93a::{pending_chd_sync_count, sync_chd_disks}` (rebuild runs outside the + device lock) → `Machine::{pending_chd_sync_count, sync_chd_disks}`. +- **GUI** (`iris-gui`): `Status.chd_sync_pending` (reported each tick), + `Cmd::SyncDisks`, `Evt::SyncProgress{disk,total,fraction}` / `Evt::SyncDone`. + `App::update` intercepts `close_requested`: if a sync is pending it sends + `SyncDisks`, shows the "Synchronizing disks…" modal (progress bar), and + `CancelClose`s; the worker stops the machine, flattens with progress, then the + app closes on `SyncDone`. `sync_then_close` latches so the final close isn't + re-intercepted. +- **Stop button** also folds: `request_stop()` (the safe-stop choke point both + Stop buttons use) sends `SyncDisks` instead of `Stop` when safe-to-stop AND a + sync is pending, with `sync_then_close=false` so the app stays open. (A + force-stop / unsafe stop keeps the diff — only the safe path folds.) +- **Sandbox flatten — Option A (folder grant), plumbing implemented 2026-06-18.** + The fold needs to create a temp sibling beside the base and `rename` over it, + which a per-*file* security-scoped grant can't do. Fix: the user grants the + *folder* their disks live in (File → "Grant a disk folder…", App Store builds + only); a *directory* bookmark is recursive, so it covers the base, the + diff/fold-temp written beside it, AND an NFS shared subfolder under it — one + grant. Stored as `GuiSettings.disk_folders`, harvested into `bookmarks` + (directory bookmark) and `restore()`d at launch like file bookmarks. Once the + folder is granted the existing `flatten_diff` (temp + rename) just works — no + change to `flatten_diff` or the container diff location needed. **Unverified on a + signed sandbox build** (the dir-bookmark recursive-write behaviour is + Apple-documented but untested here). +- **Reveal in file manager** — "📂" button on every path field + (`config_ui::reveal_in_file_manager`). macOS uses + `macos_sandbox::reveal_in_finder` → `NSWorkspace selectFile:inFileViewerRootedAtPath:` + (objc2 raw msg_send, no new dep) so it's **sandbox-safe** (no `open` subprocess); + Windows/Linux shell out. Works on dev/notarized now; sandbox path believed-good, + unverified on a signed build. +- **NFS shared folder under the App Store** — both paths: (A) pick any folder in + the NFS picker (grants it directly), or (B) a "Use /shared" button per + granted disk folder that `create_dir_all`s a `shared` subfolder and points the + share at it (recursive grant flows down). Guidance text shown when no folder is + granted yet. `disk_folders` is threaded through `show_tab`→`show_network`. +- **Per-disk COW toggle wired to CHDs (Phase 1), 2026-06-18.** The existing + `overlay` flag is now the universal copy-on-write toggle and is honored for + CHDs: `ChdHd::open(path, cow)` — COW on → ALWAYS overlay (even an uncompressed + base gets a diff, so the base is never written in-session) and `pending_sync()` + returns `None` (never auto-folds); COW off → unchanged (in-place uncompressed / + auto-folding compressed). `cow commit` and `cow reset` extended to CHDs in + `ScsiDevice` (commit = `flatten_diff` + reopen; reset = delete the `.diff.chd` + + reopen a fresh overlay = rollback); the monitor `cow status|commit|reset` is + backend-agnostic so it now drives CHDs too. Disks-tab checkbox relabelled + "Copy-on-write". Unit test: `cow_keeps_changes_separate_and_rolls_back`. One + unified COW concept across raw + CHD — no parallel system. +- **GUI commit / rollback (SCSI menu), 2026-06-18.** When stopped, the SCSI menu + lists each disk that has an on-disk overlay (`.diff.chd` / `.overlay`) with + "⬇ Commit changes to disk" and "↩ Discard changes (roll back)". These are + file-level and machine-independent — `Cmd::CowCommit`/`Cmd::CowReset` run in the + worker with no machine loaded (so they can't corrupt a running guest; the items + are hidden/disabled while running). A CHD commit reuses the "Synchronizing + disks…" progress modal (`flatten_diff` → `SyncProgress`/`SyncDone`, + `sync_then_close=false` so it doesn't quit); raw commit + all rollbacks are + instant (`CowDone` → toast). `chd_disk::diff_path_for` is now `pub`. COW default + remains **off**. +- **Not yet**: a Cancel button on the sync/commit modal; a confirm dialog before + rollback (it discards the overlay); the in-place incremental flatten for + uncompressed bases; verifying the folder-grant fold + reveal + shared-folder + flow on a signed App Store build. + +Original plan (still the target for Phase 1) below. + +--- + +Status (original): **plan, not yet implemented** (authored 2026-06-16, revised same +day after design decisions; to implement next session). + +## Goal + +1. **Copy-on-write (COW) as a per-disk toggle that protects the base image.** + When on, the emulator never writes directly to the disk during a session — + changes go to a differencing file — so a crash or force-quit mid-boot can't + corrupt the base. +2. **On a clean shutdown, apply ("sync") the diff back into the disk**, showing a + **"Syncing CHD file…"** window (iris-gui) / console line (iris), then exit. So + the disk "acts like a normal one": edit freely all session; changes fold back + into the single CHD on clean exit. + +Applies to **both** binaries: all disk logic is in the shared `iris` core crate; +only the sync *presentation* differs per frontend. + +## Design decisions (locked) + +- **D1 — defaults.** Dev/interactive build: **COW ON** by default (devs test and + force-quit often → protect by default). Release/bundled (App Store) build: + **COW OFF**, always sync directly to the disk (end users aren't testing; they + want normal persistent-disk behavior, no overlay to manage). Toggle remains + user-settable in both. *(Confirm: dev default ON — alternative is keep dev + default OFF too and make COW purely opt-in.)* +- **D2 — follow MAME, no libchdman-rs extension.** Use MAME's native + differencing-CHD mechanism for COW (`open_with_diff`/`reopen_diff`), and the + in-process `chdman copy` equivalent (`hd::create_from_reader` / `copy::copy`) + to flatten on shutdown. MAME never auto-merges a diff; the *only* thing we add + is automating that flatten on a clean exit. No new libchdman APIs required. +- **D3 — apply-diff ⇔ COW on.** Applying the diff to the base happens only when + COW is enabled (the commit-on-shutdown / sync step). COW off = writes go + straight to the base (in place). Sole MAME-style exception: a *compressed* CHD + with COW off can't write in place, so writes sit in a diff and stay there + (flatten manually) — pure MAME, we don't special-case it. + +### COW behavior matrix + +| Base image | COW ON (protect) | COW OFF (direct) | +|---|---|---| +| Uncompressed CHD | writes → diff; apply diff into base on clean exit | writes in place (MAME) | +| Compressed CHD | writes → diff; recompress base+diff on clean exit | writes → diff, kept/not merged (MAME) | +| Raw image | writes → `.overlay`; apply on clean exit | writes in place | + +## Why (recap of findings) + +- User's boot disk `irix65.chd` is an **uncompressed** CHD v5 (chdman: + `Compression: none`), 100.66 GB logical / 5.71 GB physical (sparse, not + compressed). Uncompressed CHDs are written **in place** today — no diff — so a + mid-boot kill corrupts the base directly. This feature closes that gap. +- How MAME does writable CHDs (the model we follow): compressed CHD → read-only, + writes go to an uncompressed differencing CHD (`.dif`) with the original as + parent; never auto-merged. Uncompressed CHD → in place. +- `libchdman-rs` (pinned `0.287.0-l7`, `prebuilt`) already provides everything + in-process (no shelling to `chdman`): + - `HdImage::open(path)` — writable in place; **succeeds only for uncompressed**. + - `HdImage::open_with_diff(parent, diff)` — open parent read-only, create an + uncompressed differencing child; writes land in the diff. (`hd.rs:357`) + - `HdImage::reopen_diff(parent, diff)` — reattach existing diff to parent. + (`hd.rs:389`) — gives the *merged* view via `read_sector`. + - `hd::create_from_reader(reader, out, opts, &mut progress, &cancel)` + (`hd.rs:176`) and `copy::copy(src, dst, opts, progress, cancel)` (`copy.rs:58`) + — in-process `chdman copy`; `progress`/`cancel` callbacks drive the UI. + - `HdCreateOptions { logical_size, hunk_size=4096, unit_size=512, codecs:[u32;4], + geometry, ident }` (`hd.rs:42-63`); codecs `codec.rs`. +- `CowDisk` (`src/cow_disk.rs`) already handles raw-image COW (overlay + dirty + set + `commit()`/`flush()`/snapshot import/export). **Keep it for raw images.** + Do *not* generalize it over CHDs — CHDs use MAME's diff instead. + +### Assumptions to verify during implementation + +- `open_with_diff` accepts an **uncompressed** parent (needed for COW-on over an + uncompressed CHD, to force a diff where today we'd write in place). MAME diffs + allow any parent; confirm the libchdman binding does too. +- Flatten path: prefer `reopen_diff(parent, diff)` → read merged sectors → + `create_from_reader` into a temp CHD with the **original's codecs + geometry + + GDDD/ident metadata** (read from the source header) → fsync → atomic rename → + delete diff. (`copy::copy` may need explicit parent resolution for a diff + source; `create_from_reader` over the merged `reopen_diff` view sidesteps that.) + +## Implementation + +### Phase 1 — Core: COW toggle wired through MAME's diff + +Files: `src/chd_disk.rs`, `src/wd33c93a.rs`, `src/config.rs`. + +1. **`ChdHd` open modes** (`chd_disk.rs:36-56`): add an explicit COW-on open that + **always** uses `open_with_diff`/`reopen_diff` (forces a diff even for an + uncompressed parent), vs. COW-off which keeps current behavior (`open` in + place for uncompressed; diff fallback for compressed). Diff path via existing + `diff_path_for` (honors `IRIS_CHD_DIFF_DIR`). +2. **Dispatch** (`wd33c93a::add_device`, `:327-365`): pass the device's `cow` + flag into the CHD branch (today it's ignored, `:336-338`). `cow` on → COW-on + open; off → current behavior. Raw branch keeps `CowDisk` (`:351-357`). +3. **Config** (`config.rs:20-21`): keep field `overlay: bool` (serde back-compat; + add `cow` alias) as the universal COW toggle, now honored for CHD too. Default + per D1 (dev on / release off) — gate the default on the build/`appstore` + feature or a release flag. + +### Phase 2 — Core: flatten (apply diff) on demand + +New `ChdHd::flatten()` (or a free fn in `chd_disk.rs`): +- Quiesce writes (caller ensures SCSI worker stopped). +- Open merged view `reopen_diff(parent, diff)`; read the original's codecs + + geometry + ident from the source CHD. +- Stream merged sectors into a temp CHD via `create_from_reader` (same codecs → + compressed stays compressed, uncompressed stays uncompressed), driving + `progress(0.0..=1.0)` and honoring `cancel`. +- `fsync` temp → **atomic rename** over the base path → delete the diff. +- On `cancel`/error: delete temp, leave base + diff intact (next launch + reattaches the diff — nothing lost). +- *Optimization (later, optional):* for an uncompressed base, write only changed + sectors in place instead of a full rebuild — needs diff-hunk enumeration (a + small libchdman addition); not required for v1. + +### Phase 3 — Shutdown wiring (shared) + +- **`Wd33c93a::sync_disks(progress)`** (new): after the SCSI worker thread is + stopped, walk `devices[]`; for each COW-on CHD device with a non-empty diff, + call `flatten(progress)`; for raw COW devices call `CowDisk::commit()`. +- **Call site — `Machine::stop()` (`machine.rs:625-630`)**: the single choke + point both shutdown paths hit — guest soft power-off (`machine.rs:608`, just + before `process::exit(0)` at `:614`) and host window-close (`ui.rs:642` → + `main.rs:112`). No-op when nothing needs flattening. + +### Phase 4 — iris core presentation + +- `eprintln!("iris: Syncing CHD file… ({} of {})", done, total)` around the + flatten in `Machine::stop()`, matching the `println!("Machine: soft power-off")` + style (`machine.rs:607`) and `cow_disk.rs:239`. Console only. +- On-screen overlay is out of scope for the core binary (Rex3 refresh thread is + torn down by `rex3.stop()` inside `stop()`; power-off exit runs off the UI + thread). A graphical message would need a `StatusBar` message field + (`disp.rs:480`) + a forced final `present()` before `machine.stop()`. + +### Phase 5 — iris-gui presentation ("Syncing CHD file…" window) + +`iris-gui/src/main.rs` + `iris-gui/src/handle.rs`: +1. **Worker command** (`handle.rs`): add `Cmd::SyncDisks` (`:11-22`), handled in + `worker_loop` (`:172-322`) modeled on `Cmd::SaveState`'s background disk work + (`:264-282`). It calls `machine.sync_disks(|p| Evt::SyncProgress(p))` and ends + with `Evt::SyncDone`. Add `Evt::SyncProgress(f32)`/`Evt::SyncDone`, merged in + `drain_events` (`:106-126`). +2. **Close interception** (`App::update` top, `:1318-1320`): detect + `ctx.input(|i| i.viewport().close_requested())`; if any COW device has a + pending diff and sync not started → send `Cmd::SyncDisks`, set + `self.syncing = Some(..)`, `ctx.send_viewport_cmd(ViewportCommand::CancelClose)`. + Route File→Quit (`:580-582`) through the same path. +3. **Modal** (inline `Option` style like `stop_modal` `:1451-1471`): + `egui::Window::new("Syncing CHD file…").collapsible(false).resizable(false)` + `.anchor(CENTER_CENTER,..)` with a label + `egui::ProgressBar::new(job.progress)` + (+ optional Cancel → worker cancel flag, keeps the diff). `ctx.request_repaint()`. +4. On `Evt::SyncDone`: `self.syncing=None;` then `ViewportCommand::Close` → falls + through to `on_exit` (`:1590-1602`) → `emu.shutdown()` (`:1598`). +5. **`safe_stop.rs`** (`:41-61`): with COW, force-stop never touches the base and + the diff persists + reattaches next launch, so reword "Confirm stop" copy: + not "may corrupt base" but "changes aren't yet synced into the disk; they're + kept in the overlay and applied on the next clean exit." +6. **COW toggle UI** (Disks tab): a per-device checkbox **"Protect base image + (copy-on-write)"** with the help/protection blurb below. Default per D1. + +### COW explanatory text (UI + docs) + +> **Protect base image (copy-on-write)** +> When on, the emulator never writes directly to this disk during a session — all +> changes go to a temporary overlay, so the original image stays intact even if +> the emulator crashes or is force-quit mid-boot. On a **clean shutdown** your +> changes are merged ("synced") back into the disk. When off, changes are written +> straight to the disk as you go, like a normal hard drive. +> *Recommended on while testing or experimenting; off for everyday use.* + +## Edge cases & safety + +- **Crash / kill mid-session:** base untouched; diff persists and reattaches next + launch (`reopen_diff`). Nothing committed → nothing corrupted. +- **Crash mid-flatten:** temp-file + atomic rename → original intact on + interruption; diff only deleted after a successful rename. +- **Disk space:** flatten needs room for a second copy of the CHD; check free + space first and surface a clear error in the modal. +- **Large logical disks:** `irix65.chd` is 100 GB logical. A full rebuild streams + the whole logical size (sparse-zero-dominated, fast-ish); progress bar covers + it, Cancel aborts safely. (The later in-place optimization avoids the rebuild + for uncompressed bases.) +- **Snapshots / CI:** raw-image overlay format unchanged → `export/import_overlays` + (`machine.rs:880,1008,1245`) keep working. CHD diffs are not part of snapshot + capture today; confirm snapshot save/load still behaves with a COW-on CHD + (likely: snapshot the diff path or require flatten before snapshot). + +## Test plan + +- Unit (`cargo test --lib --features chd`): + - COW-on over uncompressed CHD: writes go to diff, base bytes unchanged; after + flatten, base reflects writes and is still a valid uncompressed CHD; diff gone. + - COW-on over compressed CHD: same, output still a valid **compressed** CHD + (`Chd::verify`/reopen), same logical size + preserved geometry/metadata. + - Cancelled flatten: base + diff intact, reattach works. +- Manual (bounded runs, clean `halt`, per `dont-run-iris-long`): + - iris-gui: boot `irix65.chd` COW-on, edit, clean shutdown → modal + progress → + changes present next boot. Force-stop mid-session → base unchanged, diff + reattaches. + - iris core: same via console `Syncing CHD file…` line. + +## Out of scope / future + +- In-place incremental flatten for uncompressed bases (needs diff-hunk + enumeration in libchdman). +- Graphical sync overlay in the core `iris` binary. +- Periodic/background flatten during a session (today: clean exit or explicit + monitor `cow commit`, which already exists at `wd33c93a.rs:876-926`). +``` diff --git a/docs/networking-tab-redesign.md b/docs/networking-tab-redesign.md new file mode 100644 index 0000000..7451239 --- /dev/null +++ b/docs/networking-tab-redesign.md @@ -0,0 +1,183 @@ +# Networking tab redesign — design & plan + +Status: **Phase 0 + 1 done** (subnet logic + Networking tab UI landed, unit-tested, +builds clean on default and `appstore` features; not yet committed). Phases 2 +(FTP ALG) and 3 (in-app file bridge) pending. Captures the design agreed for the iris-gui +**Networking** config tab and the two new backend features it pulls in. Mirrors +the `docs/cow-chd-sync-plan.md` convention so the work survives across sessions. + +## Goal + +A regular user with **no networking knowledge** opens the Networking tab, accepts +the defaults, and it *just works*. Power users can still build odd subnets, port +forwards, and file-sharing setups. Everything must stay inside the Mac App Store +sandbox (no new entitlements; no reliance on spawning external binaries). + +## Background — how the backend constrains the UI + +- `nat_subnet` is stored as a **single CIDR string** (e.g. `192.168.0.0/24`). + The two new UI controls (network + mask) just recompose into that one string. +- `iris::config::parse_nat_subnet` requires the **network address** (host bits + zero), rejects prefix `> /30`, and always derives **gateway = network + 1**, + **Indy `ec0` = network + 2**. So the host octet typed by the user is never the + actual host — it must be `.0`, and both endpoints fall out of the subnet. +- `net.rs` already runs a real userspace TCP state machine *toward the guest* + (`NatTcpEntry`: `server_seq` / `client_seq` / `client_win` / `retransmit`), and + the port-forward path (`TcpFwdPending`) bridges a peer to the guest by injecting + segments. Today that peer is always a host `TcpStream`. The **in-app file + bridge** swaps that peer for an in-process protocol engine. +- NFS works by spawning an **external `unfsd`** (`src/main.rs`), which a sandboxed + MAS app generally can't exec — so NFS likely doesn't function in the App Store + build (open item below). + +## Decisions + +### A. Private-network controls (replace the single CIDR text field) +- **Network address** dropdown: *first-free 192.168.x* (default) / first-free + 172.16.x / first-free 10.x / **Custom…** (free-typed, snap-to-boundary + warn). +- **Subnet mask** dropdown — prefixes **8, 12, 16, 22, 24, 25, 26** (default + **/24**) / **Custom…** (type bits → show mask). `/8 /12 /16` are the native + sizes of the three RFC1918 blocks. Custom still reaches `/30`. +- **Live derived line**: gateway (net+1), Indy `ec0` (net+2), usable host range, + broadcast, and conflict ✓/⚠. +- **`if-addrs`** dependency powers *first-free* selection and *overlap* warnings + by reading the host's own interface addresses (no entitlement; does not trigger + the macOS 15 Local Network prompt — that's for talking *to* LAN peers). +- **Sanity tiers / override dialog**: + - **Hard** (engine can't represent): prefix `0` or `> /30`, malformed → blocked. + - **Off-boundary** (e.g. `192.168.40.0/16`): snap to the real network + (`192.168.0.0/16`) + a small grey note. Widening the mask is the common cause. + - **Soft** (parses but unwise): not RFC1918, or overlaps a host network → + confirmation dialog *"This networking configuration does not appear to be + valid, please double-check…"* → **[Override Sanity Checks]** / **[Cancel]** + (Cancel reverts to the previous good value). Big masks (`/8 /12`) overlap + Docker/VPN ranges far more often, so this earns its keep. + - Defaults are RFC1918 + /24 + conflict-checked, so a normal user **never** + sees the dialog. +- The **troubleshooting** (`netfix`) dialog surfaces the Indy's *required* address + (`ExpectedNet`); the config tab shows the host/gateway side. + +### B. Port forwards +- `+ Add forward ▼` menu: **Telnet** (2323→23) / **FTP** (2121→21) / **Custom…**. +- Pre-filled rows use **unprivileged host ports** (>1024) — required to stay in + the sandbox without root. + +### C. FTP ALG (in `net.rs`) — *kept* +- Watches a forwarded FTP control stream, rewrites `PASV`/`PORT`, and opens the + data port dynamically so the FTP **port-forward** works for a user's *own + external* FTP client. Uses the existing `network.server` entitlement. + +### D. In-app NAT-side file bridge — *the big one* +- The app is already a host on the virtual network (`gateway_ip`). An in-app + client originates from a NAT address straight to the guest's daemon over the + emulated SEEQ8003 — **no NAT traversal, no host sockets, no new entitlement**, + and it reads/writes only user-selected local files (already entitled). This is + the cleanest App Store file-sharing story and fills the NFS gap there. +- **Abstract the TCP-peer seam**: generalize the forward-path peer from a host + `TcpStream` to an in-process stream trait, reusing the `NatTcpEntry` core. +- Ship **FTP-passive client first** (app opens both control + data connections to + guest `ftpd` — no reverse channel), with **rcp/rsh behind the same seam later** + (adds a guest-dials-back stderr channel + `.rhosts` trust). +- **UI**: folder picker (Browse + MRU), file list with push/pull, credentials for + FTP. +- **Guest auto-provision** via the existing `netfix` serial path: enable the + daemon / confirm trust or account, so the user only clicks Browse. The + troubleshooting dialog and the bridge become one "set up sharing" flow. + +### E. NFS +- Add an explanatory blurb + a **live-generated** mount command (gateway + folder + fill in automatically). +- Keep NFS for the **notarized DMG** build; the App Store build relies on the + in-app bridge. Final gating pending the unfsd-in-sandbox investigation. + +## Entitlements / App Store +- **No new entitlements.** Port-forwards are already justified under + `com.apple.security.network.server`; the FTP ALG reuses it. The in-app bridge + adds **zero** socket surface. `if-addrs`/`getifaddrs` needs none. +- Unprivileged host ports keep forwards legal in the sandbox. + +## Build order + +| Phase | What | Risk | Status | +|---|---|---|---| +| **0** | Pure subnet/conflict logic (`iris-gui/src/netplan.rs`) + `if-addrs`: parse/compose CIDR, mask presets, first-free, conflict, sanity tiers, snap, derived addrs — 15 unit tests. No UI. | low | **done** | +| **1** | Networking tab UI on Phase 0: network preset combo, mask combo (8/12/16/22/24/25/26 + custom bits), CIDR field, derived line + conflict ✓/⚠, override modal (Cancel / Use suggested / Override), Add-forward menu (Telnet/FTP/Custom), NFS blurb + live mount cmd, troubleshooting-window Indy address. Network edits now mark cfg dirty. | low | **done** | +| **2** | FTP ALG in `net.rs`: rewrite passive-mode `227` replies on an inbound FTP control forward, bind a localhost data listener, register a transient (FIFO-bounded) data forward. Pure parse/rewrite unit-tested. | med | **done** | +| **3** | In-app file bridge — see decision below. | high | pending | + +### Phase 3 plan (decided 2026-06-18) +Architecture **B** (pure NAT-side client, no host sockets). FTP reuses suppaftp's +protocol code via a **transport-generic fork** rather than a hand-rolled client — +spec in `docs/suppaftp-emu-fork-prompt.md` (make suppaftp's sync client generic +over an `FtpConnector` trait; default `TcpConnector` preserves behavior; passive +first; TLS only on the TCP path). With a virtual-net connector **no ALG/PASV +rewrite is needed** for the bridge (we reach the guest's PASV address directly on +the virtual net; the Phase 2 ALG stays only for the external-client forward). + +Two tracks: +- **suppaftp-emu fork** — separate crate/repo, driven by the prompt (user kicks + this off). IRIS links it by path/git. +- **IRIS foundation** (resumes once the crate API exists): (1) `NatEngine` + in-process TCP-peer seam — generalize `TcpFwdPending`/`NatTcpEntry`'s + `stream: TcpStream` to `{ HostSocket(TcpStream), InProc(..) }`; (2) + `VirtualTcpStream: Read+Write` + a `VirtualConnector` impl of the crate's + `FtpConnector`; (3) **dual-pane** UI (rfd Browse + MRU local, FTP `LIST`/`CWD` + for the Indy); (4) rcp (hand-rolled — no crate exists); (5) guest daemon + auto-provision via the `netfix` serial path. + +Status: paused after delivering the fork prompt; IRIS side not started. + +### Phase 2 implementation notes / limits +- Works because the NAT *relays application bytes* between an OS host socket and + the userspace guest-side TCP, so the length-changing `227` rewrite needs no + seq/ack surgery (the host stack re-sequences). `client_seq` still advances by + the original payload length. +- Inbound FTP control connection identified by `server_ip == gateway && client_port == 21`. +- **Handles classic passive mode only** (`PASV` → `227`). Not handled yet: + active mode (`PORT`), extended passive (`EPSV`/`229`), or a `227` split across + TCP segments (no control-stream reassembly — fine for IRIX ftpd, which sends it + in one segment). +- Data forwards are transient, FIFO-capped at 16, and truncated on reset / + live-subnet-apply. **Unverified on a real boot** — needs a manual FTP transfer. + +### Phase 1 implementation notes +- `cfg.nat_subnet` stays the raw-CIDR source of truth; preset/mask controls + rewrite it (snapped via `to_cidr`), the CIDR field stays partial-typing-safe. +- Config-editor tab edits did **not** previously mark the config dirty (only the + Memory/SCSI quick-menus did). The Networking tab now reports `changed` up via + `TabOutcome` so its edits autosave; other tabs are unchanged (pre-existing gap + left for a separate fix). +- The override modal fires only on a *committed* soft-invalid subnet (preset/mask + pick or CIDR field losing focus), never on live keystrokes. + +Phase 1 depends on 0. Phases 2 and 3 both touch `net.rs`; sequence them. Phase 3 +is the largest. + +## Open items +- **unfsd-in-sandbox**: confirm whether `unfsd` is bundled/signed and actually + launches under the App Store sandbox. Drives whether the NFS panel is compiled + out under `feature = "appstore"`. + +## Verbiage (approved) + +**NAT intro:** "IRIS gives the Indy its own private NAT network — the same trick +your home router uses. The Indy reaches the internet through IRIS, but nothing on +your real network can see it. Pick a subnet that doesn't overlap a network your +computer already uses (Wi-Fi, Ethernet, VPN, Docker…) — if it does, IRIS flags it +below, since an overlap can cut the Indy off from the internet." + +**Port forwards helper:** "A port forward maps a port on your computer to a port +on the Indy, so host tools can reach guest services (log in, copy files…). +Inbound only, and it works once the guest is up on the NAT subnet. None exist by +default." + +**NFS blurb:** "The Indy speaks NFS natively — the easiest, batteries-included way +to move files between your computer and the emulated machine. IRIS runs the NFS +server for you, backed by the folder you pick below; there's nothing to install +and no NFS know-how required. Pick a folder, boot the Indy, then mount it: +`mkdir /shared` / `mount : /shared`. Your files appear at +`/shared` on the Indy. The host address and path above update automatically to +match your subnet and folder." + +**Override dialog:** "This networking configuration does not appear to be valid, +please double-check…" — buttons **[Override Sanity Checks]** / **[Cancel]**. diff --git a/docs/nfsudp-plan.md b/docs/nfsudp-plan.md new file mode 100644 index 0000000..0f9e1e8 --- /dev/null +++ b/docs/nfsudp-plan.md @@ -0,0 +1,201 @@ +# Plan: in-core NFSv3-over-UDP server (`src/nfsudp.rs`) + +Status: **CODE-COMPLETE** (increments 1–8 landed, ~19 unit tests, builds clean on +default + appstore). Replaces the external `unfsd` with a synchronous, pure-Rust +NFS/UDP server that lives inside the NAT. **Pending: real-boot validation** — +mount from IRIX 5.3 (v2) and 6.x (v3), read/write, and large transfers +(fragmentation/reassembly). Code: `src/nfsudp.rs` (backend, XDR/RPC, NFSv2+v3, +MOUNT, DRC, NfsServer) + `src/net.rs` (handle_udp intercept, inbound reassembly). + +## Decisions (locked 2026-06-18) + +- **Support both NFSv2 (IRIX 5.3) and NFSv3 (IRIX 6.x).** Best done by handling + *both at once*: the server dispatches on the RPC `vers` field, so the guest's + own `mount` picks the version (5.3 → v2, 6.x → v3) and we reply in kind — no + dropdown needed for it to work. We'll still expose an optional **NFS version: + Auto / v2 / v3** config (default Auto) to force/limit it for testing. + Implementation: MOUNT v1 (for NFSv2) + MOUNT v3, and NFS v2 + v3 procedure + sets, all over a **shared backend** (path↔id map, file I/O, faked attrs); only + the XDR/attr encoding differs per version. +- **Full read-write in one pass** (not a read-only spike). Implies the + **duplicate-request cache** (Q14) is in scope from the start. +- **Inbound IP reassembly now** (Q2): add a fragment-reassembly buffer to + `handle_ip` so large `wsize` works — fast writes, symmetric with reads. +- **Simplest synthetic permissions** (Q5): fixed uid/gid 0, mode by heuristic + (dir 0755 / file 0644 / +x if detectable), accept-and-ignore the guest's + SETATTR chmod/chown. +- **Guest side / `.rhosts` note:** NFS mounting needs **no `.rhosts`** (that file + is rsh/rcp trust, not NFS). The only guest-side step is the `mount` command, + which the GUI already emits live. The "add host to `.rhosts`" instruction + belongs to the *rcp/rsh* file path, which is separate — see clarification + request in the chat. + +## Goal & hard constraints + +- A minimal **NFSv3 server, UDP only**, in `src/nfsudp.rs` (the `iris` core + crate). No TLS, no NLM/locking, no real auth (allow every host, ignore RPC + credentials), **faked/synthesized unix permissions** for cross-platform + parity (esp. Windows, which has no unix uid/gid/mode). +- **The whole protocol stays inside the NAT.** The NAT engine dispatches the + guest's NFS/MOUNT/portmap RPC straight to the in-core server and injects the + replies as virtual-network frames. **Zero host network sockets** for NFS. The + *only* thing that touches the host is **file I/O to the user-chosen backing + folder**. +- Result: kills the `unfsd` problems (no native Windows build, no Homebrew, no + spawning an external binary in the macOS App Store sandbox) and lets us + **un-gate NFS on every platform**. + +## Why it fits the existing NAT cleanly + +`src/net.rs` already does most of the wiring: +- **Portmap** is intercepted in-NAT (`handle_portmap_udp`, `portmap_lookup`/ + `portmap_reply`) — it answers GETPORT with `NFS_VM_PORT=2049` / `MOUNTD_VM_PORT + =1234`. That's our "minimal service discovery"; reuse as-is. +- **NFS/mountd** UDP currently falls through `handle_udp`'s default arm to + `nfs_remap_dst`/`nat_udp` (forward to `unfsd` on host loopback). We **replace + those two cases** with: hand the RPC payload to `Nfsv3Server`, inject the reply. +- **Outbound fragmentation is already solved** — `ip_frames_udp` / + `ip_fragment_frame` fragment a large UDP reply across Ethernet frames; the + guest reassembles. So large READ replies work out of the box. +- **Inbound fragmentation is NOT handled** — `handle_ip` treats each frame as a + whole datagram (no MF/offset reassembly). So large guest WRITEs would break. + v1 mitigates by advertising a small `wtmax` (see below). + +## Architecture + +``` +guest ──UDP RPC──▶ NAT (net.rs handle_udp) + ├─ port 111 → handle_portmap_udp (already) + ├─ port 1234 → Nfsv3Server::mount_call(payload) ◀ new + └─ port 2049 → Nfsv3Server::nfs_call(payload) ◀ new + │ reply bytes + ▼ + ip_frames_udp(...) → enqueue_rx (inject, auto-fragmented) +Nfsv3Server ──std::fs──▶ (the ONLY host I/O) +``` + +- `Nfsv3Server` is **synchronous** (no tokio): `fn nfs_call(&mut self, call: + &[u8]) -> Vec` and `fn mount_call(...)`. Given one RPC call datagram, + produce one reply datagram. The NAT owns it (`Option` in + `NatEngine`/`GatewayConfig`), created at machine start from the NFS config. +- No `start_unfsd`, no loopback ports, no `unfsd` binary. + +## What to reuse from `../nfsserve` (BSD-3-Clause) + +Vendor (copy, with attribution) the **transport-agnostic** pieces; write our own +sync dispatch + backend. Do **not** depend on the crate (it pulls tokio + +async-trait, which we don't want in-core). +- `xdr.rs` — XDR encode/decode primitives (RFC 1014). +- `nfs.rs` / `mount.rs` — the wire structs (`fattr3`, `sattr3`, `fhandle3`, + `diropargs3`, etc.) and constants. +- `nfs_handlers.rs` / `mount_handlers.rs` — reference for each procedure's + semantics (re-implemented synchronously). +- `examples/mirrorfs.rs` — model for the local-dir backend + the path↔id map. + +## RPC + procedures + +- **RPC layer** (RFC 1057): parse call (xid, rpcvers=2, prog, vers, proc; + ignore cred/verf), build accepted reply (success / nfs error). Programs: + MOUNT `100005 v3`, NFS `100003 v3`. +- **MOUNT v3**: NULL, MNT (any path → the single export root fh), UMNT (no-op), + EXPORT/DUMP (minimal/optional). +- **NFS v3**: NULL, GETATTR, SETATTR, LOOKUP, ACCESS (grant all), READLINK, + READ, WRITE, CREATE, MKDIR, REMOVE, RMDIR, RENAME, READDIR, READDIRPLUS, + FSSTAT, FSINFO, PATHCONF, COMMIT (no-op — we write through). Defer: MKNOD, + LINK, SYMLINK. + +## Backing store (the only host interaction) + +- One export = the user's chosen folder. Root fileid = 1. +- **path↔fileid map** in memory (like mirrorfs's `FSMap`): assign sequential + 64-bit ids; map id↔relative path. **Don't** use the host inode (absent/unstable + on Windows). +- **File handles**: opaque `fhandle3` encodes the 8-byte fileid. +- **Faked `fattr3`**: synthesize `mode` (dir 0755 / file 0644, +x by heuristic), + `uid`/`gid` (fixed, e.g. 0 — configurable), `nlink`, `size`/times from host + metadata when present else `now`, `fileid`, fixed `fsid`, `rdev`=0. On Windows, + fully synthetic. +- **Path containment**: every op resolves within the export root; reject `..` + escapes and symlinks that leave the root. (No NFS auth, but containment is + mandatory.) + +## Fragmentation strategy + +- **READ (outbound): large is fine** — advertise `rtmax`/`rtpref` ~8 KB (or + more); `ip_frames_udp` fragments, guest reassembles. +- **WRITE (inbound): avoid fragmentation in v1** — advertise small `wtmax`/ + `wtpref` (~1 KB, fits one frame). Correct but slow writes; no NAT reassembly + needed. +- **v2 perf**: add inbound IP reassembly to `handle_ip` (reassembly buffer keyed + by src/id/proto) → allow large `wsize`. + +## Config / GUI changes + +- `NfsConfig`: keep `shared_dir`; **drop `unfsd`, `nfs_host_port`, + `mountd_host_port`** (no binary, no loopback). Optional: faked `uid`/`gid`. + Migrate old configs via serde defaults / ignore-unknown. +- **Un-gate NFS** in `config_ui.rs` (remove the Windows / macOS-bundled gating — + it now works in-process everywhere, including the sandbox). Drop the "unfsd + binary" field + macOS install hint. +- Mount hint stays **UDP** (IRIX default): `mount :/ /shared` (force + `vers=3`). Replace `start_unfsd` in `main.rs` with constructing `Nfsv3Server` + and handing it to the NAT. + +## Phasing + +- **A — read-only**: MOUNT MNT + NULL/GETATTR/LOOKUP/ACCESS/READ/READDIR(PLUS)/ + FSINFO/FSSTAT/PATHCONF. Goal: IRIX mounts and reads files. +- **B — read-write**: SETATTR/WRITE/CREATE/MKDIR/REMOVE/RMDIR/RENAME/COMMIT (+ a + duplicate-request cache, see Q14). +- **C — perf/extras**: inbound reassembly → large `wsize`; symlinks; tidy. + +--- + +## Open questions (flagged together) + +1. **NFSv2 vs v3 — biggest unknown.** Will IRIX 6.5 mount **v3** cleanly when we + force `vers=3`, or does it default to / fall back to **v2**? If it insists on + v2 we'd need v2 procedures too (different XDR + attrs) — a real scope bump. + *Needs a real-boot test.* +2. **Inbound WRITE fragmentation.** Ship v1 with small `wtmax` (~1 KB, no + reassembly) and accept slow writes, or add inbound IP reassembly up front? +3. **rsize/wsize floors.** Does IRIX 6.5 actually honor a small advertised + `wtmax`/`rtmax`, or does it have a minimum it uses regardless? *Real-boot.* +4. **Blocking I/O on the NAT thread.** The server does synchronous `std::fs` on + the NAT thread (bounded per RPC). Acceptable, or offload to a worker + thread/queue so a slow disk can't stall other NAT traffic? +5. **Faked-perms policy.** Fixed `uid`/`gid` (0? configurable?), `mode` heuristic + (dir 0755 / file 0644 / +x how?), and what to do with the guest's SETATTR + chmod/chown — keep in the in-memory map, persist to a sidecar, or accept-and- + ignore (esp. Windows)? +6. **File-handle stability.** In-memory path↔id map → handles change across IRIS + restarts. The guest remounts on *its* reboot so it's probably fine — but do we + ever need handles stable across an IRIS restart (persist the map)? +7. **Filename encoding.** NFS filenames are opaque bytes; host paths are UTF-8 + (mac/Linux) / UTF-16 (Windows). What's IRIX's filename charset, and how do we + map non-UTF-8 names cross-platform? +8. **Symlinks.** Support READLINK/SYMLINK? Windows symlink creation is + limited/privileged — fake, skip (NOTSUPP), or best-effort? +9. **Special files (MKNOD).** IRIX may try to create device nodes/FIFOs (e.g. + extracting an archive). Return NOTSUPP, or handle? +10. **Read-only v1?** Ship read-only first (safe, smaller), or go straight to + read-write? +11. **Symlink escape policy.** A symlink in the export pointing outside it — + follow (leak) or refuse? (Containment.) +12. **MOUNT export path.** Accept any path in MNT → the single export root, or + honor sub-path mounts? +13. **Vendor vs depend.** Confirm: vendor nfsserve's XDR/NFS/MOUNT structs into + `nfsudp.rs` (sync, no tokio), with BSD-3 attribution — rather than depend + on the crate. +14. **Duplicate-request cache (DRC).** NFS-over-UDP retransmits on timeout; non- + idempotent ops (WRITE/CREATE/REMOVE/RENAME) need a small DRC keyed by + `(xid, src)` to avoid double-apply. In scope for v1 write support? +15. **fsid.** What `fsid` to present (fixed value)? Does IRIX care? +16. **IRIX quirks.** Any IRIX-6.5-specific attribute/poll quirks to expect + (nfsserve's README notes some clients poll oddly with old protocols)? +17. **Config migration.** Old saved configs carry `unfsd`/`nfs_host_port`/ + `mountd_host_port`; drop them without breaking deserialization. +18. **NAT thread ownership.** `Nfsv3Server` lives in/with `NatEngine` (NAT + thread). The GUI sets the backing dir at machine start (config); do we ever + need to change the dir live (like the subnet/forwards), or is start-time + enough? diff --git a/iris-gui/Cargo.toml b/iris-gui/Cargo.toml index 20efd53..68aacf2 100644 --- a/iris-gui/Cargo.toml +++ b/iris-gui/Cargo.toml @@ -66,6 +66,11 @@ toml = "0.8" dirs = "5" log = "0.4" env_logger = "0.10" +# Read the host's own interface addresses (Networking tab: first-free subnet +# presets + overlap warnings). Reads addresses via getifaddrs — no entitlement, +# and it does not trigger the macOS 15 Local Network prompt (that's for talking +# *to* LAN peers, not enumerating your own NICs). +if-addrs = "0.13" # macOS App Sandbox: NSURL security-scoped bookmarks let the App Store build # reopen user-selected disk images / PROMs / ISOs across launches. These crates diff --git a/iris-gui/src/config_ui.rs b/iris-gui/src/config_ui.rs index acfbbeb..ea31b3e 100644 --- a/iris-gui/src/config_ui.rs +++ b/iris-gui/src/config_ui.rs @@ -5,6 +5,7 @@ use iris::config::{ ForwardBind, ForwardProto, MachineConfig, NfsConfig, PortForwardConfig, ScsiDeviceConfig, VinoSource, VinoStandard, VALID_BANK_SIZES, }; +use iris::nfsudp::NfsVersion; /// Which config tab is focused. Toolbar quick-buttons set this. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -99,16 +100,30 @@ pub enum ConfigAction { TestCamera, } -pub fn show_tab(ui: &mut Ui, tab: Tab, cfg: &mut MachineConfig, jit: &mut JitEnv) -> ConfigAction { +/// Everything a config tab hands back to the app for one frame. +#[derive(Default)] +pub struct TabOutcome { + pub action: ConfigAction, + pub net: NetworkOutcome, +} + +pub fn show_tab( + ui: &mut Ui, + tab: Tab, + cfg: &mut MachineConfig, + jit: &mut JitEnv, + host: &[crate::netplan::HostIface], + disk_folders: &[String], +) -> TabOutcome { ScrollArea::vertical().show(ui, |ui| match tab { - Tab::General => show_general(ui, cfg), - Tab::Disks => { show_disks(ui, cfg); ConfigAction::None } - Tab::Network => { show_network(ui, cfg); ConfigAction::None } - Tab::Memory => { show_memory(ui, cfg); ConfigAction::None } - Tab::Display => { show_display(ui, cfg); ConfigAction::None } - Tab::VideoIn => show_vino(ui, cfg), - Tab::Debug => { show_debug(ui, cfg, jit); ConfigAction::None } - Tab::Ci => { show_ci(ui, cfg); ConfigAction::None } + Tab::General => TabOutcome { action: show_general(ui, cfg), ..Default::default() }, + Tab::Disks => { show_disks(ui, cfg); TabOutcome::default() } + Tab::Network => TabOutcome { net: show_network(ui, cfg, host, disk_folders), ..Default::default() }, + Tab::Memory => { show_memory(ui, cfg); TabOutcome::default() } + Tab::Display => { show_display(ui, cfg); TabOutcome::default() } + Tab::VideoIn => TabOutcome { action: show_vino(ui, cfg), ..Default::default() }, + Tab::Debug => { show_debug(ui, cfg, jit); TabOutcome::default() } + Tab::Ci => { show_ci(ui, cfg); TabOutcome::default() } }).inner } @@ -264,8 +279,14 @@ fn show_disks(ui: &mut Ui, cfg: &mut MachineConfig) { ui.end_row(); } - ui.label("Overlay (COW writes -> .overlay)"); - ui.checkbox(&mut dev.overlay, ""); + ui.label("Copy-on-write") + .on_hover_text( + "Keep this session's changes in a separate overlay instead of writing the disk \ + directly, so you can roll back. Off: changes go into the disk (a compressed CHD \ + still uses an overlay, folded back on a clean exit). Raw → .overlay; CHD → \ + .diff.chd. Apply or discard with the monitor: `cow commit` / `cow reset`."); + ui.checkbox(&mut dev.overlay, "") + .on_hover_text("Keep changes separate (roll back with `cow reset`, apply with `cow commit`)"); ui.end_row(); ui.label("Scratch volume"); @@ -301,77 +322,317 @@ fn show_disks(ui: &mut Ui, cfg: &mut MachineConfig) { if let Some(id) = to_delete { cfg.scsi.remove(&id); } } -fn show_network(ui: &mut Ui, cfg: &mut MachineConfig) { +/// A soft-invalid subnet the user just entered, surfaced to the app so it can +/// pop the "Override Sanity Checks / Cancel" confirmation modal. +pub struct NetSanityPrompt { + /// Why it's questionable (non-RFC1918 or a host-network conflict). + pub reason: String, + /// A known-good CIDR offered as the safe alternative. + pub suggestion: String, + /// The `nat_subnet` value before this edit, restored if the user cancels. + pub revert_to: Option, +} + +/// What the Networking tab asks the app to do beyond mutating `cfg`. Routed up +/// through [`show_tab`] because the immediate-mode tab can't own app-level state +/// (the dirty flag, the confirmation modal). +#[derive(Default)] +pub struct NetworkOutcome { + /// A networking field changed this frame → the app should mark cfg dirty. + pub changed: bool, + /// A port-forward rule was added/removed/edited → the app should rebind the + /// running NAT's forward listeners live. + pub forwards_changed: bool, + /// A soft-invalid subnet was just committed → pop the override modal. + pub prompt: Option, +} + +fn show_network(ui: &mut Ui, cfg: &mut MachineConfig, host: &[crate::netplan::HostIface], disk_folders: &[String]) -> NetworkOutcome { + use crate::netplan; + let mut out = NetworkOutcome::default(); ui.heading("Networking"); + + ui.label(RichText::new( + "IRIS gives the Indy its own private NAT network, the same trick your home router uses. \ + The Indy reaches the internet through IRIS, but nothing on your real network can see it. \ + Pick a subnet that does not overlap a network your computer already uses (Wi-Fi, Ethernet, \ + VPN, Docker, etc.). If it does, IRIS flags it below.") + .weak()); + ui.add_space(6.0); + + // The UI exposes the network base address (a plain IPv4, not CIDR) and the + // mask separately; they compose into `cfg.nat_subnet` (a CIDR string the + // backend wants, always snapped to a clean network address). The custom-mode + // flags and the base text buffer persist in egui memory so partial typing + // and the "Custom" reveal survive across frames; `last_id` tracks the value + // we last stored so an external change (Cancel revert, machine switch) + // re-syncs the controls. + let before = cfg.nat_subnet.clone(); + let (base0, prefix0) = netplan::parse_cidr(cfg.nat_subnet.as_deref()); + + let net_custom_id = ui.make_persistent_id("net_net_custom"); + let mask_custom_id = ui.make_persistent_id("net_mask_custom"); + let base_buf_id = ui.make_persistent_id("net_base_buf"); + let last_id = ui.make_persistent_id("net_last_composed"); + + let preset_mask = |p: u8| netplan::MASK_PRESETS.contains(&p); + let mut net_custom: bool = ui.data_mut(|d| d.get_temp::(net_custom_id)).unwrap_or(false); + let mut mask_custom: bool = ui.data_mut(|d| d.get_temp::(mask_custom_id)).unwrap_or(!preset_mask(prefix0)); + let mut base_text: String = ui.data_mut(|d| d.get_temp::(base_buf_id)).unwrap_or_else(|| base0.to_string()); + + let mut prefix = prefix0; + let fa = [ + netplan::first_free(netplan::PrivateBlock::C, prefix, host), + netplan::first_free(netplan::PrivateBlock::B, prefix, host), + netplan::first_free(netplan::PrivateBlock::A, prefix, host), + ]; + + // Re-sync the controls if the stored subnet changed outside this code. + let cur = cfg.nat_subnet.clone().unwrap_or_default(); + let last: String = ui.data_mut(|d| d.get_temp::(last_id)).unwrap_or_default(); + if cur != last { + net_custom = !fa.contains(&base0); + mask_custom = !preset_mask(prefix0); + base_text = base0.to_string(); + } + let mut base = if net_custom { base_text.parse().unwrap_or(base0) } else { base0 }; + + let mut changed = false; // any subnet field changed → recompose + mark dirty + let mut committed = false; // a deliberate commit → eligible for the override modal + Grid::new("nat_grid").num_columns(2).striped(true).show(ui, |ui| { - ui.label("NAT subnet (CIDR)"); - let mut s = cfg.nat_subnet.clone().unwrap_or_default(); - if ui.add(TextEdit::singleline(&mut s).hint_text("192.168.0.0/24").desired_width(220.0)).changed() { - cfg.nat_subnet = if s.is_empty() { None } else { Some(s) }; - } + ui.label("Network"); + ui.horizontal(|ui| { + let sel = if net_custom { "Custom".to_string() } else { base.to_string() }; + ComboBox::from_id_salt("net_preset").selected_text(sel).show_ui(ui, |ui| { + for addr in fa { + if ui.selectable_label(!net_custom && base == addr, addr.to_string()).clicked() { + base = addr; net_custom = false; changed = true; committed = true; + } + } + if ui.selectable_label(net_custom, "Custom").clicked() { + net_custom = true; + base_text = base.to_string(); // seed the field with the current address + } + }); + if net_custom { + let resp = ui.add(TextEdit::singleline(&mut base_text) + .hint_text("192.168.0.0").desired_width(130.0)); + if resp.changed() { + if let Ok(ip) = base_text.parse::() { base = ip; changed = true; } + } + if resp.lost_focus() { committed = true; } + } + // A custom mask shows the prefix right beside the base address. + if mask_custom { + ui.label("/"); + let mut bits = prefix.clamp(8, netplan::MAX_PREFIX); + if ui.add(DragValue::new(&mut bits).range(8..=netplan::MAX_PREFIX)).changed() { + prefix = bits; changed = true; committed = true; + } + } + }); ui.end_row(); + + ui.label("Subnet mask"); + let sel = if mask_custom { format!("Custom (/{prefix})") } else { netplan::mask_label(prefix) }; + ComboBox::from_id_salt("net_mask").selected_text(sel).show_ui(ui, |ui| { + for &p in netplan::MASK_PRESETS { + if ui.selectable_label(!mask_custom && prefix == p, netplan::mask_label(p)).clicked() { + prefix = p; mask_custom = false; changed = true; committed = true; + } + } + if ui.selectable_label(mask_custom, "Custom").clicked() { + mask_custom = true; + if preset_mask(prefix) { prefix = 24; } // default a fresh custom mask to /24 + changed = true; + } + }); + ui.end_row(); + }); + + if changed { + cfg.nat_subnet = Some(netplan::to_cidr(base, prefix)); + out.changed = true; + } + ui.data_mut(|d| { + d.insert_temp(net_custom_id, net_custom); + d.insert_temp(mask_custom_id, mask_custom); + d.insert_temp(base_buf_id, base_text); + d.insert_temp(last_id, cfg.nat_subnet.clone().unwrap_or_default()); }); + // Derived addressing + sanity, from the live (unsnapped) base + prefix so the + // snap note stays consistent across frames. + let assess = netplan::classify(base, prefix, host); + if let Some(msg) = &assess.hard_error { + ui.label(RichText::new(format!("Invalid: {msg}")).color(Color32::from_rgb(0xd9, 0x4a, 0x3d))); + } else if let Some(d) = &assess.derived { + ui.label(RichText::new(format!( + "Gateway (IRIS host) {}, Indy ec0 {}, {} usable hosts, broadcast {}", + d.gateway, d.client, netplan::commas(d.usable_hosts), d.broadcast)).weak()); + if let Some(typed) = assess.off_boundary { + ui.label(RichText::new(format!( + "{typed} is not a network address; using {}/{}.", d.network, d.prefix)).weak()); + } + match &assess.soft { + Some(w) => { + ui.horizontal(|ui| { + let sug = netplan::to_cidr(w.suggestion_net, w.suggestion_prefix); + ui.label(RichText::new(format!("Warning: {}", w.reason)) + .color(Color32::from_rgb(0xd9, 0x9a, 0x3d))); + if ui.button(format!("Use {sug}")).clicked() { + cfg.nat_subnet = Some(sug); // external-change re-sync repaints the controls + out.changed = true; + } + }); + } + None => { + ui.label(RichText::new("OK: private range, no conflict with your host networks") + .color(Color32::from_rgb(0x35, 0xb8, 0x4a))); + } + } + // Pop the override modal when a deliberate edit (preset/mask pick, custom + // mask bits, or the base field losing focus) lands on a soft-invalid + // subnet. Live keystrokes don't trigger it — only a committed value does. + if committed && assess.soft.is_some() { + let w = assess.soft.as_ref().unwrap(); + out.prompt = Some(NetSanityPrompt { + reason: w.reason.clone(), + suggestion: netplan::to_cidr(w.suggestion_net, w.suggestion_prefix), + revert_to: before.clone(), + }); + } + } + ui.separator(); ui.strong("Port forwards"); let mut drop: Option = None; for (i, pf) in cfg.port_forward.iter_mut().enumerate() { ui.horizontal(|ui| { + let mut c = false; ComboBox::from_id_salt(("proto", i)) .selected_text(match pf.proto { ForwardProto::Tcp => "tcp", ForwardProto::Udp => "udp" }) .show_ui(ui, |ui| { - ui.selectable_value(&mut pf.proto, ForwardProto::Tcp, "tcp"); - ui.selectable_value(&mut pf.proto, ForwardProto::Udp, "udp"); + c |= ui.selectable_value(&mut pf.proto, ForwardProto::Tcp, "tcp").changed(); + c |= ui.selectable_value(&mut pf.proto, ForwardProto::Udp, "udp").changed(); }); ui.label("host"); - ui.add(DragValue::new(&mut pf.host_port).range(1..=65535)); - ui.label("-> guest"); - ui.add(DragValue::new(&mut pf.guest_port).range(1..=65535)); + c |= ui.add(DragValue::new(&mut pf.host_port).range(1..=65535)).changed(); + ui.label("to guest"); + c |= ui.add(DragValue::new(&mut pf.guest_port).range(1..=65535)).changed(); ComboBox::from_id_salt(("bind", i)) .selected_text(match pf.bind { ForwardBind::Localhost => "localhost", ForwardBind::Any => "any" }) .show_ui(ui, |ui| { - ui.selectable_value(&mut pf.bind, ForwardBind::Localhost, "localhost"); - ui.selectable_value(&mut pf.bind, ForwardBind::Any, "any"); + c |= ui.selectable_value(&mut pf.bind, ForwardBind::Localhost, "localhost").changed(); + c |= ui.selectable_value(&mut pf.bind, ForwardBind::Any, "any").changed(); }); - if ui.button("×").clicked() { drop = Some(i); } - }); - } - if let Some(i) = drop { cfg.port_forward.remove(i); } - if ui.button("+ Add forward").clicked() { - cfg.port_forward.push(PortForwardConfig { - proto: ForwardProto::Tcp, host_port: 0, guest_port: 0, bind: ForwardBind::Localhost, + if ui.button("Remove").clicked() { drop = Some(i); } + out.changed |= c; + out.forwards_changed |= c; }); } + if let Some(i) = drop { cfg.port_forward.remove(i); out.changed = true; out.forwards_changed = true; } + + let has_port = |p: u16| cfg.port_forward.iter().any(|f| f.guest_port == p); + let mut add: Option = None; + ui.menu_button("+ Add forward", |ui| { + if ui.add_enabled(!has_port(23), egui::Button::new("Telnet (host 2323 to guest 23)")) + .on_hover_text("Log in with: telnet localhost 2323. Needs the guest on IRIS's NAT subnet with telnetd running.") + .clicked() + { + add = Some(PortForwardConfig { proto: ForwardProto::Tcp, host_port: 2323, guest_port: 23, bind: ForwardBind::Localhost }); + ui.close_menu(); + } + if ui.add_enabled(!has_port(21), egui::Button::new("FTP (host 2121 to guest 21)")) + .on_hover_text("Reach the guest's FTP server. Forwards the control port; file transfer also needs the data channel (see docs).") + .clicked() + { + add = Some(PortForwardConfig { proto: ForwardProto::Tcp, host_port: 2121, guest_port: 21, bind: ForwardBind::Localhost }); + ui.close_menu(); + } + if ui.button("Custom (empty row)").clicked() { + add = Some(PortForwardConfig { proto: ForwardProto::Tcp, host_port: 0, guest_port: 0, bind: ForwardBind::Localhost }); + ui.close_menu(); + } + }); + if let Some(pf) = add { cfg.port_forward.push(pf); out.changed = true; out.forwards_changed = true; } + + ui.label(RichText::new( + "A port forward maps a port on your computer to a port on the Indy, so host tools can reach \ + guest services (log in, copy files, and so on). Inbound only, and it works once the guest is \ + up on the NAT subnet. None exist by default.") + .weak()); ui.separator(); ui.strong("NFS share"); + ui.label(RichText::new( + "The Indy speaks NFS natively — the easiest way to move files between your computer and the \ + emulated machine. IRIS serves NFS itself, in-process, backed by the folder you pick below: \ + nothing to install on any platform, and no NFS setup on the Indy side.") + .weak()); let mut has_nfs = cfg.nfs.is_some(); if ui.checkbox(&mut has_nfs, "Enable NFS").changed() { cfg.nfs = if has_nfs { - Some(NfsConfig { - shared_dir: String::new(), - unfsd: "unfsd".into(), - nfs_host_port: 12049, - mountd_host_port: 11234, - }) + Some(NfsConfig { shared_dir: String::new(), version: NfsVersion::Auto }) } else { None }; + out.changed = true; } if let Some(nfs) = cfg.nfs.as_mut() { Grid::new("nfs_grid").num_columns(2).striped(true).show(ui, |ui| { ui.label("Shared dir"); - path_row(ui, "nfs_shared", &mut nfs.shared_dir, Pick::Dir, ANY_FILTERS); - ui.end_row(); - ui.label("unfsd binary"); - path_row(ui, "nfs_unfsd", &mut nfs.unfsd, Pick::OpenFile, ANY_FILTERS); - ui.end_row(); - ui.label("NFS host port"); - ui.add(DragValue::new(&mut nfs.nfs_host_port).range(1..=65535)); + out.changed |= path_row(ui, "nfs_shared", &mut nfs.shared_dir, Pick::Dir, ANY_FILTERS); ui.end_row(); - ui.label("mountd host port"); - ui.add(DragValue::new(&mut nfs.mountd_host_port).range(1..=65535)); + ui.label("NFS version"); + ComboBox::from_id_salt("nfs_ver") + .selected_text(match nfs.version { + NfsVersion::Auto => "Auto", + NfsVersion::V2 => "v2 (IRIX 5.3)", + NfsVersion::V3 => "v3 (IRIX 6.x)", + }) + .show_ui(ui, |ui| { + out.changed |= ui.selectable_value(&mut nfs.version, NfsVersion::Auto, "Auto").changed(); + out.changed |= ui.selectable_value(&mut nfs.version, NfsVersion::V2, "v2 (IRIX 5.3)").changed(); + out.changed |= ui.selectable_value(&mut nfs.version, NfsVersion::V3, "v3 (IRIX 6.x)").changed(); + }); ui.end_row(); }); + + // App Store: the in-core NFS server can only reach a folder the sandbox + // has granted. Easiest path — put the share inside a granted disk folder, + // whose recursive grant flows down to it (you can also pick any folder + // above; the folder picker grants that one directly). + if cfg!(feature = "appstore") { + ui.add_space(4.0); + if disk_folders.is_empty() { + ui.label(RichText::new( + "On the App Store build the shared folder must live somewhere the app has been \ + granted. Grant a disk folder first (File → \"Grant a disk folder…\"), then create \ + a shared folder inside it here — or pick any folder above to grant it directly.") + .weak()); + } else { + ui.label(RichText::new("Or create a \"shared\" folder inside a granted disk folder:").weak()); + for folder in disk_folders { + let shared = std::path::Path::new(folder).join("shared"); + if ui.button(format!("Use {}", shared.display())).clicked() + && std::fs::create_dir_all(&shared).is_ok() + { + nfs.shared_dir = shared.to_string_lossy().into_owned(); + out.changed = true; + } + } + } + } + + // Live mount command — gateway fills in to match the subnet. The export + // is the single root, so the path is just "/". + let gw = assess.derived.as_ref().map(|d| d.gateway.to_string()).unwrap_or_else(|| "192.168.0.1".into()); + ui.label(RichText::new("Pick a folder, boot the Indy, then mount it:").weak()); + ui.code(format!("mkdir /shared\nmount {gw}:/ /shared")); + ui.label(RichText::new("Your files then appear at /shared on the Indy.").weak()); } + + out } fn show_vino(ui: &mut Ui, cfg: &mut MachineConfig) -> ConfigAction { @@ -541,18 +802,40 @@ enum Pick { Dir, } -/// A TextEdit + 📁 Browse button that updates `value` in place. -/// `filters` is a list of (label, &[extensions]); ignored for `Pick::Dir`. +/// Reveal `path` in the host file manager, selecting it (Finder on macOS, +/// Explorer on Windows, the default manager on the containing dir elsewhere). +/// Best-effort — failures (e.g. a sandbox blocking the spawn) are ignored. +pub fn reveal_in_file_manager(path: &str) { + #[cfg(target_os = "macos")] + { + // NSWorkspace, not `open` — sandbox-safe (see macos_sandbox::reveal_in_finder). + crate::macos_sandbox::reveal_in_finder(path); + } + #[cfg(target_os = "windows")] + { + let _ = std::process::Command::new("explorer").arg(format!("/select,{path}")).spawn(); + } + #[cfg(all(unix, not(target_os = "macos")))] + { + let dir = Path::new(path).parent().unwrap_or_else(|| Path::new(".")); + let _ = std::process::Command::new("xdg-open").arg(dir).spawn(); + } +} + +/// A TextEdit + 📁 Browse button that updates `value` in place. Returns whether +/// `value` changed this frame, so callers can mark the config dirty (typed text +/// or a Browse pick — both must persist). fn path_row( ui: &mut Ui, id: impl std::hash::Hash, value: &mut String, mode: Pick, filters: &[(&str, &[&str])], -) { +) -> bool { + let mut changed = false; ui.push_id(id, |ui| { ui.horizontal(|ui| { - ui.add(TextEdit::singleline(value).desired_width(320.0)); + changed |= ui.add(TextEdit::singleline(value).desired_width(320.0)).changed(); if ui.button("📁").on_hover_text("Browse…").clicked() { let mut d = rfd::FileDialog::new(); // Start the dialog in the existing path's directory if any. @@ -579,10 +862,19 @@ fn path_row( }; if let Some(p) = picked { *value = p.to_string_lossy().into_owned(); + changed = true; } } + // Reveal an existing path in the host file manager (Finder, Explorer, + // …) so the user can find/open it without navigating by hand. + if !value.trim().is_empty() && Path::new(value.trim()).exists() + && ui.button("📂").on_hover_text("Reveal in file manager").clicked() + { + reveal_in_file_manager(value.trim()); + } }); }); + changed } /// Same as `path_row` but for `Option` — Browse populates Some, diff --git a/iris-gui/src/handle.rs b/iris-gui/src/handle.rs index e9c696b..b8b6db2 100644 --- a/iris-gui/src/handle.rs +++ b/iris-gui/src/handle.rs @@ -1,9 +1,10 @@ use crate::framebuffer::{CaptureRenderer, FrameSink}; use crossbeam_channel::{unbounded, Receiver, Sender}; -use iris::config::MachineConfig; +use iris::config::{MachineConfig, PortForwardConfig}; use iris::machine::Machine; use iris::ps2::Ps2Controller; use parking_lot::Mutex; +use std::net::Ipv4Addr; use std::path::PathBuf; use std::sync::Arc; use std::thread::JoinHandle; @@ -15,9 +16,27 @@ pub enum Cmd { /// Type `halt\n` at the IRIX serial console in-process (no loopback socket) /// for a clean guest shutdown. HaltIrix, + /// Move the running NAT onto a new subnet live (CIDR string, e.g. + /// `192.168.1.0/24`) — no reboot. Ignored if not running / invalid. + SetNatSubnet(String), + /// Rebind the running NAT's inbound port-forward listeners from this rule + /// set, live — no reboot. Ignored if not running. + SetPortForwards(Vec), SaveState(String), RestoreState(String), Screenshot(PathBuf), + /// Stop the machine and fold any pending CHD `.diff.chd` sidecars back into + /// their bases ("Synchronizing disks"), emitting `SyncProgress`/`SyncDone`. + /// Sent on a clean exit when `Status::chd_sync_pending` is set. + SyncDisks, + /// Commit a single disk's COW overlay into its base ("apply changes"). File- + /// level (no machine) — only valid while stopped. `chd` picks the `.diff.chd` + /// vs raw `.overlay` path. A CHD commit streams `SyncProgress`/`SyncDone` + /// (it recompresses); a raw commit ends with `CowDone`. + CowCommit { base: String, chd: bool }, + /// Discard a single disk's COW overlay ("roll back") — delete the + /// `.diff.chd` / `.overlay`. File-level; only valid while stopped. + CowReset { base: String, chd: bool }, Quit, } @@ -35,6 +54,14 @@ pub enum Evt { Screenshot(PathBuf), Error(String), Status(Status), + /// Per-disk progress of a `SyncDisks` run: `disk` of `total` disks, the + /// current disk `fraction` (0.0..=1.0) through its rebuild. + SyncProgress { disk: usize, total: usize, fraction: f32 }, + /// `SyncDisks` finished; the app may now close. Carries the disks synced. + SyncDone(usize), + /// A `CowCommit` (raw) or `CowReset` finished. `committed` = changes were + /// applied (vs rolled back / nothing to do). + CowDone { committed: bool }, } #[derive(Debug, Clone, Default)] @@ -52,6 +79,48 @@ pub struct Status { /// PROM after an IRIX `halt` (0 MIPS). When set, the guest has shut down and /// stopping the machine can't corrupt a disk — see [`crate::safe_stop`]. pub cpu_halted: bool, + /// The CPU thread has actually stopped — a soft power-off called + /// `Machine::stop`. Unlike `cpu_halted` this is NOT set by mere 0-MIPS idle + /// (PROM prompt, idle desktop), so it's the precise "the machine powered + /// off" signal the framebuffer overlay uses. + pub cpu_stopped: bool, + /// At least one attached CHD has diff-borne changes pending a fold-back into + /// its base on a clean shutdown — drives the "Synchronizing disks" step. + pub chd_sync_pending: bool, + /// Cumulative count of guest Ethernet frames the NAT engine has processed. + /// Monotonic within a run; the handle watches it advance to light the + /// internal-network indicator (see [`EmulatorHandle::net_state`]). + pub net_frames: u64, + /// The guest's observed source IP (None until a frame reveals one) and the + /// address NAT expects it to have. Drive the "Check networking" diagnosis. + pub net_guest_ip: Option, + /// The guest's likely default gateway (passively inferred from its ARPs). + pub net_guest_gateway: Option, + /// IRIS's current NAT gateway (reflects any live adoption). + pub net_nat_gateway: Option, +} + +/// State of the internal-network ("NET") indicator shown next to MIPS. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NetState { + /// Guest isn't executing — stopped, halted, or idle at the PROM (grey). + Off, + /// NAT IP traffic has flowed this run — networking is up (green). + Active, + /// Running, but no NAT IP traffic seen yet this run (red). + Idle, +} + +/// Pure decision for the NET indicator, factored out so it's unit-testable +/// without a live machine. Grey whenever the guest isn't executing; otherwise +/// green once NAT IP traffic has been seen this run (`net_seen > 0`) — the +/// signal latches, since a guest that has networked doesn't become misconfigured +/// just by going idle — else red. +fn net_state_for(running: bool, halted: bool, net_seen: u64) -> NetState { + if !running || halted { + return NetState::Off; + } + if net_seen > 0 { NetState::Active } else { NetState::Idle } } pub struct EmulatorHandle { @@ -66,6 +135,10 @@ pub struct EmulatorHandle { /// `None` when no machine is up. pub ps2: Arc>>>, pub status: Status, + /// NAT IP-frame count observed this run (reset on Start). Non-zero once the + /// guest's networking has actually carried traffic; latches the NET + /// indicator green for the rest of the run. + net_seen_frames: u64, } impl EmulatorHandle { @@ -95,6 +168,7 @@ impl EmulatorHandle { frame_sink, ps2, status: Status::default(), + net_seen_frames: 0, } } @@ -113,9 +187,30 @@ impl EmulatorHandle { self.status.mips = s.mips; self.status.dirty_cow = s.dirty_cow; self.status.cpu_halted = s.cpu_halted; + self.status.cpu_stopped = s.cpu_stopped; + self.status.chd_sync_pending = s.chd_sync_pending; + // Latch the NET light: once NAT IP traffic has flowed this run + // the guest's networking is up, so keep it green through idle + // lulls (it resets to red on the next Start). + if s.net_frames > self.net_seen_frames { + self.net_seen_frames = s.net_frames; + } + self.status.net_frames = s.net_frames; + self.status.net_guest_ip = s.net_guest_ip; + self.status.net_guest_gateway = s.net_guest_gateway; + self.status.net_nat_gateway = s.net_nat_gateway; } match &evt { - Evt::Started => self.status.running = true, + Evt::Started => { + self.status.running = true; + // Clear a stale stop from the previous run so the new boot + // isn't dimmed before the first status tick lands. + self.status.cpu_stopped = false; + // Fresh machine → fresh NAT counter (starts at 0); reset our + // tracking so the indicator starts red and only greens on + // this run's first observed NAT traffic. + self.net_seen_frames = 0; + } Evt::Stopped => self.status.running = false, Evt::PowerOff => self.status.power_off_seen = true, _ => {} @@ -127,6 +222,17 @@ impl EmulatorHandle { pub fn is_running(&self) -> bool { self.status.running } + /// Whether a clean exit needs a "Synchronizing disks" step (a CHD has + /// diff-borne changes to fold back into its base). Latest reported status. + pub fn has_pending_chd_sync(&self) -> bool { self.status.chd_sync_pending } + + /// State of the internal-network indicator: grey when the guest isn't + /// executing (stopped/halted/PROM), green once NAT IP traffic has flowed + /// this run, red while a running guest has produced no NAT traffic yet. + pub fn net_state(&self) -> NetState { + net_state_for(self.status.running, self.status.cpu_halted, self.net_seen_frames) + } + /// Stop the machine (if running) and join the worker thread. Idempotent. /// Call this from the GUI's `on_exit` so a running machine is cleaned up /// even when the user closes the window without pressing Stop — and so the @@ -149,6 +255,23 @@ impl Drop for EmulatorHandle { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn net_indicator_states() { + // Unpowered (not running) → grey, regardless of past traffic. + assert_eq!(net_state_for(false, false, 42), NetState::Off); + // Running but halted / idle at the PROM → grey. + assert_eq!(net_state_for(true, true, 42), NetState::Off); + // Running, no NAT traffic seen yet → red. + assert_eq!(net_state_for(true, false, 0), NetState::Idle); + // Running, NAT traffic has flowed → green (and latches, so it stays). + assert_eq!(net_state_for(true, false, 1), NetState::Active); + } +} + /// Worker thread loop. Owns the `Machine` while it exists. The eframe app /// thread sends `Cmd`s and drains `Evt`s, never touching the machine /// directly. All `Machine` calls are wrapped in `catch_unwind` so a panic @@ -187,7 +310,16 @@ fn worker_loop( // instructions this window (halted/idle at the PROM, 0 MIPS). let cpu_stopped = machine.as_ref().map_or(true, |m| !m.cpu_is_running()); let cpu_halted = cpu_stopped || mips == 0.0; - let _ = evt_tx.send(Evt::Status(Status { mips, cpu_halted, ..Status::default() })); + let chd_sync_pending = machine.as_ref().map_or(false, |m| m.pending_chd_sync_count() > 0); + let net_frames = machine.as_ref().map_or(0, |m| m.net_guest_frames()); + let net_guest_ip = machine.as_ref().and_then(|m| m.net_observed_guest_ip()); + let net_guest_gateway = machine.as_ref().and_then(|m| m.net_observed_gateway()); + let net_nat_gateway = machine.as_ref().map(|m| m.nat_expected().1); + let _ = evt_tx.send(Evt::Status(Status { + mips, cpu_halted, cpu_stopped, chd_sync_pending, + net_frames, net_guest_ip, net_guest_gateway, net_nat_gateway, + ..Status::default() + })); } } continue; @@ -228,6 +360,11 @@ fn worker_loop( })); match result { Ok(m) => { + // Tell the NAT the host's own networks so it won't adopt a + // guest subnet that overlaps the host's real LAN/VPN/Docker. + m.set_host_nets( + crate::netplan::gather_host_ifaces() + .into_iter().map(|h| (h.network, h.prefix)).collect()); *ps2_slot.lock() = Some(m.get_ps2()); // Latch REX3's cycle counter for the live MIPS estimate. cycles = m.get_rex3().map(|r| r.cycles.clone()); @@ -251,6 +388,18 @@ fn worker_loop( None => { let _ = evt_tx.send(Evt::Error("halt: not running".into())); } } } + Ok(Cmd::SetNatSubnet(cidr)) => { + match machine.as_ref() { + Some(m) => match iris::config::parse_nat_subnet(&cidr) { + Ok((gateway, client, netmask)) => m.set_nat_subnet(gateway, client, netmask), + Err(e) => { let _ = evt_tx.send(Evt::Error(format!("set NAT subnet '{cidr}': {e}"))); } + }, + None => { let _ = evt_tx.send(Evt::Error("set NAT subnet: not running".into())); } + } + } + Ok(Cmd::SetPortForwards(rules)) => { + if let Some(m) = machine.as_ref() { m.set_port_forwards(rules); } + } Ok(Cmd::Stop) => { if let Some(m) = machine.take() { *ps2_slot.lock() = None; @@ -265,6 +414,80 @@ fn worker_loop( let _ = evt_tx.send(Evt::Error("not running".into())); } } + Ok(Cmd::SyncDisks) => { + // Clean-exit disk sync: stop the machine (quiescing disk I/O), + // then fold each pending CHD diff back into its base, streaming + // progress so the GUI can show "Synchronizing disks…". The + // machine is dropped afterwards, exactly like a Stop. + let mut synced = 0usize; + if let Some(mut m) = machine.take() { + *ps2_slot.lock() = None; + cycles = None; + m.stop(); + synced = m + .sync_chd_disks( + &mut |disk, total, fraction| { + let _ = evt_tx.send(Evt::SyncProgress { disk, total, fraction }); + }, + &|| false, + ) + .unwrap_or(0); + // `m` dropped here → fully torn down. + } + let _ = evt_tx.send(Evt::Stopped); + let _ = evt_tx.send(Evt::SyncDone(synced)); + } + Ok(Cmd::CowCommit { base, chd }) => { + // File-level commit (the GUI only offers this while stopped, so + // the disk files are closed). CHD recompresses with progress; raw + // applies the overlay in place. + if chd { + let diff = iris::chd_disk::diff_path_for(std::path::Path::new(&base)); + if diff.exists() { + let _ = evt_tx.send(Evt::SyncProgress { disk: 0, total: 1, fraction: 0.0 }); + match iris::chd_disk::flatten_diff( + std::path::Path::new(&base), + &diff, + &mut |f| { let _ = evt_tx.send(Evt::SyncProgress { disk: 0, total: 1, fraction: f }); }, + &|| false, + ) { + Ok(()) => { let _ = evt_tx.send(Evt::SyncDone(1)); } + Err(e) => { + let _ = evt_tx.send(Evt::Error(format!("commit failed: {e}"))); + let _ = evt_tx.send(Evt::SyncDone(0)); + } + } + } else { + let _ = evt_tx.send(Evt::CowDone { committed: false }); + } + } else { + let overlay = format!("{base}.overlay"); + if std::path::Path::new(&overlay).exists() { + match iris::cow_disk::CowDisk::new(&base, &overlay).and_then(|mut c| c.commit()) { + Ok(_) => { let _ = evt_tx.send(Evt::CowDone { committed: true }); } + Err(e) => { let _ = evt_tx.send(Evt::Error(format!("commit failed: {e}"))); } + } + } else { + let _ = evt_tx.send(Evt::CowDone { committed: false }); + } + } + } + Ok(Cmd::CowReset { base, chd }) => { + // Roll back: discard the overlay. File-level; stopped-only. + let target = if chd { + iris::chd_disk::diff_path_for(std::path::Path::new(&base)) + } else { + let _ = std::fs::remove_file(format!("{base}.overlay.dirty")); + std::path::PathBuf::from(format!("{base}.overlay")) + }; + match std::fs::remove_file(&target) { + Ok(()) => { let _ = evt_tx.send(Evt::CowDone { committed: false }); } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + let _ = evt_tx.send(Evt::CowDone { committed: false }); + } + Err(e) => { let _ = evt_tx.send(Evt::Error(format!("roll back failed: {e}"))); } + } + } Ok(Cmd::SaveState(name)) => { let Some(m) = machine.as_mut() else { let _ = evt_tx.send(Evt::Error("save: not running".into())); diff --git a/iris-gui/src/input.rs b/iris-gui/src/input.rs index 5ed314a..7e79fee 100644 --- a/iris-gui/src/input.rs +++ b/iris-gui/src/input.rs @@ -33,19 +33,31 @@ pub struct InputState { last_buttons: u8, // bit0=L, bit1=R, bit2=M, bit3=B4, bit4=B5 /// True while the host cursor is grabbed and input is routed to the guest. pub captured: bool, + /// When the window first reported unfocused while captured. Used to debounce + /// a real focus loss (alt-tab) from the spurious single-frame `focused=false` + /// flickers macOS emits around cursor-grab / notifications / Spaces, which + /// otherwise dropped capture mid-typing. `None` while focused. + unfocused_since: Option, } impl Default for InputState { fn default() -> Self { - Self { last_mods: Modifiers::NONE, last_buttons: 0, captured: false } + Self { last_mods: Modifiers::NONE, last_buttons: 0, captured: false, unfocused_since: None } } } +/// How long the window must stay unfocused before a capture is released. Long +/// enough to ride out macOS's transient focus flicker, short enough that a real +/// alt-tab frees the cursor promptly. +const FOCUS_LOSS_GRACE: std::time::Duration = std::time::Duration::from_millis(300); + pub fn pump(ctx: &egui::Context, fb_clicked: bool, ps2: &Ps2Controller, state: &mut InputState, scroll_pixels_per_line: f64) { // Collect everything we need inside the input borrow, then act afterwards // (sending viewport commands / PS2 writes outside the `input()` closure). let mut want_enter = false; let mut want_release = false; + let mut esc_chord = false; + let mut focused = true; let mut dx = 0.0f32; let mut dy = 0.0f32; let mut dz = 0.0f32; @@ -63,13 +75,14 @@ pub fn pump(ctx: &egui::Context, fb_clicked: bool, ps2: &Ps2Controller, state: & return; } - // Captured. Ctrl+Alt+Esc (Alt == Option on macOS) — or losing window - // focus (alt-tab) — releases. Using a chord rather than bare Esc lets - // plain Esc reach the guest. - if (i.key_pressed(Key::Escape) && i.modifiers.ctrl && i.modifiers.alt) || !i.focused { - want_release = true; - return; - } + // Captured. Ctrl+Alt+Esc (Alt == Option on macOS) is the release chord; + // a real focus loss (alt-tab) also releases, but only after a grace + // period (decided below) so a one-frame `focused=false` flicker doesn't + // drop capture while you're typing. A chord rather than bare Esc lets + // plain Esc reach the guest. Keep reading events even on a flicker frame + // so typing keeps flowing to the guest. + esc_chord = i.key_pressed(Key::Escape) && i.modifiers.ctrl && i.modifiers.alt; + focused = i.focused; mods = i.modifiers; @@ -101,14 +114,25 @@ pub fn pump(ctx: &egui::Context, fb_clicked: bool, ps2: &Ps2Controller, state: & buttons = b; }); + // Decide whether to release, outside the input borrow. The Esc chord is + // immediate; focus loss is debounced over `FOCUS_LOSS_GRACE` so a transient + // macOS flicker doesn't drop capture, while a genuine alt-tab still does. + if state.captured { + if esc_chord { + want_release = true; + } else if !focused { + let now = std::time::Instant::now(); + let since = *state.unfocused_since.get_or_insert(now); + if now.duration_since(since) >= FOCUS_LOSS_GRACE { + want_release = true; + } + } else { + state.unfocused_since = None; + } + } + if want_enter { - state.captured = true; - // Anchor modifier/button state so we don't synth a spurious press for - // a key/button already held at capture time. - state.last_mods = ctx.input(|i| i.modifiers); - state.last_buttons = 0; - ctx.send_viewport_cmd(ViewportCommand::CursorVisible(false)); - ctx.send_viewport_cmd(ViewportCommand::CursorGrab(grab_mode())); + engage_capture(ctx, state); return; } @@ -168,6 +192,21 @@ fn grab_mode() -> CursorGrab { } } +/// Engage capture: grab + hide the host cursor and route input to the guest, +/// exactly as a click on the framebuffer does. Driven both by that click (via +/// `pump`) and by the side-panel "Capture" button. No-op if already captured. +pub fn engage_capture(ctx: &egui::Context, state: &mut InputState) { + if state.captured { return; } + state.captured = true; + // Anchor modifier/button state so we don't synth a spurious press for a + // key/button already held at capture time. + state.last_mods = ctx.input(|i| i.modifiers); + state.last_buttons = 0; + state.unfocused_since = None; + ctx.send_viewport_cmd(ViewportCommand::CursorVisible(false)); + ctx.send_viewport_cmd(ViewportCommand::CursorGrab(grab_mode())); +} + /// Release a capture: show + ungrab the host cursor and lift any modifiers /// we'd synthesised, so the guest doesn't see stuck keys. Safe to call when /// not captured (no-op). Used both for Esc/focus-loss and when the emulator @@ -181,6 +220,7 @@ pub fn release_capture(ctx: &egui::Context, ps2: &Ps2Controller, state: &mut Inp state.captured = false; state.last_mods = Modifiers::NONE; state.last_buttons = 0; + state.unfocused_since = None; ctx.send_viewport_cmd(ViewportCommand::CursorVisible(true)); ctx.send_viewport_cmd(ViewportCommand::CursorGrab(CursorGrab::None)); } @@ -193,6 +233,7 @@ pub fn force_release(ctx: &egui::Context, state: &mut InputState) { state.captured = false; state.last_mods = Modifiers::NONE; state.last_buttons = 0; + state.unfocused_since = None; ctx.send_viewport_cmd(ViewportCommand::CursorVisible(true)); ctx.send_viewport_cmd(ViewportCommand::CursorGrab(CursorGrab::None)); } diff --git a/iris-gui/src/macos_sandbox.rs b/iris-gui/src/macos_sandbox.rs index cc86c40..1dea27f 100644 --- a/iris-gui/src/macos_sandbox.rs +++ b/iris-gui/src/macos_sandbox.rs @@ -49,7 +49,6 @@ pub fn config_paths(cfg: &MachineConfig) -> Vec { } if let Some(nfs) = &cfg.nfs { add(&nfs.shared_dir); - add(&nfs.unfsd); } out } @@ -75,6 +74,27 @@ pub fn restore(bookmarks: &BTreeMap>) { } } +/// Reveal `path` in Finder (select it). Uses `NSWorkspace` rather than spawning +/// `open`, so it works under the App Sandbox (which forbids launching helper +/// processes). Best-effort; a missing path just does nothing. Available on all +/// macOS builds (objc2 is a macOS-wide dependency, not gated on `appstore`). +#[cfg(target_os = "macos")] +pub fn reveal_in_finder(path: &str) { + use objc2::runtime::AnyObject; + use objc2::{class, msg_send}; + use objc2_foundation::NSString; + + let ns_path = NSString::from_str(path); + let root = NSString::from_str(""); // empty root → Finder picks the parent + // SAFETY: `+[NSWorkspace sharedWorkspace]` returns the shared singleton; + // `-selectFile:inFileViewerRootedAtPath:` takes two NSString* and returns + // BOOL. AppKit is loaded (this is a GUI app), and both strings are valid. + unsafe { + let ws: *mut AnyObject = msg_send![class!(NSWorkspace), sharedWorkspace]; + let _: bool = msg_send![ws, selectFile: &*ns_path, inFileViewerRootedAtPath: &*root]; + } +} + #[cfg(all(target_os = "macos", feature = "appstore"))] mod imp { use objc2_foundation::{ diff --git a/iris-gui/src/main.rs b/iris-gui/src/main.rs index 0524748..e98cdfe 100644 --- a/iris-gui/src/main.rs +++ b/iris-gui/src/main.rs @@ -7,6 +7,8 @@ mod framebuffer; mod handle; mod input; mod macos_sandbox; +mod netfix; +mod netplan; mod safe_stop; mod scsi_menu; mod serial_console; @@ -18,7 +20,7 @@ use dialogs::create_disk::CreateDiskDialog; use dialogs::new_machine::{distribute_ram, NewMachineDialog}; use eframe::egui; use egui::{Color32, RichText, ViewportCommand}; -use handle::{Cmd, EmulatorHandle, Evt}; +use handle::{Cmd, EmulatorHandle, Evt, NetState}; use iris::config::MachineConfig; use safe_stop::{evaluate, reason_lines}; use settings::{GuiSettings, UI_SCALE_MAX, UI_SCALE_MIN, WINDOW_DEFAULT_SIZE}; @@ -117,7 +119,7 @@ fn main() -> eframe::Result<()> { // Open large enough for the 1280×1024 display + chrome so it looks right // immediately; clamp to the monitor so it can't overflow a smaller // screen. Persisted size (once saved) takes precedence over the default. - .with_inner_size(prefs.window_size.unwrap_or(WINDOW_DEFAULT_SIZE)) + .with_inner_size(WINDOW_DEFAULT_SIZE) .with_clamp_size_to_monitor_size(true) // Start hidden so the first frame can fit the window to the monitor // (see the reveal logic in `update`) before it's shown — the window @@ -159,11 +161,19 @@ struct App { missing_modal: Option, /// Set when the user clicks "Use embedded PROM"; drives a confirmation modal. confirm_embedded_prom: bool, + /// Host interface networks (from `if-addrs`), used by the Networking tab for + /// first-free subnet presets and overlap warnings. Sampled once at launch. + net_ifaces: Vec, + /// Set when the Networking tab commits a subnet that fails the sanity checks + /// (non-RFC1918 or a host conflict); drives the Override/Cancel modal. + net_sanity_modal: Option, new_machine: NewMachineDialog, create_disk: CreateDiskDialog, /// If true, central panel shows the tabbed config editor; otherwise the /// welcome/status summary panel (default — most config lives in menus). show_config_editor: bool, + /// Whether the "Check networking" diagnosis window is open. + show_net_check: bool, save_state_name: String, restore_state_name: String, /// egui texture holding the most recent REX3 framebuffer. Allocated @@ -189,10 +199,10 @@ struct App { /// Not set on Start — the window size is latched at app load, so launching /// the VM never resizes the window. pending_fb_snap: bool, - /// True on a first-ever launch (no persisted window size). Consumed on the - /// first frame that knows the monitor size, to fit the window to a 1280×1024 - /// display at the chosen VM scale so it opens at a sensible, windowed size - /// before the first Start. Returning users just reopen at their saved size. + /// Set at every launch. Consumed on the first frame that knows the monitor + /// size, to fit the window to a 1280×1024 display at the chosen VM scale. + /// Window size is no longer persisted — each launch re-derives it from + /// `vm_scale`, so the window opens at a consistent scale every time. pending_launcher_fit: bool, /// The window starts hidden (`with_visible(false)`) so the first frame can /// fit it to the monitor before it's shown. Set true once we've revealed it. @@ -222,12 +232,47 @@ struct App { serial_input: String, /// Whether the "How camera & networking work" Help window is open. show_help_info: bool, + /// Whether the "Mount the shared folder in IRIX" Help window is open. + show_nfs_help: bool, + /// Active "Synchronizing disks…" job (folding CHD diffs back into bases on a + /// clean exit); `Some` shows the modal. `None` when no sync is in flight. + syncing: Option, + /// Latched once the exit-time disk sync has been kicked off, so the close + /// isn't re-intercepted after it finishes. + sync_then_close: bool, + /// Pending "Discard changes (roll back)" awaiting confirmation; `Some` shows + /// the are-you-sure modal. + cow_discard_confirm: Option, +} + +/// Progress of the exit-time "Synchronizing disks…" step. +struct SyncJob { + /// Disk index currently being synced (0-based) and the total count. + disk: usize, + total: usize, + /// Fraction (0.0..=1.0) through the current disk's rebuild. + fraction: f32, +} + +/// A COW overlay the user has asked to discard, awaiting confirmation. +struct CowDiscard { + id: u8, + base: String, + chd: bool, } struct StopModal { lines: Vec, } +/// Confirmation when the Networking tab commits a sanity-failing subnet. Cancel +/// restores `revert_to`; Override keeps the value as typed. +struct NetSanityModal { + reason: String, + suggestion: String, + revert_to: Option, +} + /// One SCSI device that is missing its backing file at Start time. struct MissingDisk { id: u8, @@ -313,7 +358,7 @@ impl App { fullscreen: false, // First-ever launch (no saved size) → fit the window to the monitor // on the first frame instead of using the static default verbatim. - pending_launcher_fit: prefs.window_size.is_none(), + pending_launcher_fit: true, revealed: false, startup_frame: 0, prefs, @@ -328,9 +373,12 @@ impl App { stop_modal: None, missing_modal: None, confirm_embedded_prom: false, + net_ifaces: netplan::gather_host_ifaces(), + net_sanity_modal: None, new_machine, create_disk: CreateDiskDialog::default(), show_config_editor: false, + show_net_check: false, save_state_name: "snap1".into(), restore_state_name: "snap1".into(), fb_tex: None, @@ -346,6 +394,10 @@ impl App { serial_console: None, serial_input: String::new(), show_help_info: false, + show_nfs_help: false, + syncing: None, + sync_then_close: false, + cow_discard_confirm: None, } } @@ -513,12 +565,94 @@ impl App { self.start_emulator(); } + /// App Store sandbox: let the user grant a folder (recursive read-write) so + /// disk images, the CHD diff / fold temp written beside a base, and an NFS + /// shared subfolder under it are all accessible from one grant. Persists a + /// directory security-scoped bookmark and asserts access immediately. + fn grant_disk_folder(&mut self) { + if let Some(dir) = rfd::FileDialog::new().pick_folder() { + let path = dir.to_string_lossy().into_owned(); + if !self.prefs.disk_folders.contains(&path) { + self.prefs.disk_folders.push(path.clone()); + } + let _ = self.prefs.save(); // mints the directory bookmark + macos_sandbox::restore(&self.prefs.bookmarks); // start accessing now + self.toast(format!("granted disk folder: {path}")); + } + } + + /// SCSI-menu section: per-disk Commit / Discard for copy-on-write overlays. + /// Only offered while the machine is stopped — the disk files are closed + /// then, so applying or discarding can't corrupt a running guest. + fn draw_cow_menu(&mut self, ui: &mut egui::Ui) { + // Snapshot disks that currently have an overlay on disk (cloned so we + // don't hold a `cfg` borrow while sending worker commands below). + let mut entries: Vec<(u8, String, bool)> = Vec::new(); // (id, base, is_chd) + for (&id, dev) in &self.cfg.scsi { + if dev.cdrom || dev.scratch || dev.path.trim().is_empty() { + continue; + } + let is_chd = iris::chd_disk::is_chd(&dev.path); + let has_overlay = if is_chd { + iris::chd_disk::diff_path_for(std::path::Path::new(&dev.path)).exists() + } else if dev.overlay { + std::path::Path::new(&format!("{}.overlay", dev.path)).exists() + } else { + false + }; + if has_overlay { + entries.push((id, dev.path.clone(), is_chd)); + } + } + if entries.is_empty() { + return; + } + ui.separator(); + ui.label(RichText::new("Copy-on-write changes").strong()); + if self.emu.is_running() { + ui.label(RichText::new("Stop the machine to commit or roll back.").weak()); + return; + } + for (id, base, is_chd) in entries { + let name = std::path::Path::new(&base).file_name().and_then(|n| n.to_str()).unwrap_or(&base); + ui.label(RichText::new(format!("SCSI {id}: {name}")).weak()); + if ui.button(" ⬇ Commit changes to disk") + .on_hover_text("Permanently merge this session's overlay into the disk image") + .clicked() + { + if is_chd { + // A CHD commit recompresses — show the progress modal. + self.syncing = Some(SyncJob { disk: 0, total: 1, fraction: 0.0 }); + } + self.emu.send(Cmd::CowCommit { base: base.clone(), chd: is_chd }); + ui.close_menu(); + } + if ui.button(" ↩ Discard changes (roll back)") + .on_hover_text("Throw away this session's overlay and revert to the disk as it was") + .clicked() + { + // Destructive — confirm before discarding. + self.cow_discard_confirm = Some(CowDiscard { id, base: base.clone(), chd: is_chd }); + ui.close_menu(); + } + } + } + fn request_stop(&mut self) { let reasons = evaluate(&self.emu.status, &self.cfg); - if reasons.is_empty() { - self.emu.send(Cmd::Stop); - } else { + if !reasons.is_empty() { self.stop_modal = Some(StopModal { lines: reason_lines(&reasons) }); + return; + } + // Safe to stop (guest quiesced). If a CHD has diff-borne changes, fold + // them back into the base now ("Synchronizing disks…") instead of leaving + // a sidecar — the worker stops the machine as part of the sync. This is a + // stop, not a quit, so `sync_then_close` stays false and the app stays open. + if self.emu.has_pending_chd_sync() { + self.syncing = Some(SyncJob { disk: 0, total: 0, fraction: 0.0 }); + self.emu.send(Cmd::SyncDisks); + } else { + self.emu.send(Cmd::Stop); } } @@ -533,6 +667,24 @@ impl App { Evt::Screenshot(p) => self.toast(format!("screenshot: {}", p.display())), Evt::Error(e) => self.toast(format!("error: {e}")), Evt::Status(_) => {} + Evt::SyncProgress { disk, total, fraction } => { + self.syncing = Some(SyncJob { disk, total, fraction }); + } + Evt::CowDone { committed } => { + self.toast(if committed { "changes committed to disk" } else { "done" }); + } + Evt::SyncDone(n) => { + self.syncing = None; + if n > 0 { self.toast(format!("synchronized {n} disk{}", if n == 1 { "" } else { "s" })); } + // If the sync was a pre-quit step, finish closing now. The + // `sync_then_close` latch stays set so this Close isn't + // re-intercepted (the machine is gone, so `chd_sync_pending` + // is stale-true). A Stop-triggered sync leaves it false, so + // the app stays open with the machine stopped. + if self.sync_then_close { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } } } // Repaint cadence: ~60 fps while the emulator is running so we @@ -631,6 +783,37 @@ impl App { ui.close_menu(); } } + // App Store sandbox: grant a whole folder (recursive) so the + // disk-sync fold — which writes a temp beside the base and + // renames over it — works, and so a disk image / NFS shared + // subfolder under it is covered by one grant. Hidden elsewhere. + if cfg!(feature = "appstore") { + ui.separator(); + ui.label(RichText::new("Disk folder access").strong()); + if ui.button("Grant a disk folder…") + .on_hover_text("Pick the folder your disk images live in. Grants read/write to \ + everything inside (disk images, CHD diffs, an NFS shared subfolder) \ + so changes can be synced back into the disk.") + .clicked() + { + self.grant_disk_folder(); + ui.close_menu(); + } + let folders = self.prefs.disk_folders.clone(); + for f in &folders { + ui.horizontal(|ui| { + ui.label(RichText::new(f).weak()); + if ui.small_button("📂").on_hover_text("Reveal in Finder").clicked() { + config_ui::reveal_in_file_manager(f); + } + if ui.small_button("✕").on_hover_text("Revoke").clicked() { + self.prefs.disk_folders.retain(|x| x != f); + self.prefs.bookmarks.remove(f); + let _ = self.prefs.save(); + } + }); + } + } ui.separator(); if ui.button("Quit").clicked() { if self.cfg_dirty { self.flush_machine(); } @@ -741,6 +924,7 @@ impl App { } } } + self.draw_cow_menu(ui); }); ui.menu_button("View ▶", |ui| { if ui.button(if self.fullscreen { "Exit fullscreen (F11)" } else { "Fullscreen (F11)" }).clicked() { @@ -804,6 +988,13 @@ impl App { self.show_help_info = true; ui.close_menu(); } + if ui.button("🗂 Mount the shared folder in IRIX…") + .on_hover_text("The exact mount command for the NFS share") + .clicked() + { + self.show_nfs_help = true; + ui.close_menu(); + } ui.separator(); ui.label(RichText::new("Authors").strong()); ui.label("Original: techomancer"); @@ -880,9 +1071,182 @@ impl App { } } + /// Mouse/keyboard capture state for the control column, sitting between the + /// config controls and the status footer. Only the *capture* action is a + /// button — releasing stays the Ctrl+Alt+Esc hotkey, because while captured + /// the host pointer is grabbed by the guest and can't click anything. + /// Caller renders this only while the machine is running. + fn capture_controls(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { + if self.input_state.captured { + ui.label(RichText::new("Mouse/Keyboard Captured").color(Color32::LIGHT_GREEN)); + ui.label(RichText::new("To disable: Ctrl+Alt+Esc").weak()); + } else { + ui.label(RichText::new("Mouse/Keyboard Capture Disabled").weak()); + if ui + .add_sized( + egui::vec2(ui.available_width(), 0.0), + egui::Button::new("Capture mouse/keyboard"), + ) + .clicked() + { + input::engage_capture(ctx, &mut self.input_state); + } + } + } + + /// "Check networking" diagnosis window. Compares the guest's passively + /// observed IP to what IRIS's NAT expects and explains how to fix a + /// mismatch — no guest login required (detection is from frames the guest + /// already emits). The permanent auto-fix over telnet comes later. + fn network_check_window(&mut self, ctx: &egui::Context) { + if !self.show_net_check { + return; + } + let state = self.emu.net_state(); + let guest = self.emu.status.net_guest_ip; + let gw = self.emu.status.net_guest_gateway; + // The configured NAT subnet, for fresh-guest guidance and the + // "match the guest" action. IRIS is plug-and-play: it ADOPTS whatever + // gateway the guest ARPs for (src/net.rs), so the guest's own subnet is + // fine — there is no fixed address it must use once ec0 is configured. + let (eb, ep) = netplan::parse_cidr(self.cfg.nat_subnet.as_deref()); + let cfg_net = netplan::classify(eb, ep, &[]).derived; + let fresh_hint = cfg_net.as_ref().map(|d| format!( + "If ec0 is unconfigured, the simplest setup is IRIS's defaults: ec0 {}, gateway {}, mask {}.", + d.client, d.gateway, d.netmask)); + let mut switch_to: Option = None; + let mut open = true; + egui::Window::new("Check networking") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .open(&mut open) + .show(ctx, |ui| { + ui.set_max_width(440.0); + match state { + NetState::Off => { + ui.label("Start the machine first, then check again."); + return; + } + NetState::Active => { + ui.label(RichText::new("Networking is up") + .color(Color32::from_rgb(0x35, 0xb8, 0x4a)).strong()); + let who = guest.map(|i| i.to_string()).unwrap_or_else(|| "The guest".into()); + ui.label(format!("{who} is reaching the network through IRIS.")); + if let Some(g) = gw { + ui.label(RichText::new(format!("Gateway in use: {g} (adopted by IRIS).")).weak()); + } + return; + } + NetState::Idle => {} // running but no traffic — diagnose below + } + let Some(ip) = guest else { + ui.label(RichText::new("No guest traffic seen yet").strong()); + ui.label("If IRIX just booted, give it a few seconds and re-check. \ + Otherwise ec0 may be unconfigured, or networking may be turned \ + off in IRIX."); + ui.add_space(4.0); + ui.label(RichText::new("Make sure networking is enabled (as root):").strong()); + ui.code("chkconfig network on"); + ui.label(RichText::new( + "Networking is off in some IRIX setups (the /etc/config/network flag); \ + with it off the guest sends no traffic at all. After enabling, reboot \ + (or run /etc/init.d/network start).").weak()); + if let Some(l) = &fresh_hint { ui.label(RichText::new(l).weak()); } + return; + }; + let o = ip.octets(); + let guest_net = std::net::Ipv4Addr::new(o[0], o[1], o[2], 0); + let sub_gw = std::net::Ipv4Addr::new(o[0], o[1], o[2], 1); + ui.label(RichText::new("Networking is down") + .color(Color32::from_rgb(0xd9, 0x4a, 0x3d)).strong()); + ui.label(format!("Your guest is using {ip} (network {guest_net}/24).")); + ui.add_space(6.0); + match gw { + Some(g) => { + ui.label(format!( + "Its gateway is {g}, and IRIS adopts the guest's gateway automatically, so this \ + should come up within a few seconds. Leave it a moment and re-check.")); + ui.label(RichText::new( + "If it stays red, the guest's gateway may be unreachable; log in as root to \ + check it.").weak()); + } + None => { + ui.label("Its routing table has no default gateway, so it can reach only its own \ + subnet. IRIS adopts whatever gateway you point it at, so the fix is just \ + to give the guest a default route (no need to match subnets by hand):"); + ui.add_space(4.0); + ui.label(RichText::new("Log in as root (serial console or a winterm) and run:").strong()); + ui.code(format!("/usr/etc/route add default {sub_gw} 1")); + ui.label(RichText::new( + "Lasts until reboot; to persist it, set the default route in IRIX's network \ + config (5.3 and 6.5 differ).").weak()); + } + } + // Optional: align IRIS's configured subnet to the guest's, for a + // guest kept permanently on a fixed subnet. IRIS adopts live + // regardless; this just makes IRIS's default match on next boot. + let cfg_base = cfg_net.as_ref().map(|d| d.network); + if cfg_base != Some(guest_net) { + ui.add_space(6.0); + ui.separator(); + match netplan::conflict(guest_net, 24, &self.net_ifaces) { + Some(h) => { + // The guest's subnet is one the host itself uses. + // Refuse to move IRIS there (it would shadow the + // host's real network); tell the user to renumber the + // guest onto IRIS's own subnet instead. + ui.label(RichText::new(format!( + "The guest's subnet {guest_net}/24 overlaps your host network ({} {}). \ + IRIS won't move its NAT onto your real network, so change the Indy's \ + address instead — put ec0 on IRIS's own subnet:", h.name, h.addr)) + .color(Color32::from_rgb(0xd9, 0x4a, 0x3d))); + if let Some(d) = cfg_net.as_ref() { + ui.add_space(4.0); + ui.label(RichText::new("Log in as root (serial console or a winterm) and run:").strong()); + let e = netfix::ExpectedNet { ip: d.client, gateway: d.gateway, netmask: d.netmask }; + for cmd in netfix::runtime_fix_commands(&e) { + ui.code(cmd); + } + ui.label(RichText::new(format!( + "That puts ec0 on {} (gateway {}), which doesn't clash with your \ + network. Re-check after.", d.client, d.gateway)).weak()); + } + } + None => { + let where_ = cfg_net.as_ref() + .map(|d| format!("{}/{}", d.network, d.prefix)) + .unwrap_or_else(|| "a different subnet".into()); + ui.label(RichText::new(format!( + "IRIS's NAT defaults to {where_}. You can make IRIS default to the guest's \ + subnet instead:")).weak()); + if ui.button(format!("Set IRIS's NAT subnet to {guest_net}/24")).clicked() { + switch_to = Some(format!("{guest_net}/24")); + } + } + } + } + }); + if let Some(s) = switch_to { + self.cfg.nat_subnet = Some(s.clone()); + self.mark_dirty(); + if self.emu.is_running() { + // Apply to the running NAT so the guest can route immediately, + // and persist for next launch. + self.emu.send(Cmd::SetNatSubnet(s.clone())); + self.toast(format!("NAT subnet set to {s} (applied live)")); + } else { + self.toast(format!("NAT subnet set to {s} (applies on next launch)")); + } + } + if !open { + self.show_net_check = false; + } + } + /// Run-state line (IRIX running / PROM / halted / stopped + MIPS). Used in /// the control column's status footer. - fn run_state_label(&self, ui: &mut egui::Ui) { + fn run_state_label(&mut self, ui: &mut egui::Ui) { let running = self.emu.is_running(); let halted = running && self.emu.status.cpu_halted; let status = if halted { @@ -898,6 +1262,35 @@ impl App { if running && !halted { ui.label(format!("{:.0} MIPS", self.emu.status.mips)); } + // Internal-network indicator — always shown (grey while unpowered or + // halted). Green once the guest is carrying NAT IP traffic; red when a + // running guest has produced none (a hint its IP is missing or wrong for + // the configured NAT subnet). + let (net_color, net_tip) = match self.emu.net_state() { + NetState::Active => ( + Color32::from_rgb(0x35, 0xb8, 0x4a), + "Internal network: carrying guest NAT traffic.", + ), + NetState::Idle => ( + Color32::from_rgb(0xd9, 0x4a, 0x3d), + "Internal network: no NAT traffic yet.\nThe guest may have no IP — or the wrong IP for this NAT subnet.", + ), + NetState::Off => ( + Color32::from_gray(0x80), + "Internal network: machine not running.", + ), + }; + let mut want_check = false; + ui.horizontal(|ui| { + ui.label(RichText::new("\u{25CF}").color(net_color)).on_hover_text(net_tip); + ui.label("NET").on_hover_text(net_tip); + if running && ui.small_button("check").on_hover_text("Diagnose guest networking").clicked() { + want_check = true; + } + }); + if want_check { + self.show_net_check = true; + } if running && self.fb_scale > 0.0 { // How magnified the emulated display currently is (1× = native). // Round-snap the readout so a whole-number scale reads cleanly. @@ -960,10 +1353,13 @@ impl App { // ¼× steps, so we don't snap here — when we must clamp to the monitor we // use the full fitting size (the footer readout reports the actual scale // and tags non-crisp ones) rather than dropping a whole step. + // Largest scale that fits, never exceeding the requested target, floored + // at a sane minimum — but the floor must not exceed target, or clamp() + // panics (min > max) on a degenerate target from a zeroed/garbage scale. let scale = target .min((avail_w.max(64.0)) / fb_px.x) .min((avail_h.max(64.0)) / fb_px.y) - .clamp(0.05, target); + .clamp(0.05_f32.min(target), target); let inner = egui::vec2(fb_px.x * scale + chrome_w, fb_px.y * scale + chrome_h); ctx.send_viewport_cmd(ViewportCommand::InnerSize(inner)); } @@ -1028,7 +1424,6 @@ impl App { // Consume the snap request before the immutable borrow of self.fb_tex. let do_snap = std::mem::take(&mut self.pending_fb_snap); - let mut fb_rect = egui::Rect::NOTHING; let mut fb_clicked = false; let mut new_fb_scale = 0.0; if let Some(tex) = &self.fb_tex { @@ -1048,16 +1443,37 @@ impl App { if tex_size.y >= 1.0 { new_fb_scale = size.y * zoom / tex_size.y; } + let mut fb_rect = None; ui.centered_and_justified(|ui| { let response = ui.add( egui::Image::new((tex.id(), size)).fit_to_exact_size(size).sense(egui::Sense::click()) ); - fb_rect = response.rect; // Take keyboard focus so that egui delivers Key events // to us instead of routing them to other widgets when // the user clicks into the FB. if response.clicked() { response.request_focus(); fb_clicked = true; } + fb_rect = Some(response.rect); }); + + // After a soft power-off the core stops the CPU but keeps the window + // running, so the last frame stays frozen on screen. Dim it with a + // translucent grey scrim + label so it reads as inactive, not live. + // Keyed on `cpu_stopped` (a real CPU stop), NOT `cpu_halted` — the + // latter also trips on 0-MIPS idle at the PROM, which would wrongly + // dim a machine that's only paused. The frame underneath stays visible. + if self.emu.status.cpu_stopped { + if let Some(rect) = fb_rect { + let p = ui.painter_at(rect); + p.rect_filled(rect, 0.0, Color32::from_rgba_unmultiplied(40, 42, 48, 130)); + p.text( + rect.center(), + egui::Align2::CENTER_CENTER, + "⏻ Powered off", + egui::FontId::proportional(26.0), + Color32::from_rgba_unmultiplied(235, 235, 235, 235), + ); + } + } } self.fb_scale = new_fb_scale; @@ -1082,21 +1498,6 @@ impl App { input::pump(ui.ctx(), fb_clicked, &ps2, &mut self.input_state, self.cfg.mouse_scroll_pixels_per_line); } - // Capture hint, drawn over the framebuffer. - if fb_rect.is_positive() { - let hint = if self.input_state.captured { - "Ctrl+Alt+Esc to release mouse" - } else { - "Click to capture mouse / keyboard" - }; - ui.painter().text( - fb_rect.center_bottom() + egui::vec2(0.0, -6.0), - egui::Align2::CENTER_BOTTOM, - hint, - egui::FontId::proportional(12.0), - Color32::from_white_alpha(140), - ); - } } fn central_tabs(&mut self, ui: &mut egui::Ui) { @@ -1106,11 +1507,23 @@ impl App { } }); ui.separator(); - match show_tab(ui, self.tab, &mut self.cfg, &mut self.jit) { + let out = show_tab(ui, self.tab, &mut self.cfg, &mut self.jit, &self.net_ifaces, &self.prefs.disk_folders); + match out.action { ConfigAction::RequestEmbeddedProm => self.confirm_embedded_prom = true, ConfigAction::TestCamera => self.open_camera_test(), ConfigAction::None => {} } + if out.net.changed { self.mark_dirty(); } + if out.net.forwards_changed && self.emu.is_running() { + // Rebind the running NAT's listeners so a forward added/removed now + // takes effect without a restart (latest-wins coalesces in the NAT). + self.emu.send(Cmd::SetPortForwards(self.cfg.port_forward.clone())); + } + if let Some(p) = out.net.prompt { + self.net_sanity_modal = Some(NetSanityModal { + reason: p.reason, suggestion: p.suggestion, revert_to: p.revert_to, + }); + } } /// Open (or restart) the live host-camera preview using the current @@ -1161,7 +1574,7 @@ impl App { format!("Camera unavailable: {e}")); ui.label(RichText::new( "Check that a camera is connected and that IRIS has camera \ - permission (System Settings → Privacy & Security → Camera).") + permission (System Settings > Privacy & Security > Camera).") .weak()); } else if let Some(tex) = &self.camera_test_tex { // The capture field is half-height (interlaced), so present @@ -1174,7 +1587,7 @@ impl App { ui.label("Starting capture…"); ui.label(RichText::new( "If no image appears, grant camera access in System \ - Settings → Privacy & Security → Camera, then reopen.") + Settings > Privacy & Security > Camera, then reopen.") .weak().small()); } ui.add_space(6.0); @@ -1306,8 +1719,8 @@ impl App { ); ui.add_space(6.0); ui.label(RichText::new("How to use it").strong()); - ui.label("• Help → Diagnostics → Test Camera shows a live preview (start a machine first)."); - ui.label("• Or set Video-In → Source = camera, boot IRIX, and run an IndyCam app like vino/cam."); + ui.label("• Help > Diagnostics > Test Camera shows a live preview (start a machine first)."); + ui.label("• Or set Video-In > Source = camera, boot IRIX, and run an IndyCam app like vino/cam."); ui.label("• On first use macOS asks for camera permission. Closing the preview releases the camera."); ui.add_space(6.0); ui.label(RichText::new("Privacy / for App Review").strong()); @@ -1329,9 +1742,18 @@ impl App { ui.label(RichText::new("How to use it").strong()); ui.label("• The guest serial console (ttyd1) and PROM monitor are exposed on loopback"); ui.label(" TCP (127.0.0.1:8881 / 8888) so you can attach a terminal."); - ui.label("• Help → Diagnostics → Network test opens an in-app viewer of that console."); + ui.label("• Help > Diagnostics > Network test opens an in-app viewer of that console."); ui.label("• Optional inbound port-forwards (Networking tab) let you reach guest services."); ui.add_space(6.0); + ui.label(RichText::new("Guest IP & subnets").strong()); + ui.label("• IRIS's NAT is a router on one subnet; the gateway is the .1 of that subnet"); + ui.label(" (default 192.168.0.0/24, gateway 192.168.0.1 — set on the Networking tab)."); + ui.label("• The guest must sit on that SAME subnet, with its default route = that .1 gateway."); + ui.label("• If the guest's IP is on a different network, nothing routes. The NET light's"); + ui.label(" 'check' button shows the guest's IP vs IRIS's, and how to line them up."); + ui.label("• No port-forwards exist by default; '+ Add forward' maps a host port to a guest"); + ui.label(" port (inbound, host to guest) — e.g. to telnet into the guest."); + ui.add_space(6.0); ui.label(RichText::new("Privacy / for App Review").strong()); ui.label( "Outbound guest traffic uses com.apple.security.network.client. The loopback \ @@ -1350,6 +1772,226 @@ impl App { } } + /// Help → "Mount the shared folder in IRIX": the exact, copy-pasteable mount + /// command for the in-core NFS server, with the live gateway filled in and a + /// note matching the configured NFS version. + fn nfs_help_window(&mut self, ctx: &egui::Context) { + if !self.show_nfs_help { + return; + } + use iris::nfsudp::NfsVersion; + + // Gateway the guest will mount from: the one IRIS has live-adopted while + // running, else the gateway derived from the configured NAT subnet, else + // the documented default. Matches network_check_window's derivation. + let (eb, ep) = netplan::parse_cidr(self.cfg.nat_subnet.as_deref()); + let cfg_gw = netplan::classify(eb, ep, &[]).derived.map(|d| d.gateway.to_string()); + let gw = self + .emu + .status + .net_guest_gateway + .map(|g| g.to_string()) + .or(cfg_gw) + .unwrap_or_else(|| "192.168.0.1".into()); + + let mut open = true; + egui::Window::new("Mount the shared folder in IRIX") + .open(&mut open) + .collapsible(false) + // Non-resizable so the window auto-sizes to its content. A resizable + // egui Window keeps a remembered/default size and will NOT shrink-wrap, + // which is why it spilled to full height before. + .resizable(false) + .show(ctx, |ui| { + ui.set_max_width(520.0); + // Cap height so the limitations-expanded page scrolls instead of + // growing off-screen; short content just shrinks to fit. + egui::ScrollArea::vertical().max_height(560.0).auto_shrink([false, true]).show(ui, |ui| { + let Some(nfs) = self.cfg.nfs.as_ref() else { + ui.label(RichText::new("NFS file sharing isn't enabled yet.").strong()); + ui.add_space(4.0); + ui.label( + "Turn it on under Configuration → Networking → NFS share, pick a folder \ + to share, then reopen this window for the exact mount command."); + return; + }; + + ui.label( + "IRIS serves the folder below over NFS, in-process, from the NAT gateway. \ + Boot IRIX, make sure networking is up (the NET light / Check networking), \ + then log in as root and run these commands at an IRIX shell:"); + ui.add_space(6.0); + + if nfs.shared_dir.trim().is_empty() { + ui.label(RichText::new( + "No folder is selected yet — pick one under Configuration → Networking → \ + NFS share first.").color(Color32::from_rgb(0xd9, 0x4a, 0x3d))); + } else { + ui.label(RichText::new(format!("Sharing: {}", nfs.shared_dir)).weak()); + } + ui.add_space(6.0); + + // The simple form — IRIX auto-detects NFS from the host:/path + // syntax. The export is the single root "/". + ui.code(format!("mkdir -p /shared\nmount {gw}:/ /shared")); + ui.label(RichText::new( + "Your shared folder then appears at /shared on the Indy. Use any empty mount \ + point you like in place of /shared.").weak()); + + ui.add_space(8.0); + let ver_note = match nfs.version { + NfsVersion::Auto => "NFS version: Auto — IRIX picks it. IRIX 6.5 mounts NFSv3; \ + IRIX 5.3 uses NFSv2.", + NfsVersion::V2 => "NFS version: v2 — IRIS only answers NFSv2 (the right choice \ + for IRIX 5.3).", + NfsVersion::V3 => "NFS version: v3 — IRIS only answers NFSv3 (IRIX 6.x).", + }; + ui.label(RichText::new(ver_note).weak()); + match nfs.version { + NfsVersion::V3 => { + ui.label(RichText::new( + "Max file size: NFSv3 is 64-bit, so files are limited only by your disk.") + .weak()); + } + _ => { + ui.label(RichText::new( + "⚠ Max file size over NFSv2 is ~2 GiB (4 GiB protocol ceiling; its \ + sizes/offsets are 32-bit). For larger files, use NFSv3.") + .color(Color32::from_rgb(0xd9, 0x9a, 0x2d))); + } + } + + ui.separator(); + ui.label(RichText::new("If the mount hangs or is refused").strong()); + ui.label( + "IRIS's NFS server is UDP-only, and IRIX may otherwise try TCP or the wrong \ + version. Pin both explicitly:"); + let pin = match nfs.version { + NfsVersion::V2 => "vers=2,proto=udp", + _ => "vers=3,proto=udp", + }; + ui.code(format!("mount -o {pin} {gw}:/ /shared")); + ui.add_space(4.0); + ui.label(RichText::new( + "Still stuck? Use Help → \"How camera & networking work\" and the NET light's \ + Check button to confirm the guest is on IRIS's subnet first — NFS can't mount \ + until networking is up.").weak()); + + ui.add_space(8.0); + ui.label(RichText::new("To unmount later:").strong()); + ui.code("umount /shared"); + + ui.add_space(8.0); + ui.collapsing("Limitations & advanced details", |ui| { + let item = |ui: &mut egui::Ui, head: &str, body: &str| { + ui.horizontal_wrapped(|ui| { + ui.label(RichText::new(format!("{head} — ")).strong()); + ui.label(RichText::new(body).weak()); + }); + }; + item(ui, "File size", + "NFSv2 caps a single file at ~2 GiB (4 GiB protocol max) — 32-bit sizes and \ + offsets. NFSv3 is 64-bit, bounded only by the host disk."); + item(ui, "Transport", + "UDP only — there is no NFS-over-TCP. Large reads are split into IP fragments."); + item(ui, "Single export", + "The whole shared folder is exported as \"/\"; the path after the colon in the \ + mount command is ignored."); + item(ui, "Permissions", + "Synthetic: everything is owned by root (uid/gid 0), directories 0755, files \ + 0644. chmod/chown are accepted but ignored; changing a file's size is honored."); + item(ui, "No authentication", + "Every client is allowed and credentials are ignored — don't expose anything \ + sensitive through the share."); + item(ui, "No file locking", + "There is no NLM/lockd, so advisory locks across host and guest won't work."); + item(ui, "No special files", + "Symlinks, device nodes (mknod), and hard links (link) are not served."); + item(ui, "Synchronous writes", + "Every write is flushed immediately and COMMIT is a no-op — data is safe but \ + writes are slower than a caching server."); + }); + }); + ui.separator(); + if ui.button("Close").clicked() { + self.show_nfs_help = false; + } + }); + if !open { + self.show_nfs_help = false; + } + } + + /// The "Synchronizing disks…" modal shown while a clean exit folds pending + /// CHD `.diff.chd` sidecars back into their bases. Driven by `self.syncing`, + /// updated from `Evt::SyncProgress`; closes the app on `Evt::SyncDone`. + fn sync_modal(&mut self, ctx: &egui::Context) { + let Some(job) = &self.syncing else { return }; + let frac = if job.total > 0 { + ((job.disk as f32 + job.fraction) / job.total as f32).clamp(0.0, 1.0) + } else { + job.fraction.clamp(0.0, 1.0) + }; + let disk_line = (job.total > 1).then(|| format!("Disk {} of {}", (job.disk + 1).min(job.total), job.total)); + egui::Window::new("Synchronizing disks…") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.set_max_width(430.0); + ui.label( + "Applying your changes to the CHD disk image. Everything written this session is \ + being merged permanently into the disk — this only happens with some (compressed) \ + CHD files."); + ui.add_space(10.0); + ui.add(egui::ProgressBar::new(frac).show_percentage()); + if let Some(line) = disk_line { + ui.label(RichText::new(line).weak()); + } + ui.add_space(4.0); + ui.label(RichText::new("Please don't force-quit — this finishes in a moment.").weak()); + }); + // The worker is busy rebuilding; keep repainting so progress updates show. + ctx.request_repaint(); + } + + /// Confirmation before discarding a COW overlay (it's destructive). + fn cow_discard_modal(&mut self, ctx: &egui::Context) { + let Some(job) = &self.cow_discard_confirm else { return }; + let name = std::path::Path::new(&job.base).file_name().and_then(|n| n.to_str()).unwrap_or(&job.base).to_string(); + let (id, base, chd) = (job.id, job.base.clone(), job.chd); + let mut decision: Option = None; // Some(true) = discard, Some(false) = cancel + egui::Window::new("Discard changes?") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.set_max_width(400.0); + ui.label(RichText::new("Are you sure? You will lose any changes to the disk.").strong()); + ui.add_space(4.0); + ui.label(RichText::new(format!("SCSI {id}: {name}")).weak()); + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + decision = Some(false); + } + if ui.add(egui::Button::new(RichText::new("Discard changes").color(Color32::WHITE)) + .fill(Color32::from_rgb(140, 40, 40))).clicked() + { + decision = Some(true); + } + }); + }); + match decision { + Some(true) => { + self.emu.send(Cmd::CowReset { base, chd }); + self.cow_discard_confirm = None; + } + Some(false) => self.cow_discard_confirm = None, + None => {} + } + } + fn welcome_panel(&mut self, ui: &mut egui::Ui) { ui.add_space(8.0); ui.heading("iris — SGI Indy emulator"); @@ -1445,6 +2087,12 @@ impl App { self.menu_list(ui, ctx); ui.separator(); self.config_quick_buttons(ui); + // Capture status + "Capture" button — only while a machine is up + // (it's meaningless at the stopped state shown in the footer). + if self.emu.is_running() { + ui.separator(); + self.capture_controls(ui, ctx); + } }); } } @@ -1454,14 +2102,20 @@ impl eframe::App for App { self.handle_events(ctx); self.maybe_autosave(); - // Remember the current window size so the next launch reopens at it. - // inner_rect is in logical points — the same unit ViewportBuilder's - // with_inner_size() takes — so this round-trips regardless of UI zoom. - // Stored in-memory here; on_exit() (and other save() calls) persist it. - if let Some(r) = ctx.input(|i| i.viewport().inner_rect) { - let sz = r.size(); - if sz.x.is_finite() && sz.y.is_finite() && sz.x >= 480.0 && sz.y >= 360.0 { - self.prefs.window_size = Some([sz.x.round(), sz.y.round()]); + // Intercept a clean exit (window close / File→Quit) to fold any pending + // CHD `.diff.chd` back into its base first ("Synchronizing disks…"). On + // the first close request with a pending sync we kick off the worker, + // show the modal, and cancel the close; the real close happens on + // Evt::SyncDone. `sync_then_close` latches so we don't re-intercept the + // final close (by then the machine is gone and chd_sync_pending is stale). + if ctx.input(|i| i.viewport().close_requested()) { + if !self.sync_then_close && self.emu.has_pending_chd_sync() { + self.sync_then_close = true; + self.syncing = Some(SyncJob { disk: 0, total: 0, fraction: 0.0 }); + self.emu.send(Cmd::SyncDisks); + ctx.send_viewport_cmd(ViewportCommand::CancelClose); + } else if self.syncing.is_some() { + ctx.send_viewport_cmd(ViewportCommand::CancelClose); // still syncing — stay alive } } @@ -1491,6 +2145,7 @@ impl eframe::App for App { .resizable(false) .exact_width(186.0) .show(ctx, |ui| self.control_panel(ui, ctx)); + self.network_check_window(ctx); // Config editor lives in a collapsible side panel so the emulator // screen (central panel) is never hidden by it. The toolbar's @@ -1526,10 +2181,11 @@ impl eframe::App for App { if self.emu.is_running() { self.framebuffer_panel(ui); } else { - // First-ever launch: size the window to the monitor for the - // standard 1280×1024 display before the user sees it (the - // on-Start snap later re-fits to the actual guest resolution). - // Wait for the monitor size to be known before consuming the flag. + // Size the window for the standard 1280×1024 display at the + // chosen VM scale before the user sees it. Runs on every launch + // (window size isn't persisted), so the window always opens at + // the configured scale. Wait until the monitor size is known + // before consuming the flag. if self.pending_launcher_fit && ui.ctx().input(|i| i.viewport().monitor_size).is_some() { @@ -1576,6 +2232,15 @@ impl eframe::App for App { // Help → "How camera & networking work" explainer. self.help_info_window(ctx); + // Help → "Mount the shared folder in IRIX" — the NFS mount command. + self.nfs_help_window(ctx); + + // "Synchronizing disks…" modal during the exit-time CHD fold-back. + self.sync_modal(ctx); + + // "Discard changes?" confirmation for a COW roll-back. + self.cow_discard_modal(ctx); + // Safe-stop confirmation modal. let mut close_modal = false; let mut do_force = false; @@ -1641,6 +2306,47 @@ impl eframe::App for App { if close { self.confirm_embedded_prom = false; } } + // Networking sanity-check override modal. + if let Some(modal) = &self.net_sanity_modal { + let reason = modal.reason.clone(); + let suggestion = modal.suggestion.clone(); + let revert_to = modal.revert_to.clone(); + let mut close = false; + let mut do_revert = false; + let mut do_suggest = false; + egui::Window::new("Check networking configuration") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.set_max_width(420.0); + ui.label(RichText::new( + "This networking configuration does not appear to be valid, please double-check:") + .strong()); + ui.label(format!("- {reason}")); + ui.label(RichText::new( + "The defaults are chosen to just work; an unusual subnet can leave the Indy \ + without internet access.").weak()); + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { do_revert = true; close = true; } + if ui.add(egui::Button::new(format!("Use {suggestion}")) + .fill(Color32::from_rgb(60, 90, 140))).clicked() + { + do_suggest = true; close = true; + } + if ui.add(egui::Button::new("Override Sanity Checks") + .fill(Color32::from_rgb(140, 90, 40))).clicked() + { + close = true; + } + }); + }); + if do_revert { self.cfg.nat_subnet = revert_to; self.mark_dirty(); } + if do_suggest { self.cfg.nat_subnet = Some(suggestion); self.mark_dirty(); } + if close { self.net_sanity_modal = None; } + } + // Missing-disk modal. enum MissingChoice { None, Cancel, Detach, EditDisks } let mut choice = MissingChoice::None; @@ -1720,7 +2426,6 @@ impl eframe::App for App { } fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { - self.prefs.fullscreen = self.fullscreen; // Make sure the latest cfg lands in `machines` before save(). if self.cfg_dirty { self.flush_machine(); } else { let _ = self.prefs.save(); } // Synchronously stop the machine and join the worker, so a running diff --git a/iris-gui/src/netfix.rs b/iris-gui/src/netfix.rs new file mode 100644 index 0000000..3b63e14 --- /dev/null +++ b/iris-gui/src/netfix.rs @@ -0,0 +1,178 @@ +//! Pure logic for the "check / fix guest networking" feature. +//! +//! The emulator's NAT engine expects the IRIX guest at a fixed address derived +//! from the configured NAT subnet (gateway = network+1, guest = network+2; see +//! `src/config.rs` / `src/net.rs`). When the guest's `ec0` is unset or set to a +//! different address — e.g. a static config left over from another subnet — NAT +//! traffic never flows and the GUI's NET light stays red. +//! +//! This module is the brain of the fix, kept free of any I/O so it's unit +//! testable without booting IRIX: +//! - [`ExpectedNet::from_subnet`] — what `ec0` *should* be. +//! - [`parse_ec0_inet`] — what it *is*, scraped from `ifconfig ec0` output. +//! - [`diagnose`] — compare the two. +//! - [`runtime_fix_commands`] — IRIX shell commands to correct it this session. +//! +//! The GUI injects [`PROBE_CMD`] / the fix commands over the serial console +//! (z85c30 channel B) and feeds the console text back into [`parse_ec0_inet`]. +//! The live serial round-trip is validated on a real boot; this logic isn't. + +// Foundation for the in-progress "check / fix guest networking" UI; the probe +// and Fix-networking actions that call these land in the next increment. +#![allow(dead_code)] + +use iris::config::NatSubnet; +use std::net::Ipv4Addr; + +/// Command injected at the IRIX shell to dump `ec0`'s current config. Absolute +/// path so it works regardless of the login shell's PATH. +pub const PROBE_CMD: &str = "/usr/etc/ifconfig ec0"; + +/// The address `ec0` should hold for NAT to work, derived from the NAT subnet. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExpectedNet { + /// NAT-assigned guest address (subnet network + 2). + pub ip: Ipv4Addr, + /// NAT gateway/router address (subnet network + 1). + pub gateway: Ipv4Addr, + /// Subnet mask. + pub netmask: Ipv4Addr, +} + +impl ExpectedNet { + pub fn from_subnet(s: &NatSubnet) -> Self { + Self { ip: s.client_ip, gateway: s.gateway_ip, netmask: s.netmask } + } + + /// IRIX `ifconfig` wants the mask as a 0x-prefixed 32-bit hex word + /// (e.g. `0xffffff00` for a /24), not dotted-decimal. + pub fn netmask_hex(&self) -> String { + let o = self.netmask.octets(); + format!("0x{:02x}{:02x}{:02x}{:02x}", o[0], o[1], o[2], o[3]) + } +} + +/// Result of comparing the guest's detected `ec0` address to the expected one. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NetDiagnosis { + /// `ec0` is at the expected NAT address — nothing to do. + Correct, + /// `ec0` has an address, but not the one NAT expects (likely a static + /// config for a different subnet). Carries the wrong address. + Wrong(Ipv4Addr), + /// `ec0` has no `inet` address configured at all. + Unconfigured, +} + +/// Pull the `inet` address out of `ifconfig ec0` output. IRIX prints, e.g.: +/// +/// ```text +/// ec0: flags=415c43 +/// inet 192.168.0.2 netmask 0xffffff00 broadcast 192.168.0.255 +/// ``` +/// +/// Tolerant of the echoed command and shell prompt mixed into the captured +/// console text — it just scans for the first `inet ` token. +pub fn parse_ec0_inet(console: &str) -> Option { + for line in console.lines() { + // Skip the echoed command line so a path like ".../inet..." can't match. + let t = line.trim_start(); + if t.contains("ifconfig") { + continue; + } + if let Some(rest) = t.strip_prefix("inet ") { + if let Some(tok) = rest.split_whitespace().next() { + if let Ok(ip) = tok.parse::() { + return Some(ip); + } + } + } + } + None +} + +/// Compare a detected `ec0` address (from [`parse_ec0_inet`]) to the expected +/// NAT address. +pub fn diagnose(detected: Option, expected: &ExpectedNet) -> NetDiagnosis { + match detected { + Some(ip) if ip == expected.ip => NetDiagnosis::Correct, + Some(ip) => NetDiagnosis::Wrong(ip), + None => NetDiagnosis::Unconfigured, + } +} + +/// IRIX shell commands (run as root over the serial console) to bring `ec0` up +/// at the expected address and point the default route at the NAT gateway, for +/// the current session only. Persisting across reboots is a separate step +/// (edit `/etc/config/ipaddr` + hostname → IP in `/etc/hosts`). +pub fn runtime_fix_commands(e: &ExpectedNet) -> Vec { + vec![ + format!("/usr/etc/ifconfig ec0 inet {} netmask {} up", e.ip, e.netmask_hex()), + format!("/usr/etc/route add default {} 1", e.gateway), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn subnet(net: [u8; 4]) -> NatSubnet { + // network+1 gateway, network+2 client, /24 mask — matching parse_nat_subnet. + NatSubnet { + gateway_ip: Ipv4Addr::new(net[0], net[1], net[2], net[3] + 1), + client_ip: Ipv4Addr::new(net[0], net[1], net[2], net[3] + 2), + netmask: Ipv4Addr::new(255, 255, 255, 0), + } + } + + #[test] + fn expected_from_subnet() { + let e = ExpectedNet::from_subnet(&subnet([192, 168, 0, 0])); + assert_eq!(e.ip, Ipv4Addr::new(192, 168, 0, 2)); + assert_eq!(e.gateway, Ipv4Addr::new(192, 168, 0, 1)); + assert_eq!(e.netmask_hex(), "0xffffff00"); + } + + #[test] + fn parse_configured() { + let out = "\ +# /usr/etc/ifconfig ec0 +ec0: flags=415c43 +\tinet 192.168.0.2 netmask 0xffffff00 broadcast 192.168.0.255 +# "; + assert_eq!(parse_ec0_inet(out), Some(Ipv4Addr::new(192, 168, 0, 2))); + } + + #[test] + fn parse_unconfigured() { + // Interface present but no inet line. + let out = "ec0: flags=8002\n# "; + assert_eq!(parse_ec0_inet(out), None); + } + + #[test] + fn diagnose_three_ways() { + let e = ExpectedNet::from_subnet(&subnet([192, 168, 0, 0])); + assert_eq!(diagnose(Some(e.ip), &e), NetDiagnosis::Correct); + let wrong = Ipv4Addr::new(10, 0, 0, 9); + assert_eq!(diagnose(Some(wrong), &e), NetDiagnosis::Wrong(wrong)); + assert_eq!(diagnose(None, &e), NetDiagnosis::Unconfigured); + } + + #[test] + fn fix_commands_for_default_subnet() { + let e = ExpectedNet::from_subnet(&subnet([192, 168, 0, 0])); + let cmds = runtime_fix_commands(&e); + assert_eq!(cmds[0], "/usr/etc/ifconfig ec0 inet 192.168.0.2 netmask 0xffffff00 up"); + assert_eq!(cmds[1], "/usr/etc/route add default 192.168.0.1 1"); + } + + #[test] + fn non_default_subnet() { + // A user who changed nat_subnet to 192.168.2.0/24 — guest should be .2. + let e = ExpectedNet::from_subnet(&subnet([192, 168, 2, 0])); + assert_eq!(e.ip, Ipv4Addr::new(192, 168, 2, 2)); + assert_eq!(parse_ec0_inet("\tinet 192.168.2.2 netmask 0xffffff00"), Some(e.ip)); + assert_eq!(diagnose(Some(e.ip), &e), NetDiagnosis::Correct); + } +} diff --git a/iris-gui/src/netplan.rs b/iris-gui/src/netplan.rs new file mode 100644 index 0000000..672804f --- /dev/null +++ b/iris-gui/src/netplan.rs @@ -0,0 +1,522 @@ +//! Pure planning/validation logic for the Networking tab's subnet controls. +//! +//! The Networking tab lets the user pick a **network address** and a **subnet +//! mask** that recompose into the single `nat_subnet` CIDR string the backend +//! stores (`iris::config::parse_nat_subnet`). This module is the brain behind +//! those controls, kept free of UI and (almost) free of I/O so it's unit +//! testable: +//! +//! - mask/network/broadcast math ([`netmask`], [`network_addr`], …), +//! - [`first_free_24`] — the first unused /24 in an RFC1918 block, for the +//! "first free 192.168/172.16/10" presets, +//! - [`classify`] — sort a proposed (base, prefix) into ok / off-boundary (snap) +//! / soft-invalid (override dialog) / hard-invalid (blocked), +//! - [`to_cidr`] — compose the snapped CIDR string to store. +//! +//! The only I/O is [`gather_host_ifaces`], a thin `if-addrs` wrapper that reads +//! the host's own interface addresses for conflict detection; the pure logic +//! takes a `&[HostIface]` slice so tests don't need real interfaces. +//! +//! Backend invariants this respects: `parse_nat_subnet` wants the **network +//! address** (host bits zero), rejects prefix `> /30`, and derives +//! **gateway = network + 1**, **Indy `ec0` = network + 2**. + +// Phase 0: the logic lands first; the Networking tab wires it in Phase 1. +#![allow(dead_code)] + +use std::net::Ipv4Addr; + +/// Subnet-mask prefixes offered in the mask dropdown (default `/24`). `/8 /12 +/// /16` are the native sizes of the three RFC1918 blocks. `Custom…` covers the +/// rest down to `/30`. +pub const MASK_PRESETS: &[u8] = &[8, 12, 16, 22, 24, 25, 26]; + +/// Default subnet when `nat_subnet` is unset — matches `NatSubnet::default` +/// (192.168.0.0/24, gateway .1, Indy .2). +pub const DEFAULT_BASE: Ipv4Addr = Ipv4Addr::new(192, 168, 0, 0); +pub const DEFAULT_PREFIX: u8 = 24; + +/// The largest prefix the engine can represent (`parse_nat_subnet` rejects +/// `> /30`; a /30 is the minimum viable subnet — gateway + Indy + broadcast). +pub const MAX_PREFIX: u8 = 30; + +// --------------------------------------------------------------------------- +// Bit math +// --------------------------------------------------------------------------- + +/// 32-bit mask for a prefix length (`/24` → `0xffffff00`). +pub fn mask_bits(prefix: u8) -> u32 { + if prefix == 0 { 0 } else { u32::MAX << (32 - prefix.min(32) as u32) } +} + +/// Dotted-decimal netmask for a prefix (`/24` → `255.255.255.0`). +pub fn netmask(prefix: u8) -> Ipv4Addr { + Ipv4Addr::from(mask_bits(prefix)) +} + +/// Network address: `base` with its host bits cleared. +pub fn network_addr(base: Ipv4Addr, prefix: u8) -> Ipv4Addr { + Ipv4Addr::from(u32::from(base) & mask_bits(prefix)) +} + +/// Broadcast address: network with all host bits set. +pub fn broadcast_addr(base: Ipv4Addr, prefix: u8) -> Ipv4Addr { + Ipv4Addr::from((u32::from(base) & mask_bits(prefix)) | !mask_bits(prefix)) +} + +/// Whether `base` is already on the subnet boundary (no host bits set). +pub fn is_network_addr(base: Ipv4Addr, prefix: u8) -> bool { + u32::from(base) & !mask_bits(prefix) == 0 +} + +/// Usable host count for a prefix (excludes network + broadcast). `0` for the +/// degenerate /31, /32 that the dropdown never offers. +pub fn usable_hosts(prefix: u8) -> u64 { + if prefix >= 31 { 0 } else { (1u64 << (32 - prefix as u32)) - 2 } +} + +/// Prefix length implied by a dotted-decimal netmask (popcount). +pub fn mask_to_prefix(mask: Ipv4Addr) -> u8 { + u32::from(mask).count_ones() as u8 +} + +/// Whether two CIDR blocks overlap (one contains the other's network). +pub fn cidr_overlap(a_net: Ipv4Addr, a_prefix: u8, b_net: Ipv4Addr, b_prefix: u8) -> bool { + let p = a_prefix.min(b_prefix); + network_addr(a_net, p) == network_addr(b_net, p) +} + +// --------------------------------------------------------------------------- +// RFC1918 blocks +// --------------------------------------------------------------------------- + +/// One private (RFC1918) address block, used for the network-address presets. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PrivateBlock { + /// `192.168.0.0/16` — default; "class C" sized /24s within it. + C, + /// `172.16.0.0/12`. + B, + /// `10.0.0.0/8`. + A, +} + +impl PrivateBlock { + pub fn base(self) -> Ipv4Addr { + match self { + PrivateBlock::C => Ipv4Addr::new(192, 168, 0, 0), + PrivateBlock::B => Ipv4Addr::new(172, 16, 0, 0), + PrivateBlock::A => Ipv4Addr::new(10, 0, 0, 0), + } + } + pub fn prefix(self) -> u8 { + match self { PrivateBlock::C => 16, PrivateBlock::B => 12, PrivateBlock::A => 8 } + } + /// Short label for the preset dropdown (e.g. `192.168.x`). + pub fn label(self) -> &'static str { + match self { + PrivateBlock::C => "192.168.x", + PrivateBlock::B => "172.16.x", + PrivateBlock::A => "10.x", + } + } +} + +/// Which RFC1918 block a network address falls in, or `None` if it's public. +pub fn block_of(net: Ipv4Addr) -> Option { + let o = net.octets(); + if o[0] == 10 { Some(PrivateBlock::A) } + else if o[0] == 172 && (16..=31).contains(&o[1]) { Some(PrivateBlock::B) } + else if o[0] == 192 && o[1] == 168 { Some(PrivateBlock::C) } + else { None } +} + +/// `true` if `net` is in private (RFC1918) space. +pub fn is_rfc1918(net: Ipv4Addr) -> bool { + block_of(net).is_some() +} + +// --------------------------------------------------------------------------- +// Host interfaces (the only I/O) +// --------------------------------------------------------------------------- + +/// An IPv4 network the host already occupies, scraped from `if-addrs` and used +/// for first-free selection and conflict warnings. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HostIface { + /// Interface name (e.g. `en0`) — shown in conflict messages. + pub name: String, + /// The interface's own address. + pub addr: Ipv4Addr, + /// Network address (host bits cleared). + pub network: Ipv4Addr, + /// Prefix length implied by the interface's netmask. + pub prefix: u8, +} + +/// Read the host's IPv4 interfaces. Loopback is skipped. Returns an empty Vec on +/// any error (conflict detection then simply finds nothing). +pub fn gather_host_ifaces() -> Vec { + let Ok(ifaces) = if_addrs::get_if_addrs() else { return Vec::new() }; + ifaces + .into_iter() + .filter_map(|i| match i.addr { + if_addrs::IfAddr::V4(v4) if !v4.ip.is_loopback() => { + let prefix = mask_to_prefix(v4.netmask); + Some(HostIface { + name: i.name, + addr: v4.ip, + network: network_addr(v4.ip, prefix), + prefix, + }) + } + _ => None, + }) + .collect() +} + +/// The first host interface whose network overlaps `network/prefix`, if any. +pub fn conflict<'a>(network: Ipv4Addr, prefix: u8, host: &'a [HostIface]) -> Option<&'a HostIface> { + host.iter().find(|h| cidr_overlap(network, prefix, h.network, h.prefix)) +} + +/// The first network of size `prefix` in `block` that doesn't overlap any host +/// interface — the "first free 192.168/172.16/10" preset at the chosen mask. If +/// `prefix` is no longer than the block itself, the block base is the only +/// network. Falls back to the block base if everything conflicts (pathological). +pub fn first_free(block: PrivateBlock, prefix: u8, host: &[HostIface]) -> Ipv4Addr { + if prefix <= block.prefix() { + return block.base(); + } + let base = u32::from(block.base()); + let step = 1u32 << (32 - prefix as u32); // size of one network of this prefix + let count = 1u32 << (prefix - block.prefix()); // networks of this size in the block + let scan = count.min(1 << 16); // first candidate is almost always free + for i in 0..scan { + let cand = Ipv4Addr::from(base.wrapping_add(i.wrapping_mul(step))); + if conflict(cand, prefix, host).is_none() { + return cand; + } + } + block.base() +} + +/// First free /24 in `block` — the common case, used for the safe suggestion. +pub fn first_free_24(block: PrivateBlock, host: &[HostIface]) -> Ipv4Addr { + first_free(block, 24, host) +} + +// --------------------------------------------------------------------------- +// Derived addressing + sanity classification +// --------------------------------------------------------------------------- + +/// Everything the UI shows for a chosen subnet (computed from the *snapped* +/// network, so `network` is always on-boundary). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Derived { + pub network: Ipv4Addr, + pub prefix: u8, + pub netmask: Ipv4Addr, + /// Gateway / IRIS host = network + 1. + pub gateway: Ipv4Addr, + /// Indy `ec0` = network + 2. + pub client: Ipv4Addr, + pub broadcast: Ipv4Addr, + pub first_host: Ipv4Addr, + pub last_host: Ipv4Addr, + pub usable_hosts: u64, +} + +/// Derive addressing from a (possibly off-boundary) base + prefix. The base is +/// snapped to the network address first. +pub fn derive(base: Ipv4Addr, prefix: u8) -> Derived { + let net = u32::from(network_addr(base, prefix)); + let bcast = net | !mask_bits(prefix); + Derived { + network: Ipv4Addr::from(net), + prefix, + netmask: netmask(prefix), + gateway: Ipv4Addr::from(net + 1), + client: Ipv4Addr::from(net + 2), + broadcast: Ipv4Addr::from(bcast), + first_host: Ipv4Addr::from(net + 1), + last_host: Ipv4Addr::from(bcast - 1), + usable_hosts: usable_hosts(prefix), + } +} + +/// A soft (overridable) problem with a chosen subnet, plus a safe suggestion. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SoftWarn { + /// Human-readable reason (e.g. "overlaps your en0 (192.168.1.5)"). + pub reason: String, + /// A known-good network to offer via "Use suggested" (always a /24). + pub suggestion_net: Ipv4Addr, + pub suggestion_prefix: u8, +} + +/// Result of sanity-checking a proposed subnet. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Assessment { + /// Derived addressing, or `None` when [`hard_error`](Self::hard_error) is set. + pub derived: Option, + /// Set when the engine cannot represent the subnet at all — blocks saving. + pub hard_error: Option, + /// Set (with the typed base) when the base wasn't on the subnet boundary and + /// was snapped to the network address — informational, not blocking. + pub off_boundary: Option, + /// Set when the subnet parses but is unwise (non-RFC1918 or host conflict) — + /// drives the "Override Sanity Checks / Cancel" dialog. + pub soft: Option, +} + +/// Sort a proposed `base`/`prefix` into the sanity tiers, using `host` for +/// conflict detection. +pub fn classify(base: Ipv4Addr, prefix: u8, host: &[HostIface]) -> Assessment { + if prefix == 0 || prefix > MAX_PREFIX { + return Assessment { + derived: None, + hard_error: Some(format!( + "prefix /{} is out of range; use /1 to /{} (the Indy's NAT needs at least a /{})", + prefix, MAX_PREFIX, MAX_PREFIX + )), + off_boundary: None, + soft: None, + }; + } + + let derived = derive(base, prefix); + let net = derived.network; + let off_boundary = (!is_network_addr(base, prefix)).then_some(base); + + let soft = match block_of(net) { + None => Some(SoftWarn { + reason: format!("{} isn't a private (RFC1918) range", net), + suggestion_net: first_free_24(PrivateBlock::C, host), + suggestion_prefix: 24, + }), + Some(block) => conflict(net, prefix, host).map(|h| SoftWarn { + reason: format!("overlaps your {} ({})", h.name, h.addr), + suggestion_net: first_free_24(block, host), + suggestion_prefix: 24, + }), + }; + + Assessment { derived: Some(derived), hard_error: None, off_boundary, soft } +} + +// --------------------------------------------------------------------------- +// CIDR string compose / parse +// --------------------------------------------------------------------------- + +/// Compose the CIDR string to store in `nat_subnet`, snapping `base` to its +/// network address so `parse_nat_subnet` accepts it. +pub fn to_cidr(base: Ipv4Addr, prefix: u8) -> String { + format!("{}/{}", network_addr(base, prefix), prefix) +} + +/// Best-effort parse of a stored CIDR string back into (base, prefix) for +/// editing. Returns the default subnet on `None`/malformed input. +pub fn parse_cidr(s: Option<&str>) -> (Ipv4Addr, u8) { + let Some(s) = s else { return (DEFAULT_BASE, DEFAULT_PREFIX) }; + let parsed = s.split_once('/').and_then(|(a, p)| { + Some((a.trim().parse::().ok()?, p.trim().parse::().ok()?)) + }); + parsed.unwrap_or((DEFAULT_BASE, DEFAULT_PREFIX)) +} + +/// Group a number with thousands separators for host-count labels. +pub fn commas(n: u64) -> String { + let s = n.to_string(); + let mut out = String::with_capacity(s.len() + s.len() / 3); + let bytes = s.as_bytes(); + for (i, b) in bytes.iter().enumerate() { + if i > 0 && (bytes.len() - i) % 3 == 0 { + out.push(','); + } + out.push(*b as char); + } + out +} + +/// Label for a mask-dropdown entry, e.g. `/24 = 255.255.255.0 (254 hosts)`. +pub fn mask_label(prefix: u8) -> String { + format!("/{} = {} ({} hosts)", prefix, netmask(prefix), commas(usable_hosts(prefix))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ip(a: u8, b: u8, c: u8, d: u8) -> Ipv4Addr { Ipv4Addr::new(a, b, c, d) } + + fn iface(name: &str, a: u8, b: u8, c: u8, d: u8, prefix: u8) -> HostIface { + let addr = ip(a, b, c, d); + HostIface { name: name.into(), addr, network: network_addr(addr, prefix), prefix } + } + + #[test] + fn masks_and_networks() { + assert_eq!(netmask(24), ip(255, 255, 255, 0)); + assert_eq!(netmask(25), ip(255, 255, 255, 128)); + assert_eq!(netmask(16), ip(255, 255, 0, 0)); + assert_eq!(netmask(12), ip(255, 240, 0, 0)); + assert_eq!(netmask(8), ip(255, 0, 0, 0)); + assert_eq!(netmask(30), ip(255, 255, 255, 252)); + assert_eq!(network_addr(ip(192, 168, 40, 200), 24), ip(192, 168, 40, 0)); + assert_eq!(broadcast_addr(ip(192, 168, 40, 0), 24), ip(192, 168, 40, 255)); + assert_eq!(mask_to_prefix(ip(255, 255, 255, 128)), 25); + } + + #[test] + fn host_counts() { + assert_eq!(usable_hosts(24), 254); + assert_eq!(usable_hosts(25), 126); + assert_eq!(usable_hosts(26), 62); + assert_eq!(usable_hosts(22), 1022); + assert_eq!(usable_hosts(16), 65534); + assert_eq!(usable_hosts(12), 1_048_574); + assert_eq!(usable_hosts(8), 16_777_214); + assert_eq!(usable_hosts(30), 2); + } + + #[test] + fn boundary_detection() { + assert!(is_network_addr(ip(192, 168, 0, 0), 24)); + assert!(!is_network_addr(ip(192, 168, 0, 5), 24)); + // /25: .0 and .128 are boundaries; .65 is not (it's a host in .0/25). + assert!(is_network_addr(ip(192, 168, 99, 0), 25)); + assert!(is_network_addr(ip(192, 168, 99, 128), 25)); + assert!(!is_network_addr(ip(192, 168, 99, 65), 25)); + } + + #[test] + fn overlap() { + assert!(cidr_overlap(ip(192, 168, 1, 0), 24, ip(192, 168, 1, 0), 16)); + assert!(cidr_overlap(ip(192, 168, 1, 0), 24, ip(192, 168, 1, 5), 24)); + assert!(!cidr_overlap(ip(192, 168, 1, 0), 24, ip(192, 168, 2, 0), 24)); + // a /16 host net swallows any /24 inside it. + assert!(cidr_overlap(ip(192, 168, 40, 0), 24, ip(192, 168, 0, 0), 16)); + } + + #[test] + fn first_free_skips_conflicts() { + // 192.168.0.0/24 and .1.0/24 taken → first free is .2.0. + let host = vec![iface("en0", 192, 168, 0, 5, 24), iface("en1", 192, 168, 1, 9, 24)]; + assert_eq!(first_free_24(PrivateBlock::C, &host), ip(192, 168, 2, 0)); + // No hosts → block base. + assert_eq!(first_free_24(PrivateBlock::C, &[]), ip(192, 168, 0, 0)); + assert_eq!(first_free_24(PrivateBlock::B, &[]), ip(172, 16, 0, 0)); + assert_eq!(first_free_24(PrivateBlock::A, &[]), ip(10, 0, 0, 0)); + // A host's /16 swallows the whole 192.168 block → fall through to first + // free /24 (still returns base via fallback only if ALL conflict; here a + // /16 over 192.168 conflicts with every /24, so fallback = block base). + let wide = vec![iface("vpn0", 192, 168, 0, 1, 16)]; + assert_eq!(first_free_24(PrivateBlock::C, &wide), ip(192, 168, 0, 0)); + } + + #[test] + fn first_free_honors_prefix() { + // prefix == block prefix or wider → block base is the only network. + assert_eq!(first_free(PrivateBlock::C, 16, &[]), ip(192, 168, 0, 0)); + assert_eq!(first_free(PrivateBlock::C, 8, &[]), ip(192, 168, 0, 0)); + // /25: first network is the block base; if it's taken, step to .0.128. + assert_eq!(first_free(PrivateBlock::C, 25, &[]), ip(192, 168, 0, 0)); + let host = vec![iface("en0", 192, 168, 0, 5, 25)]; // occupies 192.168.0.0/25 + assert_eq!(first_free(PrivateBlock::C, 25, &host), ip(192, 168, 0, 128)); + } + + #[test] + fn rfc1918_membership() { + assert_eq!(block_of(ip(192, 168, 0, 0)), Some(PrivateBlock::C)); + assert_eq!(block_of(ip(172, 16, 0, 0)), Some(PrivateBlock::B)); + assert_eq!(block_of(ip(172, 31, 255, 0)), Some(PrivateBlock::B)); + assert_eq!(block_of(ip(172, 32, 0, 0)), None); + assert_eq!(block_of(ip(10, 1, 2, 0)), Some(PrivateBlock::A)); + assert_eq!(block_of(ip(8, 8, 8, 0)), None); + } + + #[test] + fn derive_default() { + let d = derive(ip(192, 168, 0, 0), 24); + assert_eq!(d.gateway, ip(192, 168, 0, 1)); + assert_eq!(d.client, ip(192, 168, 0, 2)); + assert_eq!(d.broadcast, ip(192, 168, 0, 255)); + assert_eq!(d.first_host, ip(192, 168, 0, 1)); + assert_eq!(d.last_host, ip(192, 168, 0, 254)); + assert_eq!(d.usable_hosts, 254); + } + + #[test] + fn classify_ok() { + let a = classify(ip(192, 168, 0, 0), 24, &[]); + assert!(a.hard_error.is_none()); + assert!(a.off_boundary.is_none()); + assert!(a.soft.is_none()); + assert_eq!(a.derived.unwrap().client, ip(192, 168, 0, 2)); + } + + #[test] + fn classify_user_slash25_example() { + // The user's "192.168.99.65/25": a host, not a network. Snaps to + // 192.168.99.0/25, gateway .1, Indy .2, mask 255.255.255.128. + let a = classify(ip(192, 168, 99, 65), 25, &[]); + assert_eq!(a.off_boundary, Some(ip(192, 168, 99, 65))); + assert!(a.soft.is_none()); + let d = a.derived.unwrap(); + assert_eq!(d.network, ip(192, 168, 99, 0)); + assert_eq!(d.gateway, ip(192, 168, 99, 1)); + assert_eq!(d.client, ip(192, 168, 99, 2)); + assert_eq!(d.netmask, ip(255, 255, 255, 128)); + } + + #[test] + fn classify_widening_mask_snaps() { + // first-free hands 192.168.40.0; switching to /16 is off-boundary → + // snaps to 192.168.0.0/16. + let a = classify(ip(192, 168, 40, 0), 16, &[]); + assert_eq!(a.off_boundary, Some(ip(192, 168, 40, 0))); + assert_eq!(a.derived.unwrap().network, ip(192, 168, 0, 0)); + } + + #[test] + fn classify_non_rfc1918_is_soft() { + let a = classify(ip(8, 8, 8, 0), 24, &[]); + let soft = a.soft.expect("public range should warn"); + assert!(soft.reason.contains("RFC1918") || soft.reason.contains("private")); + assert_eq!(soft.suggestion_net, ip(192, 168, 0, 0)); + assert!(a.derived.is_some()); // still representable, just unwise + } + + #[test] + fn classify_conflict_is_soft_with_suggestion() { + let host = vec![iface("en0", 192, 168, 0, 5, 24)]; + let a = classify(ip(192, 168, 0, 0), 24, &host); + let soft = a.soft.expect("conflict should warn"); + assert!(soft.reason.contains("en0")); + assert_eq!(soft.suggestion_net, ip(192, 168, 1, 0)); // first free past the conflict + } + + #[test] + fn classify_hard_prefix() { + assert!(classify(ip(192, 168, 0, 0), 31, &[]).hard_error.is_some()); + assert!(classify(ip(192, 168, 0, 0), 0, &[]).hard_error.is_some()); + assert!(classify(ip(192, 168, 0, 0), 30, &[]).hard_error.is_none()); + } + + #[test] + fn cidr_roundtrip() { + assert_eq!(to_cidr(ip(192, 168, 99, 65), 25), "192.168.99.0/25"); + assert_eq!(to_cidr(ip(192, 168, 0, 0), 24), "192.168.0.0/24"); + assert_eq!(parse_cidr(Some("10.0.0.0/8")), (ip(10, 0, 0, 0), 8)); + assert_eq!(parse_cidr(None), (DEFAULT_BASE, DEFAULT_PREFIX)); + assert_eq!(parse_cidr(Some("garbage")), (DEFAULT_BASE, DEFAULT_PREFIX)); + } + + #[test] + fn commas_format() { + assert_eq!(commas(254), "254"); + assert_eq!(commas(1022), "1,022"); + assert_eq!(commas(16_777_214), "16,777,214"); + } +} diff --git a/iris-gui/src/settings.rs b/iris-gui/src/settings.rs index 1b92e6e..97252db 100644 --- a/iris-gui/src/settings.rs +++ b/iris-gui/src/settings.rs @@ -10,9 +10,6 @@ use std::path::{Path, PathBuf}; /// only, for compatibility with the standalone `iris` CLI. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct GuiSettings { - /// Window width / height at last close. - #[serde(default)] - pub window_size: Option<[f32; 2]>, /// egui UI scale (1.0 = default). #[serde(default = "default_ui_scale")] pub ui_scale: f32, @@ -22,9 +19,6 @@ pub struct GuiSettings { /// resize the picture, and vice-versa. #[serde(default = "default_vm_scale")] pub vm_scale: f32, - /// Was the app left in fullscreen mode at last close? - #[serde(default)] - pub fullscreen: bool, /// All saved machines keyed by user-visible name. BTreeMap so menus /// list them in stable alphabetical order. @@ -51,6 +45,16 @@ pub struct GuiSettings { /// See [`crate::macos_sandbox`]. #[serde(default)] pub bookmarks: BTreeMap>, + + /// Folders the user has granted access to under the Mac App Store sandbox + /// (via "Grant disks folder…"). A *directory* security-scoped bookmark is + /// recursive, so granting one folder covers every disk image, the CHD diff / + /// fold temp written beside a base, AND an NFS shared subfolder under it — so + /// the "Synchronizing disks" fold (which needs to create a sibling temp and + /// rename over the base) works without per-file grants. Empty off the App + /// Store build. See [`crate::macos_sandbox`]. + #[serde(default)] + pub disk_folders: Vec, } /// Byte offset of the Indy's 6-byte Ethernet MAC inside the NVRAM. The PROM @@ -151,7 +155,11 @@ pub const UI_SCALE_DEFAULT: f32 = 1.25; pub const VM_SCALE_MIN: f32 = 0.5; pub const VM_SCALE_MAX: f32 = 3.0; pub const VM_SCALE_STEP: f64 = 0.25; -pub const VM_SCALE_DEFAULT: f32 = 1.0; +/// Default windowed VM scale. 0.75 (not native 1.0) so a from-scratch window +/// opens *target-bound* on a typical laptop — sized to the picture exactly, +/// rather than "as big as the monitor allows" which clamps to a fractional +/// scale and leaves letterbox slack around the 5:4 display. +pub const VM_SCALE_DEFAULT: f32 = 0.75; /// First-launch window size in logical points. Sized to match the *running* /// window for the standard 1280×1024 display so the picture doesn't visibly @@ -230,9 +238,16 @@ impl GuiSettings { } pub fn load() -> Self { - let Some(path) = Self::config_path() else { return Self::default(); }; - let Ok(text) = std::fs::read_to_string(&path) else { return Self::default(); }; - let mut s: Self = serde_json::from_str(&text).unwrap_or_default(); + // Load from disk when present, else start from defaults — but ALWAYS + // fall through to the sanitizer below. A missing or unreadable file used + // to early-return `Self::default()`, which leaves `vm_scale`/`ui_scale` + // at the struct's zero `Default` (0.0, not the serde field defaults). + // A 0.0 vm_scale then panics the window-fit math (`clamp` min > max), so + // a first-ever run with no gui.json crashed instead of using defaults. + let mut s: Self = Self::config_path() + .and_then(|path| std::fs::read_to_string(&path).ok()) + .and_then(|text| serde_json::from_str::(&text).ok()) + .unwrap_or_default(); // Sanitize a stale/out-of-range persisted scale. A value below the // minimum is junk left by an older build whose keyboard zoom floored // at 0.5 (the UI can no longer produce sub-minimum values), so reset @@ -266,7 +281,12 @@ impl GuiSettings { .values() .flat_map(crate::macos_sandbox::config_paths) .collect(); - crate::macos_sandbox::harvest(paths.iter().map(String::as_str), &mut self.bookmarks); + // Harvest both per-file bookmarks and the user-granted disk folders (a + // directory bookmark is recursive — see `disk_folders`). + crate::macos_sandbox::harvest( + paths.iter().map(String::as_str).chain(self.disk_folders.iter().map(String::as_str)), + &mut self.bookmarks, + ); let path = Self::config_path().ok_or("no config dir")?; if let Some(parent) = path.parent() { diff --git a/rules/irix/networking.md b/rules/irix/networking.md index a2eb906..b09ae9a 100644 --- a/rules/irix/networking.md +++ b/rules/irix/networking.md @@ -12,6 +12,13 @@ ## Common mistakes +- **Networking turned off:** `/etc/config/network` must be `on` (set it with + `chkconfig network on`). If it's `off` or missing, the network rc scripts never + run, `ec0` is never configured, and the guest emits **no traffic at all** — the + GUI's "Check networking" window shows "No guest traffic seen yet" with no error. + Reboot after enabling (or `/etc/init.d/network start`). Easy to miss because + every other file can be correct and networking still won't start. + - **Wrong filename:** Use `ifconfig-ec0.options`, NOT `ifconfig-1.options`. IRIX names config files after the interface device name. diff --git a/rules/irix/nfs-readdir-must-respect-count-and-fit-one-datagram.md b/rules/irix/nfs-readdir-must-respect-count-and-fit-one-datagram.md new file mode 100644 index 0000000..b35fce0 --- /dev/null +++ b/rules/irix/nfs-readdir-must-respect-count-and-fit-one-datagram.md @@ -0,0 +1,61 @@ +# NFS READDIR replies must respect the client's `count` and fit one UDP datagram + +## Symptom + +Mounting the in-core NFS server (`src/nfsudp.rs`) works, but `ls` on a folder +with many entries (e.g. a real Mac `~/Downloads`) fails on the guest with: + +``` +NFS2 readdir failed for server 192.168.x.1: Can't decode result +``` + +A small directory lists fine; only a *large* one fails. The mount, GETATTR, +LOOKUP, and READ all work — so it is specifically the READDIR **reply**. + +## Cause + +"Can't decode result" is an RPC-client XDR decode failure, not a timeout — the +datagram arrived but the client couldn't parse it. Two reply-size mistakes cause +it, and a big directory trips both at once: + +1. **Ignoring the request's `count`.** BSD/SunRPC-derived clients (IRIX's NFS is + one) size their reply *receive buffer* to the `count` field they sent in the + READDIR/READDIRPLUS args. If the server returns more directory data than + `count`, the reply is truncated on the client side and the decode runs off + the end. The server MUST cap the reply at `count`. +2. **Spilling into IP fragments.** A multi-kilobyte reply fragments into several + Ethernet frames; any reassembly weakness corrupts the datagram. READDIR is + designed to be *paged* (the client re-issues with the last cookie), so there + is no reason to emit a fragmented readdir reply at all. + +The original code ignored `count` and used a fixed ~8 KB byte budget, which both +overran a smaller client buffer and forced fragmentation. + +## Fix + +Page each readdir reply to fit **both** the client's `count` **and** a single +unfragmented UDP datagram, continuing via the cookie: + +- **v2** (`nfs2_readdir`): `let limit = count.clamp(512, 1400);` — 1400 keeps the + whole RPC reply under the 1472-byte single-datagram UDP payload (MTU 1500 − IP + 20 − UDP 8) after the ~36 bytes of reply header + list terminators. +- **v3** (`nfs3_readdir`): read `maxcount` (READDIRPLUS sends `dircount` first, + then `maxcount`) and cap at `maxcount.clamp(512, 16_000)`. v3 keeps the larger + budget because its larger client buffers make fragmented readdir replies + workable, but it still must never exceed the client's stated `maxcount`. + +The `budget > 0` guard already guarantees at least one entry per reply, so a tiny +`count` still makes progress; `eof` is set only when the last entry fits. + +Regression test: `nfsudp::tests::v2_readdir_pages_within_one_datagram` builds a +200-entry directory, asserts every page is ≤ 1472 bytes, walks the cookie chain, +and checks every name comes back exactly once. + +## Watch out + +- Any new procedure that returns a variable-length list (e.g. MOUNT EXPORT with + many exports) has the same single-datagram / client-buffer constraint. +- READ already fragments by design (file data up to rtmax = 32768), so the + outbound fragmenter (`net.rs::ip_frames_udp`) must stay correct regardless — + this fix sidesteps fragmentation for *readdir*, it does not remove the need for + it elsewhere. diff --git a/src/bin/chd_extract.rs b/src/bin/chd_extract.rs index ab37ab7..e8fcab0 100644 --- a/src/bin/chd_extract.rs +++ b/src/bin/chd_extract.rs @@ -16,7 +16,8 @@ fn main() { let input = &args[1]; let output = &args[2]; - let mut chd = match iris::chd_disk::ChdHd::open(input) { + // Extract reads through the merged view; COW off (no diff is created here). + let mut chd = match iris::chd_disk::ChdHd::open(input, false) { Ok(c) => c, Err(e) => { eprintln!("open {}: {}", input, e); exit(1); } }; diff --git a/src/chd_disk.rs b/src/chd_disk.rs index efcac76..309f767 100644 --- a/src/chd_disk.rs +++ b/src/chd_disk.rs @@ -23,6 +23,20 @@ pub struct ChdHd { img: HdImage, sector_size: u32, total_bytes: u64, + /// The base CHD path (the file the user configured). + base_path: PathBuf, + /// The `.diff.chd` sidecar, when writes go to a diff (compressed base, or a + /// COW-style overlay). `None` when writing the base in place (uncompressed). + diff_path: Option, + /// Whether the diff holds changes worth folding back into the base on a + /// clean shutdown — either we wrote this session, or we reattached a diff + /// that already existed (carrying changes from a previous session). + dirty: bool, + /// Copy-on-write requested for this disk (the per-disk `overlay`/COW flag). + /// When set we ALWAYS overlay (even an uncompressed base gets a diff, so the + /// base is never written in-session) and we NEVER auto-fold on exit — the + /// user commits or rolls back deliberately via `cow commit` / `cow reset`. + cow: bool, } // The underlying MAME chd_file holds a raw pointer (`*mut ChdFile`), making it @@ -33,26 +47,57 @@ unsafe impl Send for ChdHd {} unsafe impl Send for ChdCd {} impl ChdHd { - pub fn open(path: &str) -> io::Result { + pub fn open(path: &str, cow: bool) -> io::Result { let p = Path::new(path); let diff = diff_path_for(p); - // If a diff sidecar already exists, reattach to it (so previously-written - // sectors are preserved across runs, like a COW overlay). - let img = if diff.exists() { - HdImage::reopen_diff(p, &diff).map_err(map_err)? + // Cases: + // - a diff already exists → reattach to it; it carries changes from a + // previous session, so mark dirty so a (non-COW) clean exit folds it. + // - COW on → always overlay, even an uncompressed base, so the base is + // never written in-session (protect + rollback). + // - no diff, base opens writable in place (uncompressed, COW off) → no diff. + // - no diff, base won't open writable (compressed) → create a fresh diff. + let (img, diff_path, dirty) = if diff.exists() { + (HdImage::reopen_diff(p, &diff).map_err(map_err)?, Some(diff), true) + } else if cow { + (HdImage::open_with_diff(p, &diff).map_err(map_err)?, Some(diff), false) } else { - // Try in-place first (works for uncompressed CHDs). On failure, - // fall back to creating an uncompressed diff alongside the parent. match HdImage::open(p) { - Ok(img) => img, - Err(_) => HdImage::open_with_diff(p, &diff).map_err(map_err)?, + Ok(img) => (img, None, false), + Err(_) => (HdImage::open_with_diff(p, &diff).map_err(map_err)?, Some(diff), false), } }; let sector_size = img.sector_size(); let total_bytes = img.sector_count() * u64::from(sector_size); - Ok(Self { img, sector_size, total_bytes }) + Ok(Self { img, sector_size, total_bytes, base_path: p.to_path_buf(), diff_path, dirty, cow }) + } + + /// `(base, diff)` paths if a clean exit should **auto-fold** this disk's diff + /// back into the base — i.e. it has diff-borne changes AND COW is off (COW on + /// means "keep separate"; commit/rollback are then manual). `None` otherwise. + pub fn pending_sync(&self) -> Option<(PathBuf, PathBuf)> { + if self.cow { + return None; // keep changes separate; never auto-fold + } + self.overlay_paths().filter(|_| self.dirty) + } + + /// Whether this disk is in copy-on-write mode (the per-disk COW flag). + pub fn is_cow(&self) -> bool { + self.cow + } + + /// `(base, diff)` when writes are landing in a `.diff.chd` overlay (regardless + /// of the COW flag — a compressed base always overlays). Used by commit/reset. + pub fn overlay_paths(&self) -> Option<(PathBuf, PathBuf)> { + self.diff_path.as_ref().map(|d| (self.base_path.clone(), d.clone())) + } + + /// Whether the overlay holds uncommitted changes. + pub fn diff_dirty(&self) -> bool { + self.dirty } pub fn size(&self) -> u64 { @@ -92,10 +137,143 @@ impl ChdHd { .write_sector(lba + i as u64, &data[off..off + ss]) .map_err(map_err)?; } + // Writing to a diff means the sidecar now diverges from the base, so a + // clean shutdown should fold it back. (No-op for an in-place base.) + if self.diff_path.is_some() { + self.dirty = true; + } Ok(()) } } +/// A sequential `Read` over the merged (parent + diff) sectors of an [`HdImage`], +/// used to feed [`flatten_diff`]'s rebuild. Reads one sector at a time. +struct MergedReader { + img: HdImage, + sector_size: usize, + sector_count: u64, + next_lba: u64, + buf: Vec, + pos: usize, // bytes consumed from `buf` + len: usize, // valid bytes in `buf` +} + +impl MergedReader { + fn new(img: HdImage, sector_size: usize, sector_count: u64) -> Self { + Self { img, sector_size, sector_count, next_lba: 0, buf: vec![0u8; sector_size], pos: 0, len: 0 } + } +} + +impl Read for MergedReader { + fn read(&mut self, out: &mut [u8]) -> io::Result { + if self.pos >= self.len { + if self.next_lba >= self.sector_count { + return Ok(0); // EOF — every sector streamed + } + self.img.read_sector(self.next_lba, &mut self.buf).map_err(map_err)?; + self.next_lba += 1; + self.pos = 0; + self.len = self.sector_size; + } + let n = (self.len - self.pos).min(out.len()); + out[..n].copy_from_slice(&self.buf[self.pos..self.pos + n]); + self.pos += n; + Ok(n) + } +} + +/// Fold a `.diff.chd` back into its base CHD: rebuild the base from the merged +/// (parent + diff) view, preserving the base's codecs / geometry / hunk+unit +/// sizes (so a compressed base stays compressed), via a temp file + atomic +/// rename, then delete the diff. +/// +/// Safety: the base is only ever replaced by an atomic rename of a fully-written, +/// fsynced temp file, and the diff is deleted only after that rename succeeds. On +/// any error or cancellation the base and diff are left exactly as they were, so +/// the next launch simply reattaches the diff — nothing is lost. +/// +/// `progress(fraction)` receives 0.0..=1.0; `cancel()` aborts cleanly. The caller +/// MUST have dropped any open [`ChdHd`] for this base first (so the files are +/// closed) before calling this. +pub fn flatten_diff( + base: &Path, + diff: &Path, + progress: &mut dyn FnMut(f32), + cancel: &dyn Fn() -> bool, +) -> io::Result<()> { + use libchdman_rs::hd::{create_from_reader, read_geometry, HdCreateOptions}; + use libchdman_rs::{Chd, CompressionProgress}; + + let base_str = base.to_str().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "non-UTF-8 CHD path"))?; + + // Read the base's structure so the rebuilt CHD matches it byte-for-byte in + // codecs/geometry (compressed stays compressed). Scope the handle so it's + // closed before we rename over the base. + let (codecs, hunk_bytes, unit_bytes, logical, geom) = { + let bchd = Chd::open(base_str, false, None).map_err(map_err)?; + let info = bchd.info().map_err(map_err)?; + if !info.is_hd { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "not a hard-disk CHD")); + } + (info.codecs, info.hunk_bytes, info.unit_bytes, info.logical_bytes, read_geometry(&bchd).ok()) + }; + + // Merged view: parent (base) with the diff applied. Its sectors are the + // contents we rebuild the base from. + let merged = HdImage::reopen_diff(base, diff).map_err(map_err)?; + let sector_size = merged.sector_size() as usize; + let sector_count = merged.sector_count(); + + // Rebuild into a temp file next to the base (same filesystem → the rename is + // atomic). The reader (and the merged HdImage it owns) is dropped when + // create_from_reader returns, closing the base+diff handles before rename. + let tmp = temp_sync_path_for(base); + let opts = HdCreateOptions { + logical_size: logical, + hunk_size: hunk_bytes, + unit_size: unit_bytes, + codecs, + geometry: geom, + ident: None, + }; + let reader = MergedReader::new(merged, sector_size, sector_count); + let total = logical.max(1); + let mut cb = |cp: CompressionProgress| { + progress((cp.bytes_done as f64 / total as f64).min(1.0) as f32); + }; + if let Err(e) = create_from_reader(reader, &tmp, opts, &mut cb, cancel) { + let _ = std::fs::remove_file(&tmp); // base + diff untouched + return Err(map_err(e)); + } + + // Durably replace the base, then drop the diff. The diff is removed only + // after the rename, so an interruption anywhere above leaves base+diff intact. + fsync_path(&tmp)?; + std::fs::rename(&tmp, base)?; + let _ = fsync_dir(base.parent()); + let _ = std::fs::remove_file(diff); + progress(1.0); + Ok(()) +} + +/// Temp path for the rebuilt CHD, alongside the base so the rename is atomic. +fn temp_sync_path_for(base: &Path) -> PathBuf { + let mut s = base.as_os_str().to_owned(); + s.push(".synctmp.chd"); + PathBuf::from(s) +} + +fn fsync_path(p: &Path) -> io::Result<()> { + std::fs::OpenOptions::new().read(true).write(true).open(p)?.sync_all() +} + +fn fsync_dir(dir: Option<&Path>) -> io::Result<()> { + if let Some(d) = dir { + std::fs::File::open(d)?.sync_all()?; + } + Ok(()) +} + /// Read-only CD CHD backend. pub struct ChdCd { reader: CdCookedReader, @@ -124,7 +302,9 @@ impl ChdCd { } } -fn diff_path_for(parent: &Path) -> PathBuf { +/// The `.diff.chd` sidecar path for a base CHD (honors `IRIS_CHD_DIFF_DIR`). +/// Public so the GUI can check for / locate a disk's overlay for commit/rollback. +pub fn diff_path_for(parent: &Path) -> PathBuf { // A compressed HD CHD can't be written in place, so writes go to an // uncompressed `.diff.chd` sidecar. By default it sits next to the parent. // @@ -155,3 +335,161 @@ pub fn is_chd(path: &str) -> bool { .map(|e| e.eq_ignore_ascii_case("chd")) .unwrap_or(false) } + +#[cfg(test)] +mod tests { + use super::*; + use libchdman_rs::hd::{create_from_reader, HdCreateOptions}; + use libchdman_rs::{Chd, CHD_CODEC_ZLIB}; + use std::io::Cursor; + use std::sync::atomic::{AtomicU64, Ordering}; + + fn unique_base() -> PathBuf { + static N: AtomicU64 = AtomicU64::new(0); + std::env::temp_dir().join(format!( + "iris_flatten_{}_{}.chd", + std::process::id(), + N.fetch_add(1, Ordering::Relaxed) + )) + } + + /// End-to-end: a compressed base gets a write via its diff, and flatten folds + /// the write back into the (still-compressed) base, removing the diff. + #[test] + fn flatten_folds_diff_into_compressed_base() { + let base = unique_base(); + let logical = 256 * 1024u64; // 512 sectors of 512 bytes + // Build a COMPRESSED base full of 0xAB. + let src = vec![0xABu8; logical as usize]; + create_from_reader( + Cursor::new(src), + &base, + HdCreateOptions { + logical_size: logical, + hunk_size: 4096, + unit_size: 512, + codecs: [CHD_CODEC_ZLIB, 0, 0, 0], + geometry: None, + ident: None, + }, + &mut |_| {}, + &|| false, + ) + .unwrap(); + + // Open via ChdHd: compressed → can't write in place → diff is created. + let mut hd = ChdHd::open(base.to_str().unwrap(), false).unwrap(); + assert!(hd.pending_sync().is_none(), "fresh diff, nothing written → not pending"); + hd.write_sectors(2, &[0x5Au8; 512]).unwrap(); + let (b, d) = hd.pending_sync().expect("a write makes it pending"); + assert_eq!(b, base); + assert!(d.exists(), "diff sidecar exists"); + drop(hd); // close the CHD before flattening + + // Flatten: fold the diff back into the base. + let mut last = 0.0f32; + flatten_diff(&b, &d, &mut |f| last = f, &|| false).unwrap(); + assert_eq!(last, 1.0, "progress reaches 100%"); + assert!(!d.exists(), "diff removed after a successful flatten"); + + // Base now carries the write, is still readable, and still compressed. + { + let chd = Chd::open(base.to_str().unwrap(), false, None).unwrap(); + assert!(chd.info().unwrap().compressed, "base is still a compressed CHD"); + chd.verify().expect("flattened base verifies"); + } + // Reopen as a disk (compressed → fresh empty diff) and read the sectors. + let mut hd2 = ChdHd::open(base.to_str().unwrap(), false).unwrap(); + assert_eq!(hd2.read_blocks(2, 1, 512).unwrap(), vec![0x5A; 512], "the written sector folded in"); + assert_eq!(hd2.read_blocks(0, 1, 512).unwrap(), vec![0xAB; 512], "untouched sectors preserved"); + drop(hd2); + + // Cleanup. + let _ = std::fs::remove_file(&base); + let _ = std::fs::remove_file(diff_path_for(&base)); + } + + /// COW keeps changes in the overlay (no auto-fold), the same diff DOES auto- + /// fold when COW is off, and a rollback (discard the diff) restores the base. + #[test] + fn cow_keeps_changes_separate_and_rolls_back() { + let base = unique_base(); + let logical = 128 * 1024u64; + create_from_reader( + Cursor::new(vec![0xCCu8; logical as usize]), + &base, + HdCreateOptions { + logical_size: logical, + hunk_size: 4096, + unit_size: 512, + codecs: [CHD_CODEC_ZLIB, 0, 0, 0], + geometry: None, + ident: None, + }, + &mut |_| {}, + &|| false, + ) + .unwrap(); + + // COW on: writes land in the overlay and are NOT pending an auto-fold. + let mut hd = ChdHd::open(base.to_str().unwrap(), true).unwrap(); + assert!(hd.is_cow()); + assert!(hd.overlay_paths().is_some()); + hd.write_sectors(1, &[0x33u8; 512]).unwrap(); + assert!(hd.diff_dirty()); + assert!(hd.pending_sync().is_none(), "COW keeps changes separate — never auto-folds"); + drop(hd); + + // Reopen COW off: the same dirty diff IS now pending an exit fold, and the + // write is visible through the overlay. + let mut hd_off = ChdHd::open(base.to_str().unwrap(), false).unwrap(); + assert!(hd_off.pending_sync().is_some(), "COW off: the diff auto-folds on a clean exit"); + assert_eq!(hd_off.read_blocks(1, 1, 512).unwrap(), vec![0x33; 512], "write visible via the overlay"); + drop(hd_off); + + // Roll back: discard the diff. A fresh overlay over the untouched base + // reads the original content again. + std::fs::remove_file(diff_path_for(&base)).unwrap(); + let mut hd_rb = ChdHd::open(base.to_str().unwrap(), true).unwrap(); + assert_eq!(hd_rb.read_blocks(1, 1, 512).unwrap(), vec![0xCC; 512], "rollback restored the base content"); + drop(hd_rb); + + let _ = std::fs::remove_file(&base); + let _ = std::fs::remove_file(diff_path_for(&base)); + } + + /// A cancelled flatten leaves the base and diff intact. + #[test] + fn cancelled_flatten_preserves_base_and_diff() { + let base = unique_base(); + let logical = 128 * 1024u64; + create_from_reader( + Cursor::new(vec![0x11u8; logical as usize]), + &base, + HdCreateOptions { + logical_size: logical, + hunk_size: 4096, + unit_size: 512, + codecs: [CHD_CODEC_ZLIB, 0, 0, 0], + geometry: None, + ident: None, + }, + &mut |_| {}, + &|| false, + ) + .unwrap(); + let mut hd = ChdHd::open(base.to_str().unwrap(), false).unwrap(); + hd.write_sectors(1, &[0x22u8; 512]).unwrap(); + let (b, d) = hd.pending_sync().unwrap(); + drop(hd); + + let err = flatten_diff(&b, &d, &mut |_| {}, &|| true).unwrap_err(); + let _ = err; // cancellation surfaces as an error + assert!(b.exists(), "base intact after cancel"); + assert!(d.exists(), "diff intact after cancel"); + assert!(!temp_sync_path_for(&b).exists(), "no temp left behind"); + + let _ = std::fs::remove_file(&base); + let _ = std::fs::remove_file(&d); + } +} diff --git a/src/config.rs b/src/config.rs index 85564dc..5b0163d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -70,26 +70,17 @@ pub struct PortForwardConfig { pub bind: ForwardBind, } -/// NFS share configuration (requires unfsd on the host). +/// NFS share configuration. NFS is served in-process by the NAT +/// (`src/nfsudp.rs`) — no external `unfsd`, no host sockets. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NfsConfig { /// Directory to export over NFS. pub shared_dir: String, - /// Path to the unfsd binary [default: "unfsd"]. - #[serde(default = "default_unfsd")] - pub unfsd: String, - /// Host-side port unfsd listens on for NFS (high port, NAT'd to 2049 inside the VM). - #[serde(default = "default_nfs_host_port")] - pub nfs_host_port: u16, - /// Host-side port unfsd listens on for mountd (high port, NAT'd to 1234 inside the VM). - #[serde(default = "default_mountd_host_port")] - pub mountd_host_port: u16, + /// NFS protocol version to serve (Auto answers whatever the guest mounts). + #[serde(default)] + pub version: crate::nfsudp::NfsVersion, } -fn default_unfsd() -> String { "unfsd".to_string() } -fn default_nfs_host_port() -> u16 { 12049 } -fn default_mountd_host_port() -> u16 { 11234 } - /// Pre-parsed NAT subnet derived from a CIDR string. #[derive(Debug, Clone, Copy)] pub struct NatSubnet { @@ -524,18 +515,6 @@ pub struct Cli { #[arg(long = "nfs-dir", value_name = "DIR")] pub nfs_dir: Option, - /// Path to unfsd binary [default: unfsd] - #[arg(long = "unfsd", value_name = "PATH")] - pub unfsd: Option, - - /// Host port for unfsd NFS listener [default: 12049] - #[arg(long = "nfs-port", value_name = "PORT")] - pub nfs_host_port: Option, - - /// Host port for unfsd mountd listener [default: 11234] - #[arg(long = "mountd-port", value_name = "PORT")] - pub mountd_host_port: Option, - /// NAT subnet in CIDR notation (e.g. 192.168.5.0/24). /// Gateway gets .1, guest (IRIX) gets .2. Default: 192.168.0.0/24. #[arg(long = "nat-subnet", value_name = "CIDR")] @@ -613,21 +592,14 @@ impl Cli { // NB: --ci does NOT imply --headless. REX3 stays alive so screenshots // work; main.rs simply skips the host window when ci && !ci_display. - // NFS: --nfs-dir enables NFS; other flags refine an existing [nfs] section or the defaults. + // NFS: --nfs-dir enables the in-core NFS export. if let Some(dir) = &self.nfs_dir { let base = cfg.nfs.get_or_insert_with(|| NfsConfig { - shared_dir: dir.clone(), - unfsd: default_unfsd(), - nfs_host_port: default_nfs_host_port(), - mountd_host_port: default_mountd_host_port(), + shared_dir: dir.clone(), + version: Default::default(), }); base.shared_dir = dir.clone(); } - if let Some(ref mut nfs) = cfg.nfs { - if let Some(p) = &self.unfsd { nfs.unfsd = p.clone(); } - if let Some(p) = self.nfs_host_port { nfs.nfs_host_port = p; } - if let Some(p) = self.mountd_host_port { nfs.mountd_host_port = p; } - } if let Some(p) = self.gdb_port { cfg.gdb_port = Some(p); } if let Some(ref s) = self.nat_subnet { cfg.nat_subnet = Some(s.clone()); } diff --git a/src/lib.rs b/src/lib.rs index 2014929..a09bd30 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,6 +43,7 @@ pub mod monitor; pub mod locks; pub mod pit8254; pub mod net; +pub mod nfsudp; pub mod seeq8003; pub mod cow_disk; #[cfg(feature = "chd")] diff --git a/src/machine.rs b/src/machine.rs index a00fbad..a73be19 100644 --- a/src/machine.rs +++ b/src/machine.rs @@ -701,6 +701,13 @@ impl Machine { self.hpc3.ioc().scc().inject_b(bytes); } + /// Read (and consume) IRIX serial-console (tty1) output captured in-process + /// since the last call. Pairs with `inject_serial_console` to drive a + /// request/response probe over the console without a loopback TCP client. + pub fn read_serial_console(&self) -> Vec { + self.hpc3.ioc().scc().drain_console() + } + /// CPU thread, started explicitly by the CI `start` command or by /// `ci_restore`. In `--ci` mode the CPU is not autostarted in `start()` /// — the harness drives startup via `restore`. @@ -716,6 +723,71 @@ impl Machine { self.cpu.is_running() } + /// Number of attached CHD disks whose `.diff.chd` holds changes pending a + /// fold-back into the base on a clean shutdown (the "Synchronizing disks" + /// step). 0 means a clean exit needs no disk sync. + pub fn pending_chd_sync_count(&self) -> usize { + self.hpc3.scsi().pending_chd_sync_count() + } + + /// Fold every pending CHD diff back into its base, preserving compression. + /// Call only after the guest has stopped (so disk I/O is quiesced). + /// `progress(done, total, fraction)` drives a UI; `cancel()` aborts cleanly, + /// leaving un-synced bases+diffs intact. Returns the count synced. + pub fn sync_chd_disks( + &self, + progress: &mut dyn FnMut(usize, usize, f32), + cancel: &dyn Fn() -> bool, + ) -> std::io::Result { + self.hpc3.scsi().sync_chd_disks(progress, cancel) + } + + /// Cumulative count of guest-originated Ethernet frames the NAT engine has + /// processed. Monotonic for the life of the machine; an embedder samples the + /// delta to tell whether the guest's internal networking is alive. + pub fn net_guest_frames(&self) -> u64 { + self.hpc3.seeq().nat_control().guest_frames() + } + + /// NAT addresses the emulator hands the guest: (ec0 client IP, gateway IP, + /// netmask) — the source of truth for what the guest's ec0 should be. + pub fn nat_expected(&self) -> (std::net::Ipv4Addr, std::net::Ipv4Addr, std::net::Ipv4Addr) { + self.hpc3.seeq().gateway_addrs() + } + + /// The guest's own source IP as last seen on the wire (None if no frame has + /// revealed one yet). Captured passively, so it works even when the guest's + /// networking is misconfigured and nothing routes. + pub fn net_observed_guest_ip(&self) -> Option { + self.hpc3.seeq().nat_control().observed_guest_ip() + } + + /// The guest's likely default gateway, inferred passively from the in-subnet + /// address it keeps ARP-ing for but can't resolve. None if none seen. + pub fn net_observed_gateway(&self) -> Option { + self.hpc3.seeq().nat_control().observed_gateway() + } + + /// Move the running NAT onto a new subnet without a reboot: the NAT thread + /// swaps its `(gateway, client, netmask)` and flushes connection state on + /// its next loop. Typically gateway = network+1, client = network+2. + pub fn set_nat_subnet(&self, gateway: std::net::Ipv4Addr, client: std::net::Ipv4Addr, netmask: std::net::Ipv4Addr) { + self.hpc3.seeq().nat_control().request_subnet(gateway, client, netmask); + } + + /// Tell the NAT engine the host's own IPv4 networks `(network, prefix)` so it + /// won't adopt a guest subnet that overlaps them (which would shadow the + /// host's real LAN). The embedder gathers these from the host interfaces. + pub fn set_host_nets(&self, nets: Vec<(std::net::Ipv4Addr, u8)>) { + self.hpc3.seeq().nat_control().set_host_nets(nets); + } + + /// Replace the running NAT's inbound port-forward rules without a reboot; + /// the NAT thread rebinds its host listeners on its next loop. + pub fn set_port_forwards(&self, rules: Vec) { + self.hpc3.seeq().nat_control().set_port_forwards(rules); + } + /// Step the CPU `n` instructions in-line on the calling thread, with all /// peripheral threads stopped so the CPU sees no external interrupts. /// Used by Phase 3.3 snapshot determinism validator. diff --git a/src/main.rs b/src/main.rs index 7c41153..94217f4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,10 @@ -use iris::config::{load_config, NfsConfig}; +use iris::config::load_config; use iris::machine::Machine; fn main() { print_build_features(); - let (mut cfg, scale) = load_config(); + let (cfg, scale) = load_config(); let scroll_pixels_per_line = cfg.mouse_scroll_pixels_per_line; let lock_aspect_ratio = cfg.lock_aspect_ratio; let headless = cfg.headless; @@ -16,13 +16,8 @@ fn main() { // CI control socket will be started after Machine::new below (it needs a // pointer into the constructed Machine). - // Start unfsd before the machine so NFS is ready when IRIX boots. - // If start_unfsd returns None (directory missing/uncreatable, or binary not found), - // clear cfg.nfs so the network layer doesn't try to route to a non-running server. - let nfs_proc = cfg.nfs.as_ref().and_then(|nfs| start_unfsd(nfs)); - if cfg.nfs.is_some() && nfs_proc.is_none() { - cfg.nfs = None; - } + // NFS is now served in-process by the NAT (src/nfsudp.rs) — no external + // unfsd to spawn. The directory is created on demand by the server. // Machine::new() allocates >1MB on the stack (Physical device_map), which overflows // the default stack on Windows (1MB). We spawn a thread with a larger stack to create it. @@ -110,11 +105,6 @@ fn main() { } machine.stop(); - - // Kill unfsd on exit. - if let Some(proc) = nfs_proc { - proc.kill(); - } } /// Print which compile-time feature flags this binary was built with. Handy @@ -141,105 +131,3 @@ fn print_build_features() { ); } -struct UnfsdProc { - /// On Windows the Child holds the real process handle; kill() works directly. - /// On Unix unfsd daemonizes, so Child is the short-lived launcher. We record - /// the daemon PID from the pidfile and kill that instead. - #[cfg(windows)] - child: std::process::Child, - #[cfg(not(windows))] - pid_path: std::path::PathBuf, -} - -impl UnfsdProc { - fn kill(self) { - #[cfg(windows)] - { - let mut child = self.child; - let _ = child.kill(); - let _ = child.wait(); - } - #[cfg(not(windows))] - { - // Read the PID written by unfsd -i, then SIGTERM it. - // Give the daemon a moment to write the file if it hasn't yet. - for _ in 0..20 { - if self.pid_path.exists() { break; } - std::thread::sleep(std::time::Duration::from_millis(50)); - } - if let Ok(s) = std::fs::read_to_string(&self.pid_path) { - if let Ok(pid) = s.trim().parse::() { - unsafe { libc::kill(pid, libc::SIGTERM); } - } - } - let _ = std::fs::remove_file(&self.pid_path); - } - } -} - -fn start_unfsd(nfs: &NfsConfig) -> Option { - use std::io::Write as _; - - // NFS requires an absolute path in the exports file. - // Try to create the directory if it doesn't exist yet. - let shared_path = std::path::Path::new(&nfs.shared_dir); - if !shared_path.exists() { - if let Err(e) = std::fs::create_dir_all(shared_path) { - eprintln!("iris: warning: NFS shared_dir '{}' does not exist and could not be created: {} (NFS sharing disabled)", - nfs.shared_dir, e); - return None; - } - eprintln!("iris: created NFS shared_dir '{}'", nfs.shared_dir); - } - let abs_dir = match std::fs::canonicalize(shared_path) { - Ok(p) => p, - Err(e) => { - eprintln!("iris: warning: NFS shared_dir '{}': {} (NFS sharing disabled)", nfs.shared_dir, e); - return None; - } - }; - let exports_path = std::env::temp_dir().join("iris_nfs.exports"); - { - let mut f = std::fs::File::create(&exports_path) - .expect("failed to create NFS exports file"); - // Export only to 127.0.0.1 — all VM traffic arrives via NAT from localhost. - // insecure: NAT uses unprivileged source ports (>1024). - writeln!(f, "{} 127.0.0.1(rw,insecure)", - abs_dir.display()).expect("failed to write exports file"); - } - - let pid_path = std::env::temp_dir().join("iris_nfs.pid"); - - let child = match std::process::Command::new(&nfs.unfsd) - .arg("-u") // don't require root - .arg("-p") // don't register with host portmap - .arg("-3") // truncate fileid/cookie to 32 bits (IRIX compat) - .arg("-n").arg(nfs.nfs_host_port.to_string()) - .arg("-m").arg(nfs.mountd_host_port.to_string()) - .arg("-l").arg("127.0.0.1") - .arg("-e").arg(&exports_path) - .arg("-i").arg(&pid_path) - .spawn() - { - Ok(child) => child, - Err(e) => { - eprintln!("iris: warning: failed to start unfsd '{}': {} (NFS sharing disabled)", nfs.unfsd, e); - return None; - } - }; - - eprintln!("iris: unfsd started (pid {}) nfs=127.0.0.1:{} mountd=127.0.0.1:{} dir={}", - child.id(), nfs.nfs_host_port, nfs.mountd_host_port, abs_dir.display()); - eprintln!("iris: to mount inside IRIX (rsize/wsize must be <=8192 due to UDP fragment limit):"); - eprintln!("iris: mount -o rsize=8192,wsize=8192 192.168.0.1:{} /shared", abs_dir.display()); - - // On Unix, wait for the launcher to exit (it forks the daemon and quits). - #[cfg(not(windows))] - { let mut c = child; let _ = c.wait(); } - - #[cfg(windows)] - return Some(UnfsdProc { child }); - - #[cfg(not(windows))] - return Some(UnfsdProc { pid_path }); -} diff --git a/src/net.rs b/src/net.rs index 389ff77..1bf1fec 100644 --- a/src/net.rs +++ b/src/net.rs @@ -7,7 +7,7 @@ use std::collections::{HashMap, VecDeque}; use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener, TcpStream, UdpSocket}; use socket2::{Domain, Protocol, Socket, Type}; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::Arc; use crate::config::{ForwardBind, ForwardProto, NatSubnet, NfsConfig, PortForwardConfig}; use crate::devlog::LogModule; @@ -464,6 +464,42 @@ pub struct NatControl { /// Set to true to flush all NAT tables on the next NatEngine loop iteration. /// The NAT thread clears the flag after flushing. pub reset_nat: AtomicBool, + /// Count of guest-originated IP frames the NAT engine has processed (ARP and + /// other link-layer chatter are excluded — they happen even with no/wrong + /// IP). Monotonic for the life of the machine; the GUI samples it to light a + /// grey/red/green "internal network" indicator. + pub guest_frames: AtomicU64, + /// Set once the guest sends an IP frame *to the gateway's MAC* — i.e. it is + /// actually routing off-subnet through NAT. Gates plug-and-play adoption: + /// adoption locks only once the guest is really using the gateway, so local + /// or broadcast IP chatter (e.g. a ping to x.x.x.255) no longer disarms it. + pub routed: AtomicBool, + /// The guest's own source IPv4 address as last seen on the wire (ARP sender + /// address, or IP source) — `0` = none seen yet. Captured even when the + /// guest's config is wrong and nothing routes (it still ARPs for its + /// gateway), so the GUI can show what address the guest actually has and + /// compare it to what NAT expects. + pub observed_guest_ip: AtomicU32, + /// The address the guest is ARP-ing for within its own subnet but failing + /// to resolve (NAT doesn't own it) — almost always its configured default + /// gateway. `0` = none seen. Lets the GUI tell what gateway the guest + /// expects (and whether the host-side fix will satisfy it). + pub observed_gateway: AtomicU32, + /// Pending live subnet change (gateway / client / netmask as u32), latched + /// by `apply_subnet`. Lets an embedder move the running NAT onto a new + /// subnet without a reboot; applied on the NAT thread's next loop. + pub apply_gateway: AtomicU32, + pub apply_client: AtomicU32, + pub apply_netmask: AtomicU32, + pub apply_subnet: AtomicBool, + /// The host's own IPv4 networks `(network, prefix)`, set by the embedder. + /// NAT refuses to plug-and-play *adopt* a subnet that overlaps one of these, + /// so the emulator never shadows the host's real LAN / VPN / Docker network. + pub host_nets: Mutex>, + /// New port-forward rule set to bind live, latched by `apply_forwards`. Lets + /// an embedder add/remove forwards without a reboot. + pub pending_forwards: Mutex>>, + pub apply_forwards: AtomicBool, } impl NatControl { @@ -474,11 +510,72 @@ impl NatControl { debug_icmp: AtomicBool::new(false), snapshot: Mutex::new(NatSnapshot::default()), reset_nat: AtomicBool::new(false), + guest_frames: AtomicU64::new(0), + routed: AtomicBool::new(false), + observed_guest_ip: AtomicU32::new(0), + observed_gateway: AtomicU32::new(0), + apply_gateway: AtomicU32::new(0), + apply_client: AtomicU32::new(0), + apply_netmask: AtomicU32::new(0), + apply_subnet: AtomicBool::new(false), + host_nets: Mutex::new(Vec::new()), + pending_forwards: Mutex::new(None), + apply_forwards: AtomicBool::new(false), }) } pub fn dbg_tcp(&self) -> bool { self.debug_tcp.load(Ordering::Relaxed) } pub fn dbg_udp(&self) -> bool { self.debug_udp.load(Ordering::Relaxed) } pub fn dbg_icmp(&self) -> bool { self.debug_icmp.load(Ordering::Relaxed) } + /// The guest's likely default gateway (in-subnet ARP target it can't + /// resolve), or None if none seen. + pub fn observed_gateway(&self) -> Option { + match self.observed_gateway.load(Ordering::Relaxed) { + 0 => None, + v => Some(Ipv4Addr::from(v)), + } + } + /// The guest's last-seen source IP, or None if no frame has revealed one. + pub fn observed_guest_ip(&self) -> Option { + match self.observed_guest_ip.load(Ordering::Relaxed) { + 0 => None, + v => Some(Ipv4Addr::from(v)), + } + } + /// Number of guest frames the NAT engine has seen so far. + pub fn guest_frames(&self) -> u64 { self.guest_frames.load(Ordering::Relaxed) } + + /// Ask the running NAT to move to a new subnet (applied on the NAT thread's + /// next loop: config swapped + connection tables flushed). No reboot needed. + pub fn request_subnet(&self, gateway: Ipv4Addr, client: Ipv4Addr, netmask: Ipv4Addr) { + self.apply_gateway.store(u32::from(gateway), Ordering::Relaxed); + self.apply_client.store(u32::from(client), Ordering::Relaxed); + self.apply_netmask.store(u32::from(netmask), Ordering::Relaxed); + self.apply_subnet.store(true, Ordering::Release); + } + + /// Record the host's own IPv4 networks (network address + prefix) so NAT + /// won't adopt a subnet that overlaps them. + pub fn set_host_nets(&self, nets: Vec<(Ipv4Addr, u8)>) { + *self.host_nets.lock() = nets.into_iter().map(|(n, p)| (u32::from(n), p)).collect(); + } + + /// Replace the running NAT's port-forward rules (rebound on the NAT thread's + /// next loop). No reboot needed. + pub fn set_port_forwards(&self, rules: Vec) { + *self.pending_forwards.lock() = Some(rules); + self.apply_forwards.store(true, Ordering::Release); + } + + /// Whether `net/prefix` overlaps any recorded host network — i.e. adopting + /// or moving onto it would shadow a network the host already uses. + pub fn host_conflict(&self, net: Ipv4Addr, prefix: u8) -> bool { + let a = u32::from(net); + self.host_nets.lock().iter().any(|&(b, hp)| { + let p = prefix.min(hp); + let mask = if p == 0 { 0 } else { u32::MAX << (32 - p) }; + (a & mask) == (b & mask) + }) + } } #[derive(Default)] @@ -544,6 +641,68 @@ struct TcpFwdPending { client_isn: u32, // ISN we put in the synthetic SYN to the guest } +/// FTP application-layer gateway (passive mode). If `payload` — server→client +/// bytes on an FTP control connection — contains a `227 Entering Passive Mode +/// (h1,h2,h3,h4,p1,p2)` reply, rewrite the address tuple to advertise +/// `host`:`host_port` and return `(rewritten_payload, guest_data_port)`. Returns +/// `None` when there's no PASV reply to rewrite. +/// +/// The length-changing rewrite is safe here because the NAT relays the byte +/// stream between two independent TCP connections (a host OS socket and the +/// userspace guest-side TCP), so the host stack re-sequences on its own — no +/// seq/ack surgery. The non-FTP / no-match path is left untouched. +fn ftp_pasv_rewrite(payload: &[u8], host: Ipv4Addr, host_port: u16) -> Option<(Vec, u16)> { + // The "227" reply code at the start of a line (start of buffer or after \n). + let pos = (0..payload.len().saturating_sub(2)) + .find(|&i| &payload[i..i + 3] == b"227" && (i == 0 || payload[i - 1] == b'\n'))?; + let open = pos + payload[pos..].iter().position(|&b| b == b'(')?; + let close = open + payload[open..].iter().position(|&b| b == b')')?; + // Exactly six 0..=255 numbers between the parentheses. + let nums = payload[open + 1..close] + .split(|&b| b == b',') + .map(|s| std::str::from_utf8(s).ok().and_then(|t| t.trim().parse::().ok())) + .collect::>>()?; + if nums.len() != 6 || nums.iter().any(|&n| n > 255) { + return None; + } + let data_port = (nums[4] << 8) | nums[5]; + let o = host.octets(); + let replacement = format!("({},{},{},{},{},{})", + o[0], o[1], o[2], o[3], host_port >> 8, host_port & 0xff); + let mut out = Vec::with_capacity(payload.len() + replacement.len()); + out.extend_from_slice(&payload[..open]); + out.extend_from_slice(replacement.as_bytes()); + out.extend_from_slice(&payload[close + 1..]); + Some((out, data_port)) +} + +#[cfg(test)] +mod ftp_alg_tests { + use super::*; + + #[test] + fn rewrites_pasv_reply() { + let p = b"227 Entering Passive Mode (192,168,0,2,200,21).\r\n"; + let (out, port) = ftp_pasv_rewrite(p, Ipv4Addr::new(127, 0, 0, 1), 50000).unwrap(); + assert_eq!(port, 200 * 256 + 21); + let s = std::str::from_utf8(&out).unwrap(); + assert!(s.contains("(127,0,0,1,195,80)"), "got: {s}"); // 50000 = 195*256+80 + assert!(s.starts_with("227 Entering Passive Mode")); + assert!(s.ends_with(".\r\n")); + } + + #[test] + fn ignores_non_pasv() { + assert!(ftp_pasv_rewrite(b"USER anonymous\r\n", Ipv4Addr::LOCALHOST, 1).is_none()); + assert!(ftp_pasv_rewrite(b"200 PORT command successful.\r\n", Ipv4Addr::LOCALHOST, 1).is_none()); + // "227" not at a line start must not match. + assert!(ftp_pasv_rewrite(b"x227 (1,2,3,4,5,6)\r\n", Ipv4Addr::LOCALHOST, 1).is_none()); + // Malformed tuple (not six bytes) is rejected. + assert!(ftp_pasv_rewrite(b"227 (1,2,3,4,5)\r\n", Ipv4Addr::LOCALHOST, 1).is_none()); + assert!(ftp_pasv_rewrite(b"227 (1,2,3,4,5,999)\r\n", Ipv4Addr::LOCALHOST, 1).is_none()); + } +} + // ── NAT engine ──────────────────────────────────────────────────────────────── pub struct NatEngine { config: GatewayConfig, @@ -569,10 +728,88 @@ pub struct NatEngine { tcp_fwd_pending: HashMap<(u32, u16, u16), TcpFwdPending>, // Monotonically increasing counter for generating ephemeral ports for inbound forwards. fwd_ephemeral_next: u16, + // Number of configured (static) forwards at the front of `tcp_fwd_listeners`; + // anything past this index is a transient FTP-ALG data forward (bounded, FIFO). + fwd_static_count: usize, // Guest MAC learned from any outbound frame (ARP SHA or Ethernet src). guest_mac: Option<[u8; 6]>, // Monotonically increasing IP identification counter for fragmented datagrams. ip_id: u16, + // In-core NFS/UDP server (replaces external unfsd). Some when an NFS export + // is configured; the NAT dispatches guest MOUNT/NFS RPC straight to it. + nfs: Option, + // Inbound IP-fragment reassembly buffers, keyed by (src_ip, ip_id, proto). + // NFS writes arrive fragmented when wsize > MTU. + frag_reasm: HashMap<(u32, u16, u8), FragReasm>, +} + +/// Reassembly state for one fragmented inbound IP datagram. +struct FragReasm { + frags: std::collections::BTreeMap>, // offset -> bytes (deduped) + total: Option, // known once the last fragment arrives + last: Instant, +} +impl FragReasm { + fn new() -> Self { + Self { frags: std::collections::BTreeMap::new(), total: None, last: Instant::now() } + } + /// Add a fragment; return the reassembled payload once it's contiguous + + /// complete. + fn add(&mut self, offset: usize, data: &[u8], more: bool) -> Option> { + self.frags.insert(offset, data.to_vec()); + self.last = Instant::now(); + if !more { + self.total = Some(offset + data.len()); + } + let total = self.total?; + let mut out = Vec::with_capacity(total); + for (&off, d) in &self.frags { + if off != out.len() { + return None; // gap — still waiting on a fragment + } + out.extend_from_slice(d); + } + (out.len() == total).then_some(out) + } +} + +/// Bind host listeners for a set of port-forward rules. Used at construction and +/// when forwards are reconfigured live (see `rebind_forwards`). +fn bind_forwards(rules: &[PortForwardConfig]) -> (Vec, Vec) { + let mut tcp = Vec::new(); + let mut udp = Vec::new(); + for rule in rules { + let bind_addr = match rule.bind { + ForwardBind::Localhost => Ipv4Addr::LOCALHOST, + ForwardBind::Any => Ipv4Addr::UNSPECIFIED, + }; + let addr = SocketAddr::new(IpAddr::V4(bind_addr), rule.host_port); + match rule.proto { + ForwardProto::Tcp => match TcpListener::bind(addr) { + Ok(listener) => { + let _ = listener.set_nonblocking(true); + eprintln!("iris: TCP port forward {}:{} → guest:{}", + bind_addr, rule.host_port, rule.guest_port); + tcp.push(TcpFwdListener { listener, guest_port: rule.guest_port }); + } + Err(e) => eprintln!("iris: TCP port forward {}:{} failed to bind: {}", + bind_addr, rule.host_port, e), + }, + ForwardProto::Udp => match UdpSocket::bind(addr) { + Ok(sock) => { + let _ = sock.set_nonblocking(true); + eprintln!("iris: UDP port forward {}:{} → guest:{}", + bind_addr, rule.host_port, rule.guest_port); + udp.push(UdpFwdListener { + sock, guest_port: rule.guest_port, host_port: rule.host_port, last_sender: None, + }); + } + Err(e) => eprintln!("iris: UDP port forward {}:{} failed to bind: {}", + bind_addr, rule.host_port, e), + }, + } + } + (tcp, udp) } impl NatEngine { @@ -583,58 +820,33 @@ impl NatEngine { tx_wake: Arc<(Mutex<()>, Condvar)>, running: Arc, ctl: Arc) -> Self { - let mut tcp_fwd_listeners = Vec::new(); - let mut udp_fwd_listeners = Vec::new(); - - for rule in &config.port_forwards { - let bind_addr = match rule.bind { - ForwardBind::Localhost => Ipv4Addr::LOCALHOST, - ForwardBind::Any => Ipv4Addr::UNSPECIFIED, - }; - match rule.proto { - ForwardProto::Tcp => { - let addr = SocketAddr::new(IpAddr::V4(bind_addr), rule.host_port); - match TcpListener::bind(addr) { - Ok(listener) => { - let _ = listener.set_nonblocking(true); - eprintln!("iris: TCP port forward {}:{} → guest:{}", - bind_addr, rule.host_port, rule.guest_port); - tcp_fwd_listeners.push(TcpFwdListener { - listener, - guest_port: rule.guest_port, - }); - } - Err(e) => eprintln!("iris: TCP port forward {}:{} failed to bind: {}", - bind_addr, rule.host_port, e), - } - } - ForwardProto::Udp => { - let addr = SocketAddr::new(IpAddr::V4(bind_addr), rule.host_port); - match UdpSocket::bind(addr) { - Ok(sock) => { - let _ = sock.set_nonblocking(true); - eprintln!("iris: UDP port forward {}:{} → guest:{}", - bind_addr, rule.host_port, rule.guest_port); - udp_fwd_listeners.push(UdpFwdListener { - sock, - guest_port: rule.guest_port, - host_port: rule.host_port, - last_sender: None, - }); - } - Err(e) => eprintln!("iris: UDP port forward {}:{} failed to bind: {}", - bind_addr, rule.host_port, e), - } - } - } - } - + let (tcp_fwd_listeners, udp_fwd_listeners) = bind_forwards(&config.port_forwards); + let fwd_static_count = tcp_fwd_listeners.len(); + // Spin up the in-core NFS server if an export is configured. + let nfs = config.nfs.as_ref().map(|c| { + eprintln!("iris: in-core NFS server exporting {}", c.shared_dir); + crate::nfsudp::NfsServer::new(c.shared_dir.clone(), c.version) + }); Self { config, tx_cons, rx_prod, rx_wake, tx_wake, running, ctl, udp_nat: HashMap::new(), tcp_nat: HashMap::new(), tcp_tw: HashMap::new(), icmp_nat: HashMap::new(), icmp_unavailable: false, deferred_rx: Vec::new(), - tcp_fwd_listeners, udp_fwd_listeners, + tcp_fwd_listeners, udp_fwd_listeners, fwd_static_count, tcp_fwd_pending: HashMap::new(), fwd_ephemeral_next: 49152, - guest_mac: None, ip_id: 1 } + guest_mac: None, ip_id: 1, nfs, frag_reasm: HashMap::new() } + } + + /// Rebind the static port-forward listeners from a new rule set, live (no + /// reboot). The old static listeners are dropped (closing their host + /// sockets); transient FTP-ALG data forwards and already-established + /// connections (in `tcp_nat`) are preserved. + fn rebind_forwards(&mut self, rules: &[PortForwardConfig]) { + let cut = self.fwd_static_count.min(self.tcp_fwd_listeners.len()); + let transient = self.tcp_fwd_listeners.split_off(cut); // FTP-ALG data forwards + let (mut tcp, udp) = bind_forwards(rules); + self.fwd_static_count = tcp.len(); + tcp.extend(transient); + self.tcp_fwd_listeners = tcp; // drops the old static listeners + self.udp_fwd_listeners = udp; } pub fn run(&mut self) { @@ -654,6 +866,34 @@ impl NatEngine { self.udp_nat.clear(); // drops all UdpSockets self.icmp_nat.clear(); // drops all ICMP raw sockets self.tcp_fwd_pending.clear(); + self.tcp_fwd_listeners.truncate(self.fwd_static_count); // drop transient FTP data forwards + self.ctl.routed.store(false, Ordering::Relaxed); // re-arm plug-and-play adoption + } + + // Live subnet change requested by an embedder: swap the gateway / + // client / netmask and flush connection state so nothing lingers on + // the old subnet. The guest then reaches the new gateway as soon as + // it ARPs for it (or routes to it) — no reboot. Adoption still + // applies if the guest is on a different subnet again. + if self.ctl.apply_subnet.swap(false, Ordering::AcqRel) { + self.config.gateway_ip = Ipv4Addr::from(self.ctl.apply_gateway.load(Ordering::Relaxed)); + self.config.client_ip = Ipv4Addr::from(self.ctl.apply_client.load(Ordering::Relaxed)); + self.config.netmask = Ipv4Addr::from(self.ctl.apply_netmask.load(Ordering::Relaxed)); + self.tcp_nat.clear(); + self.tcp_tw.clear(); + self.udp_nat.clear(); + self.icmp_nat.clear(); + self.tcp_fwd_pending.clear(); + self.tcp_fwd_listeners.truncate(self.fwd_static_count); // drop transient FTP data forwards + self.ctl.routed.store(false, Ordering::Relaxed); // re-arm adoption onto the new subnet + } + + // Live port-forward reconfigure: rebind the static listeners. + if self.ctl.apply_forwards.swap(false, Ordering::AcqRel) { + let rules = self.ctl.pending_forwards.lock().take(); // drop the lock before rebinding + if let Some(rules) = rules { + self.rebind_forwards(&rules); + } } // FIXME: investigate interrupt race between TX completion and RX delivery. @@ -741,6 +981,59 @@ impl NatEngine { self.guest_mac = Some(src_mac); } let etype = r16(frame, 12); + // Record the guest's own source address so the GUI can tell what IP it's + // using — even when that IP is wrong and nothing routes. ARP carries it + // (sender protocol address) even with zero IP traffic, since the guest + // ARPs for its configured gateway. + let src_ip = match etype { + ETHERTYPE_ARP if frame.len() >= 14 + 28 => + Some(Ipv4Addr::new(frame[28], frame[29], frame[30], frame[31])), + ETHERTYPE_IP if frame.len() >= 14 + 20 => + Some(Ipv4Addr::new(frame[26], frame[27], frame[28], frame[29])), + _ => None, + }; + if let Some(ip) = src_ip { + if !ip.is_unspecified() { + self.ctl.observed_guest_ip.store(u32::from(ip), Ordering::Relaxed); + } + } + // Candidate default gateway: a guest ARP *request* (op=1) for an + // in-subnet address it can't resolve (NAT doesn't own it) is almost + // always the gateway it's trying — and failing — to reach. Record it so + // the GUI can tell what gateway the guest expects. + if etype == ETHERTYPE_ARP && frame.len() >= 14 + 28 && r16(frame, 14 + 6) == 1 { + let spa = [frame[28], frame[29], frame[30], frame[31]]; + let tpa = Ipv4Addr::new(frame[38], frame[39], frame[40], frame[41]); + let t = tpa.octets(); + dlog_dev!(LogModule::Net, "NAT guest ARP who-has {} tell {}.{}.{}.{} (nat gw={} client={} guest_frames={})", + tpa, spa[0], spa[1], spa[2], spa[3], + self.config.gateway_ip, self.config.client_ip, + self.ctl.guest_frames.load(Ordering::Relaxed)); + if !tpa.is_unspecified() && t != spa && t[0] == spa[0] && t[1] == spa[1] && t[2] == spa[2] { + self.ctl.observed_gateway.store(u32::from(tpa), Ordering::Relaxed); + let adopt_net = Ipv4Addr::new(t[0], t[1], t[2], 0); + let conflicts = self.ctl.host_conflict(adopt_net, 24); + // Plug-and-play: while nothing is routing yet, adopt the gateway + // the guest is asking for so NAT answers that ARP and traffic + // flows — no config change or restart. Self-limiting: once any IP + // frame routes (guest_frames > 0) we stop, so a working setup is + // never moved. Refused when the guest's subnet overlaps a host + // network: adopting it would shadow the host's real LAN, so we + // leave the guest unrouted and the GUI asks the user to change the + // guest's ec0 to a non-overlapping subnet instead. + if self.config.gateway_ip != tpa + && !self.ctl.routed.load(Ordering::Relaxed) + && !conflicts + { + dlog_dev!(LogModule::Net, "NAT adopting gateway {} (was {})", tpa, self.config.gateway_ip); + self.config.gateway_ip = tpa; + self.config.client_ip = Ipv4Addr::new(t[0], t[1], t[2], 2); + self.config.netmask = Ipv4Addr::new(255, 255, 255, 0); + } else if conflicts { + dlog_dev!(LogModule::Net, "NAT refusing to adopt {}: subnet {}/24 overlaps a host network", tpa, adopt_net); + } + } + } dlog_dev!(LogModule::Net, "NAT TX {}", eth_summary(frame)); if self.ctl.dbg_tcp() && etype == ETHERTYPE_IP { dlog_dev!(LogModule::Net, "NAT RX (IRIX→NAT):"); @@ -748,7 +1041,20 @@ impl NatEngine { } match etype { ETHERTYPE_ARP => self.handle_arp(frame, &src_mac), - ETHERTYPE_IP => self.handle_ip(frame, &src_mac), + ETHERTYPE_IP => { + // Count only IP traffic — the actual NAT workload — as the + // network-alive signal. ARP (and other link-layer chatter) + // happens even when the guest's IP is missing or wrong, so + // counting it would flash the indicator green misleadingly. + self.ctl.guest_frames.fetch_add(1, Ordering::Relaxed); + // A frame addressed to the gateway's MAC is off-subnet traffic + // the guest is routing through us — that, not local/broadcast IP + // chatter, is what locks adoption to the current gateway. + if frame[0..6] == self.config.gateway_mac { + self.ctl.routed.store(true, Ordering::Relaxed); + } + self.handle_ip(frame, &src_mac); + } _ => {} } } @@ -799,6 +1105,31 @@ impl NatEngine { // Clamp to actual frame size in case ip_total > frame bytes available. let ip_end = ip_total.min(frame.len() - 14); let payload = &ip[ihl..ip_end]; + + // Inbound IP-fragment reassembly: a guest NFS WRITE with a large wsize + // arrives fragmented. Buffer fragments keyed by (src, id, proto) and + // dispatch the whole datagram once it's contiguous. + let flags_frag = r16(ip, 6); + let more_frags = flags_frag & 0x2000 != 0; + let frag_off = ((flags_frag & 0x1fff) as usize) * 8; + if more_frags || frag_off != 0 { + let id = r16(ip, 4); + let key = (u32::from(src_ip), id, proto); + self.frag_reasm.retain(|_, v| v.last.elapsed() < Duration::from_secs(5)); + let assembled = self + .frag_reasm + .entry(key) + .or_insert_with(FragReasm::new) + .add(frag_off, payload, more_frags); + if let Some(full) = assembled { + self.frag_reasm.remove(&key); + if proto == IP_PROTO_UDP { + self.handle_udp(src_mac, src_ip, dst_ip, &full); + } + } + return; + } + match proto { IP_PROTO_ICMP => self.handle_icmp(src_mac, src_ip, dst_ip, ttl, payload), IP_PROTO_UDP => self.handle_udp(src_mac, src_ip, dst_ip, payload), @@ -982,6 +1313,8 @@ impl NatEngine { UDP_PORT_DNS => self.forward_dns(src_mac, src_ip, sport, payload), UDP_PORT_PORTMAP if self.config.nfs.is_some() => self.handle_portmap_udp(src_mac, src_ip, sport, payload), + NFS_VM_PORT | MOUNTD_VM_PORT if self.nfs.is_some() && dst_ip == self.config.gateway_ip + => self.handle_nfs_udp(src_mac, src_ip, sport, dport, payload), UDP_PORT_TIME if dst_ip == self.config.gateway_ip => self.handle_time_udp(src_mac, src_ip, sport), UDP_PORT_NTP if dst_ip == self.config.gateway_ip @@ -994,6 +1327,24 @@ impl NatEngine { } } + // ── in-core NFS ───────────────────────────────────────────────────────── + /// Dispatch a guest MOUNT/NFS RPC datagram to the in-core server and inject + /// the reply (auto-fragmented). `server_port` is the port the guest sent to + /// (NFS 2049 or mountd 1234), which becomes the reply's source port. + fn handle_nfs_udp(&mut self, client_mac: &[u8; 6], client_ip: Ipv4Addr, + client_port: u16, server_port: u16, payload: &[u8]) { + let reply = match self.nfs.as_mut() { + Some(server) => server.handle(payload), + None => return, + }; + let Some(reply) = reply else { return }; + let udp = udp_packet(self.config.gateway_ip, client_ip, server_port, client_port, &reply); + let id = self.ip_id; + self.ip_id = self.ip_id.wrapping_add(1); + self.deferred_rx.extend(ip_frames_udp( + client_mac, &self.config.gateway_mac, self.config.gateway_ip, client_ip, id, &udp)); + } + // ── BOOTP / DHCP ────────────────────────────────────────────────────────── fn handle_bootp(&mut self, client_mac: &[u8; 6], _client_port: u16, payload: &[u8]) { if payload.len() < 236 || payload[0] != BOOTP_OP_REQUEST { return; } @@ -1161,17 +1512,10 @@ impl NatEngine { // // IRIX sees the gateway at 192.168.0.1 but that's a virtual address iris // doesn't actually bind to, so unmodified TcpStream::connect() fails. We - // rewrite any gateway-destined packet to 127.0.0.1. NFS ports additionally - // shift to the high host ports where unfsd listens. + // rewrite any gateway-destined packet to 127.0.0.1. (NFS no longer goes + // through here — it's served in-process by the NAT before this point.) fn nfs_remap_dst(&self, dst_ip: Ipv4Addr, dport: u16) -> (Ipv4Addr, u16) { if dst_ip != self.config.gateway_ip { return (dst_ip, dport); } - if let Some(nfs) = &self.config.nfs { - match dport { - NFS_VM_PORT => return (Ipv4Addr::LOCALHOST, nfs.nfs_host_port), - MOUNTD_VM_PORT => return (Ipv4Addr::LOCALHOST, nfs.mountd_host_port), - _ => {} - } - } // Generic outbound: guest→gateway becomes guest→host loopback on // the same port. Lets the guest reach any service the host is // running on 127.0.0.1: (pyftpdlib on 2121, python -m @@ -1183,10 +1527,6 @@ impl NatEngine { // dialed, so replies look like they came from the gateway. fn nfs_unmap_src(&self, src_ip: Ipv4Addr, sport: u16) -> (Ipv4Addr, u16) { if src_ip != Ipv4Addr::LOCALHOST { return (src_ip, sport); } - if let Some(nfs) = &self.config.nfs { - if sport == nfs.nfs_host_port { return (self.config.gateway_ip, NFS_VM_PORT); } - if sport == nfs.mountd_host_port { return (self.config.gateway_ip, MOUNTD_VM_PORT); } - } // Generic outbound: reply from host-side dport becomes gateway:dport // to the guest. (self.config.gateway_ip, sport) @@ -1469,6 +1809,10 @@ impl NatEngine { self.tcp_nat.remove(&key); return; } + // FTP ALG: a rewritten PASV reply binds a host data listener here and + // defers registering its forward until the `entry` borrow ends below. + let mut new_data_fwd: Option<(TcpListener, u16)> = None; + let gateway_ip = self.config.gateway_ip; let entry = match self.tcp_nat.get_mut(&key) { Some(e) => e, None => { @@ -1521,7 +1865,34 @@ impl NatEngine { && seq != entry.client_seq; if !already_acked { use std::io::Write as _; - let _ = entry.stream.write_all(payload); + // FTP ALG: on an inbound port-forward to the guest's ftpd + // (server = gateway, guest control port 21), rewrite a passive + // 227 reply so the host client reaches the data connection via a + // freshly-bound host forward. client_seq still advances by the + // *original* length (that's what the guest sent and we ACK). + let is_ftp_ctrl = entry.server_ip == gateway_ip && entry.client_port == 21; + let mut handled = false; + if is_ftp_ctrl && ftp_pasv_rewrite(payload, Ipv4Addr::LOCALHOST, 0).is_some() { + if let Ok(listener) = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) { + if let Ok(addr) = listener.local_addr() { + let host_port = addr.port(); + let _ = listener.set_nonblocking(true); + if let Some((rewritten, data_port)) = + ftp_pasv_rewrite(payload, Ipv4Addr::LOCALHOST, host_port) + { + dlog_dev!(LogModule::Net, + "NAT FTP ALG: PASV guest data port {} -> host 127.0.0.1:{}", + data_port, host_port); + let _ = entry.stream.write_all(&rewritten); + new_data_fwd = Some((listener, data_port)); + handled = true; + } + } + } + } + if !handled { + let _ = entry.stream.write_all(payload); + } entry.client_seq = seq.wrapping_add(payload.len() as u32); } let sip = entry.server_ip; @@ -1556,6 +1927,19 @@ impl NatEngine { let _ = entry.stream.shutdown(Shutdown::Write); entry.fin_wait = true; } + + // Register the FTP-ALG data forward now that the `entry` borrow is gone. + // Transient and FIFO-bounded so a long session's PASV transfers don't + // leak host listeners; the oldest is dropped once the cap is hit. + if let Some((listener, data_port)) = new_data_fwd { + const MAX_FTP_DATA_FWD: usize = 16; + while self.tcp_fwd_listeners.len() >= self.fwd_static_count + MAX_FTP_DATA_FWD + && self.tcp_fwd_listeners.len() > self.fwd_static_count + { + self.tcp_fwd_listeners.remove(self.fwd_static_count); // drop oldest dynamic + } + self.tcp_fwd_listeners.push(TcpFwdListener { listener, guest_port: data_port }); + } } fn poll_tcp(&mut self) { @@ -1678,7 +2062,9 @@ impl NatEngine { if self.fwd_ephemeral_next < 49152 { self.fwd_ephemeral_next = 49152; } let client_isn = 0x6000_0000u32.wrapping_add(ephemeral as u32); - let guest_ip = self.config.client_ip; + // Forward to the guest's *actual* IP (learned from its traffic), not + // the assumed NAT client address — a static guest can be on any host. + let guest_ip = self.ctl.observed_guest_ip().unwrap_or(self.config.client_ip); let gw_ip = self.config.gateway_ip; let gw_mac = self.config.gateway_mac; @@ -1742,7 +2128,7 @@ impl NatEngine { { fwd.last_sender = Some(from); } - let guest_ip = self.config.client_ip; + let guest_ip = self.ctl.observed_guest_ip().unwrap_or(self.config.client_ip); let gw_ip = self.config.gateway_ip; let gw_mac = self.config.gateway_mac; // Inject as UDP: src=gateway_ip:host_port dst=guest_ip:guest_port diff --git a/src/nfsudp.rs b/src/nfsudp.rs new file mode 100644 index 0000000..44fe54c --- /dev/null +++ b/src/nfsudp.rs @@ -0,0 +1,1943 @@ +//! In-core NFS server (NFSv2 + NFSv3) over UDP, dispatched by the NAT engine. +//! +//! The whole protocol stays inside the NAT: `src/net.rs` hands guest MOUNT/NFS +//! RPC datagrams to this module and injects the reply bytes back as +//! virtual-network frames — there are **no host network sockets**. The only host +//! interaction is file I/O against the user-chosen backing folder. +//! +//! Plan + open questions: `docs/nfsudp-plan.md`. Wire structs/semantics are +//! modelled on `nfsserve` (BSD-3-Clause), re-implemented synchronously here. +//! +//! This file is built bottom-up. **Increment 1 (this commit): the +//! version-agnostic backend** — the `fileid`↔path map, path containment, the +//! faked/synthetic unix attributes, and the filesystem operations. The XDR/RPC +//! layer, the v2/v3 procedure encoders, MOUNT, the NAT wiring, and the GUI land +//! on top of this. + +#![allow(dead_code)] // wire layer that consumes the backend lands in later increments + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// The reserved NFS `fileid` of the export root. +pub const ROOT_ID: u64 = 1; + +/// File kind, mapped to NFS `ftype3` (and the v2 equivalent) by the wire layer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileKind { + Reg, + Dir, + Lnk, + Other, +} + +/// Synthetic POSIX attributes for one object. "Faked" deliberately: uid/gid are +/// fixed and the mode is a heuristic, so the export behaves identically on +/// Linux, macOS, and Windows (which has no unix uid/gid/mode at all). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Attr { + pub kind: FileKind, + /// Full mode incl. type bits (e.g. `0o040755` for a dir) — convenience for + /// the wire layer; the type bits come from `kind`. + pub mode: u32, + pub nlink: u32, + pub uid: u32, + pub gid: u32, + pub size: u64, + pub fileid: u64, + /// (seconds, nanoseconds) since the unix epoch. + pub atime: (u32, u32), + pub mtime: (u32, u32), + pub ctime: (u32, u32), +} + +/// POSIX `S_IF*` type bits, for the `mode` field. +const S_IFDIR: u32 = 0o040000; +const S_IFREG: u32 = 0o100000; +const S_IFLNK: u32 = 0o120000; + +/// The backing store: one exported directory, plus a stable `fileid`↔relative +/// path map so the guest's opaque file handles resolve back to host paths. Used +/// only from the NAT thread, so plain `&mut self` (no locking). +pub struct NfsBacking { + /// Absolute path of the exported folder. All access is contained within it. + root: PathBuf, + next_id: u64, + /// fileid → path relative to `root` (root itself = ROOT_ID = empty path). + id_to_path: HashMap, + /// reverse map so re-looking-up a path returns its existing id. + path_to_id: HashMap, + /// Default owner for faked attributes. + uid: u32, + gid: u32, +} + +impl NfsBacking { + /// Create a backing store over `root`. `root` should be an existing, + /// absolute directory; access is confined to it. + pub fn new(root: impl Into) -> Self { + let mut id_to_path = HashMap::new(); + let mut path_to_id = HashMap::new(); + id_to_path.insert(ROOT_ID, PathBuf::new()); + path_to_id.insert(PathBuf::new(), ROOT_ID); + Self { + root: root.into(), + next_id: ROOT_ID + 1, + id_to_path, + path_to_id, + uid: 0, + gid: 0, + } + } + + /// Intern a root-relative path, returning a stable fileid for it. + fn intern(&mut self, rel: PathBuf) -> u64 { + if let Some(&id) = self.path_to_id.get(&rel) { + return id; + } + let id = self.next_id; + self.next_id += 1; + self.id_to_path.insert(id, rel.clone()); + self.path_to_id.insert(rel, id); + id + } + + /// The root-relative path for a fileid, if known. + fn rel_of(&self, id: u64) -> Option<&PathBuf> { + self.id_to_path.get(&id) + } + + /// The absolute host path for a fileid. Guaranteed within `root` because the + /// relative paths only ever contain validated, normal components. + pub fn abs_of(&self, id: u64) -> Option { + self.rel_of(id).map(|rel| self.root.join(rel)) + } + + /// Whether `id` is a directory the guest can list. + pub fn is_dir(&self, id: u64) -> bool { + self.abs_of(id).map(|p| p.is_dir()).unwrap_or(false) + } + + /// Synthetic attributes for `id`, or `None` if it no longer exists. + pub fn attr(&self, id: u64) -> Option { + let abs = self.abs_of(id)?; + let md = std::fs::symlink_metadata(&abs).ok()?; + Some(self.attr_from(id, &md)) + } + + /// Resolve `name` within directory `dirid`, interning the child and + /// returning its fileid. Rejects names that could escape the export. + pub fn lookup(&mut self, dirid: u64, name: &[u8]) -> Option { + let comp = valid_component(name)?; + let rel = self.rel_of(dirid)?.join(&comp); + let abs = self.root.join(&rel); + if !abs.symlink_metadata().is_ok() { + return None; + } + Some(self.intern(rel)) + } + + /// List `dirid`, returning `(name, fileid, attr)` for each entry. `.` / `..` + /// are not included (the wire layer synthesizes them if a client needs them). + pub fn readdir(&mut self, dirid: u64) -> Option, u64, Attr)>> { + let dir_rel = self.rel_of(dirid)?.clone(); + let abs = self.root.join(&dir_rel); + let mut out = Vec::new(); + for ent in std::fs::read_dir(&abs).ok()? { + let ent = ent.ok()?; + let name = name_bytes(&ent.file_name()); + // Skip anything that wouldn't round-trip as a safe component. + if valid_component(&name).is_none() { + continue; + } + let rel = dir_rel.join(ent.file_name()); + let id = self.intern(rel); + if let Some(attr) = self.attr(id) { + out.push((name, id, attr)); + } + } + Some(out) + } + + /// Read up to `count` bytes at `offset` from file `id`. Returns the data and + /// whether end-of-file was reached. + pub fn read(&self, id: u64, offset: u64, count: u32) -> Option<(Vec, bool)> { + use std::io::{Read, Seek, SeekFrom}; + let abs = self.abs_of(id)?; + let mut f = std::fs::File::open(&abs).ok()?; + let len = f.metadata().ok()?.len(); + f.seek(SeekFrom::Start(offset)).ok()?; + let want = count as usize; + let mut buf = vec![0u8; want]; + let mut got = 0; + while got < want { + match f.read(&mut buf[got..]) { + Ok(0) => break, + Ok(n) => got += n, + Err(_) => break, + } + } + buf.truncate(got); + let eof = offset.saturating_add(got as u64) >= len; + Some((buf, eof)) + } + + /// Write `data` at `offset` to file `id`, returning the post-write attrs. + pub fn write(&mut self, id: u64, offset: u64, data: &[u8]) -> Option { + use std::io::{Seek, SeekFrom, Write}; + let abs = self.abs_of(id)?; + let mut f = std::fs::OpenOptions::new().write(true).open(&abs).ok()?; + f.seek(SeekFrom::Start(offset)).ok()?; + f.write_all(data).ok()?; + f.flush().ok()?; + self.attr(id) + } + + /// Truncate (or extend) file `id` to `size` bytes. Used by SETATTR. + pub fn truncate(&mut self, id: u64, size: u64) -> bool { + let Some(abs) = self.abs_of(id) else { return false }; + std::fs::OpenOptions::new() + .write(true) + .open(&abs) + .and_then(|f| f.set_len(size)) + .is_ok() + } + + /// Create an empty regular file `name` in `dirid`; returns its fileid. + pub fn create(&mut self, dirid: u64, name: &[u8]) -> Option { + let comp = valid_component(name)?; + let rel = self.rel_of(dirid)?.join(&comp); + let abs = self.root.join(&rel); + std::fs::OpenOptions::new().write(true).create(true).truncate(true).open(&abs).ok()?; + Some(self.intern(rel)) + } + + /// Create directory `name` in `dirid`; returns its fileid. + pub fn mkdir(&mut self, dirid: u64, name: &[u8]) -> Option { + let comp = valid_component(name)?; + let rel = self.rel_of(dirid)?.join(&comp); + std::fs::create_dir(self.root.join(&rel)).ok()?; + Some(self.intern(rel)) + } + + /// Remove file `name` from `dirid`. + pub fn remove(&mut self, dirid: u64, name: &[u8]) -> bool { + self.remove_with(dirid, name, false) + } + + /// Remove directory `name` from `dirid`. + pub fn rmdir(&mut self, dirid: u64, name: &[u8]) -> bool { + self.remove_with(dirid, name, true) + } + + fn remove_with(&mut self, dirid: u64, name: &[u8], dir: bool) -> bool { + let Some(comp) = valid_component(name) else { return false }; + let Some(parent) = self.rel_of(dirid) else { return false }; + let rel = parent.join(&comp); + let abs = self.root.join(&rel); + let ok = if dir { std::fs::remove_dir(&abs) } else { std::fs::remove_file(&abs) }.is_ok(); + if ok { + if let Some(id) = self.path_to_id.remove(&rel) { + self.id_to_path.remove(&id); + } + } + ok + } + + /// Rename `from_name` in `from_dir` to `to_name` in `to_dir`. + pub fn rename(&mut self, from_dir: u64, from_name: &[u8], to_dir: u64, to_name: &[u8]) -> bool { + let (Some(fc), Some(tc)) = (valid_component(from_name), valid_component(to_name)) else { + return false; + }; + let (Some(fp), Some(tp)) = (self.rel_of(from_dir).cloned(), self.rel_of(to_dir).cloned()) else { + return false; + }; + let from_rel = fp.join(&fc); + let to_rel = tp.join(&tc); + if std::fs::rename(self.root.join(&from_rel), self.root.join(&to_rel)).is_err() { + return false; + } + // Re-point the moved id at its new path so its handle stays valid. + if let Some(id) = self.path_to_id.remove(&from_rel) { + self.id_to_path.insert(id, to_rel.clone()); + self.path_to_id.insert(to_rel, id); + } + true + } + + // ── synthetic attribute construction ──────────────────────────────────── + + fn attr_from(&self, id: u64, md: &std::fs::Metadata) -> Attr { + let ft = md.file_type(); + let (kind, type_bits, base) = if ft.is_dir() { + (FileKind::Dir, S_IFDIR, 0o755) + } else if ft.is_symlink() { + (FileKind::Lnk, S_IFLNK, 0o777) + } else if ft.is_file() { + (FileKind::Reg, S_IFREG, file_perm(md)) + } else { + (FileKind::Other, S_IFREG, 0o644) + }; + Attr { + kind, + mode: type_bits | base, + nlink: if kind == FileKind::Dir { 2 } else { 1 }, + uid: self.uid, + gid: self.gid, + size: md.len(), + fileid: id, + atime: systime(md.accessed().ok()), + mtime: systime(md.modified().ok()), + ctime: systime(md.modified().ok()), // no portable ctime; reuse mtime + } + } +} + +/// Permission bits for a regular file: 0644, plus the execute bits if the host +/// marks it executable (unix only; on other platforms files are 0644). +fn file_perm(md: &std::fs::Metadata) -> u32 { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if md.permissions().mode() & 0o111 != 0 { + return 0o755; + } + } + let _ = md; + 0o644 +} + +/// Convert a `SystemTime` into `(secs, nsecs)` since the unix epoch (0 if before +/// the epoch or unavailable). +fn systime(t: Option) -> (u32, u32) { + t.and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| (d.as_secs() as u32, d.subsec_nanos())) + .unwrap_or((0, 0)) +} + +/// Validate a single path component coming off the wire. Rejects anything that +/// could escape the export (`..`, `.`, empty, embedded separators or NUL). +/// Returns the component as an `OsString`-bearing `PathBuf` fragment. +fn valid_component(name: &[u8]) -> Option { + if name.is_empty() || name == b"." || name == b".." { + return None; + } + if name.iter().any(|&b| b == b'/' || b == b'\\' || b == 0) { + return None; + } + let s = os_string_from_bytes(name)?; + let pb = PathBuf::from(&s); + // Defense in depth: the parsed fragment must be exactly one normal component. + let mut comps = pb.components(); + match (comps.next(), comps.next()) { + (Some(std::path::Component::Normal(_)), None) => Some(pb), + _ => None, + } +} + +/// Bytes → OS string. On unix, filenames are arbitrary bytes; on other targets +/// they must be valid UTF-8 (a documented cross-platform limitation). +fn os_string_from_bytes(b: &[u8]) -> Option { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + return Some(std::ffi::OsStr::from_bytes(b).to_os_string()); + } + #[cfg(not(unix))] + { + std::str::from_utf8(b).ok().map(std::ffi::OsString::from) + } +} + +/// An OS filename → bytes (inverse of `os_string_from_bytes`). +fn name_bytes(name: &std::ffi::OsStr) -> Vec { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + return name.as_bytes().to_vec(); + } + #[cfg(not(unix))] + { + name.to_string_lossy().into_owned().into_bytes() + } +} + +/// Path containment check used by tests and as a sanity assert. +pub fn within(root: &Path, candidate: &Path) -> bool { + candidate.starts_with(root) +} + +// ── XDR (RFC 1014) + Sun RPC (RFC 1057) wire layer ────────────────────────── +// +// Increment 2: the transport-agnostic encode/decode used by both NFSv2 and v3. +// One UDP datagram carries exactly one RPC message (no TCP record marking). + +const MSG_CALL: u32 = 0; +const MSG_REPLY: u32 = 1; +const RPC_VERSION: u32 = 2; +const REPLY_ACCEPTED: u32 = 0; +const AUTH_NULL: u32 = 0; + +/// RPC accept-status values (RFC 1057). We allow every host, so auth never +/// fails; these cover protocol-level outcomes only. +pub mod accept { + pub const SUCCESS: u32 = 0; + pub const PROG_UNAVAIL: u32 = 1; + pub const PROG_MISMATCH: u32 = 2; + pub const PROC_UNAVAIL: u32 = 3; + pub const GARBAGE_ARGS: u32 = 4; + pub const SYSTEM_ERR: u32 = 5; +} + +/// Big-endian, 4-byte-aligned XDR encoder. +#[derive(Default)] +pub struct Xdr { + buf: Vec, +} +impl Xdr { + pub fn new() -> Self { + Self { buf: Vec::new() } + } + pub fn u32(&mut self, v: u32) { + self.buf.extend_from_slice(&v.to_be_bytes()); + } + pub fn i32(&mut self, v: i32) { + self.u32(v as u32); + } + pub fn u64(&mut self, v: u64) { + self.buf.extend_from_slice(&v.to_be_bytes()); + } + pub fn bool(&mut self, v: bool) { + self.u32(v as u32); + } + /// Length-prefixed opaque/string, padded to a 4-byte boundary. + pub fn opaque(&mut self, b: &[u8]) { + self.u32(b.len() as u32); + self.fixed(b); + } + /// Fixed-length bytes (no length prefix), padded to 4 bytes. + pub fn fixed(&mut self, b: &[u8]) { + self.buf.extend_from_slice(b); + let pad = (4 - (b.len() % 4)) % 4; + self.buf.extend(std::iter::repeat(0u8).take(pad)); + } + pub fn len(&self) -> usize { + self.buf.len() + } + pub fn is_empty(&self) -> bool { + self.buf.is_empty() + } + pub fn into_bytes(self) -> Vec { + self.buf + } +} + +/// Big-endian XDR decode cursor over a borrowed datagram. +pub struct Cur<'a> { + b: &'a [u8], + pos: usize, +} +impl<'a> Cur<'a> { + pub fn new(b: &'a [u8]) -> Self { + Self { b, pos: 0 } + } + pub fn u32(&mut self) -> Option { + let e = self.pos.checked_add(4)?; + if e > self.b.len() { + return None; + } + let v = u32::from_be_bytes(self.b[self.pos..e].try_into().ok()?); + self.pos = e; + Some(v) + } + pub fn u64(&mut self) -> Option { + let e = self.pos.checked_add(8)?; + if e > self.b.len() { + return None; + } + let v = u64::from_be_bytes(self.b[self.pos..e].try_into().ok()?); + self.pos = e; + Some(v) + } + pub fn i32(&mut self) -> Option { + self.u32().map(|v| v as i32) + } + /// Length-prefixed opaque/string (returns the bytes; consumes the pad). + pub fn opaque(&mut self) -> Option<&'a [u8]> { + let len = self.u32()? as usize; + self.fixed(len) + } + /// Fixed-length opaque of `len` bytes, consuming the pad to a 4-byte boundary. + pub fn fixed(&mut self, len: usize) -> Option<&'a [u8]> { + let e = self.pos.checked_add(len)?; + if e > self.b.len() { + return None; + } + let s = &self.b[self.pos..e]; + let pad = (4 - (len % 4)) % 4; + self.pos = (e + pad).min(self.b.len()); // tolerate a missing trailing pad + Some(s) + } + pub fn skip(&mut self, n: usize) -> Option<()> { + let e = self.pos.checked_add(n)?; + if e > self.b.len() { + return None; + } + self.pos = e; + Some(()) + } + /// Skip one RPC opaque_auth (`flavor` + length-prefixed `body`). + fn skip_auth(&mut self) -> Option<()> { + let _flavor = self.u32()?; + self.opaque()?; + Some(()) + } + pub fn remaining(&self) -> &'a [u8] { + &self.b[self.pos.min(self.b.len())..] + } +} + +/// A parsed RPC CALL header. Credentials are skipped — every host is allowed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RpcCall { + pub xid: u32, + pub prog: u32, + pub vers: u32, + pub proc_num: u32, +} + +/// Parse the RPC CALL header from one UDP datagram, returning the header and a +/// cursor positioned at the procedure arguments. `None` if it isn't a v2 CALL. +pub fn parse_call(msg: &[u8]) -> Option<(RpcCall, Cur<'_>)> { + let mut c = Cur::new(msg); + let xid = c.u32()?; + if c.u32()? != MSG_CALL { + return None; + } + if c.u32()? != RPC_VERSION { + return None; + } + let prog = c.u32()?; + let vers = c.u32()?; + let proc_num = c.u32()?; + c.skip_auth()?; // cred + c.skip_auth()?; // verf + Some((RpcCall { xid, prog, vers, proc_num }, c)) +} + +/// Begin an accepted RPC reply (AUTH_NULL verifier), ready for the caller to +/// append the procedure result. `accept_stat` is one of [`accept`]. +pub fn reply(xid: u32, accept_stat: u32) -> Xdr { + let mut x = Xdr::new(); + x.u32(xid); + x.u32(MSG_REPLY); + x.u32(REPLY_ACCEPTED); + x.u32(AUTH_NULL); // verifier flavor + x.u32(0); // verifier body length + x.u32(accept_stat); + x +} + +// ── NFSv3 (RFC 1813) procedures ───────────────────────────────────────────── +// +// Increment 3: the read-side procedures over the backend. Write-side +// (SETATTR/WRITE/CREATE/MKDIR/REMOVE/RMDIR/RENAME/COMMIT), NFSv2, MOUNT, and the +// NAT wiring land in later increments. + +/// NFS RPC program + version. +pub const NFS_PROG: u32 = 100003; +pub const NFS_V3: u32 = 3; + +// NFSv3 procedure numbers. +const PROC3_NULL: u32 = 0; +const PROC3_GETATTR: u32 = 1; +const PROC3_SETATTR: u32 = 2; +const PROC3_LOOKUP: u32 = 3; +const PROC3_ACCESS: u32 = 4; +const PROC3_READ: u32 = 6; +const PROC3_WRITE: u32 = 7; +const PROC3_CREATE: u32 = 8; +const PROC3_MKDIR: u32 = 9; +const PROC3_REMOVE: u32 = 12; +const PROC3_RMDIR: u32 = 13; +const PROC3_RENAME: u32 = 14; +const PROC3_READDIR: u32 = 16; +const PROC3_READDIRPLUS: u32 = 17; +const PROC3_FSSTAT: u32 = 18; +const PROC3_FSINFO: u32 = 19; +const PROC3_PATHCONF: u32 = 20; +const PROC3_COMMIT: u32 = 21; + +// nfsstat3 values we use. +const NFS3_OK: u32 = 0; +const NFS3ERR_IO: u32 = 5; +const NFS3ERR_NOENT: u32 = 2; +const NFS3ERR_NOTDIR: u32 = 20; +const NFS3ERR_STALE: u32 = 70; +const NFS3ERR_NOTEMPTY: u32 = 66; + +// fsinfo3 sizes. Reads can be large (the NAT fragments outbound); writes need +// the inbound-reassembly increment before a large wtmax is safe. +const RTMAX: u32 = 32768; +const WTMAX: u32 = 32768; +const DTPREF: u32 = 8192; +const FSF3_HOMOGENEOUS: u32 = 0x8; +const FSF3_CANSETTIME: u32 = 0x10; + +const FSID: u64 = 0x4952_4953; // "IRIS" + +fn ftype3(kind: FileKind) -> u32 { + match kind { + FileKind::Reg | FileKind::Other => 1, // NF3REG + FileKind::Dir => 2, // NF3DIR + FileKind::Lnk => 5, // NF3LNK + } +} + +/// File handle (`nfs_fh3`): we encode the 8-byte fileid as the opaque handle. +fn put_fh(x: &mut Xdr, fileid: u64) { + x.opaque(&fileid.to_be_bytes()); +} +fn get_fh(c: &mut Cur) -> Option { + let h = c.opaque()?; + (h.len() == 8).then(|| u64::from_be_bytes(h.try_into().ok().unwrap())) +} + +fn put_time(x: &mut Xdr, t: (u32, u32)) { + x.u32(t.0); + x.u32(t.1); +} + +/// `fattr3`. Mode carries the full st_mode (type + perm bits) — matches knfsd and +/// is what clients expect; `type` is the kind. +fn put_fattr3(x: &mut Xdr, a: &Attr) { + x.u32(ftype3(a.kind)); + x.u32(a.mode); + x.u32(a.nlink); + x.u32(a.uid); + x.u32(a.gid); + x.u64(a.size); + x.u64(a.size); // used (approximated by size) + x.u32(0); + x.u32(0); // rdev (specdata3) + x.u64(FSID); + x.u64(a.fileid); + put_time(x, a.atime); + put_time(x, a.mtime); + put_time(x, a.ctime); +} + +/// `post_op_attr`: a present-flag + optional `fattr3`. +fn put_post_op_attr(x: &mut Xdr, a: Option<&Attr>) { + match a { + Some(a) => { + x.bool(true); + put_fattr3(x, a); + } + None => x.bool(false), + } +} + +fn garbage(xid: u32) -> Vec { + reply(xid, accept::GARBAGE_ARGS).into_bytes() +} + +/// Dispatch one NFSv3 call to its handler, returning the full reply datagram. +pub fn nfs3_call(call: &RpcCall, args: &mut Cur, b: &mut NfsBacking) -> Vec { + match call.proc_num { + PROC3_NULL => reply(call.xid, accept::SUCCESS).into_bytes(), + PROC3_GETATTR => nfs3_getattr(call, args, b), + PROC3_LOOKUP => nfs3_lookup(call, args, b), + PROC3_ACCESS => nfs3_access(call, args, b), + PROC3_READ => nfs3_read(call, args, b), + PROC3_READDIR => nfs3_readdir(call, args, b, false), + PROC3_READDIRPLUS => nfs3_readdir(call, args, b, true), + PROC3_FSINFO => nfs3_fsinfo(call, args, b), + PROC3_FSSTAT => nfs3_fsstat(call, args, b), + PROC3_PATHCONF => nfs3_pathconf(call, args, b), + PROC3_SETATTR => nfs3_setattr(call, args, b), + PROC3_WRITE => nfs3_write(call, args, b), + PROC3_CREATE => nfs3_create(call, args, b, false), + PROC3_MKDIR => nfs3_create(call, args, b, true), + PROC3_REMOVE => nfs3_remove(call, args, b, false), + PROC3_RMDIR => nfs3_remove(call, args, b, true), + PROC3_RENAME => nfs3_rename(call, args, b), + PROC3_COMMIT => nfs3_commit(call, args, b), + _ => reply(call.xid, accept::PROC_UNAVAIL).into_bytes(), + } +} + +fn nfs3_getattr(call: &RpcCall, args: &mut Cur, b: &mut NfsBacking) -> Vec { + let Some(fid) = get_fh(args) else { return garbage(call.xid) }; + let mut x = reply(call.xid, accept::SUCCESS); + match b.attr(fid) { + Some(a) => { + x.u32(NFS3_OK); + put_fattr3(&mut x, &a); + } + None => x.u32(NFS3ERR_STALE), + } + x.into_bytes() +} + +fn nfs3_lookup(call: &RpcCall, args: &mut Cur, b: &mut NfsBacking) -> Vec { + let Some(dir) = get_fh(args) else { return garbage(call.xid) }; + let name = match args.opaque() { + Some(n) => n.to_vec(), + None => return garbage(call.xid), + }; + let mut x = reply(call.xid, accept::SUCCESS); + match b.lookup(dir, &name) { + Some(fid) => { + x.u32(NFS3_OK); + put_fh(&mut x, fid); + put_post_op_attr(&mut x, b.attr(fid).as_ref()); // obj attributes + put_post_op_attr(&mut x, b.attr(dir).as_ref()); // dir attributes + } + None => { + x.u32(NFS3ERR_NOENT); + put_post_op_attr(&mut x, b.attr(dir).as_ref()); + } + } + x.into_bytes() +} + +fn nfs3_access(call: &RpcCall, args: &mut Cur, b: &mut NfsBacking) -> Vec { + let Some(fid) = get_fh(args) else { return garbage(call.xid) }; + let Some(requested) = args.u32() else { return garbage(call.xid) }; + let mut x = reply(call.xid, accept::SUCCESS); + match b.attr(fid) { + Some(a) => { + x.u32(NFS3_OK); + put_post_op_attr(&mut x, Some(&a)); + x.u32(requested); // grant everything asked for (no security) + } + None => { + x.u32(NFS3ERR_STALE); + put_post_op_attr(&mut x, None); + } + } + x.into_bytes() +} + +fn nfs3_read(call: &RpcCall, args: &mut Cur, b: &mut NfsBacking) -> Vec { + let Some(fid) = get_fh(args) else { return garbage(call.xid) }; + let Some(offset) = args.u64() else { return garbage(call.xid) }; + let Some(count) = args.u32() else { return garbage(call.xid) }; + let mut x = reply(call.xid, accept::SUCCESS); + match b.read(fid, offset, count.min(RTMAX)) { + Some((data, eof)) => { + x.u32(NFS3_OK); + put_post_op_attr(&mut x, b.attr(fid).as_ref()); + x.u32(data.len() as u32); + x.bool(eof); + x.opaque(&data); + } + None => { + x.u32(NFS3ERR_IO); + put_post_op_attr(&mut x, b.attr(fid).as_ref()); + } + } + x.into_bytes() +} + +/// READDIR / READDIRPLUS. Entries are name-sorted so the cookie (a 1-based index) +/// is stable across calls; we page within a byte budget and set `eof` when done. +fn nfs3_readdir(call: &RpcCall, args: &mut Cur, b: &mut NfsBacking, plus: bool) -> Vec { + let Some(fid) = get_fh(args) else { return garbage(call.xid) }; + let Some(cookie) = args.u64() else { return garbage(call.xid) }; + if args.fixed(8).is_none() { + return garbage(call.xid); // cookieverf + } + // Honor the client's reply-size limit so we never overrun its decode buffer + // (the v2 path documents the "Can't decode result" failure this avoids). + // READDIR carries `maxcount`; READDIRPLUS carries `dircount` then `maxcount`. + if plus { + args.u32(); // dircount + } + let maxcount = args.u32().unwrap_or(0) as usize; + let limit = if maxcount == 0 { 16_000 } else { maxcount.clamp(512, 16_000) }; + let Some(mut entries) = b.readdir(fid) else { + let mut x = reply(call.xid, accept::SUCCESS); + x.u32(NFS3ERR_NOTDIR); + put_post_op_attr(&mut x, b.attr(fid).as_ref()); + return x.into_bytes(); + }; + entries.sort_by(|p, q| p.0.cmp(&q.0)); + let dir_attr = b.attr(fid); + + let mut x = reply(call.xid, accept::SUCCESS); + x.u32(NFS3_OK); + put_post_op_attr(&mut x, dir_attr.as_ref()); + x.fixed(&[0u8; 8]); // cookieverf + + let mut i = cookie as usize; + let mut budget = 0usize; + while i < entries.len() { + let (name, id, attr) = &entries[i]; + let est = 40 + name.len() + if plus { 96 } else { 0 }; + if budget > 0 && budget + est > limit { + break; // page is full; client resumes from this cookie + } + budget += est; + x.bool(true); // entry follows + x.u64(*id); // fileid + x.opaque(name); + x.u64((i + 1) as u64); // cookie = next index + if plus { + put_post_op_attr(&mut x, Some(attr)); // name_attributes + x.bool(true); // handle follows + put_fh(&mut x, *id); + } + i += 1; + } + x.bool(false); // no more entries in this reply + x.bool(i >= entries.len()); // eof + x.into_bytes() +} + +fn nfs3_fsinfo(call: &RpcCall, args: &mut Cur, b: &mut NfsBacking) -> Vec { + let Some(fid) = get_fh(args) else { return garbage(call.xid) }; + let mut x = reply(call.xid, accept::SUCCESS); + x.u32(NFS3_OK); + put_post_op_attr(&mut x, b.attr(fid).as_ref()); + x.u32(RTMAX); // rtmax + x.u32(RTMAX); // rtpref + x.u32(4096); // rtmult + x.u32(WTMAX); // wtmax + x.u32(WTMAX); // wtpref + x.u32(4096); // wtmult + x.u32(DTPREF); // dtpref + x.u64(0x7fff_ffff_ffff_ffff); // maxfilesize + put_time(&mut x, (1, 0)); // time_delta (1s) + x.u32(FSF3_HOMOGENEOUS | FSF3_CANSETTIME); // properties + x.into_bytes() +} + +fn nfs3_fsstat(call: &RpcCall, args: &mut Cur, b: &mut NfsBacking) -> Vec { + let Some(fid) = get_fh(args) else { return garbage(call.xid) }; + let mut x = reply(call.xid, accept::SUCCESS); + x.u32(NFS3_OK); + put_post_op_attr(&mut x, b.attr(fid).as_ref()); + let big = 1u64 << 40; // faked capacity + x.u64(big); + x.u64(big); + x.u64(big); // total / free / avail bytes + let files = 1u64 << 20; + x.u64(files); + x.u64(files); + x.u64(files); // total / free / avail files + x.u32(0); // invarsec + x.into_bytes() +} + +fn nfs3_pathconf(call: &RpcCall, args: &mut Cur, b: &mut NfsBacking) -> Vec { + let Some(fid) = get_fh(args) else { return garbage(call.xid) }; + let mut x = reply(call.xid, accept::SUCCESS); + x.u32(NFS3_OK); + put_post_op_attr(&mut x, b.attr(fid).as_ref()); + x.u32(1023); // linkmax + x.u32(255); // name_max + x.bool(true); // no_trunc + x.bool(false); // chown_restricted + x.bool(false); // case_insensitive + x.bool(true); // case_preserving + x.into_bytes() +} + +// ── NFSv3 write-side procedures (increment 4) ─────────────────────────────── + +/// `wcc_data`: a (omitted) pre-op attr followed by the post-op attr. +fn put_wcc_data(x: &mut Xdr, post: Option<&Attr>) { + x.bool(false); // pre_op_attr omitted + put_post_op_attr(x, post); +} + +/// Parse `sattr3`, returning the requested size if `set_size` is true (we honor +/// truncation; mode/uid/gid/atime/mtime are accepted-and-ignored). Outer `None` +/// means the structure was malformed. +fn parse_sattr3(c: &mut Cur) -> Option> { + if c.u32()? != 0 { c.u32()?; } // set_mode + if c.u32()? != 0 { c.u32()?; } // set_uid + if c.u32()? != 0 { c.u32()?; } // set_gid + let size = if c.u32()? != 0 { Some(c.u64()?) } else { None }; + if c.u32()? == 2 { c.u32()?; c.u32()?; } // set_atime = SET_TO_CLIENT_TIME + if c.u32()? == 2 { c.u32()?; c.u32()?; } // set_mtime = SET_TO_CLIENT_TIME + Some(size) +} + +fn nfs3_setattr(call: &RpcCall, args: &mut Cur, b: &mut NfsBacking) -> Vec { + let Some(fid) = get_fh(args) else { return garbage(call.xid) }; + let Some(size) = parse_sattr3(args) else { return garbage(call.xid) }; + if let Some(sz) = size { + b.truncate(fid, sz); + } + let mut x = reply(call.xid, accept::SUCCESS); + match b.attr(fid) { + Some(a) => { + x.u32(NFS3_OK); + put_wcc_data(&mut x, Some(&a)); + } + None => { + x.u32(NFS3ERR_STALE); + put_wcc_data(&mut x, None); + } + } + x.into_bytes() +} + +fn nfs3_write(call: &RpcCall, args: &mut Cur, b: &mut NfsBacking) -> Vec { + let Some(fid) = get_fh(args) else { return garbage(call.xid) }; + let Some(offset) = args.u64() else { return garbage(call.xid) }; + let Some(_count) = args.u32() else { return garbage(call.xid) }; + let Some(_stable) = args.u32() else { return garbage(call.xid) }; + let Some(data) = args.opaque() else { return garbage(call.xid) }; + let mut x = reply(call.xid, accept::SUCCESS); + match b.write(fid, offset, data) { + Some(a) => { + x.u32(NFS3_OK); + put_wcc_data(&mut x, Some(&a)); + x.u32(data.len() as u32); // count written + x.u32(2); // committed = FILE_SYNC + x.fixed(&[0u8; 8]); // write verifier + } + None => { + x.u32(NFS3ERR_IO); + put_wcc_data(&mut x, b.attr(fid).as_ref()); + } + } + x.into_bytes() +} + +/// CREATE/MKDIR share a shape: diropargs3 then attrs we ignore. `dir` true = +/// MKDIR. On success returns post_op fh + attrs + dir wcc. +fn nfs3_create(call: &RpcCall, args: &mut Cur, b: &mut NfsBacking, dir: bool) -> Vec { + let Some(parent) = get_fh(args) else { return garbage(call.xid) }; + let name = match args.opaque() { + Some(n) => n.to_vec(), + None => return garbage(call.xid), + }; + // The remaining args (createmode3 + sattr3, or MKDIR's sattr3) are ignored. + let made = if dir { b.mkdir(parent, &name) } else { b.create(parent, &name) }; + let mut x = reply(call.xid, accept::SUCCESS); + match made { + Some(fid) => { + x.u32(NFS3_OK); + x.bool(true); // post_op_fh3: handle follows + put_fh(&mut x, fid); + put_post_op_attr(&mut x, b.attr(fid).as_ref()); + put_wcc_data(&mut x, b.attr(parent).as_ref()); + } + None => { + x.u32(NFS3ERR_IO); + x.bool(false); // no handle + put_post_op_attr(&mut x, None); + put_wcc_data(&mut x, b.attr(parent).as_ref()); + } + } + x.into_bytes() +} + +/// REMOVE/RMDIR: diropargs3 → dir wcc. `dir` true = RMDIR. +fn nfs3_remove(call: &RpcCall, args: &mut Cur, b: &mut NfsBacking, dir: bool) -> Vec { + let Some(parent) = get_fh(args) else { return garbage(call.xid) }; + let name = match args.opaque() { + Some(n) => n.to_vec(), + None => return garbage(call.xid), + }; + let ok = if dir { b.rmdir(parent, &name) } else { b.remove(parent, &name) }; + let mut x = reply(call.xid, accept::SUCCESS); + x.u32(if ok { + NFS3_OK + } else if dir { + NFS3ERR_NOTEMPTY + } else { + NFS3ERR_NOENT + }); + put_wcc_data(&mut x, b.attr(parent).as_ref()); + x.into_bytes() +} + +fn nfs3_rename(call: &RpcCall, args: &mut Cur, b: &mut NfsBacking) -> Vec { + let Some(from_dir) = get_fh(args) else { return garbage(call.xid) }; + let from_name = match args.opaque() { + Some(n) => n.to_vec(), + None => return garbage(call.xid), + }; + let Some(to_dir) = get_fh(args) else { return garbage(call.xid) }; + let to_name = match args.opaque() { + Some(n) => n.to_vec(), + None => return garbage(call.xid), + }; + let ok = b.rename(from_dir, &from_name, to_dir, &to_name); + let mut x = reply(call.xid, accept::SUCCESS); + x.u32(if ok { NFS3_OK } else { NFS3ERR_IO }); + put_wcc_data(&mut x, b.attr(from_dir).as_ref()); // fromdir wcc + put_wcc_data(&mut x, b.attr(to_dir).as_ref()); // todir wcc + x.into_bytes() +} + +fn nfs3_commit(call: &RpcCall, args: &mut Cur, b: &mut NfsBacking) -> Vec { + let Some(fid) = get_fh(args) else { return garbage(call.xid) }; + let _ = args.u64(); // offset + let _ = args.u32(); // count + let mut x = reply(call.xid, accept::SUCCESS); + x.u32(NFS3_OK); // writes are synchronous, so COMMIT is a no-op success + put_wcc_data(&mut x, b.attr(fid).as_ref()); + x.fixed(&[0u8; 8]); // write verifier + x.into_bytes() +} + +// ── NFSv2 (RFC 1094) procedures ───────────────────────────────────────────── +// +// Increment 5: the parallel v2 encoding — 32-bit attrs, fixed 32-byte handles, +// v2 procedure numbers — over the same backend. Served when the guest mounts +// NFSv2 (IRIX 5.3). + +pub const NFS_V2: u32 = 2; + +// v2 procedure numbers (these differ from v3). +const PROC2_NULL: u32 = 0; +const PROC2_GETATTR: u32 = 1; +const PROC2_SETATTR: u32 = 2; +const PROC2_LOOKUP: u32 = 4; +const PROC2_READ: u32 = 6; +const PROC2_WRITE: u32 = 8; +const PROC2_CREATE: u32 = 9; +const PROC2_REMOVE: u32 = 10; +const PROC2_RENAME: u32 = 11; +const PROC2_MKDIR: u32 = 14; +const PROC2_RMDIR: u32 = 15; +const PROC2_READDIR: u32 = 16; +const PROC2_STATFS: u32 = 17; + +const NFS2_OK: u32 = 0; // v2 nfsstat shares numeric values with v3 for our cases + +fn ftype2(kind: FileKind) -> u32 { + match kind { + FileKind::Reg | FileKind::Other => 1, + FileKind::Dir => 2, + FileKind::Lnk => 5, + } +} + +/// v2 file handle: a fixed 32-byte opaque — we store the 8-byte fileid + zeros. +fn put_fh2(x: &mut Xdr, fileid: u64) { + let mut h = [0u8; 32]; + h[..8].copy_from_slice(&fileid.to_be_bytes()); + x.fixed(&h); +} +fn get_fh2(c: &mut Cur) -> Option { + let h = c.fixed(32)?; + Some(u64::from_be_bytes(h[..8].try_into().ok()?)) +} + +/// v2 timeval is (seconds, *micro*seconds); convert our nanoseconds. +fn put_time2(x: &mut Xdr, t: (u32, u32)) { + x.u32(t.0); + x.u32(t.1 / 1000); +} + +/// v2 `fattr` (all 32-bit). +fn put_fattr2(x: &mut Xdr, a: &Attr) { + x.u32(ftype2(a.kind)); + x.u32(a.mode); + x.u32(a.nlink); + x.u32(a.uid); + x.u32(a.gid); + x.u32(a.size as u32); // v2 size is 32-bit (>4 GiB unsupported) + x.u32(4096); // blocksize + x.u32(0); // rdev + x.u32(((a.size + 511) / 512) as u32); // blocks + x.u32(FSID as u32); + x.u32(a.fileid as u32); + put_time2(x, a.atime); + put_time2(x, a.mtime); + put_time2(x, a.ctime); +} + +/// Parse a v2 `sattr`, returning the size if set (`0xFFFFFFFF` = don't set). +fn parse_sattr2(c: &mut Cur) -> Option> { + c.u32()?; // mode + c.u32()?; // uid + c.u32()?; // gid + let size = c.u32()?; + c.u64()?; // atime (sec + usec) + c.u64()?; // mtime + Some((size != 0xFFFF_FFFF).then_some(size as u64)) +} + +/// Dispatch one NFSv2 call. +pub fn nfs2_call(call: &RpcCall, args: &mut Cur, b: &mut NfsBacking) -> Vec { + match call.proc_num { + PROC2_NULL => reply(call.xid, accept::SUCCESS).into_bytes(), + PROC2_GETATTR => nfs2_getattr(call, args, b), + PROC2_SETATTR => nfs2_setattr(call, args, b), + PROC2_LOOKUP => nfs2_lookup(call, args, b), + PROC2_READ => nfs2_read(call, args, b), + PROC2_WRITE => nfs2_write(call, args, b), + PROC2_CREATE => nfs2_create(call, args, b, false), + PROC2_MKDIR => nfs2_create(call, args, b, true), + PROC2_REMOVE => nfs2_remove(call, args, b, false), + PROC2_RMDIR => nfs2_remove(call, args, b, true), + PROC2_RENAME => nfs2_rename(call, args, b), + PROC2_READDIR => nfs2_readdir(call, args, b), + PROC2_STATFS => nfs2_statfs(call, args, b), + _ => reply(call.xid, accept::PROC_UNAVAIL).into_bytes(), + } +} + +fn nfs2_attr_reply(call: &RpcCall, b: &mut NfsBacking, fid: u64) -> Vec { + let mut x = reply(call.xid, accept::SUCCESS); + match b.attr(fid) { + Some(a) => { + x.u32(NFS2_OK); + put_fattr2(&mut x, &a); + } + None => x.u32(NFS3ERR_STALE), + } + x.into_bytes() +} + +fn nfs2_getattr(call: &RpcCall, args: &mut Cur, b: &mut NfsBacking) -> Vec { + let Some(fid) = get_fh2(args) else { return garbage(call.xid) }; + nfs2_attr_reply(call, b, fid) +} + +fn nfs2_setattr(call: &RpcCall, args: &mut Cur, b: &mut NfsBacking) -> Vec { + let Some(fid) = get_fh2(args) else { return garbage(call.xid) }; + let Some(size) = parse_sattr2(args) else { return garbage(call.xid) }; + if let Some(sz) = size { + b.truncate(fid, sz); + } + nfs2_attr_reply(call, b, fid) +} + +fn nfs2_lookup(call: &RpcCall, args: &mut Cur, b: &mut NfsBacking) -> Vec { + let Some(dir) = get_fh2(args) else { return garbage(call.xid) }; + let name = match args.opaque() { + Some(n) => n.to_vec(), + None => return garbage(call.xid), + }; + let mut x = reply(call.xid, accept::SUCCESS); + match b.lookup(dir, &name) { + Some(fid) => { + x.u32(NFS2_OK); + put_fh2(&mut x, fid); + if let Some(a) = b.attr(fid) { + put_fattr2(&mut x, &a); + } + } + None => x.u32(NFS3ERR_NOENT), + } + x.into_bytes() +} + +fn nfs2_read(call: &RpcCall, args: &mut Cur, b: &mut NfsBacking) -> Vec { + let Some(fid) = get_fh2(args) else { return garbage(call.xid) }; + let Some(offset) = args.u32() else { return garbage(call.xid) }; + let Some(count) = args.u32() else { return garbage(call.xid) }; + let _total = args.u32(); + let mut x = reply(call.xid, accept::SUCCESS); + match b.read(fid, offset as u64, count.min(8192)) { + Some((data, _eof)) => { + x.u32(NFS2_OK); + if let Some(a) = b.attr(fid) { + put_fattr2(&mut x, &a); + } + x.opaque(&data); + } + None => x.u32(NFS3ERR_IO), + } + x.into_bytes() +} + +fn nfs2_write(call: &RpcCall, args: &mut Cur, b: &mut NfsBacking) -> Vec { + let Some(fid) = get_fh2(args) else { return garbage(call.xid) }; + let _begin = args.u32(); + let Some(offset) = args.u32() else { return garbage(call.xid) }; + let _total = args.u32(); + let Some(data) = args.opaque() else { return garbage(call.xid) }; + b.write(fid, offset as u64, data); + nfs2_attr_reply(call, b, fid) +} + +fn nfs2_create(call: &RpcCall, args: &mut Cur, b: &mut NfsBacking, dir: bool) -> Vec { + let Some(parent) = get_fh2(args) else { return garbage(call.xid) }; + let name = match args.opaque() { + Some(n) => n.to_vec(), + None => return garbage(call.xid), + }; + // sattr ignored. + let made = if dir { b.mkdir(parent, &name) } else { b.create(parent, &name) }; + let mut x = reply(call.xid, accept::SUCCESS); + match made { + Some(fid) => { + x.u32(NFS2_OK); + put_fh2(&mut x, fid); + if let Some(a) = b.attr(fid) { + put_fattr2(&mut x, &a); + } + } + None => x.u32(NFS3ERR_IO), + } + x.into_bytes() +} + +fn nfs2_remove(call: &RpcCall, args: &mut Cur, b: &mut NfsBacking, dir: bool) -> Vec { + let Some(parent) = get_fh2(args) else { return garbage(call.xid) }; + let name = match args.opaque() { + Some(n) => n.to_vec(), + None => return garbage(call.xid), + }; + let ok = if dir { b.rmdir(parent, &name) } else { b.remove(parent, &name) }; + let mut x = reply(call.xid, accept::SUCCESS); + x.u32(if ok { NFS2_OK } else { NFS3ERR_NOENT }); + x.into_bytes() +} + +fn nfs2_rename(call: &RpcCall, args: &mut Cur, b: &mut NfsBacking) -> Vec { + let Some(fd) = get_fh2(args) else { return garbage(call.xid) }; + let fname = match args.opaque() { + Some(n) => n.to_vec(), + None => return garbage(call.xid), + }; + let Some(td) = get_fh2(args) else { return garbage(call.xid) }; + let tname = match args.opaque() { + Some(n) => n.to_vec(), + None => return garbage(call.xid), + }; + let ok = b.rename(fd, &fname, td, &tname); + let mut x = reply(call.xid, accept::SUCCESS); + x.u32(if ok { NFS2_OK } else { NFS3ERR_IO }); + x.into_bytes() +} + +fn nfs2_readdir(call: &RpcCall, args: &mut Cur, b: &mut NfsBacking) -> Vec { + let Some(fid) = get_fh2(args) else { return garbage(call.xid) }; + let Some(cookie_bytes) = args.fixed(4) else { return garbage(call.xid) }; + let cookie = u32::from_be_bytes(cookie_bytes.try_into().unwrap()) as usize; + let count = args.u32().unwrap_or(0) as usize; + let mut x = reply(call.xid, accept::SUCCESS); + let Some(mut entries) = b.readdir(fid) else { + x.u32(NFS3ERR_NOTDIR); + return x.into_bytes(); + }; + entries.sort_by(|p, q| p.0.cmp(&q.0)); + x.u32(NFS2_OK); + // Page so the reply fits BOTH the client's `count`-sized decode buffer and a + // single unfragmented UDP datagram. NFSv2/BSD-derived clients (IRIX) size + // their receive buffer to `count`; overrunning it truncates the datagram on + // their side and the XDR decode fails ("Can't decode result"). The directory + // is continued across calls via the cookie, so a small per-reply budget just + // costs a few more READDIR round-trips. 1400 keeps us under the 1472-byte + // single-datagram UDP payload after the ~36 bytes of header + terminators. + let limit = count.clamp(512, 1400); + let mut i = cookie; + let mut budget = 0usize; + while i < entries.len() { + let (name, id, _attr) = &entries[i]; + let est = 24 + name.len(); + if budget > 0 && budget + est > limit { + break; + } + budget += est; + x.bool(true); // entry follows + x.u32(*id as u32); // fileid + x.opaque(name); + x.fixed(&((i as u32) + 1).to_be_bytes()); // nfscookie (opaque[4]) + i += 1; + } + x.bool(false); // end of entries + x.bool(i >= entries.len()); // eof + x.into_bytes() +} + +fn nfs2_statfs(call: &RpcCall, args: &mut Cur, _b: &mut NfsBacking) -> Vec { + let Some(_fid) = get_fh2(args) else { return garbage(call.xid) }; + let mut x = reply(call.xid, accept::SUCCESS); + x.u32(NFS2_OK); + x.u32(8192); // tsize (optimum transfer size) + x.u32(4096); // bsize + let big = 1u32 << 20; + x.u32(big); // blocks + x.u32(big); // bfree + x.u32(big); // bavail + x.into_bytes() +} + +// ── MOUNT protocol (RFC 1813 App. I / RFC 1094 App. A) ────────────────────── +// +// Increment 6: MNT hands the guest the root file handle. We export a single +// directory and allow everyone, so the requested path is ignored. + +pub const MOUNT_PROG: u32 = 100005; +pub const MOUNT_V1: u32 = 1; // for NFSv2 +pub const MOUNT_V3: u32 = 3; // for NFSv3 + +const MNTPROC_NULL: u32 = 0; +const MNTPROC_MNT: u32 = 1; +const MNTPROC_DUMP: u32 = 2; +const MNTPROC_UMNT: u32 = 3; +const MNTPROC_UMNTALL: u32 = 4; +const MNTPROC_EXPORT: u32 = 5; + +/// Dispatch one MOUNT call. +pub fn mount_call(call: &RpcCall, args: &mut Cur) -> Vec { + match call.proc_num { + MNTPROC_NULL => reply(call.xid, accept::SUCCESS).into_bytes(), + MNTPROC_MNT => { + let _path = args.opaque(); // export path ignored — single export + let mut x = reply(call.xid, accept::SUCCESS); + if call.vers == MOUNT_V1 { + x.u32(0); // fhstatus = OK + put_fh2(&mut x, ROOT_ID); // v2 fhandle (fixed 32 bytes) + } else { + x.u32(0); // mountstat3 = MNT3_OK + put_fh(&mut x, ROOT_ID); // v3 fhandle3 (opaque) + x.u32(1); // one auth flavor follows + x.u32(AUTH_NULL); + } + x.into_bytes() + } + MNTPROC_UMNT | MNTPROC_UMNTALL => reply(call.xid, accept::SUCCESS).into_bytes(), + MNTPROC_DUMP => { + let mut x = reply(call.xid, accept::SUCCESS); + x.bool(false); // empty mount list + x.into_bytes() + } + MNTPROC_EXPORT => { + let mut x = reply(call.xid, accept::SUCCESS); + x.bool(true); // one export entry follows + x.opaque(b"/"); // ex_dir + x.bool(false); // ex_groups: none -> everyone + x.bool(false); // no further entries + x.into_bytes() + } + _ => reply(call.xid, accept::PROC_UNAVAIL).into_bytes(), + } +} + +// ── server: program/version dispatch + duplicate-request cache ────────────── + +/// Which NFS version(s) to serve. `Auto` answers whatever the guest mounts with. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum NfsVersion { + #[default] + Auto, + V2, + V3, +} + +/// Duplicate-request cache: NFS-over-UDP clients retransmit on timeout, so +/// non-idempotent ops (WRITE/CREATE/REMOVE/...) must not be re-applied. We cache +/// recent replies by xid and replay them. Bounded, FIFO-evicted. +struct Drc { + cap: usize, + order: std::collections::VecDeque, + map: HashMap>, +} +impl Drc { + fn new(cap: usize) -> Self { + Self { cap, order: std::collections::VecDeque::new(), map: HashMap::new() } + } + fn get(&self, xid: u32) -> Option<&Vec> { + self.map.get(&xid) + } + fn put(&mut self, xid: u32, reply: Vec) { + if self.map.contains_key(&xid) { + return; + } + while self.order.len() >= self.cap { + if let Some(old) = self.order.pop_front() { + self.map.remove(&old); + } + } + self.order.push_back(xid); + self.map.insert(xid, reply); + } +} + +/// The in-core NFS server: one export + the dedup cache. The NAT calls +/// [`handle`](Self::handle) with each guest MOUNT/NFS RPC datagram. +pub struct NfsServer { + backing: NfsBacking, + drc: Drc, + version: NfsVersion, +} + +impl NfsServer { + pub fn new(root: impl Into, version: NfsVersion) -> Self { + Self { backing: NfsBacking::new(root), drc: Drc::new(256), version } + } + + /// Process one RPC call datagram, returning the reply datagram (or `None` if + /// it isn't a parseable RPC call to ignore). + pub fn handle(&mut self, msg: &[u8]) -> Option> { + let (call, mut args) = parse_call(msg)?; + let idem = is_idempotent(&call); + if !idem { + if let Some(cached) = self.drc.get(call.xid) { + return Some(cached.clone()); + } + } + let out = self.dispatch(&call, &mut args); + if !idem { + self.drc.put(call.xid, out.clone()); + } + Some(out) + } + + fn dispatch(&mut self, call: &RpcCall, args: &mut Cur) -> Vec { + if call.prog == MOUNT_PROG { + return mount_call(call, args); + } + if call.prog == NFS_PROG { + match call.vers { + NFS_V3 if self.version != NfsVersion::V2 => { + return nfs3_call(call, args, &mut self.backing); + } + NFS_V2 if self.version != NfsVersion::V3 => { + return nfs2_call(call, args, &mut self.backing); + } + _ => return reply(call.xid, accept::PROG_MISMATCH).into_bytes(), + } + } + reply(call.xid, accept::PROG_UNAVAIL).into_bytes() + } +} + +/// Whether a procedure is safe to re-run (so it skips the dedup cache). Version- +/// aware because v2 and v3 number their procedures differently. +fn is_idempotent(call: &RpcCall) -> bool { + if call.prog != NFS_PROG { + return true; + } + let non_idempotent: &[u32] = if call.vers == NFS_V2 { + &[PROC2_SETATTR, PROC2_WRITE, PROC2_CREATE, PROC2_REMOVE, PROC2_RENAME, PROC2_MKDIR, PROC2_RMDIR] + } else { + &[PROC3_SETATTR, PROC3_WRITE, PROC3_CREATE, PROC3_MKDIR, PROC3_REMOVE, PROC3_RMDIR, PROC3_RENAME] + }; + !non_idempotent.contains(&call.proc_num) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + /// Unique temp export dir (no external tempfile crate). + fn temp_export() -> PathBuf { + static N: AtomicU64 = AtomicU64::new(0); + let p = std::env::temp_dir().join(format!( + "iris_nfs_test_{}_{}", + std::process::id(), + N.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&p).unwrap(); + p + } + + #[test] + fn root_is_a_dir_with_id_1() { + let root = temp_export(); + let b = NfsBacking::new(&root); + assert_eq!(b.abs_of(ROOT_ID).unwrap(), root); + assert!(b.is_dir(ROOT_ID)); + let a = b.attr(ROOT_ID).unwrap(); + assert_eq!(a.kind, FileKind::Dir); + assert_eq!(a.fileid, ROOT_ID); + assert_eq!(a.uid, 0); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn lookup_intern_and_attrs() { + let root = temp_export(); + std::fs::write(root.join("hello.txt"), b"hi there").unwrap(); + std::fs::create_dir(root.join("sub")).unwrap(); + let mut b = NfsBacking::new(&root); + + let fid = b.lookup(ROOT_ID, b"hello.txt").unwrap(); + assert_eq!(b.lookup(ROOT_ID, b"hello.txt").unwrap(), fid, "stable id"); + let a = b.attr(fid).unwrap(); + assert_eq!(a.kind, FileKind::Reg); + assert_eq!(a.size, 8); + assert_eq!(a.mode & 0o170000, S_IFREG); + + let did = b.lookup(ROOT_ID, b"sub").unwrap(); + assert!(b.is_dir(did)); + assert!(b.lookup(ROOT_ID, b"nope").is_none()); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn read_write_roundtrip() { + let root = temp_export(); + let mut b = NfsBacking::new(&root); + let fid = b.create(ROOT_ID, b"f.bin").unwrap(); + let attr = b.write(fid, 0, b"abcdefgh").unwrap(); + assert_eq!(attr.size, 8); + let (data, eof) = b.read(fid, 2, 3).unwrap(); + assert_eq!(data, b"cde"); + assert!(!eof); + let (rest, eof) = b.read(fid, 5, 100).unwrap(); + assert_eq!(rest, b"fgh"); + assert!(eof); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn readdir_lists_children() { + let root = temp_export(); + std::fs::write(root.join("a"), b"1").unwrap(); + std::fs::write(root.join("b"), b"22").unwrap(); + std::fs::create_dir(root.join("d")).unwrap(); + let mut b = NfsBacking::new(&root); + let mut names: Vec> = b.readdir(ROOT_ID).unwrap().into_iter().map(|(n, _, _)| n).collect(); + names.sort(); + assert_eq!(names, vec![b"a".to_vec(), b"b".to_vec(), b"d".to_vec()]); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn mkdir_remove_rename() { + let root = temp_export(); + let mut b = NfsBacking::new(&root); + let d = b.mkdir(ROOT_ID, b"dir").unwrap(); + assert!(b.is_dir(d)); + let f = b.create(d, b"x").unwrap(); + assert!(b.attr(f).is_some()); + assert!(b.rename(d, b"x", ROOT_ID, b"y")); + assert!(root.join("y").exists()); + assert!(!root.join("dir/x").exists()); + assert!(b.remove(ROOT_ID, b"y")); + assert!(b.rmdir(ROOT_ID, b"dir")); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn rejects_traversal_components() { + assert!(valid_component(b"..").is_none()); + assert!(valid_component(b".").is_none()); + assert!(valid_component(b"").is_none()); + assert!(valid_component(b"a/b").is_none()); + assert!(valid_component(b"a\\b").is_none()); + assert!(valid_component(b"ok.txt").is_some()); + + // lookup must refuse to escape the export root. + let root = temp_export(); + let mut b = NfsBacking::new(&root); + assert!(b.lookup(ROOT_ID, b"..").is_none()); + assert!(b.lookup(ROOT_ID, b"../etc").is_none()); + std::fs::remove_dir_all(&root).ok(); + } + + // ── wire layer (increment 2) ──────────────────────────────────────────── + + #[test] + fn xdr_roundtrip() { + let mut x = Xdr::new(); + x.u32(0xdead_beef); + x.u64(0x0102_0304_0506_0708); + x.opaque(b"abc"); // 4 (len) + 3 + 1 pad = 8 + let bytes = x.into_bytes(); + assert_eq!(bytes.len(), 4 + 8 + 8); + let mut c = Cur::new(&bytes); + assert_eq!(c.u32(), Some(0xdead_beef)); + assert_eq!(c.u64(), Some(0x0102_0304_0506_0708)); + assert_eq!(c.opaque(), Some(&b"abc"[..])); + assert_eq!(c.u32(), None); // exhausted + } + + #[test] + fn parse_rpc_call_skips_auth() { + let mut x = Xdr::new(); + x.u32(0x1122_3344); // xid + x.u32(0); // CALL + x.u32(2); // rpcvers + x.u32(100003); // NFS program + x.u32(3); // v3 + x.u32(1); // GETATTR + x.u32(0); x.u32(0); // cred: AUTH_NULL, len 0 + x.u32(0); x.u32(0); // verf: AUTH_NULL, len 0 + x.u32(0xCAFE_BABE); // one arg word + let msg = x.into_bytes(); + let (call, mut args) = parse_call(&msg).unwrap(); + assert_eq!(call, RpcCall { xid: 0x1122_3344, prog: 100003, vers: 3, proc_num: 1 }); + assert_eq!(args.u32(), Some(0xCAFE_BABE), "cursor lands on the args"); + } + + #[test] + fn parse_rejects_non_call_and_bad_version() { + let mut reply = Xdr::new(); + reply.u32(1); reply.u32(1); // xid, REPLY (not CALL) + assert!(parse_call(&reply.into_bytes()).is_none()); + + let mut badver = Xdr::new(); + badver.u32(1); badver.u32(0); badver.u32(3); // CALL but rpcvers=3 + assert!(parse_call(&badver.into_bytes()).is_none()); + } + + #[test] + fn reply_header_bytes() { + let bytes = reply(0x1122_3344, accept::SUCCESS).into_bytes(); + let mut c = Cur::new(&bytes); + assert_eq!(c.u32(), Some(0x1122_3344)); // xid + assert_eq!(c.u32(), Some(1)); // REPLY + assert_eq!(c.u32(), Some(0)); // MSG_ACCEPTED + assert_eq!(c.u32(), Some(0)); // verf flavor AUTH_NULL + assert_eq!(c.u32(), Some(0)); // verf len + assert_eq!(c.u32(), Some(0)); // accept_stat SUCCESS + } + + // ── NFSv3 procedures (increment 3) ────────────────────────────────────── + + /// Build an NFSv3 RPC call with AUTH_NULL and pre-encoded `args`. + fn build_call(proc_num: u32, args: &[u8]) -> Vec { + let mut x = Xdr::new(); + x.u32(0x42); // xid + x.u32(0); // CALL + x.u32(2); // rpcvers + x.u32(NFS_PROG); + x.u32(NFS_V3); + x.u32(proc_num); + x.u32(0); x.u32(0); // cred AUTH_NULL + x.u32(0); x.u32(0); // verf AUTH_NULL + x.fixed(args); // args are already 4-aligned, so no extra pad + x.into_bytes() + } + + /// Run one call against a backing store, returning (accept_stat, cursor at + /// the procedure result). + fn run(b: &mut NfsBacking, proc_num: u32, args: &[u8]) -> (u32, Vec) { + let req = build_call(proc_num, args); + let (rpc, mut argcur) = parse_call(&req).unwrap(); + let reply_bytes = nfs3_call(&rpc, &mut argcur, b); + // Validate + strip the 6-word accepted-reply header. + let mut c = Cur::new(&reply_bytes); + assert_eq!(c.u32(), Some(0x42)); // xid echoed + assert_eq!(c.u32(), Some(1)); // REPLY + assert_eq!(c.u32(), Some(0)); // ACCEPTED + c.u32(); c.u32(); // verf + let stat = c.u32().unwrap(); + let off = reply_bytes.len() - c.remaining().len(); + (stat, reply_bytes[off..].to_vec()) + } + + #[test] + fn getattr_root_is_dir() { + let root = temp_export(); + let mut b = NfsBacking::new(&root); + let mut a = Xdr::new(); + put_fh(&mut a, ROOT_ID); + let (stat, res) = run(&mut b, PROC3_GETATTR, &a.into_bytes()); + assert_eq!(stat, accept::SUCCESS); + let mut r = Cur::new(&res); + assert_eq!(r.u32(), Some(NFS3_OK)); // nfsstat3 + assert_eq!(r.u32(), Some(2)); // ftype3 == NF3DIR + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn lookup_then_read() { + let root = temp_export(); + std::fs::write(root.join("hello.txt"), b"hello world").unwrap(); + let mut b = NfsBacking::new(&root); + + let mut a = Xdr::new(); + put_fh(&mut a, ROOT_ID); + a.opaque(b"hello.txt"); + let (stat, res) = run(&mut b, PROC3_LOOKUP, &a.into_bytes()); + assert_eq!(stat, accept::SUCCESS); + let mut r = Cur::new(&res); + assert_eq!(r.u32(), Some(NFS3_OK)); + let fid = u64::from_be_bytes(r.opaque().unwrap().try_into().unwrap()); // object fh + + let mut a = Xdr::new(); + put_fh(&mut a, fid); + a.u64(0); // offset + a.u32(5); // count + let (_stat, res) = run(&mut b, PROC3_READ, &a.into_bytes()); + let mut r = Cur::new(&res); + assert_eq!(r.u32(), Some(NFS3_OK)); + // The read data is carried as the final opaque; assert the first 5 bytes + // of the file appear in the reply. + assert!(res.windows(5).any(|w| w == b"hello"), "read returned the data"); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn readdir_lists_entries() { + let root = temp_export(); + std::fs::write(root.join("a.txt"), b"1").unwrap(); + std::fs::write(root.join("b.txt"), b"2").unwrap(); + let mut b = NfsBacking::new(&root); + let mut a = Xdr::new(); + put_fh(&mut a, ROOT_ID); + a.u64(0); // cookie + a.fixed(&[0u8; 8]); // cookieverf + a.u32(8192); // count + let (stat, res) = run(&mut b, PROC3_READDIR, &a.into_bytes()); + assert_eq!(stat, accept::SUCCESS); + assert!(res.windows(5).any(|w| w == b"a.txt")); + assert!(res.windows(5).any(|w| w == b"b.txt")); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn fsinfo_advertises_sizes() { + let root = temp_export(); + let mut b = NfsBacking::new(&root); + let mut a = Xdr::new(); + put_fh(&mut a, ROOT_ID); + let (stat, res) = run(&mut b, PROC3_FSINFO, &a.into_bytes()); + assert_eq!(stat, accept::SUCCESS); + let mut r = Cur::new(&res); + assert_eq!(r.u32(), Some(NFS3_OK)); + // skip post_op_attr (present + fattr3): bool + 21 words (fattr3 is 84 bytes) + assert_eq!(r.u32(), Some(1)); // attrs follow + for _ in 0..21 { r.u32(); } // fattr3 = 84 bytes = 21 words + assert_eq!(r.u32(), Some(RTMAX)); // rtmax + std::fs::remove_dir_all(&root).ok(); + } + + // ── write procedures + DRC (increment 4) ──────────────────────────────── + + /// Build an NFSv3 call with a chosen xid. + fn call_xid(xid: u32, proc_num: u32, args: &[u8]) -> Vec { + let mut x = Xdr::new(); + x.u32(xid); x.u32(0); x.u32(2); + x.u32(NFS_PROG); x.u32(NFS_V3); x.u32(proc_num); + x.u32(0); x.u32(0); x.u32(0); x.u32(0); // AUTH_NULL cred+verf + x.fixed(args); + x.into_bytes() + } + + fn lookup_fh(s: &mut NfsServer, name: &[u8]) -> u64 { + let mut a = Xdr::new(); + put_fh(&mut a, ROOT_ID); + a.opaque(name); + let r = s.handle(&call_xid(1, PROC3_LOOKUP, &a.into_bytes())).unwrap(); + let mut c = Cur::new(&r); + for _ in 0..6 { c.u32(); } // reply header + assert_eq!(c.u32(), Some(NFS3_OK)); + u64::from_be_bytes(c.opaque().unwrap().try_into().unwrap()) + } + + #[test] + fn write_through_server_and_drc_dedup() { + let root = temp_export(); + std::fs::write(root.join("f.bin"), b"").unwrap(); + let mut s = NfsServer::new(&root, NfsVersion::Auto); + let fid = lookup_fh(&mut s, b"f.bin"); + + let write_args = |data: &[u8]| { + let mut w = Xdr::new(); + put_fh(&mut w, fid); + w.u64(0); // offset + w.u32(data.len() as u32); // count + w.u32(2); // stable = FILE_SYNC + w.opaque(data); + w.into_bytes() + }; + + s.handle(&call_xid(100, PROC3_WRITE, &write_args(b"AAAA"))).unwrap(); + assert_eq!(std::fs::read(root.join("f.bin")).unwrap(), b"AAAA"); + + // Same xid, different data: the DRC must replay, NOT re-apply. + s.handle(&call_xid(100, PROC3_WRITE, &write_args(b"BBBB"))).unwrap(); + assert_eq!(std::fs::read(root.join("f.bin")).unwrap(), b"AAAA", "retransmit deduped"); + + // A fresh xid does apply. + s.handle(&call_xid(101, PROC3_WRITE, &write_args(b"BBBB"))).unwrap(); + assert_eq!(std::fs::read(root.join("f.bin")).unwrap(), b"BBBB"); + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn create_and_remove_via_server() { + let root = temp_export(); + let mut s = NfsServer::new(&root, NfsVersion::Auto); + + let mut a = Xdr::new(); + put_fh(&mut a, ROOT_ID); + a.opaque(b"new.txt"); // diropargs3 (createmode/sattr3 are ignored by the handler) + let r = s.handle(&call_xid(1, PROC3_CREATE, &a.into_bytes())).unwrap(); + let mut c = Cur::new(&r); + for _ in 0..6 { c.u32(); } + assert_eq!(c.u32(), Some(NFS3_OK)); + assert!(root.join("new.txt").exists()); + + let mut rm = Xdr::new(); + put_fh(&mut rm, ROOT_ID); + rm.opaque(b"new.txt"); + let r = s.handle(&call_xid(2, PROC3_REMOVE, &rm.into_bytes())).unwrap(); + let mut c = Cur::new(&r); + for _ in 0..6 { c.u32(); } + assert_eq!(c.u32(), Some(NFS3_OK)); + assert!(!root.join("new.txt").exists()); + + std::fs::remove_dir_all(&root).ok(); + } + + // ── NFSv2 (increment 5) ───────────────────────────────────────────────── + + fn call2(xid: u32, proc_num: u32, args: &[u8]) -> Vec { + let mut x = Xdr::new(); + x.u32(xid); x.u32(0); x.u32(2); + x.u32(NFS_PROG); x.u32(NFS_V2); x.u32(proc_num); + x.u32(0); x.u32(0); x.u32(0); x.u32(0); + x.fixed(args); + x.into_bytes() + } + + #[test] + fn v2_getattr_lookup_read_write() { + let root = temp_export(); + std::fs::write(root.join("v2.txt"), b"hello v2").unwrap(); + let mut s = NfsServer::new(&root, NfsVersion::Auto); + + let mut a = Xdr::new(); + put_fh2(&mut a, ROOT_ID); + let r = s.handle(&call2(1, PROC2_GETATTR, &a.into_bytes())).unwrap(); + let mut c = Cur::new(&r); + for _ in 0..6 { c.u32(); } + assert_eq!(c.u32(), Some(NFS2_OK)); + assert_eq!(c.u32(), Some(2)); // ftype2 == NFDIR + + let mut a = Xdr::new(); + put_fh2(&mut a, ROOT_ID); + a.opaque(b"v2.txt"); + let r = s.handle(&call2(2, PROC2_LOOKUP, &a.into_bytes())).unwrap(); + let mut c = Cur::new(&r); + for _ in 0..6 { c.u32(); } + assert_eq!(c.u32(), Some(NFS2_OK)); + let fid = u64::from_be_bytes(c.fixed(32).unwrap()[..8].try_into().unwrap()); + + let mut a = Xdr::new(); + put_fh2(&mut a, fid); + a.u32(0); a.u32(5); a.u32(0); // offset, count, totalcount + let r = s.handle(&call2(3, PROC2_READ, &a.into_bytes())).unwrap(); + assert!(r.windows(5).any(|w| w == b"hello")); + + let mut a = Xdr::new(); + put_fh2(&mut a, fid); + a.u32(0); a.u32(0); a.u32(4); // beginoffset, offset, totalcount + a.opaque(b"XYZW"); + s.handle(&call2(4, PROC2_WRITE, &a.into_bytes())).unwrap(); + assert_eq!(&std::fs::read(root.join("v2.txt")).unwrap()[..4], b"XYZW"); + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn v2_readdir_pages_within_one_datagram() { + let root = temp_export(); + // Enough entries that the listing can't fit one UDP datagram, so the + // reply MUST be paged. This is the Mac-Downloads case that produced + // "NFS2 readdir failed ... Can't decode result": the old fixed 8000-byte + // budget overran the client's count-sized buffer and spilled into + // multiple IP fragments. + let n: usize = 200; + for i in 0..n { + std::fs::write(root.join(format!("file_{i:03}")), b"x").unwrap(); + } + let mut s = NfsServer::new(&root, NfsVersion::Auto); + + // Decode one v2 READDIR reply: strip the 6-word RPC header + status, walk + // the entry list, return (names, last_cookie, eof). + fn decode(reply: &[u8]) -> (Vec>, [u8; 4], bool) { + let mut c = Cur::new(reply); + for _ in 0..6 { c.u32(); } // RPC accepted-reply header + assert_eq!(c.u32(), Some(NFS2_OK)); + let mut names = Vec::new(); + let mut last_cookie = [0u8; 4]; + while c.u32() == Some(1) { // value_follows; 0 ends the list + c.u32(); // fileid + names.push(c.opaque().unwrap().to_vec()); + last_cookie = c.fixed(4).unwrap().try_into().unwrap(); + } + let eof = c.u32() == Some(1); + (names, last_cookie, eof) + } + + let mut all = Vec::new(); + let mut cookie = [0u8; 4]; + let mut pages: u32 = 0; + loop { + let mut a = Xdr::new(); + put_fh2(&mut a, ROOT_ID); + a.fixed(&cookie); // nfscookie + a.u32(8192); // count — a typical IRIX NFSv2 readdir size + let r = s.handle(&call2(100 + pages, PROC2_READDIR, &a.into_bytes())).unwrap(); + // The whole reply must fit one unfragmented UDP datagram (≤ 1472). + assert!(r.len() <= 1472, "readdir page {pages} is {} bytes — would fragment", r.len()); + let (names, last, eof) = decode(&r); + assert!(!names.is_empty(), "each page must make progress"); + all.extend(names); + pages += 1; + if eof { break; } + cookie = last; + assert!(pages < 50, "paging should terminate"); + } + assert!(pages > 1, "the listing should have needed multiple pages"); + all.sort(); + all.dedup(); + assert_eq!(all.len(), n, "every entry returned exactly once across pages"); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn mount_returns_root_handle() { + let root = temp_export(); + let mut s = NfsServer::new(&root, NfsVersion::Auto); + + // MOUNT v3 MNT "/" -> mountstat3 OK + fhandle3(root) + auth flavors. + let mut req = Xdr::new(); + req.u32(7); req.u32(0); req.u32(2); + req.u32(MOUNT_PROG); req.u32(MOUNT_V3); req.u32(MNTPROC_MNT); + req.u32(0); req.u32(0); req.u32(0); req.u32(0); + req.opaque(b"/"); + let r = s.handle(&req.into_bytes()).unwrap(); + let mut c = Cur::new(&r); + for _ in 0..6 { c.u32(); } // reply header + assert_eq!(c.u32(), Some(0)); // MNT3_OK + let fh = c.opaque().unwrap(); + assert_eq!(u64::from_be_bytes(fh.try_into().unwrap()), ROOT_ID); + assert_eq!(c.u32(), Some(1)); // one auth flavor + assert_eq!(c.u32(), Some(0)); // AUTH_NULL + + // MOUNT v1 MNT -> fhstatus OK + 32-byte fhandle. + let mut req = Xdr::new(); + req.u32(8); req.u32(0); req.u32(2); + req.u32(MOUNT_PROG); req.u32(MOUNT_V1); req.u32(MNTPROC_MNT); + req.u32(0); req.u32(0); req.u32(0); req.u32(0); + req.opaque(b"/"); + let r = s.handle(&req.into_bytes()).unwrap(); + let mut c = Cur::new(&r); + for _ in 0..6 { c.u32(); } + assert_eq!(c.u32(), Some(0)); // fhstatus OK + let fh = c.fixed(32).unwrap(); + assert_eq!(u64::from_be_bytes(fh[..8].try_into().unwrap()), ROOT_ID); + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn version_gating() { + // A V3-only server rejects a v2 call with PROG_MISMATCH. + let root = temp_export(); + let mut s = NfsServer::new(&root, NfsVersion::V3); + let mut a = Xdr::new(); + put_fh2(&mut a, ROOT_ID); + let r = s.handle(&call2(1, PROC2_GETATTR, &a.into_bytes())).unwrap(); + let mut c = Cur::new(&r); + c.u32(); c.u32(); c.u32(); c.u32(); c.u32(); // xid, REPLY, ACCEPTED, verf x2 + assert_eq!(c.u32(), Some(accept::PROG_MISMATCH)); + std::fs::remove_dir_all(&root).ok(); + } +} diff --git a/src/scsi.rs b/src/scsi.rs index 0b99273..40c40bd 100644 --- a/src/scsi.rs +++ b/src/scsi.rs @@ -218,21 +218,60 @@ impl ScsiDevice { self.unit_attention = true; } - /// Commit the COW overlay to the base image. No-op if not using COW. - /// Returns the number of sectors committed, or 0 if direct/no media. + /// Commit the COW overlay into the base image ("apply the changes"). For a + /// raw overlay this copies the dirty sectors in place; for a CHD it rebuilds + /// the base from the diff (recompressing) and reopens a fresh overlay. No-op + /// if not overlaid. Returns a coarse count of what was committed. pub fn cow_commit(&mut self) -> io::Result { - match &mut self.backend { - Some(DiskBackend::Cow(cow)) => cow.commit(), - _ => Ok(0), + if let Some(DiskBackend::Cow(cow)) = &mut self.backend { + return cow.commit(); + } + #[cfg(feature = "chd")] + { + // CHD: rebuild needs the file closed first, so extract the paths, + // drop the backend, flatten, then reopen with the same COW mode. + let info = match &self.backend { + Some(DiskBackend::ChdHd(hd)) if hd.diff_dirty() => { + hd.overlay_paths().map(|(b, d)| (b, d, hd.is_cow())) + } + _ => None, + }; + if let Some((base, diff, cow)) = info { + self.backend = None; + crate::chd_disk::flatten_diff(&base, &diff, &mut |_| {}, &|| false)?; + let base_str = base.to_str() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "non-UTF-8 CHD path"))?; + let reopened = crate::chd_disk::ChdHd::open(base_str, cow)?; + self.backend = Some(DiskBackend::ChdHd(reopened)); + return Ok(1); + } } + Ok(0) } - /// Reset the COW overlay (discard all writes). No-op if not using COW. + /// Reset the COW overlay — discard all uncommitted writes ("roll back"). For + /// a raw overlay this truncates it; for a CHD it deletes the `.diff.chd` and + /// reopens a fresh overlay over the untouched base. No-op if not overlaid. pub fn cow_reset(&mut self) -> io::Result<()> { - match &mut self.backend { - Some(DiskBackend::Cow(cow)) => cow.reset_overlay(), - _ => Ok(()), + if let Some(DiskBackend::Cow(cow)) = &mut self.backend { + return cow.reset_overlay(); + } + #[cfg(feature = "chd")] + { + let info = match &self.backend { + Some(DiskBackend::ChdHd(hd)) => hd.overlay_paths().map(|(b, d)| (b, d, hd.is_cow())), + _ => None, + }; + if let Some((base, diff, cow)) = info { + self.backend = None; + let _ = std::fs::remove_file(&diff); // discard every overlay write + let base_str = base.to_str() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "non-UTF-8 CHD path"))?; + let reopened = crate::chd_disk::ChdHd::open(base_str, cow)?; + self.backend = Some(DiskBackend::ChdHd(reopened)); + } } + Ok(()) } /// Copy the COW overlay into `dest` and return its dirty sector set. @@ -253,17 +292,51 @@ impl ScsiDevice { } } - /// Number of dirty sectors in the COW overlay, or 0 if direct/no media. + /// Number of dirty sectors in the COW overlay (raw), or for a CHD a coarse + /// 1/0 "has uncommitted changes" (we don't track per-sector dirt there). + /// 0 if direct / no media. pub fn cow_dirty_count(&self) -> usize { match &self.backend { Some(DiskBackend::Cow(cow)) => cow.dirty_count(), + #[cfg(feature = "chd")] + Some(DiskBackend::ChdHd(hd)) => usize::from(hd.diff_dirty()), _ => 0, } } - /// Whether this device is using COW overlay mode. + /// Whether this device has a copy-on-write overlay (a raw `.overlay` or a CHD + /// `.diff.chd`) that `cow commit` / `cow reset` can act on. pub fn is_cow(&self) -> bool { - matches!(&self.backend, Some(DiskBackend::Cow(_))) + match &self.backend { + Some(DiskBackend::Cow(_)) => true, + #[cfg(feature = "chd")] + Some(DiskBackend::ChdHd(hd)) => hd.overlay_paths().is_some(), + _ => false, + } + } + + /// `(base, diff)` paths if this device is a CHD writing to a `.diff.chd` + /// sidecar that holds changes worth folding back into the base on a clean + /// shutdown. `None` for in-place / non-CHD / no-media devices. + pub fn pending_chd_sync(&self) -> Option<(std::path::PathBuf, std::path::PathBuf)> { + #[cfg(feature = "chd")] + { + if let Some(DiskBackend::ChdHd(hd)) = &self.backend { + return hd.pending_sync(); + } + } + None + } + + /// Take the pending-sync paths AND release the disk backend, closing the CHD + /// so the base can be atomically rebuilt by `chd_disk::flatten_diff`. Returns + /// `None` and leaves the backend in place when there's nothing to sync. + pub fn take_pending_chd_sync(&mut self) -> Option<(std::path::PathBuf, std::path::PathBuf)> { + let pending = self.pending_chd_sync(); + if pending.is_some() { + self.backend = None; + } + pending } /// Advance to the next disc in the list (wraps around). diff --git a/src/seeq8003.rs b/src/seeq8003.rs index 84ce9b2..c3577ae 100644 --- a/src/seeq8003.rs +++ b/src/seeq8003.rs @@ -263,6 +263,18 @@ impl Seeq8003 { } } + /// Shared NAT control/stats handle (debug toggles, table reset, and the + /// guest-frame counter the GUI's network indicator samples). + pub fn nat_control(&self) -> Arc { + self.nat_ctl.clone() + } + + /// NAT addresses this gateway hands the guest: (client_ip, gateway_ip, + /// netmask) — i.e. what the guest's ec0 should be configured as. + pub fn gateway_addrs(&self) -> (std::net::Ipv4Addr, std::net::Ipv4Addr, std::net::Ipv4Addr) { + (self.config.client_ip, self.config.gateway_ip, self.config.netmask) + } + /// Deassert the interrupt line. Called when the driver writes CLRINT. /// Mark both status registers as OLD so raise_interrupt won't immediately re-raise. pub fn reset_interrupt(&self) { diff --git a/src/wd33c93a.rs b/src/wd33c93a.rs index 1d34506..3429ca1 100644 --- a/src/wd33c93a.rs +++ b/src/wd33c93a.rs @@ -344,10 +344,12 @@ impl Wd33c93a { let sz = cd.size(); (DiskBackend::ChdCd(cd), sz) } else { - // HD CHDs are inherently writable via the libchdman-rs HdImage - // surface (in-place for uncompressed, diff sidecar for - // compressed), so the `overlay` flag is not applicable here. - let hd = ChdHd::open(path)?; + // HD CHD. The `overlay` flag is the per-disk copy-on-write + // toggle: COW on → always overlay (even an uncompressed base + // gets a diff) and never auto-fold on exit (commit/roll back + // manually); COW off → write in place (uncompressed) or a diff + // that auto-folds on a clean exit (compressed). + let hd = ChdHd::open(path, overlay)?; let sz = hd.size(); (DiskBackend::ChdHd(hd), sz) } @@ -488,6 +490,63 @@ impl Wd33c93a { results } + /// Number of attached CHD devices whose `.diff.chd` holds changes pending a + /// fold-back into the base on a clean shutdown. + pub fn pending_chd_sync_count(&self) -> usize { + let state = self.state.lock(); + state.devices.iter().flatten().filter(|d| d.pending_chd_sync().is_some()).count() + } + + /// Fold every pending CHD diff back into its base ("sync"), preserving the + /// base's compression. Releases each disk backend first (closing the CHD), + /// then rebuilds outside the device lock since recompression is slow. + /// `progress(done, total, fraction)` reports per-disk progress; `cancel()` + /// stops before the next disk (the in-flight rebuild also honours it), + /// leaving every un-synced base+diff intact. Returns the count synced. + #[cfg(feature = "chd")] + pub fn sync_chd_disks( + &self, + progress: &mut dyn FnMut(usize, usize, f32), + cancel: &dyn Fn() -> bool, + ) -> std::io::Result { + // Collect pending (base, diff) pairs, releasing CHD handles under the + // lock so the files can be atomically rebuilt. The rebuild itself runs + // unlocked. + let pending: Vec<(std::path::PathBuf, std::path::PathBuf)> = { + let mut state = self.state.lock(); + let mut v = Vec::new(); + for id in 0..8 { + if let Some(dev) = &mut state.devices[id] { + if let Some(pair) = dev.take_pending_chd_sync() { + v.push(pair); + } + } + } + v + }; + let total = pending.len(); + let mut done = 0usize; + for (base, diff) in pending { + if cancel() { + break; + } + progress(done, total, 0.0); + crate::chd_disk::flatten_diff(&base, &diff, &mut |f| progress(done, total, f), cancel)?; + done += 1; + progress(done, total, 1.0); + } + Ok(done) + } + + #[cfg(not(feature = "chd"))] + pub fn sync_chd_disks( + &self, + _progress: &mut dyn FnMut(usize, usize, f32), + _cancel: &dyn Fn() -> bool, + ) -> std::io::Result { + Ok(0) + } + /// Copy every COW overlay into `dir` as `scsi.overlay`. Returns a /// list of `(id, dirty_sector_list)` entries so snapshot save can /// persist the dirty set alongside the raw overlay bytes. diff --git a/src/z85c30.rs b/src/z85c30.rs index 0032100..2a7d87b 100644 --- a/src/z85c30.rs +++ b/src/z85c30.rs @@ -590,10 +590,18 @@ pub struct Z85c30 { // "Send IRIX halt") can type at the console without opening a loopback TCP // client. Independent of whichever backend is installed. inject_b: Arc>>, + // Bounded mirror of channel-B (IRIX serial console) guest output. Lets an + // in-process reader — the GUI's network probe — scrape the console without + // opening the 8881 TCP socket. Oldest bytes drop once full. + console_buf: Arc>>, running: Arc, threads: Arc>>>, } +/// Cap on the channel-B console mirror (`console_buf`). 64 KiB is far more than +/// any single probe response; the buffer is drained on each read. +const CONSOLE_TAP_CAP: usize = 64 * 1024; + impl Z85c30 { /// Default constructor: binds TCP serial backends on 127.0.0.1:8880 /// (channel A / tty2) and 127.0.0.1:8881 (channel B / tty1). @@ -635,6 +643,7 @@ impl Z85c30 { backend_a: Arc::new(Mutex::new(backend_a)), backend_b: Arc::new(Mutex::new(backend_b)), inject_b: Arc::new(Mutex::new(VecDeque::new())), + console_buf: Arc::new(Mutex::new(VecDeque::new())), running: Arc::new(AtomicBool::new(false)), threads: Arc::new(Mutex::new(Vec::new())), } @@ -671,6 +680,13 @@ impl Z85c30 { self.inject_b.lock().extend(data.iter().copied()); } + /// Drain and return the channel-B (IRIX serial console) output captured + /// since the last call. Empties the mirror buffer. Used by the GUI network + /// probe to read the guest's response to an injected command in-process. + pub fn drain_console(&self) -> Vec { + self.console_buf.lock().drain(..).collect() + } + pub fn read_a_control(&self) -> u8 { let mut a = self.channel_a.0.lock(); if a.reg_ptr == 2 { @@ -815,7 +831,10 @@ impl Device for Z85c30 { let tx_channel = channel_arc.clone(); let tx_backend = backend.clone(); let running = self.running.clone(); - + // Channel B (i == 1) is the IRIX serial console — mirror its output + // into console_buf for in-process readers (the GUI network probe). + let tx_tap = if i == 1 { Some(self.console_buf.clone()) } else { None }; + threads.push(thread::Builder::new().name(format!("SCC-TX-{}", ch_name)).spawn(move || { let mut last_tx_time = Instant::now(); let channel_name = { @@ -874,6 +893,11 @@ impl Device for Z85c30 { // Output character crate::dlog_dev!(LogModule::Scc, "SCC: TX({}) '{}' ({:02x})", channel_name, if byte.is_ascii_graphic() { byte as char } else { '.' }, byte); + if let Some(tap) = &tx_tap { + let mut b = tap.lock(); + while b.len() >= CONSOLE_TAP_CAP { b.pop_front(); } + b.push_back(byte); + } tx_backend.send_byte(byte); // Transmission complete: fire Tx interrupt (once) to