diff --git a/CLAUDE.md b/CLAUDE.md index a329d46..6dc08bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,7 @@ Sanitizer builds are not wired into `make test` (they need per-file compilation - `src/vfs.c` — the `Vfs` mount table itself (`MountSnapshot`, refcounted, atomically swapped) and the public API's dispatch-by-longest-prefix-match. - `src/upper.c` — the writable layer shared by `mem`, `dir`, and the overlay's upper side: `UpperSnapshot`/`UpperEntry`/`MutCell`, the structural-write functions (`upper_create`/`upper_mkdir`/`upper_remove`/`upper_rename`/`upper_copy_up`), the content-write fast path (`upper_cell_read`/`upper_cell_write`), and the standalone `mem`/`dir` `Backend` (`backend_mem_new`/`backend_dir_new`). - `src/overlay.c` — composes a `Pack` (lower, read-only) with an `UpperStore` (upper): copy-up, whiteouts, the merged view (`overlay_stat`/`overlay_readdir`), the append journal, and `vfs_sync`'s compaction. -- `src/pack.c` — the on-disk pack format: load-time integrity validation, binary-search lookup/range-query, and the compaction writer (atomic rename, exact-duplicate elimination). +- `src/pack.c` — the on-disk pack format: load-time integrity validation, binary-search lookup/range-query, the compaction writer (atomic rename, exact-duplicate elimination), and the standalone read-only `pack` `Backend` (`backend_pack_new`) for mounting a pack with no writable upper layer at all. - `src/containment.c` — `dir`-mount path containment (`openat2`/`O_NOFOLLOW` fallback) and opt-in Landlock hardening. - `src/path.c` — virtual-namespace `.`/`..` canonicalization. - `src/hash.c` — FNV-1a64, used for pack checksums and compaction dedup. @@ -83,13 +83,15 @@ These are hard requirements stated in the spec, not stylistic suggestions — an ## Self-evaluation -**Methodology.** This file was checked against the repository's actual state (`make test` passing; `include/`, `src/`, `tests/` present and matching the description below; confirmed by directory listing and a live build), against `concept.md` as frozen (for factual consistency of the constraints and architecture summarized above), and against the documentation standard stated in this file. +**Methodology.** This file was checked against the repository's actual state (`make test` passing; `include/`, `src/`, `tests/` present and matching the description below; confirmed by directory listing and a live build), against `concept.md` as frozen (for factual consistency of the constraints and architecture summarized above), and against the documentation standard stated in this file. This revision followed a documentation-completeness audit that cross-checked every function declared in `include/packfs.h` against `nm -D libpackfs.so.0`, which found one real gap (see below) before any prose was written or re-checked. | Category | Grade | Notes | |---|---|---| | Factual accuracy | A | Build/test commands and the code map were verified against a live `make test` run and the actual file layout, not written from memory of intent. | | Adherence to the documentation standard | A | Direct, constraint-labeled prose; no manufactured sections; scaled appropriately to an instructions file rather than imitating `concept.md`'s full academic structure. | -| Completeness for its purpose | A | Covers repository status, build/test/lint commands, a per-file code map, every hard constraint from `concept.md` relevant to implementation work, and the cross-cutting `reclaim_gate` pattern that no single file's comments fully explain on its own. | +| Completeness for its purpose | A | Covers repository status, build/test/lint commands, a per-file code map, every hard constraint from `concept.md` relevant to implementation work, the permanent zip/tar exclusion, and the cross-cutting `reclaim_gate` pattern that no single file's comments fully explain on its own. | | Avoidance of generic or invented content | A | No fabricated "Common Development Tasks" or "Tips" sections; the sanitizer-testing instruction is stated as a requirement precisely because skipping it once already let a real bug through, not as generic advice. | **Overall grade: A.** The file states only what is verifiably true of the repository, the spec, and the implementation; labels constraints by strength; and documents the one architectural pattern (snapshot reclamation via `reclaim_gate`) that spans multiple files and would otherwise have to be rediscovered by reading `vfs.c` and `upper.c` side by side. + +**A real gap this audit found and fixed, not just documented:** `backend_pack_new` was declared in `include/packfs.h` and referenced in this file's own architecture map, but was never implemented in `src/pack.c` — any program calling it would fail at link time. It is now implemented (a standalone, read-only `pack` `Backend`; every mutating operation returns `VFS_ERR_PERM`), covered by a new case in `tests/test_pack_overlay.c`, and verified under `-fsanitize=undefined` per this file's own testing rule. This is recorded here because it is exactly the kind of error "document literally all" is supposed to catch: a documentation pass that describes an unimplemented function accurately is still wrong in a way that matters. diff --git a/README.md b/README.md index 0833d79..40f0112 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,9 @@ # PackFS A statically linked, in-process virtual file system for C. PackFS treats a -shipped file tree as an immutable **pack** image, derives writability from a -`mem` or `dir` upper layer through a copy-on-write overlay, and confines -`zip`/`tar` to import/export roles rather than treating either as a live, -writable store. +shipped file tree as an immutable **pack** image and derives writability +from a `mem` or `dir` upper layer through a copy-on-write overlay. `zip` and +`tar` are not part of this project and never will be — see "Status" below. The full design rationale — why this shape, what alternatives were rejected, and the concurrency, path-containment, and integrity models this @@ -17,9 +16,11 @@ followed from it, not a restatement of the rationale. This is an initial, partial implementation of the spec, not a complete one. -**Implemented and tested:** mount table, `mem`/`dir`/`pack`/overlay backends, -copy-up, whiteouts, compaction, an append journal, path containment, and -pack integrity validation. +**Implemented and tested:** mount table, `mem`/`dir`/`pack`/overlay backends +(including the standalone read-only `pack` backend, `backend_pack_new` — +every mutating call against it returns `VFS_ERR_PERM`), copy-up, whiteouts, +compaction, an append journal, path containment, and pack integrity +validation. **Will never be built, by explicit project decision:** the `zip` (miniz) and `tar` (USTAR) import/export backends. `concept.md` Section 11 recommends @@ -110,6 +111,17 @@ vfs_mount(v, "/data", dir); * ".." and symlink escapes are rejected, not merely discouraged. */ ``` +A shipped pack mounted read-only, no writable layer at all: + +```c +int perr = 0; +Backend *ro = backend_pack_new("assets.pack", &perr); +vfs_mount(v, "/assets", ro); +/* vfs_write, vfs_mkdir, vfs_unlink, vfs_rename, vfs_sync against anything + * under /assets all return VFS_ERR_PERM; there is no upper layer to + * absorb a write into. */ +``` + ## API The public API is [`include/packfs.h`](include/packfs.h); every function and @@ -117,8 +129,12 @@ struct is documented there with a pointer to the `concept.md` section that specifies its behavior. In outline: - `vfs_new` / `vfs_free` — a `Vfs` owns a mount table, nothing else. -- `backend_mem_new` / `backend_dir_new` / `backend_overlay_new` — construct a - backend; `vfs_mount`/`vfs_unmount` attach or detach it at a path prefix. +- `backend_mem_new` / `backend_dir_new` / `backend_pack_new` / + `backend_overlay_new` — construct a backend; `vfs_mount`/`vfs_unmount` + attach or detach it at a path prefix. `backend_pack_new` mounts a pack + read-only, with no writable upper layer at all — every mutating call + against it returns `VFS_ERR_PERM`; wrap the same pack in + `backend_overlay_new` instead when writability is wanted. - `vfs_open` / `vfs_read` / `vfs_write` / `vfs_close` — file I/O. - `vfs_stat` / `vfs_readdir` / `vfs_mkdir` / `vfs_unlink` / `vfs_rename` — metadata and namespace operations. diff --git a/include/packfs.h b/include/packfs.h index 35eb120..7f65bbd 100644 --- a/include/packfs.h +++ b/include/packfs.h @@ -47,27 +47,39 @@ typedef enum { VFS_KIND_DIR = 1 } VfsEntryKind; +/* Result of vfs_stat. For a directory, `size` is always 0 (directories + * carry no byte content) and `mtime` is 0 for one that exists only + * implicitly via a child's path (Section 9.3) rather than an explicit + * directory-marker entry. */ typedef struct VfsStat { pfs_usize size; int64_t mtime; /* seconds since epoch */ VfsEntryKind kind; } VfsStat; +/* One child of a directory, as populated by vfs_readdir. `name` is the + * final path component only (not the full path), NUL-terminated. */ typedef struct VfsDirEntry { char name[256]; VfsEntryKind kind; } VfsDirEntry; +/* Result of vfs_readdir: `count` entries at `entries`, owned by the + * caller until passed to vfs_dir_free. A freshly zeroed VfsDir + * (`{0}`/`memset`) is always safe to pass to vfs_dir_free even if + * vfs_readdir was never called on it or returned an error. */ typedef struct VfsDir { VfsDirEntry *entries; pfs_usize count; } VfsDir; +/* Flags for vfs_open, bitwise-OR'd. VFS_O_RDONLY is 0, i.e. the absence + * of VFS_O_WRONLY/VFS_O_RDWR, matching POSIX's convention. */ #define VFS_O_RDONLY 0x00 #define VFS_O_WRONLY 0x01 #define VFS_O_RDWR 0x02 -#define VFS_O_CREAT 0x04 -#define VFS_O_TRUNC 0x08 +#define VFS_O_CREAT 0x04 /* create the file if it does not already exist */ +#define VFS_O_TRUNC 0x08 /* reset an existing file's content to empty */ /* --- VFS lifecycle -------------------------------------------------- */ @@ -87,7 +99,14 @@ Backend *backend_mem_new(void); /* dir: capability-scoped host directory, containment per Section 6. */ Backend *backend_dir_new(const char *host_path, int *err); -/* pack: read-only image loaded and validated per Section 7. */ +/* + * pack: a shipped tree in one file, read-only, loaded and validated per + * Section 7 (Section 2.1, 3.1). Every mutating operation on a Backend + * returned by this constructor (open with a write flag, mkdir, unlink, + * rename, sync) returns VFS_ERR_PERM rather than being silently absorbed. + * To make a pack writable, wrap it in an overlay via backend_overlay_new + * instead of mounting it directly. + */ Backend *backend_pack_new(const char *pack_path, int *err); /* @@ -115,21 +134,73 @@ void backend_free(Backend *b); * observed mid-change by a concurrent path resolution. */ +/* Mounts `b` at `prefix` (longest-prefix match resolves overlapping + * mounts, Section 2.2). Returns VFS_ERR_INVAL if `prefix` fails + * virtual-namespace canonicalization (Section 6.1), VFS_ERR_EXIST if a + * mount already exists at exactly this prefix, else VFS_OK. */ int vfs_mount(Vfs *v, const char *prefix, Backend *b); + +/* Detaches the backend mounted at exactly `prefix`. Returns + * VFS_ERR_NOMOUNT if nothing is mounted there. The detached Backend is + * not freed — call backend_free explicitly once no other thread can + * still be resolving a path through it. */ int vfs_unmount(Vfs *v, const char *prefix); /* --- File operations -------------------------------------------------- */ +/* Opens `path` per the resolved backend's semantics. VFS_O_CREAT creates + * a new file if none exists; VFS_O_TRUNC resets an existing file's + * content to empty. Returns NULL and sets *err on failure (e.g. + * VFS_ERR_NOENT if the path doesn't exist and VFS_O_CREAT wasn't given, + * VFS_ERR_ISDIR if the path names a directory, VFS_ERR_PERM if the + * backend or the specific path is read-only). */ VfsFile *vfs_open(Vfs *v, const char *path, int flags, int *err); + +/* Reads up to `n` bytes from the file's current position into `buf`, + * advancing that position by the number of bytes actually read. Returns + * the byte count (0 at end of file), or a negative VfsStatus on error. */ pfs_isize vfs_read(VfsFile *f, void *buf, pfs_usize n); + +/* Writes `n` bytes from `buf` at the file's current position, advancing + * that position by the number of bytes actually written; a write past + * the current end of the file extends it. Returns the byte count + * written, or a negative VfsStatus on error (e.g. VFS_ERR_PERM for a + * file opened read-only or served from a read-only backend). */ pfs_isize vfs_write(VfsFile *f, const void *buf, pfs_usize n); + +/* Closes a file handle from vfs_open, releasing every resource it held. + * `f` must not be used again afterward. Always returns VFS_OK. */ int vfs_close(VfsFile *f); +/* Fills `out` with `path`'s size, kind, and mtime. Returns VFS_ERR_NOENT + * if nothing exists at that path in the resolved backend. */ int vfs_stat(Vfs *v, const char *path, VfsStat *out); + +/* Lists the immediate children of the directory at `path` into `out` + * (caller-owned until passed to vfs_dir_free). Returns VFS_ERR_NOENT if + * `path` doesn't exist, VFS_ERR_NOTDIR if it names a file. */ int vfs_readdir(Vfs *v, const char *path, VfsDir *out); + +/* Frees the entries array a prior vfs_readdir populated into `*d` and + * zeroes it; safe to call on an already-freed or zeroed VfsDir. */ void vfs_dir_free(VfsDir *d); + +/* Creates an empty directory at `path` (Section 9.3: this writes an + * explicit, compaction-surviving marker, since an empty directory has no + * file beneath it to imply its existence). Returns VFS_ERR_EXIST if + * `path` already names something. */ int vfs_mkdir(Vfs *v, const char *path); + +/* Removes the file or empty directory at `path`. Returns VFS_ERR_NOENT + * if nothing exists there, VFS_ERR_NOTEMPTY for a non-empty directory, + * VFS_ERR_PERM if the resolved backend is read-only. Against an overlay, + * removing a path that exists in the read-only lower pack writes a + * whiteout rather than failing (Section 4.2). */ int vfs_unlink(Vfs *v, const char *path); + +/* Moves the entry at `from` to `to` within the same mount; both paths + * must resolve to the same backend (VFS_ERR_INVAL otherwise — v0 does + * not support a cross-mount rename). */ int vfs_rename(Vfs *v, const char *from, const char *to); /* diff --git a/src/internal.h b/src/internal.h index 6905c68..f6a77bf 100644 --- a/src/internal.h +++ b/src/internal.h @@ -35,6 +35,9 @@ int pfs_path_under(const char *prefix, const char *path); /* ---- hash.c ---- */ +/* FNV-1a, 64-bit. Not cryptographic — used only for pack integrity + * checksums (Section 7) and compaction's exact-duplicate detection + * (Section 9.2), never where adversarial collision resistance matters. */ uint64_t pfs_fnv1a64(const void *data, size_t len); /* ---- containment.c: capability-scoped dir mounts (Section 6.2) ---- */ @@ -53,9 +56,15 @@ int pfs_dir_capability_open(const char *path, int *err); * is unavailable (Section 6.3). `flags`/`mode` are plain open(2) flags. */ int pfs_dir_openat(int root_fd, const char *rel, int flags, unsigned mode, int *err); + +/* Same containment (openat2/O_NOFOLLOW-fallback) as pfs_dir_openat, applied + * to the parent directory of `rel`, then mkdirat/unlinkat/renameat'd + * against that safely-resolved parent — never a raw path-string syscall. */ int pfs_dir_mkdirat(int root_fd, const char *rel, int *err); -int pfs_dir_unlinkat(int root_fd, const char *rel, int is_dir, int *err); +int pfs_dir_unlinkat(int root_fd, const char *rel, int is_dir, int *err); /* is_dir selects AT_REMOVEDIR */ int pfs_dir_renameat(int root_fd, const char *from, const char *to, int *err); + +/* Contained fstat of `rel` beneath `root_fd`, filling the public VfsStat. */ int pfs_dir_statat(int root_fd, const char *rel, VfsStat *out, int *err); /* @@ -72,15 +81,23 @@ int pfs_landlock_restrict_to(const int *roots, size_t count); #define PFS_MODE_DIR 0x1u /* directory-marker bit, Section 9.3 */ +/* One on-disk index row (Section 9). Offsets are absolute byte offsets + * into the pack file, already bounds-checked by pack_load (Section 7) + * before any entry is trusted; `mode`'s only currently-interpreted bit + * is PFS_MODE_DIR. */ typedef struct PackIndexEntry { - uint64_t name_off; - uint32_t name_len; - uint64_t data_off; - uint64_t size; + uint64_t name_off; /* offset of the name within the strings region */ + uint32_t name_len; /* name length, excluding the NUL terminator */ + uint64_t data_off; /* absolute offset of the blob within the pack file */ + uint64_t size; /* blob length in bytes */ uint32_t mode; int64_t mtime; } PackIndexEntry; +/* An mmap'd, loaded, and Section-7-validated pack file. `entries` and + * `strings_base` point directly into `map` — this is why pack_load + * bounds-checks every offset before returning: everything downstream + * trusts these pointers without re-checking. */ typedef struct Pack { int fd; void *map; @@ -179,24 +196,80 @@ typedef struct UpperStore { char *root_path; /* UPPER_DIR only, for error messages */ } UpperStore; +/* `root_fd` is the UPPER_DIR containment root (Section 6.2); pass -1 for + * UPPER_MEM, which needs none. Starts with one empty UpperSnapshot. */ UpperStore *upper_new(UpperKind kind, int root_fd /* -1 for mem */, const char *root_path); + +/* Releases the current snapshot's reference and, for UPPER_DIR, closes + * root_fd. Every file handle the caller opened against this store must + * already be closed — this does not track or forcibly close them. */ void upper_free(UpperStore *u); +/* Wait-free: returns the current snapshot with the caller's own + * reference counted in (see UpperStore.reclaim_gate above for why this + * is not simply "load the pointer, then atomically increment"). The + * caller must upper_release() it exactly once, even on an error path. */ UpperSnapshot *upper_acquire(UpperStore *u); + +/* Drops one reference; frees the snapshot's entries (and unrefs their + * cells) only when this was the last reference. Safe to call with NULL. */ void upper_release(UpperSnapshot *s); /* Structural + content operations, all against paths already normalized * and relative to the mount (leading "/"). */ + +/* Binary search by full path within an already-acquired snapshot. + * Returns 1 and sets *out on hit, leaving *out untouched on a miss. */ int upper_lookup(UpperSnapshot *s, const char *path, const UpperEntry **out); + +/* True if any live (non-whiteout) entry's name starts with `path` + "/" — + * i.e. `path` is a directory that exists only implicitly, via a child, + * with no explicit entry of its own (Section 9.3). */ int upper_has_children(UpperSnapshot *s, const char *path); + +/* Resets `c`'s content to empty in place (VFS_O_TRUNC on an + * already-existing file) — a content operation, not a structural one, so + * it never touches the snapshot pointer (Section 5.3). */ void upper_cell_truncate(UpperStore *u, MutCell *c, const char *path); + +/* Structural write: installs a new, empty ENTRY_FILE at `path`, or + * (racing another creator) hands back the cell an equivalent concurrent + * call already installed. `truncate_existing` is currently unused — + * truncation of an *existing* entry is a content operation, handled by + * upper_cell_truncate, not by this function. */ int upper_create(UpperStore *u, const char *path, int truncate_existing, MutCell **cell_out, int *err); + +/* Structural write: installs an ENTRY_DIR_MARKER at `path` (Section 9.3). + * Fails with VFS_ERR_EXIST if anything, explicit or implicit, already + * occupies that path. */ int upper_mkdir(UpperStore *u, const char *path, int *err); + int upper_remove(UpperStore *u, const char *path, int had_lower, int *err); /* whiteout if had_lower */ + +/* Structural write: moves the entry at `from` to `to` (same cell, + * new name — no data copy for a file already in the upper layer). + * `had_lower_from` selects whiteout-vs-plain-removal for `from`, exactly + * as `upper_remove`'s parameter of the same name does. */ int upper_rename(UpperStore *u, const char *from, const char *to, int had_lower_from, int *err); + +/* Structural write: installs a new ENTRY_FILE at `path` pre-populated + * with `data`/`size` (Section 4.1 step 3's copy-up, and journal replay's + * PUT records, both reuse this rather than duplicating "create then + * write"). Racing with another copy-up of the same path is handled the + * same way as upper_create. */ int upper_copy_up(UpperStore *u, const char *path, const void *data, uint64_t size, int64_t mtime, MutCell **cell_out, int *err); +/* Content read: never touches the snapshot pointer or writer_lock. + * UPPER_MEM copies out of `c`'s buffer under buf_lock's read side + * (Section 5.7); UPPER_DIR opens `path` beneath the containment root and + * pread(2)s it. Returns the byte count read (0 at end of file, per + * vfs_read's contract) or -1 on error. */ pfs_isize upper_cell_read(UpperStore *u, MutCell *c, const char *path, pfs_usize off, void *buf, pfs_usize n); + +/* Content write: the Section 5.3 fast path. Never touches the snapshot + * pointer; a growing UPPER_MEM write follows Section 5.7 (allocate new, + * copy, publish, retire old — never realloc the live buffer in place). + * Returns the byte count written, or -1 with *err set. */ pfs_isize upper_cell_write(UpperStore *u, MutCell *c, const char *path, pfs_usize off, const void *buf, pfs_usize n, int *err); /* Snapshot the whole store into pack-builder entries (compaction, dir-marker-aware). */ diff --git a/src/pack.c b/src/pack.c index 40fe03b..a998c3f 100644 --- a/src/pack.c +++ b/src/pack.c @@ -302,3 +302,140 @@ int pack_write(const char *path, const PackBuildEntry *in, size_t count, int *er if (rename(tmp_path, path) < 0) { unlink(tmp_path); if (err) *err = VFS_ERR_IO; return -1; } return 0; } + +/* + * ---- standalone read-only pack Backend (Section 2.1, 3.1) ---- + * + * `backend_overlay_new` is how a pack is normally made writable (Sections + * 4-5), but Section 2.1's architecture diagram lists `pack` as its own + * backend kind too — "one file, indexed, read-only" — for the case where + * no writable upper layer is wanted at all, e.g. mounting a shipped asset + * pack somewhere a program never intends to write. This wraps the same + * `Pack` load/lookup/range machinery used by the overlay as a plain, + * read-only Backend: every mutating operation returns VFS_ERR_PERM rather + * than being silently absorbed. + */ + +typedef struct PackFile { + const Pack *pack; + const PackIndexEntry *entry; + pfs_usize pos; +} PackFile; + +static int packbe_open(Backend *b, const char *path, int flags, VfsFile **out) { + Pack *p = (Pack *)b->state; + if (flags & (VFS_O_WRONLY | VFS_O_RDWR | VFS_O_CREAT | VFS_O_TRUNC)) return VFS_ERR_PERM; + + const PackIndexEntry *e; + if (!pack_find(p, path, &e)) return VFS_ERR_NOENT; + if (e->mode & PFS_MODE_DIR) return VFS_ERR_ISDIR; + + PackFile *pf = (PackFile *)calloc(1, sizeof(PackFile)); + pf->pack = p; + pf->entry = e; + VfsFile *f = (VfsFile *)calloc(1, sizeof(VfsFile)); + f->backend = b; + f->state = pf; + *out = f; + return VFS_OK; +} + +static pfs_isize packbe_read(VfsFile *f, void *buf, pfs_usize n) { + PackFile *pf = (PackFile *)f->state; + pfs_usize size = pf->entry->size; + pfs_usize avail = pf->pos < size ? size - pf->pos : 0; + pfs_usize to_copy = n < avail ? n : avail; + if (to_copy) memcpy(buf, (const char *)pack_entry_data(pf->pack, pf->entry) + pf->pos, to_copy); + pf->pos += to_copy; + return (pfs_isize)to_copy; +} + +static pfs_isize packbe_write(VfsFile *f, const void *buf, pfs_usize n) { + (void)f; (void)buf; (void)n; + return VFS_ERR_PERM; +} + +static int packbe_close(VfsFile *f) { + free(f->state); + free(f); + return VFS_OK; +} + +static int packbe_stat(Backend *b, const char *path, VfsStat *out) { + Pack *p = (Pack *)b->state; + const PackIndexEntry *e; + if (pack_find(p, path, &e)) { + out->size = e->size; + out->mtime = e->mtime; + out->kind = (e->mode & PFS_MODE_DIR) ? VFS_KIND_DIR : VFS_KIND_FILE; + return VFS_OK; + } + char prefix[PFS_PATH_MAX]; + if (strcmp(path, "/") == 0) strcpy(prefix, "/"); else snprintf(prefix, sizeof(prefix), "%s/", path); + size_t lo, hi; + pack_range(p, prefix, &lo, &hi); + if (hi > lo || strcmp(path, "/") == 0) { + out->size = 0; out->mtime = 0; out->kind = VFS_KIND_DIR; + return VFS_OK; + } + return VFS_ERR_NOENT; +} + +static int packbe_readdir(Backend *b, const char *path, VfsDir *out) { + Pack *p = (Pack *)b->state; + char prefix[PFS_PATH_MAX]; + if (strcmp(path, "/") == 0) strcpy(prefix, "/"); else snprintf(prefix, sizeof(prefix), "%s/", path); + size_t plen = strlen(prefix); + + size_t lo, hi; + pack_range(p, prefix, &lo, &hi); + if (lo == hi && strcmp(path, "/") != 0) { + const PackIndexEntry *e; + if (!pack_find(p, path, &e)) return VFS_ERR_NOENT; + if (!(e->mode & PFS_MODE_DIR)) return VFS_ERR_NOTDIR; + } + + VfsDirEntry *entries = NULL; + pfs_usize count = 0, cap = 0; + for (size_t i = lo; i < hi; i++) { + const char *name = pack_entry_name(p, &p->entries[i]); + const char *restp = name + plen; + const char *slash = strchr(restp, '/'); + size_t clen = slash ? (size_t)(slash - restp) : strlen(restp); + if (clen == 0 || clen >= sizeof(entries[0].name)) continue; + if (count > 0 && strncmp(entries[count - 1].name, restp, clen) == 0 && + entries[count - 1].name[clen] == '\0') continue; /* sorted -> dup is adjacent */ + if (count == cap) { cap = cap ? cap * 2 : 8; entries = (VfsDirEntry *)realloc(entries, cap * sizeof(VfsDirEntry)); } + memcpy(entries[count].name, restp, clen); + entries[count].name[clen] = '\0'; + entries[count].kind = slash ? VFS_KIND_DIR : ((p->entries[i].mode & PFS_MODE_DIR) ? VFS_KIND_DIR : VFS_KIND_FILE); + count++; + } + out->entries = entries; + out->count = count; + return VFS_OK; +} + +static int packbe_mkdir(Backend *b, const char *path) { (void)b; (void)path; return VFS_ERR_PERM; } +static int packbe_unlink(Backend *b, const char *path) { (void)b; (void)path; return VFS_ERR_PERM; } +static int packbe_rename(Backend *b, const char *from, const char *to) { (void)b; (void)from; (void)to; return VFS_ERR_PERM; } +static int packbe_sync(Backend *b) { (void)b; return VFS_ERR_PERM; } + +static void packbe_free(Backend *b) { + pack_close((Pack *)b->state); + free(b); +} + +static const BackendOps PACK_OPS = { + packbe_open, packbe_read, packbe_write, packbe_close, packbe_stat, + packbe_readdir, packbe_mkdir, packbe_unlink, packbe_rename, packbe_sync, packbe_free +}; + +Backend *backend_pack_new(const char *pack_path, int *err) { + Pack *p; + if (pack_load(pack_path, &p, err) < 0) return NULL; + Backend *b = (Backend *)calloc(1, sizeof(Backend)); + b->ops = &PACK_OPS; + b->state = p; + return b; +} diff --git a/tests/test_pack_overlay.c b/tests/test_pack_overlay.c index ac27ee0..32e5612 100644 --- a/tests/test_pack_overlay.c +++ b/tests/test_pack_overlay.c @@ -100,6 +100,37 @@ int main(void) { backend_free(mem2); vfs_free(v2); + /* --- standalone read-only pack backend (Section 2.1/3.1), no overlay --- */ + Vfs *v4 = vfs_new(); + int perr = 0; + Backend *ro = backend_pack_new(pack_path, &perr); + CHECK(ro != NULL); + CHECK_EQ_INT(vfs_mount(v4, "/", ro), VFS_OK); + + f = vfs_open(v4, "/a.txt", VFS_O_RDONLY, &err); + CHECK(f != NULL); + memset(buf, 0, sizeof(buf)); + CHECK_EQ_INT(vfs_read(f, buf, sizeof(buf)), 4); + CHECK_STR_EQ(buf, "ZZZZ"); + CHECK_EQ_INT(vfs_close(f), VFS_OK); + + /* read-only: every mutation is rejected, not silently absorbed */ + CHECK_EQ_INT(vfs_mkdir(v4, "/nope"), VFS_ERR_PERM); + CHECK_EQ_INT(vfs_unlink(v4, "/a.txt"), VFS_ERR_PERM); + f = vfs_open(v4, "/new.txt", VFS_O_WRONLY | VFS_O_CREAT, &err); + CHECK(f == NULL); + CHECK_EQ_INT(err, VFS_ERR_PERM); + CHECK_EQ_INT(vfs_sync(v4, "/"), VFS_ERR_PERM); + + VfsDir rdir; + CHECK_EQ_INT(vfs_readdir(v4, "/", &rdir), VFS_OK); + CHECK(rdir.count > 0); + vfs_dir_free(&rdir); + + vfs_unmount(v4, "/"); + backend_free(ro); + vfs_free(v4); + /* --- pack integrity validation (Section 7): a corrupted pack is rejected --- */ int fd = open(pack_path, O_RDWR); CHECK(fd >= 0);