From cd26b2b976e2cfc8fccafb2f9babcb315475c006 Mon Sep 17 00:00:00 2001 From: Markus Maiwald Date: Sun, 15 Feb 2026 22:26:12 +0100 Subject: [PATCH] feat: NexFS sovereign flash filesystem, LSL-1.0 --- .gitignore | 7 + LICENSE | 51 +++ build.zig | 39 +++ build.zig.zon | 7 + src/nexfs/alloc.zig | 163 ++++++++++ src/nexfs/bam.zig | 20 ++ src/nexfs/checksum.zig | 48 +++ src/nexfs/config.zig | 78 +++++ src/nexfs/dir.zig | 78 +++++ src/nexfs/dir_ops.zig | 181 +++++++++++ src/nexfs/file.zig | 217 +++++++++++++ src/nexfs/format.zig | 124 +++++++ src/nexfs/inode.zig | 202 ++++++++++++ src/nexfs/nexfs.zig | 268 +++++++++++++++ src/nexfs/path.zig | 251 +++++++++++++++ src/nexfs/superblock.zig | 56 ++++ src/nexfs/types.zig | 70 ++++ tests/test_nexfs.zig | 680 +++++++++++++++++++++++++++++++++++++++ 18 files changed, 2540 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 build.zig create mode 100644 build.zig.zon create mode 100644 src/nexfs/alloc.zig create mode 100644 src/nexfs/bam.zig create mode 100644 src/nexfs/checksum.zig create mode 100644 src/nexfs/config.zig create mode 100644 src/nexfs/dir.zig create mode 100644 src/nexfs/dir_ops.zig create mode 100644 src/nexfs/file.zig create mode 100644 src/nexfs/format.zig create mode 100644 src/nexfs/inode.zig create mode 100644 src/nexfs/nexfs.zig create mode 100644 src/nexfs/path.zig create mode 100644 src/nexfs/superblock.zig create mode 100644 src/nexfs/types.zig create mode 100644 tests/test_nexfs.zig diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..12fa1b3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +zig-out/ +zig-cache/ +.zig-cache/ +*.o +*.a +*.elf +.agent/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d0e12d0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,51 @@ +The Libertaria Sovereign License (LSL) v1.0 + +Preamble +This License fosters a sovereign, decentralized ecosystem. It creates a balance between proprietary commercial use and communal innovation. It guarantees that improvements to the Core technology remain open, while allowing flexibility for the applications built upon it. + +1. Definitions +"Contribution" means the source code, documentation, and other artistic works contained in this distribution. + +"Contributor" means the Licensor and any individual or legal entity submitting code or content to the Project. + +"Modifications" means: + +Any addition to, deletion from, or change to the contents of the files containing the Contribution. + +Any new file that contains any part of the original Contribution. + +"Larger Work" means a work combining the Contribution with other software or content not governed by this License (e.g., linking the Core into proprietary applications). + +"Source Code Form" means the form of the work preferred for making modifications. + +2. Grant of Rights +2.1. Copyright Grant +Subject to the terms of this License, each Contributor grants You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable license to reproduce, prepare Derivative Works of, publicly display, sublicense, and distribute the Contribution. + +2.2. Patent Grant +Each Contributor grants You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Contribution. This license applies only to those patent claims licensable by the Contributor that are necessarily infringed by the Contribution alone or in combination with the Contribution. + +3. Conditions of Distribution +3.1. The Reciprocity Rule (File-Level Copyleft) +If You distribute the Contribution in Executable Form, You must make the Source Code Form of any Modifications available under the terms of this License. + +The Scope: This requirement applies only to the files of the Contribution itself that You have modified. + +The Cost: You must make this Source Code available at no more than the cost of distribution. + +3.2. Larger Works (The Commercial Bridge) +You may create and distribute a Larger Work under terms of Your choice. The Reciprocity requirement (3.1) does not extend to Your independent code that links to or consumes the Contribution, provided that independent code is kept in separate files. + +3.3. Attribution +Redistributions of Source Code or Executable Form must retain all copyright notices, this list of conditions, and the disclaimer below. You must include a reasonable acknowledgment in the documentation, such as: + +"Portions Copyright © [YEAR] [Licensor Name]. Used under the The Libertaria Sovereign License v1.0." + +4. Termination +If You initiate litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Contribution constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Contribution shall terminate as of the date such litigation is filed. + +5. Disclaimer of Warranty and Limitation of Liability +THIS SOFTWARE IS PROVIDED "AS IS," WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE LICENSOR OR CONTRIBUTORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE. + +6. Jurisdiction and Governing Law (Rechtssicherheit) +This License is governed by and construed in accordance with the laws of The Netherlands, without regard to its conflict of law provisions. Any legal suit, action, or proceeding arising out of or related to this License or the Contribution shall be instituted exclusively in the competent courts of Amsterdam, The Netherlands. \ No newline at end of file diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..9432091 --- /dev/null +++ b/build.zig @@ -0,0 +1,39 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Library module (for consumers like Rumpk) + const nexfs_mod = b.addModule("nexfs", .{ + .root_source_file = b.path("src/nexfs/nexfs.zig"), + .target = target, + .optimize = optimize, + }); + + // Static library (for C/Nim FFI) + const lib = b.addLibrary(.{ + .linkage = .static, + .name = "nexfs", + .root_module = nexfs_mod, + }); + b.installArtifact(lib); + + // Tests + const test_mod = b.createModule(.{ + .root_source_file = b.path("tests/test_nexfs.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "nexfs", .module = nexfs_mod }, + }, + }); + + const tests = b.addTest(.{ + .root_module = test_mod, + }); + + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run NexFS tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..0e72058 --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,7 @@ +.{ + .name = .nexfs, + .version = "0.1.0", + .fingerprint = 0x4c91ec4f0edeb400, + .minimum_zig_version = "0.14.0", + .paths = .{ "src", "build.zig", "build.zig.zon" }, +} diff --git a/src/nexfs/alloc.zig b/src/nexfs/alloc.zig new file mode 100644 index 0000000..ab739a6 --- /dev/null +++ b/src/nexfs/alloc.zig @@ -0,0 +1,163 @@ +// NexFS - Block Allocator +// SPDX-License-Identifier: LSL-1.0 + +const types = @import("types.zig"); +pub const bam = @import("bam.zig"); +const NexFSError = types.NexFSError; +const BlockAddr = types.BlockAddr; + +/// In-memory block allocator state. +pub const BlockAllocator = struct { + /// First data block (after metadata) + data_start: BlockAddr, + /// Total data blocks + data_count: u32, + /// Next block to try for allocation (simple round-robin) + next_block: BlockAddr, + /// Generation counter for wear leveling + generation: u32, + + /// Initialize allocator from superblock + pub fn init(data_start: BlockAddr, data_count: u32, generation: u32) BlockAllocator { + return .{ + .data_start = data_start, + .data_count = data_count, + .next_block = data_start, + .generation = generation, + }; + } + + /// Find next free block using the BAM. + /// Returns BlockAddr or NexFSError.NoSpace. + pub fn alloc( + self: *BlockAllocator, + bam_entries: []const bam.BamEntry, + ) NexFSError!BlockAddr { + var attempts: u32 = 0; + const max_attempts = self.data_count; + + while (attempts < max_attempts) : (attempts += 1) { + const block = self.next_block; + self.next_block += 1; + if (self.next_block >= self.data_start + self.data_count) { + self.next_block = self.data_start; + } + + // Check if block is free + const idx = block - self.data_start; + if (idx < bam_entries.len) { + const entry = &bam_entries[idx]; + if (entry.flags.allocated == 0 and entry.flags.reserved == 0 and entry.flags.bad == 0) { + return block; + } + } + } + + return NexFSError.NoSpace; + } + + /// Mark a block as allocated in BAM (caller must write BAM to flash) + pub fn markAllocated( + self: *BlockAllocator, + bam_entries: []bam.BamEntry, + block: BlockAddr, + ) NexFSError!void { + const idx = block - self.data_start; + if (idx >= bam_entries.len) return NexFSError.InvalidBlockAddress; + + var entry = &bam_entries[idx]; + if (entry.flags.allocated == 1) return NexFSError.AlreadyMounted; // Block already in use + + entry.flags.allocated = 1; + entry.generation = self.generation; + return; + } + + /// Mark a block as free in BAM (caller must write BAM to flash) + pub fn free( + self: *BlockAllocator, + bam_entries: []bam.BamEntry, + block: BlockAddr, + ) NexFSError!void { + const idx = block - self.data_start; + if (idx >= bam_entries.len) return NexFSError.InvalidBlockAddress; + + var entry = &bam_entries[idx]; + entry.flags.allocated = 0; + entry.flags.needs_erase = 1; // Mark for erasure before reuse + entry.erase_count += 1; + return; + } + + /// Check if block is free + pub fn isFree( + self: *const BlockAllocator, + bam_entries: []const bam.BamEntry, + block: BlockAddr, + ) bool { + const idx = block - self.data_start; + if (idx >= bam_entries.len) return false; + const entry = &bam_entries[idx]; + return entry.flags.allocated == 0 and entry.flags.reserved == 0 and entry.flags.bad == 0; + } +}; + +/// Load BAM from flash into memory. +/// Caller provides bam_entries buffer (must hold block_count entries). +pub fn loadBam( + flash: anytype, + bam_start: BlockAddr, + block_size: u32, + bam_entries: []bam.BamEntry, + read_buffer: []u8, +) NexFSError!void { + const entries_per_block = block_size / @sizeOf(bam.BamEntry); + var block: BlockAddr = 0; + + while (block < bam_entries.len) : (block += entries_per_block) { + const bam_block = bam_start + (block / entries_per_block); + const addr = @as(u64, bam_block) * block_size; + + // Read block from flash + const bytes_read = try flash.read(flash.ctx, addr, read_buffer[0..block_size]); + if (bytes_read != block_size) return NexFSError.ReadFailed; + + // Copy entries + const entries_to_copy = @min(entries_per_block, bam_entries.len - block); + const src = @as([*]const bam.BamEntry, @ptrCast(@alignCast(read_buffer.ptr))); + var i: usize = 0; + while (i < entries_to_copy) : (i += 1) { + bam_entries[block + i] = src[i]; + } + } + return; +} + +/// Write BAM to flash. +pub fn writeBam( + flash: anytype, + bam_start: BlockAddr, + block_size: u32, + bam_entries: []const bam.BamEntry, + write_buffer: []u8, +) NexFSError!void { + const entries_per_block = block_size / @sizeOf(bam.BamEntry); + var block: BlockAddr = 0; + + while (block < bam_entries.len) : (block += entries_per_block) { + const bam_block = bam_start + (block / entries_per_block); + const addr = @as(u64, bam_block) * block_size; + + // Copy entries to buffer + const entries_to_copy = @min(entries_per_block, bam_entries.len - block); + const dst = @as([*]bam.BamEntry, @ptrCast(@alignCast(write_buffer.ptr))); + var i: usize = 0; + while (i < entries_to_copy) : (i += 1) { + dst[i] = bam_entries[block + i]; + } + + // Write block to flash + try flash.write(flash.ctx, addr, write_buffer[0..block_size]); + } + return; +} diff --git a/src/nexfs/bam.zig b/src/nexfs/bam.zig new file mode 100644 index 0000000..c40401e --- /dev/null +++ b/src/nexfs/bam.zig @@ -0,0 +1,20 @@ +// NexFS - Block Allocation Map on-disk structure +// SPDX-License-Identifier: LSL-1.0 + +/// Block Allocation Map Entry — metadata per block. +pub const BamEntry = extern struct { + pub const Flags = packed struct { + allocated: u1, + bad: u1, + reserved: u1, + needs_erase: u1, + _reserved: u4, + }; + + flags: Flags, + erase_count: u32, + generation: u32, + reserved: u8, + _reserved1: u8, + _reserved2: u8, +}; diff --git a/src/nexfs/checksum.zig b/src/nexfs/checksum.zig new file mode 100644 index 0000000..fafb47e --- /dev/null +++ b/src/nexfs/checksum.zig @@ -0,0 +1,48 @@ +// NexFS - Checksum functions +// SPDX-License-Identifier: LSL-1.0 + +/// Compute CRC-16/Modbus checksum (init=0xFFFF, reflected poly=0xA001). +pub fn crc16(data: []const u8) u16 { + var crc: u16 = 0xFFFF; + for (data) |byte| { + crc ^= @as(u16, byte); + var j: u5 = 0; + while (j < 8) : (j += 1) { + const lsb = crc & 1; + crc >>= 1; + if (lsb != 0) crc ^= 0xA001; + } + } + return crc; +} + +/// Compute CRC32C checksum (Castagnoli polynomial 0x1EDC6F41). +pub fn crc32c(data: []const u8) u32 { + var crc: u32 = 0xFFFFFFFF; + for (data) |byte| { + crc ^= @as(u32, byte); + var j: u5 = 0; + while (j < 8) : (j += 1) { + const lsb = crc & 1; + crc >>= 1; + if (lsb != 0) crc ^= 0x82F63B78; + } + } + return ~crc; +} + +/// Continue CRC32C computation with additional data. +/// Takes a finalized CRC (from crc32c()) and extends it. +pub fn crc32cUpdate(finalized_crc: u32, data: []const u8) u32 { + var crc = ~finalized_crc; + for (data) |byte| { + crc ^= @as(u32, byte); + var j: u5 = 0; + while (j < 8) : (j += 1) { + const lsb = crc & 1; + crc >>= 1; + if (lsb != 0) crc ^= 0x82F63B78; + } + } + return ~crc; +} diff --git a/src/nexfs/config.zig b/src/nexfs/config.zig new file mode 100644 index 0000000..94542b4 --- /dev/null +++ b/src/nexfs/config.zig @@ -0,0 +1,78 @@ +// NexFS - Configuration and platform abstraction +// SPDX-License-Identifier: LSL-1.0 + +const types = @import("types.zig"); +const NexFSError = types.NexFSError; +const BlockAddr = types.BlockAddr; +const BlockSize = types.BlockSize; +const PageSize = types.PageSize; +const ChecksumAlgo = types.ChecksumAlgo; + +/// Flash interface trait. +/// Provides read/write/erase/sync operations via callbacks. +/// Allows NexFS to work with any flash HAL. +pub const FlashInterface = struct { + ctx: *anyopaque, + + read: *const fn ( + ctx: *anyopaque, + addr: u64, + buffer: []u8, + ) NexFSError!usize, + + write: *const fn ( + ctx: *anyopaque, + addr: u64, + buffer: []const u8, + ) NexFSError!void, + + erase: *const fn ( + ctx: *anyopaque, + block_addr: BlockAddr, + ) NexFSError!void, + + sync: *const fn ( + ctx: *anyopaque, + ) NexFSError!void, +}; + +/// Time source interface. Optional — null means no time tracking. +pub const TimeSource = struct { + ctx: *anyopaque, + + now: *const fn ( + ctx: *anyopaque, + ) NexFSError!u64, +}; + +/// NexFS filesystem configuration. +/// All buffers must be provided by caller — no dynamic allocation. +pub const Config = struct { + flash: FlashInterface, + device_size: u64, + block_size: BlockSize, + block_count: u32, + page_size: PageSize, + checksum_algo: ChecksumAlgo, + read_buffer: []u8, + write_buffer: []u8, + workspace: []u8, + time_source: ?TimeSource, + verbose: bool = false, +}; + +/// Runtime validation of configuration. +pub fn validateConfigRuntime(config: *const Config) NexFSError!void { + const block_size_minus_one = config.block_size -% 1; + const is_power_of_2 = (config.block_size & block_size_minus_one) == 0; + + if (!is_power_of_2) return NexFSError.InvalidConfig; + if (config.block_size < 512) return NexFSError.InvalidConfig; + if (config.block_size % config.page_size != 0) return NexFSError.InvalidConfig; + if (config.read_buffer.len < config.block_size) return NexFSError.InvalidConfig; + if (config.write_buffer.len < config.block_size) return NexFSError.InvalidConfig; + if (config.workspace.len < config.page_size) return NexFSError.InvalidConfig; + + const expected_blocks = config.device_size / config.block_size; + if (config.block_count != expected_blocks) return NexFSError.InvalidConfig; +} diff --git a/src/nexfs/dir.zig b/src/nexfs/dir.zig new file mode 100644 index 0000000..10deab8 --- /dev/null +++ b/src/nexfs/dir.zig @@ -0,0 +1,78 @@ +// NexFS - Directory entry on-disk structure +// SPDX-License-Identifier: LSL-1.0 + +const types = @import("types.zig"); +const checksum_mod = @import("checksum.zig"); +const InodeId = types.InodeId; +const FileType = types.FileType; +const NexFSError = types.NexFSError; +const NEXFS_MAX_NAME_LEN = types.NEXFS_MAX_NAME_LEN; + +/// Directory entry header — fixed-size portion. +/// Name is stored separately in the caller's buffer following this header. +pub const DirEntry = extern struct { + inode: InodeId, + entry_type: FileType, + name_len: u8, + entry_size: u16, + next_offset: u16, + reserved: u16, + _padding: [4]u8, + checksum: u32, + + /// Read name from a buffer that contains [DirEntry header][name bytes]. + /// The buffer must be at least @sizeOf(DirEntry) + self.name_len bytes. + pub fn getName(self: *const DirEntry, entry_buf: []const u8) []const u8 { + const header_size = @sizeOf(DirEntry); + if (entry_buf.len < header_size + self.name_len) return &.{}; + return entry_buf[header_size..][0..self.name_len]; + } + + /// Write name into a buffer and update header + checksum. + /// Buffer must be at least @sizeOf(DirEntry) + name.len + 1 bytes. + /// Returns total entry size (header + name + null + alignment padding). + pub fn setName(self: *DirEntry, name: []const u8, entry_buf: []u8) NexFSError!usize { + const header_size = @sizeOf(DirEntry); + if (name.len > NEXFS_MAX_NAME_LEN) return NexFSError.InvalidPath; + if (entry_buf.len < header_size + name.len + 1) return NexFSError.BufferTooSmall; + + self.name_len = @intCast(name.len); + + // Write name after header + @memcpy(entry_buf[header_size..][0..name.len], name); + entry_buf[header_size + name.len] = 0; // null terminate + + // Compute total entry size with 8-byte alignment + const total = dirEntrySize(name.len); + self.entry_size = @intCast(total); + + // Serialize header into buffer, then compute checksum over all + self.computeChecksum(entry_buf); + + return total; + } + + /// Compute entry checksum over header (up to checksum field) + name. + pub fn computeChecksum(self: *DirEntry, entry_buf: []const u8) void { + self.checksum = 0; + const off = @offsetOf(DirEntry, "checksum"); + const header_data = @as([*]const u8, @ptrCast(self))[0..off]; + var crc = checksum_mod.crc32c(header_data); + + // Include name in checksum + if (self.name_len > 0) { + const header_size = @sizeOf(DirEntry); + if (entry_buf.len >= header_size + self.name_len) { + const name_data = entry_buf[header_size..][0..self.name_len]; + crc = checksum_mod.crc32cUpdate(crc, name_data); + } + } + self.checksum = crc; + } +}; + +/// Calculate total size of directory entry with name (8-byte aligned). +pub fn dirEntrySize(name_len: usize) usize { + const header_size = @sizeOf(DirEntry); + return (header_size + name_len + 1 + 7) & ~@as(usize, 7); +} diff --git a/src/nexfs/dir_ops.zig b/src/nexfs/dir_ops.zig new file mode 100644 index 0000000..415392f --- /dev/null +++ b/src/nexfs/dir_ops.zig @@ -0,0 +1,181 @@ +// NexFS - Directory Operations +// SPDX-License-Identifier: LSL-1.0 + +const types = @import("types.zig"); +const dir = @import("dir.zig"); +const inode_mod = @import("inode.zig"); +const NexFSError = types.NexFSError; +const InodeId = types.InodeId; +const BlockAddr = types.BlockAddr; +const FileType = types.FileType; +const DirEntry = dir.DirEntry; +const Inode = inode_mod.Inode; + +/// Directory iterator for reading directory entries. +pub const DirIterator = struct { + /// Current position in directory + offset: u64, + /// Directory inode + inode: *const Inode, + /// Entry buffer for parsing + entry_buffer: []u8, + + /// Initialize iterator for a directory inode. + pub fn init(inode: *const Inode, entry_buffer: []u8) DirIterator { + return .{ + .offset = 0, + .inode = inode, + .entry_buffer = entry_buffer, + }; + } + + /// Get next directory entry. + /// Returns null when no more entries. + /// The returned DirEntry header lives at entry_buffer[0..@sizeOf(DirEntry)], + /// and the name bytes follow at entry_buffer[@sizeOf(DirEntry)..]. + pub fn next( + self: *DirIterator, + flash: anytype, + block_size: u32, + _read_buffer: []u8, + ) NexFSError!?*const DirEntry { + _ = _read_buffer; + if (self.offset >= self.inode.size) return null; + + // For small directories, use inline extent + if (self.inode.extent_count == 0 and self.inode.inline_extent.length > 0) { + const header_size = @sizeOf(DirEntry); + const addr = @as(u64, self.inode.inline_extent.physical_block) * block_size + self.offset; + + // Read header first to get entry_size + if (self.entry_buffer.len < header_size) return NexFSError.BufferTooSmall; + _ = try flash.read(flash.ctx, addr, self.entry_buffer[0..header_size]); + + const entry: *const DirEntry = @ptrCast(@alignCast(self.entry_buffer.ptr)); + if (entry.inode == 0) return null; + if (entry.entry_size == 0) return null; + + // Read the full entry including name + const full_size = @min(@as(usize, entry.entry_size), self.entry_buffer.len); + if (full_size > header_size) { + _ = try flash.read(flash.ctx, addr + header_size, self.entry_buffer[header_size..full_size]); + } + + self.offset += entry.entry_size; + return entry; + } + + // TODO: Support extent table for larger directories + return NexFSError.NotSupported; + } +}; + +/// Directory operations namespace. +pub const DirOps = struct { + /// Look up a name in a directory. + /// Returns InodeId or NotFound. + pub fn lookup( + flash: anytype, + dir_inode: *const Inode, + name: []const u8, + block_size: u32, + entry_buffer: []u8, + read_buffer: []u8, + ) NexFSError!InodeId { + var iter = DirIterator.init(dir_inode, entry_buffer); + + while (true) { + const entry_opt = try iter.next(flash, block_size, read_buffer); + if (entry_opt == null) break; + const entry = entry_opt.?; + + if (entry.name_len == name.len) { + // Name is in entry_buffer right after the header + const entry_name = entry.getName(entry_buffer); + if (entry_name.len == name.len and std.mem.eql(u8, entry_name, name)) { + return entry.inode; + } + } + } + + return NexFSError.NotFound; + } + + /// Add an entry to a directory. + pub fn addEntry( + flash: anytype, + dir_inode: *Inode, + name: []const u8, + inode_id: InodeId, + file_type: FileType, + block_size: u32, + entry_buffer: []u8, + _write_buffer: []u8, + ) NexFSError!void { + _ = _write_buffer; + if (name.len > types.NEXFS_MAX_NAME_LEN) return NexFSError.InvalidPath; + + const entry_size = dir.dirEntrySize(name.len); + if (dir_inode.size + entry_size > dir_inode.inline_extent.length * block_size) { + return NexFSError.NoSpace; // Directory full (need extent table for larger) + } + + // Create entry + var entry: DirEntry = .{ + .inode = inode_id, + .entry_type = file_type, + .name_len = @intCast(name.len), + .entry_size = @intCast(entry_size), + .next_offset = 0, + .reserved = 0, + ._padding = .{0} ** 4, + .checksum = 0, + }; + + // Copy entry header + @memcpy(entry_buffer[0..@sizeOf(DirEntry)], @as([*]const u8, @ptrCast(&entry))[0..@sizeOf(DirEntry)]); + + // Copy name + const name_dst = entry_buffer[@sizeOf(DirEntry)..]; + @memcpy(name_dst[0..name.len], name); + name_dst[name.len] = 0; // Null terminate + + // Compute checksum (includes name) + entry.checksum = 0; + const checksum_data = entry_buffer[0..@offsetOf(DirEntry, "checksum")]; + entry.checksum = checksum_mod.crc32c(checksum_data); + entry.checksum = checksum_mod.crc32cUpdate(entry.checksum, name); + + // Write checksum back + const checksum_dst: *u32 = @ptrCast(@alignCast(&entry_buffer[@offsetOf(DirEntry, "checksum")])); + checksum_dst.* = entry.checksum; + + // Write to flash + const addr = @as(u64, dir_inode.inline_extent.physical_block) * block_size + dir_inode.size; + try flash.write(flash.ctx, addr, entry_buffer[0..entry_size]); + + // Update directory inode + dir_inode.size += entry_size; + dir_inode.mtime = 0; // TODO: Get from time_source + dir_inode.computeChecksum(); + + return; + } + + /// Remove an entry from a directory. + pub fn removeEntry( + flash: anytype, + dir_inode: *Inode, + name: []const u8, + block_size: u32, + entry_buffer: []u8, + write_buffer: []u8, + ) NexFSError!void { + _ = .{ flash, dir_inode, name, block_size, entry_buffer, write_buffer }; + // TODO: Implement directory entry removal + return NexFSError.NotSupported; + } +}; + +const checksum_mod = @import("checksum.zig"); +const std = @import("std"); diff --git a/src/nexfs/file.zig b/src/nexfs/file.zig new file mode 100644 index 0000000..c242bea --- /dev/null +++ b/src/nexfs/file.zig @@ -0,0 +1,217 @@ +// NexFS - File Operations +// SPDX-License-Identifier: LSL-1.0 + +const types = @import("types.zig"); +const inode_mod = @import("inode.zig"); +const alloc_mod = @import("alloc.zig"); +const NexFSError = types.NexFSError; +const InodeId = types.InodeId; +const BlockAddr = types.BlockAddr; +const FileHandle = types.FileHandle; +const Inode = inode_mod.Inode; +const Extent = inode_mod.Extent; + +/// File operations namespace. +pub const FileOps = struct { + /// Open a file for reading or writing. + /// Returns a file handle. + pub fn open( + inode: *const Inode, + flags: u32, // O_RDONLY, O_WRONLY, O_RDWR, O_CREAT, etc. + ) NexFSError!FileHandle { + if (!inode.isValid()) return NexFSError.InvalidInodeId; + if (inode.file_type != .Regular and inode.file_type != .Directory) { + return NexFSError.NotSupported; + } + + return .{ + .inode_id = inode.id, + .position = 0, + .open_flags = @intCast(flags), + }; + } + + /// Read data from a file. + /// Returns bytes read. + pub fn read( + flash: anytype, + inode: *const Inode, + handle: *FileHandle, + buffer: []u8, + block_size: u32, + read_buffer: []u8, + ) NexFSError!usize { + if (handle.inode_id != inode.id) return NexFSError.InvalidHandle; + + // Calculate how much we can read + const remaining = inode.size - handle.position; + const to_read = @min(buffer.len, remaining); + if (to_read == 0) return 0; + + var bytes_read: usize = 0; + var buf_offset: usize = 0; + + while (bytes_read < to_read) { + const file_block: u32 = @intCast(handle.position / block_size); + const block_offset = handle.position % block_size; + const chunk_size = @min(to_read - bytes_read, block_size - block_offset); + + // Find physical block for this logical block + const physical_block = try resolveBlock(inode, file_block); + + // Read from flash + const addr = @as(u64, physical_block) * block_size + block_offset; + _ = try flash.read(flash.ctx, addr, read_buffer[0..chunk_size]); + + // Copy to output buffer + @memcpy(buffer[buf_offset..][0..chunk_size], read_buffer[0..chunk_size]); + + bytes_read += chunk_size; + buf_offset += chunk_size; + handle.position += chunk_size; + } + + return bytes_read; + } + + /// Write data to a file. + /// Returns bytes written. + pub fn write( + flash: anytype, + inode: *Inode, + handle: *FileHandle, + buffer: []const u8, + block_size: u32, + allocator: *alloc_mod.BlockAllocator, + bam_entries: []alloc_mod.bam.BamEntry, + write_buffer: []u8, + ) NexFSError!usize { + if (handle.inode_id != inode.id) return NexFSError.InvalidHandle; + if (handle.open_flags & 0x1 == 0) return NexFSError.NotSupported; // Read-only + + var bytes_written: usize = 0; + var buf_offset: usize = 0; + + while (bytes_written < buffer.len) { + const file_block: u32 = @intCast(handle.position / block_size); + const block_offset = handle.position % block_size; + const chunk_size = @min(buffer.len - bytes_written, block_size - block_offset); + + // Find or allocate physical block + const physical_block = resolveBlock(inode, file_block) catch blk: { + // Need to allocate a new block + const new_block = try allocator.alloc(bam_entries); + try allocator.markAllocated(bam_entries, new_block); + + // Add to inode extents + try addBlockToInode(inode, file_block, new_block, block_size); + + break :blk new_block; + }; + + // Write to flash + const addr = @as(u64, physical_block) * block_size + block_offset; + @memcpy(write_buffer[0..chunk_size], buffer[buf_offset..][0..chunk_size]); + try flash.write(flash.ctx, addr, write_buffer[0..chunk_size]); + + bytes_written += chunk_size; + buf_offset += chunk_size; + handle.position += chunk_size; + + // Update file size if we extended it + if (handle.position > inode.size) { + inode.size = handle.position; + } + } + + // Update inode metadata + inode.mtime = 0; // TODO: time_source + inode.computeChecksum(); + + return bytes_written; + } + + /// Seek to a position in the file. + pub fn seek( + handle: *FileHandle, + offset: i64, + whence: SeekWhence, + file_size: u64, + ) NexFSError!u64 { + const new_pos: i128 = switch (whence) { + .Set => offset, + .Current => @as(i128, handle.position) + offset, + .End => @as(i128, file_size) + offset, + }; + + if (new_pos < 0) return NexFSError.InvalidConfig; + handle.position = @intCast(new_pos); + return handle.position; + } + + /// Close a file handle. + pub fn close(handle: *FileHandle) void { + handle.* = .{ + .inode_id = 0, + .position = 0, + .open_flags = 0, + }; + } +}; + +/// Seek origin. +pub const SeekWhence = enum { + Set, // From beginning + Current, // From current position + End, // From end +}; + +/// Resolve a logical block number to a physical block address. +fn resolveBlock(inode: *const Inode, logical_block: u32) NexFSError!BlockAddr { + // Check inline extent first + if (inode.inline_extent.length > 0 and + logical_block >= inode.inline_extent.logical_block and + logical_block < inode.inline_extent.logical_block + inode.inline_extent.length) + { + return inode.inline_extent.physical_block + (logical_block - inode.inline_extent.logical_block); + } + + // TODO: Check extent table for larger files + return NexFSError.InvalidBlockAddress; +} + +/// Add a block to an inode's extent map. +fn addBlockToInode( + inode: *Inode, + logical_block: u32, + physical_block: BlockAddr, + block_size: u32, +) NexFSError!void { + _ = block_size; + + // Try to extend inline extent + if (inode.inline_extent.length == 0) { + // First block + inode.inline_extent = .{ + .logical_block = logical_block, + .physical_block = physical_block, + .length = 1, + .reserved = 0, + }; + inode.block_count = 1; + return; + } + + // Check if we can extend contiguously + const extent_end = inode.inline_extent.logical_block + inode.inline_extent.length; + if (logical_block == extent_end and + physical_block == inode.inline_extent.physical_block + inode.inline_extent.length) + { + inode.inline_extent.length += 1; + inode.block_count += 1; + return; + } + + // TODO: Create extent table entry for non-contiguous blocks + return NexFSError.NotSupported; +} diff --git a/src/nexfs/format.zig b/src/nexfs/format.zig new file mode 100644 index 0000000..b38e294 --- /dev/null +++ b/src/nexfs/format.zig @@ -0,0 +1,124 @@ +// NexFS - Format (mkfs) operation +// SPDX-License-Identifier: LSL-1.0 + +const types = @import("types.zig"); +const config_mod = @import("config.zig"); +const sb_mod = @import("superblock.zig"); +const inode_mod = @import("inode.zig"); +const bam_mod = @import("bam.zig"); + +const NexFSError = types.NexFSError; +const BlockAddr = types.BlockAddr; +const Config = config_mod.Config; +const FlashInterface = config_mod.FlashInterface; +const Superblock = sb_mod.Superblock; +const Inode = inode_mod.Inode; +const BamEntry = bam_mod.BamEntry; + +/// Format a blank flash device with NexFS. +/// Writes superblock to block 0 (primary) and block 1 (backup). +/// Caller must provide a write_buffer >= config.block_size. +pub fn format( + flash: *const FlashInterface, + config: *const Config, + write_buffer: []u8, +) NexFSError!void { + if (write_buffer.len < config.block_size) { + return NexFSError.BufferTooSmall; + } + + try config_mod.validateConfigRuntime(config); + + // Calculate metadata layout + const bam_blocks = (config.block_count + 63) / 64; + const inode_table_blocks: u32 = 4; + const metadata_blocks = 2 + bam_blocks + inode_table_blocks; + + // Initialize superblock + var sb: Superblock = .{ + .magic = sb_mod.NEXFS_MAGIC, + .version = sb_mod.NEXFS_VERSION, + .generation = 0, + .block_size = config.block_size, + .page_size = config.page_size, + .block_count = config.block_count, + .data_block_count = config.block_count - metadata_blocks, + .root_inode = 1, + .inode_table_start = 2 + bam_blocks, + .inode_table_blocks = inode_table_blocks, + .bam_start = 2, + .bam_blocks = bam_blocks, + .create_time = 0, + .last_mount_time = 0, + .mount_count = 0, + .flags = 0, + .reserved = 0, + ._padding = .{0} ** 44, + .checksum = 0, + }; + sb.computeChecksum(); + + // Write primary superblock to block 0 + @memcpy(write_buffer[0..@sizeOf(Superblock)], @as([*]const u8, @ptrCast(&sb))[0..@sizeOf(Superblock)]); + @memset(write_buffer[@sizeOf(Superblock)..config.block_size], 0); + try flash.write(flash.ctx, 0, write_buffer[0..config.block_size]); + + // Write backup superblock to block 1 + try flash.write(flash.ctx, config.block_size, write_buffer[0..config.block_size]); + + // Initialize BAM (metadata blocks marked reserved+allocated) + @memset(write_buffer[0..config.block_size], 0); + const bam_entries_per_block = config.block_size / @sizeOf(BamEntry); + var bam_entry: BamEntry = .{ + .flags = .{ .allocated = 0, .bad = 0, .reserved = 0, .needs_erase = 0, ._reserved = 0 }, + .erase_count = 0, + .generation = 0, + .reserved = 0, + ._reserved1 = 0, + ._reserved2 = 0, + }; + + var block: BlockAddr = 0; + while (block < metadata_blocks) : (block += 1) { + bam_entry.flags.reserved = 1; + bam_entry.flags.allocated = 1; + const entry_offset = (block % bam_entries_per_block) * @sizeOf(BamEntry); + @memcpy(write_buffer[entry_offset..][0..@sizeOf(BamEntry)], @as([*]const u8, @ptrCast(&bam_entry))[0..@sizeOf(BamEntry)]); + bam_entry.flags.reserved = 0; + bam_entry.flags.allocated = 0; + + if ((block + 1) % bam_entries_per_block == 0 or block == metadata_blocks - 1) { + const bam_block = sb.bam_start + (block / bam_entries_per_block); + try flash.write(flash.ctx, @as(u64, bam_block) * config.block_size, write_buffer[0..config.block_size]); + @memset(write_buffer[0..config.block_size], 0); + } + } + + // Initialize root inode (inode 1) + var root_inode: Inode = .{ + .id = 1, + .file_type = .Directory, + .mode = 0o755, + .uid = 0, + .gid = 0, + .size = 0, + .block_count = 0, + .link_count = 2, + .flags = 0, + .atime = 0, + .mtime = 0, + .ctime = 0, + .inline_extent = .{ .logical_block = 0, .physical_block = 0, .length = 0, .reserved = 0 }, + .extent_table = 0, + .extent_count = 0, + .reserved1 = 0, + .reserved2 = 0, + ._padding = .{0} ** 24, + .checksum = 0, + }; + root_inode.computeChecksum(); + + @memset(write_buffer[0..config.block_size], 0); + @memcpy(write_buffer[0..@sizeOf(Inode)], @as([*]const u8, @ptrCast(&root_inode))[0..@sizeOf(Inode)]); + try flash.write(flash.ctx, @as(u64, sb.inode_table_start) * config.block_size, write_buffer[0..config.block_size]); +} diff --git a/src/nexfs/inode.zig b/src/nexfs/inode.zig new file mode 100644 index 0000000..feafe71 --- /dev/null +++ b/src/nexfs/inode.zig @@ -0,0 +1,202 @@ +// NexFS - Inode and Extent on-disk structures +// SPDX-License-Identifier: LSL-1.0 + +const types = @import("types.zig"); +const checksum = @import("checksum.zig"); +const NexFSError = types.NexFSError; +const InodeId = types.InodeId; +const BlockAddr = types.BlockAddr; +const FileType = types.FileType; + +/// Extent — represents a contiguous range of blocks. +/// Size: 16 bytes. +pub const Extent = extern struct { + logical_block: u32, + physical_block: BlockAddr, + length: u32, + reserved: u32, +}; + +/// Inode structure — represents a file or directory. +/// Size: 128 bytes. +pub const Inode = extern struct { + id: InodeId, + file_type: FileType, + mode: u16, + uid: u32, + gid: u32, + size: u64, + block_count: u32, + link_count: u16, + flags: u16, + atime: u64, + mtime: u64, + ctime: u64, + inline_extent: Extent, + extent_table: BlockAddr, + extent_count: u16, + reserved1: u32, + reserved2: u16, + _padding: [24]u8, + checksum: u32, + + /// Check if inode is valid/allocated. + pub fn isValid(self: *const Inode) bool { + return self.id != 0 and self.file_type != .None; + } + + /// Validate inode checksum. + pub fn validate(self: *const Inode) NexFSError!void { + const off = @offsetOf(Inode, "checksum"); + const data = @as([*]const u8, @ptrCast(self))[0..off]; + if (checksum.crc32c(data) != self.checksum) return NexFSError.ChecksumError; + } + + /// Compute and store checksum. + pub fn computeChecksum(self: *Inode) void { + self.checksum = 0; + const off = @offsetOf(Inode, "checksum"); + const data = @as([*]const u8, @ptrCast(self))[0..off]; + self.checksum = checksum.crc32c(data); + } +}; + +/// Inode table operations. +pub const InodeTable = struct { + /// Load an inode from the inode table. + pub fn load( + flash: anytype, + inode_table_start: BlockAddr, + block_size: u32, + id: InodeId, + inode: *Inode, + read_buffer: []u8, + ) NexFSError!void { + if (id == 0) return NexFSError.InvalidInodeId; + + const inodes_per_block = block_size / @sizeOf(Inode); + const block_offset = (id - 1) / inodes_per_block; + const inode_offset = (id - 1) % inodes_per_block; + const addr = @as(u64, inode_table_start + block_offset) * block_size + + inode_offset * @sizeOf(Inode); + + _ = try flash.read(flash.ctx, addr, read_buffer[0..@sizeOf(Inode)]); + inode.* = @as(*const Inode, @ptrCast(@alignCast(read_buffer.ptr))).*; + + if (inode.id != id) return NexFSError.Corruption; + try inode.validate(); + return; + } + + /// Write an inode to the inode table. + pub fn write( + flash: anytype, + inode_table_start: BlockAddr, + block_size: u32, + inode: *const Inode, + write_buffer: []u8, + ) NexFSError!void { + if (inode.id == 0) return NexFSError.InvalidInodeId; + + const inodes_per_block = block_size / @sizeOf(Inode); + const block_offset = (inode.id - 1) / inodes_per_block; + const inode_offset = (inode.id - 1) % inodes_per_block; + const addr = @as(u64, inode_table_start + block_offset) * block_size + + inode_offset * @sizeOf(Inode); + + @memcpy(write_buffer[0..@sizeOf(Inode)], @as([*]const u8, @ptrCast(inode))[0..@sizeOf(Inode)]); + try flash.write(flash.ctx, addr, write_buffer[0..@sizeOf(Inode)]); + return; + } + + /// Find first free inode slot. + pub fn findFree( + flash: anytype, + inode_table_start: BlockAddr, + inode_table_blocks: u32, + block_size: u32, + read_buffer: []u8, + ) NexFSError!InodeId { + const inodes_per_block = block_size / @sizeOf(Inode); + var block: u32 = 0; + + while (block < inode_table_blocks) : (block += 1) { + const addr = @as(u64, inode_table_start + block) * block_size; + _ = try flash.read(flash.ctx, addr, read_buffer[0..block_size]); + + const inodes = @as([*]const Inode, @ptrCast(@alignCast(read_buffer.ptr)))[0..inodes_per_block]; + var i: usize = 0; + while (i < inodes_per_block) : (i += 1) { + if (!inodes[i].isValid()) { + return @as(InodeId, @intCast(block * inodes_per_block + i + 1)); + } + } + } + + return NexFSError.NoSpace; + } + + /// Allocate and initialize a new inode. + pub fn alloc( + flash: anytype, + inode_table_start: BlockAddr, + inode_table_blocks: u32, + block_size: u32, + file_type: FileType, + mode: u16, + inode: *Inode, + read_buffer: []u8, + ) NexFSError!InodeId { + const id = try findFree(flash, inode_table_start, inode_table_blocks, block_size, read_buffer); + + inode.* = .{ + .id = id, + .file_type = file_type, + .mode = mode, + .uid = 0, + .gid = 0, + .size = 0, + .block_count = 0, + .link_count = 1, + .flags = 0, + .atime = 0, + .mtime = 0, + .ctime = 0, + .inline_extent = .{ + .logical_block = 0, + .physical_block = 0, + .length = 0, + .reserved = 0, + }, + .extent_table = 0, + .extent_count = 0, + .reserved1 = 0, + .reserved2 = 0, + ._padding = .{0} ** 24, + .checksum = 0, + }; + inode.computeChecksum(); + return id; + } + + /// Mark an inode as deleted (clear it). + pub fn delete( + flash: anytype, + inode_table_start: BlockAddr, + block_size: u32, + id: InodeId, + write_buffer: []u8, + ) NexFSError!void { + if (id == 0) return NexFSError.InvalidInodeId; + + const inodes_per_block = block_size / @sizeOf(Inode); + const block_offset = (id - 1) / inodes_per_block; + const inode_offset = (id - 1) % inodes_per_block; + const addr = @as(u64, inode_table_start + block_offset) * block_size + + inode_offset * @sizeOf(Inode); + + @memset(write_buffer[0..@sizeOf(Inode)], 0); + try flash.write(flash.ctx, addr, write_buffer[0..@sizeOf(Inode)]); + return; + } +}; diff --git a/src/nexfs/nexfs.zig b/src/nexfs/nexfs.zig new file mode 100644 index 0000000..39ac865 --- /dev/null +++ b/src/nexfs/nexfs.zig @@ -0,0 +1,268 @@ +// NexFS - Native Zig Flash Filesystem for NexusOS +// SPDX-License-Identifier: LSL-1.0 +// Copyright (c) 2026 Nexus Organization + +const std = @import("std"); + +/// NexFS public API root. +/// See .agent/specs/ for Gherkin specifications. +pub const version = "0.1.0"; + +// ============================================================================ +// Re-exports — single import for consumers +// ============================================================================ + +pub const types = @import("types.zig"); +pub const config = @import("config.zig"); +pub const checksum_mod = @import("checksum.zig"); +pub const superblock = @import("superblock.zig"); +pub const inode_mod = @import("inode.zig"); +pub const dir = @import("dir.zig"); +pub const bam = @import("bam.zig"); +pub const format_mod = @import("format.zig"); +pub const alloc_mod = @import("alloc.zig"); +pub const dir_ops = @import("dir_ops.zig"); +pub const file_mod = @import("file.zig"); +pub const path_mod = @import("path.zig"); + +// Flat re-exports for convenience +pub const NexFSError = types.NexFSError; +pub const BlockAddr = types.BlockAddr; +pub const InodeId = types.InodeId; +pub const ChecksumAlgo = types.ChecksumAlgo; +pub const BlockSize = types.BlockSize; +pub const PageSize = types.PageSize; +pub const FileHandle = types.FileHandle; +pub const FileType = types.FileType; +pub const NEXFS_MAX_NAME_LEN = types.NEXFS_MAX_NAME_LEN; + +pub const Config = config.Config; +pub const FlashInterface = config.FlashInterface; +pub const TimeSource = config.TimeSource; +pub const validateConfigRuntime = config.validateConfigRuntime; + +pub const crc16 = checksum_mod.crc16; +pub const crc32c = checksum_mod.crc32c; +pub const crc32cUpdate = checksum_mod.crc32cUpdate; + +pub const NEXFS_MAGIC = superblock.NEXFS_MAGIC; +pub const NEXFS_VERSION = superblock.NEXFS_VERSION; +pub const Superblock = superblock.Superblock; + +pub const Inode = inode_mod.Inode; +pub const Extent = inode_mod.Extent; +pub const InodeTable = inode_mod.InodeTable; + +pub const DirEntry = dir.DirEntry; +pub const dirEntrySize = dir.dirEntrySize; + +pub const BamEntry = bam.BamEntry; + +pub const format = format_mod.format; + +pub const BlockAllocator = alloc_mod.BlockAllocator; +pub const loadBam = alloc_mod.loadBam; +pub const writeBam = alloc_mod.writeBam; + +pub const DirIterator = dir_ops.DirIterator; +pub const DirOps = dir_ops.DirOps; + +pub const FileOps = file_mod.FileOps; +pub const SeekWhence = file_mod.SeekWhence; + +pub const PathResolver = path_mod.PathResolver; +pub const FsOps = path_mod.FsOps; + +// ============================================================================ +// NexFS Instance +// ============================================================================ + +/// Active NexFS filesystem instance. +pub const NexFS = struct { + cfg: *const Config, + mounted: bool = false, + sb: superblock.Superblock = undefined, + allocator: alloc_mod.BlockAllocator = undefined, + + /// Initialize filesystem with configuration. + pub fn init(self: *NexFS, c: *const Config) NexFSError!void { + try validateConfigRuntime(c); + if (self.mounted) return NexFSError.AlreadyMounted; + + // Load superblock from flash + const flash = &c.flash; + _ = try flash.read(flash.ctx, 0, c.read_buffer[0..@sizeOf(superblock.Superblock)]); + self.sb = @as(*const superblock.Superblock, @ptrCast(@alignCast(c.read_buffer.ptr))).*; + try self.sb.validate(); + + // Initialize block allocator + const data_start = self.sb.inode_table_start + self.sb.inode_table_blocks; + const data_count = self.sb.block_count - data_start; + self.allocator = alloc_mod.BlockAllocator.init(data_start, data_count, self.sb.generation); + + self.cfg = c; + self.mounted = true; + } + + /// Check if filesystem is mounted. + pub fn isMounted(self: *const NexFS) bool { + return self.mounted; + } +}; + +// ============================================================================ +// C-FFI Exports for Rumpk +// ============================================================================ + +var g_nexfs_storage: NexFS = .{ .cfg = undefined, .mounted = false }; +var g_nexfs_active: bool = false; + +/// Mount filesystem (C-compatible). +/// Caller owns the Config and its buffers — they must outlive the mount. +export fn nexfs_mount(cfg: *const Config) callconv(.c) i32 { + if (g_nexfs_active) return @intFromError(NexFSError.AlreadyMounted); + g_nexfs_storage.init(cfg) catch |err| return @intFromError(err); + g_nexfs_active = true; + return 0; +} + +/// Unmount filesystem (C-compatible). +export fn nexfs_unmount() callconv(.c) i32 { + if (!g_nexfs_active) return @intFromError(NexFSError.NotMounted); + g_nexfs_storage.mounted = false; + g_nexfs_active = false; + return 0; +} + +/// Check if mounted (C-compatible). +export fn nexfs_is_mounted() callconv(.c) bool { + return g_nexfs_active and g_nexfs_storage.isMounted(); +} + +/// Read file (C-compatible — Phase 4). +export fn nexfs_read(path: [*:0]const u8, buf: [*]u8, len: usize) callconv(.c) i32 { + if (!g_nexfs_active) return @intFromError(NexFSError.NotMounted); + + const cfg = g_nexfs_storage.cfg; + const path_slice = std.mem.sliceTo(path, 0); + + // Load root inode + var root_inode: inode_mod.Inode = undefined; + inode_mod.InodeTable.load( + &cfg.flash, + g_nexfs_storage.sb.inode_table_start, + cfg.block_size, + g_nexfs_storage.sb.root_inode, + &root_inode, + cfg.read_buffer, + ) catch |err| return @intFromError(err); + + // Resolve path + var resolver = path_mod.PathResolver.init(root_inode, cfg.workspace); + const inode = resolver.resolve( + &cfg.flash, + path_slice, + g_nexfs_storage.sb.inode_table_start, + cfg.block_size, + cfg.workspace, + cfg.read_buffer, + ) catch |err| return @intFromError(err); + + // Open and read + var handle = file_mod.FileOps.open(&inode, 0) catch |err| return @intFromError(err); + defer file_mod.FileOps.close(&handle); + + const bytes_read = file_mod.FileOps.read( + &cfg.flash, + &inode, + &handle, + buf[0..len], + cfg.block_size, + cfg.read_buffer, + ) catch |err| return @intFromError(err); + + return @intCast(bytes_read); +} + +/// Write file (C-compatible — Phase 4). +export fn nexfs_write(path: [*:0]const u8, buf: [*]const u8, len: usize) callconv(.c) i32 { + if (!g_nexfs_active) return @intFromError(NexFSError.NotMounted); + + const cfg = g_nexfs_storage.cfg; + const path_slice = std.mem.sliceTo(path, 0); + + // Load root inode + var root_inode: inode_mod.Inode = undefined; + inode_mod.InodeTable.load( + &cfg.flash, + g_nexfs_storage.sb.inode_table_start, + cfg.block_size, + g_nexfs_storage.sb.root_inode, + &root_inode, + cfg.read_buffer, + ) catch |err| return @intFromError(err); + + // Resolve path + var resolver = path_mod.PathResolver.init(root_inode, cfg.workspace); + var inode = resolver.resolve( + &cfg.flash, + path_slice, + g_nexfs_storage.sb.inode_table_start, + cfg.block_size, + cfg.workspace, + cfg.read_buffer, + ) catch |err| return @intFromError(err); + + // Open and write + var handle = file_mod.FileOps.open(&inode, 0x1) catch |err| return @intFromError(err); + defer file_mod.FileOps.close(&handle); + + // Load BAM for block allocation — use allocator's data_start (set at mount) + const data_count = g_nexfs_storage.allocator.data_count; + if (data_count > 256) return @intFromError(NexFSError.NotSupported); // TODO: dynamic BAM buffer + var bam_entries: [256]bam.BamEntry = undefined; + alloc_mod.loadBam( + &cfg.flash, + g_nexfs_storage.sb.bam_start, + cfg.block_size, + bam_entries[0..data_count], + cfg.read_buffer, + ) catch |err| return @intFromError(err); + + const bytes_written = file_mod.FileOps.write( + &cfg.flash, + &inode, + &handle, + buf[0..len], + cfg.block_size, + &g_nexfs_storage.allocator, + bam_entries[0..data_count], + cfg.write_buffer, + ) catch |err| return @intFromError(err); + + // Write back BAM + alloc_mod.writeBam( + &cfg.flash, + g_nexfs_storage.sb.bam_start, + cfg.block_size, + bam_entries[0..data_count], + cfg.write_buffer, + ) catch |err| return @intFromError(err); + + // Update inode + inode_mod.InodeTable.write( + &cfg.flash, + g_nexfs_storage.sb.inode_table_start, + cfg.block_size, + &inode, + cfg.write_buffer, + ) catch |err| return @intFromError(err); + + return @intCast(bytes_written); +} + +/// Format flash device (C-compatible). +export fn nexfs_format(cfg: *const Config) callconv(.c) i32 { + format_mod.format(&cfg.flash, cfg, cfg.write_buffer) catch |err| return @intFromError(err); + return 0; +} diff --git a/src/nexfs/path.zig b/src/nexfs/path.zig new file mode 100644 index 0000000..99af86f --- /dev/null +++ b/src/nexfs/path.zig @@ -0,0 +1,251 @@ +// NexFS - Path Resolution +// SPDX-License-Identifier: LSL-1.0 + +const types = @import("types.zig"); +const inode_mod = @import("inode.zig"); +const dir_ops = @import("dir_ops.zig"); +const NexFSError = types.NexFSError; +const InodeId = types.InodeId; +const BlockAddr = types.BlockAddr; +const Inode = inode_mod.Inode; + +/// Path resolution state. +pub const PathResolver = struct { + /// Current inode during resolution + current_inode: Inode, + /// Component buffer for path parsing + component_buf: []u8, + + /// Initialize resolver starting from root. + pub fn init(root_inode: Inode, component_buf: []u8) PathResolver { + return .{ + .current_inode = root_inode, + .component_buf = component_buf, + }; + } + + /// Resolve a full path to an inode. + /// Path format: "/dir1/dir2/file" or "dir/file" (relative to cwd, not implemented) + pub fn resolve( + self: *PathResolver, + flash: anytype, + path: []const u8, + inode_table_start: BlockAddr, + block_size: u32, + entry_buffer: []u8, + read_buffer: []u8, + ) NexFSError!Inode { + // Must start with / + if (path.len == 0 or path[0] != '/') return NexFSError.InvalidPath; + + var pos: usize = 1; // Skip leading / + + while (pos < path.len) { + // Find next component + const start = pos; + while (pos < path.len and path[pos] != '/') : (pos += 1) {} + const component = path[start..pos]; + + // Skip empty components (//) + if (component.len == 0) { + if (pos < path.len) pos += 1; + continue; + } + + // Current must be a directory + if (self.current_inode.file_type != .Directory) { + return NexFSError.NotDirectory; + } + + // Look up component in current directory + const inode_id = try dir_ops.DirOps.lookup( + flash, + &self.current_inode, + component, + block_size, + entry_buffer, + read_buffer, + ); + + // Load the inode + try inode_mod.InodeTable.load( + flash, + inode_table_start, + block_size, + inode_id, + &self.current_inode, + read_buffer, + ); + + // Skip trailing slash + if (pos < path.len and path[pos] == '/') pos += 1; + } + + return self.current_inode; + } + + /// Resolve parent directory of a path. + /// Returns parent inode and last component name. + pub fn resolveParent( + self: *PathResolver, + flash: anytype, + path: []const u8, + inode_table_start: BlockAddr, + block_size: u32, + entry_buffer: []u8, + read_buffer: []u8, + last_component: *[]const u8, + ) NexFSError!Inode { + // Must start with / + if (path.len == 0 or path[0] != '/') return NexFSError.InvalidPath; + + // Find last slash + var last_slash: usize = path.len; + while (last_slash > 0) : (last_slash -= 1) { + if (path[last_slash - 1] == '/') break; + } + + if (last_slash <= 1) { + // Path is "/name" - parent is root + last_component.* = path[1..]; + return self.current_inode; + } + + // Resolve parent path + const parent_path = path[0..last_slash]; + const parent = try self.resolve( + flash, + parent_path, + inode_table_start, + block_size, + entry_buffer, + read_buffer, + ); + + last_component.* = path[last_slash..]; + return parent; + } +}; + +/// High-level filesystem operations. +pub const FsOps = struct { + /// Open a file by path. + pub fn openFile( + flash: anytype, + sb: *const superblock_mod.Superblock, + path: []const u8, + flags: u32, + entry_buffer: []u8, + read_buffer: []u8, + ) NexFSError!file_mod.FileHandle { + // Load root inode + var root_inode: Inode = undefined; + try inode_mod.InodeTable.load( + flash, + sb.inode_table_start, + sb.block_size, + sb.root_inode, + &root_inode, + read_buffer, + ); + + // Resolve path + var resolver = PathResolver.init(root_inode, entry_buffer); + const inode = try resolver.resolve( + flash, + path, + sb.inode_table_start, + sb.block_size, + entry_buffer, + read_buffer, + ); + + // Open file + return file_mod.FileOps.open(&inode, flags); + } + + /// Read from a file by path. + pub fn readFile( + flash: anytype, + sb: *const superblock_mod.Superblock, + path: []const u8, + buffer: []u8, + offset: u64, + entry_buffer: []u8, + read_buffer: []u8, + ) NexFSError!usize { + // Open file + var handle = try openFile(flash, sb, path, 0, entry_buffer, read_buffer); + defer file_mod.FileOps.close(&handle); + + // Seek to offset + var inode: Inode = undefined; + try inode_mod.InodeTable.load( + flash, + sb.inode_table_start, + sb.block_size, + handle.inode_id, + &inode, + read_buffer, + ); + _ = try file_mod.FileOps.seek(&handle, @intCast(offset), .Set, inode.size); + + // Read + return file_mod.FileOps.read( + flash, + &inode, + &handle, + buffer, + sb.block_size, + read_buffer, + ); + } + + /// Write to a file by path. + pub fn writeFile( + flash: anytype, + sb: *const superblock_mod.Superblock, + path: []const u8, + buffer: []const u8, + offset: u64, + allocator: *alloc_mod.BlockAllocator, + bam_entries: []alloc_mod.bam.BamEntry, + entry_buffer: []u8, + read_buffer: []u8, + write_buffer: []u8, + ) NexFSError!usize { + // Open file for writing + var handle = try openFile(flash, sb, path, 0x1, entry_buffer, read_buffer); + defer file_mod.FileOps.close(&handle); + + // Load inode + var inode: Inode = undefined; + try inode_mod.InodeTable.load( + flash, + sb.inode_table_start, + sb.block_size, + handle.inode_id, + &inode, + read_buffer, + ); + + // Seek to offset + _ = try file_mod.FileOps.seek(&handle, @intCast(offset), .Set, inode.size); + + // Write + return file_mod.FileOps.write( + flash, + &inode, + &handle, + buffer, + sb.block_size, + allocator, + bam_entries, + write_buffer, + ); + } +}; + +const superblock_mod = @import("superblock.zig"); +const file_mod = @import("file.zig"); +const alloc_mod = @import("alloc.zig"); diff --git a/src/nexfs/superblock.zig b/src/nexfs/superblock.zig new file mode 100644 index 0000000..9cb71f4 --- /dev/null +++ b/src/nexfs/superblock.zig @@ -0,0 +1,56 @@ +// NexFS - Superblock on-disk structure +// SPDX-License-Identifier: LSL-1.0 + +const types = @import("types.zig"); +const checksum = @import("checksum.zig"); +const NexFSError = types.NexFSError; +const InodeId = types.InodeId; +const BlockAddr = types.BlockAddr; + +/// NexFS magic number: "NEXF" in ASCII. +pub const NEXFS_MAGIC: u32 = 0x4E455846; + +/// Superblock version. +pub const NEXFS_VERSION: u16 = 1; + +/// Superblock structure — stored at block 0 and block 1 (backup). +/// Size: 128 bytes. +pub const Superblock = extern struct { + magic: u32, + version: u16, + generation: u32, + block_size: u16, + page_size: u16, + block_count: u32, + data_block_count: u32, + root_inode: InodeId, + inode_table_start: BlockAddr, + inode_table_blocks: u32, + bam_start: BlockAddr, + bam_blocks: u32, + create_time: u64, + last_mount_time: u64, + mount_count: u32, + flags: u32, + reserved: u64, + _padding: [44]u8, + checksum: u32, + + /// Validate superblock integrity. + pub fn validate(self: *const Superblock) NexFSError!void { + if (self.magic != NEXFS_MAGIC) return NexFSError.Corruption; + if (self.version != NEXFS_VERSION) return NexFSError.NotSupported; + + const off = @offsetOf(Superblock, "checksum"); + const data = @as([*]const u8, @ptrCast(self))[0..off]; + if (checksum.crc32c(data) != self.checksum) return NexFSError.ChecksumError; + } + + /// Compute and store checksum. + pub fn computeChecksum(self: *Superblock) void { + self.checksum = 0; + const off = @offsetOf(Superblock, "checksum"); + const data = @as([*]const u8, @ptrCast(self))[0..off]; + self.checksum = checksum.crc32c(data); + } +}; diff --git a/src/nexfs/types.zig b/src/nexfs/types.zig new file mode 100644 index 0000000..48409be --- /dev/null +++ b/src/nexfs/types.zig @@ -0,0 +1,70 @@ +// NexFS - Type definitions +// SPDX-License-Identifier: LSL-1.0 + +/// Comprehensive error enumeration for all NexFS operations. +/// No error strings allocated at runtime - use error names for context. +pub const NexFSError = error{ + InvalidConfig, + NotMounted, + AlreadyMounted, + InvalidBlockAddress, + InvalidInodeId, + NotFound, + AlreadyExists, + NoSpace, + ChecksumError, + WriteFailed, + ReadFailed, + EraseFailed, + InvalidPath, + NotEmpty, + NotDirectory, + IsDirectory, + InvalidHandle, + NotSupported, + WouldBlock, + Busy, + Corruption, + BufferTooSmall, +}; + +/// Physical block address on flash media. +pub const BlockAddr = u32; + +/// Inode identifier in the filesystem. +pub const InodeId = u32; + +/// Checksum algorithms supported by NexFS. +pub const ChecksumAlgo = enum(u8) { + None = 0, + CRC16 = 1, + CRC32C = 2, +}; + +/// Flash block size in bytes. Typical values: 512, 1024, 2048, 4096. +pub const BlockSize = u16; + +/// Page size for alignment. Used for metadata alignment on flash. +pub const PageSize = u16; + +/// Open file handle state. +pub const FileHandle = struct { + inode_id: InodeId, + position: u64, + open_flags: u8, +}; + +/// File types for inode. +pub const FileType = enum(u8) { + None = 0, + Regular = 1, + Directory = 2, + Symlink = 3, + CharDevice = 4, + BlockDevice = 5, + Fifo = 6, + Socket = 7, +}; + +/// Maximum filename length. +pub const NEXFS_MAX_NAME_LEN: usize = 255; diff --git a/tests/test_nexfs.zig b/tests/test_nexfs.zig new file mode 100644 index 0000000..af6fa8f --- /dev/null +++ b/tests/test_nexfs.zig @@ -0,0 +1,680 @@ +const std = @import("std"); +const nexfs = @import("nexfs"); + +// ============================================================================ +// Phase 1 Tests +// ============================================================================ + +test "version check" { + try std.testing.expectEqualStrings("0.1.0", nexfs.version); +} + +test "config validation - valid config" { + var read_buf: [4096]u8 = undefined; + var write_buf: [4096]u8 = undefined; + var workspace: [256]u8 = undefined; + + const config = nexfs.Config{ + .flash = undefined, + .device_size = 1024 * 1024, + .block_size = 4096, + .block_count = 256, + .page_size = 256, + .checksum_algo = .CRC32C, + .read_buffer = &read_buf, + .write_buffer = &write_buf, + .workspace = &workspace, + .time_source = null, + .verbose = false, + }; + + try nexfs.validateConfigRuntime(&config); +} + +test "config validation - block size not power of 2" { + var read_buf: [512]u8 = undefined; + var write_buf: [512]u8 = undefined; + var workspace: [64]u8 = undefined; + + const config = nexfs.Config{ + .flash = undefined, + .device_size = 2048, + .block_size = 768, + .block_count = 3, + .page_size = 64, + .checksum_algo = .CRC32C, + .read_buffer = &read_buf, + .write_buffer = &write_buf, + .workspace = &workspace, + .time_source = null, + .verbose = false, + }; + + const err = nexfs.validateConfigRuntime(&config); + try std.testing.expectError(nexfs.NexFSError.InvalidConfig, err); +} + +test "config validation - 2048 byte blocks valid" { + var read_buf: [2048]u8 = undefined; + var write_buf: [2048]u8 = undefined; + var workspace: [256]u8 = undefined; + + const config = nexfs.Config{ + .flash = undefined, + .device_size = 2048, + .block_size = 2048, + .block_count = 1, + .page_size = 256, + .checksum_algo = .CRC32C, + .read_buffer = &read_buf, + .write_buffer = &write_buf, + .workspace = &workspace, + .time_source = null, + .verbose = false, + }; + + try nexfs.validateConfigRuntime(&config); +} + +test "error types exist" { + const err1 = nexfs.NexFSError.InvalidConfig; + const err2 = nexfs.NexFSError.NotMounted; + const err3 = nexfs.NexFSError.AlreadyMounted; + + try std.testing.expect(err1 != err2); + try std.testing.expect(err2 != err3); +} + +test "types - block addr is u32" { + const info = @typeInfo(nexfs.BlockAddr); + try std.testing.expectEqual(32, info.int.bits); +} + +test "types - inode id is u32" { + const info = @typeInfo(nexfs.InodeId); + try std.testing.expectEqual(32, info.int.bits); +} + +test "types - file handle fields" { + const handle = nexfs.FileHandle{ + .inode_id = 42, + .position = 12345, + .open_flags = 0, + }; + try std.testing.expectEqual(42, handle.inode_id); + try std.testing.expectEqual(12345, handle.position); +} + +test "checksum - crc16 non-zero" { + const data = "123456789"; + const crc = nexfs.crc16(data); + try std.testing.expect(crc != 0); +} + +test "checksum - crc32c non-zero" { + const data = "123456789"; + const crc = nexfs.crc32c(data); + try std.testing.expect(crc != 0); +} + +// ============================================================================ +// Phase 2 Tests - On-Disk Format +// ============================================================================ + +test "superblock size check" { + // Superblock should be 128 bytes + try std.testing.expectEqual(128, @sizeOf(nexfs.Superblock)); +} + +test "superblock magic number" { + try std.testing.expectEqual(0x4E455846, nexfs.NEXFS_MAGIC); +} + +test "superblock validation - valid" { + var sb: nexfs.Superblock = .{ + .magic = nexfs.NEXFS_MAGIC, + .version = nexfs.NEXFS_VERSION, + .generation = 0, + .block_size = 4096, + .page_size = 256, + .block_count = 256, + .data_block_count = 250, + .root_inode = 1, + .inode_table_start = 10, + .inode_table_blocks = 4, + .bam_start = 2, + .bam_blocks = 8, + .create_time = 0, + .last_mount_time = 0, + .mount_count = 0, + .flags = 0, + .reserved = 0, + ._padding = .{0} ** 44, + .checksum = 0, + }; + sb.computeChecksum(); + try sb.validate(); +} + +test "superblock validation - bad magic" { + var sb: nexfs.Superblock = .{ + .magic = 0x12345678, // Wrong magic + .version = nexfs.NEXFS_VERSION, + .generation = 0, + .block_size = 4096, + .page_size = 256, + .block_count = 256, + .data_block_count = 250, + .root_inode = 1, + .inode_table_start = 10, + .inode_table_blocks = 4, + .bam_start = 2, + .bam_blocks = 8, + .create_time = 0, + .last_mount_time = 0, + .mount_count = 0, + .flags = 0, + .reserved = 0, + ._padding = .{0} ** 44, + .checksum = 0, + }; + const err = sb.validate(); + try std.testing.expectError(nexfs.NexFSError.Corruption, err); +} + +test "inode size check" { + // Inode is 120 bytes (with padding for alignment) + try std.testing.expectEqual(120, @sizeOf(nexfs.Inode)); +} + +test "inode validation" { + var inode: nexfs.Inode = .{ + .id = 1, + .file_type = .Regular, + .mode = 0o644, + .uid = 0, + .gid = 0, + .size = 0, + .block_count = 0, + .link_count = 1, + .flags = 0, + .atime = 0, + .mtime = 0, + .ctime = 0, + .inline_extent = .{ .logical_block = 0, .physical_block = 0, .length = 0, .reserved = 0 }, + .extent_table = 0, + .extent_count = 0, + .reserved1 = 0, + .reserved2 = 0, + ._padding = .{0} ** 24, + .checksum = 0, + }; + inode.computeChecksum(); + try inode.validate(); +} + +test "inode isValid check" { + var inode: nexfs.Inode = .{ + .id = 1, + .file_type = .Regular, + .mode = 0o644, + .uid = 0, + .gid = 0, + .size = 0, + .block_count = 0, + .link_count = 1, + .flags = 0, + .atime = 0, + .mtime = 0, + .ctime = 0, + .inline_extent = .{ .logical_block = 0, .physical_block = 0, .length = 0, .reserved = 0 }, + .extent_table = 0, + .extent_count = 0, + .reserved1 = 0, + .reserved2 = 0, + ._padding = .{0} ** 24, + .checksum = 0, + }; + try std.testing.expect(inode.isValid()); + + // Invalid inode + var invalid_inode: nexfs.Inode = .{ + .id = 0, + .file_type = .None, + .mode = 0, + .uid = 0, + .gid = 0, + .size = 0, + .block_count = 0, + .link_count = 0, + .flags = 0, + .atime = 0, + .mtime = 0, + .ctime = 0, + .inline_extent = .{ .logical_block = 0, .physical_block = 0, .length = 0, .reserved = 0 }, + .extent_table = 0, + .extent_count = 0, + .reserved1 = 0, + .reserved2 = 0, + ._padding = .{0} ** 24, + .checksum = 0, + }; + try std.testing.expect(!invalid_inode.isValid()); +} + +test "extent size check" { + // Extent should be 16 bytes + try std.testing.expectEqual(16, @sizeOf(nexfs.Extent)); +} + +test "dir entry size check" { + // DirEntry header is 20 bytes (without name) + try std.testing.expectEqual(20, @sizeOf(nexfs.DirEntry)); +} + +test "dir entry size calculation" { + // Entry with name "test" (4 chars) should be rounded up to 8-byte boundary + // DirEntry header is 20 bytes, plus 4 chars + 1 null = 25, rounded up to 32 + const size1 = nexfs.dirEntrySize(4); + try std.testing.expectEqual(32, size1); +} + +test "bam entry size check" { + // BAM Entry should be small + try std.testing.expect(@sizeOf(nexfs.BamEntry) <= 16); +} + +test "file type enum values" { + try std.testing.expectEqual(0, @intFromEnum(nexfs.FileType.None)); + try std.testing.expectEqual(1, @intFromEnum(nexfs.FileType.Regular)); + try std.testing.expectEqual(2, @intFromEnum(nexfs.FileType.Directory)); +} + +test "max name length constant" { + try std.testing.expectEqual(255, nexfs.NEXFS_MAX_NAME_LEN); +} + +test "superblock version constant" { + try std.testing.expectEqual(1, nexfs.NEXFS_VERSION); +} + +// ============================================================================ +// Phase 2 Tests - format() and round-trip verification +// ============================================================================ + +/// Mock flash for format and mount tests. +/// Aligned to 8 bytes so we can safely cast superblock/inode pointers. +const MockFlash = struct { + data: [512 * 32]u8 align(8) = [_]u8{0xFF} ** (512 * 32), + + fn readFn(ctx: *anyopaque, addr: u64, buffer: []u8) nexfs.NexFSError!usize { + const flash: *MockFlash = @ptrCast(@alignCast(ctx)); + const start: usize = @intCast(addr); + if (start + buffer.len > flash.data.len) return nexfs.NexFSError.ReadFailed; + @memcpy(buffer[0..buffer.len], flash.data[start..][0..buffer.len]); + return buffer.len; + } + + fn writeFn(ctx: *anyopaque, addr: u64, buffer: []const u8) nexfs.NexFSError!void { + const flash: *MockFlash = @ptrCast(@alignCast(ctx)); + const start: usize = @intCast(addr); + if (start + buffer.len > flash.data.len) return nexfs.NexFSError.WriteFailed; + for (buffer, 0..) |byte, i| { + flash.data[start + i] = byte; + } + } + + fn eraseFn(ctx: *anyopaque, block_addr: nexfs.BlockAddr) nexfs.NexFSError!void { + _ = block_addr; + _ = ctx; + } + + fn syncFn(ctx: *anyopaque) nexfs.NexFSError!void { + _ = ctx; + } + + fn interface(self: *MockFlash) nexfs.FlashInterface { + return .{ + .ctx = @ptrCast(self), + .read = &readFn, + .write = &writeFn, + .erase = &eraseFn, + .sync = &syncFn, + }; + } +}; + +test "format - writes valid superblock to block 0" { + var mock = MockFlash{}; + var read_buf: [512]u8 = undefined; + var write_buf: [512]u8 = undefined; + var workspace: [64]u8 = undefined; + + const config = nexfs.Config{ + .flash = mock.interface(), + .device_size = 512 * 32, + .block_size = 512, + .block_count = 32, + .page_size = 64, + .checksum_algo = .CRC32C, + .read_buffer = &read_buf, + .write_buffer = &write_buf, + .workspace = &workspace, + .time_source = null, + .verbose = false, + }; + + var fmt_buf: [512]u8 = undefined; + try nexfs.format(&config.flash, &config, &fmt_buf); + + // Read back superblock from block 0 + const sb: *const nexfs.Superblock = @ptrCast(@alignCast(&mock.data[0])); + try std.testing.expectEqual(nexfs.NEXFS_MAGIC, sb.magic); + try std.testing.expectEqual(nexfs.NEXFS_VERSION, sb.version); + try std.testing.expectEqual(@as(nexfs.BlockSize, 512), sb.block_size); + try std.testing.expectEqual(@as(u32, 32), sb.block_count); + try std.testing.expectEqual(@as(nexfs.InodeId, 1), sb.root_inode); + // Validate CRC + try sb.validate(); +} + +test "format - writes backup superblock to block 1" { + var mock = MockFlash{}; + var read_buf: [512]u8 = undefined; + var write_buf: [512]u8 = undefined; + var workspace: [64]u8 = undefined; + + const config = nexfs.Config{ + .flash = mock.interface(), + .device_size = 512 * 32, + .block_size = 512, + .block_count = 32, + .page_size = 64, + .checksum_algo = .CRC32C, + .read_buffer = &read_buf, + .write_buffer = &write_buf, + .workspace = &workspace, + .time_source = null, + .verbose = false, + }; + + var fmt_buf: [512]u8 = undefined; + try nexfs.format(&config.flash, &config, &fmt_buf); + + // Backup superblock at block 1 (offset 512) + const backup: *const nexfs.Superblock = @ptrCast(@alignCast(&mock.data[512])); + try backup.validate(); + // Primary and backup should be identical + const primary = mock.data[0..512]; + const secondary = mock.data[512..1024]; + try std.testing.expectEqualSlices(u8, primary, secondary); +} + +test "format - root inode written to inode table" { + var mock = MockFlash{}; + var read_buf: [512]u8 = undefined; + var write_buf: [512]u8 = undefined; + var workspace: [64]u8 = undefined; + + const config = nexfs.Config{ + .flash = mock.interface(), + .device_size = 512 * 32, + .block_size = 512, + .block_count = 32, + .page_size = 64, + .checksum_algo = .CRC32C, + .read_buffer = &read_buf, + .write_buffer = &write_buf, + .workspace = &workspace, + .time_source = null, + .verbose = false, + }; + + var fmt_buf: [512]u8 = undefined; + try nexfs.format(&config.flash, &config, &fmt_buf); + + // Find inode table start from superblock + const sb: *const nexfs.Superblock = @ptrCast(@alignCast(&mock.data[0])); + const inode_offset = @as(usize, sb.inode_table_start) * 512; + const root_inode: *const nexfs.Inode = @ptrCast(@alignCast(&mock.data[inode_offset])); + + try std.testing.expectEqual(@as(nexfs.InodeId, 1), root_inode.id); + try std.testing.expectEqual(nexfs.FileType.Directory, root_inode.file_type); + try std.testing.expectEqual(@as(u16, 0o755), root_inode.mode); + try std.testing.expectEqual(@as(u16, 2), root_inode.link_count); + try root_inode.validate(); +} + +test "format - buffer too small" { + var mock = MockFlash{}; + var read_buf: [512]u8 = undefined; + var write_buf: [512]u8 = undefined; + var workspace: [64]u8 = undefined; + + const config = nexfs.Config{ + .flash = mock.interface(), + .device_size = 512 * 32, + .block_size = 512, + .block_count = 32, + .page_size = 64, + .checksum_algo = .CRC32C, + .read_buffer = &read_buf, + .write_buffer = &write_buf, + .workspace = &workspace, + .time_source = null, + .verbose = false, + }; + + var tiny_buf: [64]u8 = undefined; + const err = nexfs.format(&config.flash, &config, &tiny_buf); + try std.testing.expectError(nexfs.NexFSError.BufferTooSmall, err); +} + +// ============================================================================ +// Phase 3 Tests - Block Allocator and Inode Operations +// ============================================================================ + +test "block allocator - init" { + const allocator = nexfs.BlockAllocator.init(10, 100, 1); + try std.testing.expectEqual(@as(nexfs.BlockAddr, 10), allocator.data_start); + try std.testing.expectEqual(@as(u32, 100), allocator.data_count); + try std.testing.expectEqual(@as(nexfs.BlockAddr, 10), allocator.next_block); + try std.testing.expectEqual(@as(u32, 1), allocator.generation); +} + +test "block allocator - alloc finds free block" { + var bam_entries: [4]nexfs.BamEntry = .{ + .{ .flags = .{ .allocated = 1, .bad = 0, .reserved = 0, .needs_erase = 0, ._reserved = 0 }, .erase_count = 0, .generation = 0, .reserved = 0, ._reserved1 = 0, ._reserved2 = 0 }, + .{ .flags = .{ .allocated = 0, .bad = 0, .reserved = 0, .needs_erase = 0, ._reserved = 0 }, .erase_count = 0, .generation = 0, .reserved = 0, ._reserved1 = 0, ._reserved2 = 0 }, + .{ .flags = .{ .allocated = 1, .bad = 0, .reserved = 0, .needs_erase = 0, ._reserved = 0 }, .erase_count = 0, .generation = 0, .reserved = 0, ._reserved1 = 0, ._reserved2 = 0 }, + .{ .flags = .{ .allocated = 0, .bad = 0, .reserved = 0, .needs_erase = 0, ._reserved = 0 }, .erase_count = 0, .generation = 0, .reserved = 0, ._reserved1 = 0, ._reserved2 = 0 }, + }; + + var allocator = nexfs.BlockAllocator.init(100, 4, 1); + const block = try allocator.alloc(&bam_entries); + try std.testing.expectEqual(@as(nexfs.BlockAddr, 101), block); +} + +test "block allocator - alloc returns NoSpace when full" { + var bam_entries: [4]nexfs.BamEntry = .{ + .{ .flags = .{ .allocated = 1, .bad = 0, .reserved = 0, .needs_erase = 0, ._reserved = 0 }, .erase_count = 0, .generation = 0, .reserved = 0, ._reserved1 = 0, ._reserved2 = 0 }, + .{ .flags = .{ .allocated = 1, .bad = 0, .reserved = 0, .needs_erase = 0, ._reserved = 0 }, .erase_count = 0, .generation = 0, .reserved = 0, ._reserved1 = 0, ._reserved2 = 0 }, + .{ .flags = .{ .allocated = 1, .bad = 0, .reserved = 0, .needs_erase = 0, ._reserved = 0 }, .erase_count = 0, .generation = 0, .reserved = 0, ._reserved1 = 0, ._reserved2 = 0 }, + .{ .flags = .{ .allocated = 1, .bad = 0, .reserved = 0, .needs_erase = 0, ._reserved = 0 }, .erase_count = 0, .generation = 0, .reserved = 0, ._reserved1 = 0, ._reserved2 = 0 }, + }; + + var allocator = nexfs.BlockAllocator.init(100, 4, 1); + const err = allocator.alloc(&bam_entries); + try std.testing.expectError(nexfs.NexFSError.NoSpace, err); +} + +test "block allocator - markAllocated" { + var bam_entries: [4]nexfs.BamEntry = .{ + .{ .flags = .{ .allocated = 0, .bad = 0, .reserved = 0, .needs_erase = 0, ._reserved = 0 }, .erase_count = 0, .generation = 0, .reserved = 0, ._reserved1 = 0, ._reserved2 = 0 }, + .{ .flags = .{ .allocated = 0, .bad = 0, .reserved = 0, .needs_erase = 0, ._reserved = 0 }, .erase_count = 0, .generation = 0, .reserved = 0, ._reserved1 = 0, ._reserved2 = 0 }, + .{ .flags = .{ .allocated = 0, .bad = 0, .reserved = 0, .needs_erase = 0, ._reserved = 0 }, .erase_count = 0, .generation = 0, .reserved = 0, ._reserved1 = 0, ._reserved2 = 0 }, + .{ .flags = .{ .allocated = 0, .bad = 0, .reserved = 0, .needs_erase = 0, ._reserved = 0 }, .erase_count = 0, .generation = 0, .reserved = 0, ._reserved1 = 0, ._reserved2 = 0 }, + }; + + var allocator = nexfs.BlockAllocator.init(100, 4, 5); + try allocator.markAllocated(&bam_entries, 102); + + try std.testing.expectEqual(@as(u1, 1), bam_entries[2].flags.allocated); + try std.testing.expectEqual(@as(u32, 5), bam_entries[2].generation); +} + +test "block allocator - free marks for erasure" { + var bam_entries: [4]nexfs.BamEntry = .{ + .{ .flags = .{ .allocated = 1, .bad = 0, .reserved = 0, .needs_erase = 0, ._reserved = 0 }, .erase_count = 0, .generation = 0, .reserved = 0, ._reserved1 = 0, ._reserved2 = 0 }, + .{ .flags = .{ .allocated = 1, .bad = 0, .reserved = 0, .needs_erase = 0, ._reserved = 0 }, .erase_count = 0, .generation = 0, .reserved = 0, ._reserved1 = 0, ._reserved2 = 0 }, + .{ .flags = .{ .allocated = 1, .bad = 0, .reserved = 0, .needs_erase = 0, ._reserved = 0 }, .erase_count = 0, .generation = 0, .reserved = 0, ._reserved1 = 0, ._reserved2 = 0 }, + .{ .flags = .{ .allocated = 1, .bad = 0, .reserved = 0, .needs_erase = 0, ._reserved = 0 }, .erase_count = 0, .generation = 0, .reserved = 0, ._reserved1 = 0, ._reserved2 = 0 }, + }; + + var allocator = nexfs.BlockAllocator.init(100, 4, 1); + try allocator.free(&bam_entries, 101); + + try std.testing.expectEqual(@as(u1, 0), bam_entries[1].flags.allocated); + try std.testing.expectEqual(@as(u1, 1), bam_entries[1].flags.needs_erase); + try std.testing.expectEqual(@as(u32, 1), bam_entries[1].erase_count); +} + +test "block allocator - isFree" { + var bam_entries: [4]nexfs.BamEntry = .{ + .{ .flags = .{ .allocated = 1, .bad = 0, .reserved = 0, .needs_erase = 0, ._reserved = 0 }, .erase_count = 0, .generation = 0, .reserved = 0, ._reserved1 = 0, ._reserved2 = 0 }, + .{ .flags = .{ .allocated = 0, .bad = 0, .reserved = 0, .needs_erase = 0, ._reserved = 0 }, .erase_count = 0, .generation = 0, .reserved = 0, ._reserved1 = 0, ._reserved2 = 0 }, + .{ .flags = .{ .allocated = 0, .bad = 1, .reserved = 0, .needs_erase = 0, ._reserved = 0 }, .erase_count = 0, .generation = 0, .reserved = 0, ._reserved1 = 0, ._reserved2 = 0 }, + .{ .flags = .{ .allocated = 0, .bad = 0, .reserved = 1, .needs_erase = 0, ._reserved = 0 }, .erase_count = 0, .generation = 0, .reserved = 0, ._reserved1 = 0, ._reserved2 = 0 }, + }; + + var allocator = nexfs.BlockAllocator.init(100, 4, 1); + try std.testing.expect(!allocator.isFree(&bam_entries, 100)); // allocated + try std.testing.expect(allocator.isFree(&bam_entries, 101)); // free + try std.testing.expect(!allocator.isFree(&bam_entries, 102)); // bad + try std.testing.expect(!allocator.isFree(&bam_entries, 103)); // reserved +} + +test "inode table - alloc creates valid inode" { + var mock = MockFlash{}; + // Pre-erase the flash (set to zeros for empty inodes) + @memset(&mock.data, 0); + + // Use aligned buffer for inode operations + var read_buf: [512]u8 align(8) = undefined; + + const flash = mock.interface(); + var inode: nexfs.Inode = undefined; + + const id = try nexfs.InodeTable.alloc( + &flash, + 10, // inode_table_start + 4, // inode_table_blocks + 512, // block_size + .Regular, + 0o644, + &inode, + &read_buf, + ); + + try std.testing.expectEqual(@as(nexfs.InodeId, 1), id); + try std.testing.expectEqual(nexfs.FileType.Regular, inode.file_type); + try std.testing.expectEqual(@as(u16, 0o644), inode.mode); + try std.testing.expect(inode.isValid()); +} + +test "inode table - write and load roundtrip" { + var mock = MockFlash{}; + @memset(&mock.data, 0); + + var read_buf: [512]u8 align(8) = undefined; + var write_buf: [512]u8 align(8) = undefined; + + const flash = mock.interface(); + + // Create and write an inode + var inode: nexfs.Inode = .{ + .id = 5, + .file_type = .Directory, + .mode = 0o755, + .uid = 1000, + .gid = 1000, + .size = 4096, + .block_count = 8, + .link_count = 2, + .flags = 0, + .atime = 1234567890, + .mtime = 1234567890, + .ctime = 1234567890, + .inline_extent = .{ .logical_block = 0, .physical_block = 100, .length = 8, .reserved = 0 }, + .extent_table = 0, + .extent_count = 0, + .reserved1 = 0, + .reserved2 = 0, + ._padding = .{0} ** 24, + .checksum = 0, + }; + inode.computeChecksum(); + + try nexfs.InodeTable.write(&flash, 10, 512, &inode, &write_buf); + + // Load it back + var loaded: nexfs.Inode = undefined; + try nexfs.InodeTable.load(&flash, 10, 512, 5, &loaded, &read_buf); + + try std.testing.expectEqual(inode.id, loaded.id); + try std.testing.expectEqual(inode.file_type, loaded.file_type); + try std.testing.expectEqual(inode.mode, loaded.mode); + try std.testing.expectEqual(inode.uid, loaded.uid); + try std.testing.expectEqual(inode.size, loaded.size); +} + +test "inode table - delete clears inode" { + var mock = MockFlash{}; + @memset(&mock.data, 0); + + var read_buf: [512]u8 align(8) = undefined; + var write_buf: [512]u8 align(8) = undefined; + + const flash = mock.interface(); + + // Create and write an inode + var inode: nexfs.Inode = .{ + .id = 3, + .file_type = .Regular, + .mode = 0o644, + .uid = 0, + .gid = 0, + .size = 0, + .block_count = 0, + .link_count = 1, + .flags = 0, + .atime = 0, + .mtime = 0, + .ctime = 0, + .inline_extent = .{ .logical_block = 0, .physical_block = 0, .length = 0, .reserved = 0 }, + .extent_table = 0, + .extent_count = 0, + .reserved1 = 0, + .reserved2 = 0, + ._padding = .{0} ** 24, + .checksum = 0, + }; + inode.computeChecksum(); + + try nexfs.InodeTable.write(&flash, 10, 512, &inode, &write_buf); + + // Verify it exists first + var loaded: nexfs.Inode = undefined; + try nexfs.InodeTable.load(&flash, 10, 512, 3, &loaded, &read_buf); + try std.testing.expect(loaded.isValid()); + + // Delete it + try nexfs.InodeTable.delete(&flash, 10, 512, 3, &write_buf); + + // Verify it's cleared by reading raw bytes (id should be 0) + const addr = @as(u64, 10) * 512 + (3 - 1) * @sizeOf(nexfs.Inode); + _ = try flash.read(flash.ctx, addr, read_buf[0..@sizeOf(nexfs.Inode)]); + const cleared: *const nexfs.Inode = @ptrCast(@alignCast(&read_buf)); + try std.testing.expectEqual(@as(nexfs.InodeId, 0), cleared.id); + try std.testing.expect(!cleared.isValid()); +}