From d091e96cb3d9fd41aec3af94312243d4b6803247 Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Wed, 15 Jul 2026 03:31:12 +0900 Subject: [PATCH 01/10] filesys: write support for host directory mounts HOSTFS volumes were read-only: every mutating packet failed with "disk is write protected", which on a booting Workbench means requesters nobody asked for. Implement the write half of the handler -- FINDOUTPUT/FINDUPDATE, WRITE, SET_FILE_SIZE, CREATE_DIR, DELETE_OBJECT, RENAME_OBJECT, SET_DATE -- and report the volume as validated. A created leaf is taken literally so it lands under the name the guest chose, which makes the guest the one picking a host path: names that would escape the mount ("..", separators) or shadow our .uaem sidecars are refused. [[filesys]] readonly = true keeps the old behaviour for a mount that should not change. Co-Authored-By: Claude Opus 4.8 --- copperline.example.toml | 14 +- src/amigaos/dos.rs | 8 + src/config.rs | 6 + src/filesys.rs | 410 ++++++++++++++++++++++++++++++++++++++-- src/video/launcher.rs | 3 + 5 files changed, 424 insertions(+), 17 deletions(-) diff --git a/copperline.example.toml b/copperline.example.toml index 699b7a6..01dd1be 100644 --- a/copperline.example.toml +++ b/copperline.example.toml @@ -244,11 +244,14 @@ video = "PAL" # Host directories mounted directly as AmigaDOS volumes (HOSTFS0:, -# HOSTFS1:, ...), served live with no disk image in between. Read-only for -# now: write operations fail with "disk is write-protected". Up to 16 -# mounts. volume defaults to the directory name. bootpri (-128..127, -# default -128 = never boot) enters the boot-device vote: hard-disk boot -# partitions typically sit at 0 and DF0: at 5. +# HOSTFS1:, ...), served live with no disk image in between: the guest reads +# and writes the host's files directly, and changes land in the directory as +# you would expect. Up to 16 mounts. volume defaults to the directory name. +# bootpri (-128..127, default -128 = never boot) enters the boot-device vote: +# hard-disk boot partitions typically sit at 0 and DF0: at 5. readonly exports +# the directory write-protected: the guest sees a read-only disk and every +# write fails, which is worth setting on anything you would rather the Amiga +# could not damage. # [[filesys]] # path = "/data/amiga/Workbench" # volume = "Workbench" @@ -256,6 +259,7 @@ video = "PAL" # # [[filesys]] # path = "/data/amiga/downloads" +# readonly = true # Presentation options. overscan: "tv" (default; mask deep horizontal diff --git a/src/amigaos/dos.rs b/src/amigaos/dos.rs index ba83d67..4296cfc 100644 --- a/src/amigaos/dos.rs +++ b/src/amigaos/dos.rs @@ -48,12 +48,20 @@ pub const ACTION_EXAMINE_FH: i32 = 1034; // ExamineFH() pub const DOSTRUE: u32 = 0xFFFF_FFFF; pub const DOSFALSE: u32 = 0; pub const ERROR_OBJECT_IN_USE: u32 = 202; +pub const ERROR_OBJECT_EXISTS: u32 = 203; +pub const ERROR_DIRECTORY_NOT_FOUND: u32 = 204; pub const ERROR_OBJECT_NOT_FOUND: u32 = 205; +pub const ERROR_INVALID_COMPONENT_NAME: u32 = 206; pub const ERROR_ACTION_NOT_KNOWN: u32 = 209; pub const ERROR_INVALID_LOCK: u32 = 211; pub const ERROR_OBJECT_WRONG_TYPE: u32 = 212; pub const ERROR_DISK_WRITE_PROTECTED: u32 = 214; +pub const ERROR_RENAME_ACROSS_DEVICES: u32 = 215; +pub const ERROR_DIRECTORY_NOT_EMPTY: u32 = 216; pub const ERROR_SEEK_ERROR: u32 = 219; +pub const ERROR_DISK_FULL: u32 = 221; +pub const ERROR_DELETE_PROTECTED: u32 = 222; +pub const ERROR_WRITE_PROTECTED: u32 = 223; pub const ERROR_NO_MORE_ENTRIES: u32 = 232; /// ACTION_SEEK Arg3 modes (dos/dos.h OFFSET_*). pub const OFFSET_BEGINNING: i32 = -1; diff --git a/src/config.rs b/src/config.rs index e25886c..0e81ac5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1343,6 +1343,10 @@ pub(crate) struct RawFilesysMount { /// volume but never offers it as a boot candidate. #[serde(skip_serializing_if = "Option::is_none")] pub(crate) bootpri: Option, + /// Export the directory write-protected: the guest sees the volume as a + /// read-only disk and every write fails. Defaults to false. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) readonly: Option, } /// `[input]` host-input preferences. Currently just the initial joystick input @@ -2155,6 +2159,7 @@ impl TryFrom for Config { .unwrap_or_else(|| "HostFS".into()) }), boot_pri: m.bootpri.unwrap_or(-128), + readonly: m.readonly.unwrap_or(false), }) .collect(), chipset, @@ -2940,6 +2945,7 @@ mod tests { path: std::path::PathBuf::from("."), volume: volume.to_string(), boot_pri: -128, + readonly: false, }], ..Config::default() }; diff --git a/src/filesys.rs b/src/filesys.rs index 666467f..1a9db95 100644 --- a/src/filesys.rs +++ b/src/filesys.rs @@ -111,6 +111,9 @@ pub struct MountSpec { /// AddBootNode priority: -128 = mounted but never a boot candidate /// (the default); higher beats other boot devices in strap's ranking. pub boot_pri: i8, + /// Refuse every write, answering like a write-protected disk. Off by + /// default: the mount is the host directory, and the guest can change it. + pub readonly: bool, } /// DOS device name of mount `unit` (`HOSTFS0`, `HOSTFS1`, ...). @@ -308,6 +311,13 @@ impl FilesysUnit { self.mount.path.join(&rec.rel) } + /// The error a mutating packet must fail with, or None when the mount takes + /// writes. A `[[filesys]] readonly` mount answers exactly like a + /// write-protected disk. + fn write_refusal(&self) -> Option { + self.mount.readonly.then_some(ERROR_DISK_WRITE_PROTECTED) + } + /// Allocate a FileLock in this unit's board-window sub-pool and register /// it. The handler port and volume node come from the unit. fn alloc_lock( @@ -334,7 +344,19 @@ impl FilesysUnit { /// semantics: an optional `prefix:` is stripped (the supplied lock is /// already the base it named), `/` goes to the parent, and names are /// case-insensitive. + /// Resolve a name that is about to be created: every component but the last + /// must exist (and is matched case-insensitively, like `resolve`), while a + /// missing last component is taken literally so it can be created under the + /// spelling the guest asked for. + fn resolve_for_create(&self, lock_bptr: u32, name: &[u8]) -> Option { + self.resolve_inner(lock_bptr, name, true) + } + fn resolve(&self, lock_bptr: u32, name: &[u8]) -> Option { + self.resolve_inner(lock_bptr, name, false) + } + + fn resolve_inner(&self, lock_bptr: u32, name: &[u8], create_leaf: bool) -> Option { // A lock handed to this unit's handler always belongs to this unit. let mut rel = if lock_bptr != 0 { self.locks.get(&(lock_bptr << 2))?.rel.clone() @@ -366,7 +388,8 @@ impl FilesysUnit { if comps.last() == Some(&&b""[..]) { comps.pop(); } - for comp in comps { + let last = comps.len().saturating_sub(1); + for (i, comp) in comps.iter().enumerate() { if comp.is_empty() { // Leading or doubled '/': up to the parent. if !rel.pop() { @@ -376,7 +399,18 @@ impl FilesysUnit { } let comp = String::from_utf8_lossy(comp).into_owned(); let dir = self.mount.path.join(&rel); - rel.push(match_component(&dir, &comp)?); + match match_component(&dir, &comp) { + Some(existing) => rel.push(existing), + // The leaf may legitimately not exist yet, but a name the host + // would read as a path (or as "here"/"up") never becomes one. + None if create_leaf && i == last && is_creatable_name(&comp) => { + if !dir.is_dir() { + return None; + } + rel.push(&comp); + } + None => return None, + } } Some(LockRec { rel }) } @@ -585,8 +619,12 @@ impl FilesysUnit { let info = InfoData { num_soft_errors: long(0), unit_number: long(self.index as u32), - // Read-only for now: shows as "Read Only" in C:Info. - disk_state: long(ID_WRITE_PROTECTED), + // C:Info prints this as "Read Only" or "Read/Write". + disk_state: long(if self.mount.readonly { + ID_WRITE_PROTECTED + } else { + ID_VALIDATED + }), num_blocks: long(numblocks), num_blocks_used: long(inuse), bytes_per_block: long(blocksize), @@ -907,14 +945,230 @@ impl FilesysUnit { ); (DOSTRUE, 0) } - // Write-family actions: mounts are read-only for now, so the - // proper refusal is "write protected", not "unknown packet". - // Write() and SetFileSize() signal failure with Res1 = -1. - ACTION_WRITE | ACTION_SET_FILE_SIZE => (DOSTRUE, ERROR_DISK_WRITE_PROTECTED), - ACTION_FINDOUTPUT | ACTION_FINDUPDATE | ACTION_CREATE_DIR | ACTION_DELETE_OBJECT - | ACTION_RENAME_OBJECT | ACTION_SET_COMMENT | ACTION_SET_DATE | ACTION_RENAME_DISK => { - (DOSFALSE, ERROR_DISK_WRITE_PROTECTED) + ACTION_FINDOUTPUT | ACTION_FINDUPDATE => { + // Open(MODE_NEWFILE) truncates or creates; Open(MODE_READWRITE) + // opens for update, creating the file if it is not there. + // Arg1 = BPTR FileHandle, Arg2 = lock, Arg3 = BSTR name. + if let Some(err) = self.write_refusal() { + return (DOSFALSE, err); + } + let fh = arg(bus, 1) << 2; + let name_bptr = arg(bus, 3); + let name = read_bstr(bus, name_bptr); + let Some(rec) = self.resolve_for_create(arg(bus, 2), &name) else { + return (DOSFALSE, ERROR_OBJECT_NOT_FOUND); + }; + let path = self.lock_path(&rec); + if path.is_dir() { + return (DOSFALSE, ERROR_OBJECT_WRONG_TYPE); + } + let mut opts = std::fs::OpenOptions::new(); + opts.read(true).write(true).create(true); + if dp_type == ACTION_FINDOUTPUT { + opts.truncate(true); + } + match opts.open(&path) { + Ok(f) => { + self.next_file_key += 1; + let key = self.next_file_key; + self.files.insert(key, (f, rec)); + bus.write_long(fh + FILEHANDLE_ARG1, key); + (DOSTRUE, 0) + } + Err(e) => (DOSFALSE, host_error(&e)), + } + } + ACTION_WRITE => { + // Arg1 = fh_Arg1 cookie, Arg2 = buffer APTR, Arg3 = length. + // Res1 = bytes written, or -1 with Res2 = error. + use std::io::Write; + let key = arg(bus, 1); + let buf = arg(bus, 2); + let len = arg(bus, 3) as usize; + if let Some(err) = self.write_refusal() { + return (DOSTRUE, err); // res1 = -1 + } + let Some((f, _)) = self.files.get_mut(&key) else { + return (DOSTRUE, ERROR_INVALID_LOCK); + }; + // Chunked for the same reason as ACTION_READ: the length comes + // from the guest, so a bogus one must not size a host buffer. + const WRITE_CHUNK: usize = 64 * 1024; + let mut chunk = vec![0u8; len.min(WRITE_CHUNK)]; + let mut done = 0usize; + while done < len { + let want = (len - done).min(WRITE_CHUNK); + for (i, b) in chunk[..want].iter_mut().enumerate() { + *b = bus.read_byte(buf + (done + i) as u32); + } + match f.write_all(&chunk[..want]) { + Ok(()) => done += want, + Err(e) => return (DOSTRUE, host_error(&e)), // res1 = -1 + } + } + (done as u32, 0) + } + ACTION_SET_FILE_SIZE => { + // Arg1 = fh_Arg1, Arg2 = offset, Arg3 = OFFSET_* mode. + // Res1 = the new size, or -1 with Res2 = error. + use std::io::{Seek, SeekFrom}; + let key = arg(bus, 1); + let offset = arg(bus, 2) as i32 as i64; + let mode = arg(bus, 3) as i32; + if let Some(err) = self.write_refusal() { + return (DOSTRUE, err); // res1 = -1 + } + let Some((f, _)) = self.files.get_mut(&key) else { + return (DOSTRUE, ERROR_INVALID_LOCK); + }; + let (pos, end) = match (f.stream_position(), f.metadata()) { + (Ok(p), Ok(m)) => (p as i64, m.len() as i64), + _ => return (DOSTRUE, ERROR_SEEK_ERROR), + }; + let size = match mode { + OFFSET_BEGINNING => offset, + OFFSET_CURRENT => pos + offset, + OFFSET_END => end + offset, + _ => -1, + }; + if size < 0 { + return (DOSTRUE, ERROR_SEEK_ERROR); + } + if let Err(e) = f.set_len(size as u64) { + return (DOSTRUE, host_error(&e)); + } + // Truncating below the file position leaves it past the end; + // DOS expects the position clamped to the new size. + if pos > size && f.seek(SeekFrom::Start(size as u64)).is_err() { + return (DOSTRUE, ERROR_SEEK_ERROR); + } + (size as u32, 0) + } + ACTION_CREATE_DIR => { + // Arg1 = lock, Arg2 = BSTR name. Result is a lock on the new + // directory (the caller frees it). + if let Some(err) = self.write_refusal() { + return (DOSFALSE, err); + } + let name_bptr = arg(bus, 2); + let name = read_bstr(bus, name_bptr); + let Some(rec) = self.resolve_for_create(arg(bus, 1), &name) else { + return (DOSFALSE, ERROR_OBJECT_NOT_FOUND); + }; + let path = self.lock_path(&rec); + if path.exists() { + return (DOSFALSE, ERROR_OBJECT_EXISTS); + } + if let Err(e) = std::fs::create_dir(&path) { + return (DOSFALSE, host_error(&e)); + } + match self.alloc_lock(bus, board_base, ACCESS_READ, rec) { + Some(addr) => (addr >> 2, 0), + None => (DOSFALSE, ERROR_OBJECT_NOT_FOUND), + } + } + ACTION_DELETE_OBJECT => { + // Arg1 = lock, Arg2 = BSTR name. + if let Some(err) = self.write_refusal() { + return (DOSFALSE, err); + } + let name_bptr = arg(bus, 2); + let name = read_bstr(bus, name_bptr); + let Some(rec) = self.resolve(arg(bus, 1), &name) else { + return (DOSFALSE, ERROR_OBJECT_NOT_FOUND); + }; + let path = self.lock_path(&rec); + let meta = match std::fs::symlink_metadata(&path) { + Ok(m) => m, + Err(e) => return (DOSFALSE, host_error(&e)), + }; + let res = if meta.is_dir() { + std::fs::remove_dir(&path) + } else { + std::fs::remove_file(&path) + }; + match res { + Ok(()) => { + // The attribute sidecar belongs to the file, not to the + // guest: it goes with it. + let _ = std::fs::remove_file(uaem_path(&path)); + (DOSTRUE, 0) + } + Err(e) => (DOSFALSE, host_error(&e)), + } + } + ACTION_RENAME_OBJECT => { + // Arg1 = source lock, Arg2 = BSTR source name, + // Arg3 = target dir lock, Arg4 = BSTR target name. Both locks + // reached this handler, so both are on this unit's volume. + if let Some(err) = self.write_refusal() { + return (DOSFALSE, err); + } + let (from_bptr, to_bptr) = (arg(bus, 2), arg(bus, 4)); + let from_name = read_bstr(bus, from_bptr); + let to_name = read_bstr(bus, to_bptr); + let Some(from) = self.resolve(arg(bus, 1), &from_name) else { + return (DOSFALSE, ERROR_OBJECT_NOT_FOUND); + }; + let Some(to) = self.resolve_for_create(arg(bus, 3), &to_name) else { + return (DOSFALSE, ERROR_DIRECTORY_NOT_FOUND); + }; + let (from_path, to_path) = (self.lock_path(&from), self.lock_path(&to)); + // A rename that only changes case is not an overwrite: on a + // case-insensitive host the two paths are the same file. + let renaming_in_place = from_path == to_path; + if to_path.exists() && !renaming_in_place { + return (DOSFALSE, ERROR_OBJECT_EXISTS); + } + match std::fs::rename(&from_path, &to_path) { + Ok(()) => { + let (from_uaem, to_uaem) = (uaem_path(&from_path), uaem_path(&to_path)); + if from_uaem.exists() { + let _ = std::fs::rename(&from_uaem, &to_uaem); + } + (DOSTRUE, 0) + } + Err(e) => (DOSFALSE, host_error(&e)), + } + } + ACTION_SET_DATE => { + // Arg2 = lock, Arg3 = BSTR name, Arg4 = ptr to DateStamp. + if let Some(err) = self.write_refusal() { + return (DOSFALSE, err); + } + let name_bptr = arg(bus, 3); + let name = read_bstr(bus, name_bptr); + let Some(rec) = self.resolve(arg(bus, 2), &name) else { + return (DOSFALSE, ERROR_OBJECT_NOT_FOUND); + }; + let ds = arg(bus, 4); + let (days, mins, ticks) = ( + bus.read_long(ds), + bus.read_long(ds + 4), + bus.read_long(ds + 8), + ); + match set_host_mtime(&self.lock_path(&rec), days, mins, ticks) { + Ok(()) => (DOSTRUE, 0), + Err(e) => (DOSFALSE, host_error(&e)), + } } + ACTION_SET_COMMENT => { + // The host has nowhere to keep an Amiga file comment. Accept it + // so the caller does not fail, and drop it. + // TODO(codewiz): persist comments and protection bits to the + // .uaem sidecar we already read back. + if let Some(err) = self.write_refusal() { + return (DOSFALSE, err); + } + let name_bptr = arg(bus, 3); + let name = read_bstr(bus, name_bptr); + match self.resolve(arg(bus, 2), &name) { + Some(rec) if self.lock_path(&rec).exists() => (DOSTRUE, 0), + _ => (DOSFALSE, ERROR_OBJECT_NOT_FOUND), + } + } + // Relabel: the volume name comes from the config, not the guest. + ACTION_RENAME_DISK => (DOSFALSE, ERROR_ACTION_NOT_KNOWN), _ => { log::debug!( "filesys: {}: unhandled action {dp_type}", @@ -1016,9 +1270,48 @@ struct UaemInfo { /// Read and parse the `.uaem` sidecar of `path`, if any. fn read_uaem(path: &Path) -> Option { + parse_uaem(&std::fs::read(uaem_path(path)).ok()?) +} + +/// The attribute sidecar that belongs to a host path. +fn uaem_path(path: &Path) -> PathBuf { let mut side = path.as_os_str().to_owned(); side.push(".uaem"); - parse_uaem(&std::fs::read(Path::new(&side)).ok()?) + PathBuf::from(side) +} + +/// Map a host I/O error onto the AmigaDOS error the guest expects, so a full +/// disk says "disk full" rather than a generic failure. +fn host_error(e: &std::io::Error) -> u32 { + use std::io::ErrorKind as K; + match e.kind() { + K::NotFound => ERROR_OBJECT_NOT_FOUND, + K::PermissionDenied => ERROR_WRITE_PROTECTED, + K::AlreadyExists => ERROR_OBJECT_EXISTS, + K::DirectoryNotEmpty => ERROR_DIRECTORY_NOT_EMPTY, + K::StorageFull => ERROR_DISK_FULL, + K::CrossesDevices => ERROR_RENAME_ACROSS_DEVICES, + K::InvalidFilename => ERROR_INVALID_COMPONENT_NAME, + _ => ERROR_SEEK_ERROR, + } +} + +/// Stamp a host file with an AmigaDOS DateStamp (days/minutes/ticks since +/// 1978-01-01). The host keeps only the modification time, which is what +/// Examine() reports back. +fn set_host_mtime(path: &Path, days: u32, mins: u32, ticks: u32) -> std::io::Result<()> { + /// Seconds between the Unix epoch and the AmigaDOS epoch. + const AMIGA_EPOCH_OFFSET: u64 = 252_460_800; + let secs = AMIGA_EPOCH_OFFSET + + u64::from(days) * 86_400 + + u64::from(mins) * 60 + + u64::from(ticks) / 50; + let time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs); + std::fs::File::options() + .write(true) + .open(path) + .or_else(|_| std::fs::File::open(path))? + .set_modified(time) } /// Parse a `.uaem` sidecar: one line, eight flag letters ("hsparwed", a @@ -1086,6 +1379,22 @@ fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { /// Case-insensitive component match: prefer the exact host name, else scan /// the directory for a case-insensitive match (AmigaDOS names are /// case-insensitive but case-preserving). +/// Whether a name the guest asked us to create is safe to create verbatim on +/// the host. "." and ".." are ordinary names on AmigaDOS but path operators to +/// the host, and a separator or NUL would let one component become several -- +/// either way the write could land outside the mount. +fn is_creatable_name(comp: &str) -> bool { + !comp.is_empty() + && comp != "." + && comp != ".." + && !comp.contains('/') + && !comp.contains('\\') + && !comp.contains('\0') + // The .uaem sidecars are ours: a guest file by that name would shadow + // another file's attributes and vanish from directory listings. + && !comp.to_ascii_lowercase().ends_with(".uaem") +} + fn match_component(dir: &Path, comp: &str) -> Option { // "." and ".." are not directory shortcuts in AmigaDOS ("/" is the // parent), but the host would honor them and ".." escapes the mount. @@ -1149,6 +1458,7 @@ mod tests { path: "/nonexistent".into(), volume: "Test".into(), boot_pri: -128, + readonly: false, }] } @@ -1218,6 +1528,7 @@ mod tests { path: root.clone(), volume: "Test".into(), boot_pri: -128, + readonly: false, }]); // A lock on Libs, as DOS supplies with opens through the LIBS: // assign. The name still carries the user's "LIBS:" prefix; it @@ -1242,6 +1553,81 @@ mod tests { std::fs::remove_dir_all(&root).unwrap(); } + /// Creating a file resolves its parent like any other lookup, but takes the + /// leaf literally so it lands under the spelling the guest asked for. What + /// it must never do is let that leaf become a path: the guest picks the + /// name, and "..", a separator, or a `.uaem` suffix would write outside the + /// mount or shadow another file's attributes. + #[test] + fn a_created_name_stays_inside_the_mount() { + let root = std::env::temp_dir().join(format!("clfs-create-{}", std::process::id())); + std::fs::create_dir_all(root.join("Sub")).unwrap(); + + let mut hle = FilesysHle::default(); + hle.set_mounts(vec![MountSpec { + path: root.clone(), + volume: "Test".into(), + boot_pri: -128, + readonly: false, + }]); + let unit = &hle.units[0]; + + // A missing leaf under an existing directory is the whole point. + let rec = unit.resolve_for_create(0, b"Sub/brand-new.txt").unwrap(); + assert_eq!(rec.rel, PathBuf::from("Sub/brand-new.txt")); + // The parent still has to exist, and is still matched case-insensitively. + assert_eq!( + unit.resolve_for_create(0, b"SUB/other.txt").unwrap().rel, + PathBuf::from("Sub/other.txt") + ); + assert!(unit.resolve_for_create(0, b"Nope/file.txt").is_none()); + + // None of these may resolve: each would escape the mount or collide + // with our own sidecars. + for escape in [ + &b".."[..], + b"Sub/..", + b"../outside.txt", + b"Sub/../../outside.txt", + b".", + b"ReadMe.txt.uaem", + ] { + assert!( + unit.resolve_for_create(0, escape).is_none(), + "resolved {:?}", + String::from_utf8_lossy(escape) + ); + } + + std::fs::remove_dir_all(&root).unwrap(); + } + + /// A `readonly` mount answers every mutating packet like a write-protected + /// disk, and says so in the volume's InfoData. + #[test] + fn a_readonly_mount_refuses_every_write() { + let mut hle = FilesysHle::default(); + hle.set_mounts(vec![ + MountSpec { + path: "/nonexistent".into(), + volume: "Locked".into(), + boot_pri: -128, + readonly: true, + }, + MountSpec { + path: "/nonexistent".into(), + volume: "Open".into(), + boot_pri: -128, + readonly: false, + }, + ]); + assert_eq!( + hle.units[0].write_refusal(), + Some(ERROR_DISK_WRITE_PROTECTED) + ); + assert_eq!(hle.units[1].write_refusal(), None); + } + #[test] fn uaem_sidecar_parses_flags_date_and_comment() { // A real line written by Amiberry for S/Shell-Startup: script bit diff --git a/src/video/launcher.rs b/src/video/launcher.rs index 7a56a79..271af19 100644 --- a/src/video/launcher.rs +++ b/src/video/launcher.rs @@ -974,6 +974,8 @@ impl MachineSetup { .filter(|s| !s.is_empty()) .map(str::to_string), bootpri: (self.filesys_bootpri[i] != -128).then_some(self.filesys_bootpri[i]), + // Not in the GUI yet: mounts it exports stay writable. + readonly: None, }) }) .chain(self.filesys_extra.iter().cloned()) @@ -2177,6 +2179,7 @@ mod tests { path: path.to_string(), volume: Some(path.trim_start_matches('/').to_uppercase()), bootpri: None, + readonly: None, } } From bcbda7d19bce6fe9f9fae253bf908bf486469812 Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Wed, 15 Jul 2026 10:03:01 +0900 Subject: [PATCH 02/10] filesys: make the create-path test portable to case-insensitive hosts match_component returns the caller's spelling when the host path already exists, so the canonical case of a resolved parent depends on whether the host filesystem folds case. Assert the case-folded path instead of an exact spelling. Co-Authored-By: Claude Opus 4.8 --- src/filesys.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/filesys.rs b/src/filesys.rs index 1a9db95..9216002 100644 --- a/src/filesys.rs +++ b/src/filesys.rs @@ -1576,10 +1576,15 @@ mod tests { let rec = unit.resolve_for_create(0, b"Sub/brand-new.txt").unwrap(); assert_eq!(rec.rel, PathBuf::from("Sub/brand-new.txt")); // The parent still has to exist, and is still matched case-insensitively. - assert_eq!( - unit.resolve_for_create(0, b"SUB/other.txt").unwrap().rel, - PathBuf::from("Sub/other.txt") - ); + // The resolved parent keeps whatever spelling the host reports, which + // differs between case-sensitive and case-insensitive filesystems, so + // only the case-folded path is portable to assert. + let rec = unit.resolve_for_create(0, b"SUB/other.txt").unwrap(); + assert!(rec.rel.file_name().unwrap() == "other.txt"); + assert!(rec + .rel + .to_string_lossy() + .eq_ignore_ascii_case("Sub/other.txt")); assert!(unit.resolve_for_create(0, b"Nope/file.txt").is_none()); // None of these may resolve: each would escape the mount or collide From d366fd570898e6b6aac68cc200e85eb794f02edb Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Wed, 15 Jul 2026 18:54:10 +0900 Subject: [PATCH 03/10] filesys: map host UTF-8 filenames to AmigaDOS Latin-1 Host directory entries were shipped to the guest as raw UTF-8 and guest names were decoded as UTF-8, so any non-ASCII filename (Locale/Catalogs/ francais, espanol, ...) rendered garbled. Convert Latin-1<->UTF-8 at the packet boundary, matching amiberry: names with no Latin-1 spelling are hidden from directory listings rather than mangled. Co-Authored-By: Claude Opus 4.8 --- src/filesys.rs | 85 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 78 insertions(+), 7 deletions(-) diff --git a/src/filesys.rs b/src/filesys.rs index 9216002..7256a59 100644 --- a/src/filesys.rs +++ b/src/filesys.rs @@ -397,7 +397,7 @@ impl FilesysUnit { } continue; } - let comp = String::from_utf8_lossy(comp).into_owned(); + let comp = latin1_to_utf8(comp); let dir = self.mount.path.join(&rel); match match_component(&dir, &comp) { Some(existing) => rel.push(existing), @@ -425,12 +425,16 @@ impl FilesysUnit { ) -> Result<(), u32> { let path = self.lock_path(rec); let meta = std::fs::metadata(&path).map_err(|_| ERROR_OBJECT_NOT_FOUND)?; - let name: String = if rec.rel.as_os_str().is_empty() { - self.mount.volume.clone() + // The volume label is config-supplied ASCII; a leaf name comes from the + // host and is mapped back to Latin-1. Anything the guest could have + // reached is representable (resolve only matches names it could name), + // and dir_listing already hides the rest, so this never loses data here. + let name: Vec = if rec.rel.as_os_str().is_empty() { + self.mount.volume.clone().into_bytes() } else { rec.rel .file_name() - .map(|n| n.to_string_lossy().into_owned()) + .and_then(utf8_to_latin1) .unwrap_or_default() }; let entry_type: i32 = if !meta.is_dir() { @@ -462,7 +466,7 @@ impl FilesysUnit { let fib_data = FileInfoBlock { disk_key: long(disk_key), dir_entry_type: long(entry_type as u32), - file_name: bcpl::<108>(name.as_bytes()), + file_name: bcpl::<108>(&name), protection: long(protection), entry_type: long(entry_type as u32), size: long(meta.len().min(u32::MAX as u64) as u32), @@ -485,6 +489,9 @@ impl FilesysUnit { .flatten() .map(|e| e.file_name()) .filter(|n| !n.as_encoded_bytes().ends_with(b".uaem")) + // Hide names that have no Latin-1 spelling: the guest could neither + // display nor reopen them. amiberry's my_readdir skips them too. + .filter(|n| utf8_to_latin1(n).is_some()) .collect(); names.sort(); names @@ -646,7 +653,7 @@ impl FilesysUnit { log::debug!( "filesys: {}: locate \"{}\" (lock {:#X})", device_name(self.index), - String::from_utf8_lossy(&name), + latin1_to_utf8(&name), arg(bus, 1) ); let Some(rec) = self.resolve(arg(bus, 1), &name) else { @@ -756,7 +763,7 @@ impl FilesysUnit { log::debug!( "filesys: {}: open \"{}\" (lock {:#X})", device_name(self.index), - String::from_utf8_lossy(&name), + latin1_to_utf8(&name), arg(bus, 2) ); let Some(rec) = self.resolve(arg(bus, 2), &name) else { @@ -1376,6 +1383,29 @@ fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { era * 146097 + doe - 719_468 } +// AmigaDOS filenames are ISO-8859-1 (Latin-1); the host filesystem is UTF-8. +// Latin-1 is exactly the first 256 Unicode code points, so both directions are +// a trivial per-character map and need no iconv. This mirrors amiberry's +// osdep/amiberry_filesys.cpp (utf8_to_latin1_string / iso_8859_1_to_utf8) so +// host directory mounts stay interoperable between the two emulators -- names +// that amiberry drops, we drop the same way. + +/// A guest-supplied Latin-1 name -> the host UTF-8 string. Total: every byte is +/// a valid Latin-1 code point. +fn latin1_to_utf8(name: &[u8]) -> String { + name.iter().map(|&b| b as char).collect() +} + +/// A host filename -> AmigaDOS Latin-1 bytes, or None if it contains any +/// character outside Latin-1 (including invalid UTF-8). amiberry hides such +/// entries from the guest rather than mangling them; so do we. +fn utf8_to_latin1(name: &std::ffi::OsStr) -> Option> { + name.to_str()? + .chars() + .map(|c| (u32::from(c) <= 0xff).then_some(c as u8)) + .collect() +} + /// Case-insensitive component match: prefer the exact host name, else scan /// the directory for a case-insensitive match (AmigaDOS names are /// case-insensitive but case-preserving). @@ -1607,6 +1637,47 @@ mod tests { std::fs::remove_dir_all(&root).unwrap(); } + /// Latin-1 <-> UTF-8 mapping matches amiberry: a guest name with high bytes + /// finds its UTF-8 host file, the host name maps back to those same bytes, + /// and a host name outside Latin-1 is hidden from directory listings. + #[test] + fn utf8_host_names_map_to_latin1() { + // "francais" with a c-cedilla (U+00E7): Latin-1 byte 0xE7, UTF-8 0xC3 0xA7. + assert_eq!(latin1_to_utf8(b"fran\xe7ais"), "fran\u{e7}ais"); + assert_eq!( + utf8_to_latin1(std::ffi::OsStr::new("fran\u{e7}ais")), + Some(b"fran\xe7ais".to_vec()) + ); + // Above Latin-1 (U+2603 SNOWMAN): no mapping. + assert_eq!(utf8_to_latin1(std::ffi::OsStr::new("sn\u{2603}w")), None); + + let root = std::env::temp_dir().join(format!("clfs-latin1-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("fran\u{e7}ais"), b"x").unwrap(); + std::fs::write(root.join("sn\u{2603}w"), b"x").unwrap(); + + let mut hle = FilesysHle::default(); + hle.set_mounts(vec![MountSpec { + path: root.clone(), + volume: "Test".into(), + boot_pri: -128, + readonly: false, + }]); + let unit = &hle.units[0]; + + // The guest names it in Latin-1 and reaches the UTF-8 host file. + let rec = unit.resolve(0, b"fran\xe7ais").unwrap(); + assert_eq!(rec.rel, PathBuf::from("fran\u{e7}ais")); + // The listing shows the mappable name and hides the snowman. + let listing = unit.dir_listing(&LockRec { + rel: PathBuf::new(), + }); + assert!(listing.iter().any(|n| n == "fran\u{e7}ais")); + assert!(listing.iter().all(|n| n != "sn\u{2603}w")); + + std::fs::remove_dir_all(&root).unwrap(); + } + /// A `readonly` mount answers every mutating packet like a write-protected /// disk, and says so in the volume's InfoData. #[test] From 50fc695adecd5d2d92913e5ca919ce6366d768d4 Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Wed, 15 Jul 2026 21:51:39 +0900 Subject: [PATCH 04/10] filesys: persist protection, comments, and dates to .uaem sidecars SET_PROTECT/SET_COMMENT were accepted and discarded, so a DOpus copy onto a host mount silently lost the script bit and other non-default attributes. Write them to the .uaem sidecar in amiberry's exact format (deleting the sidecar when attributes return to default), and keep an existing sidecar's datestamp in step on SET_DATE. SET_PROTECT now also refuses on read-only mounts. Co-Authored-By: Claude Opus 4.8 --- src/filesys.rs | 177 +++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 158 insertions(+), 19 deletions(-) diff --git a/src/filesys.rs b/src/filesys.rs index 7256a59..b66bf00 100644 --- a/src/filesys.rs +++ b/src/filesys.rs @@ -742,16 +742,27 @@ impl FilesysUnit { } } ACTION_SET_PROTECT => { - // Arg2 = lock, Arg3 = BSTR name, Arg4 = mask. The host - // filesystem has nowhere faithful to keep Amiga protection - // bits; accept and ignore (like a FAT filesystem would). - // TODO(codewiz): persist to the .uaem sidecar once write - // support lands, so protection round-trips. + // Arg2 = lock, Arg3 = BSTR name, Arg4 = mask (fib_Protection). + // The host mode cannot hold the h/s/p/a bits or a non-default + // rwed set, so anything but the default lands in a .uaem sidecar. + if let Some(err) = self.write_refusal() { + return (DOSFALSE, err); + } let name_bptr = arg(bus, 3); let name = read_bstr(bus, name_bptr); - match self.resolve(arg(bus, 2), &name) { - Some(rec) if self.lock_path(&rec).exists() => (DOSTRUE, 0), - _ => (DOSFALSE, ERROR_OBJECT_NOT_FOUND), + let Some(rec) = self.resolve(arg(bus, 2), &name) else { + return (DOSFALSE, ERROR_OBJECT_NOT_FOUND); + }; + let path = self.lock_path(&rec); + if !path.exists() { + return (DOSFALSE, ERROR_OBJECT_NOT_FOUND); + } + // Keep the existing date and comment; change only protection. + let mut info = read_uaem(&path).unwrap_or_default(); + info.protection = arg(bus, 4) & 0xFF; + match write_uaem(&path, &info) { + Ok(()) => (DOSTRUE, 0), + Err(e) => (DOSFALSE, host_error(&e)), } } ACTION_FINDINPUT => { @@ -1154,24 +1165,45 @@ impl FilesysUnit { bus.read_long(ds + 4), bus.read_long(ds + 8), ); - match set_host_mtime(&self.lock_path(&rec), days, mins, ticks) { - Ok(()) => (DOSTRUE, 0), - Err(e) => (DOSFALSE, host_error(&e)), + let path = self.lock_path(&rec); + if let Err(e) = set_host_mtime(&path, days, mins, ticks) { + return (DOSFALSE, host_error(&e)); } + // If a sidecar already exists (for protection or a comment), keep + // its stored date in step with the new mtime. A file with only a + // date and no other attributes needs no sidecar -- the mtime holds + // it -- so we never create one here. + if let Some(mut info) = read_uaem(&path) { + info.date = Some((days, mins, ticks)); + if let Err(e) = write_uaem(&path, &info) { + return (DOSFALSE, host_error(&e)); + } + } + (DOSTRUE, 0) } ACTION_SET_COMMENT => { - // The host has nowhere to keep an Amiga file comment. Accept it - // so the caller does not fail, and drop it. - // TODO(codewiz): persist comments and protection bits to the - // .uaem sidecar we already read back. + // Arg2 = lock, Arg3 = BSTR name, Arg4 = BSTR comment. The host + // cannot hold a file comment, so it goes in the .uaem sidecar. if let Some(err) = self.write_refusal() { return (DOSFALSE, err); } let name_bptr = arg(bus, 3); let name = read_bstr(bus, name_bptr); - match self.resolve(arg(bus, 2), &name) { - Some(rec) if self.lock_path(&rec).exists() => (DOSTRUE, 0), - _ => (DOSFALSE, ERROR_OBJECT_NOT_FOUND), + let comment_bptr = arg(bus, 4); + let comment = read_bstr(bus, comment_bptr); + let Some(rec) = self.resolve(arg(bus, 2), &name) else { + return (DOSFALSE, ERROR_OBJECT_NOT_FOUND); + }; + let path = self.lock_path(&rec); + if !path.exists() { + return (DOSFALSE, ERROR_OBJECT_NOT_FOUND); + } + // Keep the existing protection and date; change only the comment. + let mut info = read_uaem(&path).unwrap_or_default(); + info.comment = comment; + match write_uaem(&path, &info) { + Ok(()) => (DOSTRUE, 0), + Err(e) => (DOSFALSE, host_error(&e)), } } // Relabel: the volume name comes from the config, not the guest. @@ -1267,8 +1299,10 @@ impl HleHandler for FilesysHle { /// Metadata from a UAE `.uaem` sidecar file: the attributes a host /// filesystem cannot hold (script/pure/archive bits, file comment, exact /// datestamp), written by UAE-family emulators next to the real file. +#[derive(Default)] struct UaemInfo { - /// fib_Protection value (deny-style rwed like the FIB wants). + /// fib_Protection value (deny-style rwed like the FIB wants). 0 is the + /// default (rwed all allowed, no h/s/p/a), which needs no sidecar. protection: u32, /// DateStamp, when the sidecar's timestamp parses. date: Option, @@ -1287,6 +1321,75 @@ fn uaem_path(path: &Path) -> PathBuf { PathBuf::from(side) } +/// Persist `info` to `path`'s `.uaem` sidecar, or delete the sidecar when the +/// attributes are all default (rwed allowed, no h/s/p/a bits, no comment) -- the +/// host file's own mode and mtime then carry everything, so there is nothing to +/// keep. The line format and the write-or-delete rule match amiberry's +/// fsdb_host.cpp, so sidecars stay interoperable between the two emulators. +fn write_uaem(path: &Path, info: &UaemInfo) -> std::io::Result<()> { + let side = uaem_path(path); + if info.protection & 0xFF == 0 && info.comment.is_empty() { + return match std::fs::remove_file(&side) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + other => other, + }; + } + + // The rwed group is stored deny-style in fib_Protection; `^ 0xF` turns it + // back into the "allowed" letters the sidecar shows (the inverse of + // parse_uaem's flip). h/s/p/a are stored directly. + let mode = info.protection ^ 0xF; + let mut line: Vec = (0..8) + .zip(b"hsparwed") + .map(|(i, &l)| if mode & (1 << (7 - i)) != 0 { l } else { b'-' }) + .collect(); + + let (days, mins, ticks) = info.date.unwrap_or_else(|| { + amiga_datestamp(std::fs::metadata(path).ok().and_then(|m| m.modified().ok())) + }); + let (y, m, d) = civil_from_days(days_from_civil(1978, 1, 1) + i64::from(days)); + let text = format!( + " {y:04}-{m:02}-{d:02} {:02}:{:02}:{:02}.{:02}", + mins / 60, + mins % 60, + ticks / 50, + (ticks % 50) * 2, + ); + line.extend_from_slice(text.as_bytes()); + if !info.comment.is_empty() { + line.push(b' '); + // The sidecar body is UTF-8 like the host filenames (amiberry writes + // the comment through the same host-name encoding). + line.extend_from_slice(latin1_to_utf8(&info.comment).as_bytes()); + } + line.push(b'\n'); + + // Write through a temp file and rename so a crash never leaves a truncated + // sidecar in place of the old one (matching amiberry). + let tmp = { + let mut t = side.clone().into_os_string(); + t.push(".tmp"); + PathBuf::from(t) + }; + std::fs::write(&tmp, &line)?; + std::fs::rename(&tmp, &side) +} + +/// The civil date of `z` days since 1970-01-01 (Howard Hinnant's algorithm, +/// the inverse of [`days_from_civil`]). +fn civil_from_days(z: i64) -> (i64, i64, i64) { + let z = z + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + (if m <= 2 { y + 1 } else { y }, m, d) +} + /// Map a host I/O error onto the AmigaDOS error the guest expects, so a full /// disk says "disk full" rather than a generic failure. fn host_error(e: &std::io::Error) -> u32 { @@ -1729,6 +1832,42 @@ mod tests { assert_eq!(info.date, None); } + /// write_uaem produces exactly the byte format amiberry reads, round-trips + /// through parse_uaem, and deletes the sidecar when attributes go default. + #[test] + fn write_uaem_matches_amiberry_and_removes_default() { + let root = std::env::temp_dir().join(format!("clfs-uaem-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let file = root.join("Shell-Startup"); + std::fs::write(&file, b"; script").unwrap(); + + // Script bit + execute denied, a comment, and a fixed datestamp. + let info = UaemInfo { + protection: 0x42, // FIBF_SCRIPT | FIBF_EXECUTE + date: Some((17722, 16 * 60 + 16, 51 * 50 + 32 / 2)), + comment: b"a comment".to_vec(), + }; + write_uaem(&file, &info).unwrap(); + + // Byte-for-byte what amiberry writes. + let raw = std::fs::read(uaem_path(&file)).unwrap(); + assert_eq!(raw, b"-s--rw-d 2026-07-10 16:16:51.32 a comment\n"); + + // ...and it parses back to the same attributes. + let back = read_uaem(&file).unwrap(); + assert_eq!(back.protection, 0x42); + assert_eq!(back.date, info.date); + assert_eq!(back.comment, b"a comment"); + + // Default attributes (rwed, no bits, no comment) delete the sidecar. + write_uaem(&file, &UaemInfo::default()).unwrap(); + assert!(!uaem_path(&file).exists()); + // Removing an already-absent sidecar is not an error. + write_uaem(&file, &UaemInfo::default()).unwrap(); + + std::fs::remove_dir_all(&root).unwrap(); + } + #[test] fn block_scaling_keeps_counts_32bit_sane() { // A small filesystem keeps 512-byte blocks. From 1cc8ad651ee5e8b67f1c7089bdf3d497d278a051 Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Wed, 15 Jul 2026 22:52:53 +0900 Subject: [PATCH 05/10] filesys: keep EXAMINE_NEXT stable when the caller deletes as it walks Delete ALL examines a directory and unlinks each entry as it goes. Our EXAMINE_NEXT indexed a freshly re-read, re-sorted host listing by position, so every unlink shifted the list and the entry that slid into the used slot was skipped. The guest never saw the whole directory, left stragglers, and then could not remove the non-empty directory. Track the cursor by the last name returned instead: the listing is sorted, so "the first name after the last one" is stable even as entries vanish. Cursor state is per directory lock, reset by EXAMINE_OBJECT and freed with the lock. Co-Authored-By: Claude Opus 4.8 --- src/filesys.rs | 70 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 62 insertions(+), 8 deletions(-) diff --git a/src/filesys.rs b/src/filesys.rs index b66bf00..38a7cea 100644 --- a/src/filesys.rs +++ b/src/filesys.rs @@ -229,6 +229,10 @@ struct FilesysUnit { next_file_key: u32, /// Guest FileLock address -> what it locks. locks: HashMap, + /// EXAMINE_NEXT cursor per directory lock: the last child name handed out. + /// Positioning by name (not a list index) keeps enumeration stable when the + /// caller deletes entries as it goes, as `Delete ALL` does. + examine: HashMap, /// This unit's fixed slice of the board-window FileLock pool. pool: LockPool, } @@ -244,6 +248,7 @@ impl FilesysUnit { files: HashMap::new(), next_file_key: 0, locks: HashMap::new(), + examine: HashMap::new(), pool: LockPool::new(pool_base, pool_end), } } @@ -671,14 +676,18 @@ impl FilesysUnit { ACTION_FREE_LOCK => { let addr = arg(bus, 1) << 2; if addr != 0 && self.locks.remove(&addr).is_some() { + self.examine.remove(&addr); self.pool.release(addr); } (DOSTRUE, 0) } ACTION_EXAMINE_OBJECT => { - let Some(rec) = self.locks.get(&(arg(bus, 1) << 2)).cloned() else { + let lock = arg(bus, 1) << 2; + let Some(rec) = self.locks.get(&lock).cloned() else { return (DOSFALSE, ERROR_OBJECT_NOT_FOUND); }; + // Examine restarts any enumeration on this lock. + self.examine.remove(&lock); let fib = arg(bus, 2) << 2; match self.fill_fib(bus, fib, &rec, 0) { Ok(()) => (DOSTRUE, 0), @@ -686,21 +695,28 @@ impl FilesysUnit { } } ACTION_EXAMINE_NEXT => { - let Some(rec) = self.locks.get(&(arg(bus, 1) << 2)).cloned() else { + let lock = arg(bus, 1) << 2; + let Some(rec) = self.locks.get(&lock).cloned() else { return (DOSFALSE, ERROR_NO_MORE_ENTRIES); }; let fib = arg(bus, 2) << 2; - // The enumeration cursor lives in fib_DiskKey, where the - // previous EXAMINE_OBJECT/EXAMINE_NEXT left it. - let index = bus.read_long(fib) as usize; + // Position by the last name handed out, not a list index: a + // caller that deletes each entry as it examines (Delete ALL) + // shrinks the host directory under us, so an index into the + // re-read, re-sorted listing would skip whatever slid into the + // used slot. The listing is sorted, so "the first name after the + // last one" is a cursor that survives the entries vanishing. let names = self.dir_listing(&rec); - let Some(name) = names.get(index) else { + let last = self.examine.get(&lock).cloned(); + let Some(name) = examine_after(&names, last.as_deref()).cloned() else { + self.examine.remove(&lock); return (DOSFALSE, ERROR_NO_MORE_ENTRIES); }; let child = LockRec { - rel: rec.rel.join(name), + rel: rec.rel.join(&name), }; - match self.fill_fib(bus, fib, &child, index as u32 + 1) { + self.examine.insert(lock, name); + match self.fill_fib(bus, fib, &child, 0) { Ok(()) => (DOSTRUE, 0), Err(e) => (DOSFALSE, e), } @@ -1509,6 +1525,21 @@ fn utf8_to_latin1(name: &std::ffi::OsStr) -> Option> { .collect() } +/// The next EXAMINE_NEXT entry from a sorted listing given the last name handed +/// out (`None` to start). Positioning by name rather than index keeps a walk +/// stable when the caller deletes each entry as it goes: the entry that slides +/// into a vacated slot is never skipped, and the just-deleted name still orders +/// the search correctly even though it is gone from `names`. +fn examine_after<'a>( + names: &'a [std::ffi::OsString], + last: Option<&std::ffi::OsStr>, +) -> Option<&'a std::ffi::OsString> { + match last { + None => names.first(), + Some(l) => names.iter().find(|n| n.as_os_str() > l), + } +} + /// Case-insensitive component match: prefer the exact host name, else scan /// the directory for a case-insensitive match (AmigaDOS names are /// case-insensitive but case-preserving). @@ -1781,6 +1812,29 @@ mod tests { std::fs::remove_dir_all(&root).unwrap(); } + /// EXAMINE_NEXT must visit every entry exactly once even when the caller + /// deletes each one as it is examined (what `Delete ALL` does). Walking a + /// re-read listing by index skips whatever slides into the vacated slot; + /// walking by last-name-returned does not. + #[test] + fn examine_next_survives_deletion_mid_walk() { + let mut remaining: Vec = ["a", "b", "c", "d", "e", "f"] + .iter() + .map(Into::into) + .collect(); + let mut visited = Vec::new(); + let mut last: Option = None; + // Re-read the (shrinking) listing each step, exactly as the handler does. + while let Some(name) = examine_after(&remaining, last.as_deref()).cloned() { + visited.push(name.to_string_lossy().into_owned()); + last = Some(name.clone()); + // The caller deletes the entry it just examined. + remaining.retain(|n| *n != name); + } + assert_eq!(visited, ["a", "b", "c", "d", "e", "f"]); + assert!(remaining.is_empty()); + } + /// A `readonly` mount answers every mutating packet like a write-protected /// disk, and says so in the volume's InfoData. #[test] From 2c87401a406bb4cdc40f96a1f5d812be61808eb5 Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Thu, 16 Jul 2026 08:53:31 +0900 Subject: [PATCH 06/10] launcher: Access spinner (read-write/read-only) for host FS mounts The launcher dropped a hand-written readonly = true on save: from_raw never read the flag and to_raw always emitted None, silently making the mount writable. Carry it through and let each HOSTFS slot cycle between Read-write and Read-only, emitted like bootpri (only when set). Co-Authored-By: Claude Fable 5 --- src/video/launcher.rs | 78 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 71 insertions(+), 7 deletions(-) diff --git a/src/video/launcher.rs b/src/video/launcher.rs index 271af19..6c72ec0 100644 --- a/src/video/launcher.rs +++ b/src/video/launcher.rs @@ -264,12 +264,16 @@ pub enum LauncherField { // Host FS mounts (the GUI edits the first FILESYS_GUI_SLOTS entries) Filesys0Dir, Filesys0Boot, + Filesys0ReadOnly, Filesys1Dir, Filesys1Boot, + Filesys1ReadOnly, Filesys2Dir, Filesys2Boot, + Filesys2ReadOnly, Filesys3Dir, Filesys3Boot, + Filesys3ReadOnly, // CD CdImage, CdInsertDelay, @@ -343,6 +347,17 @@ fn filesys_slot(field: LauncherField) -> Option<(usize, bool)> { }) } +/// The Host FS mount slot of an Access (read-only) spinner field. +fn filesys_readonly_slot(field: LauncherField) -> Option { + Some(match field { + LauncherField::Filesys0ReadOnly => 0, + LauncherField::Filesys1ReadOnly => 1, + LauncherField::Filesys2ReadOnly => 2, + LauncherField::Filesys3ReadOnly => 3, + _ => return None, + }) +} + impl LauncherField { /// Whether this field is a Host FS mount's directory (folder picker), /// as opposed to a boot-priority stepper or any other field. @@ -407,15 +422,19 @@ const STORAGE_ROWS: [Row; 12] = [ row(F::ScsiUnit5, "SCSI unit 5", Drive), row(F::ScsiUnit6, "SCSI unit 6", Drive), ]; -const HOSTFS_ROWS: [Row; 8] = [ +const HOSTFS_ROWS: [Row; 12] = [ row(F::Filesys0Dir, "HOSTFS0", Drive), row(F::Filesys0Boot, " Boot priority", Cycle), + row(F::Filesys0ReadOnly, " Access", Cycle), row(F::Filesys1Dir, "HOSTFS1", Drive), row(F::Filesys1Boot, " Boot priority", Cycle), + row(F::Filesys1ReadOnly, " Access", Cycle), row(F::Filesys2Dir, "HOSTFS2", Drive), row(F::Filesys2Boot, " Boot priority", Cycle), + row(F::Filesys2ReadOnly, " Access", Cycle), row(F::Filesys3Dir, "HOSTFS3", Drive), row(F::Filesys3Boot, " Boot priority", Cycle), + row(F::Filesys3ReadOnly, " Access", Cycle), ]; const CD_ROWS: [Row; 3] = [ row(F::CdImage, "CD image", PathRow), @@ -630,6 +649,7 @@ pub struct MachineSetup { filesys_dirs: [Option; FILESYS_GUI_SLOTS], filesys_names: [Option; FILESYS_GUI_SLOTS], filesys_bootpri: [i8; FILESYS_GUI_SLOTS], + filesys_readonly: [bool; FILESYS_GUI_SLOTS], filesys_extra: Vec, // CD cd_image: Option, @@ -738,6 +758,9 @@ impl MachineSetup { filesys_bootpri: std::array::from_fn(|i| { raw.filesys.get(i).and_then(|m| m.bootpri).unwrap_or(-128) }), + filesys_readonly: std::array::from_fn(|i| { + raw.filesys.get(i).and_then(|m| m.readonly).unwrap_or(false) + }), filesys_extra: raw .filesys .iter() @@ -974,8 +997,9 @@ impl MachineSetup { .filter(|s| !s.is_empty()) .map(str::to_string), bootpri: (self.filesys_bootpri[i] != -128).then_some(self.filesys_bootpri[i]), - // Not in the GUI yet: mounts it exports stay writable. - readonly: None, + // Emitted only when set, like bootpri: writable is the + // default, so an untouched config stays as written. + readonly: self.filesys_readonly[i].then_some(true), }) }) .chain(self.filesys_extra.iter().cloned()) @@ -1213,11 +1237,19 @@ impl MachineSetup { F::Df1Image | F::Df1WriteProtect => reason(self.floppy_drives >= 2, "drive off"), F::Df2Image | F::Df2WriteProtect => reason(self.floppy_drives >= 3, "drive off"), F::Df3Image | F::Df3WriteProtect => reason(self.floppy_drives >= 4, "drive off"), - // A boot priority is meaningless without a directory to boot. + // A boot priority or read-only flag is meaningless without a + // directory to mount. F::Filesys0Boot | F::Filesys1Boot | F::Filesys2Boot | F::Filesys3Boot => { let (slot, _) = filesys_slot(field).expect("boot field"); reason(self.filesys_dirs[slot].is_some(), "no directory") } + F::Filesys0ReadOnly + | F::Filesys1ReadOnly + | F::Filesys2ReadOnly + | F::Filesys3ReadOnly => { + let slot = filesys_readonly_slot(field).expect("readonly field"); + reason(self.filesys_dirs[slot].is_some(), "no directory") + } #[cfg(feature = "midi")] F::MidiOut | F::MidiIn => reason(self.serial_mode == SerialMode::Midi, "MIDI off"), // Channel mode and separation shape the output, so they do nothing @@ -1435,6 +1467,17 @@ impl MachineSetup { pri => pri.to_string(), } } + F::Filesys0ReadOnly + | F::Filesys1ReadOnly + | F::Filesys2ReadOnly + | F::Filesys3ReadOnly => { + let slot = filesys_readonly_slot(field).expect("readonly field"); + if self.filesys_readonly[slot] { + "Read-only".to_string() + } else { + "Read-write".to_string() + } + } // Path/drive fields: the file name, or a placeholder. F::Rom => self.path_label(field, "(bundled AROS)"), _ if rows_contains_kind(field, RowKind::Path) @@ -1551,6 +1594,9 @@ impl MachineSetup { _ => { if let Some((slot, true)) = filesys_slot(field) { self.filesys_bootpri[slot] = cycle_bootpri(self.filesys_bootpri[slot], forward); + } else if let Some(slot) = filesys_readonly_slot(field) { + // Two values: either direction lands on the other one. + self.filesys_readonly[slot] = !self.filesys_readonly[slot]; } } } @@ -1637,9 +1683,10 @@ impl MachineSetup { _ => { if let Some((slot, false)) = filesys_slot(field) { self.filesys_dirs[slot] = None; - // Boot priority on a mount with no directory is - // meaningless; reset it so a cleared slot emits nothing. + // Boot priority or read-only on a mount with no directory is + // meaningless; reset both so a cleared slot emits nothing. self.filesys_bootpri[slot] = -128; + self.filesys_readonly[slot] = false; } } } @@ -2185,10 +2232,12 @@ mod tests { #[test] fn host_mounts_round_trip_and_keep_entries_past_the_gui_slots() { - let raw = RawConfig { + let mut raw = RawConfig { filesys: (0..6).map(|i| raw_mount(&format!("/host{i}"))).collect(), ..RawConfig::default() }; + // A hand-written readonly flag on a GUI-slot mount must survive a save. + raw.filesys[0].readonly = Some(true); let mut setup = MachineSetup::from_raw(&raw).unwrap(); // The GUI edits the first FILESYS_GUI_SLOTS mounts; the rest are held @@ -2200,6 +2249,21 @@ mod tests { // An untouched save is a faithful round trip. assert_eq!(setup.to_raw().filesys, raw.filesys); + // The Access spinner flips between the two modes; a writable mount + // emits no readonly key at all rather than an explicit false. + assert_eq!( + setup.value_label(LauncherField::Filesys0ReadOnly), + "Read-only" + ); + setup.cycle(LauncherField::Filesys0ReadOnly, true); + assert_eq!( + setup.value_label(LauncherField::Filesys0ReadOnly), + "Read-write" + ); + assert_eq!(setup.to_raw().filesys[0].readonly, None); + setup.cycle(LauncherField::Filesys0ReadOnly, false); + assert_eq!(setup.to_raw().filesys[0].readonly, Some(true)); + // Clearing a slot removes that mount. HOSTFS is the position in the // config, so the mounts after it renumber, exactly as they would if the // entry were deleted from the TOML by hand. From a404d33dfa1996780c70087b71665f6e96d0451d Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Thu, 16 Jul 2026 08:54:31 +0900 Subject: [PATCH 07/10] filesys: honor the delete-protection bit on ACTION_DELETE_OBJECT The d bit persisted in the .uaem sidecar was never enforced, so a "Protect -d" file deleted anyway. Refuse with ERROR_DELETE_PROTECTED as the OS4 autodoc specifies for EXDF_NO_DELETE; residual host permission failures keep ERROR_WRITE_PROTECTED, which the autodoc also sanctions ("a delete operation on a file also implies a write"). Co-Authored-By: Claude Fable 5 --- src/filesys.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/filesys.rs b/src/filesys.rs index 38a7cea..aaddab2 100644 --- a/src/filesys.rs +++ b/src/filesys.rs @@ -1116,6 +1116,16 @@ impl FilesysUnit { Ok(m) => m, Err(e) => return (DOSFALSE, host_error(&e)), }; + // The object's own delete-protection bit (kept in the .uaem + // sidecar) refuses the delete, per the ACTION_DELETE_OBJECT + // autodoc. A residual host permission failure stays + // ERROR_WRITE_PROTECTED via host_error, which the same autodoc + // sanctions ("a delete operation on a file also implies a + // write"); the whole-volume case returns + // ERROR_DISK_WRITE_PROTECTED through write_refusal above. + if read_uaem(&path).is_some_and(|u| u.protection & FIBF_DELETE != 0) { + return (DOSFALSE, ERROR_DELETE_PROTECTED); + } let res = if meta.is_dir() { std::fs::remove_dir(&path) } else { From 87a8a2a7e2d0a7725a76394d1cf268be34fa2fcb Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Thu, 16 Jul 2026 08:57:49 +0900 Subject: [PATCH 08/10] filesys: fail on out-of-range guest dates instead of panicking SystemTime addition panics when the sum exceeds the platform's time representation, so a garbage DateStamp in ACTION_SET_DATE could crash the emulator on hosts with a narrow FILETIME range. Co-Authored-By: Claude Fable 5 --- src/filesys.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/filesys.rs b/src/filesys.rs index aaddab2..b0a8066 100644 --- a/src/filesys.rs +++ b/src/filesys.rs @@ -1442,7 +1442,13 @@ fn set_host_mtime(path: &Path, days: u32, mins: u32, ticks: u32) -> std::io::Res + u64::from(days) * 86_400 + u64::from(mins) * 60 + u64::from(ticks) / 50; - let time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs); + // checked_add: `+` panics when the sum exceeds the platform's time + // representation (Windows FILETIME tops out around year 30828, and a + // garbage guest DateStamp reaches far beyond), and a bad date from the + // guest must fail the packet, not the emulator. + let time = std::time::UNIX_EPOCH + .checked_add(std::time::Duration::from_secs(secs)) + .ok_or(std::io::ErrorKind::InvalidInput)?; std::fs::File::options() .write(true) .open(path) From 4c2a0705aa68e51968a4478c83e1cbefbf5c147f Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Thu, 16 Jul 2026 09:00:39 +0900 Subject: [PATCH 09/10] filesys: document the symlink policy and the quadratic EXAMINE_NEXT walk Both are deliberate: host symlinks are followed under the UAE-family trust model (only the host user can create one), and the per-call directory listing is what keeps EXAMINE_NEXT correct under Delete ALL; ACTION_EXAMINE_ALL is the fast path for software that cares. Co-Authored-By: Claude Fable 5 --- src/filesys.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/filesys.rs b/src/filesys.rs index b0a8066..f30494d 100644 --- a/src/filesys.rs +++ b/src/filesys.rs @@ -404,6 +404,12 @@ impl FilesysUnit { } let comp = latin1_to_utf8(comp); let dir = self.mount.path.join(&rel); + // Host symlinks are followed, wherever they point: the guest has no + // packet that creates one, so a symlink inside the mount was placed + // there by the host user, and grafting an outside directory into a + // mount that way is a feature (same trust model as the UAE family). + // The escapes we do block ("..", separators) are the ones a guest + // program could construct on its own. match match_component(&dir, &comp) { Some(existing) => rel.push(existing), // The leaf may legitimately not exist yet, but a name the host @@ -485,8 +491,12 @@ impl FilesysUnit { /// Sorted directory listing used by EXAMINE_NEXT, hiding the `.uaem` /// metadata sidecars (their contents surface as the companion file's - /// attributes instead). Recomputed per call: simple and correct for - /// interactive use; cache if it ever shows up. + /// attributes instead). Recomputed per call, which makes a full + /// EXAMINE_NEXT walk quadratic in the directory size -- deliberately so: + /// the fresh listing is what keeps a walk correct while the caller + /// mutates the directory (Delete ALL), and EXAMINE_NEXT is the legacy + /// slow path anyway, superseded by ACTION_EXAMINE_ALL for software that + /// cares about speed. Revisit only if a real workload hurts. fn dir_listing(&self, rec: &LockRec) -> Vec { let mut names: Vec<_> = std::fs::read_dir(self.lock_path(rec)) .into_iter() From cd691948dee6a7ac8e3dab61656db0e1c934ff1d Mon Sep 17 00:00:00 2001 From: Bernie Innocenti Date: Thu, 16 Jul 2026 12:14:02 +0900 Subject: [PATCH 10/10] filesys: keep DateStamp ticks across the host mtime SetFileDate stored whole seconds and Examine read whole seconds, so a copy that clones datestamps (DOpus) lost the sub-second part. A tick is 1/50 s: store it in the mtime nanoseconds and read it back, so stamps round-trip at the DateStamp's native 20 ms resolution. Co-Authored-By: Claude Fable 5 --- src/amigaos/dos.rs | 12 ++++++------ src/filesys.rs | 24 +++++++++++++++++++++++- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/amigaos/dos.rs b/src/amigaos/dos.rs index 4296cfc..27c7291 100644 --- a/src/amigaos/dos.rs +++ b/src/amigaos/dos.rs @@ -216,19 +216,19 @@ pub fn read_bstr(bus: &mut dyn AddressBus, bptr: u32) -> Vec { /// An AmigaDOS DateStamp: days/minutes/ticks since 1978-01-01. pub type DateStamp = (u32, u32, u32); -/// Host mtime -> AmigaDOS DateStamp. +/// Host mtime -> AmigaDOS DateStamp, at the DateStamp's native resolution: +/// a tick is 1/50 s, so the host's sub-second part survives to 20 ms. pub fn amiga_datestamp(time: Option) -> DateStamp { /// Seconds between the Unix epoch and the AmigaDOS epoch. const AMIGA_EPOCH_OFFSET: u64 = 252_460_800; - let secs = time + let since_epoch = time .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_secs()) - .unwrap_or(0) - .saturating_sub(AMIGA_EPOCH_OFFSET); + .unwrap_or_default(); + let secs = since_epoch.as_secs().saturating_sub(AMIGA_EPOCH_OFFSET); ( (secs / 86_400) as u32, (secs % 86_400 / 60) as u32, - (secs % 60) as u32 * 50, + (secs % 60) as u32 * 50 + since_epoch.subsec_nanos() / 20_000_000, ) } diff --git a/src/filesys.rs b/src/filesys.rs index f30494d..8e3ca7c 100644 --- a/src/filesys.rs +++ b/src/filesys.rs @@ -1452,12 +1452,15 @@ fn set_host_mtime(path: &Path, days: u32, mins: u32, ticks: u32) -> std::io::Res + u64::from(days) * 86_400 + u64::from(mins) * 60 + u64::from(ticks) / 50; + // A tick is 1/50 s = 20 ms: keep the sub-second part, so a DateStamp + // round-trips through the host mtime at its native resolution. + let nanos = (ticks % 50) * 20_000_000; // checked_add: `+` panics when the sum exceeds the platform's time // representation (Windows FILETIME tops out around year 30828, and a // garbage guest DateStamp reaches far beyond), and a bad date from the // guest must fail the packet, not the emulator. let time = std::time::UNIX_EPOCH - .checked_add(std::time::Duration::from_secs(secs)) + .checked_add(std::time::Duration::new(secs, nanos)) .ok_or(std::io::ErrorKind::InvalidInput)?; std::fs::File::options() .write(true) @@ -1887,6 +1890,25 @@ mod tests { assert_eq!(hle.units[1].write_refusal(), None); } + /// A DateStamp survives the trip through the host mtime at its native + /// 1/50 s resolution: SetFileDate then Examine must return the same + /// ticks, not the value truncated to whole seconds. + #[test] + fn datestamp_round_trips_subsecond_through_host_mtime() { + let root = std::env::temp_dir().join(format!("clfs-ticks-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let file = root.join("stamped"); + std::fs::write(&file, b"x").unwrap(); + + // 2026-07-16 12:34:56.84: 42 ticks past the second. + let stamp = (17743, 12 * 60 + 34, 56 * 50 + 42); + set_host_mtime(&file, stamp.0, stamp.1, stamp.2).unwrap(); + let mtime = std::fs::metadata(&file).unwrap().modified().ok(); + assert_eq!(amiga_datestamp(mtime), stamp); + + std::fs::remove_dir_all(&root).unwrap(); + } + #[test] fn uaem_sidecar_parses_flags_date_and_comment() { // A real line written by Amiberry for S/Shell-Startup: script bit