oaknut.afs

The Acorn Level 3 File Server’s private on-disc filesystem, identified by the AFS0 magic. An AFS region occupies the tail cylinders of an old-map ADFS hard-disc image, coexisting with the ADFS partition at the front of the same disc, so an AFS handle is opened through its host ADFS (see oaknut.adfs).

Every name documented here is importable directly from oaknut.afs.

The filesystem

class oaknut.afs.AFS(unified_disc, sec1, sec2, *, region_base=0, user='Syst', enforce_quota=True)

Open handle on an Acorn Level 3 File Server filesystem region.

Instances are normally obtained through AFS.from_file() or via ADFS.afs_partition. The constructor is public but low level: callers must supply the two info-sector addresses from the host map themselves.

Parameters:
static from_file(filepath)

Open a disc image and yield the AFS partition as a context manager.

Opens the image first as ADFS (reusing its sector-access and geometry detection), then reaches the AFS partition through oaknut.adfs.ADFS.afs_partition. The image is opened writable when host filesystem permissions allow, read-only otherwise; mutations against a read-only-backed image raise from the mmap layer at the point of write.

Parameters:

filepath (str | PathLike) – Path to the ADFS hard-disc image carrying the AFS partition.

Raises:

AFSNotPresentError – If the disc has no AFS pointers.

Return type:

Iterator[AFS]

static create_file(filepath, *, capacity=None, disc_name='', cylinders=None, compact_adfs=False, users=(), omit_users=(), emplacements=())

Create a new AFS image as a context manager.

