22 KiB
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
ROADMAP.txt: what's left, in priority order, plus the decisions that are still open (section 6). Pick work from it.- This file: how things work, what the user wants, how to test.
- 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,/etcconfig handling.IMAGE_DESIGN.txt: PROPOSAL for system images (bootstrap OUTPUT, the sysroot library change). Open decisions are in section 9.GLACIER_BOOTSTRAP.txt: PLAN forglacier-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 .sos 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_printflevels:[x]is error (2),[!]warning (1),[i]info (0),[✓]success (3). Messages are capitalized. Exact strings the user chose includeCannot open system index: Permission denied(no period) andNothing to do.(success level).- The pre-trust-check
die()in syspkg/gstore reproduces the[x]format byte for byte, because it can't loadlibglacier_log.sobefore that file has been verified.
- The pre-trust-check
- 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 runsgstore -n UID; a user runsdoas gstore -nthrough a doas rule pinned toargs -n(option "B"). syspkg -ncreates the top-level tree itself (GLACIER_BOOTSTRAP 9d)./etcconfig files: option 1. Copy on install,.newon conflict (GLACIER_BOOTSTRAP 9c).-Owith-l= replace.-Odisplaces non-glacier files, and-lOalso replaces an installed different version (upgrade or downgrade from a local file). Plain-lwith another version installed refuses in one line.KEEP_OLDin/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.
- It has no default. It ships as
gpkg -u/syspkg -uupgrade from a REMOTE repository, andgpkg -fis-lbut fetched by name from a remote repository. Both wait for remote repos + signing (ROADMAP 3.1). Don't implement them against local files.everest-bootscriptsis the name of the base/etcpackage. 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_checkand the layout comment are incommon.h. gl_init_stage_contextrefuses a user base that isn't a real directory (lstat) owned by its uid with no group/other bits, or whose/glacier/usris loosened.gl_usr_base_statusexports 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/storefrom 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. Thengl_relink_store+gl_rebuild_indexregenerate 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/etcconfig 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)inpkg.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_pkgwon't overwrite a non-glacier path without the-Omarker. It never descends through another package's directory symlink into that package's store directory. - Pruning.
gl_relink_storePRUNES stale links; it never wipes a links destination. A link is stale only if its own store target is gone (checked withlstat, 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, plusO_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_recursiveskips only./... Every caller passes a glacier-only path; keep it that way.gl_delete_userlstat-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 -ddirectory while being hashed withlibglacier_verify.so. The result is checked againstFILE.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_pkgis 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 onEXDEVcopied (keeping mode, owner and mtime) and then unlinked. - Directories, special files and paths that already have a backup are still refused.
- In user scope
-Ois effectively a no-op, because the links tree is rebuilt from scratch on every commit.
Version replacement (-lO, M.install in gpkg_common.lua):
- Installed entries are read from the live index's
currentfile (repo::name::versionlines). - A same name+repo with a different version is a replacement. Without
-Oit's refused; with-O,keep_old()readsKEEP_OLD, and the summary showsold -> new. - In the transaction:
gl_backup_pkg(live, repo, name, X.gpkg.part), thengl_remove_pkgfrom the stage, then install. - After the commit: rename
.part→<scope>/backup/<repo>/<name>/<name>-<ver>.gpkg, write its sidecar, and rungl_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.gpkgrestores 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_sysruns it before writing the index. The index marks-nas complete, so a secondsyspkg -nis 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/-Vare applied in a pre-pass, so their position doesn't matter (-lO==-Ol). It finds its user via/proc/self/loginuid, falling back togetuid(). - syspkg,
-n: creates the tree plus/glacier/sys. - syspkg as non-root:
-n/-l/-xare refused after argument parsing withCannot open system index: Permission denied. - gstore,
-nwithout a UID, under doas: acts for the invoking user (loginuid, thenDOAS_USER, then getuid). The doas rulepermit nopass :wheel as root cmd /path/gstore args -nis the boundary that stops a user naming another UID. - Shared code:
lib/gpkg_common.lua(M.install,M.remove, verification, replacement, backups). - The
-xsummary 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.pathand everyffi.loadpinned to their ownrealpath, and when euid is 0, every ancestor directory and every loaded.lua/.somust 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.luais the single source of truth for everyffi.cdef.tests/glacier_ffi.luais only a wrapper around it. Declaration ORDER matters: a declaration usinggl_context_tmust 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/--versionprints the version,--verboseturns on verbose output.- Autodetects the build system, with autotools checked BEFORE a bare Makefile.
- Writes
NAME-VER.gpkgplus a.sha256sidecar, usinggsha256sumfrom$GSHA256SUM_BIN, else<script dir>/build/bin, else PATH. - There is NO
-Psystem-profile support, and gworld has noSYS_PROFILEfield (ROADMAP 4.1). The target libc is decided by where gbuild runs: Alpine for musl.
- gworld (Lua): batch orchestrator, reads
recipes/*.recipe, sorts topologically byDEPS, runs gbuild per package. ~/Projects/everest: the gbuild command for each package lives inscripts/container.sh;everest.shruns it inside an Alpine image (Dockerfile.build).- Kernel version: linux
6.12.112; plainv6.12doesn't build with GCC 15.
libglacier-ng build
- Separate
.soper 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.sois private: no public header.- Warnings:
config.mkbuilds with-Werror.-Wno-format-truncationis set because that warning is noise;-Wstringop-truncationstays fatal. - Makefile quirk:
make logbuilds onlylog.o; the shared object comes frommake log_test(or plainmake).
Testing
Run everything in throwaway containers. Several suites write to /usr,
/etc and /glacier.
FFI suite (glacier-ng/tests/*.lua; 8 files, all passing):
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:
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
/glacieruntilgstore -n/syspkg -n; - the doas rule plus
permit nopass glacier; glacier.confinstalled.
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.
gl_abort_transactionnever released the lock (wrong variable). Nowopen(O_CREAT)+flock(), and lock files are never unlinked.- No
EXDEVcheck before renaming. Commitstat()s both sides and returnsGL_TXN_ERR_XDEV. - Segfault on install: the
transactiontarget didn't linkistoreutils/log. - Stale links post-commit:
gl_link_pkgran during staged installs, embedding stage paths. It now runs only for LIVE contexts, and links regenerate post-commit. - Dangling links were never cleaned on removal →
prune_stale_links. GL_TXN_ERR_RENAMEafter the first commit: a leftoverold/. It's now wiped first.-Wstringop-truncation:strncpy(..., PATH_MAX - 1)could leave strings unterminated. Usesnprintf(dst, sizeof dst, "%s", ...).- gbuild detected a bare
Makefilebeforeconfigure. The fix had been lost from the tree and was re-applied 2026-10-04. - The index had no version field; it's now
repo::pkgname::version. - [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.
gsha256sum.cincludedsha256.cinstead of.h.- gbuild told users to run
gpkg -f(a stub) instead ofgpkg -l. gpkg -f's stub referenced an undefineduid.- Path traversal from manifests/archives:
PKG_NAME/PKG_REPOmust passgl_valid_pkg_ident, and../absolute entries refuse the package. Finding 1 (self-declaredbase) stays open until signing. - Stage seeding and
gl_rmdir_recursiveskipped ALL dot-entries: dotfiles vanished, andold/couldn't be removed (bug 6 again). - Per-user transactions could never work for a real user (shared
writable parents needed). Fixed by the per-uid layout. Found alongside:
gstore -nnever created parent directories;- the stage was wiped before the lock was taken;
- cross-filesystem seeding was reported as
MKDIR; gl_restore_istoredoubled the uid and accepted..entries;- gstore loaded the combined
.soviaLIBGLACIER_SOfrom the environment.
- 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.
add_to_archive(gstore -b, backups) silently dropped symlinks.- gpkg acted on
-las soon as it parsed it, so-lOignored itsO. tests/glacier_ffi.luaduplicated the cdefs and had drifted (it was missingGL_INSTALL_ERR_SCOPE). It's now a wrapper.- Link conflicts were re-reported on every system commit, for every
package (it now uses the
.glacier-freshmarker). - A failed extraction left a half-made version directory, so in a live
context every retry reported
ALREADY_INSTALLED. - 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_MAXstack buffers plussnprintffor 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%swithtostring(), orffi.new("int", n). - Shell commands from Lua: always
shell_quoteinterpolated paths (syspkg runs as root). - Verify by running. Several real bugs here were found only by executing something.