diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fb3300..56fe75a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## M6D Read-Only FAT32 candidate — 2026-07-30 + +- Added an allocation-free FAT32 backend over the architecture-independent + block-device contract. +- Validated BPB geometry, FAT capacity, device bounds, FSInfo signatures, and + the complete backup boot sector before mounting. +- Added bounded cluster traversal with free, reserved, bad, out-of-range, + premature-end, overlong, and cyclic-chain rejection. +- Added FAT 8.3 and checksum-validated, bounded UTF-16 long-name decoding. +- Added root, nested-directory, offset, and multi-cluster persistent reads. +- Mounted FAT32 read-only at `/disk` behind the M6C VFS. +- Added streaming shell `cat`, path-aware `ls`, and `fat32` diagnostics. +- Added a deterministic 64 MiB FAT32 QEMU fixture preserving the M6B device + identity sector. +- Advanced the capability registry to version 7 and added exact M6D smoke + evidence while preserving M5 through M6C regression gates. + +M6D does not expose persistent files to Ring 3, load executables from FAT32, +write persistent media, discover partitions, or support physical installation. + ## M6C Bounded Cache and VFS candidate — 2026-07-27 - Adopted Soma OS as the temporary user-facing working identity while retaining diff --git a/Cargo.lock b/Cargo.lock index 84181e8..6f744e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,11 +4,11 @@ version = 4 [[package]] name = "sanju-boot" -version = "0.0.11" +version = "0.0.12" dependencies = [ "sanju-kernel", ] [[package]] name = "sanju-kernel" -version = "0.0.11" +version = "0.0.12" diff --git a/PROJECT.md b/PROJECT.md index fcec4ac..54793f3 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -6,9 +6,9 @@ - Architecture: x86-64 - Firmware: UEFI 2.x - Primary language: Rust 2024 -- Accepted checkpoint: M6B Virtio Block Transport -- Immutable release: `v0.0.10-m6b` -- Current development checkpoint: M6C bounded cache and VFS +- Accepted checkpoint: M6C Bounded Cache and VFS +- Immutable release: `v0.0.11-m6c` +- Current development checkpoint: M6D read-only FAT32 - Deployment policy: QEMU only until physical-install safety gates pass ## Mission @@ -21,12 +21,11 @@ Development is grouped into major milestone batches. Small formatting or CI corr ## Current objective -Add an allocation-free, fixed-capacity read-through cache over the accepted -virtio block transport. Establish bounded inode, superblock, mount, canonical -path, directory, and generation-protected user file-handle contracts. Adapt -RAMFS behind that VFS boundary without enabling persistent writes. +Validate and mount a deterministic FAT32 volume over the accepted cache and +virtio block stack. Expose bounded persistent directory and file reads through +the VFS and shell while preserving an enforceable zero-dirty-data boundary. ## Next major objective -Validate and mount a dedicated FAT32 image read-only, then expose bounded -persistent directory listing and file reads through the accepted VFS. +Add process-facing persistent file syscalls and executable reads without +weakening path, handle, address-space, or read-only storage isolation. diff --git a/README.md b/README.md index f590e50..07fc3a2 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,9 @@ milestones before any physical-disk work. [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/chavalasantosh/OperatingSystem) -## Current checkpoint: M6C Bounded Cache and VFS +## Current checkpoint: M6D Read-Only FAT32 -The accepted baseline entering this phase is `v0.0.10-m6b`. M0 through FH2 +The accepted baseline entering this phase is M6C. M0 through FH2 proved UEFI ownership transfer, protected kernel execution, interrupts, Ring 3 entry, `SYSCALL`/`SYSRET`, ELF64 loading, recoverable user faults, physical ownership, and a fresh Soma OS page-table @@ -65,10 +65,23 @@ M6C builds the filesystem boundary without enabling persistent writes: - RAMFS accessed through the same VFS interface reserved for FAT32; - `cache` and `mounts` shell diagnostics plus exact smoke evidence. +M6D activates persistent reads through that boundary: + +- a deterministic 64 MiB FAT32 image attached as the dedicated virtio device; +- validated BPB geometry, FAT capacity, FSInfo, backup boot sector, and device + bounds; +- bounded FAT-chain traversal with malformed-cluster and cycle rejection; +- short-name and checksum-validated UTF-16 long-filename decoding; +- root, nested-directory, offset, and multi-cluster file reads; +- a second VFS mount at `/disk` with read-only enforcement; +- cache-backed persistent reads with zero dirty entries; +- `fat32`, path-aware `ls`, and persistent streaming `cat` shell diagnostics; +- exact QEMU evidence preserving every M5 through M6C regression gate. + ## Shell commands ```text -help version userspace uptime memory irq tasks pci block cache mounts ls cat write echo clear +help version userspace uptime memory irq tasks pci block cache fat32 mounts ls cat write echo clear ``` ## Build and verify @@ -98,14 +111,15 @@ docs/ Requirements, architecture, ADRs, testing, security, process ## Current boundary -M6C and FH3 remain single-core and PIT-driven. Block completion is synchronous +M6D and FH3 remain single-core and PIT-driven. Block completion is synchronous and polling, one request is outstanding at a time, and only the explicitly identified disposable QEMU disk is used. The cache cannot issue writes and -cannot contain dirty entries. RAMFS remains the only writable filesystem. The -combined EFI-stub kernel still retains a bounded identity mapping while a -separate high-half kernel image is designed. Read-only FAT32, persistent -writes, physical-disk installation, graphics, SMP, and local APIC timers remain -later gates. +cannot contain dirty entries. RAMFS is the writable root and FAT32 is mounted +read-only at `/disk`. The combined EFI-stub kernel still retains a bounded +identity mapping while a separate high-half kernel image is designed. +Process-facing persistent file syscalls, executable loading from FAT32, +persistent writes, physical-disk installation, graphics, SMP, and local APIC +timers remain later gates. ## Safety diff --git a/SOURCE_MANIFEST.sha256 b/SOURCE_MANIFEST.sha256 index 2b11207..4761baa 100644 --- a/SOURCE_MANIFEST.sha256 +++ b/SOURCE_MANIFEST.sha256 @@ -3,22 +3,27 @@ ae0cb6c3b2dcaed5941d193848a2d05028f12f0692e95aacf2808ed7b037dd8f .gitattributes 9f35a88b86d7601a4b40fe13e7987baac18a1310911aec01111ee123f8bbc3a4 .github/workflows/ci.yml 4337eb9c42b5bfdf2840306b3944a65e475e21af781307f998ef299b56f685d7 .gitignore +e9c73c8314203328f4e7bd580523aa2d93301804219c315d4eaa9ee31f1dd7d9 CHANGELOG.md +fa978f37685c47c2209dc2c11e4bb7d278ab47a813b051d1b25ad66e3eafc770 CONTRIBUTING.md +b005b436a9410aa67e042285a1854b9b83b7c0b351d3adfb1044e032b73485a8 Cargo.lock +2c03da856ec66c6d6407cfe6f007989ac5e48acdf5ac923bf1d1bc7d3d448675 Cargo.toml +718742da4c6d89cf96babe555371260bcaaadca3ce715e74342cb445e4d80245 Makefile +1d3d88e61dab2ee6685a1f707b018096f2f7de66ab670d91e0a5caeb63ebdafa PROJECT.md +93b9eb67c61384abc261e61be00889a012230bf40bacb07368d03183b217387e README.md +3ebceddec88cf60e09b74d7f17cbe6f67a5672a9653d7f5084edc3f43547756c SECURITY.md 17fe0e368f6e7327b413a3113c56cce5f169a1ccb54f0b65375e4abcacc35145 assets/branding/sanjuos-logo.png -7ba85a6000841f1851b06532a09ea534d1cf8f30e821cf700a8d7c8500128358 boot/uefi/Cargo.toml +b76bfe164228ed69486dcf752025c38cd4ec8b78c10fdbd90ecc7b7468306b13 boot/uefi/Cargo.toml d80a890c3bfcb13a3a9fc850b6e9b9a2eea333c83b1e78c5db60abd0272bc9b8 boot/uefi/src/arch/mod.rs -0421c9e8f0f8d9831db2e45f0755baaac2d6685325b90f42454318db3e1f1351 boot/uefi/src/arch/x86_64/mod.rs +2e1e0a9d3f411a129246981f897aa3e161e9ce9c43cbda5b40909c3cefbd55da boot/uefi/src/arch/x86_64/mod.rs 3899462a2005889f9e86c3ef775364441b46f0b7da079cad52e792c02fb6b997 boot/uefi/src/arch/x86_64/paging.rs dd017528cd4bd9206416bc0df717263aca92719b80ffd83009902577b519ce32 boot/uefi/src/arch/x86_64/pci.rs -74116134615fd003f8592bec4557a326920ffa0a70f1b2afde755c44844c4b9f boot/uefi/src/arch/x86_64/qemu.rs +856bb4f4fe71b42b41b46518fc1b693481dd08271e902aaf14b082d7c824143d boot/uefi/src/arch/x86_64/qemu.rs 7d11876ab13082c796a5a2af100ece7b713251c408e3c67b313cc51ed3a3d410 boot/uefi/src/arch/x86_64/serial.rs 7143759544dc7000e33aa1f2e7d554997596256ac9e1d3f4a4b71c7101082d3b boot/uefi/src/arch/x86_64/virtio_block.rs -47d9c858ae79958d4c09a61f1bdd9bc64d3f5a852f7aade95463f6decac0ad3a boot/uefi/src/main.rs -395268c2b70e15139514429ccce4e04628560ac42ccf94ced6448eb6602f0001 capabilities/capabilities.toml -3f1809f5a5df415a9e2c514a53590f5e8faad1d0c58bc5e41f023999472b57ca capabilities/smoke-expectations.txt -9e9b7c91e0240a56ca882439b3f84e14994c76e4ba1408af7f2cc55deffd6123 Cargo.lock -2c03da856ec66c6d6407cfe6f007989ac5e48acdf5ac923bf1d1bc7d3d448675 Cargo.toml -fcb21c956affe13e6da974cb3086cebefa090da4f5f6b834a8021a019e26d942 CHANGELOG.md -fa978f37685c47c2209dc2c11e4bb7d278ab47a813b051d1b25ad66e3eafc770 CONTRIBUTING.md +170bbed66770e66c19298c0b9338102160029dfc3863a1184733c65fe9de4b98 boot/uefi/src/main.rs +e2c964ea3da4fb68513434992c00bd47bdb88cb0d3846331d4f4726aae26cdcf capabilities/capabilities.toml +2f51d76ab9535dbef035139472ad9c77be4002b5aee9351bcb62643a1788ec4b capabilities/smoke-expectations.txt +9e75ed09ebb91c490a3cbb1d14641ff8fe062cf76b1d1c912ac44c134870a893 docs/CAPABILITY_MATRIX.md 4b490a94642825c10c5876ab94d6e1390360ba754e9615735c649b64a5fea334 docs/adr/0001-rust-first.md 2408920abe6ae8d6c3861f0bcfbe5c3cb362a444fc5104eb5d245fee9334a22d docs/adr/0002-uefi-x86-64-first.md 774a003ce11a0bf69485779bdfd25a3ee935b492258d6ba082f9c5aa378dc4bc docs/adr/0003-modular-monolithic-kernel.md @@ -32,9 +37,9 @@ e57c9bfed03a3a425d8592033d8b5fc36146f56b0388ffb8af9c8ec8c75413b3 docs/adr/0008- 012833274e4c031ef4b8b3fe59ae552ac2ad90b03cd3824abc940a91c0ee3b69 docs/adr/0011-private-address-spaces-preemptive-contexts.md 7a0311f75934b7c354720bc5b6df7537521d8dd3ac347893b58dd5a4eb69ae09 docs/adr/0012-pci-storage-foundation.md 0f73d36c6c092c8fcd9da7fdce333dca1730c82a0ad1f6e51bf7f6538a9c3996 docs/adr/0013-bounded-cache-vfs-contracts.md -8218e319a472f46e49fb77d7211d47b0bb9ef439a02ce37cf2b691d1efc633ea docs/architecture/SYSTEM_OVERVIEW.md -ab1a238796efa11410026c202e590f78a23e460f128f44a34ec39c2f94f487ff docs/CAPABILITY_MATRIX.md -9ff539a3bc489a25ca113fd28d05ecb157af365d22dbc8a1472b71b6d11dbc3d docs/process/BACKLOG.md +7c62c3d7a9e1bc0dd2ad74698ee57a7e7f03f8b3190af1945dc5e363edb0baf6 docs/adr/0014-read-only-fat32.md +f10d714529b183eb5e3ce4ce55d68669d920b28e3796d3c8240e3aaefcc9afbb docs/architecture/SYSTEM_OVERVIEW.md +09c0f9949090e778bb5fdcc3a477763c6c133985a461482ce6749b00597fcd17 docs/process/BACKLOG.md fee8050e8dcc105d812af867282e410f229c2422e535add412d987b4ab70c890 docs/process/DEFINITION_OF_DONE.md 620312efa0d2c090a909a109f0303cdbfb10f6b756be238a41b182f80cf4c76c docs/process/ENVIRONMENT_NOTE.md e88116bc1322b9bb9bd971f45c27ae9a104231f7e7175e2dabaa3bdcc874aef5 docs/process/RISK_REGISTER.md @@ -47,9 +52,9 @@ f1775d368dea5e3970dc3449e2b2431dc4b46c8d3517461e82f9b343575c73ca docs/process/S 5ef377c5d302832ac135ca68a5d2299a1cfefa84fd0cc1df3f2a759160c62734 docs/process/SPRINT_FOUNDATION_1.md e2e35b9e47bcaa4b909f6d52bd64c56fc02d03ff45e440d4d2f2ae5a207a7b6d docs/process/SPRINT_FOUNDATION_2.md 6f15cc971ee785b6211dfa469d345c13dc7ef6b65ab484adb9be0b76bf5ebec9 docs/process/SPRINT_FOUNDATION_3.md -f725fbcc2dc35f4ff05a234e8b52369deaf2cae0203cc532f95852a95c21c388 docs/process/SPRINT_M6_STORAGE_FOUNDATION.md +7b389a225cddaa49961d5e66156dfac95c349ae3471ede3b4044b9f48c870621 docs/process/SPRINT_M6_STORAGE_FOUNDATION.md 695d5c8f71075cc6f377a12c222d2b95e010192a7c6a428d6f3e830dcab8c7c2 docs/requirements/PRODUCT_REQUIREMENTS.md -194ea4a76bc203f7888ac75ea9ad4d1e55c5eed9d3e1c643592e1b1e878759bc docs/security/THREAT_MODEL.md +b92a6698778218974c058f0d5d826edc99361cd8dfcf46619d58c258935d93fc docs/security/THREAT_MODEL.md 80e655063c71d86e90895b289c57a61a127e2ea2b0ee8dfb26bbe78e032f027f docs/testing/FOUNDATION_HARDENING_1_VALIDATION.md a7706bae9b2288ef67a77263af7ef0672eeec34c0d9331671cda04cf0858c39f docs/testing/FOUNDATION_HARDENING_2_VALIDATION.md d9c7d5e718be2f21f50408551733c5860c04745bd4dd55c78b3876827cb1ea21 docs/testing/FOUNDATION_HARDENING_3_VALIDATION.md @@ -59,52 +64,51 @@ d9c7d5e718be2f21f50408551733c5860c04745bd4dd55c78b3876827cb1ea21 docs/testing/F ca41b8558018f1e32ffdf570f061c60dc9914f781ae17f755d2c6402607eb67e docs/testing/M6A_PCI_DISCOVERY_VALIDATION.md c4556e45804712a1f0382fdbd2d4e4fecdf467c95a867a3d16ef0a8aaa515fc7 docs/testing/M6B_BLOCK_TRANSPORT_VALIDATION.md 9f082036ffb705d275ad4d8ccdff940d71fac78b59c7d73c409baebb0a122d36 docs/testing/M6C_CACHE_VFS_VALIDATION.md -efa26f528172a01bec7dc627f9164d19808f590d05fffd3a7400711fef041017 docs/testing/TEST_STRATEGY.md -43f52e072f297d1ba7a3e7a4f92f9e8a3d9bfdc5c4476972be62dc411f0fc20a kernel/Cargo.toml +0831501e48e91561dedf6fa1e20f3bfcf11a94f36d1756e37b317c8c6805cf47 docs/testing/M6D_FAT32_VALIDATION.md +2567e747a138ed4afb9342e964e9d7a775cc256a6c92a6e076cd8a17edcc10cf docs/testing/TEST_STRATEGY.md +b9b62f40044477dd3ba88c6d860a1148299cac8c99e01991b3790573b5bc78de kernel/Cargo.toml 528774183d2c2c8a809b796a1dad91a336462bfd589255007da6d0447ffff04c kernel/src/block.rs 0f8884112d25f779cff472251105301b160b4368ee6aeca421eff75242a4404e kernel/src/boot_info.rs -00f58db133b7d506c6c71b921ba5b8f3a62590c11583201277bae152f3c02a08 kernel/src/cache.rs +f38b67a8ae6c0e8a733038dc62c410a4d1c042fb54e3f3746ea2fa795235ebd1 kernel/src/cache.rs 069853672f94c05025c9342485c3f00453d76c95a228302e01b9ebff47a072a6 kernel/src/capabilities.rs 8010bfaa835197b850c4a45e007f8b74b72c1de78673ebf3f0bedddd5912cf5d kernel/src/elf.rs +3268dbae4febca5081223c7d29dabde3665a73d4ec7c63bb839fdcc8170361d6 kernel/src/fat32.rs 165004c146db5716c8fc5f0f264dcdbc2920c70676caecf3f0e63635b271b8d9 kernel/src/fs.rs -e2624581521c46725a393ed2070c56f23b266738773a7aef1891dd5e16438459 kernel/src/generated/capabilities.rs +fda176faf186e931699ebbc5fcf0c290edf81d240550346ef1d47c6add9c6988 kernel/src/generated/capabilities.rs ae5160fc6d70367b7758afac6876faeb3c9e7766411b4dc1fc3ac10fa28dafa8 kernel/src/generated/mod.rs e6d19b13631029aefb179636a8b2cf20dad347bf2bc89365133912271c758008 kernel/src/heap.rs 9b88a5d4c56554cb1c4bd646925b28bc97a3feb4d3d6c576b5dc33dbe8496d02 kernel/src/input.rs -6180752d6eb7f79d2d174e9a300b186553a88a3c2ee1e7709dc55a5e85426e57 kernel/src/lib.rs +770972a6bf8f7816412f61fdda7200ff7cf814ce5ba3513797648e592f19b77d kernel/src/lib.rs 472d2bb193b4c3449807927e2059db0ed94ea7218c3238f7f082aeda65582683 kernel/src/memory.rs 6188979546aeab5b63216056d9d148184fe89ede5226726b9c8c29471c0717a0 kernel/src/ownership.rs 52dabe4f896d71aa0dd67716cd2660ce1b96550a32ead52836185138384f4ba6 kernel/src/paging.rs f0ad8e59490e98186eb44b4dae04a42be46aae6b669e34b6548efa6646dbae82 kernel/src/pci.rs b36eff431fd41dada1848ccc363ac9a66f1c1ff38f99cb2e16a8655820ebbe06 kernel/src/process.rs 18426c0070887453f9de8a816927fe2c2b3cec57e40f90ef152d67546a462ca3 kernel/src/scheduler.rs -293e9bb2c227fe95f8df14870434207f23aeb5be236225d0e44db12d2b898f50 kernel/src/shell.rs +144860da122b3f90371563499cfd296092f7d002382d53b0ca382adb65c2d451 kernel/src/shell.rs 48ade91d48b51bb4b4cb48d4c3c5e060b5d9cf6e04af139f80ccbe5aa0d64edd kernel/src/startup.rs 1f6223882f082385c2152ef1d6e2938768f995c73eb02e9aaa6845b2e097d739 kernel/src/syscall.rs -7616eda0e38ebf9c2a56f16bb82a2484846e1879dcdd2b6c55b5e689d8c0a3fe kernel/src/vfs.rs -718742da4c6d89cf96babe555371260bcaaadca3ce715e74342cb445e4d80245 Makefile -b2e953a9fae4f33d871d23b45ba726c6035a0c13f04bfaa493122333701c5055 PROJECT.md -de6c9c714fb60079d38d9f403583b2d2bb28ec813111b5c89d5766437501b3e7 README.md +e3d968e077578909717fb6e2fae0af6848ed1d804c9f21d6606648ef62abf7b7 kernel/src/vfs.rs 5aa479510db257bb2f5c82eb11fbbd3c30d1cc5ee70864dd9af5b2387e709c43 rust-toolchain.toml cd7c4d2ac3a81f614a8134661c0353e238ea19d4cb000bf98143a8e5f4a9a521 scripts/build-llvm-probe.sh c26e150f860ee56bc77d7181d7ff3514b32615e17a21494c33c6a159108cb528 scripts/build-smoke.sh 644efa62e87b7fd2b884d694f54d0a020a3c94040b9dcba8c3712ed6924bc64f scripts/build-user-programs.sh 2906bb8debe2870d78b51b72fe33dfeb725cf2f6bffe03af8f8454ad9715c100 scripts/build.sh +57c2bb12342f4269d9d2c39c4d402d72758d4b3f2c47c8b5bec8323f71a1bc1e scripts/create-fat32-image.py f5e3a26817aecf454c61b0bb0ff5f86fd03d7a3fb0ece74c67f1913a4dd94a74 scripts/find-ovmf.sh dee85ec792e3681c8bd60d98122c3dee6efcee1ca185643ff2475674496eb456 scripts/generate-capabilities.py ebb9021802198356ab3068e70f73519ee409919d5785e018447c4d880c1048e1 scripts/generate-source-manifest.py b424e16e3444699ec89bb3d84e4e44c2dfaf4631431d26c88cc6a0e9e957cf42 scripts/image.sh -5ae79499617b1d5d44b0ff7f7d797606d1b8eb3a47081af0b36ac006269bd64e scripts/run-qemu.sh +7102e7e0d2f2c70ed8242291349c21d945f31acd285aa9800d0c6da18a90e675 scripts/run-qemu.sh e16f345f1ba68ffe4960ffbc162126c6bd8e9a8e753f72e91c88e6f1f8317d22 scripts/setup.sh -7a5880e9ba6e6ead03ca59fd1433fe19b3de942ab99b5fc82e0aa09c7f8963e0 scripts/smoke-test.sh -598e74922b59a8cb4d264105c77310143234cc7cd32dcbe0299ee16bf300c0e5 scripts/source-check.py +5c173cb2d9283719235f9c48296916ee5bb33c332d72c8114ed3ea25f1b23759 scripts/smoke-test.sh +b64fde198997fd985c317210d762ce8b5dc2aca929ab4e0fd5daa9f4c6f9487a scripts/source-check.py 4cd8845ab8db1a1702165dcd7b777917804a285a06a5270226aff85e8a53280a scripts/verify-llvm-probe.sh -3ebceddec88cf60e09b74d7f17cbe6f67a5672a9653d7f5084edc3f43547756c SECURITY.md 88cf4ef34b86ae3e33b04d4f1b7e67a15778d88e1728be97982c679a52361a82 user/programs/bin/fault-test.elf 005acc8f60ef17a3db46b01f8c17257ed77a21168649fc1367013080387ba77c user/programs/bin/hello.elf abc0d075a3f46d74a8bd26eafbc8320b0449835287ac5e05b8fa40aae34e88d6 user/programs/bin/init.elf b6bfae7312024c4f6a58168103374fdd8ef78d6fe1b13637c414f996161453c5 user/programs/src/fault-test.S 1dd645bc1eb5fda9d15685f359a564be1346fd08fefa384ea85da829abe3ad46 user/programs/src/hello.S 3dcc935b116e777b03821a59082b05a44e9a636ac39dcecf3915368ffaeda389 user/programs/src/init.S -613a7ccaa0b692edca8b0f823d60874df86459fdfe6310ef863bc310be1a9deb verification/uefi-probe/main.c 5ff24f8132b0ee21d0e54b7d1c91aefef56e3a49c55c25a3437e0f0a8b6b8d5f verification/uefi-probe/README.md +613a7ccaa0b692edca8b0f823d60874df86459fdfe6310ef863bc310be1a9deb verification/uefi-probe/main.c diff --git a/boot/uefi/Cargo.toml b/boot/uefi/Cargo.toml index b7d2497..223c4e4 100644 --- a/boot/uefi/Cargo.toml +++ b/boot/uefi/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sanju-boot" -version = "0.0.11" +version = "0.0.12" edition.workspace = true rust-version.workspace = true publish.workspace = true diff --git a/boot/uefi/src/arch/x86_64/mod.rs b/boot/uefi/src/arch/x86_64/mod.rs index 2bca3bf..9f9afd4 100644 --- a/boot/uefi/src/arch/x86_64/mod.rs +++ b/boot/uefi/src/arch/x86_64/mod.rs @@ -28,7 +28,7 @@ const USER_DATA_SELECTOR: u16 = 0x18; const USER_CODE_SELECTOR: u16 = 0x20; const TSS_SELECTOR: u16 = 0x28; const DOUBLE_FAULT_IST: u8 = 1; -const KERNEL_STACK_SIZE: usize = 64 * 1024; +const KERNEL_STACK_SIZE: usize = 512 * 1024; const DOUBLE_FAULT_STACK_SIZE: usize = 32 * 1024; const SYSCALL_STACK_SIZE: usize = 64 * 1024; const USER_INTERRUPT_STACK_SIZE: usize = 64 * 1024; @@ -682,6 +682,23 @@ unsafe extern "efiapi" { /// is abandoned permanently. pub unsafe fn switch_to_kernel_stack(entry: extern "efiapi" fn() -> !) -> ! { let stack_top = kernel_stack_top(); + let stack_base = unsafe { addr_of_mut!(KERNEL_STACK.0).cast::().addr() as u64 }; + + let current_rsp: u64; + unsafe { asm!("mov {}, rsp", out(reg) current_rsp) }; + + qemu::debug_write_line("[ASM-A] Before RSP switch"); + qemu::debug_write_label_hex("Current RSP: ", current_rsp); + qemu::debug_write_label_hex("Kernel stack base: ", stack_base); + qemu::debug_write_label_hex("Kernel stack top: ", stack_top as u64); + + // Plant a bottom canary + unsafe { + let canary_ptr = stack_base as *mut u64; + canary_ptr.write_volatile(0xDEAD_BEEF_CAFE_BABE); + // Fill the rest with a pattern for high-water mark, up to stack_top - 32 + core::ptr::write_bytes((stack_base + 8) as *mut u8, 0xA5, KERNEL_STACK_SIZE - 40); + } // SAFETY: The stack is statically reserved and 16-byte aligned. The 32-byte // home area satisfies the x86-64 UEFI calling convention before the call. @@ -689,9 +706,29 @@ pub unsafe fn switch_to_kernel_stack(entry: extern "efiapi" fn() -> !) -> ! { asm!( "cli", "mov rsp, {stack_top}", + "mov dx, 0xe9", + "mov al, 91", "out dx, al", // '[' + "mov al, 65", "out dx, al", // 'A' + "mov al, 83", "out dx, al", // 'S' + "mov al, 77", "out dx, al", // 'M' + "mov al, 45", "out dx, al", // '-' + "mov al, 66", "out dx, al", // 'B' + "mov al, 93", "out dx, al", // ']' + "mov al, 13", "out dx, al", // '\r' + "mov al, 10", "out dx, al", // '\n' "and rsp, -16", "sub rsp, 32", "xor rbp, rbp", + "mov dx, 0xe9", + "mov al, 91", "out dx, al", // '[' + "mov al, 65", "out dx, al", // 'A' + "mov al, 83", "out dx, al", // 'S' + "mov al, 77", "out dx, al", // 'M' + "mov al, 45", "out dx, al", // '-' + "mov al, 67", "out dx, al", // 'C' + "mov al, 93", "out dx, al", // ']' + "mov al, 13", "out dx, al", // '\r' + "mov al, 10", "out dx, al", // '\n' "call {entry}", "ud2", stack_top = in(reg) stack_top, @@ -1751,11 +1788,12 @@ fn debug_byte(byte: u8) { #[cfg(feature = "qemu-test")] fn qemu_exit_failure() -> ! { - // SAFETY: The smoke-test machine maps `isa-debug-exit` at port 0xF4. - unsafe { - outl(0x00f4, 0x11); - } - halt() + qemu::exit_failure() +} + +#[cfg(feature = "qemu-test")] +fn qemu_exit_boot_failure() -> ! { + qemu::exit_boot_failure() } fn halt() -> ! { diff --git a/boot/uefi/src/arch/x86_64/qemu.rs b/boot/uefi/src/arch/x86_64/qemu.rs index dd80b14..7e1cb03 100644 --- a/boot/uefi/src/arch/x86_64/qemu.rs +++ b/boot/uefi/src/arch/x86_64/qemu.rs @@ -4,6 +4,7 @@ const DEBUG_PORT: u16 = 0x00e9; const EXIT_PORT: u16 = 0x00f4; const EXIT_SUCCESS: u32 = 0x10; const EXIT_FAILURE: u32 = 0x11; +const EXIT_BOOT_FAILURE: u32 = 0x12; pub fn debug_byte(byte: u8) { // SAFETY: Enabled only for the QEMU test machine, where port 0xE9 is @@ -18,6 +19,33 @@ pub fn debug_byte(byte: u8) { } } +pub fn debug_write(bytes: &[u8]) { + for byte in bytes { + debug_byte(*byte); + } +} + +pub fn debug_write_line(text: &str) { + debug_write(text.as_bytes()); + debug_write(b"\r\n"); +} + +#[allow(clippy::cast_possible_truncation)] +pub fn debug_write_label_hex(label: &str, value: u64) { + debug_write(label.as_bytes()); + debug_write(b"0x"); + for shift in (0..16).rev() { + let nibble = ((value >> (shift * 4)) & 0x0f) as u8; + let byte = if nibble < 10 { + b'0' + nibble + } else { + b'a' + nibble - 10 + }; + debug_byte(byte); + } + debug_write(b"\r\n"); +} + pub fn exit_success() -> ! { exit(EXIT_SUCCESS) } @@ -26,6 +54,10 @@ pub fn exit_failure() -> ! { exit(EXIT_FAILURE) } +pub fn exit_boot_failure() -> ! { + exit(EXIT_BOOT_FAILURE) +} + fn exit(code: u32) -> ! { // SAFETY: The smoke-test QEMU machine configures `isa-debug-exit` at // port 0xF4. This module is omitted from physical-hardware builds. diff --git a/boot/uefi/src/main.rs b/boot/uefi/src/main.rs index f878b4f..20bf9d8 100644 --- a/boot/uefi/src/main.rs +++ b/boot/uefi/src/main.rs @@ -15,6 +15,7 @@ use sanju_kernel::boot_info::{ }; use sanju_kernel::cache::{BlockCache, CacheError, DEFAULT_CACHE_ENTRIES, DirtyStatePolicy}; use sanju_kernel::elf::load_position_independent; +use sanju_kernel::fat32::Fat32; use sanju_kernel::fs::RamFs; use sanju_kernel::heap::KernelHeap; #[cfg(not(feature = "qemu-test"))] @@ -31,14 +32,15 @@ use sanju_kernel::scheduler::{Scheduler, TaskKind}; use sanju_kernel::shell::{Shell, ShellEnvironment}; use sanju_kernel::startup::{self, StartupStage}; use sanju_kernel::vfs::{ - HandleRights, MAX_PATH_COMPONENTS, NodeKind, NormalizedPath, PathError, Vfs, VfsError, + FileSystem, HandleRights, MAX_PATH_COMPONENTS, NodeKind, NormalizedPath, PathError, Vfs, + VfsError, }; use sanju_kernel::{ BootInfo, Console, FoundationHardeningPhase2Report, FoundationHardeningPhase3Report, - FoundationHardeningReport, M5Report, M6aReport, M6bReport, M6cReport, MemoryMapInfo, + FoundationHardeningReport, M5Report, M6aReport, M6bReport, M6cReport, M6dReport, MemoryMapInfo, kernel_main_foundation_hardening, kernel_main_foundation_hardening_phase2, kernel_main_foundation_hardening_phase3, kernel_main_m5, kernel_main_m6a, kernel_main_m6b, - kernel_main_m6c, + kernel_main_m6c, kernel_main_m6d, }; type EfiHandle = *mut c_void; @@ -513,15 +515,28 @@ extern "efiapi" fn efi_main( #[allow(clippy::too_many_lines)] #[unsafe(no_mangle)] extern "efiapi" fn sanju_m5_kernel_entry() -> ! { + cpu::qemu::debug_write_line("[RUST-A] Rust entry reached"); + + cpu::qemu::debug_write_line("[RUST-B] Before BootInfo read"); // SAFETY: `efi_main` initializes the slot exactly once before switching to // this stack and no other execution context can access it during M5 boot. let boot_info = unsafe { addr_of!(BOOT_INFO_SLOT).cast::().read() }; + cpu::qemu::debug_write_line("[RUST-C] After BootInfo read"); + + cpu::qemu::debug_write_line("[RUST-D] Before console initialization"); let mut console = KernelConsole::initialize(); + cpu::qemu::debug_write_line("[RUST-E] Console initialized"); + + cpu::qemu::debug_write_line("[RUST-F] Before Physical memory line"); startup::print_stage(&mut console, StartupStage::Memory, true); + cpu::qemu::debug_write_line("[RUST-G] After Physical memory line"); // SAFETY: Firmware has exited, execution is on the dedicated kernel stack, // and the bootstrap path is still single-core with interrupts disabled. + cpu::qemu::debug_write_line("[RUST-H] Before cpu::initialize"); let cpu_report = unsafe { cpu::initialize() }; + cpu::qemu::debug_write_line("[RUST-I] After cpu::initialize"); + startup::print_stage(&mut console, StartupStage::Cpu, cpu_report.idt_active); if !boot_info.is_compatible() { @@ -1304,6 +1319,13 @@ extern "efiapi" fn sanju_m5_kernel_entry() -> ! { vfs_mounts: vfs.mounts().len(), vfs_handle_capacity: vfs.handles().capacity(), vfs_path_normalization_passed: false, + fat32_mounted: false, + fat32_total_sectors: 0, + fat32_cluster_count: 0, + fat32_sectors_per_cluster: 0, + fat32_persistent_read_passed: false, + fat32_long_name_passed: false, + fat32_multicluster_read_passed: false, }; for byte in b"version\nuserspace\n" { shell.feed_byte(*byte, &mut null_console, &mut vfs, &self_test_environment); @@ -1628,6 +1650,110 @@ extern "efiapi" fn sanju_m5_kernel_entry() -> ! { ); } + let fat32 = match Fat32::mount(block_cache) { + Ok(filesystem) => filesystem, + Err(_) => boot_failure( + &mut console, + "M6D-FAT32-001", + "FAT32 geometry or metadata validation failed", + ), + }; + let fat32_info = fat32.mount_info(); + let mut vfs = match vfs.mount("/disk", fat32) { + Ok(mounted) => mounted, + Err(_) => boot_failure( + &mut console, + "M6D-VFS-001", + "FAT32 VFS mount dispatch initialization failed", + ), + }; + + let mut root_has_readme = false; + let mut root_has_docs = false; + let mut root_has_long_name = false; + let root_directory_read_passed = vfs + .visit_directory("/disk", &mut |name, inode| { + root_has_readme |= + name.eq_ignore_ascii_case("README.TXT") && inode.kind == NodeKind::File; + root_has_docs |= name.eq_ignore_ascii_case("DOCS") && inode.kind == NodeKind::Directory; + root_has_long_name |= name == "Getting-Started.txt" && inode.kind == NodeKind::File; + }) + .is_ok() + && root_has_readme + && root_has_docs + && root_has_long_name; + + let mut persistent_data = [0_u8; 96]; + let persistent_file_read_passed = + read_vfs_file(&mut vfs, "/disk/README.TXT", &mut persistent_data).is_ok_and(|read| { + read <= persistent_data.len() + && persistent_data[..read] + .starts_with(b"Welcome to Soma OS persistent FAT32 storage.") + }); + + let mut long_name_data = [0_u8; 96]; + let long_filename_read_passed = + read_vfs_file(&mut vfs, "/disk/Getting-Started.txt", &mut long_name_data).is_ok_and( + |read| { + read <= long_name_data.len() + && long_name_data[..read].starts_with(b"Soma OS long filename support") + }, + ); + + let nested_directory_read_passed = vfs + .resolve("/disk/docs/GUIDE.TXT") + .is_ok_and(|inode| inode.kind == NodeKind::File && inode.size == 900); + let mut multicluster_data = [0_u8; 1_024]; + let multicluster_read_passed = + read_vfs_file(&mut vfs, "/disk/docs/GUIDE.TXT", &mut multicluster_data).is_ok_and(|read| { + read == 900 + && multicluster_data.starts_with(b"Soma OS M6D multi-cluster guide.") + && multicluster_data[512..read] + .windows(16) + .any(|window| window == b"0123456789abcdef") + }); + let read_only_enforced = vfs.open("/disk/README.TXT", HandleRights::ReadWrite) + == Err(VfsError::ReadOnly) + && vfs.create_or_replace("/disk/new.txt", b"blocked") == Err(VfsError::ReadOnly); + let m6d_vfs_mounts = vfs.mounts().len(); + let vfs_mount_dispatch_active = m6d_vfs_mounts == 2 + && vfs + .resolve("/disk") + .is_ok_and(|inode| inode.kind == NodeKind::Directory); + let fat_cache_stats = vfs + .secondary_backend() + .map(|filesystem| filesystem.inspect_device(BlockCache::stats)) + .unwrap_or_default(); + + let m6d_report = M6dReport { + fat32_mount_active: true, + bytes_per_sector: fat32_info.bytes_per_sector, + sectors_per_cluster: fat32_info.sectors_per_cluster, + total_sectors: fat32_info.total_sectors, + cluster_count: fat32_info.cluster_count, + fs_info_valid: fat32_info.fs_info_valid, + backup_boot_valid: fat32_info.backup_boot_valid, + vfs_mount_dispatch_active, + mounted_filesystems: m6d_vfs_mounts, + root_directory_read_passed, + persistent_file_read_passed, + long_filename_read_passed, + nested_directory_read_passed, + multicluster_read_passed, + read_only_enforced, + cache_backed_reads: fat_cache_stats.device_reads, + dirty_cache_entries: fat_cache_stats.dirty_entries, + m6c_regression_passed: m6c_report.gate_passed(), + }; + kernel_main_m6d(&mut console, m6d_report); + if !m6d_report.gate_passed() { + boot_failure( + &mut console, + "M6D-GATE-001", + "read-only FAT32 persistent-read acceptance gate failed", + ); + } + startup::print_stage(&mut console, StartupStage::Shell, true); Shell::start(&mut console); @@ -1651,17 +1777,28 @@ extern "efiapi" fn sanju_m5_kernel_entry() -> ! { block_read_test_passed: m6b_report.known_sector_read_passed, block_write_test_passed: m6b_report.disposable_sector_write_readback_passed, cache_capacity: m6c_report.cache_capacity_entries, - cache_hits: cache_stats.hits, - cache_misses: cache_stats.misses, - cache_device_reads: cache_stats.device_reads, - cache_dirty_entries: cache_stats.dirty_entries, + cache_hits: fat_cache_stats.hits, + cache_misses: fat_cache_stats.misses, + cache_device_reads: fat_cache_stats.device_reads, + cache_dirty_entries: fat_cache_stats.dirty_entries, cache_read_only_policy: m6c_report.read_only_dirty_policy_active, - vfs_mounts, + vfs_mounts: m6d_vfs_mounts, vfs_handle_capacity, vfs_path_normalization_passed: path_normalization_passed, + fat32_mounted: m6d_report.fat32_mount_active, + fat32_total_sectors: fat32_info.total_sectors, + fat32_cluster_count: fat32_info.cluster_count, + fat32_sectors_per_cluster: fat32_info.sectors_per_cluster, + fat32_persistent_read_passed: persistent_file_read_passed, + fat32_long_name_passed: long_filename_read_passed, + fat32_multicluster_read_passed: multicluster_read_passed, }; - let smoke_commands = - b"help\nuserspace\npci\nblock\ncache\nmounts\nls\ncat welcome.txt\ntasks\nuptime\n"; + let smoke_commands = concat!( + "help\nuserspace\npci\nblock\ncache\nfat32\nmounts\n", + "ls\ncat welcome.txt\nls /disk\ncat /disk/README.TXT\n", + "ls /disk/docs\ntasks\nuptime\n", + ) + .as_bytes(); for byte in smoke_commands { shell.feed_byte(*byte, &mut console, &mut vfs, &environment); } @@ -1700,14 +1837,21 @@ extern "efiapi" fn sanju_m5_kernel_entry() -> ! { block_read_test_passed: m6b_report.known_sector_read_passed, block_write_test_passed: m6b_report.disposable_sector_write_readback_passed, cache_capacity: m6c_report.cache_capacity_entries, - cache_hits: cache_stats.hits, - cache_misses: cache_stats.misses, - cache_device_reads: cache_stats.device_reads, - cache_dirty_entries: cache_stats.dirty_entries, + cache_hits: fat_cache_stats.hits, + cache_misses: fat_cache_stats.misses, + cache_device_reads: fat_cache_stats.device_reads, + cache_dirty_entries: fat_cache_stats.dirty_entries, cache_read_only_policy: m6c_report.read_only_dirty_policy_active, - vfs_mounts, + vfs_mounts: m6d_vfs_mounts, vfs_handle_capacity, vfs_path_normalization_passed: path_normalization_passed, + fat32_mounted: m6d_report.fat32_mount_active, + fat32_total_sectors: fat32_info.total_sectors, + fat32_cluster_count: fat32_info.cluster_count, + fat32_sectors_per_cluster: fat32_info.sectors_per_cluster, + fat32_persistent_read_passed: persistent_file_read_passed, + fat32_long_name_passed: long_filename_read_passed, + fat32_multicluster_read_passed: multicluster_read_passed, }; shell.feed_byte(byte, &mut console, &mut vfs, &environment); } @@ -1718,10 +1862,24 @@ extern "efiapi" fn sanju_m5_kernel_entry() -> ! { } } +fn read_vfs_file( + vfs: &mut Vfs, + path: &str, + destination: &mut [u8], +) -> Result { + let handle = vfs.open(path, HandleRights::ReadOnly)?; + let read_result = vfs.read(handle, destination); + let close_result = vfs.close(handle); + match (read_result, close_result) { + (Ok(read), Ok(())) => Ok(read), + (Err(error), _) | (_, Err(error)) => Err(error), + } +} + fn boot_failure(console: &mut dyn Console, code: &str, message: &str) -> ! { startup::print_failure(console, code, message); #[cfg(feature = "qemu-test")] - cpu::qemu::exit_failure(); + cpu::qemu::exit_boot_failure(); #[cfg(not(feature = "qemu-test"))] cpu::halt_forever() diff --git a/capabilities/capabilities.toml b/capabilities/capabilities.toml index 42b0ff6..b1bf22a 100644 --- a/capabilities/capabilities.toml +++ b/capabilities/capabilities.toml @@ -1,4 +1,4 @@ -registry_version = 6 +registry_version = 7 [[capability]] id = "SYS-TC-001" @@ -259,7 +259,15 @@ required_boot_line = "Generation-protected user handle table: active" [[capability]] id = "FS-FAT32-001" name = "Read-only FAT32 persistent filesystem" -status = "planned" +status = "hardware_active" milestone = "m6d" -evidence = ["docs/process/SPRINT_M6_STORAGE_FOUNDATION.md"] +evidence = [ + "kernel/src/fat32.rs", + "kernel/src/vfs.rs", + "boot/uefi/src/main.rs", + "scripts/create-fat32-image.py", + "scripts/smoke-test.sh", + "docs/testing/M6D_FAT32_VALIDATION.md", +] boot_label = "FAT32 filesystem" +required_boot_line = "M6D read-only FAT32 gate: passed" diff --git a/capabilities/smoke-expectations.txt b/capabilities/smoke-expectations.txt index a6557ef..b6b312d 100644 --- a/capabilities/smoke-expectations.txt +++ b/capabilities/smoke-expectations.txt @@ -23,3 +23,4 @@ M6B block transport gate: passed Fixed-capacity block cache: active VFS contracts: active Generation-protected user handle table: active +M6D read-only FAT32 gate: passed diff --git a/docs/CAPABILITY_MATRIX.md b/docs/CAPABILITY_MATRIX.md index 8c78b8b..e59acc0 100644 --- a/docs/CAPABILITY_MATRIX.md +++ b/docs/CAPABILITY_MATRIX.md @@ -1,6 +1,6 @@ # Soma OS Capability Matrix -Registry version: **6** +Registry version: **7** This file is generated from `capabilities/capabilities.toml`. Do not edit it manually. @@ -35,4 +35,4 @@ This file is generated from `capabilities/capabilities.toml`. Do not edit it man | `STOR-CACHE-001` | Bounded read-only block cache | `verified` | `m6c` | `kernel/src/cache.rs`
`boot/uefi/src/main.rs`
`scripts/smoke-test.sh` | | `VFS-CORE-001` | VFS inode, mount, path, and handle contracts | `verified` | `m6c` | `kernel/src/vfs.rs`
`kernel/src/fs.rs`
`boot/uefi/src/main.rs`
`scripts/smoke-test.sh` | | `VFS-HANDLE-001` | Generation-protected bounded user file handles | `verified` | `m6c` | `kernel/src/vfs.rs`
`boot/uefi/src/main.rs`
`scripts/smoke-test.sh` | -| `FS-FAT32-001` | Read-only FAT32 persistent filesystem | `planned` | `m6d` | `docs/process/SPRINT_M6_STORAGE_FOUNDATION.md` | +| `FS-FAT32-001` | Read-only FAT32 persistent filesystem | `hardware_active` | `m6d` | `kernel/src/fat32.rs`
`kernel/src/vfs.rs`
`boot/uefi/src/main.rs`
`scripts/create-fat32-image.py`
`scripts/smoke-test.sh`
`docs/testing/M6D_FAT32_VALIDATION.md` | diff --git a/docs/adr/0014-read-only-fat32.md b/docs/adr/0014-read-only-fat32.md new file mode 100644 index 0000000..c6fcd4e --- /dev/null +++ b/docs/adr/0014-read-only-fat32.md @@ -0,0 +1,54 @@ +# ADR 0014: Read-Only FAT32 Behind the VFS + +- Status: Accepted for M6D implementation +- Date: 2026-07-30 + +## Context + +M6B established a bounded virtio block transport and M6C added a fixed-capacity +read-through cache plus VFS contracts. The next dependency is a real, +persistent filesystem that can be tested without authorizing general-purpose +disk writes or coupling filesystem code to x86 PCI details. + +FAT32 is suitable for this gate because its on-disk structures are documented, +the fixture can be generated deterministically, and the implementation can be +kept allocation-free. The format is also untrusted input: malformed geometry, +cluster chains, directory entries, and long filenames must fail closed. + +## Decision + +1. M6D supports 512-byte-sector FAT32 volumes only. +2. Mounting validates the boot signature, BPB geometry, total device bounds, + FAT capacity, root cluster, FSInfo signatures, and complete backup boot + sector. +3. The filesystem depends only on the architecture-independent `BlockDevice` + contract. M6D wraps the accepted virtio device in the M6C read-only cache. +4. RAMFS remains the writable root and FAT32 mounts at `/disk` as the one + secondary VFS backend. +5. FAT and directory traversals are bounded by the validated data-cluster + count. Free, reserved, bad, out-of-range, and cyclic chains are rejected. +6. File reads advance sequentially through the chain. When a read reaches the + declared file size, the current cluster must terminate the chain. +7. FAT 8.3 names are ASCII-bounded. Long filenames require a complete ordinal + sequence, matching short-name checksum, valid UTF-16, and a VFS-bounded + component. +8. The VFS and cache reject all persistent writes. The M6D gate requires zero + dirty cache entries after every acceptance read. +9. QEMU uses a deterministic generated image containing a root file, a long + filename, a nested directory, and a multi-cluster file. + +## Consequences + +- Persistent data is now read through the real virtio/cache/VFS stack. +- Shell `ls` and `cat` can address `/disk` without knowing the backend type. +- Corrupt media produces bounded errors instead of unbounded traversal. +- The early VFS intentionally supports one root and one secondary concrete + backend; a dynamic backend registry is deferred. +- OEM short-name code pages, partitions, writable FAT32, journaling, recovery, + and physical-disk installation are outside M6D. + +## Rollback + +`v0.0.11-m6c` remains the rollback point. Removing the `/disk` mount and M6D +acceptance path restores the cache/VFS baseline without changing the accepted +M6B transport. diff --git a/docs/architecture/SYSTEM_OVERVIEW.md b/docs/architecture/SYSTEM_OVERVIEW.md index e8a00cc..b6d73a7 100644 --- a/docs/architecture/SYSTEM_OVERVIEW.md +++ b/docs/architecture/SYSTEM_OVERVIEW.md @@ -145,6 +145,12 @@ entry. The VFS layer defines fixed inode, superblock, mount, path, directory, and generation-tagged handle contracts. Paths are absolute and canonical with -bounded depth; mount selection observes component boundaries. RAMFS is the only -active backend and remains volatile. M6D will attach a read-only FAT32 backend -without importing PCI or virtio types into filesystem code. +bounded depth; mount selection observes component boundaries. RAMFS remains the +writable root. + +M6D mounts one allocation-free FAT32 backend at `/disk`. It consumes the cached +block-device contract, validates the complete volume geometry before trusting +offsets, and bounds every FAT and directory traversal by the verified cluster +count. The backend decodes short names and bounded checksum-validated UTF-16 +long names, supports nested and multi-cluster reads, and cannot issue writes. +Filesystem code imports no PCI or virtio implementation types. diff --git a/docs/process/BACKLOG.md b/docs/process/BACKLOG.md index 38486b7..c7742ad 100644 --- a/docs/process/BACKLOG.md +++ b/docs/process/BACKLOG.md @@ -54,7 +54,7 @@ - Block-device abstraction and polling virtio-blk transport. - Dedicated disk identity, known-sector read, and confined write/restore gate. -## M6C — Bounded Cache and VFS — implementation ready for CI +## M6C — Bounded Cache and VFS — accepted (`v0.0.11-m6c`) - Fixed-capacity read-through cache with a hard no-dirty-data policy. - VFS inode, superblock, mount, canonical path, and file-handle contracts. @@ -63,8 +63,16 @@ ## M6D — Read-Only Persistent Filesystem -- Read-only FAT32 persistent filesystem prototype. -- User VFS handles and executable spawning after the kernel VFS gate. +- Validated read-only FAT32 on the dedicated virtio disk. +- Persistent root, long-name, nested, offset, and multi-cluster reads. +- Secondary VFS mount, shell access, and zero-dirty enforcement. + +## M6E — Process-Facing Persistent Reads + +- Extend the syscall/VFS boundary to persistent mount-aware file handles. +- Copy persistent file data safely into private user address spaces. +- Load signed/accepted ELF64 executables from read-only FAT32. +- Preserve process, path, handle, and address-space isolation. ## Later major epics diff --git a/docs/process/SPRINT_M6_STORAGE_FOUNDATION.md b/docs/process/SPRINT_M6_STORAGE_FOUNDATION.md index bf0a560..400026e 100644 --- a/docs/process/SPRINT_M6_STORAGE_FOUNDATION.md +++ b/docs/process/SPRINT_M6_STORAGE_FOUNDATION.md @@ -47,7 +47,7 @@ Exit criteria: ## M6C — buffer cache and VFS -Status: implementation candidate; QEMU acceptance required. +Status: accepted as `v0.0.11-m6c`. - [x] 16-sector, allocation-free, read-through LRU block cache; - [x] hard read-only dirty-state policy that rejects writes before transport; @@ -59,7 +59,7 @@ Status: implementation candidate; QEMU acceptance required. - [x] RAMFS adapted behind the VFS contract; - [x] live virtio first-miss/repeat-hit cache probe; - [x] `cache` and `mounts` shell diagnostics; -- [ ] pass the pinned-toolchain headless QEMU smoke gate. +- [x] pass the pinned-toolchain headless QEMU smoke gate. Exit criteria: @@ -74,12 +74,26 @@ Exit criteria: ## M6D — read-only FAT32 -- validate BPB geometry and FAT bounds; -- mount the dedicated second disk read-only; -- support root-directory listing and bounded file reads; -- reject malformed chains, loops, invalid clusters, and unsupported layouts; -- expose mounted files through `ls` and `cat`; -- prove a seeded file survives a fresh QEMU boot. +Status: implementation candidate; QEMU acceptance required. + +- [x] validate BPB geometry, FAT capacity, FSInfo, backup boot, and device bounds; +- [x] mount the dedicated second disk read-only at `/disk`; +- [x] support root and nested directory listing; +- [x] support offset and multi-cluster file reads; +- [x] decode FAT 8.3 and bounded checksum-validated long filenames; +- [x] reject malformed chains, loops, invalid clusters, and unsupported layouts; +- [x] enforce read-only VFS and zero-dirty cache state; +- [x] expose mounted files through path-aware `ls` and streaming `cat`; +- [x] generate a deterministic FAT32 fixture for every QEMU run; +- [ ] pass the pinned-toolchain headless QEMU smoke gate. + +Exit criteria: + +- the deterministic persistent files survive a fresh boot; +- root, nested, long-name, offset, and multi-cluster reads pass; +- malformed chains fail without unbounded traversal; +- persistent writes are rejected before the transport; +- M6C and every earlier regression gate remain passed. ## Deferred beyond M6D diff --git a/docs/security/THREAT_MODEL.md b/docs/security/THREAT_MODEL.md index fa174b4..bc15eeb 100644 --- a/docs/security/THREAT_MODEL.md +++ b/docs/security/THREAT_MODEL.md @@ -88,7 +88,14 @@ - mount resolution checks component boundaries and uses the longest prefix; - user file handles include generations so closed identifiers cannot alias a reused slot; -- filesystem work begins read-only with geometry and bounds validation; +- FAT32 mounting validates BPB geometry, FAT capacity, FSInfo signatures, the + complete backup boot sector, root cluster, and device bounds; +- FAT and directory walks reject free, reserved, bad, out-of-range, premature, + overlong, and cyclic chains within a fixed traversal budget; +- long filenames require a complete ordinal sequence, matching short-name + checksum, valid UTF-16, and a VFS-bounded component; +- the persistent backend and cache both reject writes and the M6D gate requires + zero dirty entries after all acceptance reads; - persistent writes remain blocked until reboot, corruption, and recovery gates are implemented. diff --git a/docs/testing/M6D_FAT32_VALIDATION.md b/docs/testing/M6D_FAT32_VALIDATION.md new file mode 100644 index 0000000..fe4317c --- /dev/null +++ b/docs/testing/M6D_FAT32_VALIDATION.md @@ -0,0 +1,52 @@ +# M6D Read-Only FAT32 Validation + +## Purpose + +Prove that Soma OS mounts a deterministic FAT32 volume through the accepted +virtio block, cache, and VFS layers; reads persistent data correctly; rejects +corrupt chains; and never creates persistent dirty state. + +## Mechanical gates + +```bash +python3 scripts/generate-capabilities.py --check +python3 scripts/source-check.py +cargo fmt --all -- --check +cargo test -p sanju-kernel +cargo clippy -p sanju-kernel --all-targets -- -D warnings +cargo clippy -p sanju-boot --target x86_64-unknown-uefi -- -D warnings +cargo build -p sanju-boot --release --target x86_64-unknown-uefi +bash scripts/smoke-test.sh +``` + +## Host coverage + +- valid BPB, FSInfo, complete backup boot, FAT capacity, and root-cluster mount; +- invalid signature and inconsistent backup rejection; +- multi-cluster and offset reads; +- malformed cyclic-chain rejection; +- checksum-validated long-filename lookup; +- secondary VFS mount dispatch; +- streaming shell reads beyond one sector; +- VFS and backend write rejection. + +## QEMU acceptance evidence + +- M5, FH1, FH2, FH3, M6A, M6B, and M6C gates remain passed; +- the deterministic 131072-sector FAT32 image is the identified virtio device; +- FAT32 reports 512-byte sectors, one sector per cluster, and 129022 data + clusters; +- FSInfo and backup boot validation pass; +- `/disk` mounts as the second VFS filesystem; +- root, persistent-file, long-name, nested-directory, and multi-cluster reads + pass; +- FAT32 writes are blocked; +- the cache reports zero dirty entries after all persistent reads; +- shell `mounts`, `fat32`, `ls /disk`, `ls /disk/docs`, and + `cat /disk/README.TXT` expose the accepted state. + +## Boundary + +M6D does not provide process-facing persistent file descriptors, executable +loading from FAT32, partition discovery, writable FAT32, recovery, encryption, +or physical-disk installation. The accepted environment remains QEMU-only. diff --git a/docs/testing/TEST_STRATEGY.md b/docs/testing/TEST_STRATEGY.md index ab979b6..c3f8f0e 100644 --- a/docs/testing/TEST_STRATEGY.md +++ b/docs/testing/TEST_STRATEGY.md @@ -23,6 +23,11 @@ - live first-miss/repeat-hit cache evidence with one underlying device read; - explicit write rejection and zero-dirty-entry evidence for M6C; - bounded path, mount-prefix, RAMFS adapter, handle-capacity, and stale-handle tests; +- deterministic FAT32 BPB, FSInfo, backup boot, directory, long-name, and + multi-cluster fixtures; +- malformed FAT-chain, cyclic-chain, offset-read, secondary-mount, and + persistent write-rejection tests; +- QEMU root, nested, long-name, multi-cluster, and streaming shell read evidence; - every previously accepted milestone rerun as a regression gate. ## Future gates diff --git a/kernel/Cargo.toml b/kernel/Cargo.toml index f8fc103..77ed3ce 100644 --- a/kernel/Cargo.toml +++ b/kernel/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sanju-kernel" -version = "0.0.11" +version = "0.0.12" edition.workspace = true rust-version.workspace = true publish.workspace = true diff --git a/kernel/src/cache.rs b/kernel/src/cache.rs index 188db81..40f0365 100644 --- a/kernel/src/cache.rs +++ b/kernel/src/cache.rs @@ -232,6 +232,35 @@ impl BlockCache { } } +impl BlockDevice for BlockCache { + fn geometry(&self) -> BlockGeometry { + BlockGeometry { + read_only: self.policy == DirtyStatePolicy::RejectWrites, + ..self.device.geometry() + } + } + + fn read_sector( + &mut self, + sector: u64, + destination: &mut [u8; SECTOR_SIZE], + ) -> Result<(), BlockError> { + BlockCache::read_sector(self, sector, destination).map_err(cache_block_error) + } + + fn write_sector(&mut self, sector: u64, source: &[u8; SECTOR_SIZE]) -> Result<(), BlockError> { + BlockCache::write_sector(self, sector, source).map_err(cache_block_error) + } +} + +const fn cache_block_error(error: CacheError) -> BlockError { + match error { + CacheError::Block(error) => error, + CacheError::ReadOnlyPolicy => BlockError::ReadOnly, + CacheError::ZeroCapacity | CacheError::NoEvictableEntry => BlockError::Io, + } +} + #[cfg(test)] mod tests { use super::{BlockCache, CacheError, DirtyStatePolicy}; diff --git a/kernel/src/fat32.rs b/kernel/src/fat32.rs new file mode 100644 index 0000000..d8a61b4 --- /dev/null +++ b/kernel/src/fat32.rs @@ -0,0 +1,1071 @@ +#![allow(clippy::module_name_repetitions)] + +//! Bounded, read-only FAT32 filesystem. +//! +//! The implementation validates the on-disk geometry before trusting offsets, +//! never allocates, rejects malformed cluster chains, and exposes persistent +//! files only through the VFS contracts. + +use core::cell::RefCell; +use core::char::decode_utf16; +use core::str; + +use crate::block::{BlockDevice, BlockError, SECTOR_SIZE}; +use crate::vfs::{FileSystem, Inode, InodeId, MAX_COMPONENT_BYTES, NodeKind, Superblock, VfsError}; + +const FAT32_MIN_CLUSTERS: u32 = 65_525; +const FAT32_MAX_DATA_CLUSTER: u32 = 0x0fff_ffef; +const FAT32_ENTRY_MASK: u32 = 0x0fff_ffff; +const FAT32_BAD_CLUSTER: u32 = 0x0fff_fff7; +const FAT32_END_OF_CHAIN: u32 = 0x0fff_fff8; +const DIRECTORY_ENTRY_BYTES: usize = 32; +const DIRECTORY_ENTRIES_PER_SECTOR: usize = SECTOR_SIZE / DIRECTORY_ENTRY_BYTES; +const ATTRIBUTE_DIRECTORY: u8 = 0x10; +const ATTRIBUTE_VOLUME_ID: u8 = 0x08; +const ATTRIBUTE_LONG_NAME: u8 = 0x0f; +const ROOT_INODE: InodeId = InodeId(1); +const ENTRY_INODE_FLAG: u64 = 1_u64 << 63; +const MAX_LFN_UTF16_UNITS: usize = 65; + +/// Validated FAT32 geometry retained by the mounted backend. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Fat32MountInfo { + pub bytes_per_sector: u16, + pub sectors_per_cluster: u8, + pub reserved_sectors: u16, + pub fat_count: u8, + pub sectors_per_fat: u32, + pub total_sectors: u32, + pub cluster_count: u32, + pub root_cluster: u32, + pub volume_id: u32, + pub fs_info_valid: bool, + pub backup_boot_valid: bool, +} + +/// FAT32 mount and traversal failures. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Fat32Error { + Device(BlockError), + InvalidBootSignature, + UnsupportedSectorSize, + InvalidSectorsPerCluster, + InvalidReservedSectors, + InvalidFatCount, + InvalidFatSize, + InvalidTotalSectors, + NotFat32, + InvalidRootCluster, + InvalidFsInfo, + InvalidBackupBoot, + InvalidCluster, + CorruptFat, + ClusterLoop, + DirectoryCorrupt, + InvalidInode, + NotFound, + NotDirectory, + IsDirectory, + ReadOnly, +} + +impl From for Fat32Error { + fn from(value: BlockError) -> Self { + Self::Device(value) + } +} + +#[derive(Clone, Copy)] +struct Fat32Layout { + info: Fat32MountInfo, + active_fat_sector: u32, + first_data_sector: u32, + max_cluster: u32, +} + +#[derive(Clone, Copy)] +struct DirectoryRecord { + inode: InodeId, + first_cluster: u32, + size: u32, + kind: NodeKind, + name: [u8; MAX_COMPONENT_BYTES], + name_len: usize, +} + +impl DirectoryRecord { + fn name(&self) -> &str { + str::from_utf8(&self.name[..self.name_len]).unwrap_or("") + } + + const fn inode(self) -> Inode { + Inode { + id: self.inode, + kind: self.kind, + size: self.size as u64, + } + } +} + +#[derive(Clone, Copy)] +struct LongNameState { + units: [u16; MAX_LFN_UTF16_UNITS], + expected_ordinal: u8, + checksum: u8, + active: bool, +} + +impl LongNameState { + const fn new() -> Self { + Self { + units: [0xffff; MAX_LFN_UTF16_UNITS], + expected_ordinal: 0, + checksum: 0, + active: false, + } + } + + fn reset(&mut self) { + *self = Self::new(); + } + + fn consume(&mut self, entry: &[u8]) { + let ordinal_raw = entry[0]; + let ordinal = ordinal_raw & 0x1f; + let last = ordinal_raw & 0x40 != 0; + if ordinal == 0 || entry[11] != ATTRIBUTE_LONG_NAME || entry[12] != 0 { + self.reset(); + return; + } + if read_u16(entry, 26) != 0 { + self.reset(); + return; + } + let start = usize::from(ordinal.saturating_sub(1)) * 13; + if start >= self.units.len() || start.saturating_add(13) > self.units.len() { + self.reset(); + return; + } + if last { + self.units.fill(0xffff); + self.expected_ordinal = ordinal; + self.checksum = entry[13]; + self.active = true; + } + if !self.active || ordinal != self.expected_ordinal || entry[13] != self.checksum { + self.reset(); + return; + } + + const OFFSETS: [usize; 13] = [1, 3, 5, 7, 9, 14, 16, 18, 20, 22, 24, 28, 30]; + for (index, offset) in OFFSETS.into_iter().enumerate() { + self.units[start + index] = read_u16(entry, offset); + } + self.expected_ordinal = self.expected_ordinal.saturating_sub(1); + } + + fn decode(&self, short_name: &[u8; 11]) -> Option<([u8; MAX_COMPONENT_BYTES], usize)> { + if !self.active + || self.expected_ordinal != 0 + || self.checksum != short_name_checksum(short_name) + { + return None; + } + + let unit_len = self + .units + .iter() + .position(|unit| *unit == 0 || *unit == 0xffff) + .unwrap_or(self.units.len()); + let mut output = [0_u8; MAX_COMPONENT_BYTES]; + let mut output_len: usize = 0; + for decoded in decode_utf16(self.units[..unit_len].iter().copied()) { + let character = decoded.ok()?; + if character == '\0' || character == '/' || character.is_control() { + return None; + } + let mut encoded = [0_u8; 4]; + let text = character.encode_utf8(&mut encoded); + let end = output_len.checked_add(text.len())?; + if end > output.len() { + return None; + } + output[output_len..end].copy_from_slice(text.as_bytes()); + output_len = end; + } + (output_len != 0).then_some((output, output_len)) + } +} + +/// Mounted, read-only FAT32 backend. +pub struct Fat32 { + device: RefCell, + layout: Fat32Layout, +} + +impl Fat32 { + /// Validates and mounts a FAT32 volume from sector zero. + /// + /// # Errors + /// + /// Rejects unsupported geometry, invalid signatures, non-FAT32 cluster + /// counts, out-of-device ranges, and inconsistent backup metadata. + pub fn mount(mut device: D) -> Result { + let geometry = device.geometry(); + if geometry.sector_size != SECTOR_SIZE as u32 { + return Err(Fat32Error::UnsupportedSectorSize); + } + + let mut boot = [0_u8; SECTOR_SIZE]; + device.read_sector(0, &mut boot)?; + if boot[510] != 0x55 || boot[511] != 0xaa { + return Err(Fat32Error::InvalidBootSignature); + } + + let bytes_per_sector = read_u16(&boot, 11); + if bytes_per_sector != SECTOR_SIZE as u16 { + return Err(Fat32Error::UnsupportedSectorSize); + } + let sectors_per_cluster = boot[13]; + if sectors_per_cluster == 0 + || !sectors_per_cluster.is_power_of_two() + || sectors_per_cluster > 128 + { + return Err(Fat32Error::InvalidSectorsPerCluster); + } + let reserved_sectors = read_u16(&boot, 14); + if reserved_sectors == 0 { + return Err(Fat32Error::InvalidReservedSectors); + } + let fat_count = boot[16]; + if !(1..=2).contains(&fat_count) { + return Err(Fat32Error::InvalidFatCount); + } + if read_u16(&boot, 17) != 0 || read_u16(&boot, 22) != 0 { + return Err(Fat32Error::NotFat32); + } + let total_sectors = read_u32(&boot, 32); + if read_u16(&boot, 19) != 0 + || total_sectors == 0 + || u64::from(total_sectors) > geometry.sectors + { + return Err(Fat32Error::InvalidTotalSectors); + } + let sectors_per_fat = read_u32(&boot, 36); + if sectors_per_fat == 0 { + return Err(Fat32Error::InvalidFatSize); + } + + let fat_region = u32::from(fat_count) + .checked_mul(sectors_per_fat) + .ok_or(Fat32Error::InvalidFatSize)?; + let first_data_sector = u32::from(reserved_sectors) + .checked_add(fat_region) + .ok_or(Fat32Error::InvalidTotalSectors)?; + if first_data_sector >= total_sectors { + return Err(Fat32Error::InvalidTotalSectors); + } + let data_sectors = total_sectors - first_data_sector; + let cluster_count = data_sectors / u32::from(sectors_per_cluster); + if !(FAT32_MIN_CLUSTERS..=FAT32_MAX_DATA_CLUSTER - 1).contains(&cluster_count) { + return Err(Fat32Error::NotFat32); + } + let max_cluster = cluster_count + .checked_add(1) + .ok_or(Fat32Error::InvalidTotalSectors)?; + let required_fat_bytes = u64::from(cluster_count + 2) + .checked_mul(4) + .ok_or(Fat32Error::InvalidFatSize)?; + if required_fat_bytes + > u64::from(sectors_per_fat) + .checked_mul(SECTOR_SIZE as u64) + .ok_or(Fat32Error::InvalidFatSize)? + { + return Err(Fat32Error::InvalidFatSize); + } + + let root_cluster = read_u32(&boot, 44); + if root_cluster < 2 || root_cluster > max_cluster { + return Err(Fat32Error::InvalidRootCluster); + } + let flags = read_u16(&boot, 40); + let active_fat = if flags & 0x0080 == 0 { + 0 + } else { + u32::from(flags & 0x000f) + }; + if active_fat >= u32::from(fat_count) { + return Err(Fat32Error::InvalidFatCount); + } + let active_fat_sector = u32::from(reserved_sectors) + .checked_add( + active_fat + .checked_mul(sectors_per_fat) + .ok_or(Fat32Error::InvalidFatSize)?, + ) + .ok_or(Fat32Error::InvalidFatSize)?; + + let fs_info_sector = read_u16(&boot, 48); + if fs_info_sector == 0 || fs_info_sector >= reserved_sectors { + return Err(Fat32Error::InvalidFsInfo); + } + let mut fs_info = [0_u8; SECTOR_SIZE]; + device.read_sector(u64::from(fs_info_sector), &mut fs_info)?; + let fs_info_valid = read_u32(&fs_info, 0) == 0x4161_5252 + && read_u32(&fs_info, 484) == 0x6141_7272 + && read_u32(&fs_info, 508) == 0xaa55_0000; + if !fs_info_valid { + return Err(Fat32Error::InvalidFsInfo); + } + + let backup_boot_sector = read_u16(&boot, 50); + if backup_boot_sector == 0 || backup_boot_sector >= reserved_sectors { + return Err(Fat32Error::InvalidBackupBoot); + } + let mut backup_boot = [0_u8; SECTOR_SIZE]; + device.read_sector(u64::from(backup_boot_sector), &mut backup_boot)?; + let backup_boot_valid = backup_boot == boot; + if !backup_boot_valid { + return Err(Fat32Error::InvalidBackupBoot); + } + + let info = Fat32MountInfo { + bytes_per_sector, + sectors_per_cluster, + reserved_sectors, + fat_count, + sectors_per_fat, + total_sectors, + cluster_count, + root_cluster, + volume_id: read_u32(&boot, 67), + fs_info_valid, + backup_boot_valid, + }; + Ok(Self { + device: RefCell::new(device), + layout: Fat32Layout { + info, + active_fat_sector, + first_data_sector, + max_cluster, + }, + }) + } + + /// Returns the validated mount geometry. + #[must_use] + pub const fn mount_info(&self) -> Fat32MountInfo { + self.layout.info + } + + /// Inspects the wrapped read-only device without transferring ownership. + pub fn inspect_device(&self, inspector: impl FnOnce(&D) -> T) -> T { + let device = self.device.borrow(); + inspector(&device) + } + + fn read_sector( + &self, + sector: u32, + destination: &mut [u8; SECTOR_SIZE], + ) -> Result<(), Fat32Error> { + if sector >= self.layout.info.total_sectors { + return Err(Fat32Error::InvalidCluster); + } + self.device + .borrow_mut() + .read_sector(u64::from(sector), destination) + .map_err(Fat32Error::Device) + } + + fn next_cluster(&self, cluster: u32) -> Result, Fat32Error> { + self.validate_cluster(cluster)?; + let fat_offset = cluster.checked_mul(4).ok_or(Fat32Error::CorruptFat)?; + let fat_sector = self + .layout + .active_fat_sector + .checked_add(fat_offset / SECTOR_SIZE as u32) + .ok_or(Fat32Error::CorruptFat)?; + let offset = + usize::try_from(fat_offset % SECTOR_SIZE as u32).map_err(|_| Fat32Error::CorruptFat)?; + let mut sector = [0_u8; SECTOR_SIZE]; + self.read_sector(fat_sector, &mut sector)?; + let value = read_u32(§or, offset) & FAT32_ENTRY_MASK; + match value { + FAT32_END_OF_CHAIN..=FAT32_ENTRY_MASK => Ok(None), + FAT32_BAD_CLUSTER | 0 | 1 => Err(Fat32Error::CorruptFat), + 2..=FAT32_MAX_DATA_CLUSTER => { + self.validate_cluster(value)?; + Ok(Some(value)) + } + _ => Err(Fat32Error::CorruptFat), + } + } + + fn validate_cluster(&self, cluster: u32) -> Result<(), Fat32Error> { + if cluster < 2 || cluster > self.layout.max_cluster { + return Err(Fat32Error::InvalidCluster); + } + Ok(()) + } + + fn cluster_sector(&self, cluster: u32, sector_in_cluster: u32) -> Result { + self.validate_cluster(cluster)?; + if sector_in_cluster >= u32::from(self.layout.info.sectors_per_cluster) { + return Err(Fat32Error::InvalidCluster); + } + self.layout + .first_data_sector + .checked_add( + (cluster - 2) + .checked_mul(u32::from(self.layout.info.sectors_per_cluster)) + .ok_or(Fat32Error::InvalidCluster)?, + ) + .and_then(|sector| sector.checked_add(sector_in_cluster)) + .filter(|sector| *sector < self.layout.info.total_sectors) + .ok_or(Fat32Error::InvalidCluster) + } + + fn cluster_at(&self, first: u32, index: u64) -> Result { + self.validate_cluster(first)?; + if index >= u64::from(self.layout.info.cluster_count) { + return Err(Fat32Error::InvalidCluster); + } + let mut current = first; + let mut tortoise = first; + let mut hare = Some(first); + for _ in 0..index { + current = self.next_cluster(current)?.ok_or(Fat32Error::CorruptFat)?; + tortoise = self.next_cluster(tortoise)?.ok_or(Fat32Error::CorruptFat)?; + hare = self.advance_twice(hare)?; + if hare == Some(tortoise) { + return Err(Fat32Error::ClusterLoop); + } + } + Ok(current) + } + + fn advance_twice(&self, cluster: Option) -> Result, Fat32Error> { + let Some(first) = cluster else { + return Ok(None); + }; + let Some(second) = self.next_cluster(first)? else { + return Ok(None); + }; + self.next_cluster(second) + } + + fn entry_from_inode(&self, inode: InodeId) -> Result { + let (sector, slot) = decode_entry_inode(inode)?; + if sector >= self.layout.info.total_sectors || slot >= DIRECTORY_ENTRIES_PER_SECTOR { + return Err(Fat32Error::InvalidInode); + } + let mut data = [0_u8; SECTOR_SIZE]; + self.read_sector(sector, &mut data)?; + let start = slot * DIRECTORY_ENTRY_BYTES; + let entry = &data[start..start + DIRECTORY_ENTRY_BYTES]; + if entry[0] == 0 || entry[0] == 0xe5 || entry[11] == ATTRIBUTE_LONG_NAME { + return Err(Fat32Error::InvalidInode); + } + self.short_record(sector, slot, entry, &LongNameState::new()) + } + + fn directory_cluster(&self, inode: InodeId) -> Result { + if inode == ROOT_INODE { + return Ok(self.layout.info.root_cluster); + } + let record = self.entry_from_inode(inode)?; + if record.kind != NodeKind::Directory { + return Err(Fat32Error::NotDirectory); + } + self.validate_cluster(record.first_cluster)?; + Ok(record.first_cluster) + } + + fn scan_directory( + &self, + inode: InodeId, + visitor: &mut dyn FnMut(DirectoryRecord) -> bool, + ) -> Result<(), Fat32Error> { + let first = self.directory_cluster(inode)?; + let mut current = first; + let mut hare = Some(first); + let mut long_name = LongNameState::new(); + + for _ in 0..self.layout.info.cluster_count { + for sector_in_cluster in 0..u32::from(self.layout.info.sectors_per_cluster) { + let sector_number = self.cluster_sector(current, sector_in_cluster)?; + let mut sector = [0_u8; SECTOR_SIZE]; + self.read_sector(sector_number, &mut sector)?; + for slot in 0..DIRECTORY_ENTRIES_PER_SECTOR { + let start = slot * DIRECTORY_ENTRY_BYTES; + let entry = §or[start..start + DIRECTORY_ENTRY_BYTES]; + match entry[0] { + 0 => return Ok(()), + 0xe5 => { + long_name.reset(); + continue; + } + _ => {} + } + if entry[11] == ATTRIBUTE_LONG_NAME { + long_name.consume(entry); + continue; + } + if entry[11] & ATTRIBUTE_VOLUME_ID != 0 { + long_name.reset(); + continue; + } + + let record = self.short_record(sector_number, slot, entry, &long_name)?; + long_name.reset(); + if record.name() == "." || record.name() == ".." { + continue; + } + if !visitor(record) { + return Ok(()); + } + } + } + + let Some(next) = self.next_cluster(current)? else { + return Ok(()); + }; + current = next; + hare = self.advance_twice(hare)?; + if hare == Some(current) { + return Err(Fat32Error::ClusterLoop); + } + } + Err(Fat32Error::ClusterLoop) + } + + fn short_record( + &self, + sector: u32, + slot: usize, + entry: &[u8], + long_name: &LongNameState, + ) -> Result { + let short: [u8; 11] = entry[..11] + .try_into() + .map_err(|_| Fat32Error::DirectoryCorrupt)?; + let (name, name_len) = long_name + .decode(&short) + .or_else(|| decode_short_name(&short, entry[12])) + .ok_or(Fat32Error::DirectoryCorrupt)?; + let first_cluster = (u32::from(read_u16(entry, 20)) << 16) | u32::from(read_u16(entry, 26)); + let size = read_u32(entry, 28); + let kind = if entry[11] & ATTRIBUTE_DIRECTORY != 0 { + NodeKind::Directory + } else { + NodeKind::File + }; + let name_text = str::from_utf8(&name[..name_len]).unwrap_or(""); + let root_relative_dot = + kind == NodeKind::Directory && first_cluster == 0 && name_text == ".."; + if !root_relative_dot && (kind == NodeKind::Directory || size != 0 || first_cluster != 0) { + self.validate_cluster(first_cluster)?; + } + Ok(DirectoryRecord { + inode: encode_entry_inode(sector, slot)?, + first_cluster, + size, + kind, + name, + name_len, + }) + } + + fn read_file( + &self, + record: DirectoryRecord, + offset: u64, + destination: &mut [u8], + ) -> Result { + if record.kind == NodeKind::Directory { + return Err(Fat32Error::IsDirectory); + } + if offset >= u64::from(record.size) || destination.is_empty() { + return Ok(0); + } + let available = u64::from(record.size) - offset; + let requested = usize::try_from(available) + .unwrap_or(usize::MAX) + .min(destination.len()); + if requested == 0 { + return Ok(0); + } + self.validate_cluster(record.first_cluster)?; + + let cluster_bytes = u64::from(self.layout.info.sectors_per_cluster) * SECTOR_SIZE as u64; + let starting_cluster_index = offset / cluster_bytes; + let mut current_cluster = self.cluster_at(record.first_cluster, starting_cluster_index)?; + let mut hare = Some(current_cluster); + let mut within_cluster = offset % cluster_bytes; + let mut copied = 0; + while copied < requested { + let sector_in_cluster = u32::try_from(within_cluster / SECTOR_SIZE as u64) + .map_err(|_| Fat32Error::InvalidCluster)?; + let sector_offset = usize::try_from(within_cluster % SECTOR_SIZE as u64) + .map_err(|_| Fat32Error::InvalidCluster)?; + let sector_number = self.cluster_sector(current_cluster, sector_in_cluster)?; + let mut sector = [0_u8; SECTOR_SIZE]; + self.read_sector(sector_number, &mut sector)?; + let chunk = (SECTOR_SIZE - sector_offset).min(requested - copied); + destination[copied..copied + chunk] + .copy_from_slice(§or[sector_offset..sector_offset + chunk]); + copied += chunk; + within_cluster = within_cluster + .checked_add(u64::try_from(chunk).map_err(|_| Fat32Error::InvalidCluster)?) + .ok_or(Fat32Error::InvalidCluster)?; + + if within_cluster == cluster_bytes && copied < requested { + current_cluster = self + .next_cluster(current_cluster)? + .ok_or(Fat32Error::CorruptFat)?; + hare = self.advance_twice(hare)?; + if hare == Some(current_cluster) { + return Err(Fat32Error::ClusterLoop); + } + within_cluster = 0; + } + } + + if offset.checked_add(u64::try_from(copied).map_err(|_| Fat32Error::InvalidCluster)?) + == Some(u64::from(record.size)) + && self.next_cluster(current_cluster)?.is_some() + { + return Err(Fat32Error::CorruptFat); + } + Ok(copied) + } +} + +impl FileSystem for Fat32 { + fn superblock(&self) -> Superblock { + Superblock { + filesystem_name: "fat32", + root_inode: ROOT_INODE, + block_size: u32::from(self.layout.info.sectors_per_cluster) * SECTOR_SIZE as u32, + read_only: true, + } + } + + fn lookup(&self, parent: InodeId, name: &str) -> Result { + let mut found = None; + self.scan_directory(parent, &mut |record| { + if record.name().eq_ignore_ascii_case(name) { + found = Some(record.inode()); + false + } else { + true + } + }) + .map_err(fat_vfs_error)?; + found.ok_or(VfsError::NotFound) + } + + fn read(&self, inode: InodeId, offset: u64, destination: &mut [u8]) -> Result { + let record = self.entry_from_inode(inode).map_err(fat_vfs_error)?; + self.read_file(record, offset, destination) + .map_err(fat_vfs_error) + } + + fn create_or_replace( + &mut self, + _parent: InodeId, + _name: &str, + _data: &[u8], + ) -> Result { + Err(VfsError::ReadOnly) + } + + fn visit_directory( + &self, + inode: InodeId, + visitor: &mut dyn FnMut(&str, Inode), + ) -> Result<(), VfsError> { + self.scan_directory(inode, &mut |record| { + visitor(record.name(), record.inode()); + true + }) + .map_err(fat_vfs_error) + } +} + +const fn fat_vfs_error(error: Fat32Error) -> VfsError { + match error { + Fat32Error::NotFound => VfsError::NotFound, + Fat32Error::NotDirectory => VfsError::NotDirectory, + Fat32Error::IsDirectory => VfsError::IsDirectory, + Fat32Error::ReadOnly => VfsError::ReadOnly, + _ => VfsError::Backend, + } +} + +fn encode_entry_inode(sector: u32, slot: usize) -> Result { + if slot >= DIRECTORY_ENTRIES_PER_SECTOR { + return Err(Fat32Error::InvalidInode); + } + Ok(InodeId( + ENTRY_INODE_FLAG | (u64::from(sector) << 4) | slot as u64, + )) +} + +fn decode_entry_inode(inode: InodeId) -> Result<(u32, usize), Fat32Error> { + if inode.0 & ENTRY_INODE_FLAG == 0 || inode == ROOT_INODE { + return Err(Fat32Error::InvalidInode); + } + let raw_sector = (inode.0 & !ENTRY_INODE_FLAG) >> 4; + let sector = u32::try_from(raw_sector).map_err(|_| Fat32Error::InvalidInode)?; + let slot = usize::try_from(inode.0 & 0x0f).map_err(|_| Fat32Error::InvalidInode)?; + Ok((sector, slot)) +} + +fn decode_short_name( + short: &[u8; 11], + case_flags: u8, +) -> Option<([u8; MAX_COMPONENT_BYTES], usize)> { + let base_end = short[..8] + .iter() + .rposition(|byte| *byte != b' ') + .map_or(0, |index| index + 1); + if base_end == 0 { + return None; + } + let extension_end = short[8..] + .iter() + .rposition(|byte| *byte != b' ') + .map_or(0, |index| index + 1); + let mut output = [0_u8; MAX_COMPONENT_BYTES]; + let mut output_len = 0; + for (index, byte) in short[..base_end].iter().copied().enumerate() { + let byte = if index == 0 && byte == 0x05 { + 0xe5 + } else { + byte + }; + if !byte.is_ascii() || byte.is_ascii_control() || byte == b'/' { + return None; + } + output[output_len] = if case_flags & 0x08 != 0 { + byte.to_ascii_lowercase() + } else { + byte + }; + output_len += 1; + } + if extension_end != 0 { + output[output_len] = b'.'; + output_len += 1; + for byte in short[8..8 + extension_end].iter().copied() { + if !byte.is_ascii() || byte.is_ascii_control() || byte == b'/' { + return None; + } + output[output_len] = if case_flags & 0x10 != 0 { + byte.to_ascii_lowercase() + } else { + byte + }; + output_len += 1; + } + } + Some((output, output_len)) +} + +fn short_name_checksum(short: &[u8; 11]) -> u8 { + short.iter().fold(0_u8, |sum, byte| { + ((sum & 1) << 7).wrapping_add(sum >> 1).wrapping_add(*byte) + }) +} + +fn read_u16(bytes: &[u8], offset: usize) -> u16 { + bytes + .get(offset..offset + 2) + .and_then(|value| value.try_into().ok()) + .map(u16::from_le_bytes) + .unwrap_or(0) +} + +fn read_u32(bytes: &[u8], offset: usize) -> u32 { + bytes + .get(offset..offset + 4) + .and_then(|value| value.try_into().ok()) + .map(u32::from_le_bytes) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::{DIRECTORY_ENTRY_BYTES, Fat32, Fat32Error, short_name_checksum}; + use crate::block::{BlockDevice, BlockError, BlockGeometry, SECTOR_SIZE}; + use crate::fs::RamFs; + use crate::vfs::{FileSystem, HandleRights, Vfs, VfsError}; + use std::vec::Vec; + + const TOTAL_SECTORS: u32 = 70_000; + const RESERVED: u16 = 32; + const FAT_SECTORS: u32 = 544; + const DATA_START: u32 = RESERVED as u32 + FAT_SECTORS; + + struct SparseDevice { + sectors: Vec<(u64, [u8; SECTOR_SIZE])>, + geometry: BlockGeometry, + } + + impl SparseDevice { + fn valid() -> Self { + let mut device = Self { + sectors: Vec::new(), + geometry: BlockGeometry::new(u64::from(TOTAL_SECTORS), true), + }; + let boot = boot_sector(); + device.set(0, boot); + device.set(6, boot); + device.set(1, fs_info_sector()); + + let mut fat = [0_u8; SECTOR_SIZE]; + set_u32(&mut fat, 0, 0x0fff_fff8); + set_u32(&mut fat, 4, 0xffff_ffff); + set_u32(&mut fat, 8, 0x0fff_ffff); + set_u32(&mut fat, 12, 4); + set_u32(&mut fat, 16, 0x0fff_ffff); + set_u32(&mut fat, 20, 0x0fff_ffff); + device.set(u64::from(RESERVED), fat); + + let mut root = [0_u8; SECTOR_SIZE]; + write_short_entry(&mut root, 0, *b"README TXT", 0x20, 3, 700); + write_lfn_entry(&mut root, 1, 0x41, *b"GETTIN~1TXT", "Getting.txt"); + write_short_entry(&mut root, 2, *b"GETTIN~1TXT", 0x20, 5, 15); + root[3 * DIRECTORY_ENTRY_BYTES] = 0; + device.set(u64::from(DATA_START), root); + + let mut first = [b'A'; SECTOR_SIZE]; + first[..17].copy_from_slice(b"Persistent FAT32\n"); + device.set(u64::from(DATA_START + 1), first); + device.set(u64::from(DATA_START + 2), [b'B'; SECTOR_SIZE]); + + let mut long_file = [0_u8; SECTOR_SIZE]; + long_file[..15].copy_from_slice(b"Long name works"); + device.set(u64::from(DATA_START + 3), long_file); + device + } + + fn set(&mut self, sector: u64, data: [u8; SECTOR_SIZE]) { + if let Some((_, existing)) = self + .sectors + .iter_mut() + .find(|(existing_sector, _)| *existing_sector == sector) + { + *existing = data; + } else { + self.sectors.push((sector, data)); + } + } + } + + impl BlockDevice for SparseDevice { + fn geometry(&self) -> BlockGeometry { + self.geometry + } + + fn read_sector( + &mut self, + sector: u64, + destination: &mut [u8; SECTOR_SIZE], + ) -> Result<(), BlockError> { + if sector >= self.geometry.sectors { + return Err(BlockError::OutOfBounds); + } + destination.fill(0); + if let Some((_, data)) = self + .sectors + .iter() + .find(|(existing_sector, _)| *existing_sector == sector) + { + destination.copy_from_slice(data); + } + Ok(()) + } + + fn write_sector( + &mut self, + _sector: u64, + _source: &[u8; SECTOR_SIZE], + ) -> Result<(), BlockError> { + Err(BlockError::ReadOnly) + } + } + + #[test] + fn valid_volume_mounts_and_reads_multicluster_file() { + let fat = Fat32::mount(SparseDevice::valid()).unwrap(); + assert!(fat.mount_info().fs_info_valid); + let inode = fat.lookup(super::ROOT_INODE, "readme.txt").unwrap(); + let mut data = [0_u8; 700]; + assert_eq!(fat.read(inode.id, 0, &mut data).unwrap(), data.len()); + assert!(data.starts_with(b"Persistent FAT32\n")); + assert!(data[512..].iter().all(|byte| *byte == b'B')); + } + + #[test] + fn long_names_and_secondary_vfs_dispatch_are_active() { + let fat = Fat32::mount(SparseDevice::valid()).unwrap(); + let mut vfs = Vfs::new(RamFs::with_defaults()) + .mount("/disk", fat) + .unwrap(); + let handle = vfs + .open("/disk/Getting.txt", HandleRights::ReadOnly) + .unwrap(); + let mut data = [0_u8; 32]; + let read = vfs.read(handle, &mut data).unwrap(); + assert_eq!(&data[..read], b"Long name works"); + vfs.close(handle).unwrap(); + assert_eq!( + vfs.create_or_replace("/disk/new.txt", b"blocked"), + Err(VfsError::ReadOnly) + ); + } + + #[test] + fn invalid_metadata_and_cluster_loops_are_rejected() { + let mut invalid = SparseDevice::valid(); + let mut boot = boot_sector(); + boot[510] = 0; + invalid.set(0, boot); + assert!(matches!( + Fat32::mount(invalid), + Err(Fat32Error::InvalidBootSignature) + )); + + let mut invalid_backup = SparseDevice::valid(); + let mut backup = boot_sector(); + backup[100] = 1; + invalid_backup.set(6, backup); + assert!(matches!( + Fat32::mount(invalid_backup), + Err(Fat32Error::InvalidBackupBoot) + )); + + let mut looped = SparseDevice::valid(); + let mut fat = [0_u8; SECTOR_SIZE]; + set_u32(&mut fat, 0, 0x0fff_fff8); + set_u32(&mut fat, 4, 0xffff_ffff); + set_u32(&mut fat, 8, 0x0fff_ffff); + set_u32(&mut fat, 12, 4); + set_u32(&mut fat, 16, 3); + set_u32(&mut fat, 20, 0x0fff_ffff); + looped.set(u64::from(RESERVED), fat); + let mounted = Fat32::mount(looped).unwrap(); + let inode = mounted.lookup(super::ROOT_INODE, "README.TXT").unwrap(); + let mut data = [0_u8; 700]; + assert_eq!(mounted.read(inode.id, 0, &mut data), Err(VfsError::Backend)); + + let record = mounted.entry_from_inode(inode.id).unwrap(); + assert_eq!( + mounted.cluster_at(record.first_cluster, 2), + Err(Fat32Error::ClusterLoop) + ); + } + + #[test] + fn offset_reads_cross_cluster_boundaries_without_rewalking_the_chain() { + let mounted = Fat32::mount(SparseDevice::valid()).unwrap(); + let inode = mounted.lookup(super::ROOT_INODE, "README.TXT").unwrap(); + let mut data = [0_u8; 200]; + assert_eq!(mounted.read(inode.id, 500, &mut data), Ok(data.len())); + assert!(data[..12].iter().all(|byte| *byte == b'A')); + assert!(data[12..].iter().all(|byte| *byte == b'B')); + } + + fn boot_sector() -> [u8; SECTOR_SIZE] { + let mut boot = [0_u8; SECTOR_SIZE]; + boot[0..3].copy_from_slice(&[0xeb, 0x58, 0x90]); + boot[3..11].copy_from_slice(b"SOMAOS "); + set_u16(&mut boot, 11, SECTOR_SIZE as u16); + boot[13] = 1; + set_u16(&mut boot, 14, RESERVED); + boot[16] = 1; + set_u16(&mut boot, 17, 0); + boot[21] = 0xf8; + set_u16(&mut boot, 22, 0); + set_u32(&mut boot, 32, TOTAL_SECTORS); + set_u32(&mut boot, 36, FAT_SECTORS); + set_u32(&mut boot, 44, 2); + set_u16(&mut boot, 48, 1); + set_u16(&mut boot, 50, 6); + set_u32(&mut boot, 67, 0x534f_4d41); + boot[82..90].copy_from_slice(b"FAT32 "); + boot[510] = 0x55; + boot[511] = 0xaa; + boot + } + + fn fs_info_sector() -> [u8; SECTOR_SIZE] { + let mut sector = [0_u8; SECTOR_SIZE]; + set_u32(&mut sector, 0, 0x4161_5252); + set_u32(&mut sector, 484, 0x6141_7272); + set_u32(&mut sector, 488, 0xffff_ffff); + set_u32(&mut sector, 492, 6); + set_u32(&mut sector, 508, 0xaa55_0000); + sector + } + + fn write_short_entry( + sector: &mut [u8; SECTOR_SIZE], + slot: usize, + name: [u8; 11], + attributes: u8, + cluster: u32, + size: u32, + ) { + let start = slot * DIRECTORY_ENTRY_BYTES; + sector[start..start + 11].copy_from_slice(&name); + sector[start + 11] = attributes; + set_u16(sector, start + 20, (cluster >> 16) as u16); + set_u16(sector, start + 26, cluster as u16); + set_u32(sector, start + 28, size); + } + + fn write_lfn_entry( + sector: &mut [u8; SECTOR_SIZE], + slot: usize, + ordinal: u8, + short: [u8; 11], + name: &str, + ) { + let start = slot * DIRECTORY_ENTRY_BYTES; + let entry = &mut sector[start..start + DIRECTORY_ENTRY_BYTES]; + entry.fill(0xff); + entry[0] = ordinal; + entry[11] = super::ATTRIBUTE_LONG_NAME; + entry[12] = 0; + entry[13] = short_name_checksum(&short); + entry[26] = 0; + entry[27] = 0; + const OFFSETS: [usize; 13] = [1, 3, 5, 7, 9, 14, 16, 18, 20, 22, 24, 28, 30]; + let mut units = name.encode_utf16(); + let mut ended = false; + for offset in OFFSETS { + let value = if ended { + 0xffff + } else if let Some(unit) = units.next() { + unit + } else { + ended = true; + 0 + }; + entry[offset..offset + 2].copy_from_slice(&value.to_le_bytes()); + } + } + + fn set_u16(bytes: &mut [u8], offset: usize, value: u16) { + bytes[offset..offset + 2].copy_from_slice(&value.to_le_bytes()); + } + + fn set_u32(bytes: &mut [u8], offset: usize, value: u32) { + bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } +} diff --git a/kernel/src/generated/capabilities.rs b/kernel/src/generated/capabilities.rs index b11fa0b..e4ee202 100644 --- a/kernel/src/generated/capabilities.rs +++ b/kernel/src/generated/capabilities.rs @@ -1,7 +1,7 @@ // @generated by scripts/generate-capabilities.py; do not edit. use crate::capabilities::{Capability, CapabilityStatus}; -pub const REGISTRY_VERSION: u32 = 6; +pub const REGISTRY_VERSION: u32 = 7; pub const CAPABILITIES: &[Capability] = &[ Capability { id: "SYS-TC-001", @@ -209,7 +209,7 @@ pub const CAPABILITIES: &[Capability] = &[ Capability { id: "FS-FAT32-001", name: "Read-only FAT32 persistent filesystem", - status: CapabilityStatus::Planned, + status: CapabilityStatus::HardwareActive, milestone: "m6d", boot_label: "FAT32 filesystem", }, diff --git a/kernel/src/lib.rs b/kernel/src/lib.rs index a5081b6..45f53ba 100644 --- a/kernel/src/lib.rs +++ b/kernel/src/lib.rs @@ -6,6 +6,7 @@ pub mod boot_info; pub mod cache; pub mod capabilities; pub mod elf; +pub mod fat32; pub mod fs; pub mod generated; pub mod heap; @@ -1115,6 +1116,139 @@ pub fn kernel_main_m6c(console: &mut dyn Console, report: M6cReport) { } } +/// Runtime evidence for the M6D read-only FAT32 gate. +#[allow(clippy::struct_excessive_bools)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct M6dReport { + pub fat32_mount_active: bool, + pub bytes_per_sector: u16, + pub sectors_per_cluster: u8, + pub total_sectors: u32, + pub cluster_count: u32, + pub fs_info_valid: bool, + pub backup_boot_valid: bool, + pub vfs_mount_dispatch_active: bool, + pub mounted_filesystems: usize, + pub root_directory_read_passed: bool, + pub persistent_file_read_passed: bool, + pub long_filename_read_passed: bool, + pub nested_directory_read_passed: bool, + pub multicluster_read_passed: bool, + pub read_only_enforced: bool, + pub cache_backed_reads: u64, + pub dirty_cache_entries: usize, + pub m6c_regression_passed: bool, +} + +impl M6dReport { + #[must_use] + pub const fn gate_passed(self) -> bool { + self.fat32_mount_active + && self.bytes_per_sector == 512 + && self.sectors_per_cluster > 0 + && self.total_sectors > 0 + && self.cluster_count >= 65_525 + && self.fs_info_valid + && self.backup_boot_valid + && self.vfs_mount_dispatch_active + && self.mounted_filesystems == 2 + && self.root_directory_read_passed + && self.persistent_file_read_passed + && self.long_filename_read_passed + && self.nested_directory_read_passed + && self.multicluster_read_passed + && self.read_only_enforced + && self.cache_backed_reads > 0 + && self.dirty_cache_entries == 0 + && self.m6c_regression_passed + } +} + +/// Prints the M6D FAT32 and persistent-read acceptance report. +pub fn kernel_main_m6d(console: &mut dyn Console, report: M6dReport) { + console.write_line(""); + console.write_line("Soma OS M6D Read-Only FAT32"); + write_state(console, "Validated FAT32 mount", report.fat32_mount_active); + console.write_str("FAT32 bytes per sector: "); + console.write_u64(u64::from(report.bytes_per_sector)); + console.write_line(""); + console.write_str("FAT32 sectors per cluster: "); + console.write_u64(u64::from(report.sectors_per_cluster)); + console.write_line(""); + console.write_str("FAT32 total sectors: "); + console.write_u64(u64::from(report.total_sectors)); + console.write_line(""); + console.write_str("FAT32 data clusters: "); + console.write_u64(u64::from(report.cluster_count)); + console.write_line(""); + console.write_line(if report.fs_info_valid { + "FAT32 FSInfo validation: passed" + } else { + "FAT32 FSInfo validation: failed" + }); + console.write_line(if report.backup_boot_valid { + "FAT32 backup boot validation: passed" + } else { + "FAT32 backup boot validation: failed" + }); + write_state( + console, + "Secondary VFS mount dispatch", + report.vfs_mount_dispatch_active, + ); + console.write_str("Mounted filesystems under M6D: "); + console.write_usize(report.mounted_filesystems); + console.write_line(""); + console.write_line(if report.root_directory_read_passed { + "Persistent root directory test: passed" + } else { + "Persistent root directory test: failed" + }); + console.write_line(if report.persistent_file_read_passed { + "Persistent file read test: passed" + } else { + "Persistent file read test: failed" + }); + console.write_line(if report.long_filename_read_passed { + "FAT32 long-filename test: passed" + } else { + "FAT32 long-filename test: failed" + }); + console.write_line(if report.nested_directory_read_passed { + "Nested directory traversal test: passed" + } else { + "Nested directory traversal test: failed" + }); + console.write_line(if report.multicluster_read_passed { + "Multi-cluster file read test: passed" + } else { + "Multi-cluster file read test: failed" + }); + console.write_line(if report.read_only_enforced { + "FAT32 persistent writes: blocked" + } else { + "FAT32 persistent writes: unsafe" + }); + console.write_str("Cache-backed FAT32 device reads: "); + console.write_u64(report.cache_backed_reads); + console.write_line(""); + console.write_str("Dirty cache entries after FAT32 reads: "); + console.write_usize(report.dirty_cache_entries); + console.write_line(""); + console.write_line(if report.m6c_regression_passed { + "M6C regression under M6D: passed" + } else { + "M6C regression under M6D: failed" + }); + if report.gate_passed() { + console.write_line("M6D read-only FAT32 gate: passed"); + console + .write_line("Next gate: process-facing persistent file syscalls and executable reads"); + } else { + console.write_line("M6D read-only FAT32 gate: failed"); + } +} + fn write_hex_u64(console: &mut dyn Console, value: u64) { for shift in (0..16).rev() { let nibble = u8::try_from((value >> (shift * 4)) & 0x0f).unwrap_or(0); @@ -1139,10 +1273,10 @@ fn write_state(console: &mut dyn Console, label: &str, active: bool) { mod tests { use super::{ BootInfo, Console, FoundationHardeningPhase2Report, FoundationHardeningPhase3Report, - FoundationHardeningReport, M4Report, M5Report, M6aReport, M6bReport, M6cReport, + FoundationHardeningReport, M4Report, M5Report, M6aReport, M6bReport, M6cReport, M6dReport, MemoryMapInfo, kernel_main, kernel_main_foundation_hardening, kernel_main_foundation_hardening_phase2, kernel_main_foundation_hardening_phase3, - kernel_main_m5, kernel_main_m6a, kernel_main_m6b, kernel_main_m6c, + kernel_main_m5, kernel_main_m6a, kernel_main_m6b, kernel_main_m6c, kernel_main_m6d, }; use std::string::String; @@ -1483,6 +1617,51 @@ mod tests { ); } + #[test] + fn m6d_banner_requires_validated_persistent_reads_and_zero_dirty_state() { + let mut console = RecordingConsole::default(); + let report = M6dReport { + fat32_mount_active: true, + bytes_per_sector: 512, + sectors_per_cluster: 1, + total_sectors: 131_072, + cluster_count: 129_022, + fs_info_valid: true, + backup_boot_valid: true, + vfs_mount_dispatch_active: true, + mounted_filesystems: 2, + root_directory_read_passed: true, + persistent_file_read_passed: true, + long_filename_read_passed: true, + nested_directory_read_passed: true, + multicluster_read_passed: true, + read_only_enforced: true, + cache_backed_reads: 12, + dirty_cache_entries: 0, + m6c_regression_passed: true, + }; + kernel_main_m6d(&mut console, report); + assert!(report.gate_passed()); + assert!( + !M6dReport { + dirty_cache_entries: 1, + ..report + } + .gate_passed() + ); + assert!(console.output.contains("Soma OS M6D Read-Only FAT32\r\n")); + assert!( + console + .output + .contains("FAT32 long-filename test: passed\r\n") + ); + assert!( + console + .output + .contains("M6D read-only FAT32 gate: passed\r\n") + ); + } + #[test] fn m5_banner_confirms_protected_userspace_gate() { let mut console = RecordingConsole::default(); diff --git a/kernel/src/shell.rs b/kernel/src/shell.rs index 90df788..9b8cf93 100644 --- a/kernel/src/shell.rs +++ b/kernel/src/shell.rs @@ -6,7 +6,7 @@ use core::str; use crate::Console; use crate::fs::{MAX_FILE_BYTES, RamFs}; -use crate::vfs::{HandleRights, MAX_PATH_BYTES, Vfs, VfsError}; +use crate::vfs::{FileSystem, HandleRights, MAX_PATH_BYTES, Vfs, VfsError}; const COMMAND_BUFFER_BYTES: usize = 128; @@ -37,6 +37,13 @@ pub struct ShellEnvironment { pub vfs_mounts: usize, pub vfs_handle_capacity: usize, pub vfs_path_normalization_passed: bool, + pub fat32_mounted: bool, + pub fat32_total_sectors: u32, + pub fat32_cluster_count: u32, + pub fat32_sectors_per_cluster: u8, + pub fat32_persistent_read_passed: bool, + pub fat32_long_name_passed: bool, + pub fat32_multicluster_read_passed: bool, } /// Interactive line editor and command dispatcher. @@ -66,11 +73,11 @@ impl Shell { } /// Processes one decoded ASCII byte. - pub fn feed_byte( + pub fn feed_byte( &mut self, byte: u8, console: &mut dyn Console, - vfs: &mut Vfs, + vfs: &mut Vfs, environment: &ShellEnvironment, ) { match byte { @@ -126,10 +133,10 @@ impl Default for Shell { } #[allow(clippy::too_many_lines)] -fn execute_line( +fn execute_line( line: &str, console: &mut dyn Console, - vfs: &mut Vfs, + vfs: &mut Vfs, environment: &ShellEnvironment, ) { let mut parts = line.split_whitespace(); @@ -140,11 +147,11 @@ fn execute_line( match command { "help" => { console.write_line(concat!( - "Commands: help version uptime memory irq tasks pci block cache mounts ls cat ", - "write echo clear userspace", + "Commands: help version uptime memory irq tasks pci block cache fat32 mounts ls ", + "cat write echo clear userspace", )); } - "version" => console.write_line("Soma OS 0.0.11-prealpha (M6C)"), + "version" => console.write_line("Soma OS 0.0.12-prealpha (M6D)"), "uptime" => { console.write_str("Timer ticks: "); console.write_u64(environment.timer_ticks); @@ -241,16 +248,55 @@ fn execute_line( }); }); } + "fat32" => { + if !environment.fat32_mounted { + console.write_line("FAT32: not mounted"); + return; + } + console.write_str("FAT32: "); + console.write_u64(u64::from(environment.fat32_total_sectors)); + console.write_str(" sectors, "); + console.write_u64(u64::from(environment.fat32_cluster_count)); + console.write_str(" clusters, "); + console.write_u64(u64::from(environment.fat32_sectors_per_cluster)); + console.write_str(" sector/cluster, persistent read "); + console.write_str(if environment.fat32_persistent_read_passed { + "passed" + } else { + "failed" + }); + console.write_str(", long names "); + console.write_str(if environment.fat32_long_name_passed { + "passed" + } else { + "failed" + }); + console.write_str(", multi-cluster "); + console.write_line(if environment.fat32_multicluster_read_passed { + "passed" + } else { + "failed" + }); + } "ls" => { + let input_path = parts.next().unwrap_or("/"); + let mut path_storage = [0_u8; MAX_PATH_BYTES]; + let Some(path) = shell_path(input_path, &mut path_storage) else { + console.write_line("invalid path"); + return; + }; let mut found = false; - let result = vfs.visit_directory("/", &mut |name, _inode| { + let result = vfs.visit_directory(path, &mut |name, _inode| { found = true; console.write_line(name); }); - if result.is_err() { - console.write_line("filesystem error"); - } else if !found { - console.write_line(""); + match result { + Ok(()) if !found => console.write_line(""), + Ok(()) => {} + Err(VfsError::NotFound) => console.write_line("directory not found"), + Err(VfsError::NotDirectory) => console.write_line("not a directory"), + Err(VfsError::Path(_)) => console.write_line("invalid path"), + Err(_) => console.write_line("filesystem error"), } } "cat" => { @@ -266,11 +312,29 @@ fn execute_line( match vfs.open(path, HandleRights::ReadOnly) { Ok(handle) => { let mut data = [0_u8; MAX_FILE_BYTES]; - let read_result = vfs.read(handle, &mut data); + let mut last_byte = None; + let mut read_failed = false; + loop { + match vfs.read(handle, &mut data) { + Ok(0) => break, + Ok(read) => { + write_bytes_fragment(console, &data[..read]); + last_byte = data[..read].last().copied(); + } + Err(_) => { + read_failed = true; + break; + } + } + } let close_result = vfs.close(handle); - match (read_result, close_result) { - (Ok(read), Ok(())) => write_bytes(console, &data[..read]), - _ => console.write_line("filesystem error"), + if read_failed || close_result.is_err() { + if last_byte.is_some_and(|byte| byte != b'\n') { + console.write_line(""); + } + console.write_line("filesystem error"); + } else if last_byte.is_some_and(|byte| byte != b'\n') { + console.write_line(""); } } Err(VfsError::NotFound) => console.write_line("file not found"), @@ -332,7 +396,7 @@ fn shell_path<'a>(input: &'a str, storage: &'a mut [u8; MAX_PATH_BYTES]) -> Opti str::from_utf8(&storage[..required]).ok() } -fn write_bytes(console: &mut dyn Console, bytes: &[u8]) { +fn write_bytes_fragment(console: &mut dyn Console, bytes: &[u8]) { for byte in bytes { if *byte == b'\n' { console.write_line(""); @@ -340,9 +404,6 @@ fn write_bytes(console: &mut dyn Console, bytes: &[u8]) { console.write_byte(*byte); } } - if bytes.last().is_some_and(|byte| *byte != b'\n') { - console.write_line(""); - } } #[cfg(test)] @@ -350,7 +411,7 @@ mod tests { use super::{Shell, ShellEnvironment}; use crate::Console; use crate::fs::RamFs; - use crate::vfs::Vfs; + use crate::vfs::{FileSystem, Inode, InodeId, NodeKind, Superblock, Vfs, VfsError}; use std::string::String; #[derive(Default)] @@ -410,4 +471,88 @@ mod tests { assert!(console.output.contains("/ ramfs read-write\r\n")); assert_eq!(shell.commands_executed(), 6); } + + struct LargeReadOnlyFile; + + impl FileSystem for LargeReadOnlyFile { + fn superblock(&self) -> Superblock { + Superblock { + filesystem_name: "testfs", + root_inode: InodeId(1), + block_size: 512, + read_only: true, + } + } + + fn lookup(&self, parent: InodeId, name: &str) -> Result { + if parent == InodeId(1) && name == "large.txt" { + return Ok(Inode { + id: InodeId(2), + kind: NodeKind::File, + size: 720, + }); + } + Err(VfsError::NotFound) + } + + fn read( + &self, + inode: InodeId, + offset: u64, + destination: &mut [u8], + ) -> Result { + if inode != InodeId(2) { + return Err(VfsError::NotFound); + } + let size = 720_usize; + let start = usize::try_from(offset).map_err(|_| VfsError::InvalidOffset)?; + if start >= size { + return Ok(0); + } + let read = destination.len().min(size - start); + destination[..read].fill(b'A'); + const MARKER_OFFSET: usize = 700; + const MARKER: &[u8] = b"tail-marker"; + for (index, byte) in destination[..read].iter_mut().enumerate() { + let absolute = start + index; + if (MARKER_OFFSET..MARKER_OFFSET + MARKER.len()).contains(&absolute) { + *byte = MARKER[absolute - MARKER_OFFSET]; + } + } + Ok(read) + } + + fn create_or_replace( + &mut self, + _parent: InodeId, + _name: &str, + _data: &[u8], + ) -> Result { + Err(VfsError::ReadOnly) + } + + fn visit_directory( + &self, + _inode: InodeId, + _visitor: &mut dyn FnMut(&str, Inode), + ) -> Result<(), VfsError> { + Ok(()) + } + } + + #[test] + fn cat_streams_files_larger_than_one_shell_buffer() { + let mut shell = Shell::new(); + let mut console = RecordingConsole::default(); + let mut vfs = Vfs::new(RamFs::with_defaults()) + .mount("/disk", LargeReadOnlyFile) + .unwrap(); + + for byte in b"cat /disk/large.txt\n" { + shell.feed_byte(*byte, &mut console, &mut vfs, &ShellEnvironment::default()); + } + + assert!(console.output.contains("tail-marker")); + assert_eq!(shell.commands_executed(), 1); + } } diff --git a/kernel/src/vfs.rs b/kernel/src/vfs.rs index d416355..48f4150 100644 --- a/kernel/src/vfs.rs +++ b/kernel/src/vfs.rs @@ -1,9 +1,9 @@ #![allow(clippy::module_name_repetitions)] -//! Allocation-free virtual-filesystem contracts for M6C. +//! Allocation-free virtual-filesystem contracts. //! -//! The first backend is RAMFS. The same inode, mount, path, directory, and -//! handle contracts form the boundary for the read-only FAT32 backend in M6D. +//! RAMFS is the writable root. M6D adds one read-only persistent backend while +//! retaining fixed mount and handle capacities. use core::str; @@ -236,6 +236,51 @@ pub trait FileSystem { ) -> Result<(), VfsError>; } +/// Empty backend used before a secondary filesystem is mounted. +#[derive(Clone, Copy, Debug, Default)] +pub struct NoFilesystem; + +impl FileSystem for NoFilesystem { + fn superblock(&self) -> Superblock { + Superblock { + filesystem_name: "none", + root_inode: InodeId(0), + block_size: 0, + read_only: true, + } + } + + fn lookup(&self, _parent: InodeId, _name: &str) -> Result { + Err(VfsError::UnsupportedMount) + } + + fn read( + &self, + _inode: InodeId, + _offset: u64, + _destination: &mut [u8], + ) -> Result { + Err(VfsError::UnsupportedMount) + } + + fn create_or_replace( + &mut self, + _parent: InodeId, + _name: &str, + _data: &[u8], + ) -> Result { + Err(VfsError::UnsupportedMount) + } + + fn visit_directory( + &self, + _inode: InodeId, + _visitor: &mut dyn FnMut(&str, Inode), + ) -> Result<(), VfsError> { + Err(VfsError::UnsupportedMount) + } +} + /// Stable identifier for one mount-table entry. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct MountId(pub u16); @@ -507,51 +552,84 @@ impl Default for UserHandleTable { } } -/// Root VFS instance for one concrete backend. -pub struct Vfs { - root: F, +/// VFS instance with a writable root and at most one secondary backend. +/// +/// The fixed two-backend shape is intentional for the early storage epoch. It +/// proves real mount dispatch without allocation or trait-object lifetimes. +pub struct Vfs { + root: R, + secondary: M, + secondary_mount: Option, mounts: MountTable, handles: UserHandleTable, } -impl Vfs { +impl Vfs { #[must_use] - pub fn new(root: F) -> Self { + pub fn new(root: R) -> Self { let mounts = MountTable::with_root(root.superblock()); Self { root, + secondary: NoFilesystem, + secondary_mount: None, mounts, handles: UserHandleTable::new(), } } + /// Installs one secondary backend and returns the widened VFS type. + /// + /// # Errors + /// + /// Returns a path, duplicate, capacity, or busy-state error. + pub fn mount( + mut self, + raw_path: &str, + secondary: M, + ) -> Result, VfsError> { + if !self.handles.is_empty() { + return Err(VfsError::Backend); + } + let secondary_mount = self.mounts.mount(raw_path, secondary.superblock())?; + Ok(Vfs { + root: self.root, + secondary, + secondary_mount: Some(secondary_mount), + mounts: self.mounts, + handles: self.handles, + }) + } +} + +impl Vfs { /// Resolves one canonical path to inode metadata. /// /// # Errors /// /// Returns path, mount, lookup, or type errors from the VFS/backend. pub fn resolve(&self, raw_path: &str) -> Result { + self.resolve_with_mount(raw_path).map(|(_, inode)| inode) + } + + fn resolve_with_mount(&self, raw_path: &str) -> Result<(Mount, Inode), VfsError> { let path = NormalizedPath::parse(raw_path)?; let mount = self .mounts .resolve(&path) .ok_or(VfsError::UnsupportedMount)?; - if mount.id != MountId(0) { - return Err(VfsError::UnsupportedMount); - } let mut inode = Inode { id: mount.superblock.root_inode, kind: NodeKind::Directory, size: 0, }; - for component in path.components() { + for component in path.components().skip(mount.path.component_count()) { if inode.kind != NodeKind::Directory { return Err(VfsError::NotDirectory); } - inode = self.root.lookup(inode.id, component)?; + inode = self.lookup(mount.id, inode.id, component)?; } - Ok(inode) + Ok((mount, inode)) } /// Opens one non-directory object. @@ -560,17 +638,16 @@ impl Vfs { /// /// Returns resolution, permission, type, or handle-capacity errors. pub fn open(&mut self, raw_path: &str, rights: HandleRights) -> Result { - let inode = self.resolve(raw_path)?; + let (mount, inode) = self.resolve_with_mount(raw_path)?; if inode.kind == NodeKind::Directory { return Err(VfsError::IsDirectory); } - let superblock = self.root.superblock(); - if rights == HandleRights::ReadWrite && superblock.read_only { + if rights == HandleRights::ReadWrite && mount.superblock.read_only { return Err(VfsError::ReadOnly); } self.handles.open(FileHandle { inode: inode.id, - mount: MountId(0), + mount: mount.id, offset: 0, rights, }) @@ -583,10 +660,7 @@ impl Vfs { /// Returns stale-handle, offset, or backend errors. pub fn read(&mut self, id: FileHandleId, destination: &mut [u8]) -> Result { let handle = *self.handles.get(id)?; - if handle.mount != MountId(0) { - return Err(VfsError::UnsupportedMount); - } - let read = self.root.read(handle.inode, handle.offset, destination)?; + let read = self.read_backend(handle.mount, handle.inode, handle.offset, destination)?; let next_offset = handle .offset .checked_add(u64::try_from(read).map_err(|_| VfsError::InvalidOffset)?) @@ -611,15 +685,22 @@ impl Vfs { /// Returns path, mount, permission, or backend errors. pub fn create_or_replace(&mut self, raw_path: &str, data: &[u8]) -> Result { let path = NormalizedPath::parse(raw_path)?; + if self + .mounts + .resolve(&path) + .is_some_and(|mount| mount.id != MountId(0) && mount.path == path) + { + return Err(VfsError::IsDirectory); + } let name = path.file_name().ok_or(VfsError::IsDirectory)?; - let parent = self.resolve(path.parent().as_str())?; + let (mount, parent) = self.resolve_with_mount(path.parent().as_str())?; if parent.kind != NodeKind::Directory { return Err(VfsError::NotDirectory); } - if self.root.superblock().read_only { + if mount.superblock.read_only { return Err(VfsError::ReadOnly); } - self.root.create_or_replace(parent.id, name, data) + self.create_backend(mount.id, parent.id, name, data) } /// Visits one directory through its backend. @@ -632,11 +713,11 @@ impl Vfs { raw_path: &str, visitor: &mut dyn FnMut(&str, Inode), ) -> Result<(), VfsError> { - let inode = self.resolve(raw_path)?; + let (mount, inode) = self.resolve_with_mount(raw_path)?; if inode.kind != NodeKind::Directory { return Err(VfsError::NotDirectory); } - self.root.visit_directory(inode.id, visitor) + self.visit_backend(mount.id, inode.id, visitor) } #[must_use] @@ -650,9 +731,72 @@ impl Vfs { } #[must_use] - pub const fn backend(&self) -> &F { + pub const fn backend(&self) -> &R { &self.root } + + /// Returns the secondary backend after it has been mounted. + #[must_use] + pub fn secondary_backend(&self) -> Option<&M> { + self.secondary_mount.map(|_| &self.secondary) + } + + fn lookup(&self, mount: MountId, parent: InodeId, name: &str) -> Result { + if mount == MountId(0) { + return self.root.lookup(parent, name); + } + if self.secondary_mount == Some(mount) { + return self.secondary.lookup(parent, name); + } + Err(VfsError::UnsupportedMount) + } + + fn read_backend( + &self, + mount: MountId, + inode: InodeId, + offset: u64, + destination: &mut [u8], + ) -> Result { + if mount == MountId(0) { + return self.root.read(inode, offset, destination); + } + if self.secondary_mount == Some(mount) { + return self.secondary.read(inode, offset, destination); + } + Err(VfsError::UnsupportedMount) + } + + fn create_backend( + &mut self, + mount: MountId, + parent: InodeId, + name: &str, + data: &[u8], + ) -> Result { + if mount == MountId(0) { + return self.root.create_or_replace(parent, name, data); + } + if self.secondary_mount == Some(mount) { + return self.secondary.create_or_replace(parent, name, data); + } + Err(VfsError::UnsupportedMount) + } + + fn visit_backend( + &self, + mount: MountId, + inode: InodeId, + visitor: &mut dyn FnMut(&str, Inode), + ) -> Result<(), VfsError> { + if mount == MountId(0) { + return self.root.visit_directory(inode, visitor); + } + if self.secondary_mount == Some(mount) { + return self.secondary.visit_directory(inode, visitor); + } + Err(VfsError::UnsupportedMount) + } } #[cfg(test)] diff --git a/scripts/create-fat32-image.py b/scripts/create-fat32-image.py new file mode 100644 index 0000000..5165332 --- /dev/null +++ b/scripts/create-fat32-image.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Create the deterministic read-only FAT32 image used by M6D smoke tests.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +SECTOR_SIZE = 512 +TOTAL_SECTORS = 131_072 +RESERVED_SECTORS = 32 +FAT_COUNT = 2 +SECTORS_PER_FAT = 1_009 +SECTORS_PER_CLUSTER = 1 +FIRST_DATA_SECTOR = RESERVED_SECTORS + FAT_COUNT * SECTORS_PER_FAT +CLUSTER_COUNT = (TOTAL_SECTORS - FIRST_DATA_SECTOR) // SECTORS_PER_CLUSTER +ROOT_CLUSTER = 2 +VOLUME_ID = 0x534F_4D41 +KNOWN_M6B_PATTERN = b"SANJUOS-M6B-READ-PATTERN" + + +def set_u16(buffer: bytearray, offset: int, value: int) -> None: + buffer[offset : offset + 2] = value.to_bytes(2, "little") + + +def set_u32(buffer: bytearray, offset: int, value: int) -> None: + buffer[offset : offset + 4] = value.to_bytes(4, "little") + + +def boot_sector() -> bytes: + boot = bytearray(SECTOR_SIZE) + boot[0:3] = b"\xeb\x58\x90" + boot[3:11] = b"SOMAOS " + set_u16(boot, 11, SECTOR_SIZE) + boot[13] = SECTORS_PER_CLUSTER + set_u16(boot, 14, RESERVED_SECTORS) + boot[16] = FAT_COUNT + set_u16(boot, 17, 0) + set_u16(boot, 19, 0) + boot[21] = 0xF8 + set_u16(boot, 22, 0) + set_u16(boot, 24, 63) + set_u16(boot, 26, 255) + set_u32(boot, 28, 0) + set_u32(boot, 32, TOTAL_SECTORS) + set_u32(boot, 36, SECTORS_PER_FAT) + set_u16(boot, 40, 0) + set_u16(boot, 42, 0) + set_u32(boot, 44, ROOT_CLUSTER) + set_u16(boot, 48, 1) + set_u16(boot, 50, 6) + boot[64] = 0x80 + boot[66] = 0x29 + set_u32(boot, 67, VOLUME_ID) + boot[71:82] = b"SOMA OS " + boot[82:90] = b"FAT32 " + boot[510:512] = b"\x55\xaa" + return bytes(boot) + + +def fs_info_sector() -> bytes: + info = bytearray(SECTOR_SIZE) + set_u32(info, 0, 0x4161_5252) + set_u32(info, 484, 0x6141_7272) + set_u32(info, 488, CLUSTER_COUNT - 6) + set_u32(info, 492, 8) + set_u32(info, 508, 0xAA55_0000) + return bytes(info) + + +def fat_sector() -> bytes: + fat = bytearray(SECTOR_SIZE) + entries = { + 0: 0x0FFF_FFF8, + 1: 0xFFFF_FFFF, + 2: 0x0FFF_FFFF, + 3: 0x0FFF_FFFF, + 4: 0x0FFF_FFFF, + 5: 6, + 6: 0x0FFF_FFFF, + 7: 0x0FFF_FFFF, + } + for cluster, value in entries.items(): + set_u32(fat, cluster * 4, value) + return bytes(fat) + + +def short_checksum(short_name: bytes) -> int: + checksum = 0 + for byte in short_name: + checksum = (((checksum & 1) << 7) + (checksum >> 1) + byte) & 0xFF + return checksum + + +def short_entry( + name: bytes, attributes: int, cluster: int, size: int = 0 +) -> bytes: + if len(name) != 11: + raise ValueError("FAT short names must contain exactly 11 bytes") + entry = bytearray(32) + entry[:11] = name + entry[11] = attributes + set_u16(entry, 20, cluster >> 16) + set_u16(entry, 26, cluster & 0xFFFF) + set_u32(entry, 28, size) + return bytes(entry) + + +def lfn_entries(name: str, short_name: bytes) -> list[bytes]: + units = list(name.encode("utf-16le")) + utf16 = [ + int.from_bytes(bytes(units[index : index + 2]), "little") + for index in range(0, len(units), 2) + ] + utf16.append(0) + while len(utf16) % 13: + utf16.append(0xFFFF) + count = len(utf16) // 13 + if count == 0 or count > 5: + raise ValueError("M6D fixture long name exceeds the bounded test contract") + checksum = short_checksum(short_name) + offsets = (1, 3, 5, 7, 9, 14, 16, 18, 20, 22, 24, 28, 30) + entries: list[bytes] = [] + for ordinal in range(count, 0, -1): + entry = bytearray(b"\xff" * 32) + entry[0] = ordinal | (0x40 if ordinal == count else 0) + entry[11] = 0x0F + entry[12] = 0 + entry[13] = checksum + set_u16(entry, 26, 0) + chunk = utf16[(ordinal - 1) * 13 : ordinal * 13] + for offset, unit in zip(offsets, chunk, strict=True): + set_u16(entry, offset, unit) + entries.append(bytes(entry)) + return entries + + +def directory_sector(entries: list[bytes]) -> bytes: + if len(entries) >= SECTOR_SIZE // 32: + raise ValueError("fixture directory exceeds one cluster") + sector = bytearray(SECTOR_SIZE) + for index, entry in enumerate(entries): + sector[index * 32 : (index + 1) * 32] = entry + sector[len(entries) * 32] = 0 + return bytes(sector) + + +def cluster_sector(cluster: int) -> int: + if cluster < 2: + raise ValueError("data clusters start at two") + return FIRST_DATA_SECTOR + (cluster - 2) * SECTORS_PER_CLUSTER + + +def write_sector(image, sector: int, data: bytes) -> None: + if len(data) != SECTOR_SIZE: + raise ValueError("sector writes must be exactly 512 bytes") + image.seek(sector * SECTOR_SIZE) + image.write(data) + + +def padded_sector(data: bytes, fill: bytes = b"\0") -> bytes: + if len(fill) != 1 or len(data) > SECTOR_SIZE: + raise ValueError("invalid sector payload") + return data + fill * (SECTOR_SIZE - len(data)) + + +def create_image(path: Path) -> None: + readme = b"Welcome to Soma OS persistent FAT32 storage.\n" + long_name = "Getting-Started.txt" + long_alias = b"GETTIN~1TXT" + long_text = b"Soma OS long filename support is active through the VFS.\n" + guide_prefix = ( + b"Soma OS M6D multi-cluster guide.\n" + b"This file proves bounded FAT-chain traversal through cache and VFS.\n" + ) + guide = (guide_prefix + b"0123456789abcdef" * 64)[:900] + + root_entries = [ + short_entry(b"SOMA OS ", 0x08, 0), + short_entry(b"README TXT", 0x20, 4, len(readme)), + short_entry(b"DOCS ", 0x10, 3), + *lfn_entries(long_name, long_alias), + short_entry(long_alias, 0x20, 7, len(long_text)), + ] + docs_entries = [ + short_entry(b". ", 0x10, 3), + short_entry(b".. ", 0x10, 0), + short_entry(b"GUIDE TXT", 0x20, 5, len(guide)), + ] + + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w+b") as image: + image.truncate(TOTAL_SECTORS * SECTOR_SIZE) + boot = boot_sector() + info = fs_info_sector() + write_sector(image, 0, boot) + write_sector(image, 1, info) + write_sector(image, 6, boot) + write_sector(image, 7, info) + write_sector(image, 8, padded_sector(KNOWN_M6B_PATTERN)) + first_fat = fat_sector() + write_sector(image, RESERVED_SECTORS, first_fat) + write_sector(image, RESERVED_SECTORS + SECTORS_PER_FAT, first_fat) + write_sector(image, cluster_sector(2), directory_sector(root_entries)) + write_sector(image, cluster_sector(3), directory_sector(docs_entries)) + write_sector(image, cluster_sector(4), padded_sector(readme)) + write_sector(image, cluster_sector(5), padded_sector(guide[:SECTOR_SIZE])) + write_sector(image, cluster_sector(6), padded_sector(guide[SECTOR_SIZE:])) + write_sector(image, cluster_sector(7), padded_sector(long_text)) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("output", type=Path) + args = parser.parse_args() + create_image(args.output) + print( + f"Created deterministic FAT32 image: {args.output} " + f"({TOTAL_SECTORS} sectors, {CLUSTER_COUNT} clusters)" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run-qemu.sh b/scripts/run-qemu.sh index a7d0870..aaff9a5 100755 --- a/scripts/run-qemu.sh +++ b/scripts/run-qemu.sh @@ -22,11 +22,8 @@ OVMF_VARS_COPY="$(mktemp /tmp/sanjuos-ovmf-vars.XXXXXX.fd)" trap 'rm -f "$OVMF_VARS_COPY"' EXIT cp "$OVMF_VARS_TEMPLATE" "$OVMF_VARS_COPY" mkdir -p build -if [[ ! -f build/sanju-storage.img ]]; then - truncate -s 64M build/sanju-storage.img -fi -printf '%s' 'SANJUOS-M6B-READ-PATTERN' | - dd of=build/sanju-storage.img bs=1 seek=$((8 * 512)) conv=notrunc status=none +STORAGE_IMAGE=build/soma-storage-fat32.img +python3 ./scripts/create-fat32-image.py "$STORAGE_IMAGE" qemu-system-x86_64 \ -machine q35,accel=tcg \ @@ -35,7 +32,7 @@ qemu-system-x86_64 \ -drive if=pflash,format=raw,readonly=on,file="$OVMF_CODE" \ -drive if=pflash,format=raw,file="$OVMF_VARS_COPY" \ -drive format=raw,file=fat:rw:build/esp \ - -drive if=none,id=sanju-storage,format=raw,file=build/sanju-storage.img \ + -drive if=none,id=sanju-storage,format=raw,file="$STORAGE_IMAGE" \ -device virtio-blk-pci,drive=sanju-storage,serial=SANJU-M6B \ -serial stdio \ -no-reboot \ diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 137573e..450d718 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -22,9 +22,7 @@ OVMF_VARS_COPY="$(mktemp /tmp/sanjuos-ovmf-vars.XXXXXX.fd)" STORAGE_IMAGE="$(mktemp /tmp/sanjuos-storage.XXXXXX.img)" trap 'rm -f "$OVMF_VARS_COPY" "$STORAGE_IMAGE"' EXIT cp "$OVMF_VARS_TEMPLATE" "$OVMF_VARS_COPY" -truncate -s 8M "$STORAGE_IMAGE" -printf '%s' 'SANJUOS-M6B-READ-PATTERN' | - dd of="$STORAGE_IMAGE" bs=1 seek=$((8 * 512)) conv=notrunc status=none +python3 ./scripts/create-fat32-image.py "$STORAGE_IMAGE" mkdir -p build rm -f build/qemu-debug.log @@ -41,6 +39,8 @@ timeout 20s qemu-system-x86_64 \ -display none \ -serial none \ -monitor none \ + -d int,cpu_reset,guest_errors \ + -D build/qemu-events.log \ -debugcon file:build/qemu-debug.log \ -global isa-debugcon.iobase=0xe9 \ -device isa-debug-exit,iobase=0xf4,iosize=0x04 \ @@ -53,6 +53,7 @@ set -e if [[ "$qemu_status" -ne 33 ]]; then echo "error: QEMU exited with status $qemu_status" >&2 [[ -f build/qemu-debug.log ]] && cat build/qemu-debug.log >&2 + [[ -f build/qemu-events.log ]] && cat build/qemu-events.log >&2 exit 1 fi @@ -130,14 +131,40 @@ grep -Fq "Stale file-handle rejection test: passed" build/qemu-debug.log grep -Fq "Persistent storage writes: disabled" build/qemu-debug.log grep -Fq "M6B regression under M6C: passed" build/qemu-debug.log grep -Fq "M6C cache and VFS gate: passed" build/qemu-debug.log +grep -Fq "Soma OS M6D Read-Only FAT32" build/qemu-debug.log +grep -Fq "Validated FAT32 mount: active" build/qemu-debug.log +grep -Fq "FAT32 bytes per sector: 512" build/qemu-debug.log +grep -Fq "FAT32 sectors per cluster: 1" build/qemu-debug.log +grep -Fq "FAT32 total sectors: 131072" build/qemu-debug.log +grep -Fq "FAT32 data clusters: 129022" build/qemu-debug.log +grep -Fq "FAT32 FSInfo validation: passed" build/qemu-debug.log +grep -Fq "FAT32 backup boot validation: passed" build/qemu-debug.log +grep -Fq "Secondary VFS mount dispatch: active" build/qemu-debug.log +grep -Fq "Mounted filesystems under M6D: 2" build/qemu-debug.log +grep -Fq "Persistent root directory test: passed" build/qemu-debug.log +grep -Fq "Persistent file read test: passed" build/qemu-debug.log +grep -Fq "FAT32 long-filename test: passed" build/qemu-debug.log +grep -Fq "Nested directory traversal test: passed" build/qemu-debug.log +grep -Fq "Multi-cluster file read test: passed" build/qemu-debug.log +grep -Fq "FAT32 persistent writes: blocked" build/qemu-debug.log +grep -Fq "Dirty cache entries after FAT32 reads: 0" build/qemu-debug.log +grep -Fq "M6C regression under M6D: passed" build/qemu-debug.log +grep -Fq "M6D read-only FAT32 gate: passed" build/qemu-debug.log grep -Fq "Soma OS kernel shell ready." build/qemu-debug.log grep -Fq "M5 protected userspace, syscalls, and ELF loader are active." build/qemu-debug.log grep -Fq "virtio-blk targets: 1" build/qemu-debug.log grep -Fq "write/readback passed" build/qemu-debug.log -grep -Fq "Block cache: 16 sectors, hits 1, misses 1, device reads 1, dirty 0, policy read-only" build/qemu-debug.log -grep -Fq "VFS mounts: 1, handle capacity: 32, normalized paths: active" build/qemu-debug.log +grep -Fq "Block cache: 16 sectors, hits " build/qemu-debug.log +grep -Fq "dirty 0, policy read-only" build/qemu-debug.log +grep -Fq "FAT32: 131072 sectors, 129022 clusters, 1 sector/cluster, persistent read passed, long names passed, multi-cluster passed" build/qemu-debug.log +grep -Fq "VFS mounts: 2, handle capacity: 32, normalized paths: active" build/qemu-debug.log grep -Fq "/ ramfs read-write" build/qemu-debug.log +grep -Fq "/disk fat32 read-only" build/qemu-debug.log grep -Fq "Welcome to Soma OS." build/qemu-debug.log +grep -Fq "README.TXT" build/qemu-debug.log +grep -Fq "Getting-Started.txt" build/qemu-debug.log +grep -Fq "GUIDE.TXT" build/qemu-debug.log +grep -Fq "Welcome to Soma OS persistent FAT32 storage." build/qemu-debug.log echo "QEMU smoke test passed." cat build/qemu-debug.log diff --git a/scripts/source-check.py b/scripts/source-check.py index b706b8c..5a04879 100755 --- a/scripts/source-check.py +++ b/scripts/source-check.py @@ -34,6 +34,7 @@ BLOCK = ROOT / "kernel/src/block.rs" CACHE = ROOT / "kernel/src/cache.rs" VFS = ROOT / "kernel/src/vfs.rs" +FAT32 = ROOT / "kernel/src/fat32.rs" SYSCALL = ROOT / "kernel/src/syscall.rs" ELF = ROOT / "kernel/src/elf.rs" STARTUP = ROOT / "kernel/src/startup.rs" @@ -42,6 +43,7 @@ FAULT_ELF = ROOT / "user/programs/bin/fault-test.elf" LOGO = ROOT / "assets/branding/sanjuos-logo.png" SMOKE = ROOT / "scripts/smoke-test.sh" +FAT32_IMAGE = ROOT / "scripts/create-fat32-image.py" USER_BUILD = ROOT / "scripts/build-user-programs.sh" SETUP = ROOT / "scripts/setup.sh" SOURCE_MANIFEST = ROOT / "SOURCE_MANIFEST.sha256" @@ -356,10 +358,12 @@ def main() -> int: block = BLOCK.read_text(encoding="utf-8") cache = CACHE.read_text(encoding="utf-8") vfs = VFS.read_text(encoding="utf-8") + fat32 = FAT32.read_text(encoding="utf-8") syscall = SYSCALL.read_text(encoding="utf-8") elf = ELF.read_text(encoding="utf-8") startup = STARTUP.read_text(encoding="utf-8") smoke = SMOKE.read_text(encoding="utf-8") + fat32_image = FAT32_IMAGE.read_text(encoding="utf-8") user_build = USER_BUILD.read_text(encoding="utf-8") setup = SETUP.read_text(encoding="utf-8") @@ -494,6 +498,12 @@ def main() -> int: require(vfs, "pub trait FileSystem", VFS) require(vfs, "fn path_normalization_is_absolute_bounded_and_canonical", VFS) require(vfs, "fn ramfs_resolves_and_reads_through_generation_checked_handle", VFS) + require(vfs, "secondary_mount: Option", VFS) + require(vfs, "pub fn secondary_backend", VFS) + require(fat32, "pub struct Fat32", FAT32) + require(fat32, "impl FileSystem for Fat32", FAT32) + require(fat32, "Fat32Error::ClusterLoop", FAT32) + require(fat32, "fn offset_reads_cross_cluster_boundaries_without_rewalking_the_chain", FAT32) require(syscall, "pub struct SyscallDispatcher", SYSCALL) for syscall_name in ("Write", "Read", "Exit", "Yield", "GetPid", "Open", "Close", "Spawn"): require(syscall, syscall_name, SYSCALL) @@ -515,7 +525,7 @@ def main() -> int: ): require(ownership, f"fn {test_name}", OWNERSHIP) require(capability_registry, "SYS-TC-001", CAPABILITY_REGISTRY) - require(capability_registry, 'registry_version = 6', CAPABILITY_REGISTRY) + require(capability_registry, 'registry_version = 7', CAPABILITY_REGISTRY) require(capability_registry, "PCI-ENUM-001", CAPABILITY_REGISTRY) require(capability_registry, "STOR-DISC-001", CAPABILITY_REGISTRY) require(capability_registry, "STOR-BLK-001", CAPABILITY_REGISTRY) @@ -523,6 +533,7 @@ def main() -> int: require(capability_registry, "STOR-CACHE-001", CAPABILITY_REGISTRY) require(capability_registry, "VFS-CORE-001", CAPABILITY_REGISTRY) require(capability_registry, "VFS-HANDLE-001", CAPABILITY_REGISTRY) + require(capability_registry, "FS-FAT32-001", CAPABILITY_REGISTRY) require(capability_registry, "software_model", CAPABILITY_REGISTRY) require(toolchain, 'channel = "1.97.0"', TOOLCHAIN) require(toolchain, 'components = ["clippy", "rustfmt"]', TOOLCHAIN) @@ -534,11 +545,13 @@ def main() -> int: require(kernel, "pub struct M6aReport", KERNEL) require(kernel, "pub struct M6bReport", KERNEL) require(kernel, "pub struct M6cReport", KERNEL) + require(kernel, "pub struct M6dReport", KERNEL) require(kernel, "Foundation hardening phase 1: passed", KERNEL) require(kernel, "Foundation hardening phase 3: passed", KERNEL) require(kernel, "M6A PCI discovery gate: passed", KERNEL) require(kernel, "M6B block transport gate: passed", KERNEL) require(kernel, "M6C cache and VFS gate: passed", KERNEL) + require(kernel, "M6D read-only FAT32 gate: passed", KERNEL) require(kernel, "Ring 3 execution", KERNEL) require(kernel, "M5 protected user-space gate: passed", KERNEL) require( @@ -551,12 +564,15 @@ def main() -> int: require(setup, "rustup override set 1.97.0", SETUP) require(smoke, "virtio-blk-pci", SMOKE) require(smoke, "M6A PCI discovery gate: passed", SMOKE) - require(smoke, "SANJUOS-M6B-READ-PATTERN", SMOKE) + require(fat32_image, "SANJUOS-M6B-READ-PATTERN", FAT32_IMAGE) require(smoke, "serial=SANJU-M6B", SMOKE) require(smoke, "M6B block transport gate: passed", SMOKE) require(smoke, "Disposable sector restoration: passed", SMOKE) require(smoke, "M6C cache and VFS gate: passed", SMOKE) require(smoke, "Persistent storage writes: disabled", SMOKE) + require(smoke, "scripts/create-fat32-image.py", SMOKE) + require(smoke, "M6D read-only FAT32 gate: passed", SMOKE) + require(smoke, "/disk fat32 read-only", SMOKE) for executable in (INIT_ELF, HELLO_ELF, FAULT_ELF): validate_elf64(executable) if LOGO.read_bytes()[:8] != b"\x89PNG\r\n\x1a\n": @@ -610,7 +626,7 @@ def main() -> int: ) validate_source_manifest() - print("Soma OS M6C cache and VFS source checks passed.") + print("Soma OS M6D read-only FAT32 source checks passed.") print("UEFI memory descriptor base size: 40 bytes") print("UEFI GOP mode-information size: 36 bytes") print("UEFI GOP mode size: 40 bytes")