Top-level orchestrator that composes oaknut.adfs.ADFS.create_file() (#30 capacity strings) + oaknut.afs.wfsinit.initialise() + zero or more oaknut.afs.libraries.emplace_library() calls into a single named constructor — mirroring the symmetric shape DFS.create_file() / ADFS.create_file() already provide for their filesystems.

Parameters:
  • filepath (Union[str, PathLike]) – Path for the new .dat hard-disc image. A companion .dsc sidecar is written automatically.

  • capacity (int | str | None) – Hard-disc capacity. int is bytes; str accepts "10MB" / "40MiB" / etc. (see oaknut.file.capacity.parse_capacity()). Default None uses ADFS’s smallest hard-disc size.

  • disc_name (str) – AFS disc-name string written into the info sector. Defaults to empty.

  • cylinders (int | None) – Number of cylinders the AFS partition should claim. None (default) takes the existing free extent at the end of the ADFS partition — the same behaviour as disc afs-init without --cylinders.

  • compact_adfs (bool) – Run ADFS.compact() before partitioning, consolidating ADFS data so AFS can claim the maximum possible tail extent.

  • users (Sequence[UserSpec]) – Sequence of UserSpec accounts to create in addition to the built-in Syst, Boot, and Welcome.

  • omit_users (Sequence[str]) – Names of built-in accounts to not create, e.g. ("Welcome",). Syst and Boot cannot be omitted.

  • emplacements (Sequence[str | PathLike]) – Sequence of library names or paths passed to oaknut.afs.emplace_library(). Names like "Library", "Library1", "ArthurLib" resolve to shipped images; anything else is treated as a path to an ADFS .adl.

Yields:

The newly-initialised AFS partition handle.

Return type:

Iterator[AFS]

property info_sector: InfoSector

The validated InfoSector for this region.

property title: str

The partition’s disc-level title — its info-sector disc name.

AFS directories have no individual title (unlike ADFS); the info-sector disc name is the only title-like field, so this is what disc title reads and writes for an AFS partition.

property root: AFSPath

The root directory path $ bound to this handle.

property users: PasswordsFile

Parsed $.Passwords file, read lazily and cached.

add_user(name, *, password='', quota=0, system=False, privileges_locked=False, boot_option=None)

Add a user account to the AFS passwords file.

Equivalent to disc afs-useradd on the CLI side. Composes PasswordsFile.with_added() with the on-disc write so callers do not have to know the serialised passwords-file layout. quota accepts either an integer byte count or a capacity string ("2MB", "512KiB", …) via oaknut.file.capacity.parse_capacity().

Raises AFSUserExistsError if the name is taken.

Parameters:
Return type:

None

remove_user(name)

Remove name from the AFS passwords file.

Equivalent to disc afs-userdel on the CLI side. The record is tombstoned in place so other users’ slots and directory references remain stable; subsequent add_user() calls reuse the tombstoned slot if one is available.

Raises AFSUserNotFoundError if name is not present.

Parameters:

name (str)

Return type:

None

set_password(name, password)

Set name’s login password in the AFS passwords file.

Equivalent to disc afs-passwd on the CLI side. The Level 3 File Server stores passwords as up to six cleartext ASCII characters (MAXPW) — there is no encryption, so the new password is written verbatim. Because the record stays the same 31 bytes this never grows the passwords file, unlike add_user().

Raises AFSUserNotFoundError if no active user has that name, and AFSPasswordError if the password is not ASCII or exceeds six characters.

Parameters:
Return type:

None

property free_sectors: int

Total free data sectors across all cylinders in the region.

compact()

Defragment the AFS region, consolidating free space.

Raises:

NotImplementedError – AFS compaction is not yet implemented.

Return type:

int

insert_into_directory(dir_sin, entry)

Insert entry into the directory at dir_sin.

Reads the directory bytes, calls insert_entry(), and writes the result back. If the directory’s free list is empty the underlying object is grown by one disc block (matching CHZSZE at Uade0E:1167) and the insert is retried. The grow step is capped at MAXDIR = 26 sectors.

Raises AFSDirectoryFullError if the directory is already at MAXDIR and a grow would exceed the cap.

Parameters:
  • dir_sin (SystemInternalName)

  • entry (DirectoryEntry)

Return type:

None

flush()

Commit all buffered sector writes to the underlying disc.

The bitmap shadow is flushed first (which adds any dirty bitmap sectors to _pending_writes via the shadow’s writer callback), then every entry in the buffer is written to the UnifiedDisc in a single pass. After a successful flush the buffer is empty.

Return type:

None

discard()

Drop all buffered writes without touching the disc.

Closes the handle. The caches are invalidated so they don’t carry stale state from the discarded session. Idempotent — a discard on an already-closed handle is a no-op.

Use this when an error path makes the in-flight changes invalid; the factory from_file() / create_file() call this automatically when their with block exits via an exception.

Return type:

None

close()

Commit any pending writes and mark this handle closed.

Idempotent. Normally invoked automatically when the from_file() / create_file() with block exits normally; call manually if you build an AFS directly and need to control its lifecycle.

Return type:

None

property closed: bool

Whether this handle has been closed via close() or discard(). Pure path manipulation on bound paths still works after close; I/O raises oaknut.file.FilesystemClosedError.

class oaknut.afs.AFSPath(parts, afs=None)

An absolute path within an AFS directory tree.

Paths always start at the root $ and accumulate named components through /:

root = AFSPath.root()
library = root / "Library"
fs_tool = library / "Fs"
str(fs_tool)  # "$.Library.Fs"

AFSPath values are treated as immutable: / returns a new path and the components are exposed through the read-only parts. It is a plain class (not a dataclass) because the inherited AcornPath carries properties — title, name, parent — and a frozen dataclass’s generated __setattr__ masks inherited property setters, turning path.title = ... into a confusing super() error.

The afs handle is bound at construction and excluded from equality and hashing — two paths denoting the same location are equal regardless of which (if any) handle they carry.

Parameters:
EntryExistsError

alias of AFSDirectoryEntryExistsError

DirectoryError

alias of AFSPathError

classmethod parse(text)

Parse a dot-separated path string into an AFSPath.

The string must start at the root $; relative paths are not supported for this phase.

Parameters:

text (str)

Return type:

AFSPath

property parts: tuple[str, ...]

The path components, root-first. Read-only.

property name: str

The final component of the path.

property path: str

The canonical dot-separated path string ($.A.B).

Symmetrical with DFSPath.path and ADFSPath.path so tooling that walks heterogeneous trees can call .path without type-discriminating.

property parent: AFSPath

The path with the final component removed.

The parent of the root is the root itself.

exists()

Check whether this path resolves to an object on disc.

Return type:

bool

is_dir()

True if this path is a directory.

Return type:

bool

is_file()

True if this path is a file (not a directory).

Return type:

bool

read_bytes()

Return the full contents of this file as bytes.

Raises AFSPathError if the path is the root or a directory rather than a file.

Return type:

bytes

stat()

Return the AFSStat for this path.

Conforms to oaknut.file.Stat — uniform across DFS, ADFS, and AFS. Use directory_entry() if you need the raw on-disc DirectoryEntry (with its sin and AFSAccess-typed access byte).

The root directory has no parent entry and is a special case; asking for its stat raises AFSPathError.

Return type:

AFSStat

directory_entry()

Return the raw on-disc DirectoryEntry.

Lower-level companion to stat(). Use this when you specifically need the on-disc AFSAccess byte layout, the sin system internal name, or other AFS-only fields. Most code should call stat() instead.

Return type:

DirectoryEntry

iterdir()

Yield the children of this directory as bound ``AFSPath``s.

Raises AFSPathError if this path is a file.

Return type:

Iterator[AFSPath]

write_bytes(data, *, load_address=0, exec_address=0, access=None, date=None)

Create or replace a file at this path with data.

If an object already exists at this path it is freed first, its directory entry is rewritten, and the new content is placed in freshly-allocated sectors. Allocator-level rollback on space exhaustion is handled by the lower layers.

access accepts:

  • None: filesystem default — owner R+W, no public, unlocked.

  • oaknut.file.Access (canonical wire form): translated to the AFS on-disc layout via oaknut.afs.access.AFSAccess.from_acorn(). Access.LWR is the canonical “locked owner R+W” combination.

  • oaknut.afs.access.AFSAccess: used verbatim.

  • int: read as a canonical wire-form access byte.

date defaults to today’s date.

Parameters:
Return type:

None

touch(*, access=None, date=None, exist_ok=True)

Create an empty file at this path, mirroring pathlib.Path.touch().

AFS directory entries carry a date stamp. With the default exist_ok=True, touching an existing file refreshes that date to date (defaulting to today).

Parameters:
  • access – Access flags for the file. On create, used for the new entry. On an existing file, ignored (pathlib’s touch does not rewrite mode either).

  • date – Acorn date stamp. Defaults to today.

  • exist_ok (bool) – Default True matches pathlib. When False, an existing file or directory at the path raises.

Raises:
Return type:

None

mkdir(*, parents=False, exist_ok=False, access=None, date=None)

Create an empty directory at this path.

A directory is an object whose data is a valid empty directory byte image (DRENTS=0, free list spanning every slot, trailing seq byte matching leading). Its access byte has the directory-type bit set.

Mirrors pathlib.Path.mkdir().

Parameters:
  • parents (bool) – If True, missing intermediate directories are created. Default False raises when any ancestor is absent.

  • exist_ok (bool) – If True, do not raise when this path already resolves to a directory. A non-directory at the path still raises. Default False.

  • access – Optional AFSAccess for the new directory; defaults to D/.

  • date – Optional AfsDate; defaults to today.

Raises:
  • AFSPathError – If the path is the root, or already exists as a file, or the parent does not exist and parents is False.

  • AFSDirectoryEntryExistsError – If the path already exists as a directory and exist_ok is False.

Return type:

None

Delete this file (or empty directory) from its parent.

Frees the underlying object’s sectors and removes the directory entry. Refuses non-empty directories, matching DELCHK at Uade0D:1218.

Return type:

None

rmdir()

Alias for unlink() — the empty-dir check is shared.

Return type:

None

chmod(access)

Set the access attributes of this file or directory.

access accepts an AFSAccess (used directly), a canonical oaknut.file.Access, or an int read as that wire byte (matching ADFSPath.chmod()); the wire E bit, which has no AFS counterpart, is dropped.

The AFSAccess.DIRECTORY bit is always forced to match the object’s actual type; chmod cannot convert a file to a directory or vice versa.

Parameters:

access (Access | AFSAccess | int)

Return type:

None

lock()

Set the L (locked) bit, preserving all other flags.

Return type:

None

unlock()

Clear the L (locked) bit, preserving all other flags.

Return type:

None

set_load_address(address)

Rewrite this entry’s load address without touching its data.

Parameters:

address (int)

Return type:

None

set_exec_address(address)

Rewrite this entry’s exec address without touching its data.

Parameters:

address (int)

Return type:

None

rename(target)

Rename or move this entry to target, returning the new path.

target must be an absolute path: a string starting with $ or an AFSPath with an absolute parts. Moving across directories is supported — the underlying object’s data is not rewritten; only the directory entry changes parent.

Parameters:

target (str | AFSPath)

Return type:

AFSPath

export_file(target_filepath, *, meta_format=MetaFormat.INF_TRAD, owner=0)

Export file to host filesystem, emitting Acorn metadata.

Mirrors DFSPath.export_file() and ADFSPath.export_file(). The AFS on-disc access byte is translated to the canonical wire-form oaknut.file.Access before encoding so the resulting sidecar / xattrs use the same attribute representation as DFS and ADFS exports.

Parameters:
  • target_filepath (Union[str, PathLike]) – Destination path on the host.

  • meta_format (MetaFormat | None) – How to encode metadata. Defaults to traditional INF sidecar (DEFAULT_EXPORT_META_FORMAT). Pass None to write only the data, with no sidecar / xattr / filename rewrite.

  • owner (int) – Econet owner ID, used only by PiEconetBridge formats.

Returns:

The actual path that was written. Equal to target_filepath except for filename-encoded formats.

Return type:

Path

import_file(source_filepath, *, meta_formats=(MetaFormat.INF_TRAD, MetaFormat.XATTR_ACORN, MetaFormat.FILENAME_RISCOS))

Import a file from the host filesystem.

Mirrors DFSPath.import_file() and ADFSPath.import_file(). The AFS filename is taken from this AFSPath; metadata is resolved by trying meta_formats in order and the first reader to match wins. The full Acorn attribute byte (R/W/L/PR/PW — AFS does not have an execute bit) is applied via chmod() after the data is written so owner-write / public-read permissions round-trip losslessly via MetaFormat.INF_PIEB or either xattr format.

Parameters:
  • source_filepath (Union[str, PathLike]) – Path to the source file on the host.

  • meta_formats (Sequence[MetaFormat]) – Ordered cascade of metadata schemes to try. Defaults to DEFAULT_IMPORT_META_FORMATS.

Return type:

None

class oaknut.afs.AFSAccess(*values)

On-disc AFS access byte.

The integer value of any combination is the byte stored in the DRACCS field of a directory entry — it may be written straight into a SectorsView without further translation:

entry_bytes[20] = int(AFSAccess.from_string("LR/R"))

Composable with |:

AFSAccess.OWNER_READ | AFSAccess.OWNER_WRITE | AFSAccess.PUBLIC_READ
classmethod from_byte(value)

Build an AFSAccess from a raw access byte.

Unknown bits (6 and 7) are silently ignored, matching the server’s tolerance for junk in the unused bits.

Parameters:

value (int)

Return type:

AFSAccess

to_byte()

Return the raw access byte suitable for on-disc storage.

Return type:

int

to_string()

Format as the human-readable "owner/public" string.

Directory objects render as "D/" or "DL/" — consistent with the server’s *EX listings and with the Beebmaster PDF.

Return type:

str

classmethod from_string(text)

Parse an "owner/public" access string.

Accepts the forms in the Beebmaster PDF’s “Example Access Strings” table:

  • "/" — no access at all (byte 0x00)

  • "L/" — locked, otherwise no access

  • "LR/R", "LWR/WR", "WR/R", "R/"

  • "D/", "DL/" — directories (the access bits are ignored for directories per the PDF; the server only honours locked)

Letters may appear in any order within each side, but the forward slash is mandatory. Upper/lower case is accepted; whitespace is not.

Parameters:

text (str)

Return type:

AFSAccess

classmethod from_acorn(access)

Translate a canonical oaknut.file.Access to AFS bits.

Owner/public read-write and the locked bit map across. The wire E (execute-only) bit has no AFS counterpart and is dropped. The directory-type bit is a property of the object, not its access, so it is never set here — the directory serialiser owns it.

Parameters:

access (Access)

Return type:

AFSAccess

to_acorn()

Translate this on-disc access to a canonical Access.

The directory-type bit has no wire-form counterpart and is dropped — directory-ness is carried by the object, not its access.

Return type:

Access

Initialisation and partitioning

Partitioning an ADFS hard disc for AFS and initialising an empty filesystem — the library analogue of WFSINIT. The spec objects describe the desired partition size and initial user accounts.

oaknut.afs.initialise(adfs, *, spec)

Initialise an AFS region on adfs from spec.

Follows the WFSINIT.bas PROCsetup flow (lines 2060-2350):

  1. Run oaknut.afs.wfsinit.partition.plan() + apply unless spec.repartition is False.

  2. Initialise every AFS cylinder’s bitmap.

  3. Allocate the root directory (map block + 2 data sectors).

  4. Allocate a URD for each user (map block + 2 data sectors).

  5. Allocate the passwords file (map block + 1 data sector).

  6. Write info sectors (both copies).

  7. Write cylinder bitmaps.

  8. Write root directory map block + data, including entries for each user’s URD (access &30) and the Passwords file (access &00).

  9. Write each user’s empty URD.

  10. Write the passwords file with built-in accounts (Syst, Boot, Welcome) followed by user-specified accounts.

  11. Emplace each library named in spec.libraries.

Parameters:
Return type:

None

class oaknut.afs.InitSpec(disc_name, date=<factory>, size=<factory>, compact_adfs=False, addition_factor=0, default_quota=263172, users=(), libraries=(), repartition=True, omit_builtins=<factory>)

Caller-facing description of a freshly-initialised AFS disc.

Defaults match WFSINIT’s historical behaviour: the AFS region occupies the existing tail free extent without pre-compaction. Pass compact_adfs=True (and optionally size=AFSSizeSpec.max()) to compact the ADFS partition first and reclaim the maximum possible space.

The default quota matches WFSINIT’s historical value of 0x40404 (~256 KiB) — kept small because the L3FS address encoding caps a single drive at ~512 MB and real-period Winchesters were ~20 MB. Callers building discs for modern large images can raise this explicitly.

Including a UserSpec in users whose name matches a built-in (Syst, Boot, or Welcome) overrides that built-in’s default quota / password / boot option. The spec’s system flag must match the built-in’s fixed value (True for Syst, False for the others); an override does not create a URD. To instead reclaim a built-in’s name for a fresh regular user (with a URD), list the name in omit_builtins.

Parameters:
class oaknut.afs.UserSpec(name, password='', quota=None, system=False, privileged=False, boot=BootOption.OFF)

Caller-facing description of one user account to create.

quota accepts either an integer byte count or a capacity string ("2MB", "512KiB", etc.) — see oaknut.file.capacity.parse_capacity() for the suffix table. Strings are parsed once at construction time and stored as bytes.

Parameters:
class oaknut.afs.AFSSizeSpec(kind, value_a=0, value_b=0)

Tagged-union describing the caller’s size request.

Construct via the classmethods max(), cylinders(), sectors(), bytes_(), ratio(), or existing_free(). The dataclass fields are implementation details; do not construct directly.

Parameters:
class oaknut.afs.RepartitionPlan(start_cylinder, afs_cylinders, new_adfs_cylinders, sec1, sec2, total_afs_sectors, will_compact)

Pure description of a repartition, produced by plan().

  • start_cylinder: where the AFS region will begin.

  • afs_cylinders: cylinder count for the AFS region.

  • new_adfs_cylinders: cylinder count retained for ADFS.

  • sec1 / sec2: absolute sector addresses of the two info sectors WFSINIT would install.

  • total_afs_sectors: number of sectors in the AFS region.

  • will_compact: whether apply() will call ADFS.compact() before shrinking.

Parameters:
  • start_cylinder (int)

  • afs_cylinders (int)

  • new_adfs_cylinders (int)

  • sec1 (int)

  • sec2 (int)

  • total_afs_sectors (int)

  • will_compact (bool)

oaknut.afs.BUILTIN_ACCOUNT_NAMES = frozenset({'Boot', 'Syst', 'Welcome'})

Build an immutable unordered collection of unique elements.

Bulk operations

Composing files into an AFS region in bulk: merging another tree, importing a host directory tree, and emplacing one of the shipped library images.

oaknut.afs.merge(target, source, *, source_path=None, target_path=None, conflict='error', exclude=None)

Copy a directory subtree from source to target.

source_path defaults to source.root; target_path defaults to target.root. Both must be directories.

exclude is a set of entry names to skip during the merge. By default, the Passwords file is always excluded so that a library merge never overwrites the target disc’s user records.

conflict controls what happens when a destination name already exists:

  • "error" (default): refuse the whole merge with AFSMergeConflictError before writing anything.

  • "skip": leave the existing target entry alone.

  • "overwrite": replace the target entry (its old bytes are released back to the allocator).

Parameters:
Return type:

None

oaknut.afs.import_host_tree(target, *, source, target_path=None, on_collision='error')

Pull the host directory at source into target_path.

  • source must be an existing directory on the host.

  • target_path defaults to the AFS root; must be a directory (created via mkdir if it doesn’t yet exist).

  • Per-file metadata is resolved through oaknut.file.host_bridge.import_with_metadata() so INF sidecars and xattr-based schemes populate load/exec/access automatically.

  • Names longer than 10 chars or containing forbidden chars (., :, space) are sanitised.

Parameters:
  • target (AFS)

  • source (Path)

  • target_path (AFSPath | None)

  • on_collision (CollisionPolicy)

Return type:

None

oaknut.afs.emplace_library(target_afs, name, *, conflict='overwrite')

Emplace a library onto an AFS partition.

If name matches a shipped library (e.g. "Library"), the bundled .adl is used. If name ends with .adl, it is treated as a path to a user-supplied ADFS image. In both cases, every file in the ADFS root is copied into $.{dirname} on the target AFS partition, where dirname is the stem of the image filename.

The target directory is created if it does not already exist.

Parameters:
Return type:

list[str]

oaknut.afs.SHIPPED_LIBRARIES = ('Library', 'Library1', 'ArthurLib', 'Utils')

Built-in immutable sequence.

If no argument is given, the constructor returns an empty tuple. If iterable is specified the tuple is initialized from iterable’s items.

If the argument is a tuple, the return value is the same object.

Passwords

The file server’s Passwords account file: the per-user records and the file that holds them.

class oaknut.afs.PasswordsFile(records)

Read-only view over the parsed $.Passwords entries.

Supports iteration (all slots, including the !is_in_use tombstones from deleted users), length, integer indexing, and name-based lookup via __getitem__(str) or find(). Only in-use entries are considered for name lookup; stale slots are skipped.

Mutation arrives in phase 14. Until then this class intentionally exposes no write surface so that the read path can ship cleanly.

Parameters:

records (Sequence[UserRecord])

classmethod from_bytes(data)

Parse a passwords file image.

WFSINIT allocates the passwords file as a whole sector (pssz% = &100) and stores BILB = 0, so the raw data may be 256 bytes — not a clean multiple of the 31-byte entry size. The L3FS server reads as many complete entries as fit and ignores any trailing fragment.

Parameters:

data (bytes)

Return type:

PasswordsFile

property active: tuple[UserRecord, ...]

All in-use records, in disc order.

find(name)

Look up an in-use entry by its bare or group.user ID.

Case-insensitive (the file server compares user IDs case-insensitively per Uade06). Raises KeyError if no active entry matches.

Parameters:

name (str)

Return type:

UserRecord

to_bytes()

Serialise every record back to on-disc bytes.

Return type:

bytes

with_added(name, *, password='', quota=0, system=False, privileges_locked=False, boot_option=BootOption.OFF)

Return a new passwords file with name added.

Raises KeyError if the user already exists. Reuses the first tombstoned slot if one is available, otherwise appends a fresh record at the end. Matches the USRMAN add-user flow semantically.

Parameters:
Return type:

PasswordsFile

with_removed(name)

Return a new passwords file with name removed (tombstoned).

The slot is cleared in place; its position is not reclaimed so subsequent adds can reuse it. Raises KeyError if the user does not exist.

Parameters:

name (str)

Return type:

PasswordsFile

class oaknut.afs.UserRecord(name, group, password, free_space, is_in_use, is_system, is_privileges_locked, boot_option)

A single parsed passwords file entry.

name is the bare user name (the portion after group. if the user belongs to a group) and group is the group prefix or None. The two together form the PWUSID field.

Phase 6 parses and exposes; phase 14 will introduce the mutating operations (add, remove, set_quota, set_password, etc.).

Parameters:
property full_id: str

The group.user or user form as stored on disc.

Free-space allocation

class oaknut.afs.Allocator(shadow, *, start_cylinder, sectors_per_cylinder)

Policy layer over BitmapShadow.

Given the AFS region’s start_cylinder (absolute, as recorded in the info sector) and its sectors_per_cylinder, the allocator translates between cylinder-local bitmap coordinates and absolute disc sector numbers. All sector addresses exposed through the public API are absolute — the same values that go into a MapSector’s extents and a SystemInternalName.

Parameters:
  • shadow (BitmapShadow)

  • start_cylinder (int)

  • sectors_per_cylinder (int)

total_free_sectors()

Sum of free sectors across every cylinder in the region.

Return type:

int

allocate(num_sectors)

Allocate num_sectors data sectors and return their extents.

The returned extents cover exactly num_sectors sectors in total. Policy:

  • Cylinders are picked in descending free-count order (FNDCY).

  • Within a cylinder, contiguous free runs are taken from the lowest free sector upward (ALBLK first-fit).

  • If a single cylinder cannot satisfy the whole request, the allocator spills to successive cylinders by free-count (FLBLKS).

  • On failure (not enough total free space), every sub-allocation made during this call is rolled back before raising AFSInsufficientSpaceError.

num_sectors must be positive.

Parameters:

num_sectors (int)

Return type:

list[Extent]

allocate_sector()

Allocate one sector and return its SIN.

Convenience for map-block allocation: a JesMap map block occupies exactly one sector, and its SIN is that sector’s absolute address. This is the ALBLK equivalent used by MPCRSP (Uade10:168) and by the chain-link path in MKRLN (Uade12:187).

Return type:

SystemInternalName

free_extent(extent)

Release extent back to the bitmap shadow.

The extent may span multiple cylinders; the allocator splits it into per-cylinder chunks before updating the shadow.

Parameters:

extent (Extent)

Return type:

None

free_sector(sector)

Release a single sector (e.g. an obsolete map block).

Equivalent to free_extent() with length 1.

Parameters:

sector (SystemInternalName | int)

Return type:

None

Disc geometry and types

The geometry of the host hard disc and the small domain types used throughout the on-disc structures.

class oaknut.afs.Geometry(cylinders, sectors_per_cylinder, total_sectors, bitmap_size_sectors=1)

Physical geometry of the disc containing an AFS region.

Attributes match the fields in the AFS info sector (see docs/dev/afs-onwire.md §Info sector):

  • cylinders: total cylinders on the physical disc (MPSZNC).

  • sectors_per_cylinder: MPSZSC.

  • total_sectors: MPSZNS — 24-bit, so capped at 2^24.

  • bitmap_size_sectors: MPSZSB — almost always 1.

Parameters:
  • cylinders (int)

  • sectors_per_cylinder (int)

  • total_sectors (int)

  • bitmap_size_sectors (int)

cylinder_start_sector(cylinder)

Return the first (sector 0) of cylinder.

Parameters:

cylinder (int)

Return type:

Sector

class oaknut.afs.AfsDate(date)

Packed 16-bit AFS creation date.

The on-disc format is the Acorn file-server “RISC OS / old-format” date, stored little-endian as two bytes. The bit layout is:

Bits

Field

0-4

Day (1..31)

5-7

High 3 bits of year delta

8-11

Month (1..12)

12-15

Low 4 bits of year delta

where year_delta = year - 1981 (so year 1981 has delta 0) and is a 7-bit value covering 1981..2108.

This packing is equivalent to WFSINIT’s formula (WFSINIT.bas line 4890 ff.):

encoded = ((year-81) * 4096)
        + (month * 256)
        + day
        + ((year-81) AND &F0) * 2
stored = encoded AND &FFFF

The high-nibble-shifted-left-1 term is how the low 4 bits of the year delta end up in bits 12-15 while the high 3 bits land in bits 5-7. WFSINIT lets the value overflow 16 bits; only the low 16 bits are actually written. We mask explicitly for clarity.

Verified against the Beebmaster PDF test disc: 8/8/2010 round-trips to 0xD828.

This class holds a Python datetime.date and (de)serialises on demand — we do not store the packed form, so every operation sees a plain date.

Parameters:

date (date)

to_bytes()

Encode as the 2-byte little-endian packed form.

Return type:

bytes

classmethod from_bytes(data)

Decode from the 2-byte little-endian packed form.

Parameters:

data (bytes)

Return type:

AfsDate

oaknut.afs.Cylinder = oaknut.afs.types.Cylinder

NewType creates simple unique types with almost zero runtime overhead.

NewType(name, tp) is considered a subtype of tp by static type checkers. At runtime, NewType(name, tp) returns a dummy callable that simply returns its argument.

Usage:

UserId = NewType('UserId', int)

def name_by_id(user_id: UserId) -> str:
    ...

UserId('user')          # Fails type check

name_by_id(42)          # Fails type check
name_by_id(UserId(42))  # OK

num = UserId(5) + 1     # type: int
oaknut.afs.Sector = oaknut.afs.types.Sector

NewType creates simple unique types with almost zero runtime overhead.

NewType(name, tp) is considered a subtype of tp by static type checkers. At runtime, NewType(name, tp) returns a dummy callable that simply returns its argument.

Usage:

UserId = NewType('UserId', int)

def name_by_id(user_id: UserId) -> str:
    ...

UserId('user')          # Fails type check

name_by_id(42)          # Fails type check
name_by_id(UserId(42))  # OK

num = UserId(5) + 1     # type: int
oaknut.afs.SystemInternalName = oaknut.afs.types.SystemInternalName

NewType creates simple unique types with almost zero runtime overhead.

NewType(name, tp) is considered a subtype of tp by static type checkers. At runtime, NewType(name, tp) returns a dummy callable that simply returns its argument.

Usage:

UserId = NewType('UserId', int)

def name_by_id(user_id: UserId) -> str:
    ...

UserId('user')          # Fails type check

name_by_id(42)          # Fails type check
name_by_id(UserId(42))  # OK

num = UserId(5) + 1     # type: int

Errors

The AFS exception hierarchy. AFSError is the base; every other AFS error inherits from it. The broader error model and the handled_errors boundary are covered in Error handling.

exception oaknut.afs.AFSError(*args, exit_code=None)

Base exception for all AFS errors.

Parameters:
  • args (object)

  • exit_code (Optional[ExitCode])

Return type:

None

exception oaknut.afs.AFSNotPresentError(*args, exit_code=None)

Raised when a caller asks for AFS on a disc that has no AFS pointers.

Parameters:
  • args (object)

  • exit_code (Optional[ExitCode])

Return type:

None

exception oaknut.afs.AFSAccessDeniedError(*args, exit_code=None)

Acting user lacks permission for the requested operation.

Parameters:
  • args (object)

  • exit_code (Optional[ExitCode])

Return type:

None

exception oaknut.afs.AFSAlreadyPartitionedError(*args, exit_code=None)

Refused: the disc already contains AFS pointers at &F6/&1F6.

Parameters:
  • args (object)

  • exit_code (Optional[ExitCode])

Return type:

None

exception oaknut.afs.AFSBrokenDirectoryError(*args, exit_code=None)

Master-sequence-number mismatch on a directory object.

The leading and trailing master-sequence bytes do not agree, which the file server reports as FS error &42 (“Broken Directory”). This typically means a write to the directory was interrupted.

Parameters:
  • args (object)

  • exit_code (Optional[ExitCode])

Return type:

None

exception oaknut.afs.AFSBrokenMapError(*args, exit_code=None)

Invalid JesMap magic or sequence-number mismatch.

Parameters:
  • args (object)

  • exit_code (Optional[ExitCode])

Return type:

None

exception oaknut.afs.AFSDirectoryEntryExistsError(*args, exit_code=None)

An entry with the same name already exists in the directory.

Parameters:
  • args (object)

  • exit_code (Optional[ExitCode])

Return type:

None

exception oaknut.afs.AFSDirectoryEntryNotFoundError(*args, exit_code=None)

No entry with the requested name exists in the directory.

Corresponds to the server’s DRERRC error code returned by FNDTEX at Uade0D:249 when the walk reaches the end of the in-use list without a match.

Parameters:
  • args (object)

  • exit_code (Optional[ExitCode])

Return type:

None

exception oaknut.afs.AFSDirectoryFullError(*args, exit_code=None)

Cannot insert into a directory whose free list is empty.

The Level 3 File Server handles this by growing the directory automatically via CHZSZE (Uade0E:1198). The oaknut-afs write path currently raises this error when growth would be required — phase 10 will add automatic growth, matching the ROM.

Parameters:
  • args (object)

  • exit_code (Optional[ExitCode])

Return type:

None

exception oaknut.afs.AFSDirectoryNotEmptyError(*args, exit_code=None)

Cannot remove a directory that still has entries.

Raised by rmdir / unlink on a non-empty sub-directory, matching DELCHK at Uade0D:1218+ (DRERRJ).

Parameters:
  • args (object)

  • exit_code (Optional[ExitCode])

Return type:

None

exception oaknut.afs.AFSDiscNameError(*args, exit_code=None)

The proposed AFS disc name is empty, too long, or contains non-printable / space characters (printable ASCII only, 1..16 chars).

Parameters:
  • args (object)

  • exit_code (Optional[ExitCode])

Return type:

None

exception oaknut.afs.AFSDiscNotCompactedError(*args, exit_code=None)

Refused: the ADFS free list is fragmented and compaction is disabled.

Raised only when partition.plan(..., compact_adfs=False). Pass compact_adfs=True (the default) to have the repartitioner run ADFS.compact() first.

Parameters:
  • args (object)

  • exit_code (Optional[ExitCode])

Return type:

None

exception oaknut.afs.AFSFileLockedError(*args, exit_code=None)

Operation refused because the object’s L bit is set.

Parameters:
  • args (object)

  • exit_code (Optional[ExitCode])

Return type:

None

exception oaknut.afs.AFSFormatError(*args, exit_code=None)

Malformed on-disc AFS structure.

Parameters:
  • args (object)

  • exit_code (Optional[ExitCode])

Return type:

None

exception oaknut.afs.AFSHostImportError(*args, exit_code=None)

import_host_tree failed to read or translate a host-side file.

Parameters:
  • args (object)

  • exit_code (Optional[ExitCode])

Return type:

None

exception oaknut.afs.AFSInfoSectorError(*args, exit_code=None)

Invalid AFS0 magic or redundancy mismatch between info sectors.

Parameters:
  • args (object)

  • exit_code (Optional[ExitCode])

Return type:

None

exception oaknut.afs.AFSInitSpecError(*args, exit_code=None)

Base for InitSpec / UserSpec validation failures.

Parameters:
  • args (object)

  • exit_code (Optional[ExitCode])

Return type:

None

exception oaknut.afs.AFSInsufficientADFSSpaceError(*args, exit_code=None)

Refused: the requested AFS size would leave too little space for ADFS.

Parameters:
  • args (object)

  • exit_code (Optional[ExitCode])

Return type:

None

exception oaknut.afs.AFSInsufficientSpaceError(*args, exit_code=None)

The allocator cannot satisfy a request from the free space pool.

Parameters:
  • args (object)

  • exit_code (Optional[ExitCode])

Return type:

None

exception oaknut.afs.AFSMergeConflictError(*args, exit_code=None)

A target name already exists and the merge policy is "error".

Parameters:
  • args (object)

  • exit_code (Optional[ExitCode])

Return type:

None

exception oaknut.afs.AFSNewMapNotSupportedError(*args, exit_code=None)

Refused: the ADFS disc uses the new-map format.

v1 of oaknut-afs supports old-map (S/M/L/D-style) ADFS hosts only. The new-map formats (E/E+/F/F+) use the reserved bytes differently and do not carry AFS pointers.

Parameters:
  • args (object)

  • exit_code (Optional[ExitCode])

Return type:

None

exception oaknut.afs.AFSPasswordError(*args, exit_code=None)

A password is not ASCII or exceeds 6 characters.

Parameters:
  • args (object)

  • exit_code (Optional[ExitCode])

Return type:

None

exception oaknut.afs.AFSPathError(*args, exit_code=None)

Path syntax error or non-existent object.

Covers invalid file titles, bad separators, paths that traverse through something that isn’t a directory, and $.foo where foo does not exist.

Parameters:
  • args (object)

  • exit_code (Optional[ExitCode])

Return type:

None

exception oaknut.afs.AFSQuotaError(*args, exit_code=None)

A quota (per-user or default) is outside 0..0xFFFFFFFF.

Parameters:
  • args (object)

  • exit_code (Optional[ExitCode])

Return type:

None

exception oaknut.afs.AFSQuotaExceededError(*args, exit_code=None)

The acting user does not have enough quota for this operation.

Parameters:
  • args (object)

  • exit_code (Optional[ExitCode])

Return type:

None

exception oaknut.afs.AFSRepartitionError(*args, exit_code=None)

Base for repartitioning failures.

Parameters:
  • args (object)

  • exit_code (Optional[ExitCode])

Return type:

None

exception oaknut.afs.AFSUserNameError(*args, exit_code=None)

A user name is empty, not ASCII, or exceeds 20 characters.

Parameters:
  • args (object)

  • exit_code (Optional[ExitCode])

Return type:

None