From 6408cdbe391942d9880045a38b6155e80da38e10 Mon Sep 17 00:00:00 2001 From: Simon Dick Date: Sun, 6 Sep 2026 09:26:56 +0100 Subject: [PATCH 1/7] Add partition mode: resolve NAME: to device/unit/range via the DosList devsoak DH1: -d [options] resolves the DOS device name to its exec device/unit and OpenDevice flags via the DosList (LockDosList/ FindDosEntry on V36+, a Forbid()-protected walk of DOSBase->dl_Root->rn_Info->di_DevInfo on Kickstart 1.3), validates the FileSysStartupMsg/DosEnvec hard before trusting any of it, and turns the partition's own cylinder/surface/blocks-per-track extent into a device-sector range exactly like -r. -r is still accepted in this mode as a bounds-checked sub-range relative to the partition start. Also reads the partition's boot sector for a filesystem-signature hint used by the next commit's confirmation tiers. dos.library's LockDosList/UnLockDosList/FindDosEntry/NextDosEntry/DoPkt have no proto/inline glue under -mcrt=nix13's ndk13-include tree (their structs and #defines are present; only these five V36+ calls are omitted), so they are hand-rolled LVO jsrs, the same approach output.c already uses for RawPutChar. --- Makefile | 2 +- src/args.c | 43 ++++-- src/devsoak.h | 40 ++++- src/dosdev.c | 412 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.c | 149 +++++++++++++++++- 5 files changed, 631 insertions(+), 15 deletions(-) create mode 100644 src/dosdev.c diff --git a/Makefile b/Makefile index 16dad65..388b127 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ SRCDIR := src OBJDIR := obj SOURCES := main.c args.c output.c timer.c content.c ring.c ops.c buf.c \ engine.c stripe.c stats.c worker.c audit.c invariant.c quirks.c \ - scsicmd.c removable.c soft64.c + scsicmd.c removable.c soft64.c dosdev.c OBJECTS := $(SOURCES:%.c=$(OBJDIR)/%.o) TARGET := devsoak diff --git a/src/args.c b/src/args.c index 6a6e502..4a2a311 100644 --- a/src/args.c +++ b/src/args.c @@ -200,7 +200,9 @@ LONG args_parse(int argc, char **argv) { int i; + int optstart; ULONG u; + size_t l; memset(&cfg, 0, sizeof(cfg)); cfg.duration_s = 60; @@ -212,19 +214,31 @@ args_parse(int argc, char **argv) cfg.watchdog_s = 5; cfg.outmode = OUT_CON; - if (argc < 3) { - seterr("usage: devsoak DEVICE UNIT -d -r START,LEN [options]", NULL); - return 1; - } - - cfg.device = argv[1]; - if (parse_ulong(argv[2], &u) != 0) { - seterr("bad UNIT: ", argv[2]); - return 1; + /* second form: `devsoak DH1: -d [options]` -- a single positional + * argument ending in ':' is a DOS device name; device/unit/range are + * resolved from the mount later (dosdev.c, main.c -- args_parse must + * never print, so no resolution happens here). */ + l = (argc >= 2) ? strlen(argv[1]) : 0; + if (l > 1 && argv[1][l - 1] == ':') { + cfg.partition = 1; + cfg.dosdev = argv[1]; + optstart = 2; + } else { + if (argc < 3) { + seterr("usage: devsoak DEVICE UNIT -d -r START,LEN [options], " + "or: devsoak NAME: -d [options]", NULL); + return 1; + } + cfg.device = argv[1]; + if (parse_ulong(argv[2], &u) != 0) { + seterr("bad UNIT: ", argv[2]); + return 1; + } + cfg.unit = (LONG)u; + optstart = 3; } - cfg.unit = (LONG)u; - for (i = 3; i < argc; i++) { + for (i = optstart; i < argc; i++) { char *arg = argv[i]; if (strcmp(arg, "-d") == 0) { @@ -349,7 +363,10 @@ args_parse(int argc, char **argv) seterr("-d is required (devsoak is destructive by design)", NULL); return 1; } - if (!cfg.have_range) { + /* partition mode: -r is optional (a relative sub-range of the + * partition, resolved once the mount is looked up); otherwise + * it is the whole test range and is required as before. */ + if (!cfg.partition && !cfg.have_range) { seterr("-r START,LEN is required", NULL); return 1; } @@ -362,9 +379,11 @@ void args_usage(void) { out_printf("devsoak DEVICE UNIT -d -r START,LEN [options]"); + out_printf("devsoak NAME: -d [options] (partition mode; see README)"); out_printf(""); out_printf(" -d destructive (required)"); out_printf(" -r START,LEN test range in sectors (required); LEN may use K/M/G suffix"); + out_printf(" partition mode: optional, relative to the partition start"); out_printf(" -t DURATION e.g. 30s, 20m, 8h (default 60s)"); out_printf(" -w N worker tasks (default 4)"); out_printf(" -q N outstanding requests per worker (default 4)"); diff --git a/src/devsoak.h b/src/devsoak.h index 2164155..4844156 100644 --- a/src/devsoak.h +++ b/src/devsoak.h @@ -90,7 +90,10 @@ struct NSDQueryResult { struct Config { char *device; /* device name, e.g. "uaehf.device" */ LONG unit; - U64 range_start; /* -r, in sectors */ + UBYTE partition; /* positional arg was NAME: (partition mode) */ + char *dosdev; /* the raw NAME: argument, incl. ':' */ + U64 range_start; /* -r, in sectors (partition mode: relative + to the partition start until resolved) */ U64 range_len; /* -r, in sectors */ ULONG duration_s; /* -t, seconds (default 60) */ ULONG workers; /* -w (default 4) */ @@ -149,6 +152,41 @@ struct DevUnderTest { extern struct Config cfg; extern struct DevUnderTest dev; +/* ---- dosdev.c: partition mode (`devsoak DH1: -d [options]`) ---- + * Resolves a DOS device name (a single positional argument ending in ':') + * to the exec device/unit and partition extent it is mounted on, by + * walking the DosList: LockDosList()/FindDosEntry() on V36+, a manual + * Forbid()-protected walk of DOSBase->dl_Root->rn_Info->di_DevInfo on + * Kickstart 1.3 (same struct DosList layout either way -- BCPL BPTR/BSTR + * fields throughout, hence BADDR() everywhere below). Every field is + * validated before use (§ args_parse must never print, so all of this + * runs from main() after out_init(), and prints its own one-line reason + * on failure). Callers treat a nonzero return as RC_FATAL. */ + +struct PartInfo { + char devname[64]; /* exec device name, e.g. "scsi.device" */ + LONG unit; /* fssm_Unit */ + ULONG opendevice_flags; /* fssm_Flags, passed to OpenDevice() */ + ULONG sizeblock; /* de_SizeBlock, in LONGWORDS */ + ULONG surfaces; /* de_Surfaces */ + ULONG blockspertrack;/* de_BlocksPerTrack */ + ULONG lowcyl; /* de_LowCyl */ + ULONG highcyl; /* de_HighCyl */ + struct MsgPort *handler; /* dol_Task; NULL = handler never started */ + UBYTE have_volume; /* a DLT_VOLUME entry names this handler */ + char volname[64]; /* if have_volume: its BSTR name, as C */ +}; + +LONG dosdev_resolve(const char *name, struct PartInfo *out); +/* ACTION_INHIBIT the handler (dp_Arg1 = DOSTRUE); DoPkt() on V36+, a + * hand-rolled struct StandardPacket on Kickstart 1.3. Prints its own + * failure reason and returns nonzero (RC_FATAL) on refusal. *inhibited is + * set on success, left 0 if handler is NULL (nothing to inhibit) or the + * packet failed; pass the same variable to dosdev_uninhibit() unchanged + * on every exit path -- it is a no-op unless *inhibited is set. */ +LONG dosdev_inhibit(struct MsgPort *handler, UBYTE *inhibited); +void dosdev_uninhibit(struct MsgPort *handler, UBYTE *inhibited); + /* ---- PRNG: xorshift32, one state per task (§13) ---- */ static __inline ULONG xs32(ULONG *s) diff --git a/src/dosdev.c b/src/dosdev.c new file mode 100644 index 0000000..122acd3 --- /dev/null +++ b/src/dosdev.c @@ -0,0 +1,412 @@ +/* + * dosdev.c - partition mode: resolve a DOS device name (DH1:) to the exec + * device/unit/geometry it is mounted on, and inhibit/uninhibit the handler + * for the duration of the run. + * + * Runs in main (a DOS Process), after out_init() -- unlike args.c this file + * is allowed to print. All of the DosList structures below are BCPL: BPTR + * fields are longword addresses shifted right 2 (BADDR() undoes that) and + * BSTR fields are BPTRs to a length-prefixed (not NUL-terminated) string. + * struct DosList is the same layout whether reached via LockDosList() + * (V36+) or by hand-walking DOSBase->dl_Root->rn_Info->di_DevInfo under + * Forbid() (Kickstart 1.3); only the list-walking API differs. + */ + +#include "devsoak.h" + +#include +#include +#include +#include +#include +#include +#include + +extern struct ExecBase *SysBase; +extern struct DosLibrary *DOSBase; + +/* ---- hand-rolled LVO calls for the V36+ DosList API ---- + * -mcrt=nix13 (see README "Building") picks up bebbo gcc's *ndk13-include* + * tree ahead of the full NDK on the include search path -- a curated + * Kickstart-1.3-safe subset that deliberately omits LockDosList(), + * UnLockDosList(), FindDosEntry(), NextDosEntry() and DoPkt() (their + * structs/#defines are all still present; only these five V36+ calls' + * proto/inline glue is missing). Called only under the lib_Version >= 36 + * gate below, so this is exactly output.c's RawPutChar situation: no + * header provides the call, so it is hand-rolled as a direct LVO jsr with + * pinned argument registers (dos.library's documented register + * assignments, from the ndk-include inline/dos.h this build does not use). + */ + +static struct DosList *call_LockDosList(ULONG flags) +{ + register struct DosLibrary *a6 asm("a6") = DOSBase; + register ULONG d1 asm("d1") = flags; + register struct DosList *d0 asm("d0"); + + asm volatile ("jsr -654(a6)" + : "=r" (d0) + : "r" (a6), "r" (d1) + : "a0", "a1", "cc", "memory"); + return d0; +} + +static void call_UnLockDosList(ULONG flags) +{ + register struct DosLibrary *a6 asm("a6") = DOSBase; + register ULONG d1 asm("d1") = flags; + + asm volatile ("jsr -660(a6)" + : + : "r" (a6), "r" (d1) + : "d0", "a0", "a1", "cc", "memory"); +} + +static struct DosList *call_FindDosEntry(struct DosList *dlist, + CONST_STRPTR name, ULONG flags) +{ + register struct DosLibrary *a6 asm("a6") = DOSBase; + register struct DosList *d1 asm("d1") = dlist; + register CONST_STRPTR d2 asm("d2") = name; + register ULONG d3 asm("d3") = flags; + register struct DosList *d0 asm("d0"); + + asm volatile ("jsr -684(a6)" + : "=r" (d0) + : "r" (a6), "r" (d1), "r" (d2), "r" (d3) + : "a0", "a1", "cc", "memory"); + return d0; +} + +static struct DosList *call_NextDosEntry(struct DosList *dlist, ULONG flags) +{ + register struct DosLibrary *a6 asm("a6") = DOSBase; + register struct DosList *d1 asm("d1") = dlist; + register ULONG d2 asm("d2") = flags; + register struct DosList *d0 asm("d0"); + + asm volatile ("jsr -690(a6)" + : "=r" (d0) + : "r" (a6), "r" (d1), "r" (d2) + : "a0", "a1", "cc", "memory"); + return d0; +} + +static LONG call_DoPkt(struct MsgPort *port, LONG action, LONG arg1, + LONG arg2, LONG arg3, LONG arg4, LONG arg5) +{ + register struct DosLibrary *a6 asm("a6") = DOSBase; + register struct MsgPort *d1 asm("d1") = port; + register LONG d2 asm("d2") = action; + register LONG d3 asm("d3") = arg1; + register LONG d4 asm("d4") = arg2; + register LONG d5 asm("d5") = arg3; + register LONG d6 asm("d6") = arg4; + register LONG d7 asm("d7") = arg5; + register LONG d0 asm("d0"); + + asm volatile ("jsr -240(a6)" + : "=r" (d0) + : "r" (a6), "r" (d1), "r" (d2), "r" (d3), "r" (d4), + "r" (d5), "r" (d6), "r" (d7) + : "a0", "a1", "cc", "memory"); + return d0; +} + +/* ---- tiny hand-rolled string helpers (no strcasecmp on this toolchain; + * see quirks.c for the same pattern) ---- */ + +static int my_tolower(int c) +{ + if (c >= 'A' && c <= 'Z') + return c - 'A' + 'a'; + return c; +} + +/* case-insensitive compare of a BSTR (BCPL length-prefixed) against a + * NUL-terminated C string. b == 0 never matches. */ +static UBYTE bstr_ci_eq(BSTR b, const char *cname) +{ + UBYTE *p; + UBYTE len, i; + + if (b == 0) + return 0; + p = (UBYTE *)BADDR(b); + len = p[0]; + if ((size_t)len != strlen(cname)) + return 0; + for (i = 0; i < len; i++) { + if (my_tolower(p[1 + i]) != my_tolower((unsigned char)cname[i])) + return 0; + } + return 1; +} + +/* copy a BSTR into a NUL-terminated C buffer, truncating to fit */ +static void bstr_to_c(BSTR b, char *out, size_t outsize) +{ + UBYTE *p; + UBYTE len, i; + + if (outsize == 0) + return; + if (b == 0) { + out[0] = '\0'; + return; + } + p = (UBYTE *)BADDR(b); + len = p[0]; + if ((size_t)len >= outsize) + len = (UBYTE)(outsize - 1); + for (i = 0; i < len; i++) + out[i] = (char)p[1 + i]; + out[len] = '\0'; +} + +/* A BPTR field is "plausible" if it is nonzero and BADDR()s to a nonzero, + * even address -- BADDR always produces a multiple of 4 when raw != 0, so + * this really only rules out raw == 0, but it documents the invariant we + * are relying on before dereferencing driver/filesystem-supplied data. */ +static UBYTE plausible_bptr(ULONG raw, APTR *outptr) +{ + APTR p; + + if (raw == 0) + return 0; + p = (APTR)BADDR(raw); + if (p == NULL || (((ULONG)p) & 1) != 0) + return 0; + *outptr = p; + return 1; +} + +/* Validate a DLT_DEVICE DosList node hard before trusting any of the + * filesystem-startup data it points to, then extract the fields + * partition mode needs. 0 on success; on failure prints the one-line + * reason and returns -1. */ +static LONG validate_and_extract(struct DosList *node, struct PartInfo *out, + const char *origname) +{ + APTR startupptr, environptr; + struct FileSysStartupMsg *fssm; + struct DosEnvec *de; + UBYTE *devbstr; + UBYTE devlen; + + if (node->dol_Type != DLT_DEVICE) { + out_printf("devsoak: %s: not a DOS device list entry", origname); + return -1; + } + + if (!plausible_bptr(node->dol_misc.dol_handler.dol_Startup, &startupptr)) { + out_printf("devsoak: %s has no filesystem startup (not a disk " + "partition)", origname); + return -1; + } + fssm = (struct FileSysStartupMsg *)startupptr; + + if (fssm->fssm_Device == 0) { + out_printf("devsoak: %s: filesystem startup has no device name", + origname); + return -1; + } + devbstr = (UBYTE *)BADDR(fssm->fssm_Device); + devlen = devbstr[0]; + if (devlen < 1) { + out_printf("devsoak: %s: filesystem startup device name is empty", + origname); + return -1; + } + + if (!plausible_bptr((ULONG)fssm->fssm_Environ, &environptr)) { + out_printf("devsoak: %s: filesystem startup has no environment " + "table", origname); + return -1; + } + de = (struct DosEnvec *)environptr; + + if (de->de_TableSize < DE_UPPERCYL) { + out_printf("devsoak: %s: environment table too short (size %ld, " + "need >= %ld)", origname, (LONG)de->de_TableSize, + (LONG)DE_UPPERCYL); + return -1; + } + if (de->de_SizeBlock == 0 || de->de_Surfaces == 0 || + de->de_BlocksPerTrack == 0) { + out_printf("devsoak: %s: implausible geometry in environment table " + "(zero SizeBlock/Surfaces/BlocksPerTrack)", origname); + return -1; + } + if (de->de_HighCyl < de->de_LowCyl) { + out_printf("devsoak: %s: environment table has HighCyl < LowCyl", + origname); + return -1; + } + + bstr_to_c(fssm->fssm_Device, out->devname, sizeof(out->devname)); + out->unit = (LONG)fssm->fssm_Unit; + out->opendevice_flags = fssm->fssm_Flags; + out->sizeblock = de->de_SizeBlock; + out->surfaces = de->de_Surfaces; + out->blockspertrack = de->de_BlocksPerTrack; + out->lowcyl = de->de_LowCyl; + out->highcyl = de->de_HighCyl; + out->handler = node->dol_Task; + + return 0; +} + +LONG dosdev_resolve(const char *name, struct PartInfo *out) +{ + char namebuf[32]; + size_t n; + struct DosList *found = NULL; + UBYTE have_v36; + LONG rc = -1; + + memset(out, 0, sizeof(*out)); + + n = strlen(name); + if (n > 0 && name[n - 1] == ':') + n--; + if (n == 0 || n >= sizeof(namebuf)) { + out_printf("devsoak: %s: bad DOS device name", name); + return -1; + } + memcpy(namebuf, name, n); + namebuf[n] = '\0'; + + have_v36 = (SysBase->LibNode.lib_Version >= 36) ? 1 : 0; + + if (have_v36) { + struct DosList *dl, *v; + + dl = call_LockDosList(LDF_DEVICES | LDF_VOLUMES | LDF_READ); + found = call_FindDosEntry(dl, (STRPTR)namebuf, LDF_DEVICES); + if (found == NULL) { + out_printf("devsoak: %s: not found in the DOS device list", name); + } else if (validate_and_extract(found, out, name) == 0) { + rc = 0; + if (out->handler != NULL) { + v = dl; + while ((v = call_NextDosEntry(v, LDF_VOLUMES)) != NULL) { + if (v->dol_Task == out->handler) { + bstr_to_c(v->dol_Name, out->volname, + sizeof(out->volname)); + out->have_volume = 1; + break; + } + } + } + } + call_UnLockDosList(LDF_DEVICES | LDF_VOLUMES | LDF_READ); + } else { + struct RootNode *root; + struct DosInfo *info; + struct DosList *node; + + Forbid(); + + root = ((struct DosLibrary *)DOSBase)->dl_Root; + info = (struct DosInfo *)BADDR(root->rn_Info); + + for (node = (struct DosList *)BADDR(info->di_DevInfo); node != NULL; + node = (struct DosList *)BADDR(node->dol_Next)) { + if (node->dol_Type == DLT_DEVICE && bstr_ci_eq(node->dol_Name, namebuf)) { + found = node; + break; + } + } + + if (found == NULL) { + out_printf("devsoak: %s: not found in the DOS device list", name); + } else if (validate_and_extract(found, out, name) == 0) { + rc = 0; + if (out->handler != NULL) { + for (node = (struct DosList *)BADDR(info->di_DevInfo); + node != NULL; + node = (struct DosList *)BADDR(node->dol_Next)) { + if (node->dol_Type == DLT_VOLUME && + node->dol_Task == out->handler) { + bstr_to_c(node->dol_Name, out->volname, + sizeof(out->volname)); + out->have_volume = 1; + break; + } + } + } + } + + Permit(); + } + + return rc; +} + +/* ---- ACTION_INHIBIT (Kickstart 1.3 has no DoPkt(); hand-roll the + * packet the way DoPkt() itself does internally on V36+) ---- */ + +static LONG send_inhibit_pre36(struct MsgPort *handler, LONG arg1) +{ + struct StandardPacket sp; + struct MsgPort *replyport = &((struct Process *)FindTask(NULL))->pr_MsgPort; + struct Message *reply; + + memset(&sp, 0, sizeof(sp)); + sp.sp_Msg.mn_Node.ln_Name = (char *)&sp.sp_Pkt; + sp.sp_Msg.mn_Length = sizeof(struct DosPacket); + sp.sp_Msg.mn_ReplyPort = replyport; + sp.sp_Pkt.dp_Link = &sp.sp_Msg; + sp.sp_Pkt.dp_Port = replyport; + sp.sp_Pkt.dp_Type = ACTION_INHIBIT; + sp.sp_Pkt.dp_Arg1 = arg1; + + PutMsg(handler, &sp.sp_Msg); + do { + WaitPort(replyport); + reply = GetMsg(replyport); + } while (reply != &sp.sp_Msg); + + return sp.sp_Pkt.dp_Res1; +} + +static LONG send_inhibit(struct MsgPort *handler, LONG arg1) +{ + if (SysBase->LibNode.lib_Version >= 36) + return call_DoPkt(handler, ACTION_INHIBIT, arg1, 0, 0, 0, 0); + return send_inhibit_pre36(handler, arg1); +} + +LONG dosdev_inhibit(struct MsgPort *handler, UBYTE *inhibited) +{ + *inhibited = 0; + + if (handler == NULL) { + out_printf("devsoak: handler not started; nothing to inhibit"); + return 0; + } + + if (send_inhibit(handler, DOSTRUE) == 0) { + out_printf("devsoak: ACTION_INHIBIT failed; the filesystem would " + "fight the test traffic, refusing to run"); + return -1; + } + + *inhibited = 1; + out_printf("devsoak: partition inhibited (filesystem access blocked " + "for the run)"); + return 0; +} + +void dosdev_uninhibit(struct MsgPort *handler, UBYTE *inhibited) +{ + if (!*inhibited || handler == NULL) + return; + + if (send_inhibit(handler, DOSFALSE) == 0) { + out_printf("devsoak: warning: ACTION_INHIBIT (uninhibit) failed; " + "the filesystem may still be inhibited"); + } + *inhibited = 0; +} diff --git a/src/main.c b/src/main.c index 5012102..cd33c91 100644 --- a/src/main.c +++ b/src/main.c @@ -11,6 +11,7 @@ #include #include #include +#include /* globals declared extern in devsoak.h */ struct Config cfg; @@ -62,6 +63,27 @@ u64_to_str(U64 v, char *buf) buf[i] = '\0'; } +/* case-insensitive compare (no strcasecmp on this toolchain; same pattern + * as quirks.c's ci_eq()) -- used only for the partition-mode volume-name + * confirmation below. */ +static int +main_tolower(int c) +{ + if (c >= 'A' && c <= 'Z') return c - 'A' + 'a'; + return c; +} + +static UBYTE +main_ci_eq(const char *a, const char *b) +{ + while (*a && *b) { + if (main_tolower((unsigned char)*a) != main_tolower((unsigned char)*b)) + return 0; + a++; b++; + } + return (*a == '\0' && *b == '\0') ? 1 : 0; +} + int main(int argc, char **argv) { @@ -73,6 +95,13 @@ main(int argc, char **argv) ULONG s0, u0; char numbuf1[24]; char numbuf2[24]; + /* partition mode (§ dosdev.c) */ + struct PartInfo pinfo; + UBYTE have_partinfo = 0; + UBYTE inhibited = 0; + UBYTE have_sig = 0; + char signame[8]; + U64 part_boot_lba = 0; if (args_parse(argc, argv) != 0) { out_init(OUT_CON); @@ -103,6 +132,19 @@ main(int argc, char **argv) cfg.seed = s0 ^ u0; } + /* partition mode: resolve NAME: to the exec device/unit/geometry it + * is mounted on before opening anything. dosdev_resolve() prints its + * own one-line failure reason. */ + if (cfg.partition) { + if (dosdev_resolve(cfg.dosdev, &pinfo) != 0) { + rc = RC_FATAL; + goto cleanup_close; + } + have_partinfo = 1; + cfg.device = pinfo.devname; + cfg.unit = pinfo.unit; + } + port = CreatePort(NULL, 0); if (port == NULL) { out_printf("devsoak: CreatePort failed"); @@ -118,7 +160,8 @@ main(int argc, char **argv) } operr = OpenDevice((CONST_STRPTR)cfg.device, cfg.unit, - (struct IORequest *)io, 0); + (struct IORequest *)io, + cfg.partition ? pinfo.opendevice_flags : 0); if (operr != 0) { out_printf("devsoak: OpenDevice(%s,%ld) failed, io_Error %ld", cfg.device, cfg.unit, (LONG)io->iotd_Req.io_Error); @@ -221,6 +264,110 @@ main(int argc, char **argv) goto cleanup_close; } + /* partition mode: turn the mounted partition's extent (envec blocks, + * de_LowCyl/de_HighCyl) into a device-sector range, exactly as -r + * would express it, absolute from LBA 0. envec blocks are + * de_SizeBlock*4 bytes; devsoak works in dev.sector_size units, so a + * clean integer scale between the two is required. */ + if (cfg.partition) { + ULONG envec_bytes = pinfo.sizeblock * 4; + ULONG scale; + U64 blocks_per_cyl, part_start, part_len; + + if (envec_bytes == 0 || dev.sector_size == 0 || + envec_bytes % dev.sector_size != 0) { + out_printf("devsoak: %s: envec block size %ld bytes is not a " + "multiple of the device sector size %ld bytes", + cfg.dosdev, (LONG)envec_bytes, (LONG)dev.sector_size); + rc = RC_FATAL; + goto cleanup_close; + } + scale = envec_bytes / dev.sector_size; + + blocks_per_cyl = (U64)pinfo.surfaces * (U64)pinfo.blockspertrack; + part_start = (U64)pinfo.lowcyl * blocks_per_cyl * (U64)scale; + part_len = ((U64)pinfo.highcyl - (U64)pinfo.lowcyl + 1) * + blocks_per_cyl * (U64)scale; + part_boot_lba = part_start; + + if (cfg.have_range) { + /* -r was also given: a relative sub-range within the + * partition, for low-RAM machines (§ README "Partition + * mode"). */ + if (cfg.range_start + cfg.range_len > part_len) { + u64_to_str(part_len, numbuf1); + out_printf("devsoak: -r sub-range exceeds the partition " + "(%s sectors)", numbuf1); + rc = RC_FATAL; + goto cleanup_close; + } + cfg.range_start = part_start + cfg.range_start; + } else { + cfg.range_start = part_start; + cfg.range_len = part_len; + } + cfg.have_range = 1; + + { + U64 endsec = cfg.range_start + cfg.range_len; + U64 mb = (cfg.range_len * (U64)dev.sector_size) / (1024 * 1024); + + u64_to_str(cfg.range_start, numbuf1); + u64_to_str(endsec, numbuf2); + out_printf("devsoak: %s = %s unit %ld, partition sectors %s..%s", + cfg.dosdev, cfg.device, (LONG)cfg.unit, + numbuf1, numbuf2); + u64_to_str(mb, numbuf1); + out_printf("devsoak: %s size: %s MB", cfg.dosdev, numbuf1); + } + + /* contents warning (§ README "Partition mode"): read the + * partition's boot block and look for a known filesystem + * signature in its first 4 bytes. Best-effort only -- a + * partition starting at or past the 4 GB boundary can't be + * addressed with a plain CMD_READ here (this runs before the + * engine's dialect probing), so the check is skipped for it and + * the confirmation falls back to the plain y/n tier. */ + if (part_boot_lba * (U64)dev.sector_size < 0x100000000ULL) { + UBYTE *scratch = AllocMem(dev.sector_size, + MEMF_PUBLIC | MEMF_CLEAR); + if (scratch != NULL) { + io->iotd_Req.io_Command = CMD_READ; + io->iotd_Req.io_Data = scratch; + io->iotd_Req.io_Length = dev.sector_size; + io->iotd_Req.io_Offset = + (ULONG)(part_boot_lba * (U64)dev.sector_size); + io->iotd_Req.io_Flags = 0; + DoIO((struct IORequest *)io); + + if (io->iotd_Req.io_Error == 0 && + io->iotd_Req.io_Actual >= 4) { + if (memcmp(scratch, "muFS", 4) == 0) { + strcpy(signame, "muFS"); + have_sig = 1; + } else if (memcmp(scratch, "DOS", 3) == 0 || + memcmp(scratch, "PFS", 3) == 0 || + memcmp(scratch, "PDS", 3) == 0 || + memcmp(scratch, "SFS", 3) == 0) { + signame[0] = (char)scratch[0]; + signame[1] = (char)scratch[1]; + signame[2] = (char)scratch[2]; + signame[3] = '\\'; signame[4] = 'x'; + { + UBYTE v = scratch[3]; + static const char hex[] = "0123456789abcdef"; + signame[5] = hex[(v >> 4) & 0xF]; + signame[6] = hex[v & 0xF]; + } + signame[7] = '\0'; + have_sig = 1; + } + } + FreeMem(scratch, dev.sector_size); + } + } + } + /* validate range against device size */ if (cfg.range_len == 0) { out_printf("devsoak: -r LEN must be nonzero"); From f7ca0a275ea9b1611eec7b3a386e2231c6b1aaec Mon Sep 17 00:00:00 2001 From: Simon Dick Date: Sun, 6 Sep 2026 09:27:09 +0100 Subject: [PATCH 2/7] Escalate the destructive-run confirmation for partition mode Three tiers, replacing the plain y/N prompt in partition mode: an empty or unrecognised partition behaves exactly as before; a recognised filesystem signature with no live volume mounted adds a warning line ahead of the same y/N; a live mounted volume (a DLT_VOLUME DosList entry naming the same handler) requires typing the volume name back case-insensitively, and -y does not bypass that tier -- destroying a filesystem someone still has mounted forces an interactive run. That last policy is marked for PR discussion; it may want a review path. --- src/main.c | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/src/main.c b/src/main.c index cd33c91..809c375 100644 --- a/src/main.c +++ b/src/main.c @@ -415,14 +415,64 @@ main(int argc, char **argv) out_printf("devsoak: workers %ld, qdepth %ld, duration %ld s", (LONG)cfg.workers, (LONG)cfg.qdepth, (LONG)cfg.duration_s); - /* confirmation (skipped with -y) */ - if (!cfg.yes) { + /* confirmation. Partition mode escalates in three tiers (§ README + * "Partition mode"): no signature/no volume behaves exactly like the + * plain -r form below; a filesystem signature with no live volume + * adds a warning line ahead of the same y/n; a live mounted volume + * requires typing the volume name back and -y does not bypass it -- + * this is destroying a filesystem someone still has open. */ + if (cfg.partition && have_partinfo && pinfo.have_volume) { if ((SetSignal(0, 0) & SIGBREAKF_CTRL_C) != 0) { out_printf("devsoak: aborted (break)"); rc = RC_FATAL; goto cleanup_close; } + /* policy: see PR discussion -- -y is deliberately not honoured + * for a live mounted volume, unlike every other confirmation + * tier; this is under review. */ + if (cfg.yes) { + out_printf("devsoak: %s: volume \"%s:\" is live-mounted; -y " + "does not bypass this confirmation. Run " + "interactively.", cfg.dosdev, pinfo.volname); + rc = RC_FATAL; + goto cleanup_close; + } + + out_printf("devsoak: %s: volume \"%s:\" is live-mounted on this " + "partition.", cfg.dosdev, pinfo.volname); + out_printf("This will DESTROY it. Type the volume name (%s) to " + "continue:", pinfo.volname); + { + BPTR cin = Input(); + char ansbuf[80]; + LONG n = Read(cin, ansbuf, (LONG)sizeof(ansbuf) - 1); + + if (n > 0) { + while (n > 0 && (ansbuf[n - 1] == '\n' || ansbuf[n - 1] == '\r')) + n--; + ansbuf[n] = '\0'; + } else { + ansbuf[0] = '\0'; + } + if (!main_ci_eq(ansbuf, pinfo.volname)) { + out_printf("devsoak: aborted (volume name did not match)"); + rc = RC_FATAL; + goto cleanup_close; + } + } + } else if (!cfg.yes) { + if ((SetSignal(0, 0) & SIGBREAKF_CTRL_C) != 0) { + out_printf("devsoak: aborted (break)"); + rc = RC_FATAL; + goto cleanup_close; + } + + if (cfg.partition && have_sig) { + out_printf("devsoak: %s: partition contains a %s filesystem " + "(no live volume mounted).", cfg.dosdev, signame); + } + out_printf("This will DESTROY data in the above range. Continue? (y/N)"); { BPTR cin = Input(); From a6048233883c40dadd37f55b4fc760561b8dc8b3 Mon Sep 17 00:00:00 2001 From: Simon Dick Date: Sun, 6 Sep 2026 09:27:23 +0100 Subject: [PATCH 3/7] Inhibit the DOS device during a partition-mode run Sends ACTION_INHIBIT (dp_Arg1 = DOSTRUE) to the partition's handler before any test traffic, so the filesystem stops touching the device for the run; uninhibited (dp_Arg1 = DOSFALSE) on every exit path via a single call at the top of cleanup_close, a no-op unless the inhibit actually succeeded. A handler that never started is skipped with a note instead of inhibited. --resume now adds a one-line reminder that a partition-mode run's device may still be inhibited after a crash, since nothing can uninhibit it at that point. --- src/main.c | 17 +++++++++++++++++ src/quirks.c | 7 +++++++ 2 files changed, 24 insertions(+) diff --git a/src/main.c b/src/main.c index 809c375..776775d 100644 --- a/src/main.c +++ b/src/main.c @@ -495,12 +495,29 @@ main(int argc, char **argv) goto cleanup_close; } + /* partition mode: inhibit the handler before any test traffic so the + * filesystem doesn't fight it (§ README "Partition mode"). Every exit + * path from here on falls through to cleanup_close, which uninhibits + * unconditionally (a no-op unless dosdev_inhibit() actually set + * `inhibited`). */ + if (cfg.partition && have_partinfo) { + if (dosdev_inhibit(pinfo.handler, &inhibited) != 0) { + crumb_close(); + quirks_cleanup(); + rc = RC_FATAL; + goto cleanup_close; + } + } + rc = engine_run(); crumb_close(); quirks_cleanup(); cleanup_close: + if (cfg.partition && have_partinfo) + dosdev_uninhibit(pinfo.handler, &inhibited); + if (opened) { CloseDevice((struct IORequest *)io); opened = 0; diff --git a/src/quirks.c b/src/quirks.c index 8dacd16..48d3eff 100644 --- a/src/quirks.c +++ b/src/quirks.c @@ -1285,6 +1285,13 @@ LONG quirks_resume_report(void) } out_printf("status suspected"); + if (cfg.partition) { + out_printf("devsoak: resume: this was a partition-mode run (%s); " + "if the crash happened after inhibit, the DOS device " + "may still be inhibited -- Mount/reboot to clear it.", + cfg.dosdev != NULL ? cfg.dosdev : "?"); + } + FreeMem(filebuf, FILEBUF_MAX + 1); return RC_CLEAN; } From 1d04e6fbd806eef837601406d08027d8c698b5b5 Mon Sep 17 00:00:00 2001 From: Simon Dick Date: Sun, 6 Sep 2026 09:27:29 +0100 Subject: [PATCH 4/7] Document partition mode in the README Usage synopsis and a new "Partition mode" subsection: the NAME: form, geometry/unit/range resolved from the mount, the inhibit behaviour, the three-tier confirmation (including the live-volume name prompt), -r as a relative sub-range, and that writes never leave the partition. --- README.md | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a9437d6..10d984f 100644 --- a/README.md +++ b/README.md @@ -44,10 +44,13 @@ dependency. If you change the toolchain flags, re-check with ``` devsoak DEVICE UNIT -d -r START,LEN [options] +devsoak NAME: -d [options] (partition mode; see below) -d destructive (required) -r START,LEN test range in sectors (required); K/M/G suffixes multiply - by 1024 (sector counts, not bytes); 0x hex accepted + by 1024 (sector counts, not bytes); 0x hex accepted. + Partition mode: optional, a sub-range relative to the + partition's own start instead of the whole test range. -t DURATION e.g. 30s, 20m, 8h (default 60s) -w N worker tasks (default 4, max 8) -q N outstanding requests per worker (default 4, max 8) @@ -80,6 +83,36 @@ devsoak refuses to run without both `-d` and `-r`, prints the device geometry and the range, and asks for confirmation unless `-y` is given. It never writes outside the range (the §8 bounds probes included). +### Partition mode + +`devsoak DH1: -d [options]` — a single positional argument ending in `:` +is taken as a mounted DOS device name instead of `DEVICE UNIT`. devsoak +looks it up in the DosList, resolves it to the underlying exec +device/unit and OpenDevice flags, and turns the partition's own extent +(from its `DosEnvec`: cylinders, surfaces, blocks/track) into the test +range — printed as `DH1: = scsi.device unit 0, partition sectors +1004832..1209455`, same as the ordinary `-r` banner. `-r START,LEN` is +still accepted in this mode, but is reinterpreted as a sub-range +*relative to the partition's start* (for low-RAM machines that can't +soak the whole thing); devsoak still never writes outside that range, so +device-end probes stay read-only and can never spill past the partition +either. + +Before any test traffic, devsoak sends `ACTION_INHIBIT` to the +partition's handler so the filesystem stops touching the device for the +run (uninhibited again on every exit path, including errors — a crash +is the one case that can't clean this up; `--resume` says so). If the +handler was never started there is nothing to inhibit and devsoak says +so and continues. + +The destructive-run confirmation escalates by what's actually on the +partition: an empty or unrecognised partition gets the normal `y/N` +prompt; a recognised filesystem signature with no live volume mounted +adds a warning line ahead of the same prompt; a **live mounted volume** +requires typing the volume's name back (case-insensitive) instead of +`y` — and `-y` does *not* bypass that last tier (this policy is under +review), so a live volume forces an interactive run. + ### Exit codes | code | meaning | From 261a5ac59befea9401bbb2c2ab06f585666fa8bb Mon Sep 17 00:00:00 2001 From: Simon Dick Date: Sun, 6 Sep 2026 09:30:14 +0100 Subject: [PATCH 5/7] Settle the live-volume confirmation policy as final -y not bypassing the type-the-volume-name tier is agreed behaviour, not an open question. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PhqCc7fq5uY5wLQMTHmzYi --- src/main.c | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main.c b/src/main.c index 776775d..a461aa9 100644 --- a/src/main.c +++ b/src/main.c @@ -428,9 +428,9 @@ main(int argc, char **argv) goto cleanup_close; } - /* policy: see PR discussion -- -y is deliberately not honoured - * for a live mounted volume, unlike every other confirmation - * tier; this is under review. */ + /* -y is deliberately not honoured for a live mounted volume, + * unlike every other confirmation tier: a misconfigured CI job + * must not be able to destroy a filesystem someone has open. */ if (cfg.yes) { out_printf("devsoak: %s: volume \"%s:\" is live-mounted; -y " "does not bypass this confirmation. Run " From c0a3a34a4f8b7399c9df0ff481f6fd8bcc2d9d57 Mon Sep 17 00:00:00 2001 From: Simon Dick Date: Sun, 6 Sep 2026 10:12:42 +0100 Subject: [PATCH 6/7] Add the amibake manifest used to test partition mode An OS 3.2.2 image with an unformatted 8 MB scratch DH1 partition, validated against copperhf.device under Copperline: a full `devsoak DH1:` run passes, and `devsoak DH0: -y` is refused by the live-volume confirmation tier. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PhqCc7fq5uY5wLQMTHmzYi --- test/amibake-scratch.toml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 test/amibake-scratch.toml diff --git a/test/amibake-scratch.toml b/test/amibake-scratch.toml new file mode 100644 index 0000000..b3e92a3 --- /dev/null +++ b/test/amibake-scratch.toml @@ -0,0 +1,25 @@ +# amibake manifest for a partition-mode test image: AmigaOS 3.2.2 on +# DH0 plus an unformatted 8 MB scratch partition -- amibake's [hdf] +# scratch option exists precisely for devsoak -- that the guest mounts +# as DH1: ("Not a DOS disk"), ready for `devsoak DH1: -d ...`. +# +# Build (needs amibake, https://github.com/sidick/amibake, with licensed +# OS 3.2.2 media + Kickstart 47.7 under its assets/): +# amibake build --assets assets --out OUTDIR test/amibake-scratch.toml +# The emitted copperline config lacks a serial sink; append +# [serial] +# mode = "stdout" +# before driving it with the ci/smoke.sh conventions. Copperline does not +# exit when a --run program finishes, so wait for the "devsoak: RESULT" +# line in the serial log and kill it. +# +# fast RAM matters: a chip-only guest cannot allocate the worker +# MaxTransfer buffers and the run fails with rc 20 before any traffic. + +base = "os3.2.2" +machine = { cpu = "68020", fpu = true, ram = "fast:8M" } +output = ["hdf"] +emit = ["copperline"] + +[hdf] +scratch = "8M" From 90116c8a4309e956bc8bd4bf6df5a0b68d5c7a76 Mon Sep 17 00:00:00 2001 From: Simon Dick Date: Sun, 6 Sep 2026 10:15:51 +0100 Subject: [PATCH 7/7] Bring the user docs up to date with partition mode CLI-Reference gains the NAME: synopsis, the partition-mode notes on -r and -y, and a Partition mode section (resolution, inhibit, confirmation tiers). Emulator-Testing documents the amibake scratch-partition flow around test/amibake-scratch.toml. The README's live-volume -y policy is no longer marked as under review. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PhqCc7fq5uY5wLQMTHmzYi --- README.md | 5 +++-- userdocs/CLI-Reference.md | 43 ++++++++++++++++++++++++++++++++++-- userdocs/Emulator-Testing.md | 33 +++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 10d984f..d887121 100644 --- a/README.md +++ b/README.md @@ -110,8 +110,9 @@ partition: an empty or unrecognised partition gets the normal `y/N` prompt; a recognised filesystem signature with no live volume mounted adds a warning line ahead of the same prompt; a **live mounted volume** requires typing the volume's name back (case-insensitive) instead of -`y` — and `-y` does *not* bypass that last tier (this policy is under -review), so a live volume forces an interactive run. +`y` — and `-y` does *not* bypass that last tier, so a live volume +forces an interactive run: a misconfigured CI job must not be able to +destroy a filesystem someone still has open. ### Exit codes diff --git a/userdocs/CLI-Reference.md b/userdocs/CLI-Reference.md index a146c2c..a1cf3f7 100644 --- a/userdocs/CLI-Reference.md +++ b/userdocs/CLI-Reference.md @@ -2,18 +2,23 @@ ``` devsoak DEVICE UNIT -d -r START,LEN [options] +devsoak NAME: -d [options] ``` devsoak refuses to run without both `-d` and `-r`, prints the device geometry and the range, and asks for confirmation unless `-y` is given. It never writes outside the range (the bounds probes included). +The second form is [partition mode](#partition-mode): a single +positional argument ending in `:` is a mounted DOS device name, and the +partition's own extent becomes the test range — no `-r` needed. + ## Options | option | default | notes | |---|---|---| | `-d` | — | destructive; required | -| `-r START,LEN` | — | test range in sectors; required. K/M/G suffixes multiply by 1024 — these are **sector counts, not bytes**. 0x hex accepted | +| `-r START,LEN` | — | test range in sectors; required. K/M/G suffixes multiply by 1024 — these are **sector counts, not bytes**. 0x hex accepted. Partition mode: optional, a sub-range relative to the partition's own start | | `-t DURATION` | `60s` | e.g. `30s`, `20m`, `8h` | | `-w N` | 4 (max 8) | worker tasks | | `-q N` | 4 (max 8) | outstanding requests per worker | @@ -23,7 +28,7 @@ It never writes outside the range (the bounds probes included). | `-m ADDR` | — | extra buffer memory region (hex address); may repeat | | `-s SEED` | from clock, printed | PRNG seed | | `-e` | off | stop on first error | -| `-y` | off | skip the destructive-run confirmation | +| `-y` | off | skip the destructive-run confirmation (except partition mode's live-volume tier — see below) | | `-B` | off | big device: include the 4 GB boundary tests | | `-R` | off | removable media semantics (change-interrupt phase) | | `-H CMD` | — | shell command run to trigger eject/insert (with `-R`) | @@ -62,6 +67,40 @@ Nothing else may have serial.device open during a serial run — `RawPutChar` drives the same hardware serial.device would use — and the baud rate is whatever the ROM/Prefs left it at (typically 9600 on 1.3). +## Partition mode + +`devsoak DH1: -d [options]` looks the name up in the DosList, resolves +it to the underlying exec device/unit and OpenDevice flags, and turns +the partition's extent (from its `DosEnvec`) into the test range — +printed as `DH1: = scsi.device unit 0, partition sectors +1004832..1209455`, same as the ordinary `-r` banner. A dedicated +scratch partition makes the safest target: the range can never be +mistyped onto a neighbour, and devsoak never writes outside it (the +device-end probes are read-only). + +`-r START,LEN` is still accepted, reinterpreted as a sub-range +*relative to the partition's start*, for low-RAM machines that can't +soak the whole partition. + +Before any test traffic devsoak sends `ACTION_INHIBIT` to the +partition's handler, so the filesystem stops touching the device for +the run; it uninhibits again on every exit path. A crash is the one +case that can't clean this up (`--resume` says so) — remount or reboot +clears it. If the handler was never started there is nothing to +inhibit and devsoak says so and continues. + +The destructive-run confirmation escalates by what is actually on the +partition: + +| found | prompt | +|---|---| +| nothing recognisable | normal `y/N`; `-y` skips | +| a filesystem signature (DOS/PFS/SFS/muFS boot block), no live volume | warning line, then the same `y/N`; `-y` skips | +| a **live mounted volume** | type the volume's name back (case-insensitive); `-y` does **not** skip this and instead refuses the run | + +The live-volume tier is deliberately interactive-only: a misconfigured +CI job must not be able to destroy a filesystem someone still has open. + ## Exit codes | code | meaning | diff --git a/userdocs/Emulator-Testing.md b/userdocs/Emulator-Testing.md index dd75085..056519a 100644 --- a/userdocs/Emulator-Testing.md +++ b/userdocs/Emulator-Testing.md @@ -31,6 +31,39 @@ Copperline's synthesized RDB occupies the first cylinder of a bare hardfile, so place `-r` past it — e.g. `-r 512,2K` on the 2 MB victim, as all three configs' comments note. +## Partition mode with an amibake image + +For [partition mode](CLI-Reference.md#partition-mode) (`devsoak DH1:`), +[amibake](https://github.com/sidick/amibake) builds a bootable image with +a real, unformatted scratch partition — its `[hdf] scratch` option exists +precisely for devsoak. **`test/amibake-scratch.toml`** is the manifest: +AmigaOS 3.2.2 on `DH0`, an 8 MB scratch `DH1` at the end of the disk, and +an emitted Copperline config. + +``` +amibake build --assets assets --out OUTDIR test/amibake-scratch.toml +``` + +Then boot the image and point devsoak at the scratch partition by name — +no `-r`, no victim generation, no RDB offset to remember: + +``` +devsoak DH1: -d -t 30s -w 2 -q 2 -A 0 -W 30 -y -o ser +``` + +Notes, all learned the hard way (and recorded in the manifest's +comments): append `[serial] mode = "stdout"` to the emitted Copperline +config (amibake doesn't emit a serial sink); keep the manifest's +`ram = "fast:8M"` (a chip-only guest can't allocate the worker +MaxTransfer buffers and fails with rc 20 before any traffic); and +Copperline doesn't exit when a `--run` program finishes, so wait for the +`devsoak: RESULT` line in the serial log and kill it, rather than +relying on a plain timeout. + +The same image also exercises the live-volume refusal: `devsoak DH0: -d +-y` resolves the system partition, finds its mounted volume, and refuses +to run — without writing a sector. + ## `ci/smoke.sh` `ci/smoke.sh` is the CI entry point. It builds a fresh scratch victim,