Files
libglacier-ng/CLAUDE.md
T
2026-10-05 14:47:58 -04:00

449 lines
22 KiB
Markdown

# CLAUDE.md
Context for Claude Code sessions on libglacier-ng: the package manager
for Everest Linux. Kept as a handoff, so a fresh session starts with full
continuity instead of re-deriving everything. Last full rewrite:
2026-10-04, at the end of a long session. **Update it as you go.**
## Start here
1. `ROADMAP.txt`: what's left, in priority order, plus the decisions that
are still open (section 6). Pick work from it.
2. This file: how things work, what the user wants, how to test.
3. The design docs, which are authoritative for their topics and not
repeated here:
- `SECURITY_DESIGN.txt`: integrity verification, scope separation,
refusal matrix, syspkg's privilege model.
- `PACKAGE_SCOPES.txt`: layout, link routing, `/etc` config handling.
- `IMAGE_DESIGN.txt`: PROPOSAL for system images (bootstrap OUTPUT,
the sysroot library change). Open decisions are in section 9.
- `GLACIER_BOOTSTRAP.txt`: PLAN for `glacier-bootstrap`. Prerequisites
are in section 7, decisions in section 9 (9c and 9d are decided).
- `SECURITY_REVIEW_2026-07-16.md`: older review findings.
Nothing from the 2026-10-04 session is committed. libglacier-ng is on
branch `dev` with a large uncommitted diff (`src/`, the docs). Commit only
when the user asks.
## The three projects
| Path | What | Git? |
|---|---|---|
| `~/Projects/libglacier-ng` | C library, design docs, this file | yes (`dev`) |
| `~/Projects/glacier-ng` | Frontend: `gpkg`, `gstore`, `syspkg`, `gbuild`, `gworld`, `lib/*.lua`, `tests/`, Dockerfiles, `pkgs/` | **no**: back files up before editing (`file.old`; if that exists, `.old2`, `.old3`, ...) |
| `~/Projects/everest` | The distro build: `everest.sh build\|initramfs\|boot`, packages, bootable QEMU image | no |
`glacier-ng/lib/glacier/` holds `.so`s built on the host (glibc). They
won't load on musl. Every Alpine image and test builds libglacier-ng from
source instead.
## What this project is
A from-scratch, security-focused Linux distribution (Everest) with its own
package manager (libglacier-ng + `gpkg`/`gstore`/`syspkg`) and build tooling
(`gbuild`/`gworld`). It's a C library, LuaJIT+FFI CLI tools and bash build
scripts. The user writes it; Claude implements and designs alongside them.
## How the user wants things done
These are preferences stated in sessions. Follow them.
- **Output format:** every message uses glacier's `lg_printf` levels:
`[x]` is error (2), `[!]` warning (1), `[i]` info (0), `[✓]` success (3).
Messages are capitalized. Exact strings the user chose include
`Cannot open system index: Permission denied` (no period) and
`Nothing to do.` (success level).
- The pre-trust-check `die()` in syspkg/gstore reproduces the `[x]`
format byte for byte, because it can't load `libglacier_log.so`
before that file has been verified.
- **No hint messages.** Don't print lines that point users to other flags
or commands (for example "use -lO to ..."). The user called these
clutter. A refusal states the fact, in one line. The user also removed a
`Run doas gstore -n ...` follow-up hint from gstore themselves.
- **Ask before settling a design choice that is the user's to make**, and
give a recommendation. They have answered every such question promptly.
Don't silently pick a layout or semantics.
- **Verify by running, in Docker or QEMU, never by inspection alone.** For
any security fix, also prove the test catches the bug: remove the
defence in a throwaway copy and confirm the test fails ("mutation
check"). Several defences here are verified that way.
- Use doas, not sudo. syspkg is never setuid.
## Decisions the user has made (don't relitigate)
- **Per-uid user layout** (`/glacier/usr/<uid>/...`, root-owned parent).
Both root and the user can create it: root runs `gstore -n UID`; a user
runs `doas gstore -n` through a doas rule pinned to `args -n`
(option "B").
- **`syspkg -n` creates the top-level tree itself** (GLACIER_BOOTSTRAP 9d).
- **`/etc` config files: option 1.** Copy on install, `.new` on conflict
(GLACIER_BOOTSTRAP 9c).
- **`-O` with `-l` = replace.** `-O` displaces non-glacier files, and
`-lO` also replaces an installed *different version* (upgrade or
downgrade from a local file). Plain `-l` with another version installed
refuses in one line.
- **`KEEP_OLD`** in `/etc/glacier/glacier.conf`: the number of backups of
replaced versions to keep per package (0 = none).
- It has **no default.** It ships as `KEEP_OLD = 1`.
- If it's missing or invalid, an operation that would replace a version
errors. Only then: other operations don't need it.
- "Backed up" means the old package's files are compressed into a
`.gpkg`. A restore interface comes later.
- **`gpkg -u` / `syspkg -u` upgrade from a REMOTE repository**, and
`gpkg -f` is `-l` but fetched by name from a remote repository. Both wait
for remote repos + signing (ROADMAP 3.1). Don't implement them against
local files.
- `everest-bootscripts` is the name of the base `/etc` package. Its source
lives in `~/Projects/everest/pkgsrc/`.
## Core architecture
**`gl_context_t`** (transaction.h) is the central abstraction. Fields:
`uid`, `root_path`, `index_path`, `store_path`, `links_path`, `mode`
(`GL_CTX_LIVE`/`GL_CTX_STAGE`), `scope` (`GL_SCOPE_USR`/`GL_SCOPE_SYS`),
`lock_fd`, and `overwrite` (last field, zeroed by the init functions).
**Layout.** Each scope owns ONE base directory holding
`{index,store,links,stage,old,lock,backup}`. Every create, rename and lock
a transaction does stays inside it.
| Scope | Base dir | Ownership |
|---|---|---|
| `GL_SCOPE_USR` | `/glacier/usr/<uid>/` | uid, mode 0700, inside a root-owned 0755 `/glacier/usr` |
| `GL_SCOPE_SYS` | `/glacier/sys/` | root |
- The helpers `gl_usr_base`, `gl_usr_base_check` and the layout comment
are in `common.h`.
- `gl_init_stage_context` refuses a user base that isn't a real directory
(lstat) owned by its uid with no group/other bits, or whose
`/glacier/usr` is loosened. `gl_usr_base_status` exports that check, so
gpkg can say why before any prompt.
**Transaction lifecycle.** `gl_init_live_context` / `gl_init_stage_context`
→ `gl_commit_transaction` or `gl_abort_transaction`.
- Stage init takes the lock FIRST, then clears any leftover stage.
- Stage contexts hardlink-seed `index`/`store` from live, so removing a
live package inside a transaction needs no special casing. Seeding
copies dotfiles: only `.` and `..` are skipped.
- A stage on another filesystem fails at seeding with `GL_TXN_ERR_XDEV`.
- **Commit:** rename-based promotion (stage → live, previous live → `old/`),
then fsync the base. Then `gl_relink_store` + `gl_rebuild_index`
regenerate links and index from a *fresh live context* and rescan the
WHOLE store (every commit relinks every package, so link-time logic must
be idempotent). `old/` is then deleted.
**Link routing** (per package, in `gl_link_pkg`):
| Scope | Repo | Destination |
|---|---|---|
| USR | any | `<base>/links` |
| SYS | `base` | `/usr` |
| SYS | anything else | `/glacier/sys/links` |
- A package's leading `usr/` is stripped.
- In system scope, a package's top-level `etc/` is never linked: it's
COPIED into `/etc` (see `/etc` config handling below).
**Repos.** `base` (the minimal system; boot-critical by definition, a
repo-level rule), `extra`, `community`. `extra` and `community` behave
identically.
**Hidden files in the store** (dot-prefixed, so linking, index rebuild and
the store scanners skip them, while seeding carries them through
transactions):
| File | Purpose |
|---|---|
| `<ver>/.glacier-manifest` | the package's manifest, saved at install, used by `gl_backup_pkg` |
| `<ver>/.glacier-fresh` | one-shot "installed in this transaction"; link conflicts are only reported when present |
| `<ver>/.glacier-overwrite` | one-shot `-O` permission for this package's next link pass |
| `<store>/.displaced/<abs path>` | originals displaced by `-O`, restored when the link that replaced them is pruned |
| `<store>/.etc-state/<path>` | the last version of each `/etc` file a package offered |
**Configuration.** `/etc/glacier/glacier.conf` (libconfig syntax).
- It's read with `gl_conf_get_int(path, key, &out)` in `pkg.c`. Missing
file, missing key and wrong type are distinct statuses, and there are no
defaults.
- The shipped copy is `glacier-ng/etc/glacier/glacier.conf`. Every
Dockerfile installs it.
## Safety invariants: read before touching links/symlink code
`/usr` is NOT glacier-exclusive territory. Treat every link destination as
possibly shared:
- **Ownership test.** `is_glacier_symlink(path, store_prefix)` is the only
test for "did glacier create this". Never touch anything it returns
false for, except through `-O`'s displace/restore.
- **Overwriting.** `gl_link_pkg` won't overwrite a non-glacier path without
the `-O` marker. It never descends through *another package's* directory
symlink into that package's store directory.
- **Pruning.** `gl_relink_store` PRUNES stale links; it never wipes a links
destination. A link is stale only if its own store target is gone
(checked with `lstat`, not by following it): a package symlink may
legitimately dangle.
- **Stage links.** The stage links tree is never seeded.
- **Extraction never writes through a symlink.**
- Symlink entries are created only after all regular content.
- Any entry whose parent is a symlink is refused (`crosses_symlink`,
plus `O_NOFOLLOW`).
- Any entry with `..` or an absolute path refuses the whole package.
- A failed extraction removes its half-made version directory.
- **Recursive deletes.** `gl_rmdir_recursive` skips only `.`/`..`. Every
caller passes a glacier-only path; keep it that way. `gl_delete_user`
lstat-checks its target first, because a symlinked base would otherwise
have its *target* emptied.
- **Wide wrappers.** Don't add convenience wrappers that silently touch a
broader filesystem scope than asked. This codebase has been bitten twice
by exactly that.
## Feature reference (current behaviour)
**Integrity verification** (`gpkg_common.lua`: `verify_and_copy`,
`verify_all`):
- Each package is copied into a private `mktemp -d` directory while being
hashed with `libglacier_verify.so`. The result is checked against
`FILE.sha256` (`sha256:<hex>`).
- Everything after that (summary, manifest read, `gl_install_pkg`) reads
ONLY the copy.
- A missing or malformed sidecar counts as a mismatch and is refused before
the prompt; one bad package refuses the whole batch. No bypass exists.
- This lives in the CLIs' shared module, not the library (as
SECURITY_DESIGN section 4 specifies), so a third-party caller of
`gl_install_pkg` is not verified (ROADMAP 3.2).
- The base→USR refusal (`GL_INSTALL_ERR_SCOPE = 7`), by contrast, *is* in
the library.
**`-O` / displace:**
- A displaced foreign file or symlink moves to `<store>/.displaced/`:
renamed, or on `EXDEV` copied (keeping mode, owner and mtime) and then
unlinked.
- Directories, special files and paths that already have a backup are
still refused.
- In user scope `-O` is effectively a no-op, because the links tree is
rebuilt from scratch on every commit.
**Version replacement** (`-lO`, `M.install` in `gpkg_common.lua`):
1. Installed entries are read from the live index's `current` file
(`repo::name::version` lines).
2. A same name+repo with a different version is a replacement. Without
`-O` it's refused; with `-O`, `keep_old()` reads `KEEP_OLD`, and the
summary shows `old -> new`.
3. In the transaction: `gl_backup_pkg(live, repo, name, X.gpkg.part)`, then
`gl_remove_pkg` from the stage, then install.
4. After the commit: rename `.part` → `<scope>/backup/<repo>/<name>/<name>-<ver>.gpkg`,
write its sidecar, and run `gl_prune_backups(dir, KEEP_OLD)` (newest by
nanosecond mtime).
- On any abort, `finish()` deletes the `.part`.
- A backup is a complete installable `.gpkg`, so `-lO backup.gpkg`
restores it.
**`/etc` config handling** (`install_config` / `prune_config` in
istoreutils.c), system scope only:
| Situation | Result |
|---|---|
| `/etc/F` missing, no record | install |
| missing, record exists | the admin deleted it: stays deleted |
| unchanged since glacier wrote it | updated in place |
| admin-modified, or never glacier's | never touched; a package change is offered ONCE as `F.new` |
| no package ships it any more | removed if unchanged, kept otherwise |
User-scope `etc/` is still linked (ROADMAP 2.9).
**Top-level tree.** `gl_create_fs_tree(root, verbose)` (exported) creates
the merged-/usr tree: `/bin` and `/sbin` → `usr/bin`, `/lib` → `usr/lib`,
`/usr/sbin` → `bin`, all RELATIVE links. It also creates `/usr/{bin,lib}`,
`/etc`, `/dev`, `/proc`, `/sys`, `/run`, `/tmp` and `/var/tmp` (1777),
`/var`, `/root` (0700) and `/home`.
- It creates only what's missing, of any type.
- `gl_init_sys` runs it before writing the index. The index marks `-n` as
complete, so a second `syspkg -n` is refused: `Cannot create
/glacier/sys: already exists`.
- It takes a root prefix, ready for `syspkg --root`.
**Package symlinks** are extracted verbatim and linked like files.
Hardlink, device and fifo entries are skipped. `add_to_archive` (used by
backups and `gstore -b`) archives symlinks.
## CLIs (glacier-ng)
| Tool | Flags | Runs as |
|---|---|---|
| `gpkg` | `-l FILE...` `-x NAME...` `-O` `-S` `-V` `-h` `-v`; `-f`/`-u` are stubs | the user; per-user scope only, NEVER constructs `GL_SCOPE_SYS` |
| `syspkg` | `-n` `-l FILE...` `-x NAME...` `-O` `-S` `-V` `-h` `-v` | root via doas; `-h`/`-v` without root |
| `gstore` | `-n [UID]` `-b UID OUTFILE` `-x UID` `-s` (stub) `-V` `-h` `-v` | `-n` needs root (direct, or `doas gstore -n` for yourself) |
- **gpkg:** modifiers `-O`/`-S`/`-V` are applied in a pre-pass, so their
position doesn't matter (`-lO` == `-Ol`). It finds its user via
`/proc/self/loginuid`, falling back to `getuid()`.
- **syspkg, `-n`:** creates the tree plus `/glacier/sys`.
- **syspkg as non-root:** `-n`/`-l`/`-x` are refused after argument
parsing with `Cannot open system index: Permission denied`.
- **gstore, `-n` without a UID, under doas:** acts for the invoking user
(loginuid, then `DOAS_USER`, then getuid). The doas rule
`permit nopass :wheel as root cmd /path/gstore args -n` is the
boundary that stops a user naming another UID.
- **Shared code:** `lib/gpkg_common.lua` (`M.install`, `M.remove`,
verification, replacement, backups).
- **The `-x` summary** comes from the live index; a name that isn't
installed fails before the prompt.
- **Hardened loading** (syspkg and gstore): `#!/usr/bin/luajit -E`,
`package.path` and every `ffi.load` pinned to their own `realpath`, and
when euid is 0, every ancestor directory and every loaded `.lua`/`.so`
must be root-owned and not group/world-writable. The preamble is
DUPLICATED in both files on purpose: change one, change both.
- **gpkg** uses the older CWD-relative convention (`require("lib/...")`,
`ffi.load("./lib/glacier/...")`): run it from its own directory.
- **Declarations:** `lib/glacier_cdef.lua` is the single source of truth
for every `ffi.cdef`. `tests/glacier_ffi.lua` is only a wrapper around
it. Declaration ORDER matters: a declaration using `gl_context_t` must
come after its typedef.
## Build tooling
- **gbuild** (bash, the only copy is `glacier-ng/gbuild`):
- `-n -V -g -c -d -m -D -C -b -i -o -p -j -F -t -x -u -r -s`;
`-v`/`--version` prints the version, `--verbose` turns on verbose
output.
- Autodetects the build system, with autotools checked BEFORE a bare
Makefile.
- Writes `NAME-VER.gpkg` plus a `.sha256` sidecar, using `gsha256sum`
from `$GSHA256SUM_BIN`, else `<script dir>/build/bin`, else PATH.
- **There is NO `-P` system-profile support, and gworld has no
`SYS_PROFILE` field** (ROADMAP 4.1). The target libc is decided by
where gbuild runs: Alpine for musl.
- **gworld** (Lua): batch orchestrator, reads `recipes/*.recipe`, sorts
topologically by `DEPS`, runs gbuild per package.
- **`~/Projects/everest`:** the gbuild command for each package lives in
`scripts/container.sh`; `everest.sh` runs it inside an Alpine image
(`Dockerfile.build`).
- **Kernel version:** linux `6.12.112`; plain `v6.12` doesn't build with
GCC 15.
## libglacier-ng build
- **Separate `.so` per module** (`libglacier_log`, `_pkg`, `_istoreutils`,
`_transaction`, `_dag`, `_verify`). This split is deliberate: a
third-party program links only what it needs.
- **The combined `libglacier-ng.so`** (`make libglacier-ng`) exists only
for the FFI tests.
- **`libglacier_verify.so`** is private: no public header.
- **Warnings:** `config.mk` builds with `-Werror`. `-Wno-format-truncation`
is set because that warning is noise; `-Wstringop-truncation` stays
fatal.
- **Makefile quirk:** `make log` builds only `log.o`; the shared object
comes from `make log_test` (or plain `make`).
## Testing
Run everything in throwaway containers. Several suites write to `/usr`,
`/etc` and `/glacier`.
**FFI suite** (`glacier-ng/tests/*.lua`; 8 files, all passing):
```sh
docker run --rm --cap-add SYS_ADMIN -v ~/Projects/glacier-ng:/src:ro \
-v ~/Projects/libglacier-ng:/libsrc:ro alpine:latest sh -c '
apk add --no-cache build-base luajit luajit-dev libarchive-dev libconfig-dev linux-headers tar >/dev/null
cp -a /libsrc /build && cd /build && make clean && make && make libglacier-ng
cp -a /src /g && cd /g/tests && GLACIER_TEST_CONFIRM=yes \
LIBGLACIER_SO=/build/build/lib/shared/libglacier-ng.so luajit run_all.lua'
```
GNU `tar` is required: the hostile-archive fixtures use `tar --append`.
**Shell suites** (`glacier-ng/tests/*.sh`):
| Suite | Image |
|---|---|
| `syspkg_alpine.sh`, `overwrite_alpine.sh` | `alpine:latest` with `/src`, `/libsrc` and `/t.sh` mounted (see each header); `overwrite_alpine.sh` runs both as-is and with `--tmpfs /glacier:exec` |
| `verify_alpine.sh`, `user_layout_alpine.sh`, `upgrade_alpine.sh` | `glacier-alpine_root` |
Build the `glacier-alpine_root` image with:
```sh
cd ~/Projects/glacier-ng && docker build -f Dockerfile.alpine_root \
--build-context libglacier=../libglacier-ng -t glacier-alpine_root .
docker run --rm -v "$PWD/tests/NAME.sh:/t.sh:ro" glacier-alpine_root sh /t.sh
```
**Interactive images:** `./run_docker.sh Dockerfile.{alpine,alpine_root,arch,debian}`.
`run_docker.sh` passes the `libglacier` build context. `alpine`, `arch`
and `debian` default to user `glacier` (uid 1000, in wheel); `alpine_root`
defaults to root. All of them have:
- root-owned `/app`;
- no `/glacier` until `gstore -n` / `syspkg -n`;
- the doas rule plus `permit nopass glacier`;
- `glacier.conf` installed.
**Boot test:** `cd ~/Projects/everest && ./everest.sh boot` boots QEMU
(KVM). Press Enter for a root shell; `poweroff -f` to leave.
## Bug history: so these don't get reintroduced
All fixed in the current tree.
1. `gl_abort_transaction` never released the lock (wrong variable). Now
`open(O_CREAT)` + `flock()`, and lock files are never unlinked.
2. No `EXDEV` check before renaming. Commit `stat()`s both sides and
returns `GL_TXN_ERR_XDEV`.
3. Segfault on install: the `transaction` target didn't link
`istoreutils`/`log`.
4. Stale links post-commit: `gl_link_pkg` ran during staged installs,
embedding stage paths. It now runs only for LIVE contexts, and links
regenerate post-commit.
5. Dangling links were never cleaned on removal → `prune_stale_links`.
6. `GL_TXN_ERR_RENAME` after the first commit: a leftover `old/`. It's now
wiped first.
7. `-Wstringop-truncation`: `strncpy(..., PATH_MAX - 1)` could leave
strings unterminated. Use `snprintf(dst, sizeof dst, "%s", ...)`.
8. gbuild detected a bare `Makefile` before `configure`. The fix had been
lost from the tree and was re-applied 2026-10-04.
9. The index had no version field; it's now `repo::pkgname::version`.
10. [The code this was about is NOT in the tree: ROADMAP 4.1.] A recipe
field silently never parsed and fell back to a plausible default.
Remember the class.
11. `gsha256sum.c` included `sha256.c` instead of `.h`.
12. gbuild told users to run `gpkg -f` (a stub) instead of `gpkg -l`.
13. `gpkg -f`'s stub referenced an undefined `uid`.
14. Path traversal from manifests/archives: `PKG_NAME`/`PKG_REPO` must
pass `gl_valid_pkg_ident`, and `..`/absolute entries refuse the
package. Finding 1 (self-declared `base`) stays open until signing.
15. Stage seeding and `gl_rmdir_recursive` skipped ALL dot-entries:
dotfiles vanished, and `old/` couldn't be removed (bug 6 again).
16. Per-user transactions could never work for a real user (shared
writable parents needed). Fixed by the per-uid layout. Found alongside:
- `gstore -n` never created parent directories;
- the stage was wiped before the lock was taken;
- cross-filesystem seeding was reported as `MKDIR`;
- `gl_restore_istore` doubled the uid and accepted `..` entries;
- gstore loaded the combined `.so` via `LIBGLACIER_SO` from the
environment.
17. Package symlinks were silently dropped at extraction, and only regular
files were linked, so musl's loader was never installed. Alpine-based
tests passed only because Alpine provides its own loader.
18. `add_to_archive` (`gstore -b`, backups) silently dropped symlinks.
19. gpkg acted on `-l` as soon as it parsed it, so `-lO` ignored its `O`.
20. `tests/glacier_ffi.lua` duplicated the cdefs and had drifted (it was
missing `GL_INSTALL_ERR_SCOPE`). It's now a wrapper.
21. Link conflicts were re-reported on every system commit, for every
package (it now uses the `.glacier-fresh` marker).
22. A failed extraction left a half-made version directory, so in a live
context every retry reported `ALREADY_INSTALLED`.
23. Backups written in the same second were pruned in the wrong order
(whole-second mtimes).
## Conventions
- **C style:** tabs, but the codebase is inconsistent; match the
surrounding function.
- **Paths:** `PATH_MAX` stack buffers plus `snprintf` for path
construction.
- **Lua requires:** gpkg uses bare CWD-relative requires.
syspkg and gstore pin their paths (see CLIs).
- **Lua numbers in C varargs:** a Lua number reaches C varargs as a
double, so never pass one to `lg_printf("%d")`. Use `%s` with
`tostring()`, or `ffi.new("int", n)`.
- **Shell commands from Lua:** always `shell_quote` interpolated paths
(syspkg runs as root).
- **Verify by running.** Several real bugs here were found only by
executing something.