diff --git a/Makefile b/Makefile index a7237a8..2896938 100644 --- a/Makefile +++ b/Makefile @@ -6,9 +6,9 @@ # CI invokes these same targets rather than re-implementing them in YAML, which is # what prevents a gate silently degrading into a warning. There is no warning tier. -.PHONY: check check-provider-alignment check-native-catalog check-licensing check-public-claims check-deb-ordering check-reproducible check-sbom check-tests check-python-floor check-packaging check-native-catalog check-licensing check-public-claims check-privacy check-docs-truth check-current-state check-headers check-docs check-scope check-shell check-refs check-paths check-index check-freeze check-vectors check-vectors-negative check-vectors-crossversion check-gate-coverage check-falsifiable help +.PHONY: check check-provider-alignment check-native-catalog check-licensing check-public-claims check-deb-ordering check-reproducible check-sbom check-tests check-python-floor check-packaging check-storage-vocabulary check-native-catalog check-licensing check-public-claims check-privacy check-docs-truth check-current-state check-headers check-docs check-scope check-shell check-refs check-paths check-index check-freeze check-vectors check-vectors-negative check-vectors-crossversion check-gate-coverage check-falsifiable help -check: check-scope check-headers check-python-floor check-packaging check-native-catalog check-licensing check-public-claims check-privacy check-docs-truth check-current-state check-shell check-refs check-paths check-index check-freeze check-vectors check-vectors-negative check-vectors-crossversion check-tests check-docs +check: check-scope check-headers check-python-floor check-packaging check-storage-vocabulary check-native-catalog check-licensing check-public-claims check-privacy check-docs-truth check-current-state check-shell check-refs check-paths check-index check-freeze check-vectors check-vectors-negative check-vectors-crossversion check-tests check-docs @echo "make check: all gates passed" ## check-scope D-96: no product implementation before architecture freeze @@ -85,6 +85,11 @@ check-python-floor: check-packaging: @python3 scripts/ci/check_packaging.py +## check-storage-vocabulary D-114: a retired inference does not return as vocabulary +check-storage-vocabulary: + @python3 scripts/ci/check_storage_vocabulary.py --self-test + @python3 scripts/ci/check_storage_vocabulary.py + ## check-native-catalog D-111: the control catalog is ISEDRAF's own, and stays that way check-native-catalog: @python3 scripts/ci/check_native_catalog.py diff --git a/docs/CURRENT_STATE.md b/docs/CURRENT_STATE.md index 919112e..40a1f84 100644 --- a/docs/CURRENT_STATE.md +++ b/docs/CURRENT_STATE.md @@ -32,6 +32,7 @@ What exists and runs today. Nothing else on this page does. |---|---|---| | `w1a_evidence_contract` | `docs/architecture/freeze/W1A_CORE_PUBLIC.sha256` | — | | `artifact_attestation` | `.github/workflows/release-candidate.yml` | — | +| `block_storage_observation_model` | `lib/isedraf/inventory/collectors.py` | — | | `clean_public_export` | `scripts/ci/release_export.sh` | — | | `code_scanning` | `.github/workflows/codeql.yml` | — | | `consolidated_product_model` | `docs/architecture/ISEDRAF_PRODUCT_HLD.md` | `make check-public-claims` | @@ -110,8 +111,8 @@ Not asserted. Each number is counted at generation time. | | | |---|---| -| Gates | 18 | -| Falsification injections | 100 | +| Gates | 19 | +| Falsification injections | 111 | | Golden vector cases | 15 | | Frozen artifacts | 7 | | Test files | 3 | diff --git a/docs/IMPLEMENTATION_QUESTIONS.md b/docs/IMPLEMENTATION_QUESTIONS.md index 7465570..8275a7d 100644 --- a/docs/IMPLEMENTATION_QUESTIONS.md +++ b/docs/IMPLEMENTATION_QUESTIONS.md @@ -22,3 +22,4 @@ Assumptions only — **never authority**. The owner resolves; resolutions become | IQ-008 | CONFLICT | STORE-001 | `/run/isedraf/` | Implemented the lock at `/var/lib/isedraf/.lock` | The sketch placed the lock at `/run/isedraf/isedraf.lock`. `STORE-001` puts `.lock` inside the state root, which also makes the lock and the store it protects share a filesystem — a lock on a different filesystem cannot guarantee exclusion if the store is mounted elsewhere. `/run/isedraf/` remains available for future ephemeral state. | `STORE-001` | OPEN | | IQ-009 | GAP | SCOPE-077, SNAP-015 | `lib/isedraf/cli.py`, `lib/isedraf/verify.py` | Verification runs in-process at the end of `identity`; a failure exits `64` (engine error) | `SNAP-015` assigns `exit 5` to `integrity_failure`, and `SCOPE-077` states `5` is not reachable in W1-A. A standalone `isedraf verify` therefore has **no frozen exit code for "verification failed"**. No exit code was invented: verification stays in-process, and the standalone command is deferred until the W1-A exit set is extended or `verify` is scoped to a later set. | discovered implementing W1-B | OPEN | | IQ-010 | CONFLICT | SCOPE-070, SCOPE-071, SCOPE-072, STORE-001, PRIV-005 | `lib/isedraf/stateroot.py` | `resolve()` refuses to fall back to a production root this slice cannot reach, and names the requirements | **Not a contradiction between frozen rules — an implementation defect.** `SCOPE-070` already states W1 *"runs under `ISEDRAF_STATE_ROOT`"*, and `PRIV-005`'s Mode A — `sudo isedraf`, root supervisor inside a sandbox, state-root writability preflight — is the only execution topology that ever owns `/var/lib/isedraf`. Mode A is `DEFERRED_TO_FREEZE_SET_2` by `SCOPE-072`, so **W1 has no production mode by design**. The implementation was offering one and failing with a bare permission error. Answers for Freeze Set 2, when Mode A lands: the **root supervisor** creates and owns the root at mode `0700`; packaging creates it at install time; no group-readable relaxation, since `STORE-001` freezes `0700`; `sudo -u` is not a path, because `SCOPE-071` refuses anything reached through sudo and `PRIV-004` refuses when `SUDO_USER` is set | `env -u ISEDRAF_STATE_ROOT isedraf identity` now explains rather than failing on `EACCES` | **CLOSED_IMPLEMENTATION_FIX** | +| IQ-011 | GAP | D-114, STORAGE-SEMANTICS-001 | `docs/reference/samples/` (withdrawn), `docs/reference/CONTROL_EVIDENCE_MAP.md` | The rendered sample report was **withdrawn** rather than edited. It was real output from disposable lab VM `isd-a9` (AlmaLinux 9.7, KVM) and reported a storage device `type` of `ROTATIONAL` / `OPTICAL` — the exact inference D-114 retired, published as an example of what the tool claims. | It could not be regenerated: `isd-a9` no longer exists, the surviving lab corpus belongs to another project and is read-only, and `kernel_subsystem`, `queue_rotational`, `kernel_removable` and `scsi_peripheral_type` were never collected from that host, so re-rendering the old collection cannot produce them. Writing plausible values would be inventing evidence for a machine nobody can re-observe, which is the one failure this project exists to prevent. **Proposal:** regenerate after the RC on a disposable VM created for the purpose, and add a documentation generator so the sample is reproducible from committed bytes rather than from a VM that only one person ever had. A `check_storage_vocabulary` gate now rejects the retired vocabulary if it returns. | `git log 6ca913e`; storage semantics proofs, this milestone | OPEN | diff --git a/docs/reference/CONTROL_EVIDENCE_MAP.md b/docs/reference/CONTROL_EVIDENCE_MAP.md index 156f2a7..6710b6b 100644 --- a/docs/reference/CONTROL_EVIDENCE_MAP.md +++ b/docs/reference/CONTROL_EVIDENCE_MAP.md @@ -202,8 +202,13 @@ Report | Collection completeness | both | per-subdomain status | | Limitations | both | what the evidence does not support | -A sample of the real output is in [`samples/SAMPLE_REPORT.md`](samples/SAMPLE_REPORT.md), generated on a -lab VM and not edited. +A rendered sample of the real output is **not published with this preview**. The previous sample was +generated on a disposable lab VM that no longer exists, and it describes the retired storage schema: +it reports a device `type` of `ROTATIONAL` or `OPTICAL`, which is exactly the inference the storage +observation model now refuses to make. It could not be migrated, because the fields that replaced +that one - `kernel_subsystem`, `queue_rotational`, `kernel_removable`, `scsi_peripheral_type` - were +never collected from that host, and writing plausible values for them would be inventing evidence. +A sample returns when it can be produced by running the tool, not by editing a document. ## Domains not yet mapped diff --git a/docs/reference/samples/SAMPLE_REPORT.json b/docs/reference/samples/SAMPLE_REPORT.json deleted file mode 100644 index 84e47ec..0000000 --- a/docs/reference/samples/SAMPLE_REPORT.json +++ /dev/null @@ -1,466 +0,0 @@ -{ - "assessment": { - "customer": "Example Company S.A.", - "organization": "ITCMS", - "prepared_by": "A. Reviewer", - "reference": "AUDIT-2026-017" - }, - "collection_summary": [ - { - "collection_status": "COLLECTED", - "method": "/proc/sys/kernel/hostname + /etc/hosts", - "reason": null, - "subdomain": "host" - }, - { - "collection_status": "COLLECTED", - "method": "/etc/os-release + uname(2) + /proc/1/comm", - "reason": null, - "subdomain": "platform" - }, - { - "collection_status": "COLLECTED", - "method": "systemd-detect-virt | /sys/hypervisor + /sys/class/dmi/id", - "reason": null, - "subdomain": "machine" - }, - { - "collection_status": "COLLECTED", - "method": "/proc/cpuinfo", - "reason": null, - "subdomain": "compute" - }, - { - "collection_status": "COLLECTED", - "method": "/proc/meminfo", - "reason": null, - "subdomain": "memory" - }, - { - "collection_status": "COLLECTED", - "method": "/sys/block + /proc/self/mounts + statvfs(2)", - "reason": null, - "subdomain": "storage" - }, - { - "collection_status": "COLLECTED", - "method": "ip -json addr/route", - "reason": null, - "subdomain": "network" - }, - { - "collection_status": "COLLECTED", - "method": "/etc/resolv.conf", - "reason": null, - "subdomain": "dns" - }, - { - "collection_status": "COLLECTED", - "method": "timedatectl show + chronyc tracking", - "reason": null, - "subdomain": "time" - } - ], - "identity_evidence": { - "available": true, - "collection_status": "COLLECTED", - "created_at": "2026-09-18T17:38:25Z", - "evidence_root": "/tmp/tmpiv5d2cqc/state", - "host_id": "sha256:672ebe0048dd70ce5695bae49ac535c13caaf344e0c8833f84c5f1fcb46bf746", - "ledger_record_hash": "sha256:ccb6f10f5214a4c36dd39032ff31f197eb3091a6adc819429d8378e37868a66e", - "ledger_sequence": 1, - "manifest_hash": "sha256:053313f08b9f173212225d855937b01f43dfa2f4e336421f89e8718aad2d6f1a", - "maturity": "W1-A CERTIFIED — snapshot-bound, manifest-hashed, ledger-chained", - "reason": null, - "run_id": "RUN-20260918T173825Z-4f88e5488b22c90e", - "snapshot_id": "SDS-20260918T173825Z-e4d0e8596510180c", - "state_hash": "sha256:fb74919253ca54f43a12b3260cc13e1651b9d8065e68fcad3161d5c071efe63d", - "state_root_class": "DEV", - "verification": { - "problems": [], - "verified": true - } - }, - "inventory": { - "artifact_digest": "sha256:528c0c991f4f59b12463767aaef828d0f7cab70c7b2a0aa5980d42b26b984560", - "collected_at": "2026-09-18T00:00:00Z", - "collection_id": "INV-20260918T000000Z-0000000000000000", - "collection_status": "COLLECTED", - "maturity": "W1-C1 — collected and normalized, bound by an artifact digest. NOT part of the frozen W1-A snapshot contract (SNAP-021)", - "schema_version": 1, - "subdomains": { - "compute": { - "collection_status": "COLLECTED", - "data": { - "cores_per_socket": 1, - "cpu_model": "Intel(R) Xeon(R) CPU E5-2640 0 @ 2.50GHz", - "cpu_vendor": "GenuineIntel", - "logical_cpus": 2, - "sockets": 2 - }, - "method": "/proc/cpuinfo", - "reason": null, - "state_dimension": "ACTIVE" - }, - "dns": { - "collection_status": "COLLECTED", - "data": { - "method": "/etc/resolv.conf", - "note": null, - "servers": [ - "192.168.122.1" - ] - }, - "method": "/etc/resolv.conf", - "reason": null, - "state_dimension": "RESOLVED" - }, - "host": { - "collection_status": "COLLECTED", - "data": { - "fqdn": null, - "fqdn_source": "NOT_AVAILABLE_LOCALLY", - "hostname": "isd-a9" - }, - "method": "/proc/sys/kernel/hostname + /etc/hosts", - "reason": null, - "state_dimension": "ACTIVE" - }, - "machine": { - "collection_status": "COLLECTED", - "data": { - "hypervisor": "kvm", - "product": "Ubuntu 24.04 PC v2 (i440FX + PIIX, arch_caps fix, 1996)", - "vendor": "QEMU", - "virtualized": true - }, - "method": "systemd-detect-virt | /sys/hypervisor + /sys/class/dmi/id", - "reason": null, - "state_dimension": "ACTIVE" - }, - "memory": { - "collection_status": "COLLECTED", - "data": { - "swap_total_bytes": 0, - "total_bytes": 2058444800 - }, - "method": "/proc/meminfo", - "reason": null, - "state_dimension": "ACTIVE" - }, - "network": { - "collection_status": "COLLECTED", - "data": { - "default_routes": [ - { - "family": "inet", - "gateway": "192.168.122.1", - "interface": "eth0" - } - ], - "interfaces": [ - { - "loopback": true, - "mtu": 65536, - "name": "lo", - "state": "UNKNOWN" - }, - { - "loopback": false, - "mtu": 1500, - "name": "eth0", - "state": "UP" - } - ], - "ipv4": [ - { - "address": "127.0.0.1", - "interface": "lo", - "loopback": true, - "prefix_length": 8, - "scope": "host" - }, - { - "address": "192.168.122.54", - "interface": "eth0", - "loopback": false, - "prefix_length": 24, - "scope": "global" - } - ], - "ipv6_stable": [ - { - "address": "::1", - "classification": "LOOPBACK", - "interface": "lo", - "prefix_length": 128, - "scope": "host" - }, - { - "address": "fe80::5054:ff:fed8:b374", - "classification": "LINK_LOCAL", - "interface": "eth0", - "prefix_length": 64, - "scope": "link" - } - ], - "ipv6_volatile": [] - }, - "method": "ip -json addr/route", - "reason": null, - "state_dimension": "ACTIVE" - }, - "platform": { - "collection_status": "COLLECTED", - "data": { - "architecture": "x86_64", - "family": "rhel centos fedora", - "id": "almalinux", - "init_system": "systemd", - "kernel_release": "5.14.0-611.45.1.el9_7.x86_64", - "pretty_name": "AlmaLinux 9.7 (Moss Jungle Cat)", - "version_id": "9.7" - }, - "method": "/etc/os-release + uname(2) + /proc/1/comm", - "reason": null, - "state_dimension": "ACTIVE" - }, - "storage": { - "collection_status": "COLLECTED", - "data": { - "devices": [ - { - "model": "QEMU HARDDISK", - "name": "sda", - "removable": false, - "size_bytes": 12884901888, - "type": "ROTATIONAL" - }, - { - "model": "QEMU DVD-ROM", - "name": "sr0", - "removable": true, - "size_bytes": 374784, - "type": "OPTICAL" - } - ], - "filesystems": [ - { - "fstype": "tmpfs", - "mount_point": "/dev/shm", - "options": [ - "inode64", - "nodev", - "nosuid", - "rw", - "seclabel" - ], - "read_only": false, - "source": "tmpfs" - }, - { - "fstype": "tmpfs", - "mount_point": "/run", - "options": [ - "inode64", - "mode=755", - "nodev", - "nosuid", - "nr_inodes=819200", - "rw", - "seclabel", - "size=402040k" - ], - "read_only": false, - "source": "tmpfs" - }, - { - "fstype": "xfs", - "mount_point": "/", - "options": [ - "attr2", - "inode64", - "logbsize=32k", - "logbufs=8", - "noquota", - "relatime", - "rw", - "seclabel" - ], - "read_only": false, - "source": "/dev/sda4" - }, - { - "fstype": "selinuxfs", - "mount_point": "/sys/fs/selinux", - "options": [ - "noexec", - "nosuid", - "relatime", - "rw" - ], - "read_only": false, - "source": "selinuxfs" - }, - { - "fstype": "xfs", - "mount_point": "/boot", - "options": [ - "attr2", - "inode64", - "logbsize=32k", - "logbufs=8", - "noquota", - "relatime", - "rw", - "seclabel" - ], - "read_only": false, - "source": "/dev/sda3" - }, - { - "fstype": "vfat", - "mount_point": "/boot/efi", - "options": [ - "codepage=437", - "dmask=0077", - "errors=remount-ro", - "fmask=0077", - "iocharset=ascii", - "relatime", - "rw", - "shortname=winnt" - ], - "read_only": false, - "source": "/dev/sda2" - }, - { - "fstype": "rpc_pipefs", - "mount_point": "/var/lib/nfs/rpc_pipefs", - "options": [ - "relatime", - "rw" - ], - "read_only": false, - "source": "sunrpc" - }, - { - "fstype": "tmpfs", - "mount_point": "/run/user/1000", - "options": [ - "gid=1000", - "inode64", - "mode=700", - "nodev", - "nosuid", - "nr_inodes=50255", - "relatime", - "rw", - "seclabel", - "size=201020k", - "uid=1000" - ], - "read_only": false, - "source": "tmpfs" - } - ], - "utilisation": [ - { - "available_bytes": 1029222400, - "mount_point": "/dev/shm", - "total_bytes": 1029222400, - "used_permille": 0 - }, - { - "available_bytes": 405987328, - "mount_point": "/run", - "total_bytes": 411688960, - "used_permille": 14 - }, - { - "available_bytes": 10647334912, - "mount_point": "/", - "total_bytes": 11532218368, - "used_permille": 77 - }, - { - "available_bytes": 857051136, - "mount_point": "/boot", - "total_bytes": 1006632960, - "used_permille": 149 - }, - { - "available_bytes": 201715712, - "mount_point": "/boot/efi", - "total_bytes": 209489920, - "used_permille": 37 - }, - { - "available_bytes": 205844480, - "mount_point": "/run/user/1000", - "total_bytes": 205844480, - "used_permille": 0 - } - ] - }, - "method": "/sys/block + /proc/self/mounts + statvfs(2)", - "reason": null, - "state_dimension": "ACTIVE" - }, - "time": { - "collection_status": "COLLECTED", - "data": { - "ntp_enabled": true, - "offset_nanoseconds": 131300, - "provider": "chrony", - "source": "A29FC801", - "stratum": 4, - "synchronized": true, - "timezone": "UTC", - "uptime_seconds": 31 - }, - "method": "timedatectl show + chronyc tracking", - "reason": null, - "state_dimension": "RESOLVED" - } - } - }, - "limitations": [ - "Host identity derives from a locally readable machine identity. It is not remote attestation, and a local root adversary can change the source it derives from.", - "Cloned systems share a machine identity when cloning did not regenerate it, so two hosts can legitimately present the same host_id.", - "Hardware facts — CPU model, core counts, memory capacity, disk topology — are observations, not configuration. They change legitimately on virtualized hosts and do not by themselves indicate drift.", - "Network addresses and routes describe local configuration. They do not prove that anything outside this host can reach it.", - "Configured DNS servers are what this host is told to use. No query was performed, so nothing here describes resolution behaviour.", - "Host inventory is not part of the frozen snapshot contract. It is bound to this report by an artifact digest, which is a content digest and not a snapshot hash.", - "Assessment details identify the person or organization declared in the report metadata. They are declarative and are NOT cryptographically authenticated; this report is not signed." - ], - "provenance": { - "collection_methods": { - "compute": "/proc/cpuinfo", - "dns": "/etc/resolv.conf", - "host": "/proc/sys/kernel/hostname + /etc/hosts", - "machine": "systemd-detect-virt | /sys/hypervisor + /sys/class/dmi/id", - "memory": "/proc/meminfo", - "network": "ip -json addr/route", - "platform": "/etc/os-release + uname(2) + /proc/1/comm", - "storage": "/sys/block + /proc/self/mounts + statvfs(2)", - "time": "timedatectl show + chronyc tracking" - }, - "engine_version": "0.0.0-pre", - "inventory_schema_version": 1, - "report_schema_version": 1 - }, - "report": { - "engine_version": "0.0.0-pre", - "generated_at": "2026-09-18T00:00:00Z", - "report_id": "RPT-FIXED", - "status": "COMPLETE" - }, - "report_schema_version": 1, - "target": { - "fqdn": null, - "fqdn_source": "NOT_AVAILABLE_LOCALLY", - "host_id": "sha256:672ebe0048dd70ce5695bae49ac535c13caaf344e0c8833f84c5f1fcb46bf746", - "hostname": "isd-a9" - } -} diff --git a/docs/reference/samples/SAMPLE_REPORT.md b/docs/reference/samples/SAMPLE_REPORT.md deleted file mode 100644 index 853c672..0000000 --- a/docs/reference/samples/SAMPLE_REPORT.md +++ /dev/null @@ -1,186 +0,0 @@ - - - -# ISEDRAF System Assurance Report - -| | | -|---|---| -| Report ID | `RPT-FIXED` | -| Generated (UTC) | 2026-09-18T00:00:00Z | -| ISEDRAF version | 0.0.0-pre | -| Report status | **COMPLETE** | - -> This report describes what was **observed**. It contains no secure/insecure verdict, because host inventory is evidence and a score would be a judgement the data does not support. - -## Assessment - -| | | -|---|---| -| Prepared by | A. Reviewer | -| Organization | ITCMS | -| Customer | Example Company S.A. | -| Reference | AUDIT-2026-017 | - -## Target - -| | | -|---|---| -| Hostname | isd-a9 | -| FQDN | *not resolvable locally — no resolver was consulted* | -| Host ID | `sha256:672ebe0048dd70ce5695bae49ac535c13caaf344e0c8833f84c5f1fcb46bf746` | - -## Evidence - -### Host identity — W1-A CERTIFIED — snapshot-bound, manifest-hashed, ledger-chained - -| | | -|---|---| -| Snapshot ID | `SDS-20260918T173825Z-e4d0e8596510180c` | -| Manifest hash | `sha256:053313f08b9f173212225d855937b01f43dfa2f4e336421f89e8718aad2d6f1a` | -| State hash | `sha256:fb74919253ca54f43a12b3260cc13e1651b9d8065e68fcad3161d5c071efe63d` | -| Ledger record | sequence 1, `sha256:ccb6f10f5214a4c36dd39032ff31f197eb3091a6adc819429d8378e37868a66e` | -| Collection status | COLLECTED | -| Evidence root | `/tmp/tmpiv5d2cqc/state` (DEV) | -| Independent verification | **PASS** — every hash recomputed from the stored preimages | - -### Host inventory — W1-C1 — collected and normalized, bound by an artifact digest. NOT part of the frozen W1-A snapshot contract (SNAP-021) - -| | | -|---|---| -| Collection ID | `INV-20260918T000000Z-0000000000000000` | -| Artifact digest | `sha256:528c0c991f4f59b12463767aaef828d0f7cab70c7b2a0aa5980d42b26b984560` | -| Collected (UTC) | 2026-09-18T00:00:00Z | -| Inventory schema | 1 | -| Collection status | COLLECTED | - -The artifact digest is a content SHA-256 over the deterministic inventory artifact. It is **not** a snapshot hash and the inventory is **not** inside the frozen snapshot. - -## Platform - -| | | -|---|---| -| Operating system | AlmaLinux 9.7 (Moss Jungle Cat) | -| Version | 9.7 | -| Family | rhel centos fedora | -| Kernel | `5.14.0-611.45.1.el9_7.x86_64` | -| Architecture | x86_64 | -| Init system | systemd | -| Machine | virtual | -| Hypervisor | kvm | -| Vendor / product | QEMU / Ubuntu 24.04 PC v2 (i440FX + PIIX, arch_caps fix, 1996) | - -## Compute and memory - -*Hardware observations. They change legitimately on virtualized hosts and do not by themselves indicate configuration drift.* - -| | | -|---|---| -| CPU | Intel(R) Xeon(R) CPU E5-2640 0 @ 2.50GHz | -| Vendor | GenuineIntel | -| Sockets / cores per socket | 2 / 1 | -| Logical CPUs | 2 | -| Memory | 1.9 GiB | -| Swap | — | - -## Storage - -| Device | Size | Class | Removable | Model | -|---|---|---|---|---| -| `sda` | 12.9 GB | ROTATIONAL | no | QEMU HARDDISK | -| `sr0` | 0.0 GB | OPTICAL | yes | QEMU DVD-ROM | - -| Mount point | Source | Type | Mode | Options | -|---|---|---|---|---| -| `/dev/shm` | `tmpfs` | tmpfs | read-write | `inode64,nodev,nosuid,rw,seclabel` | -| `/run` | `tmpfs` | tmpfs | read-write | `inode64,mode=755,nodev,nosuid,nr_inodes=819200,rw` | -| `/` | `/dev/sda4` | xfs | read-write | `attr2,inode64,logbsize=32k,logbufs=8,noquota,relatime` | -| `/sys/fs/selinux` | `selinuxfs` | selinuxfs | read-write | `noexec,nosuid,relatime,rw` | -| `/boot` | `/dev/sda3` | xfs | read-write | `attr2,inode64,logbsize=32k,logbufs=8,noquota,relatime` | -| `/boot/efi` | `/dev/sda2` | vfat | read-write | `codepage=437,dmask=0077,errors=remount-ro,fmask=0077,iocharset=ascii,relatime` | -| `/var/lib/nfs/rpc_pipefs` | `sunrpc` | rpc_pipefs | read-write | `relatime,rw` | -| `/run/user/1000` | `tmpfs` | tmpfs | read-write | `gid=1000,inode64,mode=700,nodev,nosuid,nr_inodes=50255` | - -**Utilisation** — a volatile observation, not configuration: - -| Mount point | Used | -|---|---| -| `/dev/shm` | 0.0% | -| `/run` | 1.4% | -| `/` | 7.7% | -| `/boot` | 14.9% | -| `/boot/efi` | 3.7% | -| `/run/user/1000` | 0.0% | - -## Network - -| Interface | State | MTU | -|---|---|---| -| `eth0` | UP | 1500 | - -**IPv4** - -| Address | Interface | Scope | -|---|---|---| -| `192.168.122.54/24` | `eth0` | global | - -**IPv6** - -| Address | Interface | Classification | -|---|---|---| -| `fe80::5054:ff:fed8:b374/64` | `eth0` | LINK_LOCAL | - -| Default route | Gateway | Interface | -|---|---|---| -| inet | `192.168.122.1` | `eth0` | - -**DNS** — `192.168.122.1` - -Source: `/etc/resolv.conf` - -Addresses and routes describe local configuration. They do not prove that anything outside this host can reach it. - -## Time - -| | | -|---|---| -| Timezone | UTC | -| NTP provider | chrony | -| NTP enabled | yes | -| **Clock synchronized** | yes | -| Current source | `A29FC801` (stratum 4) | -| Offset | 0.131 ms | - -*NTP enabled and clock synchronized are different facts.* Configuration says what was intended; synchronization says what is true now. - -## Collection completeness - -| Subdomain | Status | Method | Reason | -|---|---|---|---| -| host | COLLECTED | `/proc/sys/kernel/hostname + /etc/hosts` | — | -| platform | COLLECTED | `/etc/os-release + uname(2) + /proc/1/comm` | — | -| machine | COLLECTED | `systemd-detect-virt | /sys/hypervisor + /sys/class/dmi/id` | — | -| compute | COLLECTED | `/proc/cpuinfo` | — | -| memory | COLLECTED | `/proc/meminfo` | — | -| storage | COLLECTED | `/sys/block + /proc/self/mounts + statvfs(2)` | — | -| network | COLLECTED | `ip -json addr/route` | — | -| dns | COLLECTED | `/etc/resolv.conf` | — | -| time | COLLECTED | `timedatectl show + chronyc tracking` | — | - -An incomplete observation is reported as incomplete. A missing tool is `NOT_TESTED`, a present tool that failed is `ERROR`, and neither is rendered as an empty success. - -## Limitations - -- Host identity derives from a locally readable machine identity. It is not remote attestation, and a local root adversary can change the source it derives from. -- Cloned systems share a machine identity when cloning did not regenerate it, so two hosts can legitimately present the same host_id. -- Hardware facts — CPU model, core counts, memory capacity, disk topology — are observations, not configuration. They change legitimately on virtualized hosts and do not by themselves indicate drift. -- Network addresses and routes describe local configuration. They do not prove that anything outside this host can reach it. -- Configured DNS servers are what this host is told to use. No query was performed, so nothing here describes resolution behaviour. -- Host inventory is not part of the frozen snapshot contract. It is bound to this report by an artifact digest, which is a content digest and not a snapshot hash. -- Assessment details identify the person or organization declared in the report metadata. They are declarative and are NOT cryptographically authenticated; this report is not signed. - diff --git a/lib/isedraf/cli.py b/lib/isedraf/cli.py index 4d89821..2711a68 100644 --- a/lib/isedraf/cli.py +++ b/lib/isedraf/cli.py @@ -161,9 +161,14 @@ def field(block, name): out.write("\nSTORAGE\n") for dev in field("storage", "devices") or []: - out.write(" %-17s : %s, %s\n" % ( + # D-114: the observations, not an invented class. `queue_rotational` is + # printed as a queue property because that is what it is. + rot = dev.get("queue_rotational") + rot_text = "rotational" if rot is True else ( + "non-rotational" if rot is False else "rotational unknown") + out.write(" %-17s : %s, %s, %s\n" % ( dev["name"], "%.1f GB" % (dev["size_bytes"] / 1e9) if dev["size_bytes"] else "?", - dev["type"])) + dev.get("kernel_subsystem") or "subsystem unknown", rot_text)) for fs in (field("storage", "filesystems") or [])[:8]: out.write(" %-17s : %s%s\n" % (fs["mount_point"], fs["fstype"], " (read-only)" if fs["read_only"] else "")) diff --git a/lib/isedraf/inventory/collectors.py b/lib/isedraf/inventory/collectors.py index 4d469e2..5f9983a 100644 --- a/lib/isedraf/inventory/collectors.py +++ b/lib/isedraf/inventory/collectors.py @@ -152,16 +152,37 @@ def dmi(name): if cpuinfo.ok: virtualized = bool(re.search(r"^flags\s*:.*\bhypervisor\b", cpuinfo.value, re.M)) + vendor, product = dmi("sys_vendor"), dmi("product_name") data = {"virtualized": virtualized, "hypervisor": hypervisor, - "vendor": dmi("sys_vendor"), "product": dmi("product_name")} - known = [v for v in data.values() if v is not None] - status = model.COLLECTED if len(known) >= 2 else model.PARTIAL + "vendor": vendor, "product": product} + + # Status is decided PER SOURCE, not by counting non-null values across the whole + # subdomain. The previous rule was `len(known) >= 2` over all four fields, and + # `virtualized` and `hypervisor` counted toward it - so on any virtualized host + # without SMBIOS (a cloud image, a container, a minimal VM) DMI could be entirely + # absent and the subdomain still reported COLLECTED, with vendor and product null + # and no reason, because a reason is only attached to PARTIAL. + # + # That is an incomplete collection reported as complete, which is the one thing an + # evidence engine cannot be wrong about. The Raspberry Pi case was handled correctly + # by accident: no DMI AND no hypervisor happened to fall below the threshold. + # BOTH identity fields, not either. One present and one absent is still an + # incomplete collection, and reporting COLLECTED with a null field is the same + # defect one size smaller. + missing = [n for n, v in (("vendor", vendor), ("product", product)) if v is None] + if not missing: + status, reason = model.COLLECTED, None + else: + status = model.PARTIAL + reason = ("SOURCE_ABSENT: /sys/class/dmi/id exposes no %s on this platform, so " + "machine identity is incomplete. Common on ARM single-board computers " + "and on virtualized or containerized hosts without SMBIOS. " + "Virtualization detection is reported separately and does not " + "establish machine identity." % " or ".join(missing)) return model.subdomain( status, data, method="systemd-detect-virt | /sys/hypervisor + /sys/class/dmi/id", - reason=None if status == model.COLLECTED - else "SOURCE_ABSENT: DMI is not exposed on this platform (common on ARM " - "single-board computers) and no hypervisor could be identified") + reason=reason) # --- compute and memory -------------------------------------------------------------------- @@ -229,30 +250,59 @@ def collect_storage(root="/"): devmodel = read_file(os.path.join(block, name, "device/model")) removable = read_file(os.path.join(block, name, "removable")) scsi_type = read_file(os.path.join(block, name, "device/type")) - is_removable = removable.ok and removable.value.strip() == "1" - - # Order matters. `rotational == 0` is true of an optical drive as well as an - # SSD, so the optical checks come FIRST - otherwise a DVD-ROM is reported as - # solid-state storage, which is what the first published sample did. - kind = model.DEVICE_UNKNOWN - if name.startswith("nvme"): - kind = model.DEVICE_NVME - elif name.startswith(("sr", "scd")) or ( - scsi_type.ok and scsi_type.value.strip() == "5"): # SCSI TYPE_ROM - kind = model.DEVICE_OPTICAL - elif name.startswith("vd"): - # virtio-blk. What backs it is not observable from the guest, so naming - # the transport is the honest answer rather than guessing the medium. - kind = model.DEVICE_VIRTUAL - elif rotational.ok: - kind = (model.DEVICE_ROTATIONAL if rotational.value.strip() == "1" - else model.DEVICE_SOLID_STATE) + devvendor = read_file(os.path.join(block, name, "device/vendor")) + + # D-114. Each dimension is recorded as itself. Nothing here infers a + # physical medium, a transport, or that this block device corresponds to + # one physical disk - an HP RAID logical volume presents as a single + # device over four of them. + # + # kernel_subsystem comes from the kernel's own device/subsystem link, not + # from a name prefix. FIXTURE-CONFINEMENT-001: the link is read LEXICALLY. + # os.path.realpath() would resolve it against the REAL filesystem and read + # the host's /sys/bus while believing it was reading a fixture root - the + # same escape that once let timedatectl run against the live host. + subsystem = None + link = os.path.join(block, name, "device", "subsystem") + try: + if os.path.islink(link): + subsystem = os.path.basename(os.readlink(link).rstrip("/")) or None + except OSError: + subsystem = None + + rot = None + if rotational.ok: + v = rotational.value.strip() + rot = True if v == "1" else (False if v == "0" else None) + + removable_flag = None + if removable.ok: + v = removable.value.strip() + removable_flag = True if v == "1" else (False if v == "0" else None) + + # Meaningful only within the SCSI family. A global `device/type == 5` test + # would be an assumption buried in a branch; scoping it is part of the + # field's definition. + peripheral = None + if subsystem == "scsi" and scsi_type.ok: + try: + peripheral = int(scsi_type.value.strip()) + except ValueError: + peripheral = None + + # Stable keys with null, never a shape that varies by subsystem: the same + # idiom SNAP-023 already fixes for manifest_core.host_id. A missing key is + # ambiguous - old schema, conditional serialization, or a defect - where + # null says the field belongs here and no value was established. devices.append({ "name": name, # /sys/block/*/size is in 512-byte sectors regardless of logical size. "size_bytes": int(size.value.strip()) * 512 if size.ok else None, - "type": kind, - "removable": is_removable, + "kernel_subsystem": subsystem, + "queue_rotational": rot, + "kernel_removable": removable_flag, + "scsi_peripheral_type": peripheral, + "vendor": devvendor.value.strip() if devvendor.ok else None, "model": devmodel.value.strip() if devmodel.ok else None, }) diff --git a/lib/isedraf/inventory/model.py b/lib/isedraf/inventory/model.py index a1a2be5..6522765 100644 --- a/lib/isedraf/inventory/model.py +++ b/lib/isedraf/inventory/model.py @@ -51,12 +51,21 @@ # --- block device classes --------------------------------------------------------------- # `rotational == 0` does not mean "SSD". An optical drive reports 0 too, which is how a # QEMU DVD-ROM was classified as SOLID_STATE in the first sample report anyone read. -DEVICE_NVME = "NVME" -DEVICE_SOLID_STATE = "SOLID_STATE" -DEVICE_ROTATIONAL = "ROTATIONAL" -DEVICE_OPTICAL = "OPTICAL" -DEVICE_VIRTUAL = "VIRTUAL" -DEVICE_UNKNOWN = "UNKNOWN" +# D-114: the overloaded `type` enum is RETIRED, not deprecated. It conflated kernel +# subsystem (NVME, VIRTUAL), block-queue behaviour (ROTATIONAL, SOLID_STATE) and +# peripheral class (OPTICAL) into one apparently authoritative answer, and the +# `rotational == 0 -> SOLID_STATE` branch made an SD card a solid-state drive and, +# before that, a DVD-ROM one. +# +# A convenient but semantically broken field that remains readable continues to be read, +# so there is no compatibility alias. Replaced by the dimensions Linux actually exposes: +# kernel_subsystem, queue_rotational, kernel_removable, scsi_peripheral_type. +# +# STORAGE-SEMANTICS-001: +# queue_rotational = false MUST NOT imply SOLID_STATE +# kernel_subsystem = scsi MUST NOT imply SATA / SAS / USB / iSCSI / FC +# kernel_subsystem = nvme MUST NOT imply a solid-state physical medium +# a block-layer object MUST NOT be assumed to represent one physical device # --- IPv6 address classes --------------------------------------------------------------- # Flattening these is how privacy addresses become permanent false churn: RFC 4941 diff --git a/lib/isedraf/report/render.py b/lib/isedraf/report/render.py index aec1ede..f2a16f8 100644 --- a/lib/isedraf/report/render.py +++ b/lib/isedraf/report/render.py @@ -172,15 +172,31 @@ def data(name): out.append("") devices = storage.get("devices") or [] if devices: - out.append("| Device | Size | Class | Removable | Model |") - out.append("|---|---|---|---|---|") + # D-114. Column headers name the SOURCE, so a reader cannot mistake a kernel + # flag for a physical fact: "Kernel removable flag", not "Removable". + out.append("| Device | Size | Kernel subsystem | Queue rotational | " + "Kernel removable flag | Vendor | Model |") + out.append("|---|---|---|---|---|---|---|") for dev in devices: size = "%.1f GB" % (dev["size_bytes"] / 1e9) if dev.get("size_bytes") else "—" - out.append("| `%s` | %s | %s | %s | %s |" - % (dev["name"], size, dev.get("type") or "—", - "yes" if dev.get("removable") else "no", + + def tri(v): + return "—" if v is None else ("true" if v else "false") + + out.append("| `%s` | %s | %s | %s | %s | %s | %s |" + % (dev["name"], size, + dev.get("kernel_subsystem") or "—", + tri(dev.get("queue_rotational")), + tri(dev.get("kernel_removable")), + dev.get("vendor") or "—", dev.get("model") or "—")) out.append("") + out.append("*Kernel-reported block-device attributes. `Queue rotational` and " + "`Kernel removable flag` describe how Linux presents the block " + "queue; neither establishes the physical storage medium, and a " + "block device does not necessarily correspond to one physical " + "disk (D-114).*") + out.append("") filesystems = storage.get("filesystems") or [] if filesystems: out.append("| Mount point | Source | Type | Mode | Options |") diff --git a/scripts/ci/check_gate_coverage.py b/scripts/ci/check_gate_coverage.py index 696a8b6..a8c5c6c 100644 --- a/scripts/ci/check_gate_coverage.py +++ b/scripts/ci/check_gate_coverage.py @@ -123,6 +123,31 @@ def job_blocks(text): else: fail.append(f"{name}: declared scope {pat!r} matches no files - stale declaration") +# Every check_* script on disk must be accounted for in exactly one place. +# +# Until this existed, the gate verified only what gate_coverage.json DECLARED. A gate +# that nobody declared was not reported as uncovered - it was not reported at all, and +# the summary line still said every gate was wired, reached and falsifiable. That is the +# failure mode this whole file exists to prevent, one level up: an absence that reads as +# a pass. A newly added gate is now either aggregated into `make check` or listed with +# the reason it is not, and an orphaned script cannot sit in scripts/ci/ unrun. +declared_scripts = {g["script"] for g in spec["gates"].values()} +outside = spec.get("gates_outside_make_check", {}) +declared_scripts |= {g["script"] for k, g in outside.items() if not k.startswith("$")} + +for script in sorted(ROOT.glob("scripts/ci/check_*")): + if script.suffix not in (".py", ".sh"): + continue + rel = script.relative_to(ROOT).as_posix() + if rel not in declared_scripts: + fail.append(f"{rel}: a gate script that gate_coverage.json does not account for. " + f"Add it to `gates` if `make check` should run it, or to " + f"`gates_outside_make_check` with the reason it does not [GOV-001]") + +for rel in sorted(declared_scripts): + if not (ROOT / rel).exists(): + fail.append(f"{rel}: declared in gate_coverage.json but the script does not exist") + if fail: print("=== gate coverage FAILED ===", file=sys.stderr) for f in sorted(set(fail)): diff --git a/scripts/ci/check_storage_vocabulary.py b/scripts/ci/check_storage_vocabulary.py new file mode 100644 index 0000000..8a409bc --- /dev/null +++ b/scripts/ci/check_storage_vocabulary.py @@ -0,0 +1,179 @@ +# ============================================================================= +# ISEDRAF — Linux Host Assurance, State Delta & Evidence Engine (codename) +# ============================================================================= +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: Copyright (c) 2026 Antonios Voulvoulis / ITCMS +# +# Purpose: Keep a retired inference from coming back as vocabulary. +# Implements: D-114, STORAGE-SEMANTICS-001, GOV-002 +# +# D-114 removed a storage field named `type` whose value was one of ROTATIONAL, +# SOLID_STATE, OPTICAL, REMOVABLE, VIRTUAL or NVME. Every one of those was a guess +# dressed as an observation: Linux exposes `queue/rotational`, a subsystem symlink and +# a SCSI peripheral type, and none of them establishes the physical medium. The engine +# now records what the kernel exposes and names the source in the column header. +# +# The code change was the easy half. The word ROTATIONAL had also travelled into a +# published sample report, where it sat as an example of what ISEDRAF claims, three +# schema versions after the field was gone. Nothing failed, because no gate reads +# documentation for retired vocabulary — the sample was the last consumer anyone +# thought to check, and it was found by hand. +# +# So this gate reads the whole published surface, not just the source. A file may use +# the retired words only to STATE that they are retired; a file that uses one as a +# value, a column or a field is rejected and told which one. +# +# meta:type="ci-gate" +# meta:owner="Antonios Voulvoulis / ITCMS" +# meta:stability="EXPERIMENTAL" +# meta:privilege="unprivileged" +# meta:mutates="none" +# meta:binaries="git,python3" +# ============================================================================= + +"""usage: check_storage_vocabulary.py [--self-test]""" +import json +import pathlib +import re +import subprocess +import sys + +ROOT = pathlib.Path(subprocess.check_output( + ["git", "rev-parse", "--show-toplevel"], text=True).strip()) + +# The retired values. Word-bounded and upper-case: these were enum values, and matching +# them case-insensitively would flag the ordinary English words "optical" and "virtual". +RETIRED_VALUES = ("ROTATIONAL", "SOLID_STATE", "OPTICAL", "REMOVABLE", "VIRTUAL", "NVME") +VALUE_RE = re.compile(r"\bDEVICE_(?:%s)\b|[\"']\b(?:%s)\b[\"']" + % ("|".join(RETIRED_VALUES), "|".join(RETIRED_VALUES))) + +# The retired FIELD, in the two shapes a storage device record can carry it: a JSON key, +# and a Markdown column header that means the same thing. `type` is far too common a word +# to match bare - `state_dimension`, `fstype` and `meta:type` are all legitimate - so the +# field check is anchored to a storage device record. +DEVICE_KEY_RE = re.compile(r"\"type\"\s*:\s*\"(?:%s)\"" % "|".join(RETIRED_VALUES)) +DEVICE_COL_RE = re.compile(r"^\|\s*Device\s*\|.*\|\s*(?:Class|Type)\s*\|", re.M) + +# Files that exist to RECORD the prohibition, and are therefore exempt from all three +# checks below. Each one must be able to write the retired vocabulary in every shape - +# as a value, as a device key, as a table column - because forbidding a shape means +# quoting it: the register and the amendment that retired it, the instruction file, the +# document that explains why the sample was withdrawn, the two tests that assert the +# words are ABSENT from rendered output, the harness that injects them to prove this +# gate fires, and this gate, whose self-test corpus is made of them. +# +# The risk this accepts is a real defect hiding in one of these ten files. It is small +# and bounded: none of them renders a device, none is a sample, and the four files that +# actually produce storage output - collectors.py, render.py, cli.py and the report +# model - are NOT on this list and are fully checked. The strength of the gate is the +# other 230 files, and the self-test proves each of the three checks independently of +# any file in the tree. +EXEMPT = { + "CLAUDE.md", + "scripts/ci/check_storage_vocabulary.py", + "scripts/ci/falsifiable.sh", + "docs/IMPLEMENTATION_QUESTIONS.md", + "docs/architecture/DECISIONS_REGISTER.md", + "docs/architecture/AMENDMENTS.md", + "docs/architecture/INTERNAL_RECORDS.md", + "docs/reference/CONTROL_EVIDENCE_MAP.md", + "lib/isedraf/inventory/model.py", + "tests/test_inventory.py", + "tests/test_report.py", +} + +SCANNED_SUFFIXES = (".py", ".md", ".json", ".sh", ".txt", ".in", ".yml", ".yaml") +FAIL = [] + + +def bad(msg): + FAIL.append(msg) + print(" FAIL %s" % msg) + + +def tracked_files(): + out = subprocess.check_output(["git", "ls-files"], cwd=str(ROOT), text=True) + for line in out.splitlines(): + p = ROOT / line + if p.suffix in SCANNED_SUFFIXES and p.is_file(): + yield line, p + + +def inspect(rel, text): + """Return the findings for one file. Separated so --self-test can drive it.""" + found = [] + if rel in EXEMPT: + return found + if DEVICE_KEY_RE.search(text): + found.append("carries a storage device `type` with a retired value") + if DEVICE_COL_RE.search(text): + found.append("renders a storage device table with a Class/Type column; the " + "column header must name the kernel source it came from") + m = VALUE_RE.search(text) + if m: + found.append("uses the retired storage vocabulary %s as a value" % m.group(0)) + return found + + +def self_test(): + """A gate that has never been observed to fail is not a gate.""" + must_fail = [ + ('{"name": "sda", "type": "ROTATIONAL"}', "retired value in a device record"), + ('{"name": "sr0", "type": "OPTICAL"}', "retired value in a device record"), + ("| Device | Size | Class | Removable | Model |\n|---|---|---|---|---|", + "device table with a Class column"), + ("| Device | Size | Type | Model |\n|---|---|---|---|", "device table with a Type column"), + ('status = DEVICE_SOLID_STATE', "retired enum constant"), + ("medium = 'ROTATIONAL'", "retired value as a string literal"), + ] + must_pass = [ + '{"name": "sda", "kernel_subsystem": "scsi", "queue_rotational": true}', + '{"name": "sr0", "scsi_peripheral_type": 5, "kernel_removable": true}', + "| Device | Size | Kernel subsystem | Queue rotational | Vendor | Model |", + '"state_dimension": "ACTIVE"', + '{"fstype": "tmpfs", "type": "tmpfs"}', + '# meta:type="ci-gate"', + "The device is optical media in the ordinary English sense.", + "queue_rotational is false; this does not establish a solid-state medium.", + '{"type": "VIRTUAL_MACHINE_LABEL_UNRELATED_TO_STORAGE"}', + ] + errs = 0 + for text, why in must_fail: + if not inspect("some/file.md", text): + print(" SELFTEST FAIL not detected (%s): %r" % (why, text[:60])); errs += 1 + for text in must_pass: + f = inspect("some/file.md", text) + if f: + print(" SELFTEST FAIL false positive: %r -> %s" % (text[:60], f)); errs += 1 + if errs: + print("=== storage vocabulary self-test FAILED ==="); sys.exit(1) + print(" OK storage vocabulary self-test: %d rejected, %d accepted, 0 false positives" + % (len(must_fail), len(must_pass))) + + +if "--self-test" in sys.argv: + self_test() + sys.exit(0) + +scanned = 0 +for rel, path in tracked_files(): + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + scanned += 1 + for finding in inspect(rel, text): + bad("%s %s" % (rel, finding)) + +if scanned == 0: + # Z-18: a gate that passes over an empty input is not a gate. + bad("no files were scanned; the gate cannot have verified anything") + +if FAIL: + print("=== storage vocabulary gate FAILED ===") + print(" D-114 retired these words because the kernel does not support them.") + print(" Record what /sys exposes - kernel_subsystem, queue_rotational,") + print(" kernel_removable, scsi_peripheral_type - and name the source in the header.") + sys.exit(1) +print(" OK storage vocabulary: %d files, no retired D-114 device class on the surface" + % scanned) diff --git a/scripts/ci/falsifiable.sh b/scripts/ci/falsifiable.sh index 9877ae4..4917f62 100755 --- a/scripts/ci/falsifiable.sh +++ b/scripts/ci/falsifiable.sh @@ -339,6 +339,76 @@ inject "D-90 a public artifact is assembled through a symlink leaving the reposi 'ln -s /etc/hostname docs/reference/external-input.txt && git add -f -A' \ 'symlink pointing OUTSIDE|licensing gate FAILED' +# STORAGE-SEMANTICS-001 (D-114). The retired inference must be provably dead, not +# merely absent. Each of these reintroduces it in a different disguise. +inject "STORAGE-SEMANTICS-001 non-rotational is inferred to be solid state" \ + 'python3 tests/test_inventory.py' \ + 'python3 - <<'"'"'PYX'"'"' +import pathlib +p = pathlib.Path("lib/isedraf/inventory/collectors.py"); s = p.read_text() +old = " \"queue_rotational\": rot," +assert old in s, "mutation anchor miss" +p.write_text(s.replace(old, old + "\n \"type\": \"SOLID_STATE\" if rot is False else None,", 1)) +PYX' \ + 'reappeared|SOLID_STATE|FAILED' + +inject "STORAGE-SEMANTICS-001 a physical medium field is reintroduced" \ + 'python3 tests/test_inventory.py' \ + 'python3 - <<'"'"'PYX'"'"' +import pathlib +p = pathlib.Path("lib/isedraf/inventory/collectors.py"); s = p.read_text() +old = " \"kernel_removable\": removable_flag," +assert old in s, "mutation anchor miss" +p.write_text(s.replace(old, old + "\n \"physical_medium\": \"SOLID_STATE\",", 1)) +PYX' \ + 'reappeared|physical_medium|FAILED' + +inject "STORAGE-SEMANTICS-001 the queue observation is deleted instead of the inference" \ + 'python3 tests/test_inventory.py' \ + 'python3 - <<'"'"'PYX'"'"' +import pathlib +p = pathlib.Path("lib/isedraf/inventory/collectors.py"); s = p.read_text() +old = " \"queue_rotational\": rot," +assert old in s, "mutation anchor miss" +p.write_text(s.replace(old, "", 1)) +PYX' \ + 'queue_rotational|FAILED' + +# FIXTURE-CONFINEMENT-001. +# +# The first version of this injection mutated readlink() to realpath() and DID NOT FIRE. +# Measured, not assumed: with a RELATIVE link inside a fixture root, realpath resolves +# within that root, and basename() of either is the same string. The two implementations +# are indistinguishable at this call site, so that mutation had nothing to detect and +# reporting it as a control would have been a green check proving nothing. +# +# The real failure mode is an implementation that VALIDATES the target - exists(), stat(), +# listdir() - because a fixture link legitimately points at something that is not there. +# That is what this mutates, and the test's deliberately non-existent target answers it. +inject "FIXTURE-CONFINEMENT-001 the subsystem link target is validated before use" \ + 'python3 tests/test_inventory.py' \ + 'python3 - <<'"'"'PYX'"'"' +import pathlib +p = pathlib.Path("lib/isedraf/inventory/collectors.py"); s = p.read_text() +old = " if os.path.islink(link):" +assert old in s, "mutation anchor miss" +p.write_text(s.replace(old, " if os.path.islink(link) and os.path.exists(link):", 1)) +PYX' \ + 'example_fake_subsystem|FAILED' + +# D-114 Defect A. An incomplete collection reported as complete is the one thing an +# evidence engine cannot be wrong about. +inject "SCOPE-022 DMI absent with virtualization present is reported COLLECTED" \ + 'python3 tests/test_inventory.py' \ + 'python3 - <<'"'"'PYX'"'"' +import pathlib +p = pathlib.Path("lib/isedraf/inventory/collectors.py"); s = p.read_text() +old = " missing = [n for n, v in ((\"vendor\", vendor), (\"product\", product)) if v is None]" +assert old in s, "mutation anchor miss" +p.write_text(s.replace(old, " missing = [] if hypervisor else [n for n, v in ((\"vendor\", vendor), (\"product\", product)) if v is None]", 1)) +PYX' \ + 'PARTIAL|reason|FAILED' + # D-111. The native control catalog is authored first and is ISEDRAF's own. The invariant # is frozen; these prove the gate enforcing it can refuse. inject "D-111 a production module is named after a framework provider" \ @@ -437,6 +507,39 @@ inject "D-86 the rpm changelog states a weekday the date never fell on" \ 'sed -i "s|^\* Fri Sep 18 2026|* Thu Sep 18 2026|" packaging/rpm/isedraf.spec.in' \ 'which was a|packaging metadata gate FAILED' +# GOV-001. The coverage gate used to verify only what was DECLARED, so an undeclared +# gate passed by not being mentioned. These two prove it now reconciles the declaration +# against the directory in both directions. +inject "GOV-001 a gate script is added that nothing declares or runs" \ + 'python3 scripts/ci/check_gate_coverage.py' \ + 'printf "import sys\nsys.exit(0)\n" > scripts/ci/check_orphan_example.py && git add -A' \ + 'does not account for|gate coverage FAILED' + +inject "GOV-001 a declared gate script is deleted from the tree" \ + 'python3 scripts/ci/check_gate_coverage.py' \ + 'git rm -q --cached scripts/ci/check_sbom.py && rm -f scripts/ci/check_sbom.py' \ + 'does not exist|gate coverage FAILED' + +# D-114 / STORAGE-SEMANTICS-001. The retired storage vocabulary survived in a published +# sample report for three schema versions, because every gate read the source and none +# read the documentation. These three prove the gate now reads the whole surface: a +# device record carrying the retired value, a device table whose column hides the source, +# and the retired enum constant returning to the engine. +inject "D-114 a published document reports a storage device type of ROTATIONAL" \ + 'python3 scripts/ci/check_storage_vocabulary.py' \ + 'printf "%s\n" "{\"name\": \"sda\", \"type\": \"ROTATIONAL\"}" > docs/reference/PLATFORM_COMPATIBILITY.md' \ + 'retired value|storage vocabulary gate FAILED' + +inject "D-114 a storage table column says Class instead of naming its kernel source" \ + 'python3 scripts/ci/check_storage_vocabulary.py' \ + 'printf "%s\n%s\n" "| Device | Size | Class | Model |" "|---|---|---|---|" > docs/reference/PLATFORM_COMPATIBILITY.md' \ + 'Class/Type column|storage vocabulary gate FAILED' + +inject "D-114 the retired DEVICE_SOLID_STATE constant returns to the engine" \ + 'python3 scripts/ci/check_storage_vocabulary.py' \ + 'printf "%s\n" "DEVICE_SOLID_STATE = \"SOLID_STATE\"" >> lib/isedraf/inventory/collectors.py' \ + 'retired storage vocabulary|storage vocabulary gate FAILED' + # D-86. The deb and the rpm are built by two different implementations. Dropping a # document from one of them must be caught by comparing them, not by anyone remembering. inject "D-86 the rpm and the deb ship different documentation" \ @@ -602,18 +705,33 @@ inject "D-90 a private key path is committed into docs" \ 'printf "\nKey at ~/.ssh/id_ed25519_release for deploys.\n" >> docs/reference/GLOSSARY.md # privacy:planted-fixture' \ 'SSH_PRIVATE_KEY_PATH' -# W1-C1: an optical drive reports rotational=0 exactly like an SSD. Classifying on that -# alone put a QEMU DVD-ROM in the first published sample as SOLID_STATE. -inject "optical device classified by the rotational flag alone" \ +# W1-C1 -> D-114. An optical drive reports rotational=0 exactly like an SSD, and +# classifying on that alone put a QEMU DVD-ROM in the first published sample as +# SOLID_STATE. The branch this used to mutate no longer exists: D-114 retired the whole +# classification chain. STORAGE-SEMANTICS-002 says such an injection is REPLACED by one +# protecting the corrected model, not deleted - the concern survives, the mechanism +# changed. The peripheral type is now the evidence, and it must be recorded and scoped. +inject "D-114 the SCSI peripheral type is dropped from the observation" \ + 'python3 tests/test_inventory.py' \ + 'python3 - <<'"'"'PYX'"'"' +import pathlib +p = pathlib.Path("lib/isedraf/inventory/collectors.py"); s = p.read_text() +old = " \"scsi_peripheral_type\": peripheral," +assert old in s, "mutation anchor miss" +p.write_text(s.replace(old, " \"scsi_peripheral_type\": None,", 1)) +PYX' \ + 'scsi_peripheral_type|FAILED' + +inject "D-114 the peripheral type leaks outside the SCSI family" \ 'python3 tests/test_inventory.py' \ 'python3 - <<'"'"'PYX'"'"' import pathlib p = pathlib.Path("lib/isedraf/inventory/collectors.py"); s = p.read_text() -old = " elif name.startswith((\"sr\", \"scd\")) or (" +old = " if subsystem == \"scsi\" and scsi_type.ok:" assert old in s, "mutation anchor miss" -p.write_text(s.replace(old, " elif False and (")) +p.write_text(s.replace(old, " if scsi_type.ok:", 1)) PYX' \ - 'OPTICAL|DEVICE_OPTICAL' + 'leaked into|FAILED' # W1-C1: the report tells its reader that incomplete observations explain themselves. inject "SCOPE-022 an incomplete collection carries no reason" \ diff --git a/scripts/ci/gate_coverage.json b/scripts/ci/gate_coverage.json index 73d60d8..8bba2bd 100644 --- a/scripts/ci/gate_coverage.json +++ b/scripts/ci/gate_coverage.json @@ -149,6 +149,16 @@ "packaging/deb/control.in" ] }, + "check-storage-vocabulary": { + "script": "scripts/ci/check_storage_vocabulary.py", + "implements": "D-114, STORAGE-SEMANTICS-001, GOV-002", + "falsification": "D-114 a published document reports a storage device type of ROTATIONAL", + "eligible": [ + "lib/isedraf/inventory/*.py", + "lib/isedraf/report/*.py", + "docs/reference/*.md" + ] + }, "check-public-claims": { "script": "scripts/ci/check_public_claims.py", "implements": "C-01, D-88, D-89, D-90", @@ -185,5 +195,43 @@ "private_only_gates": { "$comment": "Gates that read material outside the repository and therefore cannot run in public CI. They are NOT in `make check` and that is deliberate, not a coverage gap. Listed so the omission is visible rather than silent.", "check-provider-alignment": "reads the private provider licensing registry, located by the ISEDRAF_PROVIDER_REGISTRY environment variable and absent on a runner; skips with an explicit message and never reports a pass it did not earn" + }, + "gates_outside_make_check": { + "$comment": "Every check_* script that `make check` does NOT aggregate, with the reason and the thing that actually runs it. This section exists because the coverage gate used to verify only what was DECLARED: a new gate nobody listed was invisible to it, and passed coverage by not being mentioned. The gate now reconciles this file against scripts/ci/ and fails on any check_* script that appears in neither list, so a gate cannot be added without being accounted for, and one cannot be quietly orphaned.", + "check-gate-coverage": { + "script": "scripts/ci/check_gate_coverage.py", + "invoked_by": "make check-gate-coverage, CI, scripts/ci/falsifiable.sh", + "reason": "the meta-gate. It verifies the others, so aggregating it into the target it verifies would make it report on itself." + }, + "check-sbom": { + "script": "scripts/ci/check_sbom.py", + "invoked_by": "make check-sbom, scripts/ci/falsifiable.sh", + "reason": "needs built packages and rpm's own recorded per-file digests; there is nothing to cross-check before a build." + }, + "check-package-payload": { + "script": "scripts/ci/check_package_payload.sh", + "invoked_by": "make check-package-payload, scripts/ci/release_export.sh, scripts/ci/falsifiable.sh", + "reason": "compares the built deb and rpm payloads against each other; requires both artifacts." + }, + "check-reproducible": { + "script": "scripts/ci/check_reproducible.sh", + "invoked_by": "make check-reproducible, scripts/ci/falsifiable.sh", + "reason": "builds twice and compares bytes; too slow for the pre-commit path and meaningless without a build." + }, + "check-deb-ordering": { + "script": "scripts/ci/check_deb_ordering.sh", + "invoked_by": "make check-deb-ordering, scripts/ci/falsifiable.sh", + "reason": "reads the member order inside a built .deb archive." + }, + "check-attestation-falsifiable": { + "script": "scripts/ci/check_attestation_falsifiable.sh", + "invoked_by": ".github/workflows/release-candidate.yml", + "reason": "requires a real attestation from the release workflow; it cannot be produced or verified on a workstation." + }, + "check-provider-alignment": { + "script": "scripts/ci/check_provider_alignment.py", + "invoked_by": "make check-provider-alignment", + "reason": "private: reads the provider licensing registry located by ISEDRAF_PROVIDER_REGISTRY, which is absent on a runner. Also recorded in private_only_gates." + } } } diff --git a/scripts/ci/project_status.json b/scripts/ci/project_status.json index 6fe9656..1ec6c77 100644 --- a/scripts/ci/project_status.json +++ b/scripts/ci/project_status.json @@ -42,7 +42,8 @@ "inventory": { "status": "IMPLEMENTED", "evidence": "lib/isedraf/inventory/", - "command": "isedraf inventory" + "command": "isedraf inventory", + "note": ". Machine identity reports PARTIAL with an explicit reason whenever DMI is absent or incomplete; virtualization detection is separate and does not establish it" }, "report_json": { "status": "IMPLEMENTED", @@ -233,6 +234,11 @@ "status": "PLANNED", "design": "docs/architecture/ISEDRAF_PRODUCT_HLD.md", "note": "provider-authorized pack plus the customer's own provider entitlement, only after a written agreement. Zero providers approached, zero agreements, and the architecture being ready is not a provider having agreed" + }, + "block_storage_observation_model": { + "status": "IMPLEMENTED", + "evidence": "lib/isedraf/inventory/collectors.py", + "note": "D-114: the overloaded storage `type` is retired, not deprecated. Replaced by kernel_subsystem (from device/subsystem, read lexically), queue_rotational, kernel_removable and scsi_peripheral_type (scoped to the SCSI family), with stable keys and null. No physical_medium, transport or is_aggregate: a block-layer object is not necessarily one physical device" } }, "runtime": { diff --git a/tests/test_inventory.py b/tests/test_inventory.py index 8dc5df6..593696b 100644 --- a/tests/test_inventory.py +++ b/tests/test_inventory.py @@ -23,6 +23,7 @@ import io import os import pathlib +import os import shutil import subprocess import sys @@ -309,7 +310,7 @@ class TestDeviceClassification(unittest.TestCase): """`rotational == 0` is true of an optical drive as well as an SSD.""" def device(self, name, rotational=None, removable=None, scsi_type=None, - devmodel=None): + devmodel=None, devvendor=None, subsystem=None): files = {"proc/self/mounts": ""} base = "sys/block/%s" % name files[base + "/size"] = "2097152\n" @@ -321,39 +322,172 @@ def device(self, name, rotational=None, removable=None, scsi_type=None, files[base + "/device/type"] = "%s\n" % scsi_type if devmodel is not None: files[base + "/device/model"] = "%s\n" % devmodel + if devvendor is not None: + files[base + "/device/vendor"] = "%s\n" % devvendor root = build_root(files) self.addCleanup(shutil.rmtree, root, True) + # D-114: kernel_subsystem comes from the kernel's own link, so a fixture that + # claims to test a subsystem must PROVIDE one. A RELATIVE link, and the + # collector reads it lexically - FIXTURE-CONFINEMENT-001. A target that does + # not exist is deliberate in one test: resolving it is not required. + if subsystem is not None: + devdir = os.path.join(root, "sys/block", name, "device") + if not os.path.isdir(devdir): + os.makedirs(devdir) + os.symlink("../../../bus/%s" % subsystem, + os.path.join(devdir, "subsystem")) return collectors.collect_storage(root)["data"]["devices"][0] - def test_optical_is_not_solid_state(self): - """The exact defect found in the first published sample: a QEMU DVD-ROM.""" - dev = self.device("sr0", rotational=0, removable=1, scsi_type=5, - devmodel="QEMU DVD-ROM") - self.assertEqual(dev["type"], model.DEVICE_OPTICAL) - self.assertTrue(dev["removable"]) + # ---- INV-MACHINE-NODMI-001 --------------------------------------------------- - def test_optical_detected_by_scsi_type_even_with_an_odd_name(self): - dev = self.device("sdz", rotational=0, scsi_type=5) - self.assertEqual(dev["type"], model.DEVICE_OPTICAL) + def _machine(self, files): + root = build_root(files) + self.addCleanup(shutil.rmtree, root, True) + return collectors.collect_machine(root) - def test_real_ssd_is_solid_state(self): - dev = self.device("sda", rotational=0, removable=0, scsi_type=0) - self.assertEqual(dev["type"], model.DEVICE_SOLID_STATE) + def test_absent_dmi_is_never_reported_as_collected(self): + """Defect A. Virtualization detection is not machine identity. - def test_spinning_disk(self): - self.assertEqual(self.device("sdb", rotational=1)["type"], - model.DEVICE_ROTATIONAL) + The previous rule counted non-null values across the whole subdomain, and + `virtualized` and `hypervisor` counted toward the threshold - so on any + virtualized host without SMBIOS, DMI could be entirely absent and the + subdomain still reported COLLECTED with vendor and product null and no + reason. An incomplete collection reported as complete. + """ + for name, files in ( + ("no DMI, no hypervisor", + {"proc/cpuinfo": "Hardware\t: BCM2835\n"}), + ("no DMI, hypervisor present", + {"proc/cpuinfo": "flags\t: hypervisor\n", + "sys/hypervisor/type": "kvm\n"})): + sd = self._machine(files) + self.assertEqual(sd["collection_status"], model.PARTIAL, name) + self.assertIsNone(sd["data"]["vendor"], name) + self.assertIsNone(sd["data"]["product"], name) + self.assertTrue(sd.get("reason"), "%s: PARTIAL with no reason" % name) + self.assertIn("SOURCE_ABSENT", sd["reason"], name) + + def test_partial_dmi_is_still_incomplete(self): + """One identity field present and one absent is not a complete collection.""" + sd = self._machine({"proc/cpuinfo": "x\n", + "sys/class/dmi/id/sys_vendor": "QEMU\n"}) + self.assertEqual(sd["collection_status"], model.PARTIAL) + self.assertIn("product", sd["reason"]) + + def test_complete_dmi_collects_without_a_reason(self): + sd = self._machine({"proc/cpuinfo": "x\n", + "sys/class/dmi/id/sys_vendor": "Dell Inc.\n", + "sys/class/dmi/id/product_name": "PowerEdge R640\n"}) + self.assertEqual(sd["collection_status"], model.COLLECTED) + self.assertIsNone(sd.get("reason")) + + # ---- D-114 / STORAGE-SEMANTICS-001 ------------------------------------------- + # + # The tests these replace asserted the DEFECT as expected behaviour. The worst was + # `test_real_ssd_is_solid_state`: its fixture proved only that a SCSI device + # reported non-rotational, and it asserted SOLID_STATE - exactly the inference + # D-114 prohibits. STORAGE-SEMANTICS-002 says such a test is replaced by a test of + # the corrected model, and that deleting it is not sufficient: the inverse must be + # asserted. + + def test_no_device_reports_a_physical_medium(self): + """STORAGE-SEMANTICS-001. The retired field must not come back under any name.""" + for kwargs in ({"rotational": 0, "scsi_type": 0, "subsystem": "scsi"}, + {"rotational": 1, "subsystem": "scsi"}, + {"rotational": 0, "subsystem": "nvme"}, + {"rotational": 0, "subsystem": "mmc"}, + {"rotational": 0, "scsi_type": 5, "subsystem": "scsi"}): + dev = self.device("dev0", **kwargs) + for retired in ("type", "physical_medium", "transport", "is_aggregate"): + self.assertNotIn(retired, dev, + "%s reappeared for %r" % (retired, kwargs)) + self.assertNotIn("SOLID_STATE", repr(dev)) + + def test_non_rotational_scsi_is_not_called_solid_state(self): + """Replaces test_real_ssd_is_solid_state, whose premise was false. + + The fixture proves a SCSI device reports non-rotational. It proves nothing + about the medium, and the record must say exactly that much. + """ + dev = self.device("sda", rotational=0, removable=0, scsi_type=0, + subsystem="scsi") + self.assertEqual(dev["kernel_subsystem"], "scsi") + self.assertIs(dev["queue_rotational"], False) + self.assertEqual(dev["scsi_peripheral_type"], 0) + + def test_queue_rotational_false_is_retained_as_evidence(self): + """Positive control. Without it, a future 'fix' could delete the observation + instead of the inference, and every negative test would still pass.""" + dev = self.device("sdc", rotational=0, subsystem="scsi") + self.assertIn("queue_rotational", dev) + self.assertIs(dev["queue_rotational"], False) + + def test_optical_is_recorded_as_a_scsi_peripheral_type(self): + """The sr0 regression, carried across the schema change. + + Its meaning survives: a DVD-ROM is never reported as solid-state. Its old + assertion could not, because it interrogated the retired field. + """ + dev = self.device("sr0", rotational=0, removable=1, scsi_type=5, + devmodel="QEMU DVD-ROM", subsystem="scsi") + self.assertEqual(dev["scsi_peripheral_type"], 5) + self.assertIs(dev["kernel_removable"], True) + self.assertNotIn("SOLID_STATE", repr(dev)) + + def test_scsi_peripheral_type_is_null_outside_the_scsi_family(self): + """Family scoping belongs to the field definition, not to a branch.""" + for sub in ("nvme", "mmc", "virtio"): + dev = self.device("dev0", rotational=0, scsi_type=5, subsystem=sub) + self.assertIsNone(dev["scsi_peripheral_type"], + "scsi_peripheral_type leaked into %s" % sub) + + def test_subsystem_comes_from_the_kernel_not_the_name(self): + """D-114: device/subsystem is authoritative; a name prefix is not.""" + self.assertIsNone(self.device("nvme0n1", rotational=0)["kernel_subsystem"]) + self.assertEqual( + self.device("sdz", rotational=0, subsystem="nvme")["kernel_subsystem"], + "nvme") - def test_nvme(self): - self.assertEqual(self.device("nvme0n1", rotational=0)["type"], - model.DEVICE_NVME) + def test_subsystem_link_is_read_lexically_and_never_resolved(self): + """FIXTURE-CONFINEMENT-001. The target deliberately does not exist. + + A lexical read returns the name. Any implementation calling realpath, stat or + exists fails here - and would, in a real fixture, read the HOST's /sys/bus + while believing it was reading the fixture root. + """ + dev = self.device("sdq", rotational=0, subsystem="example_fake_subsystem") + self.assertEqual(dev["kernel_subsystem"], "example_fake_subsystem") + + def test_missing_rotational_attribute_is_unknown_not_assumed(self): + dev = self.device("sdd", subsystem="scsi") + self.assertIsNone(dev["queue_rotational"]) + + def test_every_device_carries_stable_keys(self): + """Object shape does not vary by subsystem (SNAP-023 idiom).""" + keys = {"name", "size_bytes", "kernel_subsystem", "queue_rotational", + "kernel_removable", "scsi_peripheral_type", "vendor", "model"} + for sub in ("scsi", "nvme", "mmc", "virtio", None): + dev = self.device("dev0", rotational=0, subsystem=sub) + self.assertEqual(set(dev), keys, "shape varies for subsystem=%r" % sub) + + def test_raid_logical_volume_claims_no_physical_disk(self): + """Measured on a real Smart Array: four disks behind one block device.""" + dev = self.device("sda", rotational=1, removable=0, scsi_type=0, + devvendor="HP", devmodel="LOGICAL VOLUME", + subsystem="scsi") + self.assertEqual(dev["vendor"], "HP") + self.assertEqual(dev["model"], "LOGICAL VOLUME") + self.assertIs(dev["queue_rotational"], True) + self.assertNotIn("is_aggregate", dev) - def test_virtio_names_the_transport_rather_than_guessing_the_medium(self): - self.assertEqual(self.device("vda", rotational=1)["type"], - model.DEVICE_VIRTUAL) def test_unknown_when_nothing_says(self): - self.assertEqual(self.device("xyz0")["type"], model.DEVICE_UNKNOWN) + # D-114: unknown is expressed by null in each dimension, not by one + # UNKNOWN token standing in for four different unanswered questions. + dev = self.device("xyz0") + self.assertIsNone(dev["kernel_subsystem"]) + self.assertIsNone(dev["queue_rotational"]) + self.assertIsNone(dev["scsi_peripheral_type"]) class TestIncompleteAlwaysExplains(unittest.TestCase): diff --git a/tests/test_report.py b/tests/test_report.py index 1769f26..bcd137e 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -73,11 +73,15 @@ def block(status, data, method="fixture", reason=None): {"total_bytes": 34359738368, "swap_total_bytes": 0}), "storage": block(inventory_model.COLLECTED, { "devices": [{"name": "vda", "size_bytes": 250000000000, - "type": inventory_model.DEVICE_VIRTUAL, - "removable": False, "model": "QEMU"}, + "kernel_subsystem": "virtio", + "queue_rotational": False, "kernel_removable": False, + "scsi_peripheral_type": None, "vendor": None, + "model": "QEMU"}, {"name": "sr0", "size_bytes": 1073741824, - "type": inventory_model.DEVICE_OPTICAL, - "removable": True, "model": "QEMU DVD-ROM"}], + "kernel_subsystem": "scsi", + "queue_rotational": False, "kernel_removable": True, + "scsi_peripheral_type": 5, "vendor": None, + "model": "QEMU DVD-ROM"}], "filesystems": [{"source": "/dev/vda1", "mount_point": "/", "fstype": "xfs", "options": ["rw", "relatime"], "read_only": False}, @@ -287,7 +291,14 @@ def test_multiple_storage_devices_and_read_only_mount(self): def test_optical_device_is_not_reported_as_solid_state(self): text = render.to_markdown(self.build()) - self.assertIn("OPTICAL", text) + # D-114 / STORAGE-SEMANTICS-002: the OPTICAL vocabulary is retired. The + # report now carries the kernel subsystem and the queue attribute, with column + # headers naming their SOURCE so a kernel flag cannot read as a physical fact. + self.assertIn("Kernel subsystem", text) + self.assertIn("Queue rotational", text) + self.assertNotIn("OPTICAL", text) + self.assertNotIn("SOLID_STATE", text) + self.assertIn("neither establishes the physical storage medium", text) self.assertNotIn("SOLID_STATE", text) def test_unsynchronized_clock_raises_rec005(self):