diff --git a/build.zig b/build.zig index 412651e..52c34ec 100644 --- a/build.zig +++ b/build.zig @@ -397,6 +397,19 @@ pub fn build(b: *std.Build) void { // here (compiled for the host rather than inheriting a freestanding target) — // which also compile-checks that the three-way split stays self-consistent. const test_step = b.step("test", "Run tests"); + + // Every compile-time ceiling states what it counts, who decides its size, what it + // protects, what happens when it is reached, and how anyone finds out + // (docs/os-development/bounds.md). The ~273 that predate the rule are listed in + // tools/bounds-allowlist.txt so this could land without a tree-wide sweep first; + // that list may only shrink. Part of `test` rather than the default build: it reads + // the whole tree, and a red bounds check should not stop you booting a kernel. + const bounds_check = b.addSystemCommand(&.{ "python3", "tools/check-bounds.py" }); + bounds_check.setCwd(b.path(".")); + const bounds_step = b.step("bounds", "Check that every compile-time ceiling declares itself"); + bounds_step.dependOn(&bounds_check.step); + test_step.dependOn(&bounds_check.step); + for ([_][]const u8{ "system/boot-handoff.zig", "system/abi.zig", diff --git a/docs/bounds-track-plan.md b/docs/bounds-track-plan.md index de4394c..e709c81 100644 --- a/docs/bounds-track-plan.md +++ b/docs/bounds-track-plan.md @@ -13,7 +13,7 @@ next one starts.* | Step | What | State | |---|---|---| | L1 | Reclamation: a dead task's registrations die with its claims | **stopped — the step was wrong; see open question 4** | -| L2 | Bounds build check + allowlist; declare what we have already touched | not started | +| L2 | Bounds build check + allowlist; declare what we have already touched | **done** — `zig build bounds`, 273 allowlisted, 5 declared | | L3 | xHCI: slot count from `HCSPARAMS1.MaxSlots`, not 8 | not started | | L4 | USB: configuration descriptor sized by `wTotalLength`, not 512 | not started | | L5 | USB: interfaces from the descriptor, and the misattributed-endpoint bug | not started | diff --git a/system/kernel/devices-broker.zig b/system/kernel/devices-broker.zig index a985dd6..cc67ddd 100644 --- a/system/kernel/devices-broker.zig +++ b/system/kernel/devices-broker.zig @@ -23,6 +23,14 @@ const abi = @import("abi"); const platform = @import("platform"); const device_abi = @import("device-abi"); +/// bound: device nodes for the whole machine — firmware-discovered plus every child a +/// bus driver registers at runtime +/// decided-by: hardware +/// protects: nothing — this is a sizing guess about someone else's computer, which is +/// why an AMD Ryzen booted with a working display, no USB and no storage +/// at-limit: refuse — ENOSPC from device_register; `dropped` counts discovery losses +/// observed-by: the bus driver's line naming the reason (pci-bus reconciles functions +/// found against registered), and kernel.zig:203 for discovery drops pub const maximum_devices = 64; /// Cap on children a single parent may have. A zero-resource child (legal — a USB @@ -31,6 +39,15 @@ pub const maximum_devices = 64; /// `device_register` and exhaust the whole table, permanently denying it to every other /// driver. This bounds the blast radius of one claim; a real quota (and a /// `device_release` to reclaim on exit) is future work — see docs/driver-model.md. +/// +/// bound: children one claimed parent may register — in practice every PCI function on +/// the machine, since pci-bus registers them all under the one host bridge +/// decided-by: hardware +/// protects: the shared device table, against a driver looping device_register — but +/// it is a proxy for an authorisation the kernel does not perform, since any +/// process may claim any unclaimed device (docs/bounds-track-plan.md phase 2) +/// at-limit: refuse — ECHILDREN, distinct from a full table +/// observed-by: pci-bus logs the reason per refused function, and warns at end of scan const maximum_children_per_parent = 16; var devices: [maximum_devices]device_abi.DeviceDescriptor = undefined; diff --git a/system/kernel/iommu.zig b/system/kernel/iommu.zig index 824a95a..8c2c2ca 100644 --- a/system/kernel/iommu.zig +++ b/system/kernel/iommu.zig @@ -39,7 +39,20 @@ const page_size: u64 = abi.page_size; const page_mask: u64 = page_size - 1; const huge_page_size: u64 = 2 * 1024 * 1024; -/// One domain per claimed PCI function. 64 mirrors devices-broker's device cap. +/// One domain per claimed PCI function. Coupled to devices-broker's device cap — and +/// coupled *in code*, by the comptime assert beside `confined` below, because when +/// these two agreed only by this sentence the disagreement failed open. +/// +/// Both VT-d and AMD-Vi report the number of domains they support in a capability +/// register. We should be reading it rather than choosing 64 — docs/bounds-track-plan.md +/// phase 4. +/// +/// bound: IOMMU translation domains, one per claimed DMA-capable device +/// decided-by: hardware +/// protects: the statically sized domain and confinement tables +/// at-limit: refuse — ECONFINE; the claim is rolled back and the device is not driven, +/// because a claim that cannot be confined must not stand +/// observed-by: the claiming driver's own line naming ECONFINE pub const maximum_domains = 64; pub const invalid_domain: u16 = 0xFFFF; diff --git a/system/parameters.zig b/system/parameters.zig index a055bb0..d51ebdd 100644 --- a/system/parameters.zig +++ b/system/parameters.zig @@ -17,8 +17,13 @@ /// arrays (discovery pool, scheduler state, per-core GDT/TSS). Generous headroom: /// those structs are small, and the *large* per-core resources (kernel and IST /// stacks) are allocated at bring-up for cores that actually come online, so this -/// ceiling is cheap. A machine with more logical CPUs has its surplus reported and -/// left parked (see acpi `cpusDropped`). +/// ceiling is cheap. +/// +/// bound: logical CPUs the kernel tracks +/// decided-by: hardware +/// protects: the per-CPU bookkeeping arrays, which are sized at compile time +/// at-limit: degrade — the surplus cores are left parked, never brought online +/// observed-by: platform.cpusDropped() -> the WARNING at kernel.zig:281 pub const maximum_cpus = 128; /// Maximum tasks (kernel threads) alive at once — the static task-table size. Each @@ -29,6 +34,17 @@ pub const maximum_cpus = 128; /// for the USB stack: the xHCI bus driver spawns a supervised class-driver instance /// per matched interface (keyboard, mouse, mass storage), on top of the FAT and /// block servers and the growing ramdisk bundle. +/// +/// The history above is the argument against this number: it has been raised twice, +/// each time by a machine or a bundle that outgrew it, which is the pattern the +/// bounds rule exists to stop. It is `ours` only because the task table is static; +/// how many drivers a machine needs is decided by how much hardware it has. +/// +/// bound: kernel threads alive at once — the static task-table size +/// decided-by: hardware +/// protects: the statically allocated task table +/// at-limit: refuse — spawn fails; a supervised driver is never started +/// observed-by: the spawning supervisor's own log line; see docs/bounds-track-plan.md pub const maximum_tasks = 48; /// Each task's kernel stack (also each AP's bring-up stack), in bytes. diff --git a/tools/bounds-allowlist.txt b/tools/bounds-allowlist.txt new file mode 100644 index 0000000..542e2c6 --- /dev/null +++ b/tools/bounds-allowlist.txt @@ -0,0 +1,285 @@ +# Bounds that predate the rule (docs/os-development/bounds.md). +# +# Generated from the tree as it stood when the check landed, so the gate could start +# without a 278-site sweep in front of it. Every line is a compile-time ceiling that +# has not yet said what it counts, who decides its size, what it protects, what +# happens when it is reached, or how anyone finds out. +# +# **This list may only shrink.** Declaring a bound means deleting its line; the check +# fails if a listed bound is now declared, and fails if a listed bound has vanished. +# Nothing may be added to it — a new ceiling declares itself or does not land. +# +# docs/fixed-bounds-audit.md is the analysis of how they got here. +boot/efi.zig:buffer +boot/efi.zig:info_buffer +boot/efi.zig:maximum_bundled +boot/efi.zig:maximum_tree_depth +library/device/acpi/aml/interpreter.zig:argbuf +library/device/acpi/aml/interpreter.zig:args +library/device/acpi/aml/interpreter.zig:locals +library/device/acpi/aml/interpreter.zig:maximum_segments +library/device/acpi/aml/interpreter.zig:notify_queue +library/device/acpi/aml/namespace.zig:segment +library/device/acpi/aml/parser.zig:maximum_segments +library/device/driver/driver.zig:lookup_attempts +library/device/model/device-abi.zig:hid +library/device/model/device-abi.zig:maximum_device_resources +library/device/pci/pci.zig:bar_virtual +library/device/pci/pci.zig:bars +library/device/registry/device-registry.zig:cols +library/device/registry/device-registry.zig:rules +library/device/registry/device-registry.zig:rules +library/device/registry/device-registry.zig:rules +library/device/registry/device-registry.zig:rules +library/device/registry/device-registry.zig:rules +library/kernel/channel.zig:bind_attempts +library/kernel/channel.zig:name_maximum +library/kernel/channel.zig:path_maximum +library/kernel/file-system.zig:name_buffer +library/kernel/file-system.zig:out +library/kernel/logging.zig:buffer +library/kernel/process.zig:blob +library/kernel/process.zig:receive +library/kernel/process.zig:table +library/kernel/service.zig:subscriber_capacity +library/kernel/start.zig:buffer +library/kernel/thread.zig:readers +library/kernel/thread.zig:writers +library/protocol/device-manager/device-manager-protocol.zig:hid +library/protocol/display/display-protocol.zig:max_modes +library/protocol/envelope/envelope.zig:packet_maximum +library/protocol/envelope/envelope.zig:post_maximum +library/protocol/power/power-protocol.zig:hid +library/protocol/scanout/scanout-protocol.zig:max_modes +library/protocol/usb-transfer/usb-transfer-protocol.zig:max_report_data +library/protocol/usb-transfer/usb-transfer-protocol.zig:max_reported_endpoints +library/protocol/usb-transfer/usb-transfer-protocol.zig:setup +system/abi.zig:errno_maximum +system/abi.zig:klog_maximum_message +system/abi.zig:maximum_process_name +system/boot-handoff.zig:kernel_segments +system/drivers/pci-bus/pci-bus.zig:line +system/drivers/pci-bus/pci-bus.zig:sub_buffer +system/drivers/ps2-bus/mouse-packet.zig:bytes +system/drivers/ps2-bus/scancode.zig:pressed +system/drivers/ps2-bus/scancode.zig:set2_base +system/drivers/ps2-bus/scancode.zig:set2_extended +system/drivers/usb-hid/hid-report.zig:keys +system/drivers/usb-hid/keyboard.zig:receive +system/drivers/usb-hid/mouse.zig:receive +system/drivers/usb-storage/bulk-only-transport.zig:cdb +system/drivers/usb-storage/scsi.zig:op_read_capacity_10 +system/drivers/usb-storage/usb-storage.zig:capacity_bytes +system/drivers/usb-xhci-bus/usb-xhci-bus.zig:hid_buffer +system/drivers/usb-xhci-bus/usb-xhci-bus.zig:maker_buffer +system/drivers/usb-xhci-bus/usb-xhci-bus.zig:maker_buffer +system/drivers/usb-xhci-bus/usb-xhci-bus.zig:prev_connected +system/drivers/usb-xhci-bus/usb-xhci-bus.zig:product_buffer +system/drivers/usb-xhci-bus/usb-xhci-bus.zig:product_buffer +system/drivers/usb-xhci-bus/usb-xhci-library.zig:blob +system/drivers/usb-xhci-bus/usb-xhci-library.zig:buffer +system/drivers/usb-xhci-bus/usb-xhci-library.zig:bytes +system/drivers/usb-xhci-bus/usb-xhci-library.zig:data +system/drivers/usb-xhci-bus/usb-xhci-library.zig:descriptor +system/drivers/usb-xhci-bus/usb-xhci-library.zig:head +system/drivers/usb-xhci-bus/usb-xhci-library.zig:header +system/drivers/usb-xhci-bus/usb-xhci-library.zig:max_devices +system/drivers/usb-xhci-bus/usb-xhci-library.zig:max_endpoints_per_interface +system/drivers/usb-xhci-bus/usb-xhci-library.zig:max_interfaces +system/drivers/usb-xhci-bus/usb-xhci-library.zig:max_subscriptions +system/drivers/usb-xhci-bus/usb-xhci-library.zig:port_changes +system/drivers/usb-xhci-bus/usb-xhci-library.zig:raw +system/drivers/usb-xhci-bus/usb-xhci-library.zig:report_queue_capacity +system/drivers/usb-xhci-bus/usb-xhci-library.zig:request_set_hub_depth +system/drivers/virtio-gpu/virtio-gpu-protocol.zig:edid +system/drivers/virtio-gpu/virtio-gpu-protocol.zig:max_scanouts +system/drivers/virtio-gpu/virtio-gpu.zig:descriptors +system/drivers/virtio-gpu/virtio-gpu.zig:max_height +system/drivers/virtio-gpu/virtio-gpu.zig:max_width +system/initial-ramdisk.zig:buffer +system/initial-ramdisk.zig:buffer +system/initial-ramdisk.zig:maximum_name +system/kernel/acpi.zig:APIC +system/kernel/acpi.zig:BERT +system/kernel/acpi.zig:CPEP +system/kernel/acpi.zig:DMAR +system/kernel/acpi.zig:DSDT +system/kernel/acpi.zig:ECDT +system/kernel/acpi.zig:EINJ +system/kernel/acpi.zig:ERST +system/kernel/acpi.zig:FACP +system/kernel/acpi.zig:FACS +system/kernel/acpi.zig:HEST +system/kernel/acpi.zig:HPET +system/kernel/acpi.zig:IVRS +system/kernel/acpi.zig:MCFG +system/kernel/acpi.zig:MPST +system/kernel/acpi.zig:MSCT +system/kernel/acpi.zig:PMTT +system/kernel/acpi.zig:PSDT +system/kernel/acpi.zig:RASF +system/kernel/acpi.zig:RSDT +system/kernel/acpi.zig:SBST +system/kernel/acpi.zig:SLIT +system/kernel/acpi.zig:SPCR +system/kernel/acpi.zig:SRAT +system/kernel/acpi.zig:SSDT +system/kernel/acpi.zig:XSDT +system/kernel/acpi.zig:aml_block_len +system/kernel/acpi.zig:aml_block_physical +system/kernel/acpi.zig:holes +system/kernel/acpi.zig:maximum_rmrr +system/kernel/acpi.zig:nb +system/kernel/acpi.zig:nb +system/kernel/acpi.zig:nb +system/kernel/acpi.zig:oem_id +system/kernel/acpi.zig:oem_id +system/kernel/acpi.zig:oem_id +system/kernel/acpi.zig:oem_table_id +system/kernel/acpi.zig:overrides +system/kernel/acpi.zig:rmrr_limit_offset +system/kernel/acpi.zig:signature +system/kernel/acpi.zig:signature +system/kernel/acpi.zig:signature +system/kernel/architecture/x86_64/cpu.zig:irq_vector_count +system/kernel/architecture/x86_64/idt.zig:gate_count +system/kernel/architecture/x86_64/ioapic.zig:overrides +system/kernel/architecture/x86_64/iommu-amd.zig:buffer +system/kernel/architecture/x86_64/iommu-amd.zig:buffer +system/kernel/architecture/x86_64/iommu-intel.zig:buffer +system/kernel/architecture/x86_64/iommu-intel.zig:buffer +system/kernel/architecture/x86_64/iommu-intel.zig:context_table +system/kernel/device-model.zig:buffer +system/kernel/device-model.zig:cbuf +system/kernel/device-model.zig:hid_buffer +system/kernel/device-model.zig:maximum_resources +system/kernel/device-model.zig:name_buffer +system/kernel/device-model.zig:rbuf +system/kernel/heap.zig:heap_maximum +system/kernel/ipc-synchronous.zig:MESSAGE_MAXIMUM +system/kernel/ipc-synchronous.zig:POST_MAXIMUM +system/kernel/ipc-synchronous.zig:notify_buffer +system/kernel/ipc-synchronous.zig:post_capacity +system/kernel/irq.zig:maximum_gsi +system/kernel/irq.zig:msi_bound +system/kernel/irq.zig:msi_owner +system/kernel/irq.zig:vector_gsi +system/kernel/kernel.zig:buffer +system/kernel/kernel.zig:buffer +system/kernel/kernel.zig:buffer +system/kernel/kernel.zig:isos +system/kernel/kernel.zig:maximum_wake_attempts +system/kernel/log-ring.zig:message +system/kernel/log-ring.zig:out +system/kernel/log.zig:buffer +system/kernel/log.zig:maximum_sinks +system/kernel/log.zig:message +system/kernel/log.zig:ring_capacity +system/kernel/process.zig:chunk +system/kernel/process.zig:chunk +system/kernel/process.zig:chunk +system/kernel/process.zig:chunk +system/kernel/process.zig:exit_record_capacity +system/kernel/process.zig:exit_subscriber_capacity +system/kernel/process.zig:maximum_argument_bytes +system/kernel/process.zig:maximum_arguments +system/kernel/process.zig:maximum_dma_regions +system/kernel/process.zig:maximum_mmap_pages +system/kernel/process.zig:maximum_mount_prefix +system/kernel/process.zig:maximum_mount_rewrite +system/kernel/process.zig:maximum_pages +system/kernel/process.zig:maximum_resolve_path +system/kernel/process.zig:maximum_segments +system/kernel/process.zig:maximum_shared_memory_pages +system/kernel/process.zig:name_buffer +system/kernel/process.zig:timer_capacity +system/kernel/process.zig:word_bytes +system/kernel/process.zig:write_buffer +system/kernel/scheduler.zig:ipc_maximum_handles +system/kernel/scheduler.zig:maximum_space_mappings +system/kernel/vfs.zig:maximum_directories +system/kernel/vfs.zig:maximum_mounts +system/kernel/vfs.zig:maximum_prefix +system/kernel/vfs.zig:maximum_rewrite +system/services/acpi/acpi.zig:blocks +system/services/acpi/acpi.zig:buffer +system/services/acpi/acpi.zig:hid +system/services/acpi/acpi.zig:mmio_scratch +system/services/acpi/acpi.zig:name +system/services/acpi/acpi.zig:registered +system/services/device-manager/device-manager.zig:arguments +system/services/device-manager/device-manager.zig:id_text +system/services/device-manager/device-manager.zig:maximum_children +system/services/device-manager/device-manager.zig:maximum_drivers +system/services/device-manager/device-manager.zig:name_buffer +system/services/device-manager/device-manager.zig:registry_rules +system/services/device-manager/device-manager.zig:registry_source +system/services/display/backend.zig:device_table +system/services/display/backend.zig:line +system/services/display/compositor.zig:capacity +system/services/display/compositor.zig:maximum_columns +system/services/display/compositor.zig:maximum_rects +system/services/display/compositor.zig:maximum_rows +system/services/display/display.zig:line +system/services/display/display.zig:line +system/services/display/display.zig:line +system/services/display/display.zig:list +system/services/display/display.zig:maximum_layers +system/services/display/display.zig:mode_list +system/services/fat/engine.zig:buf +system/services/fat/engine.zig:buf +system/services/fat/engine.zig:buf +system/services/fat/engine.zig:buffer +system/services/fat/engine.zig:cached_back +system/services/fat/engine.zig:chunk +system/services/fat/engine.zig:chunk +system/services/fat/engine.zig:chunk +system/services/fat/engine.zig:device_back +system/services/fat/engine.zig:display +system/services/fat/engine.zig:long_name +system/services/fat/engine.zig:long_name +system/services/fat/engine.zig:long_name +system/services/fat/engine.zig:max_transfer_sectors +system/services/fat/engine.zig:pair +system/services/fat/engine.zig:pair +system/services/fat/engine.zig:payload +system/services/fat/engine.zig:payload +system/services/fat/engine.zig:payload +system/services/fat/engine.zig:readback +system/services/fat/engine.zig:run +system/services/fat/engine.zig:run +system/services/fat/engine.zig:short +system/services/fat/engine.zig:short +system/services/fat/engine.zig:short +system/services/fat/engine.zig:stem +system/services/fat/engine.zig:tail_buffer +system/services/fat/engine.zig:units +system/services/fat/engine.zig:value +system/services/fat/engine.zig:value +system/services/fat/engine.zig:window +system/services/fat/on-disk.zig:filesystem_type +system/services/fat/on-disk.zig:filesystem_type +system/services/fat/on-disk.zig:jump +system/services/fat/on-disk.zig:name +system/services/fat/on-disk.zig:name1 +system/services/fat/on-disk.zig:name2 +system/services/fat/on-disk.zig:name3 +system/services/fat/on-disk.zig:oem_name +system/services/fat/on-disk.zig:volume_label +system/services/fat/on-disk.zig:volume_label +system/services/init/init.zig:binary +system/services/init/init.zig:init_csv +system/services/init/init.zig:max_service_args +system/services/init/init.zig:max_services +system/services/init/init.zig:maximum_bindings +system/services/init/init.zig:maximum_grants +system/services/init/init.zig:maximum_name +system/services/init/init.zig:maximum_restarts +system/services/init/init.zig:process_table +system/services/init/init.zig:protocol_csv +system/services/logger/logger.zig:chunk +system/services/logger/logger.zig:gap_line +system/services/logger/logger.zig:line +system/services/logger/logger.zig:line +system/services/logger/logger.zig:maximum_files +system/services/logger/logger.zig:stamp diff --git a/tools/check-bounds.py b/tools/check-bounds.py new file mode 100644 index 0000000..8b85664 --- /dev/null +++ b/tools/check-bounds.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""Every compile-time ceiling states what it is doing there. + +A *bound* is a number chosen at compile time that decides how much of something the +code can hold: `const maximum_devices = 64`, `var below: [64]Range`, `var blob: [512]u8`. +Different units, one shape, and one recurring way of going wrong — see +docs/fixed-bounds-audit.md, where 235 of them turned up, 139 on quantities the machine +or a file decides rather than us, and 171 silent when reached. + +This is the gate that keeps new ones from joining them. It does not resize anything and +it makes no judgement about whether a bound should exist; it only refuses one that will +not say what it is for. The declaration is a doc comment immediately above: + + /// bound: logical CPUs the kernel tracks + /// decided-by: hardware + /// protects: the per-CPU bookkeeping arrays, which are sized at compile time + /// at-limit: degrade - surplus cores are left parked, never brought online + /// observed-by: platform.cpusDropped() -> the WARNING at kernel.zig:281 + pub const maximum_cpus = 128; + +`decided-by` is the field the audit turned on: `hardware` and `external` mean the +quantity is not ours to choose, and a fixed bound on one of those is a defect rather +than a tunable. `at-limit`'s vocabulary is closed on purpose — there is no `silent`, +no `drop`, and nothing meaning *allow*, so the behaviours that caused the damage cannot +be written down. `truncate` is legal only with a marker the reader can see. + +An array length that names a declared bound (`[maximum_devices]Descriptor`) is not +itself a bound: the number lives at the declaration, and that is where it is declared. +Only literal lengths are flagged, which pushes ceilings toward having names. + +The ~235 that already exist are listed in tools/bounds-allowlist.txt so this can land +without a tree-wide sweep in front of it. That list may only shrink: declaring a bound +means deleting its line, and a stale line is an error too. + +Usage: check-bounds.py [--list] [repo-root] + --list print every undeclared bound found, for regenerating the allowlist +""" + +import re +import sys +from pathlib import Path + +# Directories worth gating. `test/` is excluded: a fixture's `[100]MemoryRegion` is a +# test input, not a ceiling the system runs into. +ROOTS = ("system", "library", "boot") +SKIP_PARTS = {".zig-cache", "zig-out", ".git", ".claude", "vendor", "generated"} +SKIP_FILES = {"tests.zig"} + +FIELDS = ("bound", "decided-by", "protects", "at-limit", "observed-by") +DECIDED_BY = {"hardware", "external", "ours"} +AT_LIMIT = {"refuse", "degrade", "truncate", "grow"} + +# A `const` whose name reads like a ceiling and whose value is an integer literal. +NAMED = re.compile( + r"^\s*(?:pub\s+)?const\s+([A-Za-z_]\w*)\s*(?::\s*[\w.\[\]]+\s*)?=\s*" + r"(\d[\d_]*|0x[0-9a-fA-F_]+)\s*(?:\*\s*\d[\d_]*\s*)*;" +) +NAME_IS_BOUND = re.compile(r"(^|_)(maximum|max|limit|capacity|depth|attempts|count)($|_)", re.I) + +# A declaration or struct field whose type carries a *literal* array length. +ARRAY_DECL = re.compile(r"^\s*(?:pub\s+)?(?:const|var)\s+([A-Za-z_]\w*)\s*:[^=]*?\[\s*(\d[\d_]*)\s*\]") +ARRAY_FIELD = re.compile(r"^\s*([A-Za-z_]\w*)\s*:\s*\[\s*(\d[\d_]*)\s*\]") + +# Struct padding and reserved fields are shapes, not ceilings — nothing is ever "held" +# in them. Everything else with a literal length is a candidate, *including* the tidy +# powers of two: `[512]u8` and `[64]Range` were the two worst findings in the audit, and +# any size-based exemption would have skipped exactly them. A length that is genuinely a +# fact rather than a ceiling says so in its declaration ("decided-by: hardware, the PCI +# spec gives a function 6 BARs") — that is what the declaration is for. +NOT_A_BOUND_NAME = re.compile(r"^_*(padding|pad|reserved|unused|spare)\d*$", re.I) + + +def sources(root: Path): + for top in ROOTS: + base = root / top + if not base.is_dir(): + continue + for path in sorted(base.rglob("*.zig")): + if SKIP_PARTS & set(path.parts) or path.name in SKIP_FILES: + continue + yield path + + +def declaration_above(lines, index): + """The `/// key: value` block immediately above line `index`, as a dict.""" + fields = {} + i = index - 1 + while i >= 0: + stripped = lines[i].strip() + if not stripped.startswith("///"): + break + body = stripped[3:].strip() + match = re.match(r"([a-z-]+):\s*(.+)", body) + if match: + fields[match.group(1)] = match.group(2).strip() + i -= 1 + return fields + + +def problems_with(fields): + """Why a declaration is not acceptable, or an empty list.""" + missing = [f for f in FIELDS if f not in fields or not fields[f]] + if missing: + return ["missing " + ", ".join(missing)] + out = [] + if fields["decided-by"] not in DECIDED_BY: + out.append(f"decided-by must be one of {sorted(DECIDED_BY)}, not {fields['decided-by']!r}") + verb = fields["at-limit"].split()[0].strip("-:,").lower() + if verb not in AT_LIMIT: + out.append( + f"at-limit must start with one of {sorted(AT_LIMIT)}, not {verb!r}. " + "There is deliberately no way to say 'silent', 'drop', or anything meaning 'allow'" + ) + if verb == "truncate" and len(fields["at-limit"].split()) < 3: + out.append("at-limit: truncate must say how a reader can TELL it happened") + return out + + +def find(root: Path): + """Every bound-shaped declaration: (relative path, name, value, line, fields).""" + for path in sources(root): + rel = path.relative_to(root).as_posix() + lines = path.read_text(encoding="utf-8", errors="replace").split("\n") + for n, line in enumerate(lines): + if line.lstrip().startswith("//"): + continue + name = value = None + m = NAMED.match(line) + if m and NAME_IS_BOUND.search(m.group(1)): + name, value = m.group(1), m.group(2) + else: + m = ARRAY_DECL.match(line) or ARRAY_FIELD.match(line) + if m and not NOT_A_BOUND_NAME.match(m.group(1)): + name, value = m.group(1), m.group(2) + if name: + yield rel, name, value, n + 1, declaration_above(lines, n) + + +def main(): + argv = [a for a in sys.argv[1:] if not a.startswith("--")] + listing = "--list" in sys.argv + root = Path(argv[0]) if argv else Path(__file__).resolve().parent.parent + + allow_path = root / "tools" / "bounds-allowlist.txt" + allowed = set() + if allow_path.exists(): + for raw in allow_path.read_text().split("\n"): + entry = raw.split("#", 1)[0].strip() + if entry: + allowed.add(entry) + + undeclared, bad, seen = [], [], set() + for rel, name, value, line, fields in find(root): + key = f"{rel}:{name}" + seen.add(key) + if not fields: + (undeclared if key not in allowed else []).append((key, value, line)) + continue + for why in problems_with(fields): + bad.append((key, line, why)) + if key in allowed and not problems_with(fields): + bad.append((key, line, "now declared — delete its line from tools/bounds-allowlist.txt")) + + if listing: + for key, value, line in sorted(undeclared): + print(f"{key} # = {value}, line {line}") + for key in sorted(allowed - seen): + print(f"# STALE: {key}") + return 0 + + stale = sorted(allowed - seen) + if not undeclared and not bad and not stale: + return 0 + + print("bounds check failed\n", file=sys.stderr) + for key, value, line in sorted(undeclared): + print(f" {key} (= {value}, line {line})", file=sys.stderr) + print(" no declaration. A ceiling states what it counts, who decides its", file=sys.stderr) + print(" size, what it protects, what happens at the limit, and how you", file=sys.stderr) + print(" find out. See docs/os-development/bounds.md.", file=sys.stderr) + for key, line, why in sorted(bad): + print(f" {key} (line {line}): {why}", file=sys.stderr) + for key in stale: + print(f" {key}: allowlisted but no longer found — delete its line", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main())