From dd36cd18081d1aef141b0790f0bcdd6c937f0e5b Mon Sep 17 00:00:00 2001 From: Michal Vyskocil Date: Fri, 23 Oct 2026 08:36:28 +0200 Subject: [PATCH] book: document facts #673 removed from .claude/design claude.md: settings.json filter (host and target files, allowlist, strict JSON 1 MiB, -v/--dry-run report, managed settings not visible); login: BROWSER is /snug/lib/snug/browser, conflict with a profile BROWSER, open limit. containers.md: docker cp refused (engine view wider than the container, topology diagram linked), podman shim replaced by snug's podman calling docker, `docker build` section (DOCKER_BUILDKIT=0, see issue 690), image signatures. environment.md: prepend across profiles, name grammar, the two marks. network.md: door starts through snug's wrapper, no shell or network. variables.md: new chapter. Variable table and rules moved out of filesystem.md; {colon} and the colon in path specs and lists. filesystem.md: `file` section, links to variables.md. README: CGO_ENABLED=0. TestBook* hold the allowlist, wrapper paths, login open limit, the variable table and the spec tables at their new pages. Refs #683 --- README.md | 2 +- docs/src/SUMMARY.md | 1 + docs/src/claude.md | 48 +++++++++- docs/src/containers.md | 33 +++++++ docs/src/environment.md | 11 ++- docs/src/filesystem.md | 129 +++++++++++++++++++++------ docs/src/network.md | 8 +- docs/src/reference/profile-keys.md | 2 +- docs/src/variables.md | 45 ++++++++++ internal/loginbridge/book_test.go | 35 ++++++++ internal/policy/bookclaude_test.go | 63 +++++++++++++ internal/policy/bookmodes_test.go | 4 +- internal/policy/bookpathspec_test.go | 14 ++- 13 files changed, 352 insertions(+), 43 deletions(-) create mode 100644 docs/src/variables.md create mode 100644 internal/loginbridge/book_test.go create mode 100644 internal/policy/bookclaude_test.go diff --git a/README.md b/README.md index 6c801a6b..9b3a65b8 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ $ echo $? ## Quick install -One needs an up to date Go compiler. +`snug` must be built with `CGO_ENABLED=0`. ```bash # build the binary diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 746f500e..3a01b0cd 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -4,6 +4,7 @@ - [Configuration files](configuration.md) - [Profiles](profiles.md) +- [Variables](variables.md) - [Filesystem grants](filesystem.md) - [Environment](environment.md) - [Network](network.md) diff --git a/docs/src/claude.md b/docs/src/claude.md index 960d2a0f..f793c2dd 100644 --- a/docs/src/claude.md +++ b/docs/src/claude.md @@ -43,6 +43,42 @@ it has already expired. Run `claude` on the host to refresh it, or log in again inside with `/login` (see [`login`](#login)). A login made inside is not saved on the host; the next run starts from the host token again. +## `settings.json` + +The host's `~/.claude/settings.json` and the target's `.claude/settings.json` and +`.claude/settings.local.json` are filtered and most of their content is ignored. +The following keys are passed; the rest is ignored. + +- `model` +- `theme` +- `editorMode` +- `verbose` +- `alwaysThinkingEnabled` +- `autoCompactEnabled` +- `includeCoAuthoredBy` +- `prefersReducedMotion` +- `spinnerTipsEnabled` +- `skipWorkflowUsageWarning` + +Additionally: + +- A value that does not look like a plain name (a `model` containing + a path, for example) is dropped. +- The file is read as strict JSON, up to 1 MiB. So comments, a trailing comma or + a larger file carry nothing. +- `snug -v` and `snug --dry-run` say what is carried and what is not. +- `~/.claude/settings.json` inside the sandbox is writable, so `claude` inside + can write there. The host file remains untouched, of course. The target's copies + are read-only. +- `/etc/claude-code/managed-settings.json` is not visible inside, so an + organisation's managed policy does not apply. +- A target settings file that is a symlink, a directory or anything else but + a plain file refuses the run. +- A profile granting `~/.claude/settings.json` still gets the filtered file on + top. A `.claude/settings.json` in any other + directory a profile grants is not filtered, and Claude Code reads it when + started there. + ## The target opens without the trust dialog Claude Code normally asks "Quick safety check" before it opens a directory @@ -94,16 +130,20 @@ login = ["claude"] - snug opens the page on the host with `xdg-open`, found on your `PATH`, and needs `DISPLAY` or `WAYLAND_DISPLAY` set. `xdg-open` would fall back to a text browser otherwise. -- Inside, `BROWSER` is `/snug/bin/snug-browser`, which hands the URL to snug. - snug opens only Claude's own login page, for Claude Code's own client, at - most five times per run and at most once every 30 seconds. Anything else is - refused, and nothing opens: +- `BROWSER` inside the sandbox points to `/snug/lib/snug/browser`, a link to snug's own + executable (see [HTTP doors](network.md#http-doors-listen_names)). It passes the + URL out to snug on the host, which opens it in the host's default browser. +- snug opens only Claude's own login page, for Claude Code's own client, at + most five times per run. Anything else is refused, and nothing opens: ```text $ $BROWSER https://example.com/ snug: login bridge: refused an open request from the sandbox — not the Claude login URL snug pins (scheme, host or path): https://example.com/. Nothing was opened. ``` +- snug sets `BROWSER` itself, so a run where another profile also sets + `BROWSER` in `environ` is refused. +- The browser `xdg-open` starts is yours and may outlive the sandbox itself. - snug answers the browser's callback itself and passes the sandbox only the code and state, never a cookie. diff --git a/docs/src/containers.md b/docs/src/containers.md index 5cfe5956..ffdfe15e 100644 --- a/docs/src/containers.md +++ b/docs/src/containers.md @@ -55,6 +55,39 @@ proxy forwards only a part: `podman build`, `podman run -d`, `podman wait`, `podman logs` and `podman rm` work, but `podman run` without `-d` is refused, because it attaches to the container and the proxy does not forward attach. +- `docker cp` is refused. The engine runs beside the sandbox, not in it (see + [the diagram](img/topology-engine.svg)). It can give access to more files + than the configuration allows. + +- A host `podman` that is a shim into the host (`host-spawn`, + `flatpak-spawn`, ...) is replaced by snug's own `podman`, which calls the + `docker` client if the sandbox has one. `snug` only filters container + requests and ships no client of its own. + +## `docker build` + +snug sets `DOCKER_BUILDKIT=0`, so `docker build` uses the classic builder, +whose options the proxy filters. + +This is a default, not a bound: a process inside can set +`DOCKER_BUILDKIT=1`. What BuildKit can then reach is not yet measured, see +[#690](https://github.com/gomoni/snug/issues/690). + +## Image signatures + +The engine checks images against a signature policy built from the host's +`policy.json`: + +- `insecureAcceptAnything`, `reject` and `signedBy` carry over. Key files a + `signedBy` names are copied for the engine. +- Any other requirement, such as `sigstoreSigned` or `signedBaseLayer`, + refuses the run and names the requirement and the file. +- A host with no `policy.json` gets a policy that accepts any image. A plain + podman on that host pulls nothing, so here the sandbox is more permissive + than the host; `--dry-run` says so. +- The policy applies to pulls. An image already in the target's storage keeps + running under a stricter policy. + ## Storage per target directory Every target directory gets its own container storage. Images, containers diff --git a/docs/src/environment.md b/docs/src/environment.md index 40c61d4f..1077b35f 100644 --- a/docs/src/environment.md +++ b/docs/src/environment.md @@ -59,7 +59,7 @@ A variable's type decides which verbs (operations) it accepts. Unsupported verbs |---|---|---|---|---|---| | `set` | string | sets a scalar | profiles must agree on the value | scalar | yes | | `merge` | string or array of strings | adds elements to a list | union, sorted, duplicates removed | path-list, if its row allows it | only if the profile declares it `path-list` | -| `prepend` | string or array of strings | puts elements at the front of a list, in the order written | one profile per variable | path-list, if its row allows it | only if the profile declares it `path-list` | +| `prepend` | string or array of strings | puts elements at the front of a list, in the order written | one value per variable; profiles writing the identical list agree, different lists are an error | path-list, if its row allows it | only if the profile declares it `path-list` | | `inherit` | `true` | copies the host's value, if the host has one | union | scalar | yes | | `sanitise` | `true` | copies the host's list, keeping only elements that are granted inside and hold the host's content | union | path-list, if its row allows it | no | @@ -75,10 +75,17 @@ A variable's type decides which verbs (operations) it accepts. Unsupported verbs the list, so a declared `path-list` never takes it. - A string in `merge` or `prepend` is **one** element. `snug` never splits a value. So always write `["/a", "/b"]`, not `"/a:/b"`. +- A variable name must match `[A-Za-z_][A-Za-z0-9_]*` and anything else is refused + when the profile loads. +- `sanitise` can keep variables empty, but never unset them. +- `← not granted` in `snug --dry-run` or `snug profile show` means the + path is not granted by any profile. +- `← writable from inside` marks a `PATH` directory the sandboxed program can + write to. - `inherit` and `sanitise` take `true`. `false` is an error: nothing was inherited to begin with, so there is nothing to turn off. - `set`, `merge` and `prepend` values expand `{target}`, `{home}` and the - other [path variables](filesystem.md#variables). + other [variables](variables.md). ## Declaring a type diff --git a/docs/src/filesystem.md b/docs/src/filesystem.md index 82f46be6..a6e49091 100644 --- a/docs/src/filesystem.md +++ b/docs/src/filesystem.md @@ -24,13 +24,14 @@ symlink = [{ at = "/usr/local/bin/python", target = "/usr/bin/python3" }] | `rw` | list of path specs | a writable bind of a host path. Be aware writes reaches the host, there's no middle layer | | `tmpfs` | list of guest paths | an empty writable directory in memory, at most [`tmpfs_size`](#tmpfs) (default 1 GiB) | | `symlink` | list of `{ at, target }` | a symlink at guest path `at` pointing to `target` | +| `file` | list of `{ path, content }` tables | a read-only file (mode `0444`) at `path` holding the text `content` | | `optional` | list of paths | nothing by itself; an `ro` or `rw` host path must exist, and listing it here skips it instead when it is absent | ## Path specs A path grant is read-only (`ro`) or read-write (`rw`). Host paths, which are not granted remain not accessible inside the sandbox. The spec is split at the -first `:`. After variable expansion (for example `{target}`) both sides must be absolute. +first `:`. After [variable](variables.md) expansion (for example `{target}`) both sides must be absolute. - `HOST` — bind host `HOST` at the same path inside, or - `HOST:GUEST` — bind host `HOST` at `GUEST` inside. @@ -64,31 +65,8 @@ The examples below assume `{home}` (or `~`) is `/home/me` and the `{target}` is ### Variables -Those are NOT environment variables. The home directory is spelled `{home}` or -`~`. `$HOME` has no meaning and is taken as it. `/opt/$HOME` is the literal -path `/opt/$HOME`, and `$HOME/bin` is refused because it is not absolute. - -| variable | value | -|---|---| -| `{target}` | the directory given on the command line, symlinks resolved, see [Host symlinks](#host-symlinks) | -| `{target_parent}` | the parent of `{target}` | -| `{home}` | your account's home directory (`getent passwd`), symlinks resolved, see [Host symlinks](#host-symlinks) and [The home directory](#the-home-directory) | -| `{lbrace}` | a literal `{` | -| `{rbrace}` | a literal `}` | -| `{colon}` | a literal `:` | -| `~/` | `{home}/`, only as the first two characters | - -Variables expand in `ro`, `rw`, `tmpfs`, `symlink`, `optional`. A path -containing `:` or `{` cannot be written in a spec. A `:` or `{` that comes from -a variable's value is part of the path: - -| path to grant | spec | result | -|---|---|---| -| `/mnt/a:b` | `/mnt/a:b` | refused: `both sides must be absolute paths`; split into host `/mnt/a`, guest `b` | -| `/tmp/{foo}` | `/tmp/{foo}` | refused: `unknown variable {foo}` | -| `/mnt/a:b` | `/mnt` | works; grants the whole parent | -| `/w/x:y`, snug run on it | `{target}` | works; `/w/x:y` on both sides | -| `/tmp/{foo}`, snug run on it | `{target}` | works; `/tmp/{foo}` on both sides | +`{target}`, `{home}`, `~/` and the other variables are described in +[Variables](variables.md). ## Host symlinks @@ -180,6 +158,104 @@ snug: grant /opt/lnk/x (from p) resolves through the symlink /opt/lnk -> /proc, be made there. Remove the grant, or point the symlink somewhere else ``` +## `file` + +```toml +[[profile.work.file]] +path = "/etc/gitconfig" +content = """ +[init] + defaultBranch = main +""" +``` + +`path` is a guest path, absolute after expansion; `~/` and `{home}` work in +it. `content` is the whole text of the file, written verbatim: snug adds no +newline and removes none. `content = ""` is an empty file, and a `file` with no +`content` is refused. Nothing is read from the host and nothing is created on +it. The file is read-only, mode `0444`, and is gone when the sandbox exits. +There is no `rw` form and no `mode` key. + +The double brackets are TOML's array of tables: each `[[profile.NAME.file]]` +block adds one file, so a profile writes as many as it has blocks. + +```toml +[[profile.work.file]] +path = "/etc/gitconfig" +content = "..." + +[[profile.work.file]] +path = "~/.config/pip/pip.conf" +content = "..." +``` + +`file = [{ path = "…", content = "…" }, …]` is the same list written inline; +the block form reads better when `content` spans lines. + +**What a hostile process inside the sandbox can do with it:** read the text, +and nothing more. It cannot write, replace or unlink the file. The power is the +author's: whatever program reads `path` obeys the text. `/etc/gitconfig` is a +command table git executes (`core.sshCommand`, `credential.helper`, +`alias.x = "!cmd"`, `core.pager`). It is exactly the power of +`ro = [":/etc/gitconfig"]`, with no host file opened. Write only what you +would write in that stub. + +**Never put a credential in `content`.** `snug --dry-run` and +`snug profile show` print the full text and its SHA-256. + +The text must be valid UTF-8 with no control character except newline and tab, +and at most 64 KiB, before and after expansion. A longer text belongs in a file +you bind with `ro = [":"]`. Two profiles writing the same `path` +combine only when the text is identical; different text is refused, naming both. + +A `file` is a grant like any other. It may sit inside a `tmpfs` (`{home}` is +one), and is refused inside a `ro` or `rw` bind, because that would hide what +the bind shows there. Nothing can be granted beneath it. + +### Placeholders + +`content` expands the [variables](variables.md) and `{profile.NAME.KEY}`: a +value another profile pins. `KEY` is +`identity.` followed by a leaf that is not a host path, for example +`{profile.work-id.identity.ssh.host}`. `identity.ssh.key`, +`identity.git.signing_key` and `identity.kubectl.kubeconfig` are host paths snug +reads, and are refused. `~/` is not expanded in `content`. + +```toml +[profile.work] +include = ["work-id"] + +[[profile.work.file]] +path = "/etc/gitconfig" +content = """ +[url "git@{profile.work-id.identity.ssh.host}:"] + pushInsteadOf = https://{profile.work-id.identity.ssh.host}/ +""" +``` + +`NAME` must be the profile that writes the file or one it includes, so the text +never depends on what else a run selects. A placeholder whose key is unset is +refused, and is never expanded to nothing. Expansion is one pass: a value that +contains `{` is data, not another placeholder. A value that contains a control +character, a newline in `{target}` for one, is refused. + +### Where git reads it + +snug generates `~/.gitconfig` for an `identity.git` and pins user, signing, +`insteadOf` and `safe.directory` there. Git reads `/etc/gitconfig` at system +scope, below the global file, so a `file` at `/etc/gitconfig` adds to the +generated one and cannot override those pins. `--show-scope` reports `system` +for it. + +### Generated paths + +A `file` at a path snug generates for the same selection is refused, naming the +generated file and what to do instead: `~/.gitconfig`, `~/.ssh/config`, +`~/.ssh/known_hosts`, `/etc/resolv.conf`, `/etc/passwd`, `/etc/group`, +`/etc/nsswitch.conf` and the account files an `identity` stages. snug has no +layer for ssh configuration, so `{home}/.ssh/config` is refused with that +wording. Builtin profiles carry no `file`. + ## `optional` ```toml @@ -256,6 +332,7 @@ A grant inside another grant must not hide what the outer one shows: | outer grant | a grant inside it is | |---|---| | `tmpfs` | allowed: there is nothing to hide | +| a [`file`](#file) | refused: nothing can be mounted inside a file | | `ro`/`rw` of host `H` | allowed only when it binds the same tree, `H/sub` at `…/sub`; anything else is refused | ## Paths snug owns diff --git a/docs/src/network.md b/docs/src/network.md index 8467efaa..2c78200b 100644 --- a/docs/src/network.md +++ b/docs/src/network.md @@ -73,6 +73,8 @@ listen_names = ["web"] prints a URL carrying a one-time token. - A door name is lowercase letters, digits and single interior dashes, at most 32 characters. -- snug starts the command through a two-line `/bin/sh` script that sets - `LISTEN_PID` to the command's own pid, which only the sandbox knows. The - selection must grant `/bin/sh` (`@sys` does). +- snug starts the command through its own wrapper, which sets `LISTEN_PID` + and runs the command in its place. This feature does not need networking + enabled or a shell. +- `/snug/lib/snug/wrapper` is snug's own executable, bound read-only and not + on `PATH`. `--dry-run` shows it as `bind /proc/self/exe` with a note. diff --git a/docs/src/reference/profile-keys.md b/docs/src/reference/profile-keys.md index bc0f0db5..f3b991d5 100644 --- a/docs/src/reference/profile-keys.md +++ b/docs/src/reference/profile-keys.md @@ -10,7 +10,7 @@ Every key a `[profile.NAME]` table accepts. Any other key is an error. | `rw` | array of strings | path specs | union; `rw` wins at one path | [Filesystem](../filesystem.md) | | `tmpfs` | array of strings | guest paths | union | [Filesystem](../filesystem.md#tmpfs) | | `symlink` | array of tables | `{ at = "…", target = "…" }` | union; one target per `at` | [Filesystem](../filesystem.md#symlink) | -| `file` | array of tables | `{ path = "…", content = "…" }` | union; identical text at one `path` | [Filesystem](../filesystem.md) | +| `file` | array of tables | `{ path = "…", content = "…" }` | union; identical text at one `path` | [Filesystem](../filesystem.md#file) | | `optional` | array of strings | paths | this profile only | [Filesystem](../filesystem.md#optional) | | `nss` | boolean | `true`, `false` | any `true` | [Filesystem](../filesystem.md#generated-account-files-nss) | | `environ.set` | table of strings | `NAME = "value"` | must agree | [Environment](../environment.md) | diff --git a/docs/src/variables.md b/docs/src/variables.md new file mode 100644 index 00000000..5717a618 --- /dev/null +++ b/docs/src/variables.md @@ -0,0 +1,45 @@ +# Variables + +As usual with computers, the concept of variables, symbols or special +characters appears sooner or later. snug supports special placeholders such as +`{home}` and `{target}`, which are called _variables_. All in all this is only +simple string _interpolation_: each placeholder is replaced by its actual +value. + +Note that despite `{home}`, environment variables such as `$HOME` are not +expanded. + +| variable | value | +|---|---| +| `{target}` | the directory given on the command line, symlinks resolved, see [Host symlinks](filesystem.md#host-symlinks) | +| `{target_parent}` | the parent of `{target}` because `@parent-ro` existed forever in `snug` | +| `{home}` | your account's home directory (read by `getent passwd`), symlinks resolved, see [Host symlinks](filesystem.md#host-symlinks) and [The home directory](filesystem.md#the-home-directory) | +| `~/` | `{home}/`, only as the first two characters | +| `{lbrace}` | a literal `{` | +| `{rbrace}` | a literal `}` | +| `{colon}` | a literal `:` | + +Variables expand in `ro`, `rw`, `tmpfs`, `symlink`, `optional`, the `path` of a +[`file`](filesystem.md#file) and in `environ` values. A `{` always opens a name, and an unknown +name is refused. There is no escaping; use the special variables shown above. + +## The colon + +> One does not use colon in his paths! + +If you must (Open Build Service uses `:` both in URL identifiers and in +physical file names), be aware that `{colon}` makes a grant for a path +containing `:` possible: `ro = ["/home/u/home{colon}obs:/opt/obs"]`. + +You can't use the `{colon}` hack for `merge` or `prepend`. It is refused, as +this is simply not possible and would break most tools. + +| path to grant | spec | result | +|---|---|---| +| `/mnt/a:b` | `/mnt/a:b` | refused: `both sides must be absolute paths`; split into host `/mnt/a`, guest `b` | +| `/tmp/{foo}` | `/tmp/{foo}` | refused: `unknown variable {foo}` | +| `/mnt/a:b` | `/mnt/a{colon}b` | works; `/mnt/a:b` on both sides | +| `/tmp/{foo}` | `/tmp/{lbrace}foo}` | works; `/tmp/{foo}` on both sides | +| `/mnt/a:b` | `/mnt` | works; grants the whole parent | +| `/w/x:y`, snug run on it | `{target}` | works; `/w/x:y` on both sides | +| `/tmp/{foo}`, snug run on it | `{target}` | works; `/tmp/{foo}` on both sides | diff --git a/internal/loginbridge/book_test.go b/internal/loginbridge/book_test.go new file mode 100644 index 00000000..1674ac41 --- /dev/null +++ b/internal/loginbridge/book_test.go @@ -0,0 +1,35 @@ +package loginbridge + +import ( + "os" + "regexp" + "strings" + "testing" +) + +// TestBookLoginOpenLimitIsTheBridges checks the open limit the `login` +// section of docs/src/claude.md states against MaxOpensPerRun. +func TestBookLoginOpenLimitIsTheBridges(t *testing.T) { + const page = "../../docs/src/claude.md" + data, err := os.ReadFile(page) + if err != nil { + t.Fatal(err) + } + m := regexp.MustCompile("(?s)\n## `login`\n(.*?)\n## ").FindSubmatch(data) + if m == nil { + t.Fatalf("%s has no `login` section", page) + } + // The prose spells small counts out; a limit past the table needs a word + // added here before the book can state it. + word := func(n int) string { + words := []string{"zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten"} + if n < 0 || n >= len(words) { + t.Fatalf("no word for %d", n) + } + return words[n] + } + section := strings.Join(strings.Fields(string(m[1])), " ") + if w := "at most " + word(MaxOpensPerRun) + " times per run"; !strings.Contains(section, w) { + t.Errorf("%s's `login` section does not say %q", page, w) + } +} diff --git a/internal/policy/bookclaude_test.go b/internal/policy/bookclaude_test.go new file mode 100644 index 00000000..8f20cf97 --- /dev/null +++ b/internal/policy/bookclaude_test.go @@ -0,0 +1,63 @@ +package policy + +import ( + "os" + "regexp" + "strings" + "testing" +) + +// bookClaudePath is the user guide's page for @claude. +const bookClaudePath = "../../docs/src/claude.md" + +// TestBookClaudeSettingsListsTheAllowlist checks the key list in the +// `settings.json` section of docs/src/claude.md against ClaudeSettingAllowlist, +// in both directions. The list is the bullet list between "keys are passed" +// and "Additionally". +func TestBookClaudeSettingsListsTheAllowlist(t *testing.T) { + data, err := os.ReadFile(bookClaudePath) + if err != nil { + t.Fatal(err) + } + m := regexp.MustCompile(`(?s)keys are passed(.*?)Additionally`).FindSubmatch(data) + if m == nil { + t.Fatalf("%s has no \"keys are passed … Additionally\" list", bookClaudePath) + } + documented := map[string]bool{} + for _, q := range regexp.MustCompile("`([A-Za-z]+)`").FindAllSubmatch(m[1], -1) { + documented[string(q[1])] = true + } + for _, k := range ClaudeSettingAllowlist { + if !documented[k.Name] { + t.Errorf("snug carries %s from the host's settings.json and %s does not list it", k.Name, bookClaudePath) + } + delete(documented, k.Name) + } + for d := range documented { + t.Errorf("%s lists %s as carried, and ClaudeSettingAllowlist drops it", bookClaudePath, d) + } +} + +// TestBookNamesTheWrapperPaths checks the guest paths the book quotes against +// the constants Resolve stages. +func TestBookNamesTheWrapperPaths(t *testing.T) { + for page, wants := range map[string][]string{ + bookClaudePath: { + "`BROWSER` inside the sandbox points to `" + BrowserGuest + "`", + }, + "../../docs/src/network.md": { + "`" + WrapperGuest + "` is snug's own executable", + "`bind " + SelfExeHost + "`", + }, + } { + data, err := os.ReadFile(page) + if err != nil { + t.Fatal(err) + } + for _, w := range wants { + if !strings.Contains(string(data), w) { + t.Errorf("%s does not say %s", page, w) + } + } + } +} diff --git a/internal/policy/bookmodes_test.go b/internal/policy/bookmodes_test.go index e47f42fc..849c2f5d 100644 --- a/internal/policy/bookmodes_test.go +++ b/internal/policy/bookmodes_test.go @@ -57,10 +57,10 @@ func TestBookProfileKeysListExactlyTheParsedModes(t *testing.T) { } // TestBookListsExactlyThePathVariablesResolveBuilds checks the variable table -// in docs/src/filesystem.md against Resolve's variable map, in both +// in docs/src/variables.md against Resolve's variable map, in both // directions. The `~/` row is expandVars' prefix rule, not a map entry. func TestBookListsExactlyThePathVariablesResolveBuilds(t *testing.T) { - const page = "../../docs/src/filesystem.md" + const page = "../../docs/src/variables.md" data, err := os.ReadFile(page) if err != nil { t.Fatal(err) diff --git a/internal/policy/bookpathspec_test.go b/internal/policy/bookpathspec_test.go index 1551d8d1..a2c2b2bb 100644 --- a/internal/policy/bookpathspec_test.go +++ b/internal/policy/bookpathspec_test.go @@ -10,9 +10,10 @@ import ( // TestBookPathSpecExamplesAreWhatSplitSpecDoes runs every row of the path // spec examples table in docs/src/filesystem.md through splitSpec, with the // variable values the page states above the table. A row with `—` in both -// columns is one the page says is refused. +// columns is one the page says is refused. The `:` and `{` table it checks +// after is in docs/src/variables.md. func TestBookPathSpecExamplesAreWhatSplitSpecDoes(t *testing.T) { - const page = "../../docs/src/filesystem.md" + page := "../../docs/src/filesystem.md" data, err := os.ReadFile(page) if err != nil { t.Fatal(err) @@ -48,8 +49,13 @@ func TestBookPathSpecExamplesAreWhatSplitSpecDoes(t *testing.T) { t.Fatalf("%s: found %d accepted and %d refused example rows; the table moved or changed shape", page, accepted, refused) } - // The `:` and `{` table: each refused row's quoted error is what - // splitSpec says. + // The `:` and `{` table, on the variables page: each refused row's quoted + // error is what splitSpec says. + page = "../../docs/src/variables.md" + data, err = os.ReadFile(page) + if err != nil { + t.Fatal(err) + } refusal := regexp.MustCompile("(?m)^\\| `[^`]+` \\| `([^`]+)` \\| refused: `([^`]+)`") matched := 0 for _, r := range refusal.FindAllStringSubmatch(string(data), -1) {