From b632134374e5e8a84564833e6882378f4ff0e4b8 Mon Sep 17 00:00:00 2001 From: Sall Date: Sat, 18 Jul 2026 23:45:56 +0100 Subject: [PATCH 1/6] feat(terminal-demo): enforce raw capture boundary --- tools/readme-terminal-demo/.gitattributes | 2 + tools/readme-terminal-demo/Dockerfile | 115 ++ .../cmd/readme-terminal-demo/main.go | 570 +++++++++ .../cmd/readme-terminal-demo/main_test.go | 851 +++++++++++++ tools/readme-terminal-demo/dependencies.env | 56 + tools/readme-terminal-demo/go.mod | 13 + tools/readme-terminal-demo/go.sum | 18 + .../internal/failure/failure.go | 164 +++ .../internal/failure/failure_test.go | 109 ++ .../internal/sandbox/exec_linux.go | 236 ++++ .../internal/sandbox/fds_linux.go | 31 + .../internal/sandbox/landlock_linux.go | 189 +++ .../sandbox/landlock_syscall_linux.go | 34 + .../sandbox/launcher_internal_test.go | 1104 +++++++++++++++++ .../sandbox/launcher_structure_test.go | 521 ++++++++ .../internal/sandbox/process_linux.go | 128 ++ .../internal/sandbox/sandbox_test.go | 299 +++++ .../scripts/bootstrap-gh.sh | 31 + .../scripts/bootstrap-gh_test.sh | 12 + .../scripts/in-go-image.sh | 16 + .../scripts/in-go-image_test.sh | 21 + .../scripts/verify-gh-assets.sh | 18 + .../scripts/verify-gh-assets_test.sh | 36 + .../scripts/verify-release-assets.sh | 43 + .../scripts/verify-release-assets_test.sh | 65 + .../testdata/smoke/readme.tape | 13 + 26 files changed, 4695 insertions(+) create mode 100644 tools/readme-terminal-demo/.gitattributes create mode 100644 tools/readme-terminal-demo/Dockerfile create mode 100644 tools/readme-terminal-demo/cmd/readme-terminal-demo/main.go create mode 100644 tools/readme-terminal-demo/cmd/readme-terminal-demo/main_test.go create mode 100644 tools/readme-terminal-demo/dependencies.env create mode 100644 tools/readme-terminal-demo/go.mod create mode 100644 tools/readme-terminal-demo/go.sum create mode 100644 tools/readme-terminal-demo/internal/failure/failure.go create mode 100644 tools/readme-terminal-demo/internal/failure/failure_test.go create mode 100644 tools/readme-terminal-demo/internal/sandbox/exec_linux.go create mode 100644 tools/readme-terminal-demo/internal/sandbox/fds_linux.go create mode 100644 tools/readme-terminal-demo/internal/sandbox/landlock_linux.go create mode 100644 tools/readme-terminal-demo/internal/sandbox/landlock_syscall_linux.go create mode 100644 tools/readme-terminal-demo/internal/sandbox/launcher_internal_test.go create mode 100644 tools/readme-terminal-demo/internal/sandbox/launcher_structure_test.go create mode 100644 tools/readme-terminal-demo/internal/sandbox/process_linux.go create mode 100644 tools/readme-terminal-demo/internal/sandbox/sandbox_test.go create mode 100755 tools/readme-terminal-demo/scripts/bootstrap-gh.sh create mode 100755 tools/readme-terminal-demo/scripts/bootstrap-gh_test.sh create mode 100755 tools/readme-terminal-demo/scripts/in-go-image.sh create mode 100755 tools/readme-terminal-demo/scripts/in-go-image_test.sh create mode 100755 tools/readme-terminal-demo/scripts/verify-gh-assets.sh create mode 100755 tools/readme-terminal-demo/scripts/verify-gh-assets_test.sh create mode 100755 tools/readme-terminal-demo/scripts/verify-release-assets.sh create mode 100755 tools/readme-terminal-demo/scripts/verify-release-assets_test.sh create mode 100644 tools/readme-terminal-demo/testdata/smoke/readme.tape diff --git a/tools/readme-terminal-demo/.gitattributes b/tools/readme-terminal-demo/.gitattributes new file mode 100644 index 000000000..9cc34a954 --- /dev/null +++ b/tools/readme-terminal-demo/.gitattributes @@ -0,0 +1,2 @@ +*.go whitespace=-tab-in-indent +go.mod whitespace=-tab-in-indent diff --git a/tools/readme-terminal-demo/Dockerfile b/tools/readme-terminal-demo/Dockerfile new file mode 100644 index 000000000..7b61bb017 --- /dev/null +++ b/tools/readme-terminal-demo/Dockerfile @@ -0,0 +1,115 @@ +# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e + +FROM --platform=linux/amd64 golang:1.26.5-trixie@sha256:117e07f49461abb984fc8aef661432461ff43d06faa22c3b73af6a49ce325cb9 AS go-build + +ENV GOTOOLCHAIN=local \ + GOFLAGS=-mod=readonly +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . ./ +RUN mkdir -p /out \ + && go test ./... -count=1 \ + && go vet ./... \ + && CGO_ENABLED=0 go build -buildvcs=false -trimpath -o /out/readme-terminal-demo ./cmd/readme-terminal-demo + +FROM --platform=linux/amd64 debian:trixie-slim@sha256:020c0d20b9880058cbe785a9db107156c3c75c2ac944a6aa7ab59f2add76a7bd AS runtime-base + +COPY dependencies.env /tmp/dependencies.env +RUN set -eu; \ + . /tmp/dependencies.env; \ + rm -f /etc/apt/sources.list.d/debian.sources; \ + printf '%s\n' \ + "deb [check-valid-until=no] ${DEBIAN_SNAPSHOT_BOOTSTRAP_URL} trixie main" \ + "deb [check-valid-until=no] ${DEBIAN_SECURITY_SNAPSHOT_BOOTSTRAP_URL} trixie-security main" \ + > /etc/apt/sources.list; \ + apt-get -o Acquire::Check-Valid-Until=false update; \ + apt-get install -y --no-install-recommends \ + "ca-certificates=${DEBIAN_CA_CERTIFICATES_VERSION}"; \ + rm -rf /var/lib/apt/lists/*; \ + printf '%s\n' \ + "deb [check-valid-until=no] ${DEBIAN_SNAPSHOT_URL} trixie main" \ + "deb [check-valid-until=no] ${DEBIAN_SECURITY_SNAPSHOT_URL} trixie-security main" \ + > /etc/apt/sources.list; \ + apt-get -o Acquire::Check-Valid-Until=false update; \ + apt-get install -y --no-install-recommends \ + "ca-certificates=${DEBIAN_CA_CERTIFICATES_VERSION}" \ + "chromium=${DEBIAN_CHROMIUM_VERSION}" \ + "curl=${DEBIAN_CURL_VERSION}" \ + "ffmpeg=${DEBIAN_FFMPEG_VERSION}" \ + "fontconfig=${DEBIAN_FONTCONFIG_VERSION}" \ + "fonts-jetbrains-mono=${DEBIAN_FONTS_JETBRAINS_MONO_VERSION}" \ + "fonts-noto-color-emoji=${DEBIAN_FONTS_NOTO_COLOR_EMOJI_VERSION}" \ + "git=${DEBIAN_GIT_VERSION}" \ + "tini=${DEBIAN_TINI_VERSION}" \ + "xz-utils=${DEBIAN_XZ_UTILS_VERSION}" \ + "zsh=${DEBIAN_ZSH_VERSION}"; \ + test "$(dpkg-query -W -f='${Version}' chromium)" = "${DEBIAN_CHROMIUM_VERSION}"; \ + test "$(dpkg-query -S /usr/bin/chromium)" = 'chromium: /usr/bin/chromium'; \ + test "$(stat -c '%u:%a:%F' /usr/bin/chromium)" = '0:755:regular file'; \ + printf '%s %s\n' "${CHROMIUM_LAUNCHER_SHA256}" /usr/bin/chromium | sha256sum -c -; \ + if dpkg-query -W -f='${db:Status-Abbrev}' chromium-sandbox 2>/dev/null | grep -q '^ii '; then \ + echo 'chromium-sandbox must not be installed' >&2; \ + exit 1; \ + fi; \ + if find /usr -xdev -type f \( -name chrome-sandbox -o -name chromium-sandbox \) -perm /4000 -print -quit | grep -q .; then \ + echo 'setuid browser helper must not exist' >&2; \ + exit 1; \ + fi; \ + rm -rf /var/lib/apt/lists/* /tmp/dependencies.env + +FROM runtime-base AS release-assets + +COPY dependencies.env /tmp/dependencies.env +COPY scripts/verify-release-assets.sh scripts/verify-release-assets_test.sh /verify/ +COPY scripts/verify-gh-assets.sh scripts/verify-gh-assets_test.sh scripts/bootstrap-gh.sh scripts/bootstrap-gh_test.sh /verify/ +RUN set -eu; \ + . /tmp/dependencies.env; \ + mkdir -p /assets /out /extract/vhs /extract/eza; \ + curl --fail --location --silent --show-error --output /assets/cosign-linux-amd64 "${COSIGN_LINUX_AMD64_URL}"; \ + curl --fail --location --silent --show-error --output /assets/cosign-linux-amd64.sigstore.json "${COSIGN_BUNDLE_URL}"; \ + curl --fail --location --silent --show-error --output /assets/checksums.txt "${VHS_CHECKSUMS_URL}"; \ + curl --fail --location --silent --show-error --output /assets/checksums.txt.sigstore.json "${VHS_CHECKSUMS_BUNDLE_URL}"; \ + curl --fail --location --silent --show-error --output /assets/vhs_0.11.0_Linux_x86_64.tar.gz "${VHS_LINUX_X86_64_URL}"; \ + /verify/verify-release-assets_test.sh /assets; \ + curl --fail --location --silent --show-error --output /assets/gh_2.96.0_linux_amd64.tar.gz "${GH_CLI_LINUX_AMD64_URL}"; \ + curl --fail --location --silent --show-error --output /assets/gh_2.96.0_checksums.txt "${GH_CLI_CHECKSUMS_URL}"; \ + /verify/verify-gh-assets_test.sh /assets; \ + /verify/bootstrap-gh_test.sh /assets; \ + curl --fail --location --silent --show-error --output /assets/ttyd.x86_64 "${TTYD_X86_64_URL}"; \ + printf '%s %s\n' "${TTYD_X86_64_SHA256}" /assets/ttyd.x86_64 | sha256sum -c -; \ + curl --fail --location --silent --show-error --output /assets/eza_x86_64-unknown-linux-gnu.tar.gz "${EZA_X86_64_GNU_URL}"; \ + printf '%s %s\n' "${EZA_X86_64_GNU_SHA256}" /assets/eza_x86_64-unknown-linux-gnu.tar.gz | sha256sum -c -; \ + tar -xzf /assets/vhs_0.11.0_Linux_x86_64.tar.gz -C /extract/vhs; \ + tar -xzf /assets/eza_x86_64-unknown-linux-gnu.tar.gz -C /extract/eza; \ + cp /extract/vhs/vhs_0.11.0_Linux_x86_64/vhs /out/vhs; \ + cp /assets/ttyd.x86_64 /out/ttyd; \ + cp /extract/eza/eza /out/eza; \ + chmod 0755 /out/vhs /out/ttyd /out/eza + +FROM runtime-base AS runtime + +COPY --from=go-build /out/readme-terminal-demo /usr/local/bin/readme-terminal-demo +COPY --from=release-assets /out/vhs /usr/local/bin/vhs +COPY --from=release-assets /out/ttyd /usr/local/bin/ttyd +COPY --from=release-assets /out/eza /usr/local/bin/eza +COPY testdata/smoke/readme.tape /usr/local/share/readme-terminal-demo/smoke.tape +RUN mkdir -p /landlock-denied \ + && printf 'Landlock must deny this readable file.\n' > /landlock-denied/readable \ + && chmod 0777 /landlock-denied \ + && chmod 0444 /landlock-denied/readable \ + && chmod 0444 /usr/local/share/readme-terminal-demo/smoke.tape \ + && chmod 0755 /usr/local/bin/readme-terminal-demo /usr/local/bin/vhs /usr/local/bin/ttyd /usr/local/bin/eza + +USER 65532:65532 +WORKDIR /work +ENTRYPOINT ["/usr/bin/tini", "--"] + +FROM runtime AS test +RUN set -eu; \ + test "$(/usr/local/bin/vhs --version)" = 'vhs version v0.11.0 (c6af91a)'; \ + vhs_status=0; \ + /usr/local/bin/vhs --definitely-invalid >/tmp/vhs-invalid.out 2>&1 || vhs_status=$?; \ + test "$vhs_status" -eq 1; \ + rm -f /tmp/vhs-invalid.out; \ + printf '%s\n' 'PASS TestPinnedVHSExitStatusContract' diff --git a/tools/readme-terminal-demo/cmd/readme-terminal-demo/main.go b/tools/readme-terminal-demo/cmd/readme-terminal-demo/main.go new file mode 100644 index 000000000..cedb2e52f --- /dev/null +++ b/tools/readme-terminal-demo/cmd/readme-terminal-demo/main.go @@ -0,0 +1,570 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "errors" + "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/z-shell/.github/tools/readme-terminal-demo/internal/failure" + "github.com/z-shell/.github/tools/readme-terminal-demo/internal/sandbox" + "golang.org/x/sys/unix" +) + +const ( + captureTimeout = 45 * time.Second + chromiumLauncherPath = "/usr/bin/chromium" + chromiumLauncherSHA256 = "2c8d32d29dc6781c35ae196ea83299d507ac88fc339301abe74672feda299779" + restrictedRendererPATH = "/usr/local/bin:/usr/bin:/bin" + runtimeWorkRoot = "/work" + rendererUnavailableChildExit = 4 + fixedZshStartup = "PROMPT='demo > '\nRPROMPT=''\nPROMPT_EOL_MARK=''\nunsetopt promptsubst\nsetopt no_beep\n" +) + +var privateRuntimeDirectories = []string{ + "home", + "zdotdir", + "cache", + "config", + "data", + "runtime", + "tmp", + "demo", + "eza-config", +} + +// Rod v0.116.2 tests these Linux candidates in this order. The final name must +// resolve to the preflighted immutable launcher; every earlier candidate must +// remain absent so Rod cannot select a different browser. +var rodLinuxBrowserCandidatesThroughChromium = []string{ + "chrome", + "google-chrome", + "/usr/bin/google-chrome", + "microsoft-edge", + "/usr/bin/microsoft-edge", + "chromium", +} + +type browserPreflightConfig struct { + target string + expectedSHA256 string + lookPath func(string) (string, error) +} + +type captureDependencies struct { + browserPreflight func() error + prepareRuntime func() error + changeDirectory func(string) error + vhsPath string + execRestricted func(sandbox.ExecSpec) error +} + +type restrictedChildRunner func(context.Context, sandbox.Child) (sandbox.Outcome, error) + +func main() { + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) +} + +func run(args []string, stdout, stderr io.Writer) int { + var err error + switch { + case len(args) == 2 && args[0] == "__capture": + err = capture(args[1]) + case len(args) == 1 && args[0] == "--self-test": + err = selfTest(stdout) + default: + err = failure.E(failure.InvalidContract, failure.StageInput, "", failure.RuleInputRef, errors.New("unsupported invocation")) + } + if err == nil { + return 0 + } + fmt.Fprintln(stderr, err) + return failure.ExitCode(err) +} + +func capture(tape string) error { + return captureWith(tape, captureDependencies{ + browserPreflight: preflightBrowser, + prepareRuntime: prepareRuntimeDirectories, + changeDirectory: os.Chdir, + vhsPath: "/usr/local/bin/vhs", + execRestricted: sandbox.ExecRestricted, + }) +} + +func captureWith(tape string, dependencies captureDependencies) error { + if dependencies.browserPreflight == nil || dependencies.prepareRuntime == nil || + dependencies.changeDirectory == nil || dependencies.vhsPath == "" || dependencies.execRestricted == nil { + return failure.E(failure.RendererUnavailable, failure.StageRuntime, "", failure.RuleRuntimeUnavailable, errors.New("capture dependency unavailable")) + } + if err := dependencies.browserPreflight(); err != nil { + return failure.E(failure.RendererUnavailable, failure.StageRuntime, "", failure.RuleRuntimeUnavailable, err) + } + if err := dependencies.prepareRuntime(); err != nil { + return failure.E(failure.RendererUnavailable, failure.StageRuntime, "", failure.RuleRuntimeUnavailable, err) + } + if err := dependencies.changeDirectory("/work/demo"); err != nil { + return failure.E(failure.RendererUnavailable, failure.StageRuntime, "", failure.RuleRuntimeUnavailable, err) + } + + spec := sandbox.ExecSpec{ + Path: dependencies.vhsPath, + Args: []string{dependencies.vhsPath, tape}, + Env: vhsExecEnvironment(), + Policy: sandbox.Policy{ + ReadOnlyPaths: existingDirectories("/usr", "/etc", "/proc", "/sys", "/dev"), + ReadWritePaths: existingDirectories("/tmp", "/work", "/dev/shm"), + }, + } + if err := dependencies.execRestricted(spec); err != nil { + return failure.E(failure.RendererUnavailable, failure.StageRuntime, "", failure.RuleRuntimeUnavailable, err) + } + return nil +} + +func selfTestChildOutcome(child sandbox.Child, runChild restrictedChildRunner) error { + if runChild == nil { + return failure.E(failure.ExecutionFailed, failure.StageCapture, "", failure.RuleCaptureFailed, errors.New("restricted child runner unavailable")) + } + outcome, err := runChild(context.Background(), child) + return mapChildOutcome(outcome, err) +} + +func mapChildOutcome(outcome sandbox.Outcome, childErr error) error { + if outcome.TimedOut || errors.Is(childErr, sandbox.ErrProcessTimeout) { + return failure.E(failure.ExecutionFailed, failure.StageCapture, "", failure.RuleCaptureTimeout, errors.New("restricted child timed out")) + } + if childErr != nil { + return failure.E(failure.ExecutionFailed, failure.StageCapture, "", failure.RuleCaptureFailed, errors.New("restricted child cleanup failed")) + } + if outcome.Exited && outcome.ExitCode == 0 { + return nil + } + if outcome.Exited && outcome.ExitCode == rendererUnavailableChildExit { + return failure.E(failure.RendererUnavailable, failure.StageRuntime, "", failure.RuleRuntimeUnavailable, errors.New("restricted renderer unavailable")) + } + return failure.E(failure.ExecutionFailed, failure.StageCapture, "", failure.RuleCaptureFailed, errors.New("restricted capture failed")) +} + +func preflightBrowser() error { + return validateBrowser(browserPreflightConfig{ + target: chromiumLauncherPath, + expectedSHA256: chromiumLauncherSHA256, + lookPath: lookPathInRendererPath, + }) +} + +func validateBrowser(config browserPreflightConfig) error { + if config.target == "" || config.expectedSHA256 == "" || config.lookPath == nil { + return errors.New("invalid browser preflight configuration") + } + + last := len(rodLinuxBrowserCandidatesThroughChromium) - 1 + for index, candidate := range rodLinuxBrowserCandidatesThroughChromium { + resolved, err := config.lookPath(candidate) + if index != last { + if err == nil { + return fmt.Errorf("earlier browser candidate available: %s", candidate) + } + if !errors.Is(err, exec.ErrNotFound) { + return fmt.Errorf("inspect earlier browser candidate: %w", err) + } + continue + } + if err != nil { + return fmt.Errorf("resolve Chromium launcher: %w", err) + } + if filepath.Clean(resolved) != filepath.Clean(config.target) { + return errors.New("Chromium launcher resolved to an unexpected path") + } + } + + return validatePinnedLauncher(config.target, config.expectedSHA256) +} + +func lookPathInRendererPath(name string) (string, error) { + if strings.ContainsRune(name, os.PathSeparator) { + return executableCandidate(name) + } + for _, directory := range filepath.SplitList(restrictedRendererPATH) { + candidate := filepath.Join(directory, name) + resolved, err := executableCandidate(candidate) + if err == nil { + return resolved, nil + } + if !errors.Is(err, exec.ErrNotFound) { + return "", err + } + } + return "", exec.ErrNotFound +} + +func executableCandidate(path string) (string, error) { + info, err := os.Stat(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", exec.ErrNotFound + } + return "", err + } + if info.IsDir() || info.Mode().Perm()&0o111 == 0 { + return "", exec.ErrNotFound + } + return path, nil +} + +func validatePinnedLauncher(path, expectedSHA256 string) error { + var before unix.Stat_t + if err := unix.Fstatat(unix.AT_FDCWD, path, &before, unix.AT_SYMLINK_NOFOLLOW); err != nil { + return fmt.Errorf("inspect Chromium launcher: %w", err) + } + if before.Mode&unix.S_IFMT != unix.S_IFREG { + return errors.New("Chromium launcher is not a regular file") + } + if before.Uid != 0 { + return errors.New("Chromium launcher is not root-owned") + } + if before.Mode&0o7777 != 0o755 { + return errors.New("Chromium launcher mode is not 0755") + } + + fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err != nil { + return fmt.Errorf("open Chromium launcher without following links: %w", err) + } + file := os.NewFile(uintptr(fd), "chromium-launcher") + if file == nil { + _ = unix.Close(fd) + return errors.New("open Chromium launcher") + } + + var opened unix.Stat_t + if err := unix.Fstat(fd, &opened); err != nil { + _ = file.Close() + return fmt.Errorf("inspect opened Chromium launcher: %w", err) + } + if before.Dev != opened.Dev || before.Ino != opened.Ino || before.Mode != opened.Mode || before.Uid != opened.Uid { + _ = file.Close() + return errors.New("Chromium launcher changed during preflight") + } + + hasher := sha256.New() + if _, err := io.Copy(hasher, file); err != nil { + _ = file.Close() + return fmt.Errorf("hash Chromium launcher: %w", err) + } + if err := file.Close(); err != nil { + return fmt.Errorf("close Chromium launcher: %w", err) + } + if got := fmt.Sprintf("%x", hasher.Sum(nil)); got != expectedSHA256 { + return errors.New("Chromium launcher checksum mismatch") + } + return nil +} + +func selfTest(stdout io.Writer) error { + if os.Geteuid() == 0 { + return failure.E(failure.RendererUnavailable, failure.StageRuntime, "", failure.RuleRuntimeUnavailable, errors.New("root runtime")) + } + for _, tool := range []string{"vhs", "ttyd", "chromium", "fc-match", "zsh", "ffmpeg"} { + if _, err := exec.LookPath(tool); err != nil { + return failure.E(failure.RendererUnavailable, failure.StageRuntime, "", failure.RuleRuntimeUnavailable, err) + } + } + font, err := exec.Command("fc-match", "JetBrains Mono").Output() + if err != nil || !bytes.Contains(bytes.ToLower(font), []byte("jetbrains")) { + return failure.E(failure.RendererUnavailable, failure.StageRuntime, "", failure.RuleRuntimeUnavailable, errors.New("font unavailable")) + } + if connection, err := net.DialTimeout("tcp", "1.1.1.1:443", 500*time.Millisecond); err == nil { + _ = connection.Close() + return failure.E(failure.RendererUnavailable, failure.StageRuntime, "", failure.RuleRuntimeUnavailable, errors.New("network egress available")) + } + if _, err := os.ReadFile("/landlock-denied/readable"); err != nil { + return failure.E(failure.RendererUnavailable, failure.StageRuntime, "", failure.RuleRuntimeUnavailable, err) + } + + executable, err := os.Executable() + if err != nil { + return failure.E(failure.RendererUnavailable, failure.StageRuntime, "", failure.RuleRuntimeUnavailable, err) + } + tape := "/usr/local/share/readme-terminal-demo/smoke.tape" + var childOutput bytes.Buffer + err = selfTestChildOutcome(sandbox.Child{ + Path: executable, + Args: []string{"__capture", tape}, + Env: captureHelperEnvironment(), + Dir: "/work", + Stdout: &childOutput, + Stderr: &childOutput, + Timeout: captureTimeout, + }, sandbox.RunRestrictedChild) + if err != nil { + return err + } + media := "/work/smoke.gif" + info, err := os.Stat(media) + if err != nil || info.Size() == 0 { + return failure.E(failure.ExecutionFailed, failure.StageMedia, "", failure.RuleMediaInvalid, err) + } + fmt.Fprintln(stdout, "readme-terminal-demo self-test ok") + return nil +} + +func prepareRuntimeDirectories() error { + return prepareRuntimeDirectoriesAt(runtimeWorkRoot) +} + +func prepareRuntimeDirectoriesAt(root string) error { + if root == "" || !filepath.IsAbs(root) || filepath.Clean(root) != root { + return errors.New("invalid runtime root") + } + + rootFD, err := unix.Open(root, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err != nil { + return fmt.Errorf("open private runtime root: %w", err) + } + defer unix.Close(rootFD) + if err := requireOwnedPrivateDirectory(rootFD); err != nil { + return fmt.Errorf("validate private runtime root: %w", err) + } + + for _, name := range privateRuntimeDirectories { + fd, err := ensurePrivateDirectoryAt(rootFD, name) + if err != nil { + return err + } + if err := unix.Close(fd); err != nil { + return fmt.Errorf("close private runtime directory: %w", err) + } + } + + ezaFD, err := openPrivateDirectoryAt(rootFD, "eza-config") + if err != nil { + return err + } + if err := requireEmptyDirectory(ezaFD); err != nil { + _ = unix.Close(ezaFD) + return fmt.Errorf("validate empty eza configuration: %w", err) + } + if err := unix.Close(ezaFD); err != nil { + return fmt.Errorf("close empty eza configuration: %w", err) + } + + zdotdirFD, err := openPrivateDirectoryAt(rootFD, "zdotdir") + if err != nil { + return err + } + defer unix.Close(zdotdirFD) + if err := writePrivateFileAt(zdotdirFD, ".zshrc", []byte(fixedZshStartup)); err != nil { + return fmt.Errorf("write fixed zsh startup: %w", err) + } + return nil +} + +func ensurePrivateDirectoryAt(parentFD int, name string) (int, error) { + if name == "" || name == "." || name == ".." || strings.ContainsRune(name, os.PathSeparator) { + return -1, errors.New("invalid private runtime directory name") + } + if err := unix.Mkdirat(parentFD, name, 0o700); err != nil && !errors.Is(err, unix.EEXIST) { + return -1, fmt.Errorf("create private runtime directory: %w", err) + } + fd, err := openPrivateDirectoryAt(parentFD, name) + if err != nil { + return -1, err + } + if err := unix.Fchmod(fd, 0o700); err != nil { + _ = unix.Close(fd) + return -1, fmt.Errorf("set private runtime directory mode: %w", err) + } + return fd, nil +} + +func openPrivateDirectoryAt(parentFD int, name string) (int, error) { + fd, err := unix.Openat(parentFD, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err != nil { + return -1, fmt.Errorf("open private runtime directory: %w", err) + } + if err := requireOwnedDirectory(fd); err != nil { + _ = unix.Close(fd) + return -1, fmt.Errorf("validate private runtime directory: %w", err) + } + return fd, nil +} + +func requireOwnedPrivateDirectory(fd int) error { + if err := requireOwnedDirectory(fd); err != nil { + return err + } + var stat unix.Stat_t + if err := unix.Fstat(fd, &stat); err != nil { + return err + } + if stat.Mode&0o7777 != 0o700 { + return errors.New("runtime root mode is not 0700") + } + return nil +} + +func requireOwnedDirectory(fd int) error { + var stat unix.Stat_t + if err := unix.Fstat(fd, &stat); err != nil { + return err + } + if stat.Mode&unix.S_IFMT != unix.S_IFDIR { + return errors.New("runtime path is not a directory") + } + if int(stat.Uid) != os.Geteuid() || int(stat.Gid) != os.Getegid() { + return errors.New("runtime directory has unexpected ownership") + } + return nil +} + +func requireEmptyDirectory(fd int) error { + duplicate, err := unix.Dup(fd) + if err != nil { + return err + } + directory := os.NewFile(uintptr(duplicate), "eza-config") + if directory == nil { + _ = unix.Close(duplicate) + return errors.New("open eza configuration directory") + } + entries, readErr := directory.ReadDir(1) + closeErr := directory.Close() + if len(entries) != 0 { + return errors.New("eza configuration directory is not empty") + } + if readErr != nil && !errors.Is(readErr, io.EOF) { + return readErr + } + return closeErr +} + +func writePrivateFileAt(parentFD int, name string, content []byte) error { + flags := unix.O_WRONLY | unix.O_CLOEXEC | unix.O_NOFOLLOW | unix.O_NONBLOCK + fd, err := unix.Openat(parentFD, name, flags|unix.O_CREAT|unix.O_EXCL, 0o600) + if errors.Is(err, unix.EEXIST) { + fd, err = unix.Openat(parentFD, name, flags, 0) + } + if err != nil { + return err + } + defer unix.Close(fd) + + var stat unix.Stat_t + if err := unix.Fstat(fd, &stat); err != nil { + return err + } + if stat.Mode&unix.S_IFMT != unix.S_IFREG || stat.Nlink != 1 { + return errors.New("private runtime file is not a unique regular file") + } + if int(stat.Uid) != os.Geteuid() || int(stat.Gid) != os.Getegid() { + return errors.New("private runtime file has unexpected ownership") + } + if err := unix.Fchmod(fd, 0o600); err != nil { + return err + } + if err := unix.Ftruncate(fd, 0); err != nil { + return err + } + for len(content) != 0 { + written, err := unix.Write(fd, content) + if errors.Is(err, unix.EINTR) { + continue + } + if err != nil { + return err + } + if written == 0 { + return io.ErrShortWrite + } + content = content[written:] + } + return nil +} + +func captureHelperEnvironment() []string { + return []string{ + "PATH=/usr/local/bin:/usr/bin:/bin", + "PWD=/work/demo", + "HOME=/work/home", + "ZDOTDIR=/work/zdotdir", + "XDG_CACHE_HOME=/work/cache", + "XDG_CONFIG_HOME=/work/config", + "XDG_DATA_HOME=/work/data", + "XDG_RUNTIME_DIR=/work/runtime", + "TMPDIR=/work/tmp", + "LANG=C.UTF-8", + "LC_ALL=C.UTF-8", + "TZ=UTC", + "TERM=xterm-256color", + "COLORTERM=truecolor", + "USER=demo", + "LOGNAME=demo", + "SHELL=/usr/bin/zsh", + "PAGER=cat", + "GIT_PAGER=cat", + "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/dev/null", + "HISTFILE=/dev/null", + "SOURCE_DATE_EPOCH=946684800", + "FONTCONFIG_FILE=/etc/fonts/fonts.conf", + "FONTCONFIG_PATH=/etc/fonts", + "EZA_CONFIG_DIR=/work/eza-config", + "EZA_COLORS=", + "LS_COLORS=", + } +} + +func vhsExecEnvironment() []string { + return []string{ + "PATH=/usr/local/bin:/usr/bin:/bin", + "PWD=/work/demo", + "HOME=/work/home", + "ZDOTDIR=/work/zdotdir", + "XDG_CACHE_HOME=/work/cache", + "XDG_CONFIG_HOME=/work/config", + "XDG_DATA_HOME=/work/data", + "XDG_RUNTIME_DIR=/work/runtime", + "TMPDIR=/work/tmp", + "LANG=C.UTF-8", + "LC_ALL=C.UTF-8", + "TZ=UTC", + "TERM=xterm-256color", + "COLORTERM=truecolor", + "USER=demo", + "LOGNAME=demo", + "SHELL=/usr/bin/zsh", + "PAGER=cat", + "GIT_PAGER=cat", + "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/dev/null", + "HISTFILE=/dev/null", + "SOURCE_DATE_EPOCH=946684800", + "VHS_NO_SANDBOX=1", + "FONTCONFIG_FILE=/etc/fonts/fonts.conf", + "FONTCONFIG_PATH=/etc/fonts", + "EZA_CONFIG_DIR=/work/eza-config", + "EZA_COLORS=", + "LS_COLORS=", + } +} + +func existingDirectories(paths ...string) []string { + result := make([]string, 0, len(paths)) + for _, path := range paths { + info, err := os.Stat(path) + if err == nil && info.IsDir() { + result = append(result, filepath.Clean(path)) + } + } + return result +} diff --git a/tools/readme-terminal-demo/cmd/readme-terminal-demo/main_test.go b/tools/readme-terminal-demo/cmd/readme-terminal-demo/main_test.go new file mode 100644 index 000000000..6b414d0ff --- /dev/null +++ b/tools/readme-terminal-demo/cmd/readme-terminal-demo/main_test.go @@ -0,0 +1,851 @@ +package main + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/z-shell/.github/tools/readme-terminal-demo/internal/failure" + "github.com/z-shell/.github/tools/readme-terminal-demo/internal/sandbox" + "golang.org/x/sys/unix" +) + +const testUnsupportedLandlockCapture = "README_TERMINAL_DEMO_TEST_UNSUPPORTED_CAPTURE" + +func TestReservedChildExitMapping(t *testing.T) { + tests := reservedChildOutcomeCases() + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := mapChildOutcome(test.outcome, nil) + assertChildOutcomeMapping(t, err, test.wantClass, test.wantMessage) + }) + } +} + +func TestSelfTestMapsChildOutcome(t *testing.T) { + wantChild := sandbox.Child{ + Path: "/trusted/readme-terminal-demo", + Args: []string{"__capture", "/trusted/smoke.tape"}, + Dir: "/work", + } + for _, test := range reservedChildOutcomeCases() { + t.Run(test.name, func(t *testing.T) { + called := false + err := selfTestChildOutcome(wantChild, func(ctx context.Context, gotChild sandbox.Child) (sandbox.Outcome, error) { + called = true + if ctx == nil { + t.Fatal("self-test child runner received a nil context") + } + if !reflect.DeepEqual(gotChild, wantChild) { + t.Fatalf("self-test child = %#v, want %#v", gotChild, wantChild) + } + return test.outcome, nil + }) + if !called { + t.Fatal("self-test child runner was not called") + } + assertChildOutcomeMapping(t, err, test.wantClass, test.wantMessage) + }) + } +} + +type reservedChildOutcomeCase struct { + name string + outcome sandbox.Outcome + wantClass failure.Class + wantMessage string +} + +func reservedChildOutcomeCases() []reservedChildOutcomeCase { + return []reservedChildOutcomeCase{ + {name: "normal-exit-zero", outcome: sandbox.Outcome{Exited: true, ExitCode: 0}}, + { + name: "VHS-normal-exit-one", + outcome: sandbox.Outcome{Exited: true, ExitCode: 1}, + wantClass: failure.ExecutionFailed, + wantMessage: "execution-failed: capture (capture.failed)", + }, + { + name: "launcher-normal-exit-four", + outcome: sandbox.Outcome{Exited: true, ExitCode: 4}, + wantClass: failure.RendererUnavailable, + wantMessage: "renderer-unavailable: runtime (runtime.unavailable)", + }, + { + name: "launcher-normal-exit-five", + outcome: sandbox.Outcome{Exited: true, ExitCode: 5}, + wantClass: failure.ExecutionFailed, + wantMessage: "execution-failed: capture (capture.failed)", + }, + { + name: "signal-four-is-not-exit-four", + outcome: sandbox.Outcome{Signaled: true, Signal: unix.Signal(4)}, + wantClass: failure.ExecutionFailed, + wantMessage: "execution-failed: capture (capture.failed)", + }, + { + name: "signal-five-is-not-exit-five", + outcome: sandbox.Outcome{Signaled: true, Signal: unix.Signal(5)}, + wantClass: failure.ExecutionFailed, + wantMessage: "execution-failed: capture (capture.failed)", + }, + } +} + +func assertChildOutcomeMapping(t *testing.T, err error, wantClass failure.Class, wantMessage string) { + t.Helper() + + if wantClass == "" { + if err != nil { + t.Fatalf("mapped child outcome error = %v, want nil", err) + } + return + } + if got := failure.Classify(err); got != wantClass { + t.Fatalf("mapped child class = %q, want %q", got, wantClass) + } + if got := err.Error(); got != wantMessage { + t.Fatalf("mapped child message = %q, want %q", got, wantMessage) + } +} + +func TestCaptureHelperEnvironmentMatchesLiteralAllowlist(t *testing.T) { + t.Setenv("HTTPS_PROXY", "http://hostile.invalid") + t.Setenv("GITHUB_TOKEN", "must-not-leak") + t.Setenv("CHROME_BIN", "/tmp/host-browser") + t.Setenv("CHROME_DEVEL_SANDBOX", "/tmp/host-sandbox") + t.Setenv("HOME", "/tmp/host-home") + + want := literalRendererEnvironment(false) + got := captureHelperEnvironment() + if !reflect.DeepEqual(got, want) { + t.Fatalf("capture helper environment = %#v, want exact ordered allowlist %#v", got, want) + } + assertEnvironmentExcludesHostState(t, got) +} + +func TestVHSExecEnvironmentMatchesLiteralAllowlist(t *testing.T) { + t.Setenv("HTTPS_PROXY", "http://hostile.invalid") + t.Setenv("GITHUB_TOKEN", "must-not-leak") + t.Setenv("CHROME_BIN", "/tmp/host-browser") + t.Setenv("CHROME_DEVEL_SANDBOX", "/tmp/host-sandbox") + t.Setenv("HOME", "/tmp/host-home") + + want := literalRendererEnvironment(true) + got := vhsExecEnvironment() + if !reflect.DeepEqual(got, want) { + t.Fatalf("VHS exec environment = %#v, want exact ordered allowlist %#v", got, want) + } + assertEnvironmentExcludesHostState(t, got) +} + +func TestCaptureUsesDistinctHelperAndExecEnvironments(t *testing.T) { + helper := captureHelperEnvironment() + execEnvironment := vhsExecEnvironment() + if reflect.DeepEqual(helper, execEnvironment) { + t.Fatal("capture helper and VHS exec environments are the same conflated vector") + } + if got := strings.Count(strings.Join(helper, "\n"), "VHS_NO_SANDBOX="); got != 0 { + t.Fatalf("capture helper VHS_NO_SANDBOX count = %d, want 0", got) + } + if got := strings.Count(strings.Join(execEnvironment, "\n"), "VHS_NO_SANDBOX=1"); got != 1 { + t.Fatalf("VHS exec exception count = %d, want 1", got) + } + if execEnvironment[23] != "VHS_NO_SANDBOX=1" || execEnvironment[22] != "SOURCE_DATE_EPOCH=946684800" { + t.Fatalf("VHS exception placement = %#v, want immediately after SOURCE_DATE_EPOCH", execEnvironment) + } +} + +func literalRendererEnvironment(includeVHSException bool) []string { + environment := []string{ + "PATH=/usr/local/bin:/usr/bin:/bin", + "PWD=/work/demo", + "HOME=/work/home", + "ZDOTDIR=/work/zdotdir", + "XDG_CACHE_HOME=/work/cache", + "XDG_CONFIG_HOME=/work/config", + "XDG_DATA_HOME=/work/data", + "XDG_RUNTIME_DIR=/work/runtime", + "TMPDIR=/work/tmp", + "LANG=C.UTF-8", + "LC_ALL=C.UTF-8", + "TZ=UTC", + "TERM=xterm-256color", + "COLORTERM=truecolor", + "USER=demo", + "LOGNAME=demo", + "SHELL=/usr/bin/zsh", + "PAGER=cat", + "GIT_PAGER=cat", + "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/dev/null", + "HISTFILE=/dev/null", + "SOURCE_DATE_EPOCH=946684800", + } + if includeVHSException { + environment = append(environment, "VHS_NO_SANDBOX=1") + } + return append(environment, + "FONTCONFIG_FILE=/etc/fonts/fonts.conf", + "FONTCONFIG_PATH=/etc/fonts", + "EZA_CONFIG_DIR=/work/eza-config", + "EZA_COLORS=", + "LS_COLORS=", + ) +} + +func assertEnvironmentExcludesHostState(t *testing.T, environment []string) { + t.Helper() + joined := strings.Join(environment, "\n") + for _, forbidden := range []string{ + "http://hostile.invalid", + "must-not-leak", + "/tmp/host-browser", + "/tmp/host-sandbox", + "/tmp/host-home", + "HTTPS_PROXY=", + "GITHUB_TOKEN=", + "CHROME_BIN=", + "CHROME_DEVEL_SANDBOX=", + } { + if strings.Contains(joined, forbidden) { + t.Fatalf("environment contains forbidden host state %q: %#v", forbidden, environment) + } + } +} + +func TestPrepareRuntimeDirectoriesCreatesExactPrivateState(t *testing.T) { + root := filepath.Join(t.TempDir(), "work") + if err := os.Mkdir(root, 0o700); err != nil { + t.Fatalf("create runtime root: %v", err) + } + + if err := prepareRuntimeDirectoriesAt(root); err != nil { + t.Fatalf("prepareRuntimeDirectoriesAt() error = %v", err) + } + + wantDirectories := []string{ + "cache", + "config", + "data", + "demo", + "eza-config", + "home", + "runtime", + "tmp", + "zdotdir", + } + entries, err := os.ReadDir(root) + if err != nil { + t.Fatalf("read runtime root: %v", err) + } + gotDirectories := make([]string, 0, len(entries)) + for _, entry := range entries { + if !entry.IsDir() { + t.Fatalf("runtime root entry %q is not a directory", entry.Name()) + } + gotDirectories = append(gotDirectories, entry.Name()) + } + if !reflect.DeepEqual(gotDirectories, wantDirectories) { + t.Fatalf("runtime directories = %#v, want %#v", gotDirectories, wantDirectories) + } + + for _, name := range wantDirectories { + assertOwnedMode(t, filepath.Join(root, name), 0o700) + } + + const wantZshrc = "PROMPT='demo > '\nRPROMPT=''\nPROMPT_EOL_MARK=''\nunsetopt promptsubst\nsetopt no_beep\n" + zshrc := filepath.Join(root, "zdotdir", ".zshrc") + content, err := os.ReadFile(zshrc) + if err != nil { + t.Fatalf("read fixed zsh startup: %v", err) + } + if got := string(content); got != wantZshrc { + t.Fatalf("fixed zsh startup = %q, want %q", got, wantZshrc) + } + assertOwnedMode(t, zshrc, 0o600) + + ezaEntries, err := os.ReadDir(filepath.Join(root, "eza-config")) + if err != nil { + t.Fatalf("read eza config directory: %v", err) + } + if len(ezaEntries) != 0 { + t.Fatalf("eza config directory contains %d entries, want empty", len(ezaEntries)) + } +} + +func TestPrepareRuntimeDirectoriesRejectsUnsafeExistingState(t *testing.T) { + tests := []struct { + name string + poison func(*testing.T, string) string + }{ + { + name: "runtime-root-symlink", + poison: func(t *testing.T, root string) string { + if err := os.Remove(root); err != nil { + t.Fatalf("remove runtime root: %v", err) + } + outside := filepath.Join(t.TempDir(), "outside") + if err := os.Mkdir(outside, 0o700); err != nil { + t.Fatalf("create outside directory: %v", err) + } + if err := os.Symlink(outside, root); err != nil { + t.Fatalf("symlink runtime root: %v", err) + } + return "" + }, + }, + { + name: "private-directory-symlink", + poison: func(t *testing.T, root string) string { + outside := filepath.Join(t.TempDir(), "outside") + if err := os.Mkdir(outside, 0o700); err != nil { + t.Fatalf("create outside directory: %v", err) + } + if err := os.Symlink(outside, filepath.Join(root, "home")); err != nil { + t.Fatalf("symlink private directory: %v", err) + } + return "" + }, + }, + { + name: "fixed-startup-symlink", + poison: func(t *testing.T, root string) string { + zdotdir := filepath.Join(root, "zdotdir") + if err := os.Mkdir(zdotdir, 0o700); err != nil { + t.Fatalf("create zdotdir: %v", err) + } + outside := filepath.Join(t.TempDir(), "outside-zshrc") + if err := os.WriteFile(outside, []byte("sentinel\n"), 0o600); err != nil { + t.Fatalf("write outside startup: %v", err) + } + if err := os.Symlink(outside, filepath.Join(zdotdir, ".zshrc")); err != nil { + t.Fatalf("symlink startup: %v", err) + } + return outside + }, + }, + { + name: "fixed-startup-hardlink", + poison: func(t *testing.T, root string) string { + zdotdir := filepath.Join(root, "zdotdir") + if err := os.Mkdir(zdotdir, 0o700); err != nil { + t.Fatalf("create zdotdir: %v", err) + } + outside := filepath.Join(root, "outside-zshrc") + if err := os.WriteFile(outside, []byte("sentinel\n"), 0o600); err != nil { + t.Fatalf("write outside startup: %v", err) + } + if err := os.Link(outside, filepath.Join(zdotdir, ".zshrc")); err != nil { + t.Fatalf("hardlink startup: %v", err) + } + return outside + }, + }, + { + name: "non-empty-eza-config", + poison: func(t *testing.T, root string) string { + eza := filepath.Join(root, "eza-config") + if err := os.Mkdir(eza, 0o700); err != nil { + t.Fatalf("create eza config: %v", err) + } + if err := os.WriteFile(filepath.Join(eza, "theme.yml"), []byte("poison\n"), 0o600); err != nil { + t.Fatalf("write eza config: %v", err) + } + return "" + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := filepath.Join(t.TempDir(), "work") + if err := os.Mkdir(root, 0o700); err != nil { + t.Fatalf("create runtime root: %v", err) + } + outside := test.poison(t, root) + + if err := prepareRuntimeDirectoriesAt(root); err == nil { + t.Fatal("prepareRuntimeDirectoriesAt() error = nil, want unsafe-state rejection") + } + if outside != "" { + content, err := os.ReadFile(outside) + if err != nil { + t.Fatalf("read outside sentinel: %v", err) + } + if got := string(content); got != "sentinel\n" { + t.Fatalf("outside sentinel = %q, want unchanged", got) + } + } + }) + } +} + +func TestPrepareRuntimeDirectoriesNormalizesPrivateModes(t *testing.T) { + root := filepath.Join(t.TempDir(), "work") + if err := os.Mkdir(root, 0o700); err != nil { + t.Fatalf("create runtime root: %v", err) + } + if err := prepareRuntimeDirectoriesAt(root); err != nil { + t.Fatalf("initial runtime preparation: %v", err) + } + if err := os.Chmod(filepath.Join(root, "home"), 0o755); err != nil { + t.Fatalf("poison home mode: %v", err) + } + if err := os.Chmod(filepath.Join(root, "zdotdir", ".zshrc"), 0o666); err != nil { + t.Fatalf("poison startup mode: %v", err) + } + + if err := prepareRuntimeDirectoriesAt(root); err != nil { + t.Fatalf("repeat runtime preparation: %v", err) + } + assertOwnedMode(t, filepath.Join(root, "home"), 0o700) + assertOwnedMode(t, filepath.Join(root, "zdotdir", ".zshrc"), 0o600) +} + +func TestCapturePreparesAndEntersDemoBeforeRestrictedExec(t *testing.T) { + t.Setenv("README_TERMINAL_DEMO_TEST_VHS", "/hostile/environment/vhs") + + var events []string + err := captureWith("/trusted/readme.tape", captureDependencies{ + browserPreflight: func() error { + events = append(events, "browser") + return nil + }, + prepareRuntime: func() error { + events = append(events, "prepare") + return nil + }, + changeDirectory: func(path string) error { + if path != "/work/demo" { + t.Fatalf("capture working directory = %q, want /work/demo", path) + } + events = append(events, "chdir") + return nil + }, + vhsPath: "/trusted/vhs", + execRestricted: func(spec sandbox.ExecSpec) error { + events = append(events, "exec") + if spec.Path != "/trusted/vhs" { + t.Fatalf("restricted executable = %q, want trusted dependency path", spec.Path) + } + if want := []string{"/trusted/vhs", "/trusted/readme.tape"}; !reflect.DeepEqual(spec.Args, want) { + t.Fatalf("restricted argv = %#v, want %#v", spec.Args, want) + } + return nil + }, + }) + if err != nil { + t.Fatalf("captureWith() error = %v", err) + } + if want := []string{"browser", "prepare", "chdir", "exec"}; !reflect.DeepEqual(events, want) { + t.Fatalf("capture events = %#v, want %#v", events, want) + } +} + +func TestCaptureFailsClosedWithoutLandlockV3(t *testing.T) { + if os.Getenv(testUnsupportedLandlockCapture) == "child" { + err := captureWith(os.Getenv("README_TERMINAL_DEMO_TEST_TAPE"), captureDependencies{ + browserPreflight: func() error { return nil }, + prepareRuntime: func() error { return nil }, + changeDirectory: func(string) error { return nil }, + vhsPath: os.Getenv("README_TERMINAL_DEMO_TEST_CAPTURE_VHS"), + execRestricted: func(sandbox.ExecSpec) error { + return sandbox.ErrLandlockV3Unavailable + }, + }) + if err == nil { + os.Exit(0) + } + fmt.Fprintln(os.Stderr, err) + os.Exit(failure.ExitCode(err)) + } + + directory := t.TempDir() + marker := filepath.Join(directory, "vhs-started") + fakeVHS := filepath.Join(directory, "vhs") + script := "#!/bin/sh\nprintf started > '" + strings.ReplaceAll(marker, "'", "'\\''") + "'\n" + if err := os.WriteFile(fakeVHS, []byte(script), 0o755); err != nil { + t.Fatalf("write VHS launch sentinel: %v", err) + } + tape := filepath.Join(directory, "smoke.tape") + if err := os.WriteFile(tape, []byte("Output /tmp/never.gif\n"), 0o600); err != nil { + t.Fatalf("write smoke tape: %v", err) + } + + command := exec.Command(os.Args[0], "-test.run=^TestCaptureFailsClosedWithoutLandlockV3$") + command.Env = append(os.Environ(), + testUnsupportedLandlockCapture+"=child", + "README_TERMINAL_DEMO_TEST_CAPTURE_VHS="+fakeVHS, + "README_TERMINAL_DEMO_TEST_TAPE="+tape, + ) + output, err := command.CombinedOutput() + var exitError *exec.ExitError + if !errors.As(err, &exitError) { + t.Fatalf("unsupported-ABI capture error = %v, want exit 4; output=%q", err, output) + } + if got := exitError.ExitCode(); got != 4 { + t.Fatalf("unsupported-ABI capture exit = %d, want 4; output=%q", got, output) + } + if _, err := os.Stat(marker); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("VHS launch marker stat error = %v, want not-exist", err) + } + if got := string(output); !strings.Contains(got, "renderer-unavailable: runtime (runtime.unavailable)") { + t.Fatalf("sanitized unsupported-ABI output = %q", got) + } + if strings.Contains(string(output), directory) { + t.Fatalf("unsupported-ABI output leaked a host path: %q", output) + } +} + +func TestCapturePreflightFailuresMapRendererUnavailable(t *testing.T) { + tests := []struct { + name string + err error + }{ + {name: "executable-path-validation", err: fmt.Errorf("%w: executable /secret/path", sandbox.ErrRestrictedExec)}, + {name: "embedded-NUL-argv", err: fmt.Errorf("%w: argv at /secret/path", sandbox.ErrRestrictedExec)}, + {name: "embedded-NUL-environment", err: fmt.Errorf("%w: environment at /secret/path", sandbox.ErrRestrictedExec)}, + {name: "duplicate-environment", err: fmt.Errorf("%w: duplicate environment at /secret/path", sandbox.ErrRestrictedExec)}, + {name: "policy-validation", err: fmt.Errorf("%w: policy at /secret/path", sandbox.ErrLandlockV3Unavailable)}, + {name: "ABI-2", err: fmt.Errorf("%w: ABI 2", sandbox.ErrLandlockV3Unavailable)}, + {name: "create-ruleset", err: fmt.Errorf("%w: create ruleset", sandbox.ErrLandlockV3Unavailable)}, + {name: "open-path", err: fmt.Errorf("%w: open /secret/path", sandbox.ErrLandlockV3Unavailable)}, + {name: "opened-FD-fstat", err: fmt.Errorf("%w: fstat /secret/path", sandbox.ErrLandlockV3Unavailable)}, + {name: "add-rule", err: fmt.Errorf("%w: add /secret/path", sandbox.ErrLandlockV3Unavailable)}, + {name: "vector-construction", err: fmt.Errorf("%w: vector /secret/path", sandbox.ErrRestrictedExec)}, + {name: "set-CLOEXEC", err: fmt.Errorf("%w: set CLOEXEC", sandbox.ErrLandlockV3Unavailable)}, + {name: "read-CLOEXEC", err: fmt.Errorf("%w: read CLOEXEC", sandbox.ErrLandlockV3Unavailable)}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + directory := t.TempDir() + marker := filepath.Join(directory, "vhs-started") + fakeVHS := filepath.Join(directory, "vhs") + script := "#!/bin/sh\nprintf started > '" + strings.ReplaceAll(marker, "'", "'\\''") + "'\n" + if err := os.WriteFile(fakeVHS, []byte(script), 0o755); err != nil { + t.Fatalf("write VHS sentinel: %v", err) + } + + rawExecReached := false + err := captureWith("/secret/host/readme.tape", captureDependencies{ + browserPreflight: func() error { return nil }, + prepareRuntime: func() error { return nil }, + changeDirectory: func(string) error { return nil }, + vhsPath: fakeVHS, + execRestricted: func(sandbox.ExecSpec) error { + if test.err == nil { + rawExecReached = true + } + return test.err + }, + }) + if rawExecReached { + t.Fatal("raw exec sentinel was reached after preflight failure") + } + if _, statErr := os.Stat(marker); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("VHS sentinel stat error = %v, want not-exist", statErr) + } + if got := failure.Classify(err); got != failure.RendererUnavailable { + t.Fatalf("failure class = %q, want %q; error=%q", got, failure.RendererUnavailable, err) + } + if got := failure.ExitCode(err); got != 4 { + t.Fatalf("outer exit = %d, want normal exit 4; error=%q", got, err) + } + if got, want := err.Error(), "renderer-unavailable: runtime (runtime.unavailable)"; got != want { + t.Fatalf("sanitized error = %q, want %q", got, want) + } + if strings.Contains(err.Error(), "/secret/") { + t.Fatalf("sanitized output leaked raw detail: %q", err) + } + }) + } +} + +func assertOwnedMode(t *testing.T, path string, want os.FileMode) { + t.Helper() + + var stat unix.Stat_t + if err := unix.Fstatat(unix.AT_FDCWD, path, &stat, unix.AT_SYMLINK_NOFOLLOW); err != nil { + t.Fatalf("inspect %s without following links: %v", path, err) + } + if got := os.FileMode(stat.Mode & 0o7777); got != want { + t.Fatalf("mode for %s = %#o, want %#o", path, got, want) + } + if got, wantUID := int(stat.Uid), os.Geteuid(); got != wantUID { + t.Fatalf("owner for %s = %d, want runtime UID %d", path, got, wantUID) + } + if got, wantGID := int(stat.Gid), os.Getegid(); got != wantGID { + t.Fatalf("group for %s = %d, want runtime GID %d", path, got, wantGID) + } +} + +func TestRodLinuxBrowserCandidatesThroughChromiumMatchPinnedOrder(t *testing.T) { + if chromiumLauncherPath != "/usr/bin/chromium" { + t.Fatalf("Chromium launcher path = %q, want /usr/bin/chromium", chromiumLauncherPath) + } + if chromiumLauncherSHA256 != "2c8d32d29dc6781c35ae196ea83299d507ac88fc339301abe74672feda299779" { + t.Fatalf("Chromium launcher SHA-256 = %q, want approved literal", chromiumLauncherSHA256) + } + want := []string{ + "chrome", + "google-chrome", + "/usr/bin/google-chrome", + "microsoft-edge", + "/usr/bin/microsoft-edge", + "chromium", + } + if !reflect.DeepEqual(rodLinuxBrowserCandidatesThroughChromium, want) { + t.Fatalf("Rod Linux candidates through Chromium = %#v, want %#v", rodLinuxBrowserCandidatesThroughChromium, want) + } +} + +func TestBrowserPreflightRejectsEveryEarlierRodCandidateInOrder(t *testing.T) { + target, digest := writeLauncher(t, []byte("pinned chromium launcher\n"), 0o755) + + for shadowIndex, shadowCandidate := range rodLinuxBrowserCandidatesThroughChromium[:len(rodLinuxBrowserCandidatesThroughChromium)-1] { + t.Run(shadowCandidate, func(t *testing.T) { + var calls []string + lookPath := func(candidate string) (string, error) { + calls = append(calls, candidate) + if candidate == shadowCandidate { + return "/shadow/browser", nil + } + if candidate == "chromium" { + return target, nil + } + return "", exec.ErrNotFound + } + + err := validateBrowser(browserPreflightConfig{ + target: target, + expectedSHA256: digest, + lookPath: lookPath, + }) + if err == nil { + t.Fatal("validateBrowser() error = nil, want earlier-candidate rejection") + } + wantCalls := rodLinuxBrowserCandidatesThroughChromium[:shadowIndex+1] + if !reflect.DeepEqual(calls, wantCalls) { + t.Fatalf("candidate checks = %#v, want exact prefix %#v", calls, wantCalls) + } + }) + } +} + +func TestBrowserPreflightValidatesPinnedLauncherWithoutFollowingLinks(t *testing.T) { + good := []byte("pinned chromium launcher\n") + goodDigest := sha256Hex(good) + + tests := []struct { + name string + prepare func(*testing.T) string + wantErr bool + }{ + { + name: "valid", + prepare: func(t *testing.T) string { + path, _ := writeLauncher(t, good, 0o755) + return path + }, + }, + { + name: "missing", + prepare: func(t *testing.T) string { + return filepath.Join(t.TempDir(), "chromium") + }, + wantErr: true, + }, + { + name: "symlink", + prepare: func(t *testing.T) string { + directory := t.TempDir() + real := filepath.Join(directory, "real-chromium") + if err := os.WriteFile(real, good, 0o755); err != nil { + t.Fatalf("write real launcher: %v", err) + } + link := filepath.Join(directory, "chromium") + if err := os.Symlink(real, link); err != nil { + t.Fatalf("symlink launcher: %v", err) + } + return link + }, + wantErr: true, + }, + { + name: "non-regular", + prepare: func(t *testing.T) string { + path := filepath.Join(t.TempDir(), "chromium") + if err := os.Mkdir(path, 0o755); err != nil { + t.Fatalf("make launcher directory: %v", err) + } + return path + }, + wantErr: true, + }, + { + name: "wrong-mode", + prepare: func(t *testing.T) string { + path, _ := writeLauncher(t, good, 0o775) + return path + }, + wantErr: true, + }, + { + name: "wrong-owner", + prepare: func(t *testing.T) string { + path, _ := writeLauncher(t, good, 0o755) + if err := unix.Chown(path, 65532, 65532); err != nil { + t.Fatalf("change launcher owner: %v", err) + } + return path + }, + wantErr: true, + }, + { + name: "one-byte-mutation", + prepare: func(t *testing.T) string { + mutated := append([]byte(nil), good...) + mutated[0] ^= 1 + path, _ := writeLauncher(t, mutated, 0o755) + return path + }, + wantErr: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + target := test.prepare(t) + lookPath := func(candidate string) (string, error) { + if candidate == "chromium" { + return target, nil + } + return "", exec.ErrNotFound + } + err := validateBrowser(browserPreflightConfig{ + target: target, + expectedSHA256: goodDigest, + lookPath: lookPath, + }) + if (err != nil) != test.wantErr { + t.Fatalf("validateBrowser() error = %v, wantErr %t", err, test.wantErr) + } + }) + } +} + +func TestCaptureMapsBrowserPreflightFailureBeforeLaunch(t *testing.T) { + launched := false + err := captureWith("/secret/host/path/readme.tape", captureDependencies{ + browserPreflight: func() error { + return errors.New("browser failure at /secret/host/path") + }, + prepareRuntime: func() error { return nil }, + changeDirectory: func(string) error { return nil }, + vhsPath: "/trusted/vhs", + execRestricted: func(sandbox.ExecSpec) error { + launched = true + return errors.New("VHS or downloader sentinel ran") + }, + }) + if launched { + t.Fatal("VHS/downloader launch sentinel ran after failed browser preflight") + } + if got := failure.Classify(err); got != failure.RendererUnavailable { + t.Fatalf("failure class = %q, want %q", got, failure.RendererUnavailable) + } + if got, want := err.Error(), "renderer-unavailable: runtime (runtime.unavailable)"; got != want { + t.Fatalf("sanitized error = %q, want %q", got, want) + } + if strings.Contains(err.Error(), "/secret/") { + t.Fatalf("sanitized error leaked internal path: %q", err) + } +} + +func TestBrowserBuildContractPinsLauncherAndForbidsGlobalSandboxOverrides(t *testing.T) { + root := filepath.Clean(filepath.Join("..", "..")) + dependencies, err := os.ReadFile(filepath.Join(root, "dependencies.env")) + if err != nil { + t.Fatalf("read dependencies.env: %v", err) + } + dockerfile, err := os.ReadFile(filepath.Join(root, "Dockerfile")) + if err != nil { + t.Fatalf("read Dockerfile: %v", err) + } + + const launcherPin = "CHROMIUM_LAUNCHER_SHA256=2c8d32d29dc6781c35ae196ea83299d507ac88fc339301abe74672feda299779" + if got := strings.Count(string(dependencies), launcherPin); got != 1 { + t.Fatalf("dependencies.env launcher pin count = %d, want 1", got) + } + + docker := string(dockerfile) + for _, forbidden := range []string{ + "\"chromium-sandbox=", + "ENV VHS_NO_SANDBOX=", + "ENV CHROME_BIN=", + "VHS_NO_SANDBOX=1", + "CHROME_BIN=/usr/bin/chromium", + } { + if strings.Contains(docker, forbidden) { + t.Fatalf("Dockerfile contains forbidden browser sandbox setting %q", forbidden) + } + } + for _, required := range []string{ + "dpkg-query -W -f='${db:Status-Abbrev}' chromium-sandbox", + "-name chrome-sandbox", + "-name chromium-sandbox", + "-perm /4000", + "${CHROMIUM_LAUNCHER_SHA256}", + "/usr/bin/chromium", + } { + if !strings.Contains(docker, required) { + t.Fatalf("Dockerfile missing browser build assertion %q", required) + } + } + + const vhsExitStatusContract = `FROM runtime AS test +RUN set -eu; \ + test "$(/usr/local/bin/vhs --version)" = 'vhs version v0.11.0 (c6af91a)'; \ + vhs_status=0; \ + /usr/local/bin/vhs --definitely-invalid >/tmp/vhs-invalid.out 2>&1 || vhs_status=$?; \ + test "$vhs_status" -eq 1; \ + rm -f /tmp/vhs-invalid.out; \ + printf '%s\n' 'PASS TestPinnedVHSExitStatusContract'` + if got := strings.Count(docker, vhsExitStatusContract); got != 1 { + t.Fatalf("Dockerfile final runtime test-stage VHS exit-status contract count = %d, want exact block once", got) + } + testStage := strings.Index(docker, "FROM runtime AS test") + if testStage < 0 { + t.Fatal("Dockerfile is missing the final runtime test stage") + } + if strings.Contains(docker[:testStage], "TestPinnedVHSExitStatusContract") { + t.Fatal("VHS exit-status contract appears before the final runtime test stage") + } +} + +func writeLauncher(t *testing.T, content []byte, mode os.FileMode) (string, string) { + t.Helper() + + path := filepath.Join(t.TempDir(), "chromium") + if err := os.WriteFile(path, content, mode); err != nil { + t.Fatalf("write launcher: %v", err) + } + if err := os.Chmod(path, mode); err != nil { + t.Fatalf("set launcher mode: %v", err) + } + return path, sha256Hex(content) +} + +func sha256Hex(content []byte) string { + digest := sha256.Sum256(content) + return fmt.Sprintf("%x", digest[:]) +} diff --git a/tools/readme-terminal-demo/dependencies.env b/tools/readme-terminal-demo/dependencies.env new file mode 100644 index 000000000..3a691eac3 --- /dev/null +++ b/tools/readme-terminal-demo/dependencies.env @@ -0,0 +1,56 @@ +# Reproducible build inputs for the README terminal demo renderer. +GO_VERSION=1.26.5 +GO_IMAGE=golang:1.26.5-trixie +GO_IMAGE_DIGEST=sha256:117e07f49461abb984fc8aef661432461ff43d06faa22c3b73af6a49ce325cb9 +RUNTIME_IMAGE=debian:trixie-slim +RUNTIME_IMAGE_DIGEST=sha256:020c0d20b9880058cbe785a9db107156c3c75c2ac944a6aa7ab59f2add76a7bd + +DEBIAN_SNAPSHOT_TIMESTAMP=20260718T000000Z +DEBIAN_SNAPSHOT_BOOTSTRAP_URL=http://snapshot.debian.org/archive/debian/20260718T000000Z +DEBIAN_SECURITY_SNAPSHOT_BOOTSTRAP_URL=http://snapshot.debian.org/archive/debian-security/20260718T000000Z +DEBIAN_SNAPSHOT_URL=https://snapshot.debian.org/archive/debian/20260718T000000Z +DEBIAN_SECURITY_SNAPSHOT_URL=https://snapshot.debian.org/archive/debian-security/20260718T000000Z +DEBIAN_CA_CERTIFICATES_VERSION=20250419 +DEBIAN_CURL_VERSION=8.14.1-2+deb13u4 +DEBIAN_CHROMIUM_VERSION=150.0.7871.124-1~deb13u1 +CHROMIUM_LAUNCHER_SHA256=2c8d32d29dc6781c35ae196ea83299d507ac88fc339301abe74672feda299779 +DEBIAN_FFMPEG_VERSION=7:7.1.5-0+deb13u1 +DEBIAN_FONTCONFIG_VERSION=2.15.0-2.3 +DEBIAN_FONTS_JETBRAINS_MONO_VERSION=2.304+ds-5 +DEBIAN_FONTS_NOTO_COLOR_EMOJI_VERSION=2.051-0+deb13u1 +DEBIAN_GIT_VERSION=1:2.47.3-0+deb13u1 +DEBIAN_TINI_VERSION=0.19.0-3+b7 +DEBIAN_XZ_UTILS_VERSION=5.8.1-1+deb13u1 +DEBIAN_ZSH_VERSION=5.9-8+b23 + +VHS_VERSION=v0.11.0 +VHS_LINUX_X86_64_URL=https://github.com/charmbracelet/vhs/releases/download/v0.11.0/vhs_0.11.0_Linux_x86_64.tar.gz +VHS_LINUX_X86_64_SHA256=99cb634587eaae0473c1ea377db80c3a048c27f99fe0a7febb1a1e8cb7ee5009 +VHS_CHECKSUMS_URL=https://github.com/charmbracelet/vhs/releases/download/v0.11.0/checksums.txt +VHS_CHECKSUMS_SHA256=71b7e8eb9742c1d8bad844980dd00bf665743a0321d1a32832d24a6e371952f2 +VHS_CHECKSUMS_BUNDLE_URL=https://github.com/charmbracelet/vhs/releases/download/v0.11.0/checksums.txt.sigstore.json +VHS_CHECKSUMS_BUNDLE_SHA256=a4e998a04e9a0e43a7bf6a6180a0a83801bf6fb8b3ca88c7f2ba4f8255955128 +VHS_CERTIFICATE_IDENTITY=https://github.com/charmbracelet/meta/.github/workflows/goreleaser.yml@refs/heads/main + +COSIGN_VERSION=v3.0.6 +COSIGN_LINUX_AMD64_URL=https://github.com/sigstore/cosign/releases/download/v3.0.6/cosign-linux-amd64 +COSIGN_LINUX_AMD64_SHA256=c956e5dfcac53d52bcf058360d579472f0c1d2d9b69f55209e256fe7783f4c74 +COSIGN_BUNDLE_URL=https://github.com/sigstore/cosign/releases/download/v3.0.6/cosign-linux-amd64.sigstore.json +COSIGN_BUNDLE_SHA256=b3a04913f3a3f4a38e4a7a42b8d590834b8791de99ddeaad66c608b6aa8e02a4 +COSIGN_CERTIFICATE_IDENTITY=keyless@projectsigstore.iam.gserviceaccount.com +COSIGN_OIDC_ISSUER=https://accounts.google.com +SIGSTORE_OIDC_ISSUER=https://token.actions.githubusercontent.com + +GH_CLI_VERSION=v2.96.0 +GH_CLI_LINUX_AMD64_URL=https://github.com/cli/cli/releases/download/v2.96.0/gh_2.96.0_linux_amd64.tar.gz +GH_CLI_LINUX_AMD64_SHA256=83d5c2ccad5498f58bf6368acb1ab32588cf43ab3a4b1c301bf36328b1c8bd60 +GH_CLI_CHECKSUMS_URL=https://github.com/cli/cli/releases/download/v2.96.0/gh_2.96.0_checksums.txt +GH_CLI_CHECKSUMS_SHA256=fc046371efa250e2875208341a786a35a01717d5eebec6903e199a9b8a3f3565 + +TTYD_VERSION=1.7.7 +TTYD_X86_64_URL=https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.x86_64 +TTYD_X86_64_SHA256=8a217c968aba172e0dbf3f34447218dc015bc4d5e59bf51db2f2cd12b7be4f55 + +EZA_VERSION=v0.23.5 +EZA_X86_64_GNU_URL=https://github.com/eza-community/eza/releases/download/v0.23.5/eza_x86_64-unknown-linux-gnu.tar.gz +EZA_X86_64_GNU_SHA256=35c70c5c43c29108075e58b893234c67ef585f0b53a7eaf8e9e7d4eec9f339b4 diff --git a/tools/readme-terminal-demo/go.mod b/tools/readme-terminal-demo/go.mod new file mode 100644 index 000000000..c11e116df --- /dev/null +++ b/tools/readme-terminal-demo/go.mod @@ -0,0 +1,13 @@ +module github.com/z-shell/.github/tools/readme-terminal-demo + +go 1.26.5 + +require ( + github.com/landlock-lsm/go-landlock v0.9.0 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 + github.com/yuin/goldmark v1.8.4 + go.yaml.in/yaml/v3 v3.0.4 + golang.org/x/sys v0.40.0 +) + +require kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 // indirect diff --git a/tools/readme-terminal-demo/go.sum b/tools/readme-terminal-demo/go.sum new file mode 100644 index 000000000..4eaa506a2 --- /dev/null +++ b/tools/readme-terminal-demo/go.sum @@ -0,0 +1,18 @@ +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/landlock-lsm/go-landlock v0.9.0 h1:2q8G8yx9Hsd5bV+R6PJfgQl0zszNxC8KO+SIqGwfxlw= +github.com/landlock-lsm/go-landlock v0.9.0/go.mod h1:mn5GSi81Jf7yMs5WSi+SUi4sUeNLUGVdbT4Id6wXNQw= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA= +github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 h1:Z06sMOzc0GNCwp6efaVrIrz4ywGJ1v+DP0pjVkOfDuA= +kernel.org/pub/linux/libs/security/libcap/psx v1.2.77/go.mod h1:+l6Ee2F59XiJ2I6WR5ObpC1utCQJZ/VLsEbQCD8RG24= diff --git a/tools/readme-terminal-demo/internal/failure/failure.go b/tools/readme-terminal-demo/internal/failure/failure.go new file mode 100644 index 000000000..eb4cd4331 --- /dev/null +++ b/tools/readme-terminal-demo/internal/failure/failure.go @@ -0,0 +1,164 @@ +// Package failure defines the renderer's stable, sanitized failure contract. +package failure + +import ( + "errors" + "fmt" +) + +// Class is a stable public failure category. +type Class string + +const ( + InvalidContract Class = "invalid-contract" + UnsafePath Class = "unsafe-path" + RendererUnavailable Class = "renderer-unavailable" + ExecutionFailed Class = "execution-failed" + Nondeterministic Class = "nondeterministic" + AssetDrift Class = "asset-drift" + ReadmeContract Class = "readme-contract" +) + +// Stage is a stable renderer lifecycle stage. +type Stage string + +const ( + StageInput Stage = "input" + StageCheckout Stage = "checkout" + StagePull Stage = "pull" + StageProvenance Stage = "provenance" + StageSnapshot Stage = "snapshot" + StageSource Stage = "source" + StageManifest Stage = "manifest" + StageTape Stage = "tape" + StageFixture Stage = "fixture" + StageReadme Stage = "readme" + StageCapture Stage = "capture" + StageMedia Stage = "media" + StageStability Stage = "stability" + StageCompare Stage = "compare" + StagePromotion Stage = "promotion" + StageRuntime Stage = "runtime" + StagePreRelease Stage = "pre-release" + StageComplete Stage = "complete" +) + +var stages = [...]Stage{ + StageInput, StageCheckout, StagePull, StageProvenance, StageSnapshot, + StageSource, StageManifest, StageTape, StageFixture, StageReadme, + StageCapture, StageMedia, StageStability, StageCompare, StagePromotion, + StageRuntime, StagePreRelease, StageComplete, +} + +// Stages returns the complete stable stage vocabulary in lifecycle order. +func Stages() []Stage { + result := make([]Stage, len(stages)) + copy(result, stages[:]) + return result +} + +// Rule is a stable machine-readable failure rule. +type Rule string + +const ( + RuleInputRepository Rule = "input.repository" + RuleInputRef Rule = "input.ref" + RuleInputManifestPath Rule = "input.manifest-path" + RuleCheckoutUnavailable Rule = "checkout.unavailable" + RuleCheckoutHead Rule = "checkout.head" + RuleCheckoutClean Rule = "checkout.clean" + RulePullUnavailable Rule = "pull.unavailable" + RuleProvenanceInvalid Rule = "provenance.invalid" + RuleSnapshotUnavailable Rule = "snapshot.unavailable" + RuleSnapshotMember Rule = "snapshot.member" + RuleSourceMutated Rule = "source.mutated" + RuleManifestInvalid Rule = "manifest.invalid" + RuleTapeInvalid Rule = "tape.invalid" + RuleFixtureInvalid Rule = "fixture.invalid" + RuleReadmeInvalid Rule = "readme.invalid" + RuleRuntimeUnavailable Rule = "runtime.unavailable" + RuleCaptureFailed Rule = "capture.failed" + RuleCaptureTimeout Rule = "capture.timeout" + RuleMediaFailed Rule = "media.failed" + RuleMediaInvalid Rule = "media.invalid" + RuleStabilityMismatch Rule = "stability.mismatch" + RuleAssetsMissing Rule = "assets.missing" + RuleAssetsDrift Rule = "assets.drift" + RulePromotionFailed Rule = "promotion.failed" + RulePreReleaseDisabled Rule = "pre-release.disabled" +) + +var rules = [...]Rule{ + RuleInputRepository, RuleInputRef, RuleInputManifestPath, + RuleCheckoutUnavailable, RuleCheckoutHead, RuleCheckoutClean, + RulePullUnavailable, RuleProvenanceInvalid, RuleSnapshotUnavailable, + RuleSnapshotMember, RuleSourceMutated, RuleManifestInvalid, + RuleTapeInvalid, RuleFixtureInvalid, RuleReadmeInvalid, + RuleRuntimeUnavailable, RuleCaptureFailed, RuleCaptureTimeout, + RuleMediaFailed, RuleMediaInvalid, RuleStabilityMismatch, + RuleAssetsMissing, RuleAssetsDrift, RulePromotionFailed, + RulePreReleaseDisabled, +} + +// Rules returns the complete stable rule vocabulary in contract order. +func Rules() []Rule { + result := make([]Rule, len(rules)) + copy(result, rules[:]) + return result +} + +// Error carries only bounded structured context alongside an unlogged cause. +type Error struct { + Class Class + Stage Stage + Field string + Rule Rule + Err error +} + +// E constructs a structured failure. +func E(class Class, stage Stage, field string, rule Rule, err error) error { + return &Error{Class: class, Stage: stage, Field: field, Rule: rule, Err: err} +} + +// Error formats only stable contract values; the wrapped cause and field are +// deliberately excluded because they can originate in untrusted input. +func (e *Error) Error() string { + return fmt.Sprintf("%s: %s (%s)", e.Class, e.Stage, e.Rule) +} + +// Unwrap exposes the cause for programmatic inspection without logging it. +func (e *Error) Unwrap() error { + return e.Err +} + +// Classify returns the stable class of err, or the zero value when unexpected. +func Classify(err error) Class { + var structured *Error + if errors.As(err, &structured) { + return structured.Class + } + return "" +} + +// ExitCode maps stable failure classes to their public process status. +func ExitCode(err error) int { + switch Classify(err) { + case InvalidContract: + return 2 + case UnsafePath: + return 3 + case RendererUnavailable: + return 4 + case ExecutionFailed: + return 5 + case Nondeterministic: + return 6 + case AssetDrift: + return 7 + case ReadmeContract: + return 8 + default: + return 1 + } +} diff --git a/tools/readme-terminal-demo/internal/failure/failure_test.go b/tools/readme-terminal-demo/internal/failure/failure_test.go new file mode 100644 index 000000000..210dd42c4 --- /dev/null +++ b/tools/readme-terminal-demo/internal/failure/failure_test.go @@ -0,0 +1,109 @@ +package failure + +import ( + "errors" + "reflect" + "strings" + "testing" +) + +func TestStableClassesAndExitCodes(t *testing.T) { + t.Parallel() + + tests := []struct { + class Class + value string + code int + }{ + {InvalidContract, "invalid-contract", 2}, + {UnsafePath, "unsafe-path", 3}, + {RendererUnavailable, "renderer-unavailable", 4}, + {ExecutionFailed, "execution-failed", 5}, + {Nondeterministic, "nondeterministic", 6}, + {AssetDrift, "asset-drift", 7}, + {ReadmeContract, "readme-contract", 8}, + } + + for _, test := range tests { + test := test + t.Run(test.value, func(t *testing.T) { + t.Parallel() + + err := E(test.class, StageRuntime, "", RuleRuntimeUnavailable, errors.New("private detail")) + if got := string(test.class); got != test.value { + t.Fatalf("class value = %q, want %q", got, test.value) + } + if got := Classify(err); got != test.class { + t.Fatalf("Classify() = %q, want %q", got, test.class) + } + if got := ExitCode(err); got != test.code { + t.Fatalf("ExitCode() = %d, want %d", got, test.code) + } + }) + } +} + +func TestUnexpectedErrorsUseExitOne(t *testing.T) { + t.Parallel() + + if got := ExitCode(errors.New("unexpected")); got != 1 { + t.Fatalf("ExitCode() = %d, want 1", got) + } +} + +func TestErrorPreservesStructuredContextWithoutLeakingCause(t *testing.T) { + t.Parallel() + + cause := errors.New("token=must-not-appear") + err := E(RendererUnavailable, StageRuntime, "fixtures[2].source", RuleRuntimeUnavailable, cause) + + var structured *Error + if !errors.As(err, &structured) { + t.Fatalf("errors.As() did not find *Error in %T", err) + } + if structured.Class != RendererUnavailable || structured.Stage != StageRuntime || structured.Field != "fixtures[2].source" || structured.Rule != RuleRuntimeUnavailable { + t.Fatalf("structured error = %#v", structured) + } + if !errors.Is(err, cause) { + t.Fatal("structured error does not unwrap to its cause") + } + if strings.Contains(err.Error(), "must-not-appear") { + t.Fatalf("Error() leaked wrapped cause: %q", err.Error()) + } +} + +func TestStableStageValues(t *testing.T) { + t.Parallel() + + want := []Stage{ + StageInput, StageCheckout, StagePull, StageProvenance, StageSnapshot, + StageSource, StageManifest, StageTape, StageFixture, StageReadme, + StageCapture, StageMedia, StageStability, StageCompare, StagePromotion, + StageRuntime, StagePreRelease, StageComplete, + } + if got := Stages(); !reflect.DeepEqual(got, want) { + t.Fatalf("Stages() = %#v, want %#v", got, want) + } + if StageComplete != "complete" { + t.Fatalf("StageComplete = %q, want complete", StageComplete) + } +} + +func TestStableRuleValues(t *testing.T) { + t.Parallel() + + want := []Rule{ + RuleInputRepository, RuleInputRef, RuleInputManifestPath, + RuleCheckoutUnavailable, RuleCheckoutHead, RuleCheckoutClean, + RulePullUnavailable, RuleProvenanceInvalid, RuleSnapshotUnavailable, + RuleSnapshotMember, RuleSourceMutated, RuleManifestInvalid, + RuleTapeInvalid, RuleFixtureInvalid, RuleReadmeInvalid, + RuleRuntimeUnavailable, RuleCaptureFailed, RuleCaptureTimeout, + RuleMediaFailed, RuleMediaInvalid, RuleStabilityMismatch, + RuleAssetsMissing, RuleAssetsDrift, RulePromotionFailed, + RulePreReleaseDisabled, + } + if got := Rules(); !reflect.DeepEqual(got, want) { + t.Fatalf("Rules() = %#v, want %#v", got, want) + } +} diff --git a/tools/readme-terminal-demo/internal/sandbox/exec_linux.go b/tools/readme-terminal-demo/internal/sandbox/exec_linux.go new file mode 100644 index 000000000..43d1aaa1d --- /dev/null +++ b/tools/readme-terminal-demo/internal/sandbox/exec_linux.go @@ -0,0 +1,236 @@ +package sandbox + +import ( + "fmt" + "runtime" + "strings" + "unsafe" + + "golang.org/x/sys/unix" +) + +type terminalFault uint8 + +const ( + terminalFaultNone terminalFault = iota + terminalFaultUpperClose + terminalFaultLowerClose + terminalFaultNoNewPrivs + terminalFaultRestrictSelf + terminalFaultRulesetClose + terminalFaultTIDMismatch + terminalFaultExecve +) + +type preparedExec struct { + pathStorage []byte + argvStorage [][]byte + envStorage [][]byte + argv []*byte + envp []*byte + rulesetFD int + upperFirst uint32 + lowerLast uint32 + hasLower bool + fault terminalFault +} + +// ExecRestricted completes every fallible input, vector, and policy preflight +// before entering the non-returning current-thread terminal sequence. +func ExecRestricted(spec ExecSpec) error { + preparedPtr, err := prepareRestrictedExec(spec) + if err != nil { + return err + } + executePrepared(preparedPtr) + exitGroup(runtimeFailureExitCode) + for { + } +} + +func prepareRestrictedExec(spec ExecSpec) (*preparedExec, error) { + return prepareRestrictedExecWithOps(spec, productionPrepareV3Ops) +} + +func prepareRestrictedExecWithOps(spec ExecSpec, ops prepareV3Ops) (*preparedExec, error) { + if err := validateExecPath(spec.Path); err != nil { + return nil, err + } + if err := validateExecArgs(spec.Path, spec.Args); err != nil { + return nil, err + } + if err := validateExecEnvironment(spec.Env); err != nil { + return nil, err + } + + pathStorage := ownedCString(spec.Path) + argvStorage, argv := ownedCStringVector(spec.Args) + envStorage, envp := ownedCStringVector(spec.Env) + policy, err := prepareV3WithOps(spec.Policy, ops) + if err != nil { + return nil, err + } + + return &preparedExec{ + pathStorage: pathStorage, + argvStorage: argvStorage, + envStorage: envStorage, + argv: argv, + envp: envp, + rulesetFD: policy.rulesetFD, + upperFirst: uint32(policy.rulesetFD + 1), + lowerLast: uint32(policy.rulesetFD - 1), + hasLower: policy.rulesetFD > 3, + fault: terminalFaultNone, + }, nil +} + +func ownedCString(value string) []byte { + storage := make([]byte, len(value)+1) + copy(storage, value) + return storage +} + +func ownedCStringVector(values []string) ([][]byte, []*byte) { + storage := make([][]byte, len(values)) + vector := make([]*byte, len(values)+1) + for index, value := range values { + storage[index] = ownedCString(value) + vector[index] = &storage[index][0] + } + return storage, vector +} + +func validateExecPath(path string) error { + if path == "" || strings.ContainsRune(path, 0) || !strings.HasPrefix(path, "/") || path != strings.TrimSuffix(path, "/") { + return fmt.Errorf("%w: invalid target path", ErrRestrictedExec) + } + var stat unix.Stat_t + if err := unix.Fstatat(unix.AT_FDCWD, path, &stat, unix.AT_SYMLINK_NOFOLLOW); err != nil { + return fmt.Errorf("%w: inspect target", ErrRestrictedExec) + } + if stat.Mode&unix.S_IFMT != unix.S_IFREG || stat.Mode&0o111 == 0 { + return fmt.Errorf("%w: target is not an executable regular file", ErrRestrictedExec) + } + return nil +} + +func validateExecArgs(path string, args []string) error { + if len(args) == 0 || args[0] != path { + return fmt.Errorf("%w: invalid target argv", ErrRestrictedExec) + } + for _, arg := range args { + if strings.ContainsRune(arg, 0) { + return fmt.Errorf("%w: invalid target argv", ErrRestrictedExec) + } + } + return nil +} + +func validateExecEnvironment(environment []string) error { + seen := make(map[string]struct{}, len(environment)) + for _, entry := range environment { + if strings.ContainsRune(entry, 0) { + return fmt.Errorf("%w: invalid target environment", ErrRestrictedExec) + } + name, _, ok := strings.Cut(entry, "=") + if !ok || !validEnvironmentName(name) { + return fmt.Errorf("%w: invalid target environment", ErrRestrictedExec) + } + if _, duplicate := seen[name]; duplicate { + return fmt.Errorf("%w: duplicate target environment", ErrRestrictedExec) + } + seen[name] = struct{}{} + } + return nil +} + +func validEnvironmentName(name string) bool { + if name == "" || !environmentNameStart(name[0]) { + return false + } + for index := 1; index < len(name); index++ { + if !environmentNameStart(name[index]) && (name[index] < '0' || name[index] > '9') { + return false + } + } + return true +} + +func environmentNameStart(character byte) bool { + return character == '_' || character >= 'A' && character <= 'Z' || character >= 'a' && character <= 'z' +} + +func executePrepared(preparedPtr *preparedExec) { + runtime.LockOSThread() + lockedTID, errno := rawGetTID() + if errno != 0 { + exitGroup(runtimeFailureExitCode) + } + if preparedPtr.fault == terminalFaultUpperClose { + exitGroup(runtimeFailureExitCode) + } + if preparedPtr.fault == terminalFaultLowerClose { + if rawCloseRangeCall(preparedPtr.upperFirst, ^uint32(0), unix.CLOSE_RANGE_UNSHARE) != 0 { + exitGroup(runtimeFailureExitCode) + } + exitGroup(runtimeFailureExitCode) + } + if closeInheritedExcept(preparedPtr.rulesetFD) != 0 { + exitGroup(runtimeFailureExitCode) + } + if preparedPtr.fault == terminalFaultNoNewPrivs || rawNoNewPrivs() != 0 { + exitGroup(runtimeFailureExitCode) + } + if preparedPtr.fault == terminalFaultRestrictSelf || rawLandlockRestrictSelf(preparedPtr.rulesetFD, 0) != 0 { + exitGroup(runtimeFailureExitCode) + } + if preparedPtr.fault == terminalFaultRulesetClose || rawCloseFD(preparedPtr.rulesetFD) != 0 { + exitGroup(runtimeFailureExitCode) + } + currentTID, errno := rawGetTID() + if errno != 0 || currentTID != lockedTID || preparedPtr.fault == terminalFaultTIDMismatch { + exitGroup(runtimeFailureExitCode) + } + if preparedPtr.fault != terminalFaultExecve { + _, _, _ = unix.RawSyscall(unix.SYS_EXECVE, + uintptr(unsafe.Pointer(&preparedPtr.pathStorage[0])), + uintptr(unsafe.Pointer(&preparedPtr.argv[0])), + uintptr(unsafe.Pointer(&preparedPtr.envp[0]))) + } + runtime.KeepAlive(preparedPtr) + exitGroup(executionFailureExitCode) + for { + } +} + +//go:nosplit +func rawGetTID() (uintptr, unix.Errno) { + tid, _, errno := unix.RawSyscall(unix.SYS_GETTID, 0, 0, 0) + return tid, errno +} + +//go:nosplit +func rawNoNewPrivs() unix.Errno { + _, _, errno := unix.RawSyscall6(unix.SYS_PRCTL, uintptr(unix.PR_SET_NO_NEW_PRIVS), 1, 0, 0, 0, 0) + return errno +} + +//go:nosplit +func rawLandlockRestrictSelf(rulesetFD int, flags uint32) unix.Errno { + _, _, errno := unix.RawSyscall(unix.SYS_LANDLOCK_RESTRICT_SELF, uintptr(rulesetFD), uintptr(flags), 0) + return errno +} + +//go:nosplit +func rawCloseFD(fd int) unix.Errno { + _, _, errno := unix.RawSyscall(unix.SYS_CLOSE, uintptr(fd), 0, 0) + return errno +} + +//go:nosplit +func exitGroup(code int) { + for { + _, _, _ = unix.RawSyscall(unix.SYS_EXIT_GROUP, uintptr(code), 0, 0) + } +} diff --git a/tools/readme-terminal-demo/internal/sandbox/fds_linux.go b/tools/readme-terminal-demo/internal/sandbox/fds_linux.go new file mode 100644 index 000000000..7c329a2b3 --- /dev/null +++ b/tools/readme-terminal-demo/internal/sandbox/fds_linux.go @@ -0,0 +1,31 @@ +package sandbox + +import "golang.org/x/sys/unix" + +type rawCloseRange func(first, last, flags uint32) unix.Errno + +//go:nosplit +func rawCloseRangeCall(first, last, flags uint32) unix.Errno { + _, _, errno := unix.RawSyscall(unix.SYS_CLOSE_RANGE, uintptr(first), uintptr(last), uintptr(flags)) + return errno +} + +func closeInheritedExcept(rulesetFD int) unix.Errno { + if errno := rawCloseRangeCall(uint32(rulesetFD+1), ^uint32(0), unix.CLOSE_RANGE_UNSHARE); errno != 0 { + return errno + } + if rulesetFD > 3 { + return rawCloseRangeCall(3, uint32(rulesetFD-1), 0) + } + return 0 +} + +func closeInheritedExceptWith(rulesetFD int, call rawCloseRange) unix.Errno { + if errno := call(uint32(rulesetFD+1), ^uint32(0), unix.CLOSE_RANGE_UNSHARE); errno != 0 { + return errno + } + if rulesetFD > 3 { + return call(3, uint32(rulesetFD-1), 0) + } + return 0 +} diff --git a/tools/readme-terminal-demo/internal/sandbox/landlock_linux.go b/tools/readme-terminal-demo/internal/sandbox/landlock_linux.go new file mode 100644 index 000000000..b6d8c7cb1 --- /dev/null +++ b/tools/readme-terminal-demo/internal/sandbox/landlock_linux.go @@ -0,0 +1,189 @@ +// Package sandbox provides the fail-closed child boundary used by renderers. +package sandbox + +import ( + "errors" + "fmt" + "sort" + "strings" + + llsyscall "github.com/landlock-lsm/go-landlock/landlock/syscall" + "golang.org/x/sys/unix" +) + +const ( + runtimeFailureExitCode = 4 + executionFailureExitCode = 5 + + exactV3HandledAccess uint64 = llsyscall.AccessFSExecute | + llsyscall.AccessFSWriteFile | + llsyscall.AccessFSReadFile | + llsyscall.AccessFSReadDir | + llsyscall.AccessFSRemoveDir | + llsyscall.AccessFSRemoveFile | + llsyscall.AccessFSMakeChar | + llsyscall.AccessFSMakeDir | + llsyscall.AccessFSMakeReg | + llsyscall.AccessFSMakeSock | + llsyscall.AccessFSMakeFifo | + llsyscall.AccessFSMakeBlock | + llsyscall.AccessFSMakeSym | + llsyscall.AccessFSRefer | + llsyscall.AccessFSTruncate + + readOnlyV3Access = llsyscall.AccessFSExecute | + llsyscall.AccessFSReadFile | + llsyscall.AccessFSReadDir + readWriteV3Access = exactV3HandledAccess &^ llsyscall.AccessFSRefer + nullDeviceV3Access = llsyscall.AccessFSExecute | + llsyscall.AccessFSWriteFile | + llsyscall.AccessFSReadFile | + llsyscall.AccessFSTruncate +) + +var ( + // ErrLandlockV3Unavailable means the exact required ABI could not be applied. + ErrLandlockV3Unavailable = errors.New("Landlock V3 unavailable") + // ErrDescriptorClose means inherited descriptors could not be closed. + ErrDescriptorClose = errors.New("inherited descriptor close failed") + // ErrRestrictedExec means the restricted target could not be executed. + ErrRestrictedExec = errors.New("restricted exec failed") +) + +// Policy is the exact filesystem view inherited by a restricted target. +type Policy struct { + ReadOnlyPaths []string + ReadWritePaths []string +} + +// ExecSpec describes a target that replaces the current, already-isolated child. +type ExecSpec struct { + Path string + Args []string + Env []string + Policy Policy +} + +type preparedV3 struct { + rulesetFD int +} + +func prepareV3(policy Policy) (preparedV3, error) { + return prepareV3WithOps(policy, productionPrepareV3Ops) +} + +func prepareV3WithOps(policy Policy, ops prepareV3Ops) (preparedV3, error) { + if ops.getABI == nil || ops.createRuleset == nil || ops.openPath == nil || ops.fstat == nil || + ops.addPathRule == nil || ops.setCLOEXEC == nil || ops.getCLOEXEC == nil || ops.closeFD == nil { + return preparedV3{}, fmt.Errorf("%w: incomplete ruleset operations", ErrLandlockV3Unavailable) + } + abi, err := ops.getABI() + if err != nil || abi < 3 { + return preparedV3{}, ErrLandlockV3Unavailable + } + + readOnly, readWrite, err := validatePolicy(policy) + if err != nil { + return preparedV3{}, err + } + attribute := llsyscall.RulesetAttr{ + HandledAccessFS: exactV3HandledAccess, + HandledAccessNet: 0, + Scoped: 0, + } + rulesetFD, err := ops.createRuleset(&attribute, 0) + if err != nil { + return preparedV3{}, fmt.Errorf("%w: create ruleset", ErrLandlockV3Unavailable) + } + if rulesetFD < 3 || uint64(rulesetFD) >= uint64(^uint32(0)) { + if rulesetFD >= 0 { + _ = ops.closeFD(rulesetFD) + } + return preparedV3{}, fmt.Errorf("%w: invalid ruleset descriptor", ErrLandlockV3Unavailable) + } + fail := func(cause error) (preparedV3, error) { + _ = ops.closeFD(rulesetFD) + return preparedV3{}, cause + } + + type ruleSource struct { + path string + access uint64 + directory bool + } + sources := make([]ruleSource, 0, len(readOnly)+len(readWrite)+1) + for _, path := range readOnly { + sources = append(sources, ruleSource{path: path, access: readOnlyV3Access, directory: true}) + } + for _, path := range readWrite { + sources = append(sources, ruleSource{path: path, access: readWriteV3Access, directory: true}) + } + sources = append(sources, ruleSource{path: "/dev/null", access: nullDeviceV3Access}) + + for _, source := range sources { + fd, openErr := ops.openPath(source.path, unix.O_PATH|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if openErr != nil { + return fail(fmt.Errorf("%w: open rule source", ErrLandlockV3Unavailable)) + } + var stat unix.Stat_t + if statErr := ops.fstat(fd, &stat); statErr != nil { + _ = ops.closeFD(fd) + return fail(fmt.Errorf("%w: inspect opened rule source", ErrLandlockV3Unavailable)) + } + if source.directory { + if stat.Mode&unix.S_IFMT != unix.S_IFDIR { + _ = ops.closeFD(fd) + return fail(fmt.Errorf("%w: rule source is not a directory", ErrLandlockV3Unavailable)) + } + } else if stat.Mode&unix.S_IFMT != unix.S_IFCHR || + unix.Major(uint64(stat.Rdev)) != 1 || unix.Minor(uint64(stat.Rdev)) != 3 { + _ = ops.closeFD(fd) + return fail(fmt.Errorf("%w: null device identity mismatch", ErrLandlockV3Unavailable)) + } + pathAttribute := llsyscall.PathBeneathAttr{AllowedAccess: source.access, ParentFd: fd} + if addErr := ops.addPathRule(rulesetFD, &pathAttribute, 0); addErr != nil { + _ = ops.closeFD(fd) + return fail(fmt.Errorf("%w: add path rule", ErrLandlockV3Unavailable)) + } + if closeErr := ops.closeFD(fd); closeErr != nil { + return fail(fmt.Errorf("%w: close rule source", ErrLandlockV3Unavailable)) + } + } + + if err := ops.setCLOEXEC(rulesetFD); err != nil { + return fail(fmt.Errorf("%w: set ruleset CLOEXEC", ErrLandlockV3Unavailable)) + } + cloexec, err := ops.getCLOEXEC(rulesetFD) + if err != nil || !cloexec { + return fail(fmt.Errorf("%w: verify ruleset CLOEXEC", ErrLandlockV3Unavailable)) + } + return preparedV3{rulesetFD: rulesetFD}, nil +} + +func validatePolicy(policy Policy) ([]string, []string, error) { + seen := make(map[string]struct{}, len(policy.ReadOnlyPaths)+len(policy.ReadWritePaths)) + validate := func(paths []string) ([]string, error) { + validated := append([]string(nil), paths...) + for _, path := range validated { + if path == "" || strings.ContainsRune(path, 0) || !strings.HasPrefix(path, "/") || + (path != "/" && path != strings.TrimSuffix(path, "/")) { + return nil, fmt.Errorf("%w: invalid rule path", ErrLandlockV3Unavailable) + } + if _, duplicate := seen[path]; duplicate { + return nil, fmt.Errorf("%w: duplicate rule path", ErrLandlockV3Unavailable) + } + seen[path] = struct{}{} + } + sort.Strings(validated) + return validated, nil + } + readOnly, err := validate(policy.ReadOnlyPaths) + if err != nil { + return nil, nil, err + } + readWrite, err := validate(policy.ReadWritePaths) + if err != nil { + return nil, nil, err + } + return readOnly, readWrite, nil +} diff --git a/tools/readme-terminal-demo/internal/sandbox/landlock_syscall_linux.go b/tools/readme-terminal-demo/internal/sandbox/landlock_syscall_linux.go new file mode 100644 index 000000000..2b3927ca3 --- /dev/null +++ b/tools/readme-terminal-demo/internal/sandbox/landlock_syscall_linux.go @@ -0,0 +1,34 @@ +package sandbox + +import ( + llsyscall "github.com/landlock-lsm/go-landlock/landlock/syscall" + "golang.org/x/sys/unix" +) + +type prepareV3Ops struct { + getABI func() (int, error) + createRuleset func(*llsyscall.RulesetAttr, int) (int, error) + openPath func(string, int, uint32) (int, error) + fstat func(int, *unix.Stat_t) error + addPathRule func(int, *llsyscall.PathBeneathAttr, int) error + setCLOEXEC func(int) error + getCLOEXEC func(int) (bool, error) + closeFD func(int) error +} + +var productionPrepareV3Ops = prepareV3Ops{ + getABI: llsyscall.LandlockGetABIVersion, + createRuleset: llsyscall.LandlockCreateRuleset, + openPath: unix.Open, + fstat: unix.Fstat, + addPathRule: llsyscall.LandlockAddPathBeneathRule, + setCLOEXEC: func(fd int) error { + _, err := unix.FcntlInt(uintptr(fd), unix.F_SETFD, unix.FD_CLOEXEC) + return err + }, + getCLOEXEC: func(fd int) (bool, error) { + flags, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0) + return flags&unix.FD_CLOEXEC != 0, err + }, + closeFD: unix.Close, +} diff --git a/tools/readme-terminal-demo/internal/sandbox/launcher_internal_test.go b/tools/readme-terminal-demo/internal/sandbox/launcher_internal_test.go new file mode 100644 index 000000000..54ce94629 --- /dev/null +++ b/tools/readme-terminal-demo/internal/sandbox/launcher_internal_test.go @@ -0,0 +1,1104 @@ +package sandbox + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "reflect" + "runtime" + "strconv" + "strings" + "testing" + "time" + "unsafe" + + llsyscall "github.com/landlock-lsm/go-landlock/landlock/syscall" + "golang.org/x/sys/unix" +) + +const ( + testExecProbeMode = "README_TERMINAL_DEMO_TEST_EXEC_PROBE" + testPreserveRulesetMode = "README_TERMINAL_DEMO_TEST_PRESERVE_RULESET" + testPreserveRulesetCase = "README_TERMINAL_DEMO_TEST_PRESERVE_CASE" + testCloseRangeUnshareMode = "README_TERMINAL_DEMO_TEST_CLOSE_RANGE_UNSHARE" + testDecoyExecMode = "README_TERMINAL_DEMO_TEST_DECOY_EXEC" + testDecoyLowerPath = "README_TERMINAL_DEMO_TEST_DECOY_LOWER_PATH" + testDecoyUpperPath = "README_TERMINAL_DEMO_TEST_DECOY_UPPER_PATH" + testExecVectorMode = "README_TERMINAL_DEMO_TEST_EXEC_VECTOR_GC" + testStageExitGroupMode = "README_TERMINAL_DEMO_TEST_STAGE_EXIT_GROUP" + testStageExitGroupFault = "README_TERMINAL_DEMO_TEST_STAGE_FAULT" + testSuccessfulExecMode = "README_TERMINAL_DEMO_TEST_SUCCESSFUL_EXEC_SIBLING" +) + +const ( + testMovedRulesetFD = 100 + testLowerDecoyFD = 50 + testUpperDecoyFD = 150 +) + +func TestExecutePreparedFailureStagesExitGroup(t *testing.T) { + if os.Getenv(testStageExitGroupMode) == "child" { + faultName := os.Getenv(testStageExitGroupFault) + fault, _ := requiredTerminalFault(t, faultName) + preparedPtr := preparedExecForTerminalTest(t, "TestExecutePreparedFailureStagesExitGroup", nil) + preparedPtr.fault = fault + startLockedPausedSibling() + executePrepared(preparedPtr) + } + + tests := []struct { + name string + wantExit int + }{ + {name: "upper-close", wantExit: runtimeFailureExitCode}, + {name: "lower-close", wantExit: runtimeFailureExitCode}, + {name: "no-new-privs", wantExit: runtimeFailureExitCode}, + {name: "restrict-self", wantExit: runtimeFailureExitCode}, + {name: "ruleset-close", wantExit: runtimeFailureExitCode}, + {name: "tid-mismatch", wantExit: runtimeFailureExitCode}, + {name: "execve", wantExit: executionFailureExitCode}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + command := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestExecutePreparedFailureStagesExitGroup$") + command.Env = []string{ + testStageExitGroupMode + "=child", + testStageExitGroupFault + "=" + test.name, + } + started := time.Now() + output, err := command.CombinedOutput() + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + t.Fatalf("stage %s left a sibling OS thread alive; output=%q", test.name, output) + } + assertExitCode(t, err, test.wantExit, output) + if elapsed := time.Since(started); elapsed > time.Second { + t.Fatalf("stage %s process-wide exit took %s", test.name, elapsed) + } + }) + } +} + +func requiredTerminalFault(t *testing.T, name string) (terminalFault, int) { + t.Helper() + + switch name { + case "upper-close": + return terminalFaultUpperClose, runtimeFailureExitCode + case "lower-close": + return terminalFaultLowerClose, runtimeFailureExitCode + case "no-new-privs": + return terminalFaultNoNewPrivs, runtimeFailureExitCode + case "restrict-self": + return terminalFaultRestrictSelf, runtimeFailureExitCode + case "ruleset-close": + return terminalFaultRulesetClose, runtimeFailureExitCode + case "tid-mismatch": + return terminalFaultTIDMismatch, runtimeFailureExitCode + case "execve": + return terminalFaultExecve, executionFailureExitCode + default: + t.Fatalf("unknown terminal fault stage %q", name) + return terminalFaultNone, 0 + } +} + +func TestSuccessfulExecDestroysSibling(t *testing.T) { + switch os.Getenv(testSuccessfulExecMode) { + case "target": + return + case "launcher": + startLockedPausedSibling() + if err := ExecRestricted(ExecSpec{ + Path: mustTestExecutable(t), + Args: []string{mustTestExecutable(t), "-test.run=^TestSuccessfulExecDestroysSibling$"}, + Env: []string{testSuccessfulExecMode + "=target"}, + Policy: Policy{ReadOnlyPaths: existingTestDirectories( + "/usr", "/etc", "/proc", "/sys", "/dev", filepath.Dir(mustTestExecutable(t)), + )}, + }); err != nil { + t.Fatalf("ExecRestricted() error = %v", err) + } + t.Fatal("ExecRestricted returned after successful preflight") + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + command := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestSuccessfulExecDestroysSibling$") + command.Env = []string{testSuccessfulExecMode + "=launcher"} + output, err := command.CombinedOutput() + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + t.Fatalf("successful exec left the pre-exec sibling alive; output=%q", output) + } + if err != nil { + t.Fatalf("successful raw-exec sibling helper: %v; output=%q", err, output) + } +} + +func preparedExecForTerminalTest(t *testing.T, testName string, environment []string) *preparedExec { + t.Helper() + + executable := mustTestExecutable(t) + preparedPtr, err := prepareRestrictedExec(ExecSpec{ + Path: executable, + Args: []string{executable, "-test.run=^" + testName + "$"}, + Env: environment, + Policy: Policy{ReadOnlyPaths: existingTestDirectories( + "/usr", "/etc", "/proc", "/sys", "/dev", filepath.Dir(executable), + )}, + }) + if err != nil { + t.Fatalf("prepare terminal-stage exec: %v", err) + } + return preparedPtr +} + +func mustTestExecutable(t *testing.T) string { + t.Helper() + + executable, err := os.Executable() + if err != nil { + t.Fatalf("resolve helper executable: %v", err) + } + return executable +} + +func startLockedPausedSibling() { + ready := make(chan struct{}) + go func() { + runtime.LockOSThread() + close(ready) + for { + _, _, _ = unix.RawSyscall(unix.SYS_PAUSE, 0, 0, 0) + } + }() + <-ready +} + +func TestExecRestrictedPreservesLandlockAcrossRawExec(t *testing.T) { + mode := os.Getenv(testExecProbeMode) + if mode == "target" { + assertPostExecBoundary(t) + return + } + if mode == "launcher" { + executable, err := os.Executable() + if err != nil { + t.Fatalf("resolve helper executable: %v", err) + } + allowed := os.Getenv("README_TERMINAL_DEMO_TEST_ALLOWED") + ExecRestricted(ExecSpec{ + Path: executable, + Args: []string{executable, "-test.run=^TestExecRestrictedPreservesLandlockAcrossRawExec$"}, + Env: []string{ + testExecProbeMode + "=target", + "README_TERMINAL_DEMO_TEST_ALLOWED=" + allowed, + "README_TERMINAL_DEMO_TEST_DENIED_READ=" + os.Getenv("README_TERMINAL_DEMO_TEST_DENIED_READ"), + "README_TERMINAL_DEMO_TEST_DENIED_WRITE=" + os.Getenv("README_TERMINAL_DEMO_TEST_DENIED_WRITE"), + }, + Policy: Policy{ReadOnlyPaths: existingTestDirectories( + "/usr", "/etc", "/proc", "/sys", "/dev", + filepath.Dir(executable), filepath.Dir(allowed), + )}, + }) + t.Fatal("ExecRestricted returned after successful preflight") + } + abi, err := llsyscall.LandlockGetABIVersion() + if err != nil { + t.Skipf("detect live Landlock ABI: %v", err) + } + t.Logf("detected live Landlock ABI: %d", abi) + if abi < 3 { + t.Skipf("detected live Landlock ABI %d; raw path boundary requires ABI 3", abi) + } + + directory := t.TempDir() + allowedDirectory := filepath.Join(directory, "allowed") + deniedDirectory := filepath.Join(directory, "denied") + if err := os.MkdirAll(allowedDirectory, 0o700); err != nil { + t.Fatalf("create allowed directory: %v", err) + } + if err := os.MkdirAll(deniedDirectory, 0o700); err != nil { + t.Fatalf("create denied directory: %v", err) + } + allowed := filepath.Join(allowedDirectory, "readable") + deniedRead := filepath.Join(deniedDirectory, "readable") + deniedWrite := filepath.Join(deniedDirectory, "write-probe") + if err := os.WriteFile(allowed, []byte("allowed"), 0o600); err != nil { + t.Fatalf("write allowed fixture: %v", err) + } + if err := os.WriteFile(deniedRead, []byte("denied"), 0o600); err != nil { + t.Fatalf("write denied fixture: %v", err) + } + + command := exec.Command(os.Args[0], "-test.run=^TestExecRestrictedPreservesLandlockAcrossRawExec$") + command.Env = []string{ + testExecProbeMode + "=launcher", + "README_TERMINAL_DEMO_TEST_ALLOWED=" + allowed, + "README_TERMINAL_DEMO_TEST_DENIED_READ=" + deniedRead, + "README_TERMINAL_DEMO_TEST_DENIED_WRITE=" + deniedWrite, + } + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("post-exec Landlock helper: %v; output=%q", err, output) + } +} + +func assertPostExecBoundary(t *testing.T) { + t.Helper() + + allowed := os.Getenv("README_TERMINAL_DEMO_TEST_ALLOWED") + content, err := os.ReadFile(allowed) + if err != nil || string(content) != "allowed" { + t.Fatalf("allowed read after exec = %q, %v", content, err) + } + if _, err := os.ReadFile(os.Getenv("README_TERMINAL_DEMO_TEST_DENIED_READ")); !errors.Is(err, unix.EACCES) { + t.Fatalf("denied read after exec error = %v, want EACCES", err) + } + deniedWrite := os.Getenv("README_TERMINAL_DEMO_TEST_DENIED_WRITE") + file, err := os.OpenFile(deniedWrite, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err == nil { + _ = file.Close() + t.Fatal("denied write after exec unexpectedly succeeded") + } + if !errors.Is(err, unix.EACCES) { + t.Fatalf("denied write after exec error = %v, want EACCES", err) + } +} + +func existingTestDirectories(paths ...string) []string { + result := make([]string, 0, len(paths)) + seen := make(map[string]struct{}, len(paths)) + for _, path := range paths { + clean := filepath.Clean(path) + if _, ok := seen[clean]; ok { + continue + } + info, err := os.Stat(clean) + if err == nil && info.IsDir() { + seen[clean] = struct{}{} + result = append(result, clean) + } + } + return result +} + +func assertExitCode(t *testing.T, err error, want int, output []byte) { + t.Helper() + + var exitError *exec.ExitError + if !errors.As(err, &exitError) { + t.Fatalf("child error = %v, want exit %d; output=%q", err, want, output) + } + if got := exitError.ExitCode(); got != want { + t.Fatalf("child exit = %d, want %d; output=%q", got, want, output) + } +} + +func TestSanitizedPostLockExitCodes(t *testing.T) { + if runtimeFailureExitCode != 4 { + t.Fatalf("runtime failure exit code = %s, want 4", strconv.Itoa(runtimeFailureExitCode)) + } + if executionFailureExitCode != 5 { + t.Fatalf("execution failure exit code = %s, want 5", strconv.Itoa(executionFailureExitCode)) + } +} + +func TestPrepareV3RejectsABI2BeforeCreatingRuleset(t *testing.T) { + fake := newPrepareV3Fake(2) + + _, err := prepareV3WithOps(Policy{ + ReadOnlyPaths: []string{"/must-not-open-ro"}, + ReadWritePaths: []string{"/must-not-open-rw"}, + }, fake.ops()) + if !errors.Is(err, ErrLandlockV3Unavailable) { + t.Fatalf("prepareV3WithOps() error = %v, want ErrLandlockV3Unavailable", err) + } + if !reflect.DeepEqual(fake.events, []string{"abi"}) { + t.Fatalf("ABI 2 operations = %#v, want only ABI query", fake.events) + } + if len(fake.closed) != 0 { + t.Fatalf("ABI 2 closed descriptors = %#v, want none owned", fake.closed) + } +} + +func TestPrepareV3BuildsExactABIV3Ruleset(t *testing.T) { + for _, abi := range []int{3, 9} { + t.Run("abi-"+strconv.Itoa(abi), func(t *testing.T) { + fake := newPrepareV3Fake(abi) + prepared, err := prepareV3WithOps(Policy{ + ReadOnlyPaths: []string{"/z-ro", "/a-ro"}, + ReadWritePaths: []string{"/z-rw", "/a-rw"}, + }, fake.ops()) + if err != nil { + t.Fatalf("prepareV3WithOps() error = %v", err) + } + if prepared.rulesetFD != fake.rulesetFD { + t.Fatalf("prepared ruleset FD = %d, want %d", prepared.rulesetFD, fake.rulesetFD) + } + + wantRuleset := llsyscall.RulesetAttr{ + HandledAccessFS: exactV3HandledAccess, + HandledAccessNet: 0, + Scoped: 0, + } + if fake.createFlags != 0 || fake.rulesetAttr != wantRuleset { + t.Fatalf("create ruleset = %#v flags=%d, want %#v flags=0", fake.rulesetAttr, fake.createFlags, wantRuleset) + } + if exactV3HandledAccess != 0x7fff { + t.Fatalf("exactV3HandledAccess = %#x, want 0x7fff", exactV3HandledAccess) + } + + wantRules := []fakePathRule{ + {path: "/a-ro", allowed: readOnlyV3Access}, + {path: "/z-ro", allowed: readOnlyV3Access}, + {path: "/a-rw", allowed: readWriteV3Access}, + {path: "/z-rw", allowed: readWriteV3Access}, + {path: "/dev/null", allowed: nullDeviceV3Access}, + } + if !reflect.DeepEqual(fake.rules, wantRules) { + t.Fatalf("path rules = %#v, want %#v", fake.rules, wantRules) + } + if readOnlyV3Access != llsyscall.AccessFSExecute|llsyscall.AccessFSReadFile|llsyscall.AccessFSReadDir { + t.Fatalf("read-only access = %#x", readOnlyV3Access) + } + if readWriteV3Access != exactV3HandledAccess&^llsyscall.AccessFSRefer { + t.Fatalf("read-write access = %#x, want exact V3 without REFER", readWriteV3Access) + } + if nullDeviceV3Access != llsyscall.AccessFSExecute|llsyscall.AccessFSReadFile|llsyscall.AccessFSWriteFile|llsyscall.AccessFSTruncate { + t.Fatalf("null-device access = %#x", nullDeviceV3Access) + } + + wantOpenFlags := unix.O_PATH | unix.O_CLOEXEC | unix.O_NOFOLLOW + for _, opened := range fake.opened { + if opened.flags != wantOpenFlags || opened.mode != 0 { + t.Fatalf("open %q flags=%#x mode=%#o, want %#x mode=0", opened.path, opened.flags, opened.mode, wantOpenFlags) + } + } + if got, want := fake.closed, []int{10, 11, 12, 13, 14}; !reflect.DeepEqual(got, want) { + t.Fatalf("closed source descriptors = %#v, want %#v", got, want) + } + if !fake.cloexecSet || !fake.cloexecRead { + t.Fatalf("CLOEXEC set/read = %t/%t, want both true", fake.cloexecSet, fake.cloexecRead) + } + }) + } +} + +func TestPrepareV3ClosesEveryOwnedDescriptorOnFailure(t *testing.T) { + tests := []struct { + name string + fault string + rulesetFD int + wantClosed []int + }{ + {name: "create-ruleset", fault: "create", rulesetFD: 7}, + {name: "ruleset-fd-below-three", rulesetFD: 2, wantClosed: []int{2}}, + {name: "ruleset-fd-at-uint32-max", rulesetFD: int(^uint32(0)), wantClosed: []int{int(^uint32(0))}}, + {name: "open-path", fault: "open", rulesetFD: 7, wantClosed: []int{7}}, + {name: "opened-fd-fstat", fault: "fstat", rulesetFD: 7, wantClosed: []int{10, 7}}, + {name: "symlink-source", fault: "symlink", rulesetFD: 7, wantClosed: []int{10, 7}}, + {name: "regular-file-directory-source", fault: "regular", rulesetFD: 7, wantClosed: []int{10, 7}}, + {name: "wrong-null-device", fault: "wrong-device", rulesetFD: 7, wantClosed: []int{10, 11, 12, 7}}, + {name: "add-rule", fault: "add", rulesetFD: 7, wantClosed: []int{10, 7}}, + {name: "source-close", fault: "close-source", rulesetFD: 7, wantClosed: []int{10, 7}}, + {name: "set-cloexec", fault: "set-cloexec", rulesetFD: 7, wantClosed: []int{10, 11, 12, 7}}, + {name: "get-cloexec", fault: "get-cloexec", rulesetFD: 7, wantClosed: []int{10, 11, 12, 7}}, + {name: "cloexec-not-set", fault: "cloexec-false", rulesetFD: 7, wantClosed: []int{10, 11, 12, 7}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fake := newPrepareV3Fake(3) + fake.fault = test.fault + fake.rulesetFD = test.rulesetFD + _, err := prepareV3WithOps(Policy{ + ReadOnlyPaths: []string{"/ro"}, + ReadWritePaths: []string{"/rw"}, + }, fake.ops()) + if err == nil { + t.Fatal("prepareV3WithOps() error = nil, want injected failure") + } + if !reflect.DeepEqual(fake.closed, test.wantClosed) { + t.Fatalf("closed descriptors = %#v, want %#v; events=%#v", fake.closed, test.wantClosed, fake.events) + } + }) + } +} + +func TestPrepareExecVectors(t *testing.T) { + executable, err := os.Executable() + if err != nil { + t.Fatalf("resolve test executable: %v", err) + } + fake := newPrepareV3Fake(3) + args := []string{executable, "unique argument"} + environment := []string{"ALPHA=unique value", "EMPTY="} + prepared, err := prepareRestrictedExecWithOps(ExecSpec{ + Path: executable, + Args: args, + Env: environment, + Policy: Policy{}, + }, fake.ops()) + if err != nil { + t.Fatalf("prepareRestrictedExecWithOps() error = %v", err) + } + + if got, want := string(prepared.pathStorage), executable+"\x00"; got != want { + t.Fatalf("path storage = %q, want %q", got, want) + } + if len(prepared.argvStorage) != len(args) || len(prepared.argv) != len(args)+1 { + t.Fatalf("argv storage/vector lengths = %d/%d, want %d/%d", len(prepared.argvStorage), len(prepared.argv), len(args), len(args)+1) + } + if len(prepared.envStorage) != len(environment) || len(prepared.envp) != len(environment)+1 { + t.Fatalf("env storage/vector lengths = %d/%d, want %d/%d", len(prepared.envStorage), len(prepared.envp), len(environment), len(environment)+1) + } + for index, argument := range args { + if got, want := string(prepared.argvStorage[index]), argument+"\x00"; got != want { + t.Fatalf("argv storage %d = %q, want %q", index, got, want) + } + if prepared.argv[index] != &prepared.argvStorage[index][0] { + t.Fatalf("argv pointer %d does not reference owned storage", index) + } + } + for index, entry := range environment { + if got, want := string(prepared.envStorage[index]), entry+"\x00"; got != want { + t.Fatalf("env storage %d = %q, want %q", index, got, want) + } + if prepared.envp[index] != &prepared.envStorage[index][0] { + t.Fatalf("env pointer %d does not reference owned storage", index) + } + } + if prepared.argv[len(args)] != nil || prepared.envp[len(environment)] != nil { + t.Fatal("raw argv/environment vectors are not nil-terminated") + } + if prepared.argv[0] == &prepared.pathStorage[0] { + t.Fatal("path and argv storage unexpectedly alias") + } + if prepared.rulesetFD != fake.rulesetFD || prepared.upperFirst != uint32(fake.rulesetFD+1) || + prepared.lowerLast != uint32(fake.rulesetFD-1) || !prepared.hasLower { + t.Fatalf("descriptor geometry = fd %d upper %d lower %d hasLower=%t", prepared.rulesetFD, prepared.upperFirst, prepared.lowerLast, prepared.hasLower) + } + if prepared.fault != terminalFaultNone { + t.Fatalf("production terminal fault = %d, want none", prepared.fault) + } + if unsafe.Pointer(prepared.argv[0]) == nil || unsafe.Pointer(prepared.envp[0]) == nil { + t.Fatal("owned raw vector contains an unexpected nil pointer") + } + + args[1] = "mutated" + environment[0] = "ALPHA=mutated" + if got := string(prepared.argvStorage[1]); got != "unique argument\x00" { + t.Fatalf("argv storage aliased caller slice: %q", got) + } + if got := string(prepared.envStorage[0]); got != "ALPHA=unique value\x00" { + t.Fatalf("environment storage aliased caller slice: %q", got) + } + + invalid := []struct { + name string + spec ExecSpec + }{ + {name: "path-NUL", spec: ExecSpec{Path: executable + "\x00", Args: []string{executable + "\x00"}}}, + {name: "argv-NUL", spec: ExecSpec{Path: executable, Args: []string{executable, "bad\x00arg"}}}, + {name: "environment-NUL", spec: ExecSpec{Path: executable, Args: []string{executable}, Env: []string{"BAD=bad\x00value"}}}, + {name: "duplicate-environment", spec: ExecSpec{Path: executable, Args: []string{executable}, Env: []string{"DUP=one", "DUP=two"}}}, + } + for _, test := range invalid { + t.Run(test.name, func(t *testing.T) { + invalidFake := newPrepareV3Fake(3) + if _, err := prepareRestrictedExecWithOps(test.spec, invalidFake.ops()); !errors.Is(err, ErrRestrictedExec) { + t.Fatalf("prepareRestrictedExecWithOps() error = %v, want ErrRestrictedExec", err) + } + if len(invalidFake.events) != 0 { + t.Fatalf("invalid vectors reached ruleset operations: %#v", invalidFake.events) + } + }) + } +} + +func TestTerminalFakeRawOperationOrder(t *testing.T) { + type call struct { + first uint32 + last uint32 + flags uint32 + } + tests := []struct { + name string + fd int + want []call + }{ + {name: "ruleset-fd-three", fd: 3, want: []call{{first: 4, last: ^uint32(0), flags: unix.CLOSE_RANGE_UNSHARE}}}, + {name: "ruleset-fd-above-three", fd: 7, want: []call{ + {first: 8, last: ^uint32(0), flags: unix.CLOSE_RANGE_UNSHARE}, + {first: 3, last: 6, flags: 0}, + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var got []call + errno := closeInheritedExceptWith(test.fd, func(first, last, flags uint32) unix.Errno { + got = append(got, call{first: first, last: last, flags: flags}) + return 0 + }) + if errno != 0 { + t.Fatalf("closeInheritedExceptWith() errno = %v", errno) + } + if !reflect.DeepEqual(got, test.want) { + t.Fatalf("close-range calls = %#v, want %#v", got, test.want) + } + }) + } +} + +func TestExecutePreparedPreservesRulesetFD(t *testing.T) { + switch os.Getenv(testPreserveRulesetMode) { + case "target": + var decoys []int + switch os.Getenv(testPreserveRulesetCase) { + case "fd-three": + decoys = []int{testUpperDecoyFD} + case "fd-above-three": + decoys = []int{testLowerDecoyFD, testUpperDecoyFD} + default: + t.Fatalf("unknown preserve-ruleset target case %q", os.Getenv(testPreserveRulesetCase)) + } + for _, fd := range decoys { + if _, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0); !errors.Is(err, unix.EBADF) { + t.Fatalf("decoy FD %d after raw exec: error=%v, want EBADF", fd, err) + } + } + return + case "launcher": + runPreservedRulesetLauncher(t, os.Getenv(testPreserveRulesetCase)) + return + } + + tests := []struct { + name string + rulesetFD int + want []recordedCloseRange + }{ + { + name: "fd-three", + rulesetFD: 3, + want: []recordedCloseRange{ + {first: 4, last: ^uint32(0), flags: unix.CLOSE_RANGE_UNSHARE}, + }, + }, + { + name: "fd-above-three", + rulesetFD: testMovedRulesetFD, + want: []recordedCloseRange{ + {first: testMovedRulesetFD + 1, last: ^uint32(0), flags: unix.CLOSE_RANGE_UNSHARE}, + {first: 3, last: testMovedRulesetFD - 1, flags: 0}, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + requireExactCloseRanges(t, test.rulesetFD, test.want) + command := exec.Command(os.Args[0], "-test.run=^TestExecutePreparedPreservesRulesetFD$") + command.Env = []string{ + testPreserveRulesetMode + "=launcher", + testPreserveRulesetCase + "=" + test.name, + } + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("preserved-ruleset helper: %v; output=%q", err, output) + } + }) + } +} + +func runPreservedRulesetLauncher(t *testing.T, testCase string) { + t.Helper() + + executable, err := os.Executable() + if err != nil { + t.Fatalf("resolve helper executable: %v", err) + } + + switch testCase { + case "fd-three": + if err := unix.Close(3); err != nil && !errors.Is(err, unix.EBADF) { + t.Fatalf("free descriptor 3: %v", err) + } + case "fd-above-three": + if err := installDescriptorAt("/dev/zero", 3); err != nil { + t.Fatalf("occupy descriptor 3: %v", err) + } + default: + t.Fatalf("unknown preserve-ruleset launcher case %q", testCase) + } + + preparedPtr, err := prepareRestrictedExec(ExecSpec{ + Path: executable, + Args: []string{executable, "-test.run=^TestExecutePreparedPreservesRulesetFD$"}, + Env: []string{ + testPreserveRulesetMode + "=target", + testPreserveRulesetCase + "=" + testCase, + }, + Policy: Policy{ReadOnlyPaths: existingTestDirectories( + "/usr", "/etc", "/proc", "/sys", "/dev", filepath.Dir(executable), + )}, + }) + if err != nil { + t.Fatalf("prepare restricted exec: %v", err) + } + + switch testCase { + case "fd-three": + if preparedPtr.rulesetFD != 3 { + t.Fatalf("ruleset FD = %d, want 3 after freeing descriptor 3", preparedPtr.rulesetFD) + } + case "fd-above-three": + if preparedPtr.rulesetFD <= 3 { + t.Fatalf("ruleset FD = %d, want above occupied descriptor 3", preparedPtr.rulesetFD) + } + if preparedPtr.rulesetFD != testMovedRulesetFD { + if err := unix.Dup3(preparedPtr.rulesetFD, testMovedRulesetFD, unix.O_CLOEXEC); err != nil { + t.Fatalf("move ruleset FD to %d: %v", testMovedRulesetFD, err) + } + if err := unix.Close(preparedPtr.rulesetFD); err != nil { + t.Fatalf("close original ruleset FD: %v", err) + } + preparedPtr.rulesetFD = testMovedRulesetFD + preparedPtr.upperFirst = testMovedRulesetFD + 1 + preparedPtr.lowerLast = testMovedRulesetFD - 1 + preparedPtr.hasLower = true + } + if err := installDescriptorAt("/dev/zero", testLowerDecoyFD); err != nil { + t.Fatalf("install lower decoy: %v", err) + } + } + if err := installDescriptorAt("/dev/zero", testUpperDecoyFD); err != nil { + t.Fatalf("install upper decoy: %v", err) + } + + executePrepared(preparedPtr) +} + +func TestCloseRangeUnshareLeavesSiblingDescriptorUsable(t *testing.T) { + wantRanges := []recordedCloseRange{ + {first: testMovedRulesetFD + 1, last: ^uint32(0), flags: unix.CLOSE_RANGE_UNSHARE}, + {first: 3, last: testMovedRulesetFD - 1, flags: 0}, + } + requireExactCloseRanges(t, testMovedRulesetFD, wantRanges) + + if os.Getenv(testCloseRangeUnshareMode) == "child" { + runCloseRangeUnshareChild() + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + command := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestCloseRangeUnshareLeavesSiblingDescriptorUsable$") + command.Env = []string{testCloseRangeUnshareMode + "=child"} + output, err := command.CombinedOutput() + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + t.Fatalf("close-range unshare helper timed out; output=%q", output) + } + if err != nil { + t.Fatalf("close-range unshare helper: %v; output=%q", err, output) + } +} + +func runCloseRangeUnshareChild() { + for _, descriptor := range []struct { + path string + fd int + }{ + {path: "/dev/null", fd: testMovedRulesetFD}, + {path: "/dev/zero", fd: testLowerDecoyFD}, + {path: "/dev/zero", fd: testUpperDecoyFD}, + } { + if err := installDescriptorAt(descriptor.path, descriptor.fd); err != nil { + closeRangeChildFailure("install descriptor", err) + } + } + + type siblingResult struct { + lower error + upper error + } + ready := make(chan struct{}) + inspect := make(chan struct{}) + result := make(chan siblingResult, 1) + go func() { + runtime.LockOSThread() + close(ready) + <-inspect + var lowerStat, upperStat unix.Stat_t + result <- siblingResult{ + lower: unix.Fstat(testLowerDecoyFD, &lowerStat), + upper: unix.Fstat(testUpperDecoyFD, &upperStat), + } + }() + <-ready + + runtime.LockOSThread() + if errno := closeInheritedExcept(testMovedRulesetFD); errno != 0 { + closeRangeChildFailure("close inherited descriptors", errno) + } + var preserved unix.Stat_t + if err := unix.Fstat(testMovedRulesetFD, &preserved); err != nil { + closeRangeChildFailure("fstat preserved descriptor", err) + } + for _, fd := range []int{testLowerDecoyFD, testUpperDecoyFD} { + if _, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0); !errors.Is(err, unix.EBADF) { + closeRangeChildFailure("decoy remained in caller table", fmt.Errorf("fd %d: %v", fd, err)) + } + } + close(inspect) + sibling := <-result + if sibling.lower != nil || sibling.upper != nil { + closeRangeChildFailure("sibling lost old-table descriptor", fmt.Errorf("lower=%v upper=%v", sibling.lower, sibling.upper)) + } + os.Exit(0) +} + +func closeRangeChildFailure(stage string, err error) { + _, _ = fmt.Fprintf(os.Stderr, "%s: %v\n", stage, err) + os.Exit(17) +} + +type recordedCloseRange struct { + first uint32 + last uint32 + flags uint32 +} + +func requireExactCloseRanges(t *testing.T, rulesetFD int, want []recordedCloseRange) { + t.Helper() + + var got []recordedCloseRange + errno := closeInheritedExceptWith(rulesetFD, func(first, last, flags uint32) unix.Errno { + got = append(got, recordedCloseRange{first: first, last: last, flags: flags}) + return 0 + }) + if errno != 0 { + t.Fatalf("closeInheritedExceptWith(%d) errno = %v", rulesetFD, errno) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("close-range calls for FD %d = %#v, want %#v", rulesetFD, got, want) + } +} + +func installDescriptorAt(path string, target int) error { + fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err != nil { + return err + } + if fd == target { + _, err = unix.FcntlInt(uintptr(fd), unix.F_SETFD, 0) + return err + } + defer unix.Close(fd) + return unix.Dup3(fd, target, 0) +} + +func TestExecRestrictedDoesNotLeakReusedDecoyFD(t *testing.T) { + switch os.Getenv(testDecoyExecMode) { + case "target": + lowerDev := requiredUintEnvironment(t, "README_TERMINAL_DEMO_TEST_LOWER_DEV") + lowerIno := requiredUintEnvironment(t, "README_TERMINAL_DEMO_TEST_LOWER_INO") + upperDev := requiredUintEnvironment(t, "README_TERMINAL_DEMO_TEST_UPPER_DEV") + upperIno := requiredUintEnvironment(t, "README_TERMINAL_DEMO_TEST_UPPER_INO") + + lowerClosed := requireOriginalDescriptionAbsent(t, 3, lowerDev, lowerIno) + if lowerClosed { + reusedFD, err := unix.Open("/dev/null", unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err != nil { + t.Fatalf("reuse closed lower decoy FD: %v", err) + } + defer unix.Close(reusedFD) + if reusedFD != 3 { + t.Fatalf("reused lower descriptor = %d, want 3", reusedFD) + } + requireOriginalDescriptionAbsent(t, reusedFD, lowerDev, lowerIno) + } + requireOriginalDescriptionAbsent(t, testUpperDecoyFD, upperDev, upperIno) + return + + case "launcher": + lowerPath := os.Getenv(testDecoyLowerPath) + upperPath := os.Getenv(testDecoyUpperPath) + lowerDev, lowerIno := requiredPathIdentity(t, lowerPath) + upperDev, upperIno := requiredPathIdentity(t, upperPath) + if err := installDescriptorAt(lowerPath, 3); err != nil { + t.Fatalf("install lower decoy at FD 3: %v", err) + } + if err := installDescriptorAt(upperPath, testUpperDecoyFD); err != nil { + t.Fatalf("install upper decoy at FD %d: %v", testUpperDecoyFD, err) + } + + executable, err := os.Executable() + if err != nil { + t.Fatalf("resolve helper executable: %v", err) + } + err = ExecRestricted(ExecSpec{ + Path: executable, + Args: []string{executable, "-test.run=^TestExecRestrictedDoesNotLeakReusedDecoyFD$"}, + Env: []string{ + testDecoyExecMode + "=target", + "README_TERMINAL_DEMO_TEST_LOWER_DEV=" + strconv.FormatUint(lowerDev, 10), + "README_TERMINAL_DEMO_TEST_LOWER_INO=" + strconv.FormatUint(lowerIno, 10), + "README_TERMINAL_DEMO_TEST_UPPER_DEV=" + strconv.FormatUint(upperDev, 10), + "README_TERMINAL_DEMO_TEST_UPPER_INO=" + strconv.FormatUint(upperIno, 10), + }, + Policy: Policy{ReadOnlyPaths: existingTestDirectories( + "/usr", "/etc", "/proc", "/sys", "/dev", filepath.Dir(executable), + )}, + }) + if err != nil { + t.Fatalf("ExecRestricted() error = %v", err) + } + t.Fatal("ExecRestricted returned after successful preflight") + } + + directory := t.TempDir() + lowerPath := filepath.Join(directory, "lower-decoy") + upperPath := filepath.Join(directory, "upper-decoy") + if err := os.WriteFile(lowerPath, []byte("lower"), 0o600); err != nil { + t.Fatalf("write lower decoy: %v", err) + } + if err := os.WriteFile(upperPath, []byte("upper"), 0o600); err != nil { + t.Fatalf("write upper decoy: %v", err) + } + command := exec.Command(os.Args[0], "-test.run=^TestExecRestrictedDoesNotLeakReusedDecoyFD$") + command.Env = []string{ + testDecoyExecMode + "=launcher", + testDecoyLowerPath + "=" + lowerPath, + testDecoyUpperPath + "=" + upperPath, + } + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("decoy raw-exec helper: %v; output=%q", err, output) + } +} + +func requiredPathIdentity(t *testing.T, path string) (uint64, uint64) { + t.Helper() + + var stat unix.Stat_t + if err := unix.Stat(path, &stat); err != nil { + t.Fatalf("stat decoy %q: %v", path, err) + } + return uint64(stat.Dev), stat.Ino +} + +func requiredUintEnvironment(t *testing.T, name string) uint64 { + t.Helper() + + value, err := strconv.ParseUint(os.Getenv(name), 10, 64) + if err != nil { + t.Fatalf("parse %s: %v", name, err) + } + return value +} + +func requireOriginalDescriptionAbsent(t *testing.T, fd int, wantDev, wantIno uint64) bool { + t.Helper() + + var stat unix.Stat_t + err := unix.Fstat(fd, &stat) + if errors.Is(err, unix.EBADF) { + return true + } + if err != nil { + t.Fatalf("fstat decoy FD %d: %v", fd, err) + } + if uint64(stat.Dev) == wantDev && stat.Ino == wantIno { + t.Fatalf("original decoy file description leaked at FD %d", fd) + } + return false +} + +func TestExecVectorsSurviveGCAndCheckptr(t *testing.T) { + longArgument, longEnvironment := uniqueExecVectorValues() + switch os.Getenv(testExecVectorMode) { + case "target": + if len(os.Args) != 3 || os.Args[2] != longArgument { + t.Fatalf("post-exec argv mismatch: argc=%d final-bytes=%d", len(os.Args), len(os.Args[len(os.Args)-1])) + } + if got := os.Getenv("README_TERMINAL_DEMO_TEST_LONG_VECTOR"); got != longEnvironment { + t.Fatalf("post-exec environment bytes = %d, want %d", len(got), len(longEnvironment)) + } + return + + case "launcher": + executable, err := os.Executable() + if err != nil { + t.Fatalf("resolve helper executable: %v", err) + } + preparedPtr, err := prepareRestrictedExec(ExecSpec{ + Path: executable, + Args: []string{executable, "-test.run=^TestExecVectorsSurviveGCAndCheckptr$", longArgument}, + Env: []string{ + testExecVectorMode + "=target", + "GODEBUG=checkptr=2", + "README_TERMINAL_DEMO_TEST_LONG_VECTOR=" + longEnvironment, + }, + Policy: Policy{ReadOnlyPaths: existingTestDirectories( + "/usr", "/etc", "/proc", "/sys", "/dev", filepath.Dir(executable), + )}, + }) + if err != nil { + t.Fatalf("prepare restricted exec: %v", err) + } + + longArgument = "" + longEnvironment = "" + for round := 0; round < 8; round++ { + churn := make([][]byte, 256) + for index := range churn { + churn[index] = make([]byte, 2048) + churn[index][0] = byte(round + index) + } + runtime.GC() + runtime.KeepAlive(churn) + } + executePrepared(preparedPtr) + return + } + + command := exec.Command(os.Args[0], "-test.run=^TestExecVectorsSurviveGCAndCheckptr$") + command.Env = []string{ + testExecVectorMode + "=launcher", + "GODEBUG=checkptr=2", + } + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("GC/checkptr raw-exec helper: %v; output=%q", err, output) + } +} + +func uniqueExecVectorValues() (string, string) { + return "argument-" + strings.Repeat("a1B2c3D4", 4096), + "environment-" + strings.Repeat("Z9y8X7w6", 4096) +} + +type fakeOpen struct { + path string + flags int + mode uint32 +} + +type fakePathRule struct { + path string + allowed uint64 +} + +type prepareV3Fake struct { + abi int + rulesetFD int + fault string + nextFD int + fdPaths map[int]string + events []string + opened []fakeOpen + rules []fakePathRule + closed []int + rulesetAttr llsyscall.RulesetAttr + createFlags int + cloexecSet bool + cloexecRead bool + lastFstatFD int +} + +func newPrepareV3Fake(abi int) *prepareV3Fake { + return &prepareV3Fake{ + abi: abi, + rulesetFD: 7, + nextFD: 10, + fdPaths: make(map[int]string), + } +} + +func (fake *prepareV3Fake) ops() prepareV3Ops { + return prepareV3Ops{ + getABI: func() (int, error) { + fake.events = append(fake.events, "abi") + return fake.abi, nil + }, + createRuleset: func(attr *llsyscall.RulesetAttr, flags int) (int, error) { + fake.events = append(fake.events, "create") + fake.rulesetAttr = *attr + fake.createFlags = flags + if fake.fault == "create" { + return -1, unix.EIO + } + return fake.rulesetFD, nil + }, + openPath: func(path string, flags int, mode uint32) (int, error) { + fake.events = append(fake.events, "open:"+path) + if fake.fault == "open" { + return -1, unix.EIO + } + fd := fake.nextFD + fake.nextFD++ + fake.fdPaths[fd] = path + fake.opened = append(fake.opened, fakeOpen{path: path, flags: flags, mode: mode}) + return fd, nil + }, + fstat: func(fd int, stat *unix.Stat_t) error { + fake.events = append(fake.events, "fstat:"+strconv.Itoa(fd)) + fake.lastFstatFD = fd + if fake.fault == "fstat" { + return unix.EIO + } + path := fake.fdPaths[fd] + switch { + case fake.fault == "symlink": + stat.Mode = unix.S_IFLNK | 0o777 + case fake.fault == "regular": + stat.Mode = unix.S_IFREG | 0o644 + case path == "/dev/null": + stat.Mode = unix.S_IFCHR | 0o666 + stat.Rdev = unix.Mkdev(1, 3) + if fake.fault == "wrong-device" { + stat.Rdev = unix.Mkdev(1, 5) + } + default: + stat.Mode = unix.S_IFDIR | 0o755 + } + return nil + }, + addPathRule: func(rulesetFD int, attr *llsyscall.PathBeneathAttr, flags int) error { + fake.events = append(fake.events, "add:"+strconv.Itoa(attr.ParentFd)) + if rulesetFD != fake.rulesetFD || flags != 0 || attr.ParentFd != fake.lastFstatFD { + return unix.EINVAL + } + fake.rules = append(fake.rules, fakePathRule{path: fake.fdPaths[attr.ParentFd], allowed: attr.AllowedAccess}) + if fake.fault == "add" { + return unix.EIO + } + return nil + }, + setCLOEXEC: func(fd int) error { + fake.events = append(fake.events, "set-cloexec") + fake.cloexecSet = true + if fake.fault == "set-cloexec" { + return unix.EIO + } + return nil + }, + getCLOEXEC: func(fd int) (bool, error) { + fake.events = append(fake.events, "get-cloexec") + fake.cloexecRead = true + if fake.fault == "get-cloexec" { + return false, unix.EIO + } + return fake.fault != "cloexec-false", nil + }, + closeFD: func(fd int) error { + fake.events = append(fake.events, "close:"+strconv.Itoa(fd)) + fake.closed = append(fake.closed, fd) + if fake.fault == "close-source" && fd >= 10 { + return unix.EIO + } + return nil + }, + } +} diff --git a/tools/readme-terminal-demo/internal/sandbox/launcher_structure_test.go b/tools/readme-terminal-demo/internal/sandbox/launcher_structure_test.go new file mode 100644 index 000000000..d9346f4a7 --- /dev/null +++ b/tools/readme-terminal-demo/internal/sandbox/launcher_structure_test.go @@ -0,0 +1,521 @@ +package sandbox + +import ( + "bufio" + "go/ast" + "go/parser" + "go/token" + "os" + "os/exec" + "regexp" + "strconv" + "strings" + "testing" +) + +func TestTerminalSourceStructure(t *testing.T) { + terminal := functionSourceFrom(t, "exec_linux.go", "executePrepared") + requireOrdered(t, terminal, + "runtime.LockOSThread()", + "rawGetTID()", + "closeInheritedExcept(preparedPtr.rulesetFD)", + "rawNoNewPrivs()", + "rawLandlockRestrictSelf(preparedPtr.rulesetFD, 0)", + "rawCloseFD(preparedPtr.rulesetFD)", + "rawGetTID()", + "unix.RawSyscall(unix.SYS_EXECVE", + "runtime.KeepAlive(preparedPtr)", + "exitGroup(executionFailureExitCode)", + ) + + terminalFunctions := []struct { + filename string + name string + allowed map[string]bool + }{ + { + filename: "exec_linux.go", + name: "executePrepared", + allowed: allowedTerminalCalls( + "runtime.LockOSThread", "rawGetTID", "exitGroup", "rawCloseRangeCall", + "closeInheritedExcept", "rawNoNewPrivs", "rawLandlockRestrictSelf", + "rawCloseFD", "unix.RawSyscall", "uintptr", "uint32", "unsafe.Pointer", + "runtime.KeepAlive", + ), + }, + { + filename: "fds_linux.go", + name: "closeInheritedExcept", + allowed: allowedTerminalCalls("rawCloseRangeCall", "uint32"), + }, + { + filename: "fds_linux.go", + name: "rawCloseRangeCall", + allowed: allowedTerminalCalls("unix.RawSyscall", "uintptr"), + }, + { + filename: "exec_linux.go", + name: "rawGetTID", + allowed: allowedTerminalCalls("unix.RawSyscall"), + }, + { + filename: "exec_linux.go", + name: "rawNoNewPrivs", + allowed: allowedTerminalCalls("unix.RawSyscall6", "uintptr"), + }, + { + filename: "exec_linux.go", + name: "rawLandlockRestrictSelf", + allowed: allowedTerminalCalls("unix.RawSyscall", "uintptr"), + }, + { + filename: "exec_linux.go", + name: "rawCloseFD", + allowed: allowedTerminalCalls("unix.RawSyscall", "uintptr"), + }, + { + filename: "exec_linux.go", + name: "exitGroup", + allowed: allowedTerminalCalls("unix.RawSyscall", "uintptr"), + }, + } + for _, function := range terminalFunctions { + declaration := functionDeclarationFrom(t, function.filename, function.name) + requireRawTerminalFunction(t, function.name, declaration, function.allowed) + } + + execute := functionDeclarationFrom(t, "exec_linux.go", "executePrepared") + requireNoOrdinaryReturn(t, execute) + requireDirectExecvePointers(t, execute) + requireSingleKeepAlive(t, execute) + requirePreparedExecStoresNoUintptr(t) + requireProductionFaultNone(t) + requireProductionCloseUsesRawWrapper(t) + requireNoReturningRestrictionPath(t) + requireRawPreflightBoundary(t) + + if strings.Contains(fileSource(t, "exec_linux.go"), "os.Getenv") { + t.Fatal("terminal fault is controllable through the environment") + } +} + +func TestTerminalEscapeAnalysis(t *testing.T) { + execute := functionDeclarationFrom(t, "exec_linux.go", "executePrepared") + requireSingleKeepAlive(t, execute) + for _, filename := range []string{"exec_linux.go", "fds_linux.go"} { + for _, forbidden := range []string{"//go:noescape", "//go:uintptrescapes", "//go:linkname"} { + if strings.Contains(fileSource(t, filename), forbidden) { + t.Fatalf("%s contains forbidden escape workaround %q", filename, forbidden) + } + } + } + + spans := []terminalDiagnosticSpan{ + terminalFunctionSpan(t, "exec_linux.go", "executePrepared"), + terminalFunctionSpan(t, "fds_linux.go", "closeInheritedExcept"), + terminalFunctionSpan(t, "fds_linux.go", "rawCloseRangeCall"), + terminalFunctionSpan(t, "exec_linux.go", "rawGetTID"), + terminalFunctionSpan(t, "exec_linux.go", "rawNoNewPrivs"), + terminalFunctionSpan(t, "exec_linux.go", "rawLandlockRestrictSelf"), + terminalFunctionSpan(t, "exec_linux.go", "rawCloseFD"), + terminalFunctionSpan(t, "exec_linux.go", "exitGroup"), + } + + command := exec.Command("go", "test", "-run", "^$", "-gcflags=all=-m=2", ".") + command.Env = append(os.Environ(), "GOCACHE="+t.TempDir()) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("run terminal escape analysis: %v; output=%s", err, output) + } + + diagnosticPattern := regexp.MustCompile(`(?:^|[/\\])([^/\\:]+\.go):([0-9]+):[0-9]+:\s+(.*)$`) + seen := make(map[string]bool, len(spans)) + scanner := bufio.NewScanner(strings.NewReader(string(output))) + scanner.Buffer(make([]byte, 4096), 1024*1024) + for scanner.Scan() { + match := diagnosticPattern.FindStringSubmatch(scanner.Text()) + if match == nil { + continue + } + line, conversionErr := strconv.Atoi(match[2]) + if conversionErr != nil { + t.Fatalf("parse compiler diagnostic line %q: %v", match[2], conversionErr) + } + for _, span := range spans { + if match[1] != span.filename || line < span.firstLine || line > span.lastLine { + continue + } + seen[span.name] = true + message := match[3] + for _, forbidden := range []string{"escapes to heap", "moved to heap", "leaking param", "leaks to heap"} { + if strings.Contains(message, forbidden) { + t.Errorf("terminal compiler diagnostic for %s: %s", span.name, message) + } + } + } + } + if err := scanner.Err(); err != nil { + t.Fatalf("scan compiler diagnostics: %v", err) + } + for _, span := range spans { + if !seen[span.name] { + t.Errorf("compiler emitted no mapped diagnostic for terminal span %s", span.name) + } + } +} + +type terminalDiagnosticSpan struct { + filename string + name string + firstLine int + lastLine int +} + +func terminalFunctionSpan(t *testing.T, filename, name string) terminalDiagnosticSpan { + t.Helper() + + source := []byte(fileSource(t, filename)) + files, declaration := parseFunction(t, filename, source, name) + return terminalDiagnosticSpan{ + filename: filename, + name: name, + firstLine: files.Position(declaration.Pos()).Line, + lastLine: files.Position(declaration.End()).Line, + } +} + +func allowedTerminalCalls(names ...string) map[string]bool { + allowed := make(map[string]bool, len(names)) + for _, name := range names { + allowed[name] = true + } + return allowed +} + +func requireRawTerminalFunction(t *testing.T, name string, declaration *ast.FuncDecl, allowed map[string]bool) { + t.Helper() + + ast.Inspect(declaration.Body, func(node ast.Node) bool { + switch typed := node.(type) { + case *ast.DeferStmt: + t.Errorf("%s contains forbidden defer", name) + case *ast.GoStmt: + t.Errorf("%s contains forbidden goroutine handoff", name) + case *ast.FuncLit: + t.Errorf("%s contains forbidden cleanup callback or fallback", name) + case *ast.InterfaceType: + t.Errorf("%s contains forbidden interface boxing", name) + case *ast.CallExpr: + called := calledFunctionName(typed.Fun) + if !allowed[called] { + t.Errorf("%s calls forbidden terminal operation %q", name, called) + } + } + return true + }) +} + +func calledFunctionName(expression ast.Expr) string { + switch called := expression.(type) { + case *ast.Ident: + return called.Name + case *ast.SelectorExpr: + if packageName, ok := called.X.(*ast.Ident); ok { + return packageName.Name + "." + called.Sel.Name + } + return called.Sel.Name + default: + return "" + } +} + +func requireNoOrdinaryReturn(t *testing.T, declaration *ast.FuncDecl) { + t.Helper() + + ast.Inspect(declaration.Body, func(node ast.Node) bool { + if _, ok := node.(*ast.ReturnStmt); ok { + t.Error("executePrepared contains forbidden ordinary return") + } + return true + }) +} + +func requireDirectExecvePointers(t *testing.T, declaration *ast.FuncDecl) { + t.Helper() + + var execveCalls []*ast.CallExpr + allowedPointerConversions := make(map[token.Pos]bool) + ast.Inspect(declaration.Body, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok || calledFunctionName(call.Fun) != "unix.RawSyscall" || len(call.Args) != 4 || + expressionName(call.Args[0]) != "unix.SYS_EXECVE" { + return true + } + execveCalls = append(execveCalls, call) + for _, argument := range call.Args[1:] { + if !isDirectUnsafeUintptr(argument) { + t.Errorf("SYS_EXECVE pointer argument is not a direct uintptr(unsafe.Pointer(...)) conversion") + continue + } + allowedPointerConversions[argument.Pos()] = true + } + return true + }) + if len(execveCalls) != 1 { + t.Fatalf("executePrepared has %d raw SYS_EXECVE calls; want exactly 1", len(execveCalls)) + } + + ast.Inspect(declaration.Body, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if ok && isDirectUnsafeUintptr(call) && !allowedPointerConversions[call.Pos()] { + t.Error("executePrepared caches or uses an unsafe uintptr conversion outside the SYS_EXECVE call") + } + return true + }) +} + +func isDirectUnsafeUintptr(expression ast.Expr) bool { + outer, ok := expression.(*ast.CallExpr) + if !ok || calledFunctionName(outer.Fun) != "uintptr" || len(outer.Args) != 1 { + return false + } + inner, ok := outer.Args[0].(*ast.CallExpr) + return ok && calledFunctionName(inner.Fun) == "unsafe.Pointer" && len(inner.Args) == 1 +} + +func expressionName(expression ast.Expr) string { + switch value := expression.(type) { + case *ast.Ident: + return value.Name + case *ast.SelectorExpr: + if packageName, ok := value.X.(*ast.Ident); ok { + return packageName.Name + "." + value.Sel.Name + } + return value.Sel.Name + default: + return "" + } +} + +func requireSingleKeepAlive(t *testing.T, declaration *ast.FuncDecl) { + t.Helper() + + var keepAliveCalls []*ast.CallExpr + var execvePosition, executionExitPosition token.Pos + ast.Inspect(declaration.Body, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + switch calledFunctionName(call.Fun) { + case "runtime.KeepAlive": + keepAliveCalls = append(keepAliveCalls, call) + case "unix.RawSyscall": + if len(call.Args) == 4 && expressionName(call.Args[0]) == "unix.SYS_EXECVE" { + execvePosition = call.Pos() + } + case "exitGroup": + if len(call.Args) == 1 && expressionName(call.Args[0]) == "executionFailureExitCode" { + executionExitPosition = call.Pos() + } + } + return true + }) + if len(keepAliveCalls) != 1 { + t.Fatalf("executePrepared has %d runtime.KeepAlive calls; want exactly 1", len(keepAliveCalls)) + } + call := keepAliveCalls[0] + if len(call.Args) != 1 || expressionName(call.Args[0]) != "preparedPtr" { + t.Fatal("the sole liveness barrier is not runtime.KeepAlive(preparedPtr)") + } + if execvePosition == token.NoPos || executionExitPosition == token.NoPos || + !(execvePosition < call.Pos() && call.Pos() < executionExitPosition) { + t.Fatal("runtime.KeepAlive(preparedPtr) is not exactly between returned execve and exit-group 5") + } +} + +func requirePreparedExecStoresNoUintptr(t *testing.T) { + t.Helper() + + parsed := parseFile(t, "exec_linux.go") + for _, declaration := range parsed.Decls { + general, ok := declaration.(*ast.GenDecl) + if !ok || general.Tok != token.TYPE { + continue + } + for _, specification := range general.Specs { + typeSpec, ok := specification.(*ast.TypeSpec) + if !ok || typeSpec.Name.Name != "preparedExec" { + continue + } + structure, ok := typeSpec.Type.(*ast.StructType) + if !ok { + t.Fatal("preparedExec is not a struct") + } + ast.Inspect(structure, func(node ast.Node) bool { + if identifier, ok := node.(*ast.Ident); ok && identifier.Name == "uintptr" { + t.Error("preparedExec stores forbidden cached uintptr data") + } + return true + }) + return + } + } + t.Fatal("preparedExec type declaration not found") +} + +func requireProductionFaultNone(t *testing.T) { + t.Helper() + + declaration := functionDeclarationFrom(t, "exec_linux.go", "prepareRestrictedExecWithOps") + found := false + ast.Inspect(declaration.Body, func(node ast.Node) bool { + literal, ok := node.(*ast.CompositeLit) + if !ok || expressionName(literal.Type) != "preparedExec" { + return true + } + for _, element := range literal.Elts { + keyValue, ok := element.(*ast.KeyValueExpr) + if !ok || expressionName(keyValue.Key) != "fault" { + continue + } + found = expressionName(keyValue.Value) == "terminalFaultNone" + } + return true + }) + if !found { + t.Fatal("production preparation does not pin terminalFaultNone") + } +} + +func requireProductionCloseUsesRawWrapper(t *testing.T) { + t.Helper() + + production := functionSourceFrom(t, "fds_linux.go", "closeInheritedExcept") + if strings.Contains(production, "closeInheritedExceptWith") { + t.Error("production descriptor close routes through the injectable test seam") + } + requireOrdered(t, production, + "rawCloseRangeCall(uint32(rulesetFD+1), ^uint32(0), unix.CLOSE_RANGE_UNSHARE)", + "rulesetFD > 3", + "rawCloseRangeCall(3, uint32(rulesetFD-1), 0)", + ) + if strings.Count(production, "rawCloseRangeCall(") != 2 { + t.Errorf("production descriptor close has %d raw wrapper calls; want 2", strings.Count(production, "rawCloseRangeCall(")) + } +} + +func requireNoReturningRestrictionPath(t *testing.T) { + t.Helper() + + parsed := parseFile(t, "landlock_linux.go") + for _, declaration := range parsed.Decls { + function, ok := declaration.(*ast.FuncDecl) + if !ok { + continue + } + switch function.Name.Name { + case "RestrictV3", "applyPreparedV3": + t.Errorf("production retains forbidden returning restriction path %s", function.Name.Name) + } + } +} + +func requireRawPreflightBoundary(t *testing.T) { + t.Helper() + + preflight := functionSourceFrom(t, "exec_linux.go", "prepareRestrictedExecWithOps") + for _, required := range []string{ + "validateExecPath(spec.Path)", + "validateExecArgs(spec.Path, spec.Args)", + "validateExecEnvironment(spec.Env)", + "ownedCString(spec.Path)", + "ownedCStringVector(spec.Args)", + "ownedCStringVector(spec.Env)", + "prepareV3WithOps(spec.Policy, ops)", + } { + if !strings.Contains(preflight, required) { + t.Errorf("prepareRestrictedExecWithOps is missing fallible preflight %q", required) + } + } + if strings.Contains(preflight, "runtime.LockOSThread") { + t.Error("prepareRestrictedExecWithOps locks the OS thread before fallible preflight completes") + } + + launcher := functionSourceFrom(t, "exec_linux.go", "ExecRestricted") + requireOrdered(t, launcher, + "prepareRestrictedExec(spec)", + "executePrepared(preparedPtr)", + "exitGroup(runtimeFailureExitCode)", + ) +} + +func requireOrdered(t *testing.T, source string, fragments ...string) { + t.Helper() + + next := 0 + for _, fragment := range fragments { + relative := strings.Index(source[next:], fragment) + if relative < 0 { + t.Fatalf("source is missing %q after byte %d", fragment, next) + } + next += relative + len(fragment) + } +} + +func functionSourceFrom(t *testing.T, filename, name string) string { + t.Helper() + + source := fileSource(t, filename) + files, declaration := parseFunction(t, filename, []byte(source), name) + start := files.Position(declaration.Pos()).Offset + end := files.Position(declaration.End()).Offset + return source[start:end] +} + +func functionDeclarationFrom(t *testing.T, filename, name string) *ast.FuncDecl { + t.Helper() + + source := []byte(fileSource(t, filename)) + _, declaration := parseFunction(t, filename, source, name) + return declaration +} + +func fileSource(t *testing.T, filename string) string { + t.Helper() + + source, err := os.ReadFile(filename) + if err != nil { + t.Fatalf("read %s: %v", filename, err) + } + return string(source) +} + +func parseFile(t *testing.T, filename string) *ast.File { + t.Helper() + + parsed, err := parser.ParseFile(token.NewFileSet(), filename, fileSource(t, filename), 0) + if err != nil { + t.Fatalf("parse %s: %v", filename, err) + } + return parsed +} + +func parseFunction(t *testing.T, filename string, source []byte, name string) (*token.FileSet, *ast.FuncDecl) { + t.Helper() + + files := token.NewFileSet() + parsed, err := parser.ParseFile(files, filename, source, 0) + if err != nil { + t.Fatalf("parse %s: %v", filename, err) + } + for _, declaration := range parsed.Decls { + function, ok := declaration.(*ast.FuncDecl) + if !ok || function.Name.Name != name { + continue + } + return files, function + } + t.Fatalf("%s declaration not found", name) + return nil, nil +} diff --git a/tools/readme-terminal-demo/internal/sandbox/process_linux.go b/tools/readme-terminal-demo/internal/sandbox/process_linux.go new file mode 100644 index 000000000..b81055e77 --- /dev/null +++ b/tools/readme-terminal-demo/internal/sandbox/process_linux.go @@ -0,0 +1,128 @@ +package sandbox + +import ( + "context" + "errors" + "fmt" + "io" + "os/exec" + "syscall" + "time" + + "golang.org/x/sys/unix" +) + +const defaultTimeout = 45 * time.Second + +// ErrProcessTimeout means the complete child process group exceeded its limit. +var ErrProcessTimeout = errors.New("restricted child timed out") + +// Child describes a hidden restricted child process. +type Child struct { + Path string + Args []string + Env []string + Dir string + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer + Timeout time.Duration +} + +// Outcome preserves the direct child's wait status without conflating normal +// reserved exit codes with signals that happen to use the same numbers. +type Outcome struct { + Exited bool + ExitCode int + Signaled bool + Signal unix.Signal + TimedOut bool +} + +// RunRestrictedChild starts one process group, observes the direct child +// without reaping it, attempts group cleanup after every outcome, and then +// performs the one direct-child wait that reaps it. +func RunRestrictedChild(ctx context.Context, child Child) (Outcome, error) { + timeout := child.Timeout + if timeout == 0 { + timeout = defaultTimeout + } + deadline, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + command := exec.Command(child.Path, child.Args...) + command.Env = child.Env + command.Dir = child.Dir + command.Stdin = child.Stdin + command.Stdout = child.Stdout + command.Stderr = child.Stderr + command.WaitDelay = 2 * time.Second + command.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + Pdeathsig: syscall.SIGKILL, + } + if err := command.Start(); err != nil { + return Outcome{}, fmt.Errorf("start restricted child: %w", err) + } + + observed := make(chan error, 1) + go func() { + var info unix.Siginfo + observed <- unix.Waitid(unix.P_PID, command.Process.Pid, &info, unix.WEXITED|unix.WNOWAIT, nil) + }() + + timedOut := false + contextErr := error(nil) + observeErr := error(nil) + select { + case observeErr = <-observed: + case <-deadline.Done(): + if ctx.Err() != nil { + contextErr = ctx.Err() + } else if errors.Is(deadline.Err(), context.DeadlineExceeded) { + timedOut = true + } else { + contextErr = deadline.Err() + } + _ = unix.Kill(-command.Process.Pid, unix.SIGKILL) + observeErr = <-observed + } + + killErr := unix.Kill(-command.Process.Pid, unix.SIGKILL) + waitErr := command.Wait() + if timedOut { + return Outcome{TimedOut: true}, ErrProcessTimeout + } + outcome := outcomeFromCommand(command) + if contextErr != nil { + return outcome, contextErr + } + if observeErr != nil { + return outcome, fmt.Errorf("observe restricted child: %w", observeErr) + } + if killErr != nil && !errors.Is(killErr, unix.ESRCH) { + return outcome, fmt.Errorf("clean restricted child group: %w", killErr) + } + var exitError *exec.ExitError + if waitErr != nil && !errors.As(waitErr, &exitError) { + return outcome, fmt.Errorf("wait restricted child: %w", waitErr) + } + return outcome, nil +} + +func outcomeFromCommand(command *exec.Cmd) Outcome { + if command.ProcessState == nil { + return Outcome{} + } + waitStatus, ok := command.ProcessState.Sys().(syscall.WaitStatus) + if !ok { + return Outcome{} + } + if waitStatus.Exited() { + return Outcome{Exited: true, ExitCode: waitStatus.ExitStatus()} + } + if waitStatus.Signaled() { + return Outcome{Signaled: true, Signal: unix.Signal(waitStatus.Signal())} + } + return Outcome{} +} diff --git a/tools/readme-terminal-demo/internal/sandbox/sandbox_test.go b/tools/readme-terminal-demo/internal/sandbox/sandbox_test.go new file mode 100644 index 000000000..f42776f2a --- /dev/null +++ b/tools/readme-terminal-demo/internal/sandbox/sandbox_test.go @@ -0,0 +1,299 @@ +package sandbox_test + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + llsyscall "github.com/landlock-lsm/go-landlock/landlock/syscall" + "github.com/z-shell/.github/tools/readme-terminal-demo/internal/sandbox" + "golang.org/x/sys/unix" +) + +const ( + rawDeviceBoundaryMode = "README_TERMINAL_DEMO_TEST_RAW_DEVICE_BOUNDARY" + escapedPipeHolderMode = "README_TERMINAL_DEMO_TEST_ESCAPED_PIPE_HOLDER" + escapedPipeHolderPID = "README_TERMINAL_DEMO_TEST_ESCAPED_PIPE_PID" + escapedPipeHolderMarker = "README_TERMINAL_DEMO_TEST_ESCAPED_PIPE_MARKER" +) + +func TestRunRestrictedChildCleansGroupAfterEveryOutcome(t *testing.T) { + const childScript = ` +ulimit -c 0 +printf '%s\n' "$$" > "$1" +( + sleep 0.25 + printf staged > "$2" +) & +case "$3" in + exit-0) exit 0 ;; + exit-1) exit 1 ;; + exit-4) exit 4 ;; + exit-5) exit 5 ;; + signal-4) kill -4 "$$" ;; + signal-5) kill -5 "$$" ;; + timeout) sleep 60 ;; +esac +exit 99 +` + tests := []struct { + name string + mode string + timeout time.Duration + want sandbox.Outcome + wantErr error + }{ + {name: "normal-exit-zero", mode: "exit-0", want: sandbox.Outcome{Exited: true, ExitCode: 0}}, + {name: "normal-exit-one", mode: "exit-1", want: sandbox.Outcome{Exited: true, ExitCode: 1}}, + {name: "normal-exit-four", mode: "exit-4", want: sandbox.Outcome{Exited: true, ExitCode: 4}}, + {name: "normal-exit-five", mode: "exit-5", want: sandbox.Outcome{Exited: true, ExitCode: 5}}, + {name: "signal-four", mode: "signal-4", want: sandbox.Outcome{Signaled: true, Signal: unix.Signal(4)}}, + {name: "signal-five", mode: "signal-5", want: sandbox.Outcome{Signaled: true, Signal: unix.Signal(5)}}, + {name: "timeout", mode: "timeout", timeout: 100 * time.Millisecond, want: sandbox.Outcome{TimedOut: true}, wantErr: sandbox.ErrProcessTimeout}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + directory := t.TempDir() + pidPath := filepath.Join(directory, "direct.pid") + stagingPath := filepath.Join(directory, "staging-byte") + timeout := test.timeout + if timeout == 0 { + timeout = 2 * time.Second + } + outcome, err := sandbox.RunRestrictedChild(context.Background(), sandbox.Child{ + Path: "/bin/sh", + Args: []string{"-c", childScript, "sh", pidPath, stagingPath, test.mode}, + Timeout: timeout, + }) + if test.wantErr == nil { + if err != nil { + t.Fatalf("RunRestrictedChild() error = %v, want nil", err) + } + } else if !errors.Is(err, test.wantErr) { + t.Fatalf("RunRestrictedChild() error = %v, want %v", err, test.wantErr) + } + if outcome != test.want { + t.Fatalf("RunRestrictedChild() outcome = %#v, want %#v", outcome, test.want) + } + + pid := readProcessID(t, pidPath, time.Second) + if err := unix.Kill(pid, 0); !errors.Is(err, unix.ESRCH) { + t.Fatalf("direct child PID %d remains after wait/reap: %v", pid, err) + } + time.Sleep(400 * time.Millisecond) + if _, err := os.Stat(stagingPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("staging witness was read before group cleanup completed: %v", err) + } + }) + } +} + +func TestRunRestrictedChildBoundsEscapedPipeHolder(t *testing.T) { + if os.Getenv(escapedPipeHolderMode) == "holder" { + if _, err := unix.Setsid(); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "setsid: %v\n", err) + os.Exit(20) + } + if err := os.WriteFile(os.Getenv(escapedPipeHolderPID), []byte(strconv.Itoa(os.Getpid())), 0o600); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "write escaped PID: %v\n", err) + os.Exit(21) + } + time.Sleep(4 * time.Second) + if err := os.WriteFile(os.Getenv(escapedPipeHolderMarker), []byte("staged"), 0o600); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "write escaped marker: %v\n", err) + os.Exit(22) + } + time.Sleep(time.Minute) + os.Exit(0) + } + + directory := t.TempDir() + pidPath := filepath.Join(directory, "escaped.pid") + stagingPath := filepath.Join(directory, "escaped-staging-byte") + const launcherScript = ` +"$1" -test.run='^TestRunRestrictedChildBoundsEscapedPipeHolder$' & +while [ ! -s "$2" ]; do sleep 0.01; done +exit 0 +` + var output bytes.Buffer + type result struct { + outcome sandbox.Outcome + err error + } + resultChannel := make(chan result, 1) + started := time.Now() + go func() { + outcome, err := sandbox.RunRestrictedChild(context.Background(), sandbox.Child{ + Path: "/bin/sh", + Args: []string{"-c", launcherScript, "sh", os.Args[0], pidPath}, + Env: []string{ + "PATH=/usr/bin:/bin", + escapedPipeHolderMode + "=holder", + escapedPipeHolderPID + "=" + pidPath, + escapedPipeHolderMarker + "=" + stagingPath, + }, + Stdout: &output, + Stderr: &output, + Timeout: 8 * time.Second, + }) + resultChannel <- result{outcome: outcome, err: err} + }() + + var got result + select { + case got = <-resultChannel: + case <-time.After(5 * time.Second): + pid := readProcessID(t, pidPath, time.Second) + _ = unix.Kill(pid, unix.SIGKILL) + select { + case <-resultChannel: + case <-time.After(2 * time.Second): + } + t.Fatal("RunRestrictedChild did not bound an escaped pipe holder") + } + + escapedPID := readProcessID(t, pidPath, time.Second) + defer unix.Kill(escapedPID, unix.SIGKILL) + if got.outcome != (sandbox.Outcome{Exited: true, ExitCode: 0}) { + t.Fatalf("escaped-holder outcome = %#v, want normal exit 0", got.outcome) + } + if !errors.Is(got.err, exec.ErrWaitDelay) { + t.Fatalf("escaped-holder error = %v, want exec.ErrWaitDelay; output=%q", got.err, output.Bytes()) + } + if elapsed := time.Since(started); elapsed < 1500*time.Millisecond || elapsed > 4*time.Second { + t.Fatalf("escaped-holder bounded return took %s, want approximately two seconds", elapsed) + } + if _, err := os.Stat(stagingPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("escaped staging witness was read before bounded cleanup: %v", err) + } +} + +func readProcessID(t *testing.T, path string, timeout time.Duration) int { + t.Helper() + + deadline := time.Now().Add(timeout) + for { + content, err := os.ReadFile(path) + if err == nil { + pid, conversionErr := strconv.Atoi(strings.TrimSpace(string(content))) + if conversionErr != nil || pid <= 0 { + t.Fatalf("parse process ID %q: %v", content, conversionErr) + } + return pid + } + if !errors.Is(err, os.ErrNotExist) || time.Now().After(deadline) { + t.Fatalf("read process ID %s: %v", path, err) + } + time.Sleep(10 * time.Millisecond) + } +} + +func TestRawExecDeviceBoundary(t *testing.T) { + switch os.Getenv(rawDeviceBoundaryMode) { + case "target": + if err := writeDevice("/dev/null"); err != nil { + t.Fatal(err) + } + fd, err := unix.Open("/dev/zero", unix.O_WRONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err == nil { + _ = unix.Close(fd) + t.Fatal("/dev/zero remained writable after raw exec") + } + if !errors.Is(err, unix.EACCES) { + t.Fatalf("open /dev/zero after raw exec: %v; want EACCES", err) + } + return + case "launcher": + if err := requireCharacterDevice("/dev/null", 1, 3); err != nil { + t.Fatal(err) + } + if err := requireCharacterDevice("/dev/zero", 1, 5); err != nil { + t.Fatal(err) + } + if err := writeDevice("/dev/zero"); err != nil { + t.Fatal(err) + } + executable, err := os.Executable() + if err != nil { + t.Fatalf("resolve test executable: %v", err) + } + sandbox.ExecRestricted(sandbox.ExecSpec{ + Path: executable, + Args: []string{executable, "-test.run=^TestRawExecDeviceBoundary$"}, + Env: []string{rawDeviceBoundaryMode + "=target"}, + Policy: sandbox.Policy{ReadOnlyPaths: existingDirectories( + "/usr", "/etc", "/proc", "/sys", "/dev", filepath.Dir(executable), + )}, + }) + t.Fatal("ExecRestricted returned after successful preflight") + } + + abi, err := llsyscall.LandlockGetABIVersion() + if err != nil { + t.Skipf("detect live Landlock ABI: %v", err) + } + t.Logf("detected live Landlock ABI: %d", abi) + if abi < 3 { + t.Skipf("detected live Landlock ABI %d; raw device boundary requires ABI 3", abi) + } + + command := exec.Command(os.Args[0], "-test.run=^TestRawExecDeviceBoundary$") + command.Env = []string{rawDeviceBoundaryMode + "=launcher"} + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("raw-exec device-boundary helper: %v; output=%q", err, output) + } +} + +func existingDirectories(paths ...string) []string { + result := make([]string, 0, len(paths)) + seen := make(map[string]struct{}, len(paths)) + for _, path := range paths { + clean := filepath.Clean(path) + if _, duplicate := seen[clean]; duplicate { + continue + } + info, err := os.Stat(clean) + if err == nil && info.IsDir() { + seen[clean] = struct{}{} + result = append(result, clean) + } + } + return result +} + +func requireCharacterDevice(path string, wantMajor, wantMinor uint32) error { + var stat unix.Stat_t + if err := unix.Fstatat(unix.AT_FDCWD, path, &stat, unix.AT_SYMLINK_NOFOLLOW); err != nil { + return fmt.Errorf("inspect %s without following links: %w", path, err) + } + if stat.Mode&unix.S_IFMT != unix.S_IFCHR { + return fmt.Errorf("%s is not a character device", path) + } + if major, minor := unix.Major(uint64(stat.Rdev)), unix.Minor(uint64(stat.Rdev)); major != wantMajor || minor != wantMinor { + return fmt.Errorf("%s identity is %d:%d; want %d:%d", path, major, minor, wantMajor, wantMinor) + } + return nil +} + +func writeDevice(path string) error { + fd, err := unix.Open(path, unix.O_WRONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err != nil { + return fmt.Errorf("open %s for write: %w", path, err) + } + if _, err := unix.Write(fd, []byte{0}); err != nil { + _ = unix.Close(fd) + return fmt.Errorf("write %s: %w", path, err) + } + if err := unix.Close(fd); err != nil { + return fmt.Errorf("close %s: %w", path, err) + } + return nil +} diff --git a/tools/readme-terminal-demo/scripts/bootstrap-gh.sh b/tools/readme-terminal-demo/scripts/bootstrap-gh.sh new file mode 100755 index 000000000..2d9690311 --- /dev/null +++ b/tools/readme-terminal-demo/scripts/bootstrap-gh.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash + +# Download, verify, extract, and invoke the one approved GitHub CLI binary. + +set -euo pipefail + +readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +readonly GH_TAR_URL='https://github.com/cli/cli/releases/download/v2.96.0/gh_2.96.0_linux_amd64.tar.gz' +readonly GH_TAR_SHA256='83d5c2ccad5498f58bf6368acb1ab32588cf43ab3a4b1c301bf36328b1c8bd60' +readonly GH_CHECKSUMS_URL='https://github.com/cli/cli/releases/download/v2.96.0/gh_2.96.0_checksums.txt' +readonly GH_CHECKSUMS_SHA256='fc046371efa250e2875208341a786a35a01717d5eebec6903e199a9b8a3f3565' +readonly WORK_DIR="$(mktemp -d)" +trap 'rm -rf -- "${WORK_DIR}"' EXIT + +if [[ -n "${README_TERMINAL_DEMO_GH_ASSET_DIR:-}" ]]; then + cp -- "${README_TERMINAL_DEMO_GH_ASSET_DIR}/gh_2.96.0_linux_amd64.tar.gz" "${WORK_DIR}/" + cp -- "${README_TERMINAL_DEMO_GH_ASSET_DIR}/gh_2.96.0_checksums.txt" "${WORK_DIR}/" +else + curl --fail --location --silent --show-error \ + --output "${WORK_DIR}/gh_2.96.0_linux_amd64.tar.gz" "${GH_TAR_URL}" + curl --fail --location --silent --show-error \ + --output "${WORK_DIR}/gh_2.96.0_checksums.txt" "${GH_CHECKSUMS_URL}" +fi + +# Keep the embedded literals visible for the central parity check. +[[ "${GH_TAR_SHA256}" == '83d5c2ccad5498f58bf6368acb1ab32588cf43ab3a4b1c301bf36328b1c8bd60' ]] +[[ "${GH_CHECKSUMS_SHA256}" == 'fc046371efa250e2875208341a786a35a01717d5eebec6903e199a9b8a3f3565' ]] +"${SCRIPT_DIR}/verify-gh-assets.sh" "${WORK_DIR}" >&2 + +tar -xzf "${WORK_DIR}/gh_2.96.0_linux_amd64.tar.gz" -C "${WORK_DIR}" +"${WORK_DIR}/gh_2.96.0_linux_amd64/bin/gh" "$@" diff --git a/tools/readme-terminal-demo/scripts/bootstrap-gh_test.sh b/tools/readme-terminal-demo/scripts/bootstrap-gh_test.sh new file mode 100755 index 000000000..0fe37f4bb --- /dev/null +++ b/tools/readme-terminal-demo/scripts/bootstrap-gh_test.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +# Verify the reusable bootstrap runs only the verified extracted GitHub CLI. + +set -euo pipefail + +readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +readonly BOOTSTRAP="${SCRIPT_DIR}/bootstrap-gh.sh" +readonly ASSETS_DIR="${1:?usage: bootstrap-gh_test.sh ASSETS_DIR}" + +output="$(README_TERMINAL_DEMO_GH_ASSET_DIR="${ASSETS_DIR}" "${BOOTSTRAP}" version)" +[[ "${output}" == gh\ version\ 2.96.0* ]] diff --git a/tools/readme-terminal-demo/scripts/in-go-image.sh b/tools/readme-terminal-demo/scripts/in-go-image.sh new file mode 100755 index 000000000..76b8b345f --- /dev/null +++ b/tools/readme-terminal-demo/scripts/in-go-image.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +# Run contributor Go commands in the immutable project toolchain. + +set -euo pipefail + +readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +readonly TOOL_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)" +readonly GO_IMAGE='golang:1.26.5-trixie@sha256:117e07f49461abb984fc8aef661432461ff43d06faa22c3b73af6a49ce325cb9' + +exec docker run --rm \ + --platform linux/amd64 \ + --volume "${TOOL_DIR}:/src:ro" \ + --workdir /src \ + "${GO_IMAGE}" \ + "$@" diff --git a/tools/readme-terminal-demo/scripts/in-go-image_test.sh b/tools/readme-terminal-demo/scripts/in-go-image_test.sh new file mode 100755 index 000000000..874b3fcc3 --- /dev/null +++ b/tools/readme-terminal-demo/scripts/in-go-image_test.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +# Verify the contributor Go wrapper uses the immutable toolchain and mounts. + +set -euo pipefail + +readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +readonly WRAPPER="${SCRIPT_DIR}/in-go-image.sh" +readonly GO_DIGEST='sha256:117e07f49461abb984fc8aef661432461ff43d06faa22c3b73af6a49ce325cb9' + +grep -Fq -- "${GO_DIGEST}" "${WRAPPER}" + +version="$(${WRAPPER} go version)" +[[ "${version}" == 'go version go1.26.5 linux/amd64' ]] + +if "${WRAPPER}" sh -c 'printf blocked > /src/.read-only-probe'; then + echo 'expected the /src write probe to fail' >&2 + exit 1 +fi + +"${WRAPPER}" sh -c 'probe=/tmp/readme-terminal-demo-write-probe; printf allowed > "$probe"; test "$(cat "$probe")" = allowed; rm -f -- "$probe"' diff --git a/tools/readme-terminal-demo/scripts/verify-gh-assets.sh b/tools/readme-terminal-demo/scripts/verify-gh-assets.sh new file mode 100755 index 000000000..cbeaac23d --- /dev/null +++ b/tools/readme-terminal-demo/scripts/verify-gh-assets.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash + +# Verify the pinned GitHub CLI release bytes before extraction. + +set -euo pipefail + +readonly ASSETS_DIR="${1:?usage: verify-gh-assets.sh ASSETS_DIR}" + +( + cd -- "${ASSETS_DIR}" + printf '%s %s\n' \ + 'fc046371efa250e2875208341a786a35a01717d5eebec6903e199a9b8a3f3565' \ + 'gh_2.96.0_checksums.txt' | sha256sum -c - + grep -Fx '83d5c2ccad5498f58bf6368acb1ab32588cf43ab3a4b1c301bf36328b1c8bd60 gh_2.96.0_linux_amd64.tar.gz' gh_2.96.0_checksums.txt + printf '%s %s\n' \ + '83d5c2ccad5498f58bf6368acb1ab32588cf43ab3a4b1c301bf36328b1c8bd60' \ + 'gh_2.96.0_linux_amd64.tar.gz' | sha256sum -c - +) diff --git a/tools/readme-terminal-demo/scripts/verify-gh-assets_test.sh b/tools/readme-terminal-demo/scripts/verify-gh-assets_test.sh new file mode 100755 index 000000000..53eb21ed8 --- /dev/null +++ b/tools/readme-terminal-demo/scripts/verify-gh-assets_test.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +# Prove the pinned GitHub CLI bootstrap rejects modified release metadata/data. + +set -euo pipefail + +readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +readonly VERIFIER="${SCRIPT_DIR}/verify-gh-assets.sh" +readonly ASSETS_DIR="${1:?usage: verify-gh-assets_test.sh ASSETS_DIR}" +readonly WORK_DIR="$(mktemp -d)" +trap 'rm -rf -- "${WORK_DIR}"' EXIT + +"${VERIFIER}" "${ASSETS_DIR}" + +expect_failure() { + local label="$1" + shift + if "$@"; then + echo "expected ${label} to fail" >&2 + exit 1 + fi +} + +checksum_case="${WORK_DIR}/checksum" +mkdir -p -- "${checksum_case}" +cp -- "${ASSETS_DIR}/gh_2.96.0_checksums.txt" "${checksum_case}/gh_2.96.0_checksums.txt" +ln -s -- "${ASSETS_DIR}/gh_2.96.0_linux_amd64.tar.gz" "${checksum_case}/gh_2.96.0_linux_amd64.tar.gz" +printf 'modified\n' >>"${checksum_case}/gh_2.96.0_checksums.txt" +expect_failure 'modified GitHub CLI checksum file' "${VERIFIER}" "${checksum_case}" + +tar_case="${WORK_DIR}/tar" +mkdir -p -- "${tar_case}" +ln -s -- "${ASSETS_DIR}/gh_2.96.0_checksums.txt" "${tar_case}/gh_2.96.0_checksums.txt" +cp -- "${ASSETS_DIR}/gh_2.96.0_linux_amd64.tar.gz" "${tar_case}/gh_2.96.0_linux_amd64.tar.gz" +printf 'modified\n' >>"${tar_case}/gh_2.96.0_linux_amd64.tar.gz" +expect_failure 'modified GitHub CLI archive' "${VERIFIER}" "${tar_case}" diff --git a/tools/readme-terminal-demo/scripts/verify-release-assets.sh b/tools/readme-terminal-demo/scripts/verify-release-assets.sh new file mode 100755 index 000000000..bcb14c71b --- /dev/null +++ b/tools/readme-terminal-demo/scripts/verify-release-assets.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash + +# Verify the cosign bootstrap and VHS release bytes before any extraction. + +set -euo pipefail + +readonly ASSETS_DIR="${1:?usage: verify-release-assets.sh ASSETS_DIR}" + +( + cd -- "${ASSETS_DIR}" + + printf '%s %s\n' \ + 'c956e5dfcac53d52bcf058360d579472f0c1d2d9b69f55209e256fe7783f4c74' \ + 'cosign-linux-amd64' | sha256sum -c - + printf '%s %s\n' \ + 'b3a04913f3a3f4a38e4a7a42b8d590834b8791de99ddeaad66c608b6aa8e02a4' \ + 'cosign-linux-amd64.sigstore.json' | sha256sum -c - + printf '%s %s\n' \ + '71b7e8eb9742c1d8bad844980dd00bf665743a0321d1a32832d24a6e371952f2' \ + 'checksums.txt' | sha256sum -c - + printf '%s %s\n' \ + 'a4e998a04e9a0e43a7bf6a6180a0a83801bf6fb8b3ca88c7f2ba4f8255955128' \ + 'checksums.txt.sigstore.json' | sha256sum -c - + printf '%s %s\n' \ + '99cb634587eaae0473c1ea377db80c3a048c27f99fe0a7febb1a1e8cb7ee5009' \ + 'vhs_0.11.0_Linux_x86_64.tar.gz' | sha256sum -c - + + chmod +x -- cosign-linux-amd64 + ./cosign-linux-amd64 verify-blob \ + --bundle cosign-linux-amd64.sigstore.json \ + --certificate-identity 'keyless@projectsigstore.iam.gserviceaccount.com' \ + --certificate-oidc-issuer 'https://accounts.google.com' \ + cosign-linux-amd64 + ./cosign-linux-amd64 verify-blob \ + --bundle checksums.txt.sigstore.json \ + --certificate-identity 'https://github.com/charmbracelet/meta/.github/workflows/goreleaser.yml@refs/heads/main' \ + --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \ + checksums.txt + grep -Fx '99cb634587eaae0473c1ea377db80c3a048c27f99fe0a7febb1a1e8cb7ee5009 vhs_0.11.0_Linux_x86_64.tar.gz' checksums.txt + printf '%s %s\n' \ + '99cb634587eaae0473c1ea377db80c3a048c27f99fe0a7febb1a1e8cb7ee5009' \ + 'vhs_0.11.0_Linux_x86_64.tar.gz' | sha256sum -c - +) diff --git a/tools/readme-terminal-demo/scripts/verify-release-assets_test.sh b/tools/readme-terminal-demo/scripts/verify-release-assets_test.sh new file mode 100755 index 000000000..b36ff647b --- /dev/null +++ b/tools/readme-terminal-demo/scripts/verify-release-assets_test.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash + +# Exercise release verification against real downloaded assets and mutations. + +set -euo pipefail + +readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +readonly VERIFIER="${SCRIPT_DIR}/verify-release-assets.sh" +readonly ASSETS_DIR="${1:?usage: verify-release-assets_test.sh ASSETS_DIR}" +readonly WORK_DIR="$(mktemp -d)" +trap 'rm -rf -- "${WORK_DIR}"' EXIT + +"${VERIFIER}" "${ASSETS_DIR}" + +expect_failure() { + local label="$1" + shift + if "$@"; then + echo "expected ${label} to fail" >&2 + exit 1 + fi +} + +make_case() { + local name="$1" + local directory="${WORK_DIR}/${name}" + mkdir -p -- "${directory}" + local asset + for asset in cosign-linux-amd64 cosign-linux-amd64.sigstore.json checksums.txt checksums.txt.sigstore.json vhs_0.11.0_Linux_x86_64.tar.gz; do + ln -s -- "${ASSETS_DIR}/${asset}" "${directory}/${asset}" + done + printf '%s\n' "${directory}" +} + +checksum_case="$(make_case checksum)" +rm -- "${checksum_case}/checksums.txt" +cp -- "${ASSETS_DIR}/checksums.txt" "${checksum_case}/checksums.txt" +printf 'modified\n' >>"${checksum_case}/checksums.txt" +expect_failure 'modified checksum file' "${VERIFIER}" "${checksum_case}" + +expect_failure 'wrong certificate identity' \ + "${ASSETS_DIR}/cosign-linux-amd64" verify-blob \ + --bundle "${ASSETS_DIR}/checksums.txt.sigstore.json" \ + --certificate-identity 'https://invalid.example/workflow.yml@refs/heads/main' \ + --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \ + "${ASSETS_DIR}/checksums.txt" + +expect_failure 'wrong OIDC issuer' \ + "${ASSETS_DIR}/cosign-linux-amd64" verify-blob \ + --bundle "${ASSETS_DIR}/checksums.txt.sigstore.json" \ + --certificate-identity 'https://github.com/charmbracelet/meta/.github/workflows/goreleaser.yml@refs/heads/main' \ + --certificate-oidc-issuer 'https://invalid.example' \ + "${ASSETS_DIR}/checksums.txt" + +bundle_case="$(make_case bundle)" +rm -- "${bundle_case}/checksums.txt.sigstore.json" +cp -- "${ASSETS_DIR}/checksums.txt.sigstore.json" "${bundle_case}/checksums.txt.sigstore.json" +printf 'modified\n' >>"${bundle_case}/checksums.txt.sigstore.json" +expect_failure 'modified Sigstore bundle' "${VERIFIER}" "${bundle_case}" + +tar_case="$(make_case tar)" +rm -- "${tar_case}/vhs_0.11.0_Linux_x86_64.tar.gz" +cp -- "${ASSETS_DIR}/vhs_0.11.0_Linux_x86_64.tar.gz" "${tar_case}/vhs_0.11.0_Linux_x86_64.tar.gz" +printf 'modified\n' >>"${tar_case}/vhs_0.11.0_Linux_x86_64.tar.gz" +expect_failure 'modified VHS archive' "${VERIFIER}" "${tar_case}" diff --git a/tools/readme-terminal-demo/testdata/smoke/readme.tape b/tools/readme-terminal-demo/testdata/smoke/readme.tape new file mode 100644 index 000000000..e39305881 --- /dev/null +++ b/tools/readme-terminal-demo/testdata/smoke/readme.tape @@ -0,0 +1,13 @@ +Output "/work/smoke.gif" + +Set Shell "zsh" +Set FontFamily "JetBrains Mono" +Set FontSize 24 +Set Width 800 +Set Height 300 + +Type "printf 'readme-terminal-demo smoke\\n'" +Enter +Sleep 1s +Type "exit" +Enter From abf9119d1b74ab5acb422d8d30b0f9b39b3db1b6 Mon Sep 17 00:00:00 2001 From: Sall Date: Sun, 19 Jul 2026 03:14:51 +0100 Subject: [PATCH 2/6] feat(terminal-demo): prove hardened renderer runtime --- .../cmd/readme-terminal-demo/main.go | 197 +++++++++++- .../cmd/readme-terminal-demo/main_test.go | 275 ++++++++++++++++- .../internal/sandbox/landlock_linux.go | 200 +++++++++++- .../sandbox/landlock_syscall_linux.go | 45 ++- .../sandbox/launcher_internal_test.go | 284 +++++++++++++++++- .../internal/sandbox/sandbox_test.go | 102 ++++++- .../testdata/smoke/readme.tape | 10 +- 7 files changed, 1066 insertions(+), 47 deletions(-) diff --git a/tools/readme-terminal-demo/cmd/readme-terminal-demo/main.go b/tools/readme-terminal-demo/cmd/readme-terminal-demo/main.go index cedb2e52f..adeec0504 100644 --- a/tools/readme-terminal-demo/cmd/readme-terminal-demo/main.go +++ b/tools/readme-terminal-demo/cmd/readme-terminal-demo/main.go @@ -11,9 +11,11 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" "time" + llsyscall "github.com/landlock-lsm/go-landlock/landlock/syscall" "github.com/z-shell/.github/tools/readme-terminal-demo/internal/failure" "github.com/z-shell/.github/tools/readme-terminal-demo/internal/sandbox" "golang.org/x/sys/unix" @@ -67,6 +69,17 @@ type captureDependencies struct { execRestricted func(sandbox.ExecSpec) error } +type selfTestRuntimeProbes struct { + defaultRoutes func() (bool, bool, error) + loopbackRoundTrip func(time.Duration) error + dialTimeout func(string, string, time.Duration) error + processIdentity func() (int, int, error) + fileLimits func() (uint64, uint64, error) + handledAccessNet func() uint64 + landlockABI func() (int, error) + landlockInherited func() error +} + type restrictedChildRunner func(context.Context, sandbox.Child) (sandbox.Outcome, error) func main() { @@ -120,8 +133,9 @@ func captureWith(tape string, dependencies captureDependencies) error { Args: []string{dependencies.vhsPath, tape}, Env: vhsExecEnvironment(), Policy: sandbox.Policy{ - ReadOnlyPaths: existingDirectories("/usr", "/etc", "/proc", "/sys", "/dev"), - ReadWritePaths: existingDirectories("/tmp", "/work", "/dev/shm"), + ReadOnlyPaths: existingDirectories("/usr", "/etc", "/proc", "/sys", "/dev"), + ReadWritePaths: existingDirectories("/tmp", "/work", "/dev/shm"), + AllowPrivateDevptsPTY: true, }, } if err := dependencies.execRestricted(spec); err != nil { @@ -270,6 +284,176 @@ func validatePinnedLauncher(path, expectedSHA256 string) error { return nil } +func probeSelfTestNetworkAndRuntime(probes selfTestRuntimeProbes) (int, error) { + if probes.defaultRoutes == nil || probes.loopbackRoundTrip == nil || probes.dialTimeout == nil || + probes.processIdentity == nil || probes.fileLimits == nil || probes.handledAccessNet == nil || probes.landlockABI == nil || + probes.landlockInherited == nil { + return 0, errors.New("incomplete self-test runtime probes") + } + + hasIPv4Default, hasIPv6Default, err := probes.defaultRoutes() + if err != nil { + return 0, fmt.Errorf("inspect default routes: %w", err) + } + if hasIPv4Default || hasIPv6Default { + return 0, errors.New("default network route available") + } + if err := probes.loopbackRoundTrip(time.Second); err != nil { + return 0, fmt.Errorf("verify loopback round trip: %w", err) + } + if err := probes.dialTimeout("tcp", "192.0.2.1:443", time.Second); err == nil { + return 0, errors.New("external network connection succeeded") + } + pid, processGroup, err := probes.processIdentity() + if err != nil { + return 0, fmt.Errorf("inspect process group: %w", err) + } + if pid <= 0 || processGroup <= 0 { + return 0, errors.New("invalid process-group membership") + } + softLimit, hardLimit, err := probes.fileLimits() + if err != nil { + return 0, fmt.Errorf("inspect RLIMIT_NOFILE: %w", err) + } + if softLimit != 256 || hardLimit != 256 { + return 0, fmt.Errorf("RLIMIT_NOFILE soft/hard=%d/%d, want 256/256", softLimit, hardLimit) + } + if handledAccessNet := probes.handledAccessNet(); handledAccessNet != 0 { + return 0, fmt.Errorf("handled_access_net=%d, want 0", handledAccessNet) + } + abi, err := probes.landlockABI() + if err != nil { + return 0, fmt.Errorf("detect Landlock ABI: %w", err) + } + if abi < 3 { + return 0, fmt.Errorf("detected Landlock ABI %d, want at least 3", abi) + } + if err := probes.landlockInherited(); err != nil { + return 0, fmt.Errorf("verify Landlock inheritance prerequisite: %w", err) + } + return abi, nil +} + +func productionSelfTestRuntimeProbes() selfTestRuntimeProbes { + return selfTestRuntimeProbes{ + defaultRoutes: defaultRoutePresence, + loopbackRoundTrip: loopbackRoundTrip, + dialTimeout: func(network, address string, timeout time.Duration) error { + connection, err := net.DialTimeout(network, address, timeout) + if err == nil { + _ = connection.Close() + } + return err + }, + processIdentity: func() (int, int, error) { + processGroup, err := unix.Getpgid(0) + return os.Getpid(), processGroup, err + }, + fileLimits: func() (uint64, uint64, error) { + var limits unix.Rlimit + err := unix.Getrlimit(unix.RLIMIT_NOFILE, &limits) + return limits.Cur, limits.Max, err + }, + handledAccessNet: func() uint64 { return 0 }, + landlockABI: llsyscall.LandlockGetABIVersion, + landlockInherited: func() error { + _, err := os.ReadFile("/landlock-denied/readable") + return err + }, + } +} + +func defaultRoutePresence() (bool, bool, error) { + ipv4Routes, err := os.ReadFile("/proc/net/route") + if err != nil { + return false, false, err + } + ipv6Routes, err := os.ReadFile("/proc/net/ipv6_route") + if err != nil { + return false, false, err + } + return hasIPv4DefaultRoute(ipv4Routes), hasIPv6DefaultRoute(ipv6Routes), nil +} + +func hasIPv4DefaultRoute(routes []byte) bool { + for _, line := range strings.Split(string(routes), "\n") { + fields := strings.Fields(line) + if len(fields) < 8 || fields[1] != "00000000" || fields[7] != "00000000" { + continue + } + flags, err := strconv.ParseUint(fields[3], 16, 32) + if err == nil && flags&0x1 != 0 && flags&0x200 == 0 { + return true + } + } + return false +} + +func hasIPv6DefaultRoute(routes []byte) bool { + const ( + zeroAddress = "00000000000000000000000000000000" + rejectRouteFlag = 0x0200 // Linux UAPI RTF_REJECT. + ) + for _, line := range strings.Split(string(routes), "\n") { + fields := strings.Fields(line) + if len(fields) < 10 || fields[0] != zeroAddress || fields[1] != "00" { + continue + } + flags, err := strconv.ParseUint(fields[8], 16, 32) + if err == nil && flags&rejectRouteFlag == 0 { + return true + } + } + return false +} + +func loopbackRoundTrip(timeout time.Duration) error { + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + return err + } + defer listener.Close() + + serverResult := make(chan error, 1) + go func() { + connection, acceptErr := listener.Accept() + if acceptErr != nil { + serverResult <- acceptErr + return + } + defer connection.Close() + _ = connection.SetDeadline(time.Now().Add(timeout)) + request := []byte{0} + if _, readErr := io.ReadFull(connection, request); readErr != nil { + serverResult <- readErr + return + } + _, writeErr := connection.Write(request) + serverResult <- writeErr + }() + + connection, err := net.DialTimeout("tcp4", listener.Addr().String(), timeout) + if err != nil { + return err + } + defer connection.Close() + _ = connection.SetDeadline(time.Now().Add(timeout)) + if _, err := connection.Write([]byte{0x5a}); err != nil { + return err + } + response := []byte{0} + if _, err := io.ReadFull(connection, response); err != nil { + return err + } + if err := <-serverResult; err != nil { + return err + } + if response[0] != 0x5a { + return errors.New("loopback response mismatch") + } + return nil +} + func selfTest(stdout io.Writer) error { if os.Geteuid() == 0 { return failure.E(failure.RendererUnavailable, failure.StageRuntime, "", failure.RuleRuntimeUnavailable, errors.New("root runtime")) @@ -283,13 +467,12 @@ func selfTest(stdout io.Writer) error { if err != nil || !bytes.Contains(bytes.ToLower(font), []byte("jetbrains")) { return failure.E(failure.RendererUnavailable, failure.StageRuntime, "", failure.RuleRuntimeUnavailable, errors.New("font unavailable")) } - if connection, err := net.DialTimeout("tcp", "1.1.1.1:443", 500*time.Millisecond); err == nil { - _ = connection.Close() - return failure.E(failure.RendererUnavailable, failure.StageRuntime, "", failure.RuleRuntimeUnavailable, errors.New("network egress available")) - } - if _, err := os.ReadFile("/landlock-denied/readable"); err != nil { + + abi, err := probeSelfTestNetworkAndRuntime(productionSelfTestRuntimeProbes()) + if err != nil { return failure.E(failure.RendererUnavailable, failure.StageRuntime, "", failure.RuleRuntimeUnavailable, err) } + fmt.Fprintf(stdout, "detected Landlock ABI: %d\n", abi) executable, err := os.Executable() if err != nil { diff --git a/tools/readme-terminal-demo/cmd/readme-terminal-demo/main_test.go b/tools/readme-terminal-demo/cmd/readme-terminal-demo/main_test.go index 6b414d0ff..3bb6dd673 100644 --- a/tools/readme-terminal-demo/cmd/readme-terminal-demo/main_test.go +++ b/tools/readme-terminal-demo/cmd/readme-terminal-demo/main_test.go @@ -11,13 +11,18 @@ import ( "reflect" "strings" "testing" + "time" "github.com/z-shell/.github/tools/readme-terminal-demo/internal/failure" "github.com/z-shell/.github/tools/readme-terminal-demo/internal/sandbox" "golang.org/x/sys/unix" ) -const testUnsupportedLandlockCapture = "README_TERMINAL_DEMO_TEST_UNSUPPORTED_CAPTURE" +const ( + testUnsupportedLandlockCapture = "README_TERMINAL_DEMO_TEST_UNSUPPORTED_CAPTURE" + testBrowserResolutionChroot = "README_TERMINAL_DEMO_TEST_BROWSER_CHROOT" + testBrowserResolutionRoot = "README_TERMINAL_DEMO_TEST_BROWSER_ROOT" +) func TestReservedChildExitMapping(t *testing.T) { tests := reservedChildOutcomeCases() @@ -439,6 +444,21 @@ func TestCapturePreparesAndEntersDemoBeforeRestrictedExec(t *testing.T) { if want := []string{"/trusted/vhs", "/trusted/readme.tape"}; !reflect.DeepEqual(spec.Args, want) { t.Fatalf("restricted argv = %#v, want %#v", spec.Args, want) } + if !spec.Policy.AllowPrivateDevptsPTY { + t.Fatal("capture policy did not opt into the verified private-devpts PTY rule") + } + devReadOnly := false + for _, path := range spec.Policy.ReadOnlyPaths { + devReadOnly = devReadOnly || path == "/dev" + } + if !devReadOnly { + t.Fatal("capture policy did not retain /dev as read-only") + } + for _, path := range spec.Policy.ReadWritePaths { + if path == "/" || path == "/dev/pts" || strings.HasPrefix("/dev/pts", path+"/") || strings.HasPrefix(path, "/dev/pts/") { + t.Fatalf("capture policy broadens /dev/pts through writable path %q", path) + } + } return nil }, }) @@ -608,6 +628,134 @@ func TestRodLinuxBrowserCandidatesThroughChromiumMatchPinnedOrder(t *testing.T) } } +func TestBrowserResolutionUsesPinnedChromiumWithoutDownloader(t *testing.T) { + launcher := []byte("pinned chromium launcher\n") + if os.Getenv(testBrowserResolutionChroot) != "child" { + dependencies, err := os.ReadFile(filepath.Join("..", "..", "dependencies.env")) + if err != nil { + t.Fatalf("read dependencies.env: %v", err) + } + for _, pin := range []string{ + "DEBIAN_CHROMIUM_VERSION=150.0.7871.124-1~deb13u1\n", + "CHROMIUM_LAUNCHER_SHA256=2c8d32d29dc6781c35ae196ea83299d507ac88fc339301abe74672feda299779\n", + "VHS_VERSION=v0.11.0\n", + } { + if got := strings.Count(string(dependencies), pin); got != 1 { + t.Fatalf("dependencies.env pin %q count = %d, want 1", strings.TrimSpace(pin), got) + } + } + + root := t.TempDir() + for _, directory := range []string{"usr/local/bin", "usr/bin", "bin"} { + if err := os.MkdirAll(filepath.Join(root, directory), 0o755); err != nil { + t.Fatalf("create isolated renderer PATH: %v", err) + } + } + path := filepath.Join(root, strings.TrimPrefix(chromiumLauncherPath, "/")) + if err := os.WriteFile(path, launcher, 0o755); err != nil { + t.Fatalf("write isolated pinned Chromium: %v", err) + } + if err := os.Chmod(path, 0o755); err != nil { + t.Fatalf("set isolated Chromium mode: %v", err) + } + + command := exec.Command(os.Args[0], "-test.run=^TestBrowserResolutionUsesPinnedChromiumWithoutDownloader$") + command.Env = []string{ + testBrowserResolutionChroot + "=child", + testBrowserResolutionRoot + "=" + root, + } + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("isolated browser-resolution probe: %v\n%s", err, output) + } + return + } + + root := os.Getenv(testBrowserResolutionRoot) + if root == "" { + t.Fatal("isolated browser root is empty") + } + if err := unix.Chroot(root); err != nil { + t.Fatalf("enter isolated browser root: %v", err) + } + if err := os.Chdir("/"); err != nil { + t.Fatalf("enter isolated browser working directory: %v", err) + } + + var preflightCandidates []string + preflightComplete := false + rawExecReached := false + downloaderReached := false + resolvedByVHS := "" + + err := captureWith("/trusted/readme.tape", captureDependencies{ + browserPreflight: func() error { + err := validateBrowser(browserPreflightConfig{ + target: chromiumLauncherPath, + expectedSHA256: sha256Hex(launcher), + lookPath: func(candidate string) (string, error) { + preflightCandidates = append(preflightCandidates, candidate) + return lookPathInRendererPath(candidate) + }, + }) + preflightComplete = err == nil + return err + }, + prepareRuntime: func() error { return nil }, + changeDirectory: func(string) error { return nil }, + vhsPath: "/usr/local/bin/vhs", + execRestricted: func(spec sandbox.ExecSpec) error { + rawExecReached = true + if !preflightComplete { + t.Fatal("raw VHS exec reached before Chromium preflight completed") + } + + pathValue := "" + for _, value := range spec.Env { + if strings.HasPrefix(value, "PATH=") { + pathValue = strings.TrimPrefix(value, "PATH=") + break + } + } + if pathValue != restrictedRendererPATH { + t.Fatalf("VHS PATH = %q, want %q", pathValue, restrictedRendererPATH) + } + + // VHS v0.11.0 embeds Rod v0.116.2. Walk its pinned Linux + // candidates through the production resolver at the raw-exec seam; + // downloader fallback is guarded behind exhaustion of this walk. + for _, candidate := range rodLinuxBrowserCandidatesThroughChromium { + resolved, resolveErr := lookPathInRendererPath(candidate) + if resolveErr == nil { + resolvedByVHS = resolved + break + } + if !errors.Is(resolveErr, exec.ErrNotFound) { + return resolveErr + } + } + if resolvedByVHS == "" { + downloaderReached = true + } + return nil + }, + }) + if err != nil { + t.Fatalf("captureWith() error = %v", err) + } + if !reflect.DeepEqual(preflightCandidates, rodLinuxBrowserCandidatesThroughChromium) { + t.Fatalf("preflight candidates = %#v, want exact Rod order %#v", preflightCandidates, rodLinuxBrowserCandidatesThroughChromium) + } + if !rawExecReached { + t.Fatal("raw VHS exec seam was not reached after successful Chromium preflight") + } + if resolvedByVHS != "/usr/bin/chromium" { + t.Fatalf("VHS browser resolution = %q, want /usr/bin/chromium", resolvedByVHS) + } + if downloaderReached { + t.Fatal("guarded Rod downloader sentinel was reached") + } +} + func TestBrowserPreflightRejectsEveryEarlierRodCandidateInOrder(t *testing.T) { target, digest := writeLauncher(t, []byte("pinned chromium launcher\n"), 0o755) @@ -832,6 +980,131 @@ RUN set -eu; \ } } +func TestSmokeTapeMatchesNormativePresentation(t *testing.T) { + tape, err := os.ReadFile(filepath.Join("..", "..", "testdata", "smoke", "readme.tape")) + if err != nil { + t.Fatalf("read smoke tape: %v", err) + } + content := string(tape) + + for _, old := range []string{ + "Set FontSize 24", + "Set Width 800", + "Set Height 300", + } { + if strings.Contains(content, old) { + t.Errorf("smoke tape retains obsolete presentation setting %q", old) + } + } + + wantLines := []string{ + `Output "/work/smoke.gif"`, + `Set Shell "zsh"`, + `Set FontFamily "JetBrains Mono"`, + "Set FontSize 18", + "Set Width 960", + "Set Height 540", + `Set Theme "Catppuccin Mocha"`, + "Set Framerate 30", + "Set TypingSpeed 35ms", + "Set CursorBlink false", + `Type "printf 'readme-terminal-demo smoke\\n'"`, + "Enter", + "Sleep 1s", + `Type "exit"`, + "Enter", + } + var gotLines []string + for _, line := range strings.Split(content, "\n") { + if strings.TrimSpace(line) != "" { + gotLines = append(gotLines, line) + } + } + if !reflect.DeepEqual(gotLines, wantLines) { + t.Errorf("smoke tape directives = %#v, want exact normative presentation %#v", gotLines, wantLines) + } +} + +func TestSelfTestNetworkAndRuntimeContract(t *testing.T) { + var events []string + probes := selfTestRuntimeProbes{ + defaultRoutes: func() (bool, bool, error) { + events = append(events, "routes") + return false, false, nil + }, + loopbackRoundTrip: func(timeout time.Duration) error { + events = append(events, "loopback") + if timeout != time.Second { + t.Fatalf("loopback timeout = %v, want 1s", timeout) + } + return nil + }, + dialTimeout: func(network, address string, timeout time.Duration) error { + events = append(events, "external") + if network != "tcp" || address != "192.0.2.1:443" || timeout != time.Second { + t.Fatalf("external probe = %q %q %v, want tcp 192.0.2.1:443 1s", network, address, timeout) + } + return errors.New("network unreachable") + }, + processIdentity: func() (int, int, error) { + events = append(events, "process-group") + return 4242, 4242, nil + }, + fileLimits: func() (uint64, uint64, error) { + events = append(events, "nofile") + return 256, 256, nil + }, + handledAccessNet: func() uint64 { + events = append(events, "handled-access-net") + return 0 + }, + landlockABI: func() (int, error) { + events = append(events, "abi") + return 9, nil + }, + landlockInherited: func() error { + events = append(events, "inheritance") + return nil + }, + } + + abi, err := probeSelfTestNetworkAndRuntime(probes) + if err != nil { + t.Fatalf("probeSelfTestNetworkAndRuntime() error = %v", err) + } + if abi != 9 { + t.Fatalf("detected Landlock ABI = %d, want 9", abi) + } + wantEvents := []string{"routes", "loopback", "external", "process-group", "nofile", "handled-access-net", "abi", "inheritance"} + if !reflect.DeepEqual(events, wantEvents) { + t.Fatalf("runtime probe events = %#v, want %#v", events, wantEvents) + } + + for _, limits := range [][2]uint64{{255, 256}, {256, 512}} { + wrongLimits := probes + wrongLimits.fileLimits = func() (uint64, uint64, error) { + return limits[0], limits[1], nil + } + if _, err := probeSelfTestNetworkAndRuntime(wrongLimits); err == nil { + t.Fatalf("probeSelfTestNetworkAndRuntime() accepted nofile soft/hard %d/%d", limits[0], limits[1]) + } + } + limitError := probes + limitError.fileLimits = func() (uint64, uint64, error) { + return 0, 0, errors.New("rlimit unavailable") + } + if _, err := probeSelfTestNetworkAndRuntime(limitError); err == nil { + t.Fatal("probeSelfTestNetworkAndRuntime() accepted an RLIMIT_NOFILE query failure") + } +} + +func TestIPv6RejectDefaultRouteIsNotUsable(t *testing.T) { + const routes = "00000000000000000000000000000000 00 00000000000000000000000000000000 00 00000000000000000000000000000000 ffffffff 00000001 00000000 00200200 lo\n" + if hasIPv6DefaultRoute([]byte(routes)) { + t.Fatal("network-none IPv6 reject route was classified as a usable default route") + } +} + func writeLauncher(t *testing.T, content []byte, mode os.FileMode) (string, string) { t.Helper() diff --git a/tools/readme-terminal-demo/internal/sandbox/landlock_linux.go b/tools/readme-terminal-demo/internal/sandbox/landlock_linux.go index b6d8c7cb1..94b8aceff 100644 --- a/tools/readme-terminal-demo/internal/sandbox/landlock_linux.go +++ b/tools/readme-terminal-demo/internal/sandbox/landlock_linux.go @@ -4,6 +4,7 @@ package sandbox import ( "errors" "fmt" + "path/filepath" "sort" "strings" @@ -39,6 +40,8 @@ const ( llsyscall.AccessFSWriteFile | llsyscall.AccessFSReadFile | llsyscall.AccessFSTruncate + privateDevptsPTYV3Access = llsyscall.AccessFSWriteFile + devptsSuperMagic = 0x1cd1 ) var ( @@ -54,6 +57,12 @@ var ( type Policy struct { ReadOnlyPaths []string ReadWritePaths []string + // AllowPrivateDevptsPTY grants only WRITE_FILE beneath a verified private + // /dev/pts mount. The preflight requires a devpts filesystem whose initial + // topology is exactly ptmx and validates that device as 5:2 through the + // opened directory. Landlock ABI 3 mediates path access, not device ioctls; + // this field does not add ioctl, creation, removal, or truncate authority. + AllowPrivateDevptsPTY bool } // ExecSpec describes a target that replaces the current, already-isolated child. @@ -68,6 +77,13 @@ type preparedV3 struct { rulesetFD int } +type privateDevptsPTYSource struct { + directoryFD int + directoryStat unix.Stat_t + parentStat unix.Stat_t + rootStat unix.Stat_t +} + func prepareV3(policy Policy) (preparedV3, error) { return prepareV3WithOps(policy, productionPrepareV3Ops) } @@ -77,6 +93,10 @@ func prepareV3WithOps(policy Policy, ops prepareV3Ops) (preparedV3, error) { ops.addPathRule == nil || ops.setCLOEXEC == nil || ops.getCLOEXEC == nil || ops.closeFD == nil { return preparedV3{}, fmt.Errorf("%w: incomplete ruleset operations", ErrLandlockV3Unavailable) } + if policy.AllowPrivateDevptsPTY && + (ops.openPathAt == nil || ops.fstatAt == nil || ops.fstatfs == nil || ops.readDirNames == nil) { + return preparedV3{}, fmt.Errorf("%w: incomplete private devpts operations", ErrLandlockV3Unavailable) + } abi, err := ops.getABI() if err != nil || abi < 3 { return preparedV3{}, ErrLandlockV3Unavailable @@ -106,66 +126,210 @@ func prepareV3WithOps(policy Policy, ops prepareV3Ops) (preparedV3, error) { return preparedV3{}, cause } + var privatePTY *privateDevptsPTYSource + if policy.AllowPrivateDevptsPTY { + source, prepareErr := preparePrivateDevptsPTYSource(ops) + if prepareErr != nil { + return fail(prepareErr) + } + privatePTY = &source + } + closePrivatePTY := func() error { + if privatePTY == nil || privatePTY.directoryFD < 0 { + return nil + } + fd := privatePTY.directoryFD + privatePTY.directoryFD = -1 + return ops.closeFD(fd) + } + failWithPrivatePTY := func(cause error) (preparedV3, error) { + if closeErr := closePrivatePTY(); closeErr != nil { + cause = errors.Join(cause, fmt.Errorf("%w: close private devpts: %v", ErrLandlockV3Unavailable, closeErr)) + } + return fail(cause) + } + type ruleSource struct { path string access uint64 directory bool } - sources := make([]ruleSource, 0, len(readOnly)+len(readWrite)+1) + sources := make([]ruleSource, 0, len(readOnly)+len(readWrite)) for _, path := range readOnly { sources = append(sources, ruleSource{path: path, access: readOnlyV3Access, directory: true}) } for _, path := range readWrite { sources = append(sources, ruleSource{path: path, access: readWriteV3Access, directory: true}) } - sources = append(sources, ruleSource{path: "/dev/null", access: nullDeviceV3Access}) - - for _, source := range sources { + addRuleSource := func(source ruleSource) error { fd, openErr := ops.openPath(source.path, unix.O_PATH|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) if openErr != nil { - return fail(fmt.Errorf("%w: open rule source", ErrLandlockV3Unavailable)) + return fmt.Errorf("%w: open rule source", ErrLandlockV3Unavailable) } var stat unix.Stat_t if statErr := ops.fstat(fd, &stat); statErr != nil { _ = ops.closeFD(fd) - return fail(fmt.Errorf("%w: inspect opened rule source", ErrLandlockV3Unavailable)) + return fmt.Errorf("%w: inspect opened rule source", ErrLandlockV3Unavailable) + } + if source.access == readWriteV3Access && overlapsPrivateDevptsPTY(stat, privatePTY) { + _ = ops.closeFD(fd) + return fmt.Errorf("%w: writable rule aliases private devpts boundary", ErrLandlockV3Unavailable) } if source.directory { if stat.Mode&unix.S_IFMT != unix.S_IFDIR { _ = ops.closeFD(fd) - return fail(fmt.Errorf("%w: rule source is not a directory", ErrLandlockV3Unavailable)) + return fmt.Errorf("%w: rule source is not a directory", ErrLandlockV3Unavailable) } } else if stat.Mode&unix.S_IFMT != unix.S_IFCHR || unix.Major(uint64(stat.Rdev)) != 1 || unix.Minor(uint64(stat.Rdev)) != 3 { _ = ops.closeFD(fd) - return fail(fmt.Errorf("%w: null device identity mismatch", ErrLandlockV3Unavailable)) + return fmt.Errorf("%w: null device identity mismatch", ErrLandlockV3Unavailable) } pathAttribute := llsyscall.PathBeneathAttr{AllowedAccess: source.access, ParentFd: fd} if addErr := ops.addPathRule(rulesetFD, &pathAttribute, 0); addErr != nil { _ = ops.closeFD(fd) - return fail(fmt.Errorf("%w: add path rule", ErrLandlockV3Unavailable)) + return fmt.Errorf("%w: add path rule", ErrLandlockV3Unavailable) } if closeErr := ops.closeFD(fd); closeErr != nil { - return fail(fmt.Errorf("%w: close rule source", ErrLandlockV3Unavailable)) + return fmt.Errorf("%w: close rule source", ErrLandlockV3Unavailable) } + return nil + } + + for _, source := range sources { + if err := addRuleSource(source); err != nil { + return failWithPrivatePTY(err) + } + } + if privatePTY != nil { + attribute := llsyscall.PathBeneathAttr{ + AllowedAccess: privateDevptsPTYV3Access, + ParentFd: privatePTY.directoryFD, + } + if err := ops.addPathRule(rulesetFD, &attribute, 0); err != nil { + return failWithPrivatePTY(fmt.Errorf("%w: add private devpts rule", ErrLandlockV3Unavailable)) + } + if err := closePrivatePTY(); err != nil { + return fail(fmt.Errorf("%w: close private devpts", ErrLandlockV3Unavailable)) + } + } + if err := addRuleSource(ruleSource{path: "/dev/null", access: nullDeviceV3Access}); err != nil { + return failWithPrivatePTY(err) } if err := ops.setCLOEXEC(rulesetFD); err != nil { - return fail(fmt.Errorf("%w: set ruleset CLOEXEC", ErrLandlockV3Unavailable)) + return failWithPrivatePTY(fmt.Errorf("%w: set ruleset CLOEXEC", ErrLandlockV3Unavailable)) } cloexec, err := ops.getCLOEXEC(rulesetFD) if err != nil || !cloexec { - return fail(fmt.Errorf("%w: verify ruleset CLOEXEC", ErrLandlockV3Unavailable)) + return failWithPrivatePTY(fmt.Errorf("%w: verify ruleset CLOEXEC", ErrLandlockV3Unavailable)) } return preparedV3{rulesetFD: rulesetFD}, nil } +func overlapsPrivateDevptsPTY(stat unix.Stat_t, privatePTY *privateDevptsPTYSource) bool { + if privatePTY == nil { + return false + } + return stat.Dev == privatePTY.directoryStat.Dev || + sameFileIdentity(stat, privatePTY.parentStat) || + sameFileIdentity(stat, privatePTY.rootStat) +} + +func sameFileIdentity(left, right unix.Stat_t) bool { + return left.Dev == right.Dev && left.Ino == right.Ino +} + +func preparePrivateDevptsPTYSource(ops prepareV3Ops) (privateDevptsPTYSource, error) { + directoryFD, err := ops.openPath( + "/dev/pts", + unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, + 0, + ) + if err != nil { + return privateDevptsPTYSource{}, fmt.Errorf("%w: open private devpts", ErrLandlockV3Unavailable) + } + source := privateDevptsPTYSource{directoryFD: directoryFD} + closeDirectory := func() error { + if source.directoryFD < 0 { + return nil + } + fd := source.directoryFD + source.directoryFD = -1 + return ops.closeFD(fd) + } + fail := func(cause error) (privateDevptsPTYSource, error) { + if closeErr := closeDirectory(); closeErr != nil { + cause = errors.Join(cause, fmt.Errorf("%w: close private devpts: %v", ErrLandlockV3Unavailable, closeErr)) + } + return privateDevptsPTYSource{}, cause + } + + if err := ops.fstat(directoryFD, &source.directoryStat); err != nil { + return fail(fmt.Errorf("%w: inspect private devpts", ErrLandlockV3Unavailable)) + } + if source.directoryStat.Mode&unix.S_IFMT != unix.S_IFDIR { + return fail(fmt.Errorf("%w: private devpts is not a directory", ErrLandlockV3Unavailable)) + } + var filesystemStat unix.Statfs_t + if err := ops.fstatfs(directoryFD, &filesystemStat); err != nil { + return fail(fmt.Errorf("%w: inspect private devpts filesystem", ErrLandlockV3Unavailable)) + } + if filesystemStat.Type != devptsSuperMagic { + return fail(fmt.Errorf("%w: private devpts filesystem mismatch", ErrLandlockV3Unavailable)) + } + if err := ops.fstatAt(directoryFD, "..", &source.parentStat, unix.AT_SYMLINK_NOFOLLOW); err != nil { + return fail(fmt.Errorf("%w: inspect private devpts parent", ErrLandlockV3Unavailable)) + } + if source.parentStat.Mode&unix.S_IFMT != unix.S_IFDIR || source.parentStat.Dev == source.directoryStat.Dev { + return fail(fmt.Errorf("%w: private devpts mount boundary mismatch", ErrLandlockV3Unavailable)) + } + if err := ops.fstatAt(directoryFD, "../..", &source.rootStat, unix.AT_SYMLINK_NOFOLLOW); err != nil { + return fail(fmt.Errorf("%w: inspect private devpts root", ErrLandlockV3Unavailable)) + } + if source.rootStat.Mode&unix.S_IFMT != unix.S_IFDIR { + return fail(fmt.Errorf("%w: private devpts root is not a directory", ErrLandlockV3Unavailable)) + } + names, err := ops.readDirNames(directoryFD) + if err != nil { + return fail(fmt.Errorf("%w: inspect private devpts topology", ErrLandlockV3Unavailable)) + } + if len(names) != 1 || names[0] != "ptmx" { + return fail(fmt.Errorf("%w: private devpts topology mismatch", ErrLandlockV3Unavailable)) + } + + ptmxFD, err := ops.openPathAt( + directoryFD, + "ptmx", + unix.O_PATH|unix.O_CLOEXEC|unix.O_NOFOLLOW, + 0, + ) + if err != nil { + return fail(fmt.Errorf("%w: open private devpts ptmx", ErrLandlockV3Unavailable)) + } + var ptmxStat unix.Stat_t + if err := ops.fstat(ptmxFD, &ptmxStat); err != nil { + _ = ops.closeFD(ptmxFD) + return fail(fmt.Errorf("%w: inspect private devpts ptmx", ErrLandlockV3Unavailable)) + } + if ptmxStat.Mode&unix.S_IFMT != unix.S_IFCHR || + ptmxStat.Dev != source.directoryStat.Dev || + unix.Major(uint64(ptmxStat.Rdev)) != 5 || unix.Minor(uint64(ptmxStat.Rdev)) != 2 { + _ = ops.closeFD(ptmxFD) + return fail(fmt.Errorf("%w: private devpts ptmx identity mismatch", ErrLandlockV3Unavailable)) + } + if err := ops.closeFD(ptmxFD); err != nil { + return fail(fmt.Errorf("%w: close private devpts ptmx", ErrLandlockV3Unavailable)) + } + return source, nil +} + func validatePolicy(policy Policy) ([]string, []string, error) { seen := make(map[string]struct{}, len(policy.ReadOnlyPaths)+len(policy.ReadWritePaths)) validate := func(paths []string) ([]string, error) { validated := append([]string(nil), paths...) for _, path := range validated { - if path == "" || strings.ContainsRune(path, 0) || !strings.HasPrefix(path, "/") || + if path == "" || strings.ContainsRune(path, 0) || !filepath.IsAbs(path) || filepath.Clean(path) != path || (path != "/" && path != strings.TrimSuffix(path, "/")) { return nil, fmt.Errorf("%w: invalid rule path", ErrLandlockV3Unavailable) } @@ -185,5 +349,15 @@ func validatePolicy(policy Policy) ([]string, []string, error) { if err != nil { return nil, nil, err } + if policy.AllowPrivateDevptsPTY { + if _, overlap := seen["/dev/pts"]; overlap { + return nil, nil, fmt.Errorf("%w: private devpts overlaps a generic rule", ErrLandlockV3Unavailable) + } + for _, path := range readWrite { + if path == "/" || strings.HasPrefix("/dev/pts", path+"/") || strings.HasPrefix(path, "/dev/pts/") { + return nil, nil, fmt.Errorf("%w: private devpts overlaps a writable rule", ErrLandlockV3Unavailable) + } + } + } return readOnly, readWrite, nil } diff --git a/tools/readme-terminal-demo/internal/sandbox/landlock_syscall_linux.go b/tools/readme-terminal-demo/internal/sandbox/landlock_syscall_linux.go index 2b3927ca3..cbfb0ecad 100644 --- a/tools/readme-terminal-demo/internal/sandbox/landlock_syscall_linux.go +++ b/tools/readme-terminal-demo/internal/sandbox/landlock_syscall_linux.go @@ -9,7 +9,11 @@ type prepareV3Ops struct { getABI func() (int, error) createRuleset func(*llsyscall.RulesetAttr, int) (int, error) openPath func(string, int, uint32) (int, error) + openPathAt func(int, string, int, uint32) (int, error) fstat func(int, *unix.Stat_t) error + fstatAt func(int, string, *unix.Stat_t, int) error + fstatfs func(int, *unix.Statfs_t) error + readDirNames func(int) ([]string, error) addPathRule func(int, *llsyscall.PathBeneathAttr, int) error setCLOEXEC func(int) error getCLOEXEC func(int) (bool, error) @@ -19,8 +23,12 @@ type prepareV3Ops struct { var productionPrepareV3Ops = prepareV3Ops{ getABI: llsyscall.LandlockGetABIVersion, createRuleset: llsyscall.LandlockCreateRuleset, - openPath: unix.Open, + openPath: openRulePath, + openPathAt: unix.Openat, fstat: unix.Fstat, + fstatAt: unix.Fstatat, + fstatfs: unix.Fstatfs, + readDirNames: readDirectoryNames, addPathRule: llsyscall.LandlockAddPathBeneathRule, setCLOEXEC: func(fd int) error { _, err := unix.FcntlInt(uintptr(fd), unix.F_SETFD, unix.FD_CLOEXEC) @@ -32,3 +40,38 @@ var productionPrepareV3Ops = prepareV3Ops{ }, closeFD: unix.Close, } + +func openRulePath(path string, flags int, mode uint32) (int, error) { + return unix.Openat2(unix.AT_FDCWD, path, &unix.OpenHow{ + Flags: uint64(flags), + Mode: uint64(mode), + Resolve: unix.RESOLVE_NO_SYMLINKS | unix.RESOLVE_NO_MAGICLINKS, + }) +} + +func readDirectoryNames(fd int) ([]string, error) { + buffer := make([]byte, 4096) + names := make([]string, 0, 2) + for { + count, err := unix.ReadDirent(fd, buffer) + if err != nil { + return nil, err + } + if count == 0 { + return names, nil + } + consumed, _, batch := unix.ParseDirent(buffer[:count], -1, nil) + if consumed != count { + return nil, unix.EIO + } + for _, name := range batch { + if name == "." || name == ".." { + continue + } + names = append(names, name) + if len(names) > 1 { + return names, nil + } + } + } +} diff --git a/tools/readme-terminal-demo/internal/sandbox/launcher_internal_test.go b/tools/readme-terminal-demo/internal/sandbox/launcher_internal_test.go index 54ce94629..e6a93b707 100644 --- a/tools/readme-terminal-demo/internal/sandbox/launcher_internal_test.go +++ b/tools/readme-terminal-demo/internal/sandbox/launcher_internal_test.go @@ -330,8 +330,9 @@ func TestPrepareV3BuildsExactABIV3Ruleset(t *testing.T) { t.Run("abi-"+strconv.Itoa(abi), func(t *testing.T) { fake := newPrepareV3Fake(abi) prepared, err := prepareV3WithOps(Policy{ - ReadOnlyPaths: []string{"/z-ro", "/a-ro"}, - ReadWritePaths: []string{"/z-rw", "/a-rw"}, + ReadOnlyPaths: []string{"/z-ro", "/a-ro"}, + ReadWritePaths: []string{"/z-rw", "/a-rw"}, + AllowPrivateDevptsPTY: true, }, fake.ops()) if err != nil { t.Fatalf("prepareV3WithOps() error = %v", err) @@ -357,6 +358,7 @@ func TestPrepareV3BuildsExactABIV3Ruleset(t *testing.T) { {path: "/z-ro", allowed: readOnlyV3Access}, {path: "/a-rw", allowed: readWriteV3Access}, {path: "/z-rw", allowed: readWriteV3Access}, + {path: "/dev/pts", allowed: llsyscall.AccessFSWriteFile}, {path: "/dev/null", allowed: nullDeviceV3Access}, } if !reflect.DeepEqual(fake.rules, wantRules) { @@ -371,14 +373,23 @@ func TestPrepareV3BuildsExactABIV3Ruleset(t *testing.T) { if nullDeviceV3Access != llsyscall.AccessFSExecute|llsyscall.AccessFSReadFile|llsyscall.AccessFSWriteFile|llsyscall.AccessFSTruncate { t.Fatalf("null-device access = %#x", nullDeviceV3Access) } + if privateDevptsPTYV3Access != llsyscall.AccessFSWriteFile { + t.Fatalf("private-devpts PTY access = %#x, want only WRITE_FILE", privateDevptsPTYV3Access) + } + if privateDevptsPTYV3Access&(llsyscall.AccessFSMakeChar|llsyscall.AccessFSMakeDir|llsyscall.AccessFSRemoveDir|llsyscall.AccessFSRemoveFile|llsyscall.AccessFSTruncate|llsyscall.AccessFSRefer) != 0 { + t.Fatalf("private-devpts PTY access contains creation, removal, truncate, or refer rights: %#x", privateDevptsPTYV3Access) + } - wantOpenFlags := unix.O_PATH | unix.O_CLOEXEC | unix.O_NOFOLLOW for _, opened := range fake.opened { - if opened.flags != wantOpenFlags || opened.mode != 0 { - t.Fatalf("open %q flags=%#x mode=%#o, want %#x mode=0", opened.path, opened.flags, opened.mode, wantOpenFlags) + wantFlags := unix.O_PATH | unix.O_CLOEXEC | unix.O_NOFOLLOW + if opened.path == "/dev/pts" { + wantFlags = unix.O_RDONLY | unix.O_DIRECTORY | unix.O_CLOEXEC | unix.O_NOFOLLOW + } + if opened.flags != wantFlags || opened.mode != 0 { + t.Fatalf("open %q flags=%#x mode=%#o, want %#x mode=0", opened.path, opened.flags, opened.mode, wantFlags) } } - if got, want := fake.closed, []int{10, 11, 12, 13, 14}; !reflect.DeepEqual(got, want) { + if got, want := fake.closed, []int{11, 12, 13, 14, 15, 10, 16}; !reflect.DeepEqual(got, want) { t.Fatalf("closed source descriptors = %#v, want %#v", got, want) } if !fake.cloexecSet || !fake.cloexecRead { @@ -388,6 +399,145 @@ func TestPrepareV3BuildsExactABIV3Ruleset(t *testing.T) { } } +func TestPrepareV3RejectsUnverifiedPrivateDevptsPTY(t *testing.T) { + tests := []struct { + name string + fault string + wantClosed []int + }{ + {name: "open-directory", fault: "open-devpts", wantClosed: []int{7}}, + {name: "inspect-directory", fault: "fstat-devpts", wantClosed: []int{10, 7}}, + {name: "directory-type", fault: "regular-devpts", wantClosed: []int{10, 7}}, + {name: "inspect-filesystem", fault: "fstatfs-devpts", wantClosed: []int{10, 7}}, + {name: "filesystem-type", fault: "wrong-devpts-filesystem", wantClosed: []int{10, 7}}, + {name: "inspect-parent", fault: "fstatat-devpts-parent", wantClosed: []int{10, 7}}, + {name: "mount-boundary", fault: "same-devpts-parent", wantClosed: []int{10, 7}}, + {name: "inspect-root", fault: "fstatat-devpts-root", wantClosed: []int{10, 7}}, + {name: "root-type", fault: "regular-devpts-root", wantClosed: []int{10, 7}}, + {name: "read-topology", fault: "readdir-devpts", wantClosed: []int{10, 7}}, + {name: "unexpected-slave", fault: "unexpected-devpts-entry", wantClosed: []int{10, 7}}, + {name: "open-ptmx-relative", fault: "open-ptmx", wantClosed: []int{10, 7}}, + {name: "inspect-ptmx", fault: "fstat-ptmx", wantClosed: []int{11, 10, 7}}, + {name: "ptmx-symlink", fault: "symlink-ptmx", wantClosed: []int{11, 10, 7}}, + {name: "ptmx-filesystem", fault: "wrong-ptmx-filesystem", wantClosed: []int{11, 10, 7}}, + {name: "ptmx-identity", fault: "wrong-ptmx-device", wantClosed: []int{11, 10, 7}}, + {name: "close-ptmx", fault: "close-ptmx", wantClosed: []int{11, 10, 7}}, + {name: "add-rule", fault: "add-devpts", wantClosed: []int{11, 10, 7}}, + {name: "close-directory", fault: "close-devpts", wantClosed: []int{11, 10, 7}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fake := newPrepareV3Fake(3) + fake.fault = test.fault + _, err := prepareV3WithOps(Policy{AllowPrivateDevptsPTY: true}, fake.ops()) + if !errors.Is(err, ErrLandlockV3Unavailable) { + t.Fatalf("prepareV3WithOps() error = %v, want ErrLandlockV3Unavailable", err) + } + if !reflect.DeepEqual(fake.closed, test.wantClosed) { + t.Fatalf("closed descriptors = %#v, want %#v; events=%#v", fake.closed, test.wantClosed, fake.events) + } + if len(fake.openFDs) != 0 { + t.Fatalf("private-devpts failure leaked open FDs: %#v", fake.openFDs) + } + }) + } +} + +func TestPrepareV3RejectsBroadDevptsPolicyOverlap(t *testing.T) { + for _, policy := range []Policy{ + {ReadOnlyPaths: []string{"/dev/pts"}, AllowPrivateDevptsPTY: true}, + {ReadWritePaths: []string{"/"}, AllowPrivateDevptsPTY: true}, + {ReadWritePaths: []string{"/dev"}, AllowPrivateDevptsPTY: true}, + {ReadWritePaths: []string{"/dev/pts"}, AllowPrivateDevptsPTY: true}, + {ReadWritePaths: []string{"/dev/pts/injected"}, AllowPrivateDevptsPTY: true}, + } { + fake := newPrepareV3Fake(3) + _, err := prepareV3WithOps(policy, fake.ops()) + if !errors.Is(err, ErrLandlockV3Unavailable) { + t.Fatalf("prepareV3WithOps(%#v) error = %v, want ErrLandlockV3Unavailable", policy, err) + } + if !reflect.DeepEqual(fake.events, []string{"abi"}) { + t.Fatalf("overlapping policy operations = %#v, want only ABI query", fake.events) + } + } +} + +func TestPrepareV3RejectsNonCanonicalPolicyPathsBeforeCreatingRuleset(t *testing.T) { + for _, path := range []string{ + "/dev/../dev/pts", + "/tmp/../dev/pts", + "//dev/pts", + "/dev/./pts", + "/dev//pts", + } { + fake := newPrepareV3Fake(3) + _, err := prepareV3WithOps(Policy{ + ReadWritePaths: []string{path}, + AllowPrivateDevptsPTY: true, + }, fake.ops()) + if !errors.Is(err, ErrLandlockV3Unavailable) { + t.Fatalf("prepareV3WithOps(%q) error = %v, want ErrLandlockV3Unavailable", path, err) + } + if !reflect.DeepEqual(fake.events, []string{"abi"}) { + t.Fatalf("non-canonical policy %q operations = %#v, want only ABI query", path, fake.events) + } + } +} + +func TestPrepareV3RejectsPhysicalDevptsWriteAliases(t *testing.T) { + for _, path := range []string{"/bind/devpts", "/bind/dev", "/bind/root"} { + fake := newPrepareV3Fake(3) + _, err := prepareV3WithOps(Policy{ + ReadWritePaths: []string{path}, + AllowPrivateDevptsPTY: true, + }, fake.ops()) + if !errors.Is(err, ErrLandlockV3Unavailable) { + t.Fatalf("prepareV3WithOps(%q) error = %v, want ErrLandlockV3Unavailable", path, err) + } + for _, rule := range fake.rules { + if rule.path == path { + t.Fatalf("physical alias %q received generic writable rule %#x", path, rule.allowed) + } + } + if got, want := fake.closed, []int{11, 12, 10, 7}; !reflect.DeepEqual(got, want) { + t.Fatalf("physical alias %q closed descriptors = %#v, want %#v", path, got, want) + } + if len(fake.openFDs) != 0 { + t.Fatalf("physical alias %q leaked open FDs: %#v", path, fake.openFDs) + } + } +} + +func TestOpenRulePathRejectsIntermediateMagicLink(t *testing.T) { + fd, err := openRulePath( + "/proc/self/root/dev/pts", + unix.O_PATH|unix.O_CLOEXEC|unix.O_NOFOLLOW, + 0, + ) + if err == nil { + _ = unix.Close(fd) + t.Fatal("openRulePath() followed /proc/self/root magic link") + } + if !errors.Is(err, unix.ELOOP) { + t.Fatalf("openRulePath() error = %v, want ELOOP", err) + } +} + +func TestPrepareV3PrivateDevptsAllowsDisjointDevWritePath(t *testing.T) { + fake := newPrepareV3Fake(3) + prepared, err := prepareV3WithOps(Policy{ + ReadWritePaths: []string{"/dev/shm"}, + AllowPrivateDevptsPTY: true, + }, fake.ops()) + if err != nil { + t.Fatalf("prepareV3WithOps() error = %v", err) + } + if prepared.rulesetFD != fake.rulesetFD { + t.Fatalf("prepared ruleset FD = %d, want %d", prepared.rulesetFD, fake.rulesetFD) + } +} + func TestPrepareV3ClosesEveryOwnedDescriptorOnFailure(t *testing.T) { tests := []struct { name string @@ -1004,7 +1154,8 @@ type prepareV3Fake struct { createFlags int cloexecSet bool cloexecRead bool - lastFstatFD int + fstatSeen map[int]bool + openFDs map[int]bool } func newPrepareV3Fake(abi int) *prepareV3Fake { @@ -1013,6 +1164,8 @@ func newPrepareV3Fake(abi int) *prepareV3Fake { rulesetFD: 7, nextFD: 10, fdPaths: make(map[int]string), + fstatSeen: make(map[int]bool), + openFDs: make(map[int]bool), } } @@ -1033,26 +1186,45 @@ func (fake *prepareV3Fake) ops() prepareV3Ops { }, openPath: func(path string, flags int, mode uint32) (int, error) { fake.events = append(fake.events, "open:"+path) - if fake.fault == "open" { + if fake.fault == "open" || fake.fault == "open-devpts" && path == "/dev/pts" { return -1, unix.EIO } fd := fake.nextFD fake.nextFD++ fake.fdPaths[fd] = path + fake.openFDs[fd] = true fake.opened = append(fake.opened, fakeOpen{path: path, flags: flags, mode: mode}) return fd, nil }, + openPathAt: func(dirFD int, path string, flags int, mode uint32) (int, error) { + fake.events = append(fake.events, "openat:"+strconv.Itoa(dirFD)+":"+path) + if fake.fault == "open-ptmx" { + return -1, unix.EIO + } + if fake.fdPaths[dirFD] != "/dev/pts" || path != "ptmx" { + return -1, unix.EINVAL + } + fd := fake.nextFD + fake.nextFD++ + fullPath := "/dev/pts/ptmx" + fake.fdPaths[fd] = fullPath + fake.openFDs[fd] = true + fake.opened = append(fake.opened, fakeOpen{path: fullPath, flags: flags, mode: mode}) + return fd, nil + }, fstat: func(fd int, stat *unix.Stat_t) error { fake.events = append(fake.events, "fstat:"+strconv.Itoa(fd)) - fake.lastFstatFD = fd - if fake.fault == "fstat" { + fake.fstatSeen[fd] = true + path := fake.fdPaths[fd] + if fake.fault == "fstat" || fake.fault == "fstat-devpts" && path == "/dev/pts" || fake.fault == "fstat-ptmx" && path == "/dev/pts/ptmx" { return unix.EIO } - path := fake.fdPaths[fd] switch { case fake.fault == "symlink": stat.Mode = unix.S_IFLNK | 0o777 - case fake.fault == "regular": + case fake.fault == "symlink-ptmx" && path == "/dev/pts/ptmx": + stat.Mode = unix.S_IFLNK | 0o777 + case fake.fault == "regular" || fake.fault == "regular-devpts" && path == "/dev/pts": stat.Mode = unix.S_IFREG | 0o644 case path == "/dev/null": stat.Mode = unix.S_IFCHR | 0o666 @@ -1060,18 +1232,95 @@ func (fake *prepareV3Fake) ops() prepareV3Ops { if fake.fault == "wrong-device" { stat.Rdev = unix.Mkdev(1, 5) } + case path == "/dev/pts/ptmx": + stat.Mode = unix.S_IFCHR | 0o666 + stat.Rdev = unix.Mkdev(5, 2) + stat.Dev = 183 + if fake.fault == "wrong-ptmx-device" { + stat.Rdev = unix.Mkdev(1, 5) + } + if fake.fault == "wrong-ptmx-filesystem" { + stat.Dev = 999 + } + case path == "/bind/devpts": + stat.Mode = unix.S_IFDIR | 0o755 + stat.Dev = 183 + stat.Ino = 1 + case path == "/bind/dev": + stat.Mode = unix.S_IFDIR | 0o755 + stat.Dev = 182 + stat.Ino = 2 + case path == "/bind/root": + stat.Mode = unix.S_IFDIR | 0o755 + stat.Dev = 181 + stat.Ino = 3 default: stat.Mode = unix.S_IFDIR | 0o755 + if path == "/dev/pts" { + stat.Dev = 183 + stat.Ino = 1 + } } return nil }, + fstatfs: func(fd int, stat *unix.Statfs_t) error { + fake.events = append(fake.events, "fstatfs:"+strconv.Itoa(fd)) + if fake.fault == "fstatfs-devpts" { + return unix.EIO + } + stat.Type = devptsSuperMagic + if fake.fault == "wrong-devpts-filesystem" { + stat.Type = unix.TMPFS_MAGIC + } + return nil + }, + fstatAt: func(dirFD int, path string, stat *unix.Stat_t, flags int) error { + fake.events = append(fake.events, "fstatat:"+strconv.Itoa(dirFD)+":"+path) + if fake.fdPaths[dirFD] != "/dev/pts" || flags != unix.AT_SYMLINK_NOFOLLOW { + return unix.EINVAL + } + if fake.fault == "fstatat-devpts-parent" && path == ".." || fake.fault == "fstatat-devpts-root" && path == "../.." { + return unix.EIO + } + stat.Mode = unix.S_IFDIR | 0o755 + switch path { + case "..": + stat.Dev = 182 + stat.Ino = 2 + if fake.fault == "same-devpts-parent" { + stat.Dev = 183 + } + case "../..": + stat.Dev = 181 + stat.Ino = 3 + if fake.fault == "regular-devpts-root" { + stat.Mode = unix.S_IFREG | 0o644 + } + default: + return unix.EINVAL + } + return nil + }, + readDirNames: func(fd int) ([]string, error) { + fake.events = append(fake.events, "readdir:"+strconv.Itoa(fd)) + if fake.fdPaths[fd] != "/dev/pts" { + return nil, unix.EINVAL + } + if fake.fault == "readdir-devpts" { + return nil, unix.EIO + } + if fake.fault == "unexpected-devpts-entry" { + return []string{"0", "ptmx"}, nil + } + return []string{"ptmx"}, nil + }, addPathRule: func(rulesetFD int, attr *llsyscall.PathBeneathAttr, flags int) error { fake.events = append(fake.events, "add:"+strconv.Itoa(attr.ParentFd)) - if rulesetFD != fake.rulesetFD || flags != 0 || attr.ParentFd != fake.lastFstatFD { + if rulesetFD != fake.rulesetFD || flags != 0 || !fake.fstatSeen[attr.ParentFd] || !fake.openFDs[attr.ParentFd] { return unix.EINVAL } fake.rules = append(fake.rules, fakePathRule{path: fake.fdPaths[attr.ParentFd], allowed: attr.AllowedAccess}) - if fake.fault == "add" { + if fake.fault == "add" || fake.fault == "add-devpts" && fake.fdPaths[attr.ParentFd] == "/dev/pts" { return unix.EIO } return nil @@ -1095,9 +1344,16 @@ func (fake *prepareV3Fake) ops() prepareV3Ops { closeFD: func(fd int) error { fake.events = append(fake.events, "close:"+strconv.Itoa(fd)) fake.closed = append(fake.closed, fd) + delete(fake.openFDs, fd) if fake.fault == "close-source" && fd >= 10 { return unix.EIO } + if fake.fault == "close-ptmx" && fake.fdPaths[fd] == "/dev/pts/ptmx" { + return unix.EIO + } + if fake.fault == "close-devpts" && fake.fdPaths[fd] == "/dev/pts" { + return unix.EIO + } return nil }, } diff --git a/tools/readme-terminal-demo/internal/sandbox/sandbox_test.go b/tools/readme-terminal-demo/internal/sandbox/sandbox_test.go index f42776f2a..1fb42134c 100644 --- a/tools/readme-terminal-demo/internal/sandbox/sandbox_test.go +++ b/tools/readme-terminal-demo/internal/sandbox/sandbox_test.go @@ -30,10 +30,13 @@ func TestRunRestrictedChildCleansGroupAfterEveryOutcome(t *testing.T) { ulimit -c 0 printf '%s\n' "$$" > "$1" ( - sleep 0.25 + IFS=' ' read -r descendant_pid _ < /proc/self/stat + printf '%s\n' "$descendant_pid" > "$3" + while [ ! -e "$4" ]; do sleep 0.01; done printf staged > "$2" ) & -case "$3" in +while [ ! -s "$3" ]; do sleep 0.01; done +case "$5" in exit-0) exit 0 ;; exit-1) exit 1 ;; exit-4) exit 4 ;; @@ -64,13 +67,15 @@ exit 99 directory := t.TempDir() pidPath := filepath.Join(directory, "direct.pid") stagingPath := filepath.Join(directory, "staging-byte") + descendantPIDPath := filepath.Join(directory, "descendant.pid") + gatePath := filepath.Join(directory, "descendant.gate") timeout := test.timeout if timeout == 0 { timeout = 2 * time.Second } outcome, err := sandbox.RunRestrictedChild(context.Background(), sandbox.Child{ Path: "/bin/sh", - Args: []string{"-c", childScript, "sh", pidPath, stagingPath, test.mode}, + Args: []string{"-c", childScript, "sh", pidPath, stagingPath, descendantPIDPath, gatePath, test.mode}, Timeout: timeout, }) if test.wantErr == nil { @@ -88,14 +93,60 @@ exit 99 if err := unix.Kill(pid, 0); !errors.Is(err, unix.ESRCH) { t.Fatalf("direct child PID %d remains after wait/reap: %v", pid, err) } - time.Sleep(400 * time.Millisecond) + descendantPID := readProcessID(t, descendantPIDPath, time.Second) + t.Cleanup(func() { + _ = os.WriteFile(gatePath, []byte("release"), 0o600) + _ = unix.Kill(descendantPID, unix.SIGKILL) + }) + if err := waitForProcessTermination(descendantPID, time.Second); err != nil { + t.Fatalf("descendant cleanup: %v", err) + } + if err := os.WriteFile(gatePath, []byte("release"), 0o600); err != nil { + t.Fatalf("release descendant gate: %v", err) + } + time.Sleep(100 * time.Millisecond) if _, err := os.Stat(stagingPath); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("staging witness was read before group cleanup completed: %v", err) + t.Fatalf("descendant survived group cleanup and crossed its gate: %v", err) } }) } } +func waitForProcessTermination(pid int, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for { + err := unix.Kill(pid, 0) + if errors.Is(err, unix.ESRCH) { + return nil + } + if err != nil { + return fmt.Errorf("inspect PID %d: %w", pid, err) + } + stat, readErr := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) + if errors.Is(readErr, os.ErrNotExist) { + return nil + } + if readErr != nil { + return fmt.Errorf("read PID %d state: %w", pid, readErr) + } + stateStart := strings.LastIndex(string(stat), ") ") + if stateStart < 0 { + return fmt.Errorf("parse PID %d state %q", pid, stat) + } + fields := strings.Fields(string(stat[stateStart+2:])) + if len(fields) == 0 { + return fmt.Errorf("parse PID %d state %q", pid, stat) + } + if fields[0] == "Z" { + return nil + } + if time.Now().After(deadline) { + return fmt.Errorf("PID %d remains live in state %q after group cleanup", pid, fields[0]) + } + time.Sleep(10 * time.Millisecond) + } +} + func TestRunRestrictedChildBoundsEscapedPipeHolder(t *testing.T) { if os.Getenv(escapedPipeHolderMode) == "holder" { if _, err := unix.Setsid(); err != nil { @@ -202,6 +253,35 @@ func TestRawExecDeviceBoundary(t *testing.T) { if err := writeDevice("/dev/null"); err != nil { t.Fatal(err) } + masterFD, err := unix.Open("/dev/pts/ptmx", unix.O_RDWR|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err != nil { + t.Fatalf("open PTY master after raw exec: %v", err) + } + defer unix.Close(masterFD) + if err := unix.IoctlSetPointerInt(masterFD, unix.TIOCSPTLCK, 0); err != nil { + t.Fatalf("unlock PTY after raw exec: %v", err) + } + ptyNumber, err := unix.IoctlGetInt(masterFD, unix.TIOCGPTN) + if err != nil { + t.Fatalf("read PTY number after raw exec: %v", err) + } + slavePath := "/dev/pts/" + strconv.Itoa(ptyNumber) + slaveFD, err := unix.Open(slavePath, unix.O_RDWR|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err != nil { + t.Fatalf("open dynamic PTY slave after raw exec: %v", err) + } + var slaveStat unix.Stat_t + if err := unix.Fstat(slaveFD, &slaveStat); err != nil { + _ = unix.Close(slaveFD) + t.Fatalf("inspect dynamic PTY slave: %v", err) + } + if slaveStat.Mode&unix.S_IFMT != unix.S_IFCHR { + _ = unix.Close(slaveFD) + t.Fatalf("dynamic PTY slave mode = %#o, want character device", slaveStat.Mode) + } + if err := unix.Close(slaveFD); err != nil { + t.Fatalf("close dynamic PTY slave: %v", err) + } fd, err := unix.Open("/dev/zero", unix.O_WRONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) if err == nil { _ = unix.Close(fd) @@ -218,6 +298,9 @@ func TestRawExecDeviceBoundary(t *testing.T) { if err := requireCharacterDevice("/dev/zero", 1, 5); err != nil { t.Fatal(err) } + if err := requireCharacterDevice("/dev/pts/ptmx", 5, 2); err != nil { + t.Fatal(err) + } if err := writeDevice("/dev/zero"); err != nil { t.Fatal(err) } @@ -229,9 +312,12 @@ func TestRawExecDeviceBoundary(t *testing.T) { Path: executable, Args: []string{executable, "-test.run=^TestRawExecDeviceBoundary$"}, Env: []string{rawDeviceBoundaryMode + "=target"}, - Policy: sandbox.Policy{ReadOnlyPaths: existingDirectories( - "/usr", "/etc", "/proc", "/sys", "/dev", filepath.Dir(executable), - )}, + Policy: sandbox.Policy{ + ReadOnlyPaths: existingDirectories( + "/usr", "/etc", "/proc", "/sys", "/dev", filepath.Dir(executable), + ), + AllowPrivateDevptsPTY: true, + }, }) t.Fatal("ExecRestricted returned after successful preflight") } diff --git a/tools/readme-terminal-demo/testdata/smoke/readme.tape b/tools/readme-terminal-demo/testdata/smoke/readme.tape index e39305881..413e22fce 100644 --- a/tools/readme-terminal-demo/testdata/smoke/readme.tape +++ b/tools/readme-terminal-demo/testdata/smoke/readme.tape @@ -2,9 +2,13 @@ Output "/work/smoke.gif" Set Shell "zsh" Set FontFamily "JetBrains Mono" -Set FontSize 24 -Set Width 800 -Set Height 300 +Set FontSize 18 +Set Width 960 +Set Height 540 +Set Theme "Catppuccin Mocha" +Set Framerate 30 +Set TypingSpeed 35ms +Set CursorBlink false Type "printf 'readme-terminal-demo smoke\\n'" Enter From 4a4259a8cd17730e6837c88ffd1a30f196da06d0 Mon Sep 17 00:00:00 2001 From: Sall Date: Sun, 19 Jul 2026 04:19:13 +0100 Subject: [PATCH 3/6] feat(terminal-demo): validate the demo contract --- tools/readme-terminal-demo/go.mod | 5 +- .../internal/limits/limits.go | 78 ++ .../internal/manifest/decode.go | 224 ++++++ .../internal/manifest/manifest_test.go | 756 ++++++++++++++++++ .../internal/manifest/model.go | 29 + .../internal/manifest/validate.go | 124 +++ .../readme-terminal-demo/manifest.schema.json | 119 +++ .../invalid/alt-control-character.yml | 9 + .../testdata/invalid/alt-empty.yml | 9 + .../testdata/invalid/alt-multiline.yml | 11 + .../testdata/invalid/fixtures-wrong-root.yml | 9 + .../testdata/invalid/gif-wrong-root.yml | 9 + .../testdata/invalid/missing-fixtures.yml | 8 + .../testdata/invalid/missing-output-gif.yml | 8 + .../testdata/invalid/missing-output-png.yml | 8 + .../testdata/invalid/missing-outputs.yml | 6 + .../testdata/invalid/missing-readme-alt.yml | 8 + .../testdata/invalid/missing-readme-path.yml | 8 + .../testdata/invalid/missing-readme.yml | 6 + .../testdata/invalid/missing-scenario.yml | 8 + .../testdata/invalid/missing-version.yml | 8 + .../testdata/invalid/png-wrong-extension.yml | 9 + .../invalid/readme-wrong-extension.yml | 9 + .../testdata/invalid/readme-wrong-root.yml | 9 + .../testdata/invalid/scenario-absolute.yml | 9 + .../testdata/invalid/scenario-dot-dot.yml | 9 + .../invalid/scenario-wrong-extension.yml | 9 + .../testdata/invalid/unknown-key.yml | 10 + .../testdata/invalid/version-2.yml | 9 + .../testdata/valid/manifest-minimal.yml | 9 + 30 files changed, 1531 insertions(+), 1 deletion(-) create mode 100644 tools/readme-terminal-demo/internal/limits/limits.go create mode 100644 tools/readme-terminal-demo/internal/manifest/decode.go create mode 100644 tools/readme-terminal-demo/internal/manifest/manifest_test.go create mode 100644 tools/readme-terminal-demo/internal/manifest/model.go create mode 100644 tools/readme-terminal-demo/internal/manifest/validate.go create mode 100644 tools/readme-terminal-demo/manifest.schema.json create mode 100644 tools/readme-terminal-demo/testdata/invalid/alt-control-character.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/alt-empty.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/alt-multiline.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/fixtures-wrong-root.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/gif-wrong-root.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/missing-fixtures.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/missing-output-gif.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/missing-output-png.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/missing-outputs.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/missing-readme-alt.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/missing-readme-path.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/missing-readme.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/missing-scenario.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/missing-version.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/png-wrong-extension.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/readme-wrong-extension.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/readme-wrong-root.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/scenario-absolute.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/scenario-dot-dot.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/scenario-wrong-extension.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/unknown-key.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/version-2.yml create mode 100644 tools/readme-terminal-demo/testdata/valid/manifest-minimal.yml diff --git a/tools/readme-terminal-demo/go.mod b/tools/readme-terminal-demo/go.mod index c11e116df..eabb035a9 100644 --- a/tools/readme-terminal-demo/go.mod +++ b/tools/readme-terminal-demo/go.mod @@ -10,4 +10,7 @@ require ( golang.org/x/sys v0.40.0 ) -require kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 // indirect +require ( + golang.org/x/text v0.14.0 // indirect + kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 // indirect +) diff --git a/tools/readme-terminal-demo/internal/limits/limits.go b/tools/readme-terminal-demo/internal/limits/limits.go new file mode 100644 index 000000000..5eeb180e1 --- /dev/null +++ b/tools/readme-terminal-demo/internal/limits/limits.go @@ -0,0 +1,78 @@ +// Package limits owns immutable, versioned resource bounds for the renderer. +package limits + +import "time" + +// Limits contains every resource bound fixed by a manifest contract version. +type Limits struct { + ManifestBytes int64 + YAMLDepth int + YAMLNodes int + ScalarBytes int + AltTextBytes int + TapeBytes int64 + FixtureFiles int + FixtureBytes int64 + SingleFixtureBytes int64 + SnapshotEntries int + SnapshotBytes int64 + SnapshotEntryBytes int64 + SnapshotArchiveBytes int64 + PathBytes int + SymlinkTargetBytes int + SymlinkDepth int + DiagnosticBytes int + Directives int + TypedCommandBytes int + TypedBytes int + WaitPatternBytes int + KeyRepeat int + Sleep time.Duration + SleepTotal time.Duration + Wait time.Duration + WaitTotal time.Duration + Capture time.Duration + Width int + Height int + GIFDuration time.Duration + GIFBytes int64 + PNGBytes int64 +} + +// V1 returns the fixed resource bounds for manifest contract version 1. +func V1() Limits { + return Limits{ + ManifestBytes: 16 * 1024, + YAMLDepth: 16, + YAMLNodes: 128, + ScalarBytes: 4 * 1024, + AltTextBytes: 512, + TapeBytes: 64 * 1024, + FixtureFiles: 512, + FixtureBytes: 16 * 1024 * 1024, + SingleFixtureBytes: 1024 * 1024, + SnapshotEntries: 4096, + SnapshotBytes: 64 * 1024 * 1024, + SnapshotEntryBytes: 8 * 1024 * 1024, + SnapshotArchiveBytes: 96 * 1024 * 1024, + PathBytes: 1024, + SymlinkTargetBytes: 1024, + SymlinkDepth: 16, + DiagnosticBytes: 64 * 1024, + Directives: 128, + TypedCommandBytes: 2 * 1024, + TypedBytes: 16 * 1024, + WaitPatternBytes: 512, + KeyRepeat: 32, + Sleep: 3 * time.Second, + SleepTotal: 8 * time.Second, + Wait: 10 * time.Second, + WaitTotal: 30 * time.Second, + Capture: 45 * time.Second, + Width: 960, + Height: 540, + GIFDuration: 12 * time.Second, + GIFBytes: 5 * 1024 * 1024, + PNGBytes: 1024 * 1024, + } +} diff --git a/tools/readme-terminal-demo/internal/manifest/decode.go b/tools/readme-terminal-demo/internal/manifest/decode.go new file mode 100644 index 000000000..b71bb814d --- /dev/null +++ b/tools/readme-terminal-demo/internal/manifest/decode.go @@ -0,0 +1,224 @@ +package manifest + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + + "github.com/z-shell/.github/tools/readme-terminal-demo/internal/failure" + "github.com/z-shell/.github/tools/readme-terminal-demo/internal/limits" + "go.yaml.in/yaml/v3" +) + +var coreYAMLTags = map[string]struct{}{ + "!!bool": {}, + "!!float": {}, + "!!int": {}, + "!!map": {}, + "!!null": {}, + "!!seq": {}, + "!!str": {}, +} + +// Decode parses one bounded manifest document through a strict YAML preflight. +func Decode(r io.Reader, maxBytes int64) (Manifest, error) { + if r == nil { + return Manifest{}, invalidManifest(errors.New("manifest reader is nil")) + } + if maxBytes <= 0 { + return Manifest{}, invalidManifest(errors.New("manifest byte limit is invalid")) + } + contractLimits := limits.V1() + if maxBytes > contractLimits.ManifestBytes { + maxBytes = contractLimits.ManifestBytes + } + + data, err := io.ReadAll(io.LimitReader(r, maxBytes+1)) + if err != nil { + return Manifest{}, invalidManifest(fmt.Errorf("read manifest: %w", err)) + } + if int64(len(data)) > maxBytes { + return Manifest{}, invalidManifest(errors.New("manifest exceeds byte limit")) + } + + decoder := yaml.NewDecoder(bytes.NewReader(data)) + var document yaml.Node + if err := decoder.Decode(&document); err != nil { + return Manifest{}, invalidManifest(fmt.Errorf("decode YAML node: %w", err)) + } + if len(document.Content) == 0 { + return Manifest{}, invalidManifest(errors.New("manifest document is empty")) + } + var extra yaml.Node + if err := decoder.Decode(&extra); err == nil { + return Manifest{}, invalidManifest(errors.New("multiple YAML documents are not allowed")) + } else if !errors.Is(err, io.EOF) { + return Manifest{}, invalidManifest(fmt.Errorf("decode trailing YAML document: %w", err)) + } + + if err := preflightYAML(&document, contractLimits); err != nil { + return Manifest{}, invalidManifest(err) + } + + typed := yaml.NewDecoder(bytes.NewReader(data)) + typed.KnownFields(true) + var value Manifest + if err := typed.Decode(&value); err != nil { + return Manifest{}, invalidManifest(fmt.Errorf("decode typed manifest: %w", err)) + } + return value, nil +} + +// Load opens, decodes, validates, and proves all declared repository inputs exist. +func Load(root Reader, manifestPath string) (Manifest, error) { + if root == nil { + return Manifest{}, invalidManifest(errors.New("repository reader is nil")) + } + manifestFile, err := openInput(root, "manifest", manifestPath, false) + if err != nil { + return Manifest{}, err + } + value, decodeErr := Decode(manifestFile, limits.V1().ManifestBytes) + closeErr := manifestFile.Close() + if decodeErr != nil { + return Manifest{}, decodeErr + } + if closeErr != nil { + return Manifest{}, invalidManifest(fmt.Errorf("close manifest: %w", closeErr)) + } + if err := Validate(value); err != nil { + return Manifest{}, err + } + + inputs := []struct { + field string + path string + directory bool + }{ + {field: "scenario", path: value.Scenario}, + {field: "fixtures", path: value.Fixtures, directory: true}, + {field: "readme.path", path: value.Readme.Path}, + } + for _, input := range inputs { + file, err := openInput(root, input.field, input.path, input.directory) + if err != nil { + return Manifest{}, err + } + if err := file.Close(); err != nil { + return Manifest{}, invalidManifest(fmt.Errorf("close %s input: %w", input.field, err)) + } + } + return value, nil +} + +func preflightYAML(document *yaml.Node, bounds limits.Limits) error { + if hasYAMLAlias(document) { + return errors.New("YAML aliases are not allowed") + } + nodes := 0 + return inspectYAMLNode(document, 0, &nodes, bounds) +} + +func hasYAMLAlias(node *yaml.Node) bool { + if node == nil { + return false + } + if node.Kind == yaml.AliasNode || node.Alias != nil { + return true + } + for _, child := range node.Content { + if hasYAMLAlias(child) { + return true + } + } + return false +} + +func inspectYAMLNode(node *yaml.Node, depth int, nodes *int, bounds limits.Limits) error { + if node == nil { + return errors.New("nil YAML node") + } + *nodes++ + if *nodes > bounds.YAMLNodes { + return errors.New("YAML node limit exceeded") + } + if node.Kind != yaml.DocumentNode { + depth++ + if depth > bounds.YAMLDepth { + return errors.New("YAML depth limit exceeded") + } + } + if node.Anchor != "" { + return errors.New("YAML anchors are not allowed") + } + if node.Kind != yaml.DocumentNode { + if _, ok := coreYAMLTags[node.ShortTag()]; !ok { + return errors.New("custom YAML tags are not allowed") + } + } + if node.Kind == yaml.ScalarNode && len(node.Value) > bounds.ScalarBytes { + return errors.New("YAML scalar byte limit exceeded") + } + if node.Kind == yaml.MappingNode { + if len(node.Content)%2 != 0 { + return errors.New("invalid YAML mapping") + } + keys := make(map[string]struct{}, len(node.Content)/2) + for index := 0; index < len(node.Content); index += 2 { + key := node.Content[index] + if key.Kind != yaml.ScalarNode { + return errors.New("YAML mapping keys must be scalars") + } + fingerprint := key.ShortTag() + "\x00" + key.Value + if _, exists := keys[fingerprint]; exists { + return errors.New("duplicate YAML mapping key") + } + keys[fingerprint] = struct{}{} + } + } + for _, child := range node.Content { + if err := inspectYAMLNode(child, depth, nodes, bounds); err != nil { + return err + } + } + return nil +} + +func openInput(root Reader, field, path string, wantDirectory bool) (*os.File, error) { + file, err := root.OpenRead(path) + if err != nil { + if file != nil { + _ = file.Close() + } + var structured *failure.Error + if errors.As(err, &structured) { + return nil, structured + } + return nil, invalidManifest(fmt.Errorf("open %s input: %w", field, err)) + } + if file == nil { + return nil, invalidManifest(fmt.Errorf("open %s input: nil file", field)) + } + info, err := file.Stat() + if err != nil { + _ = file.Close() + return nil, invalidManifest(fmt.Errorf("inspect %s input: %w", field, err)) + } + if wantDirectory != info.IsDir() || (!wantDirectory && !info.Mode().IsRegular()) { + _ = file.Close() + return nil, invalidManifest(fmt.Errorf("%s input has the wrong file type", field)) + } + return file, nil +} + +func invalidManifest(err error) error { + return failure.E( + failure.InvalidContract, + failure.StageManifest, + "", + failure.RuleManifestInvalid, + err, + ) +} diff --git a/tools/readme-terminal-demo/internal/manifest/manifest_test.go b/tools/readme-terminal-demo/internal/manifest/manifest_test.go new file mode 100644 index 000000000..c1f0a92d8 --- /dev/null +++ b/tools/readme-terminal-demo/internal/manifest/manifest_test.go @@ -0,0 +1,756 @@ +package manifest + +import ( + "bytes" + "errors" + "fmt" + "os" + "path" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + "unicode" + "unicode/utf8" + + "github.com/santhosh-tekuri/jsonschema/v6" + "github.com/z-shell/.github/tools/readme-terminal-demo/internal/failure" + "github.com/z-shell/.github/tools/readme-terminal-demo/internal/limits" + "go.yaml.in/yaml/v3" +) + +const validManifestYAML = `version: 1 +scenario: .github/demos/readme.tape +fixtures: .github/demos/fixtures +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/readme-demo.png +readme: + path: docs/README.md + alt: Short description of the behavior shown in the terminal demo. +` + +func TestV1LimitsAreImmutableContractValues(t *testing.T) { + want := limits.Limits{ + ManifestBytes: 16 * 1024, + YAMLDepth: 16, + YAMLNodes: 128, + ScalarBytes: 4 * 1024, + AltTextBytes: 512, + TapeBytes: 64 * 1024, + FixtureFiles: 512, + FixtureBytes: 16 * 1024 * 1024, + SingleFixtureBytes: 1024 * 1024, + SnapshotEntries: 4096, + SnapshotBytes: 64 * 1024 * 1024, + SnapshotEntryBytes: 8 * 1024 * 1024, + SnapshotArchiveBytes: 96 * 1024 * 1024, + PathBytes: 1024, + SymlinkTargetBytes: 1024, + SymlinkDepth: 16, + DiagnosticBytes: 64 * 1024, + Directives: 128, + TypedCommandBytes: 2 * 1024, + TypedBytes: 16 * 1024, + WaitPatternBytes: 512, + KeyRepeat: 32, + Sleep: 3 * time.Second, + SleepTotal: 8 * time.Second, + Wait: 10 * time.Second, + WaitTotal: 30 * time.Second, + Capture: 45 * time.Second, + Width: 960, + Height: 540, + GIFDuration: 12 * time.Second, + GIFBytes: 5 * 1024 * 1024, + PNGBytes: 1024 * 1024, + } + if got := limits.V1(); !reflect.DeepEqual(got, want) { + t.Fatalf("V1() = %#v, want %#v", got, want) + } + changed := limits.V1() + changed.Width = 1 + if got := limits.V1().Width; got != want.Width { + t.Fatalf("fresh V1().Width = %d after caller mutation, want %d", got, want.Width) + } +} + +func TestDecodeAcceptsValidV1Shape(t *testing.T) { + got, err := Decode(strings.NewReader(validManifestYAML), limits.V1().ManifestBytes) + if err != nil { + t.Fatalf("Decode() error = %v", err) + } + if err := Validate(got); err != nil { + t.Fatalf("Validate() error = %v", err) + } + if want := validManifest(); !reflect.DeepEqual(got, want) { + t.Fatalf("Decode() = %#v, want %#v", got, want) + } +} + +func TestDecodeRejectsUntrustedYAML(t *testing.T) { + deepValue := strings.Repeat("[", limits.V1().YAMLDepth+1) + "0" + strings.Repeat("]", limits.V1().YAMLDepth+1) + manyNodes := strings.TrimSuffix(validManifestYAML, "\n") + "\nextra: [" + strings.Repeat("0,", limits.V1().YAMLNodes) + "0]\n" + oversized := validManifestYAML + strings.Repeat("#", int(limits.V1().ManifestBytes)) + + tests := []struct { + name string + input string + wantCause string + }{ + {name: "unknown key", input: validManifestYAML + "unexpected: true\n"}, + {name: "duplicate key", input: validManifestYAML + "version: 1\n"}, + {name: "anchor", input: strings.Replace(validManifestYAML, "version: 1", "version: &version 1", 1), wantCause: "anchor"}, + { + name: "alias", + input: strings.Replace( + strings.Replace(validManifestYAML, "scenario: .github/demos/readme.tape", "scenario: &scenario .github/demos/readme.tape", 1), + "fixtures: .github/demos/fixtures", + "fixtures: *scenario", + 1, + ), + wantCause: "alias", + }, + {name: "custom tag", input: strings.Replace(validManifestYAML, "version: 1", "version: !contract 1", 1), wantCause: "tag"}, + {name: "multiple documents", input: validManifestYAML + "---\n" + validManifestYAML, wantCause: "multiple YAML documents"}, + {name: "excessive depth", input: validManifestYAML + "extra: " + deepValue + "\n", wantCause: "depth"}, + {name: "excessive nodes", input: manyNodes, wantCause: "node"}, + { + name: "oversized multibyte scalar", + input: validManifestYAML + "extra: " + strings.Repeat("é", limits.V1().ScalarBytes/2+1) + "\n", + wantCause: "scalar", + }, + {name: "oversized input", input: oversized, wantCause: "manifest"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := Decode(strings.NewReader(test.input), limits.V1().ManifestBytes) + structured := requireManifestFailure(t, err, failure.InvalidContract) + if test.wantCause != "" && !strings.Contains(structured.Err.Error(), test.wantCause) { + t.Fatalf("failure cause = %q, want substring %q", structured.Err, test.wantCause) + } + }) + } +} + +func TestDecodeRejectsNestedAndWrongShapes(t *testing.T) { + tests := []struct { + name string + input string + wantCause string + }{ + {name: "empty document", input: ""}, + {name: "empty second document", input: validManifestYAML + "---\n", wantCause: "multiple YAML documents"}, + {name: "non-map document", input: "- version: 1\n"}, + {name: "wrong version type", input: strings.Replace(validManifestYAML, "version: 1", `version: "1"`, 1)}, + {name: "wrong outputs type", input: strings.Replace(validManifestYAML, "outputs:\n gif: docs/assets/readme-demo.gif\n png: docs/assets/readme-demo.png", "outputs: []", 1)}, + {name: "nested unknown key", input: strings.Replace(validManifestYAML, " png: docs/assets/readme-demo.png", " png: docs/assets/readme-demo.png\n unexpected: true", 1)}, + {name: "nested duplicate key", input: strings.Replace(validManifestYAML, " png: docs/assets/readme-demo.png", " png: docs/assets/readme-demo.png\n gif: docs/assets/duplicate.gif", 1), wantCause: "duplicate"}, + {name: "binary tag", input: strings.Replace(validManifestYAML, " alt: Short description of the behavior shown in the terminal demo.", " alt: !!binary ZGVtbw==", 1), wantCause: "tag"}, + {name: "timestamp tag", input: strings.Replace(validManifestYAML, " alt: Short description of the behavior shown in the terminal demo.", " alt: !!timestamp 2026-07-19", 1), wantCause: "tag"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := Decode(strings.NewReader(test.input), limits.V1().ManifestBytes) + structured := requireManifestFailure(t, err, failure.InvalidContract) + if test.wantCause != "" && !strings.Contains(structured.Err.Error(), test.wantCause) { + t.Fatalf("failure cause = %q, want substring %q", structured.Err, test.wantCause) + } + }) + } + + explicitCore := strings.Replace(validManifestYAML, "version: 1", "version: !!int 1", 1) + value, err := Decode(strings.NewReader(explicitCore), limits.V1().ManifestBytes) + if err != nil { + t.Fatalf("Decode(explicit core tag) error = %v", err) + } + if err := Validate(value); err != nil { + t.Fatalf("Validate(explicit core tag) error = %v", err) + } +} + +func TestDecodeEnforcesExactByteBoundsAndV1Clamp(t *testing.T) { + if _, err := Decode(strings.NewReader(validManifestYAML), int64(len(validManifestYAML))); err != nil { + t.Fatalf("Decode(exact caller byte bound) error = %v", err) + } + _, err := Decode(strings.NewReader(validManifestYAML), int64(len(validManifestYAML)-1)) + requireManifestFailure(t, err, failure.InvalidContract) + + overV1 := validManifestYAML + strings.Repeat("#", int(limits.V1().ManifestBytes)) + _, err = Decode(strings.NewReader(overV1), limits.V1().ManifestBytes*2) + structured := requireManifestFailure(t, err, failure.InvalidContract) + if !strings.Contains(structured.Err.Error(), "manifest") { + t.Fatalf("failure cause = %q, want manifest V1 byte limit", structured.Err) + } +} + +func TestYAMLPreflightExactBounds(t *testing.T) { + bounds := limits.V1() + + t.Run("depth", func(t *testing.T) { + exact := strings.Repeat("[", bounds.YAMLDepth-1) + "0" + strings.Repeat("]", bounds.YAMLDepth-1) + if err := preflightYAML(parseYAMLNode(t, exact), bounds); err != nil { + t.Fatalf("preflight exact depth: %v", err) + } + tooDeep := "[" + exact + "]" + if err := preflightYAML(parseYAMLNode(t, tooDeep), bounds); err == nil || !strings.Contains(err.Error(), "depth") { + t.Fatalf("preflight depth+1 error = %v, want depth limit", err) + } + }) + + t.Run("nodes", func(t *testing.T) { + exact := yamlSequence(bounds.YAMLNodes - 2) + if err := preflightYAML(parseYAMLNode(t, exact), bounds); err != nil { + t.Fatalf("preflight exact nodes: %v", err) + } + tooMany := yamlSequence(bounds.YAMLNodes - 1) + if err := preflightYAML(parseYAMLNode(t, tooMany), bounds); err == nil || !strings.Contains(err.Error(), "node") { + t.Fatalf("preflight nodes+1 error = %v, want node limit", err) + } + }) + + t.Run("multibyte scalar", func(t *testing.T) { + exact := strings.Repeat("é", bounds.ScalarBytes/2) + if err := preflightYAML(parseYAMLNode(t, exact), bounds); err != nil { + t.Fatalf("preflight exact scalar bytes: %v", err) + } + tooLarge := exact + "é" + if err := preflightYAML(parseYAMLNode(t, tooLarge), bounds); err == nil || !strings.Contains(err.Error(), "scalar") { + t.Fatalf("preflight scalar bytes+2 error = %v, want scalar limit", err) + } + }) +} + +func TestValidateRejectsEveryMissingField(t *testing.T) { + tests := map[string]func(*Manifest){ + "version": func(value *Manifest) { value.Version = 0 }, + "scenario": func(value *Manifest) { value.Scenario = "" }, + "fixtures": func(value *Manifest) { value.Fixtures = "" }, + "outputs": func(value *Manifest) { value.Outputs = Outputs{} }, + "outputs.gif": func(value *Manifest) { value.Outputs.GIF = "" }, + "outputs.png": func(value *Manifest) { value.Outputs.PNG = "" }, + "readme": func(value *Manifest) { value.Readme = Readme{} }, + "readme.path": func(value *Manifest) { value.Readme.Path = "" }, + "readme.alt": func(value *Manifest) { value.Readme.Alt = "" }, + } + + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + value := validManifest() + mutate(&value) + requireManifestFailure(t, Validate(value), failure.InvalidContract) + }) + } +} + +func TestValidateRejectsContractViolations(t *testing.T) { + tests := []struct { + name string + mutate func(*Manifest) + class failure.Class + }{ + {name: "version other than one", mutate: func(value *Manifest) { value.Version = 2 }, class: failure.InvalidContract}, + {name: "absolute scenario", mutate: func(value *Manifest) { value.Scenario = "/.github/demos/readme.tape" }, class: failure.UnsafePath}, + {name: "scenario dot-dot", mutate: func(value *Manifest) { value.Scenario = ".github/demos/../readme.tape" }, class: failure.UnsafePath}, + {name: "scenario wrong root", mutate: func(value *Manifest) { value.Scenario = "demo/readme.tape" }, class: failure.UnsafePath}, + {name: "scenario wrong extension", mutate: func(value *Manifest) { value.Scenario = ".github/demos/readme.yml" }, class: failure.UnsafePath}, + {name: "scenario empty stem", mutate: func(value *Manifest) { value.Scenario = ".github/demos/.tape" }, class: failure.UnsafePath}, + {name: "fixtures absolute", mutate: func(value *Manifest) { value.Fixtures = "/.github/demos/fixtures" }, class: failure.UnsafePath}, + {name: "fixtures dot-dot", mutate: func(value *Manifest) { value.Fixtures = ".github/demos/../fixtures" }, class: failure.UnsafePath}, + {name: "fixtures wrong root", mutate: func(value *Manifest) { value.Fixtures = "fixtures" }, class: failure.UnsafePath}, + {name: "gif wrong root", mutate: func(value *Manifest) { value.Outputs.GIF = "assets/readme-demo.gif" }, class: failure.UnsafePath}, + {name: "gif wrong extension", mutate: func(value *Manifest) { value.Outputs.GIF = "docs/assets/readme-demo.png" }, class: failure.UnsafePath}, + {name: "gif empty stem", mutate: func(value *Manifest) { value.Outputs.GIF = "docs/assets/.gif" }, class: failure.UnsafePath}, + {name: "png dot-dot", mutate: func(value *Manifest) { value.Outputs.PNG = "docs/assets/../readme-demo.png" }, class: failure.UnsafePath}, + {name: "png wrong root", mutate: func(value *Manifest) { value.Outputs.PNG = "docs/readme-demo.png" }, class: failure.UnsafePath}, + {name: "png wrong extension", mutate: func(value *Manifest) { value.Outputs.PNG = "docs/assets/readme-demo.gif" }, class: failure.UnsafePath}, + {name: "png empty stem", mutate: func(value *Manifest) { value.Outputs.PNG = "docs/assets/.png" }, class: failure.UnsafePath}, + {name: "readme absolute", mutate: func(value *Manifest) { value.Readme.Path = "/README.md" }, class: failure.UnsafePath}, + {name: "readme dot-dot", mutate: func(value *Manifest) { value.Readme.Path = "docs/../README.md" }, class: failure.UnsafePath}, + {name: "readme wrong root", mutate: func(value *Manifest) { value.Readme.Path = "guide/README.md" }, class: failure.UnsafePath}, + {name: "readme wrong extension", mutate: func(value *Manifest) { value.Readme.Path = "docs/README.txt" }, class: failure.UnsafePath}, + {name: "readme empty stem", mutate: func(value *Manifest) { value.Readme.Path = "docs/.md" }, class: failure.UnsafePath}, + {name: "scenario root lookalike", mutate: func(value *Manifest) { value.Scenario = ".github/demos-evil/readme.tape" }, class: failure.UnsafePath}, + {name: "asset root lookalike", mutate: func(value *Manifest) { value.Outputs.GIF = "docs/assets-evil/readme-demo.gif" }, class: failure.UnsafePath}, + {name: "readme root lookalike", mutate: func(value *Manifest) { value.Readme.Path = "docs-evil/README.md" }, class: failure.UnsafePath}, + {name: "non-normal path", mutate: func(value *Manifest) { value.Scenario = ".github//demos/readme.tape" }, class: failure.UnsafePath}, + {name: "backslash path", mutate: func(value *Manifest) { value.Scenario = `.github\demos\readme.tape` }, class: failure.UnsafePath}, + {name: "oversized path", mutate: func(value *Manifest) { + value.Scenario = ".github/demos/" + strings.Repeat("a", limits.V1().PathBytes) + ".tape" + }, class: failure.UnsafePath}, + {name: "blank alt text", mutate: func(value *Manifest) { value.Readme.Alt = " " }, class: failure.InvalidContract}, + {name: "multiline alt text", mutate: func(value *Manifest) { value.Readme.Alt = "first line\nsecond line" }, class: failure.InvalidContract}, + {name: "control character alt text", mutate: func(value *Manifest) { value.Readme.Alt = "demo\u007f" }, class: failure.InvalidContract}, + {name: "oversized alt text", mutate: func(value *Manifest) { value.Readme.Alt = strings.Repeat("a", limits.V1().AltTextBytes+1) }, class: failure.InvalidContract}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + value := validManifest() + test.mutate(&value) + requireManifestFailure(t, Validate(value), test.class) + }) + } +} + +func TestValidateUsesExactUTF8ByteBounds(t *testing.T) { + t.Run("path", func(t *testing.T) { + value := validManifest() + value.Scenario = byteSizedPath(".github/demos/", ".tape", limits.V1().PathBytes) + if err := Validate(value); err != nil { + t.Fatalf("Validate(exact path bytes) error = %v", err) + } + value.Scenario = byteSizedPath(".github/demos/", ".tape", limits.V1().PathBytes+1) + requireManifestFailure(t, Validate(value), failure.UnsafePath) + }) + + t.Run("alt text", func(t *testing.T) { + value := validManifest() + value.Readme.Alt = strings.Repeat("é", limits.V1().AltTextBytes/2) + if err := Validate(value); err != nil { + t.Fatalf("Validate(exact alt bytes) error = %v", err) + } + value.Readme.Alt += "a" + requireManifestFailure(t, Validate(value), failure.InvalidContract) + }) +} + +func TestValidateRejectsEveryAltControlClass(t *testing.T) { + tests := map[string]string{ + "carriage return": "demo\rtext", + "tab": "demo\ttext", + "NUL": "demo\x00text", + "DEL": "demo\x7ftext", + "C1 control": "demo\u0085text", + "Unicode line separator": "demo\u2028text", + "Unicode paragraph separator": "demo\u2029text", + } + for name, alt := range tests { + t.Run(name, func(t *testing.T) { + value := validManifest() + value.Readme.Alt = alt + requireManifestFailure(t, Validate(value), failure.InvalidContract) + }) + } +} + +func TestLoadRequiresInputsButNotOutputs(t *testing.T) { + root := newFileMapReader(t) + got, err := Load(root, "manifest.yml") + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if want := validManifest(); !reflect.DeepEqual(got, want) { + t.Fatalf("Load() = %#v, want %#v", got, want) + } + for _, output := range []string{got.Outputs.GIF, got.Outputs.PNG} { + if contains(root.opened, output) { + t.Fatalf("Load() opened output %q; opened = %v", output, root.opened) + } + } + for _, file := range root.files { + if _, err := file.Stat(); !errors.Is(err, os.ErrClosed) { + t.Fatalf("Reader-returned file remained open: Stat() error = %v", err) + } + } +} + +func TestLoadRejectsMissingInputs(t *testing.T) { + for _, missing := range []string{".github/demos/readme.tape", ".github/demos/fixtures", "docs/README.md"} { + t.Run(missing, func(t *testing.T) { + root := newFileMapReader(t) + delete(root.entries, missing) + _, err := Load(root, "manifest.yml") + requireManifestFailure(t, err, failure.InvalidContract) + }) + } +} + +func TestLoadPreservesReaderSymlinkEscapeFailure(t *testing.T) { + root := newFileMapReader(t) + readerFailure := failure.E( + failure.UnsafePath, + failure.StageManifest, + "scenario", + failure.RuleManifestInvalid, + errors.New("symlink escapes repository"), + ) + root.errs[".github/demos/readme.tape"] = fmt.Errorf("untrusted reader context: %w", readerFailure) + _, err := Load(root, "manifest.yml") + requireManifestFailure(t, err, failure.UnsafePath) + if err != readerFailure { + t.Fatalf("Load() error = %T %v, want exact sanitized Reader failure", err, err) + } +} + +func TestLoadRejectsWrongInputFileTypesAndClosesThem(t *testing.T) { + tests := []struct { + name string + path string + }{ + {name: "scenario directory", path: ".github/demos/readme.tape"}, + {name: "fixtures file", path: ".github/demos/fixtures"}, + {name: "README directory", path: "docs/README.md"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := newFileMapReader(t) + replacement := filepath.Join(t.TempDir(), "wrong-type") + if test.path == ".github/demos/fixtures" { + if err := os.WriteFile(replacement, []byte("not a directory"), 0o600); err != nil { + t.Fatal(err) + } + } else if err := os.Mkdir(replacement, 0o700); err != nil { + t.Fatal(err) + } + root.entries[test.path] = replacement + _, err := Load(root, "manifest.yml") + requireManifestFailure(t, err, failure.InvalidContract) + for _, file := range root.files { + if _, statErr := file.Stat(); !errors.Is(statErr, os.ErrClosed) { + t.Fatalf("Reader-returned file remained open: Stat() error = %v", statErr) + } + } + }) + } +} + +func TestLoadClosesFileReturnedWithError(t *testing.T) { + root := newFileMapReader(t) + file, err := os.Open(root.entries[".github/demos/readme.tape"]) + if err != nil { + t.Fatal(err) + } + root.errorFiles[".github/demos/readme.tape"] = file + root.errs[".github/demos/readme.tape"] = errors.New("reader returned a file and an error") + _, err = Load(root, "manifest.yml") + requireManifestFailure(t, err, failure.InvalidContract) + if _, statErr := file.Stat(); !errors.Is(statErr, os.ErrClosed) { + t.Fatalf("Reader-returned file remained open: Stat() error = %v", statErr) + } +} + +func TestManifestFailureMessageDoesNotLeakInput(t *testing.T) { + const secretPath = "/private/secret/manifest.tape" + value := validManifest() + value.Scenario = secretPath + err := Validate(value) + requireManifestFailure(t, err, failure.UnsafePath) + if strings.Contains(err.Error(), secretPath) || strings.Contains(err.Error(), "normalized repository-relative") { + t.Fatalf("public error leaked untrusted detail: %q", err) + } +} + +func TestSchemaParityFixtures(t *testing.T) { + schema := compileManifestSchema(t) + + suites := []struct { + dir string + want bool + }{ + {dir: "../../testdata/valid", want: true}, + {dir: "../../testdata/invalid", want: false}, + } + for _, suite := range suites { + entries, err := os.ReadDir(suite.dir) + if err != nil { + t.Fatalf("read fixtures %s: %v", suite.dir, err) + } + if len(entries) == 0 { + t.Fatalf("fixture directory %s is empty", suite.dir) + } + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".yml" { + continue + } + name := filepath.Join(suite.dir, entry.Name()) + t.Run(name, func(t *testing.T) { + data, err := os.ReadFile(name) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + + value, typedErr := Decode(bytes.NewReader(data), limits.V1().ManifestBytes) + if typedErr == nil { + typedErr = Validate(value) + } + typedAccepted := typedErr == nil + + var document any + parseErr := yaml.Unmarshal(data, &document) + var schemaErr error + if parseErr != nil { + schemaErr = parseErr + } else { + schemaErr = schema.Validate(document) + } + schemaAccepted := schemaErr == nil + + if typedAccepted != schemaAccepted { + t.Fatalf("fixture %s parity mismatch: typed accepted=%t (err=%v), schema accepted=%t (err=%v)", name, typedAccepted, typedErr, schemaAccepted, schemaErr) + } + if typedAccepted != suite.want { + t.Fatalf("fixture %s accepted=%t, want %t; typed err=%v; schema err=%v", name, typedAccepted, suite.want, typedErr, schemaErr) + } + }) + } + } +} + +func TestSchemaAndTypedValidationUseUTF8ByteLimits(t *testing.T) { + schema := compileManifestSchema(t) + tests := []struct { + name string + mutate func(*Manifest) + class failure.Class + }{ + { + name: "alt text", + mutate: func(value *Manifest) { + value.Readme.Alt = strings.Repeat("é", limits.V1().AltTextBytes/2+1) + }, + class: failure.InvalidContract, + }, + { + name: "path", + mutate: func(value *Manifest) { + value.Scenario = ".github/demos/" + strings.Repeat("é", limits.V1().PathBytes/2) + ".tape" + }, + class: failure.UnsafePath, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + value := validManifest() + test.mutate(&value) + requireManifestFailure(t, Validate(value), test.class) + if err := schema.Validate(manifestDocument(value)); err == nil { + t.Fatal("schema accepted a value over its UTF-8 byte limit") + } + }) + } +} + +func compileManifestSchema(t *testing.T) *jsonschema.Schema { + t.Helper() + schemaData, err := os.ReadFile("../../manifest.schema.json") + if err != nil { + t.Fatalf("read schema: %v", err) + } + schemaDocument, err := jsonschema.UnmarshalJSON(bytes.NewReader(schemaData)) + if err != nil { + t.Fatalf("parse schema: %v", err) + } + compiler := jsonschema.NewCompiler() + compiler.RegisterFormat(&jsonschema.Format{ + Name: "z-shell-path-bytes", + Validate: func(value any) error { + text, ok := value.(string) + if !ok { + return nil + } + if len(text) > limits.V1().PathBytes { + return errors.New("path exceeds UTF-8 byte limit") + } + if !utf8.ValidString(text) || path.IsAbs(text) || text == "." || path.Clean(text) != text { + return errors.New("path is not normalized repository-relative UTF-8") + } + for _, segment := range strings.Split(text, "/") { + if segment == "" || segment == "." || segment == ".." { + return errors.New("path contains an unsafe segment") + } + } + for _, character := range text { + if character == '\\' || unicode.IsControl(character) { + return errors.New("path contains an unsafe character") + } + } + return nil + }, + }) + compiler.RegisterFormat(&jsonschema.Format{ + Name: "z-shell-alt-bytes", + Validate: func(value any) error { + text, ok := value.(string) + if !ok { + return nil + } + if len(text) > limits.V1().AltTextBytes { + return errors.New("alt text exceeds UTF-8 byte limit") + } + if !utf8.ValidString(text) || strings.TrimSpace(text) == "" { + return errors.New("alt text must be non-empty plain UTF-8 text") + } + for _, character := range text { + if unicode.IsControl(character) || character == '\u2028' || character == '\u2029' { + return errors.New("alt text must be one line without controls") + } + } + return nil + }, + }) + compiler.AssertFormat() + if err := compiler.AddResource("manifest.schema.json", schemaDocument); err != nil { + t.Fatalf("add schema: %v", err) + } + schema, err := compiler.Compile("manifest.schema.json") + if err != nil { + t.Fatalf("compile schema: %v", err) + } + return schema +} + +func manifestDocument(value Manifest) map[string]any { + return map[string]any{ + "version": value.Version, + "scenario": value.Scenario, + "fixtures": value.Fixtures, + "outputs": map[string]any{ + "gif": value.Outputs.GIF, + "png": value.Outputs.PNG, + }, + "readme": map[string]any{ + "path": value.Readme.Path, + "alt": value.Readme.Alt, + }, + } +} + +func validManifest() Manifest { + return Manifest{ + Version: 1, + Scenario: ".github/demos/readme.tape", + Fixtures: ".github/demos/fixtures", + Outputs: Outputs{ + GIF: "docs/assets/readme-demo.gif", + PNG: "docs/assets/readme-demo.png", + }, + Readme: Readme{ + Path: "docs/README.md", + Alt: "Short description of the behavior shown in the terminal demo.", + }, + } +} + +func requireManifestFailure(t *testing.T, err error, wantClass failure.Class) *failure.Error { + t.Helper() + if err == nil { + t.Fatal("error = nil, want typed manifest failure") + } + var structured *failure.Error + if !errors.As(err, &structured) { + t.Fatalf("error type = %T, want *failure.Error", err) + } + if structured.Class != wantClass { + t.Fatalf("failure class = %q, want %q", structured.Class, wantClass) + } + if structured.Stage != failure.StageManifest { + t.Fatalf("failure stage = %q, want %q", structured.Stage, failure.StageManifest) + } + if structured.Rule != failure.RuleManifestInvalid { + t.Fatalf("failure rule = %q, want %q", structured.Rule, failure.RuleManifestInvalid) + } + wantExit := 2 + if wantClass == failure.UnsafePath { + wantExit = 3 + } + if got := failure.ExitCode(err); got != wantExit { + t.Fatalf("ExitCode() = %d, want %d", got, wantExit) + } + return structured +} + +type fileMapReader struct { + entries map[string]string + errs map[string]error + errorFiles map[string]*os.File + opened []string + files []*os.File +} + +func newFileMapReader(t *testing.T) *fileMapReader { + t.Helper() + directory := t.TempDir() + manifestFile := filepath.Join(directory, "manifest.yml") + scenarioFile := filepath.Join(directory, "scenario.tape") + fixturesDirectory := filepath.Join(directory, "fixtures") + readmeFile := filepath.Join(directory, "README.md") + if err := os.WriteFile(manifestFile, []byte(validManifestYAML), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(scenarioFile, []byte("Type \"echo demo\"\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(fixturesDirectory, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(readmeFile, []byte("# Demo\n"), 0o600); err != nil { + t.Fatal(err) + } + return &fileMapReader{ + entries: map[string]string{ + "manifest.yml": manifestFile, + ".github/demos/readme.tape": scenarioFile, + ".github/demos/fixtures": fixturesDirectory, + "docs/README.md": readmeFile, + }, + errs: make(map[string]error), + errorFiles: make(map[string]*os.File), + } +} + +func (reader *fileMapReader) OpenRead(name string) (*os.File, error) { + reader.opened = append(reader.opened, name) + if err := reader.errs[name]; err != nil { + file := reader.errorFiles[name] + if file != nil { + reader.files = append(reader.files, file) + } + return file, err + } + path, ok := reader.entries[name] + if !ok { + return nil, os.ErrNotExist + } + file, err := os.Open(path) + if err == nil { + reader.files = append(reader.files, file) + } + return file, err +} + +func contains(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} + +func parseYAMLNode(t *testing.T, input string) *yaml.Node { + t.Helper() + var node yaml.Node + if err := yaml.NewDecoder(strings.NewReader(input)).Decode(&node); err != nil { + t.Fatalf("decode YAML test node: %v", err) + } + return &node +} + +func yamlSequence(items int) string { + values := make([]string, items) + for index := range values { + values[index] = "0" + } + return "[" + strings.Join(values, ",") + "]" +} + +func byteSizedPath(prefix, extension string, size int) string { + remaining := size - len(prefix) - len(extension) + if remaining < 0 { + panic("path size smaller than fixed components") + } + return prefix + strings.Repeat("a", remaining%2) + strings.Repeat("é", remaining/2) + extension +} diff --git a/tools/readme-terminal-demo/internal/manifest/model.go b/tools/readme-terminal-demo/internal/manifest/model.go new file mode 100644 index 000000000..926b20000 --- /dev/null +++ b/tools/readme-terminal-demo/internal/manifest/model.go @@ -0,0 +1,29 @@ +package manifest + +import "os" + +// Manifest is the complete README terminal-demo contract. +type Manifest struct { + Version int `yaml:"version"` + Scenario string `yaml:"scenario"` + Fixtures string `yaml:"fixtures"` + Outputs Outputs `yaml:"outputs"` + Readme Readme `yaml:"readme"` +} + +// Outputs names the two repository-owned media assets. +type Outputs struct { + GIF string `yaml:"gif"` + PNG string `yaml:"png"` +} + +// Readme identifies the document and accessible animation description. +type Readme struct { + Path string `yaml:"path"` + Alt string `yaml:"alt"` +} + +// Reader opens repository paths through the caller's containment boundary. +type Reader interface { + OpenRead(string) (*os.File, error) +} diff --git a/tools/readme-terminal-demo/internal/manifest/validate.go b/tools/readme-terminal-demo/internal/manifest/validate.go new file mode 100644 index 000000000..1bf17ec8e --- /dev/null +++ b/tools/readme-terminal-demo/internal/manifest/validate.go @@ -0,0 +1,124 @@ +package manifest + +import ( + "errors" + "path" + "strings" + "unicode" + "unicode/utf8" + + "github.com/z-shell/.github/tools/readme-terminal-demo/internal/failure" + "github.com/z-shell/.github/tools/readme-terminal-demo/internal/limits" +) + +// Validate checks only the structural manifest contract; it does not open paths. +func Validate(value Manifest) error { + if value.Version != 1 { + return invalidManifest(errors.New("manifest version must be 1")) + } + if value.Scenario == "" || value.Fixtures == "" || value.Outputs.GIF == "" || + value.Outputs.PNG == "" || value.Readme.Path == "" || value.Readme.Alt == "" { + return invalidManifest(errors.New("manifest field is required")) + } + + if err := validateRootedPath("scenario", value.Scenario, ".github/demos", ".tape"); err != nil { + return err + } + if err := validateRootedPath("fixtures", value.Fixtures, ".github/demos", ""); err != nil { + return err + } + if err := validateRootedPath("outputs.gif", value.Outputs.GIF, "docs/assets", ".gif"); err != nil { + return err + } + if err := validateRootedPath("outputs.png", value.Outputs.PNG, "docs/assets", ".png"); err != nil { + return err + } + if err := validateReadmePath(value.Readme.Path); err != nil { + return err + } + if err := validateAltText(value.Readme.Alt); err != nil { + return err + } + return nil +} + +func validateRootedPath(field, value, root, extension string) error { + if err := validateRepositoryPath(field, value); err != nil { + return err + } + if !strings.HasPrefix(value, root+"/") { + return unsafeManifestPath(field, errors.New("path is outside its required root")) + } + if extension != "" { + if path.Ext(value) != extension || path.Base(value) == extension { + return unsafeManifestPath(field, errors.New("path has the wrong extension or an empty stem")) + } + } + return nil +} + +func validateReadmePath(value string) error { + if err := validateRepositoryPath("readme.path", value); err != nil { + return err + } + if value == "README.md" { + return nil + } + if !strings.HasPrefix(value, "docs/") { + return unsafeManifestPath("readme.path", errors.New("README path is outside docs")) + } + extension := path.Ext(value) + if (extension != ".md" && extension != ".markdown") || path.Base(value) == extension { + return unsafeManifestPath("readme.path", errors.New("README path is not Markdown")) + } + return nil +} + +func validateRepositoryPath(field, value string) error { + bounds := limits.V1() + if len(value) > bounds.PathBytes { + return unsafeManifestPath(field, errors.New("path exceeds byte limit")) + } + if !utf8.ValidString(value) { + return unsafeManifestPath(field, errors.New("path is not valid UTF-8")) + } + if path.IsAbs(value) || value == "." || path.Clean(value) != value { + return unsafeManifestPath(field, errors.New("path is not normalized repository-relative form")) + } + for _, segment := range strings.Split(value, "/") { + if segment == "" || segment == "." || segment == ".." { + return unsafeManifestPath(field, errors.New("path contains an unsafe segment")) + } + } + for _, character := range value { + if character == '\\' || unicode.IsControl(character) { + return unsafeManifestPath(field, errors.New("path contains an unsafe character")) + } + } + return nil +} + +func validateAltText(value string) error { + if len(value) > limits.V1().AltTextBytes { + return invalidManifest(errors.New("alt text exceeds byte limit")) + } + if !utf8.ValidString(value) || strings.TrimSpace(value) == "" { + return invalidManifest(errors.New("alt text must be non-empty plain UTF-8 text")) + } + for _, character := range value { + if unicode.IsControl(character) || character == '\u2028' || character == '\u2029' { + return invalidManifest(errors.New("alt text must be a single line without control characters")) + } + } + return nil +} + +func unsafeManifestPath(field string, err error) error { + return failure.E( + failure.UnsafePath, + failure.StageManifest, + field, + failure.RuleManifestInvalid, + err, + ) +} diff --git a/tools/readme-terminal-demo/manifest.schema.json b/tools/readme-terminal-demo/manifest.schema.json new file mode 100644 index 000000000..ee9424332 --- /dev/null +++ b/tools/readme-terminal-demo/manifest.schema.json @@ -0,0 +1,119 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://z-shell.github.io/schemas/readme-terminal-demo/manifest-v1.schema.json", + "title": "Z-Shell README terminal demo manifest v1", + "type": "object", + "additionalProperties": false, + "required": ["version", "scenario", "fixtures", "outputs", "readme"], + "properties": { + "version": { + "const": 1 + }, + "scenario": { + "$ref": "#/$defs/scenarioPath" + }, + "fixtures": { + "$ref": "#/$defs/fixturesPath" + }, + "outputs": { + "type": "object", + "additionalProperties": false, + "required": ["gif", "png"], + "properties": { + "gif": { + "$ref": "#/$defs/gifPath" + }, + "png": { + "$ref": "#/$defs/pngPath" + } + } + }, + "readme": { + "type": "object", + "additionalProperties": false, + "required": ["path", "alt"], + "properties": { + "path": { + "$ref": "#/$defs/readmePath" + }, + "alt": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "format": "z-shell-alt-bytes", + "pattern": "^[^\\x00-\\x1F\\x7F]*\\S[^\\x00-\\x1F\\x7F]*$" + } + } + } + }, + "$defs": { + "normalizedPath": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "format": "z-shell-path-bytes", + "allOf": [ + { + "not": { + "pattern": "(^|/)\\.\\.?(/|$)" + } + }, + { + "not": { + "pattern": "//|\\\\|/$|[\\x00-\\x1F\\x7F]" + } + } + ] + }, + "scenarioPath": { + "allOf": [ + { + "$ref": "#/$defs/normalizedPath" + }, + { + "pattern": "^\\.github/demos/.+\\.tape$" + } + ] + }, + "fixturesPath": { + "allOf": [ + { + "$ref": "#/$defs/normalizedPath" + }, + { + "pattern": "^\\.github/demos/.+$" + } + ] + }, + "gifPath": { + "allOf": [ + { + "$ref": "#/$defs/normalizedPath" + }, + { + "pattern": "^docs/assets/.+\\.gif$" + } + ] + }, + "pngPath": { + "allOf": [ + { + "$ref": "#/$defs/normalizedPath" + }, + { + "pattern": "^docs/assets/.+\\.png$" + } + ] + }, + "readmePath": { + "allOf": [ + { + "$ref": "#/$defs/normalizedPath" + }, + { + "pattern": "^(README\\.md|docs/.+\\.(md|markdown))$" + } + ] + } + } +} diff --git a/tools/readme-terminal-demo/testdata/invalid/alt-control-character.yml b/tools/readme-terminal-demo/testdata/invalid/alt-control-character.yml new file mode 100644 index 000000000..5abdd0a0d --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/alt-control-character.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: .github/demos/readme.tape +fixtures: .github/demos/fixtures +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/readme-demo.png +readme: + path: docs/README.md + alt: "Control character: \u0007" diff --git a/tools/readme-terminal-demo/testdata/invalid/alt-empty.yml b/tools/readme-terminal-demo/testdata/invalid/alt-empty.yml new file mode 100644 index 000000000..e1c788599 --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/alt-empty.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: .github/demos/readme.tape +fixtures: .github/demos/fixtures +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/readme-demo.png +readme: + path: docs/README.md + alt: "" diff --git a/tools/readme-terminal-demo/testdata/invalid/alt-multiline.yml b/tools/readme-terminal-demo/testdata/invalid/alt-multiline.yml new file mode 100644 index 000000000..2b631d871 --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/alt-multiline.yml @@ -0,0 +1,11 @@ +version: 1 +scenario: .github/demos/readme.tape +fixtures: .github/demos/fixtures +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/readme-demo.png +readme: + path: docs/README.md + alt: |- + First line. + Second line. diff --git a/tools/readme-terminal-demo/testdata/invalid/fixtures-wrong-root.yml b/tools/readme-terminal-demo/testdata/invalid/fixtures-wrong-root.yml new file mode 100644 index 000000000..e57885f01 --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/fixtures-wrong-root.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: .github/demos/readme.tape +fixtures: fixtures +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/readme-demo.png +readme: + path: docs/README.md + alt: Fixtures use the wrong root. diff --git a/tools/readme-terminal-demo/testdata/invalid/gif-wrong-root.yml b/tools/readme-terminal-demo/testdata/invalid/gif-wrong-root.yml new file mode 100644 index 000000000..43f57fe78 --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/gif-wrong-root.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: .github/demos/readme.tape +fixtures: .github/demos/fixtures +outputs: + gif: assets/readme-demo.gif + png: docs/assets/readme-demo.png +readme: + path: docs/README.md + alt: GIF uses the wrong root. diff --git a/tools/readme-terminal-demo/testdata/invalid/missing-fixtures.yml b/tools/readme-terminal-demo/testdata/invalid/missing-fixtures.yml new file mode 100644 index 000000000..9491f1f8f --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/missing-fixtures.yml @@ -0,0 +1,8 @@ +version: 1 +scenario: .github/demos/readme.tape +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/readme-demo.png +readme: + path: docs/README.md + alt: Missing fixtures. diff --git a/tools/readme-terminal-demo/testdata/invalid/missing-output-gif.yml b/tools/readme-terminal-demo/testdata/invalid/missing-output-gif.yml new file mode 100644 index 000000000..7f33932c7 --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/missing-output-gif.yml @@ -0,0 +1,8 @@ +version: 1 +scenario: .github/demos/readme.tape +fixtures: .github/demos/fixtures +outputs: + png: docs/assets/readme-demo.png +readme: + path: docs/README.md + alt: Missing GIF output. diff --git a/tools/readme-terminal-demo/testdata/invalid/missing-output-png.yml b/tools/readme-terminal-demo/testdata/invalid/missing-output-png.yml new file mode 100644 index 000000000..4d5e87d76 --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/missing-output-png.yml @@ -0,0 +1,8 @@ +version: 1 +scenario: .github/demos/readme.tape +fixtures: .github/demos/fixtures +outputs: + gif: docs/assets/readme-demo.gif +readme: + path: docs/README.md + alt: Missing PNG output. diff --git a/tools/readme-terminal-demo/testdata/invalid/missing-outputs.yml b/tools/readme-terminal-demo/testdata/invalid/missing-outputs.yml new file mode 100644 index 000000000..bc23cf260 --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/missing-outputs.yml @@ -0,0 +1,6 @@ +version: 1 +scenario: .github/demos/readme.tape +fixtures: .github/demos/fixtures +readme: + path: docs/README.md + alt: Missing outputs object. diff --git a/tools/readme-terminal-demo/testdata/invalid/missing-readme-alt.yml b/tools/readme-terminal-demo/testdata/invalid/missing-readme-alt.yml new file mode 100644 index 000000000..7c13ac834 --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/missing-readme-alt.yml @@ -0,0 +1,8 @@ +version: 1 +scenario: .github/demos/readme.tape +fixtures: .github/demos/fixtures +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/readme-demo.png +readme: + path: docs/README.md diff --git a/tools/readme-terminal-demo/testdata/invalid/missing-readme-path.yml b/tools/readme-terminal-demo/testdata/invalid/missing-readme-path.yml new file mode 100644 index 000000000..49afad86a --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/missing-readme-path.yml @@ -0,0 +1,8 @@ +version: 1 +scenario: .github/demos/readme.tape +fixtures: .github/demos/fixtures +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/readme-demo.png +readme: + alt: Missing README path. diff --git a/tools/readme-terminal-demo/testdata/invalid/missing-readme.yml b/tools/readme-terminal-demo/testdata/invalid/missing-readme.yml new file mode 100644 index 000000000..eaad7f1ba --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/missing-readme.yml @@ -0,0 +1,6 @@ +version: 1 +scenario: .github/demos/readme.tape +fixtures: .github/demos/fixtures +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/readme-demo.png diff --git a/tools/readme-terminal-demo/testdata/invalid/missing-scenario.yml b/tools/readme-terminal-demo/testdata/invalid/missing-scenario.yml new file mode 100644 index 000000000..4a6e61dd0 --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/missing-scenario.yml @@ -0,0 +1,8 @@ +version: 1 +fixtures: .github/demos/fixtures +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/readme-demo.png +readme: + path: docs/README.md + alt: Missing scenario. diff --git a/tools/readme-terminal-demo/testdata/invalid/missing-version.yml b/tools/readme-terminal-demo/testdata/invalid/missing-version.yml new file mode 100644 index 000000000..65a68fada --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/missing-version.yml @@ -0,0 +1,8 @@ +scenario: .github/demos/readme.tape +fixtures: .github/demos/fixtures +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/readme-demo.png +readme: + path: docs/README.md + alt: Missing version. diff --git a/tools/readme-terminal-demo/testdata/invalid/png-wrong-extension.yml b/tools/readme-terminal-demo/testdata/invalid/png-wrong-extension.yml new file mode 100644 index 000000000..c8579f7f6 --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/png-wrong-extension.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: .github/demos/readme.tape +fixtures: .github/demos/fixtures +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/readme-demo.gif +readme: + path: docs/README.md + alt: PNG has the wrong extension. diff --git a/tools/readme-terminal-demo/testdata/invalid/readme-wrong-extension.yml b/tools/readme-terminal-demo/testdata/invalid/readme-wrong-extension.yml new file mode 100644 index 000000000..b0fec0f91 --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/readme-wrong-extension.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: .github/demos/readme.tape +fixtures: .github/demos/fixtures +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/readme-demo.png +readme: + path: docs/README.txt + alt: README has the wrong extension. diff --git a/tools/readme-terminal-demo/testdata/invalid/readme-wrong-root.yml b/tools/readme-terminal-demo/testdata/invalid/readme-wrong-root.yml new file mode 100644 index 000000000..ca7128cd9 --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/readme-wrong-root.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: .github/demos/readme.tape +fixtures: .github/demos/fixtures +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/readme-demo.png +readme: + path: guide/README.md + alt: README uses the wrong root. diff --git a/tools/readme-terminal-demo/testdata/invalid/scenario-absolute.yml b/tools/readme-terminal-demo/testdata/invalid/scenario-absolute.yml new file mode 100644 index 000000000..ed7232c7d --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/scenario-absolute.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: /.github/demos/readme.tape +fixtures: .github/demos/fixtures +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/readme-demo.png +readme: + path: docs/README.md + alt: Absolute scenario path. diff --git a/tools/readme-terminal-demo/testdata/invalid/scenario-dot-dot.yml b/tools/readme-terminal-demo/testdata/invalid/scenario-dot-dot.yml new file mode 100644 index 000000000..62d20616e --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/scenario-dot-dot.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: .github/demos/../readme.tape +fixtures: .github/demos/fixtures +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/readme-demo.png +readme: + path: docs/README.md + alt: Scenario path traverses upward. diff --git a/tools/readme-terminal-demo/testdata/invalid/scenario-wrong-extension.yml b/tools/readme-terminal-demo/testdata/invalid/scenario-wrong-extension.yml new file mode 100644 index 000000000..1a7764904 --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/scenario-wrong-extension.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: .github/demos/readme.yml +fixtures: .github/demos/fixtures +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/readme-demo.png +readme: + path: docs/README.md + alt: Scenario has the wrong extension. diff --git a/tools/readme-terminal-demo/testdata/invalid/unknown-key.yml b/tools/readme-terminal-demo/testdata/invalid/unknown-key.yml new file mode 100644 index 000000000..30bee7432 --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/unknown-key.yml @@ -0,0 +1,10 @@ +version: 1 +scenario: .github/demos/readme.tape +fixtures: .github/demos/fixtures +unexpected: true +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/readme-demo.png +readme: + path: docs/README.md + alt: Unknown key. diff --git a/tools/readme-terminal-demo/testdata/invalid/version-2.yml b/tools/readme-terminal-demo/testdata/invalid/version-2.yml new file mode 100644 index 000000000..b70226b8b --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/version-2.yml @@ -0,0 +1,9 @@ +version: 2 +scenario: .github/demos/readme.tape +fixtures: .github/demos/fixtures +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/readme-demo.png +readme: + path: docs/README.md + alt: Unsupported manifest version. diff --git a/tools/readme-terminal-demo/testdata/valid/manifest-minimal.yml b/tools/readme-terminal-demo/testdata/valid/manifest-minimal.yml new file mode 100644 index 000000000..c23e3e56b --- /dev/null +++ b/tools/readme-terminal-demo/testdata/valid/manifest-minimal.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: .github/demos/readme.tape +fixtures: .github/demos/fixtures +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/readme-demo.png +readme: + path: docs/README.md + alt: Short description of the behavior shown in the terminal demo. From bd6234ac4e5ab6eebc109c76ce9d21672144da5d Mon Sep 17 00:00:00 2001 From: Sall Date: Sun, 19 Jul 2026 05:02:44 +0100 Subject: [PATCH 4/6] fix(terminal-demo): enforce strict manifest scalars --- .../internal/manifest/decode.go | 72 +++++++++++++++ .../internal/manifest/manifest_test.go | 89 ++++++++++++++++++- .../internal/manifest/validate.go | 12 ++- .../readme-terminal-demo/manifest.schema.json | 8 +- .../testdata/invalid/alt-bidi-format-only.yml | 9 ++ .../testdata/invalid/alt-bool.yml | 9 ++ .../testdata/invalid/alt-format-only.yml | 9 ++ .../testdata/invalid/alt-null.yml | 9 ++ .../testdata/invalid/alt-number.yml | 9 ++ .../testdata/invalid/fixtures-bool.yml | 9 ++ .../testdata/invalid/fixtures-null.yml | 9 ++ .../testdata/invalid/fixtures-number.yml | 9 ++ .../invalid/gif-nested-empty-stem.yml | 9 ++ .../testdata/invalid/output-gif-bool.yml | 9 ++ .../testdata/invalid/output-gif-null.yml | 9 ++ .../testdata/invalid/output-gif-number.yml | 9 ++ .../testdata/invalid/output-png-bool.yml | 9 ++ .../testdata/invalid/output-png-null.yml | 9 ++ .../testdata/invalid/output-png-number.yml | 9 ++ .../invalid/png-nested-empty-stem.yml | 9 ++ .../invalid/readme-nested-empty-stem.yml | 9 ++ .../testdata/invalid/readme-path-bool.yml | 9 ++ .../testdata/invalid/readme-path-null.yml | 9 ++ .../testdata/invalid/readme-path-number.yml | 9 ++ .../testdata/invalid/scenario-bool.yml | 9 ++ .../invalid/scenario-nested-empty-stem.yml | 9 ++ .../testdata/invalid/scenario-null.yml | 9 ++ .../testdata/invalid/scenario-number.yml | 9 ++ .../testdata/invalid/version-fractional.yml | 9 ++ .../testdata/valid/manifest-visible-zwj.yml | 9 ++ 30 files changed, 407 insertions(+), 8 deletions(-) create mode 100644 tools/readme-terminal-demo/testdata/invalid/alt-bidi-format-only.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/alt-bool.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/alt-format-only.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/alt-null.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/alt-number.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/fixtures-bool.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/fixtures-null.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/fixtures-number.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/gif-nested-empty-stem.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/output-gif-bool.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/output-gif-null.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/output-gif-number.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/output-png-bool.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/output-png-null.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/output-png-number.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/png-nested-empty-stem.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/readme-nested-empty-stem.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/readme-path-bool.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/readme-path-null.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/readme-path-number.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/scenario-bool.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/scenario-nested-empty-stem.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/scenario-null.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/scenario-number.yml create mode 100644 tools/readme-terminal-demo/testdata/invalid/version-fractional.yml create mode 100644 tools/readme-terminal-demo/testdata/valid/manifest-visible-zwj.yml diff --git a/tools/readme-terminal-demo/internal/manifest/decode.go b/tools/readme-terminal-demo/internal/manifest/decode.go index b71bb814d..28d2abf56 100644 --- a/tools/readme-terminal-demo/internal/manifest/decode.go +++ b/tools/readme-terminal-demo/internal/manifest/decode.go @@ -61,6 +61,9 @@ func Decode(r io.Reader, maxBytes int64) (Manifest, error) { if err := preflightYAML(&document, contractLimits); err != nil { return Manifest{}, invalidManifest(err) } + if err := validateManifestNodeShape(&document); err != nil { + return Manifest{}, invalidManifest(err) + } typed := yaml.NewDecoder(bytes.NewReader(data)) typed.KnownFields(true) @@ -171,6 +174,9 @@ func inspectYAMLNode(node *yaml.Node, depth int, nodes *int, bounds limits.Limit if key.Kind != yaml.ScalarNode { return errors.New("YAML mapping keys must be scalars") } + if key.ShortTag() != "!!str" { + return errors.New("manifest mapping keys must be strings") + } fingerprint := key.ShortTag() + "\x00" + key.Value if _, exists := keys[fingerprint]; exists { return errors.New("duplicate YAML mapping key") @@ -186,6 +192,72 @@ func inspectYAMLNode(node *yaml.Node, depth int, nodes *int, bounds limits.Limit return nil } +func validateManifestNodeShape(document *yaml.Node) error { + if document.Kind != yaml.DocumentNode || len(document.Content) != 1 { + return errors.New("manifest must contain one YAML document node") + } + root := document.Content[0] + if err := requireMappingNode(root, "manifest root"); err != nil { + return err + } + for index := 0; index < len(root.Content); index += 2 { + key, value := root.Content[index], root.Content[index+1] + switch key.Value { + case "version": + if err := requireScalarTag(value, "!!int", "version"); err != nil { + return err + } + case "scenario", "fixtures": + if err := requireScalarTag(value, "!!str", key.Value); err != nil { + return err + } + case "outputs": + if err := validateStringMapping(value, "outputs", "gif", "png"); err != nil { + return err + } + case "readme": + if err := validateStringMapping(value, "readme", "path", "alt"); err != nil { + return err + } + } + } + return nil +} + +func validateStringMapping(node *yaml.Node, name string, stringFields ...string) error { + if err := requireMappingNode(node, name); err != nil { + return err + } + wanted := make(map[string]struct{}, len(stringFields)) + for _, field := range stringFields { + wanted[field] = struct{}{} + } + for index := 0; index < len(node.Content); index += 2 { + key, value := node.Content[index], node.Content[index+1] + if _, ok := wanted[key.Value]; !ok { + continue + } + if err := requireScalarTag(value, "!!str", name+"."+key.Value); err != nil { + return err + } + } + return nil +} + +func requireMappingNode(node *yaml.Node, name string) error { + if node.Kind != yaml.MappingNode || node.ShortTag() != "!!map" { + return fmt.Errorf("%s must be a mapping", name) + } + return nil +} + +func requireScalarTag(node *yaml.Node, tag, name string) error { + if node.Kind != yaml.ScalarNode || node.ShortTag() != tag { + return fmt.Errorf("%s must use YAML tag %s", name, tag) + } + return nil +} + func openInput(root Reader, field, path string, wantDirectory bool) (*os.File, error) { file, err := root.OpenRead(path) if err != nil { diff --git a/tools/readme-terminal-demo/internal/manifest/manifest_test.go b/tools/readme-terminal-demo/internal/manifest/manifest_test.go index c1f0a92d8..138ed8bfd 100644 --- a/tools/readme-terminal-demo/internal/manifest/manifest_test.go +++ b/tools/readme-terminal-demo/internal/manifest/manifest_test.go @@ -171,6 +171,64 @@ func TestDecodeRejectsNestedAndWrongShapes(t *testing.T) { } } +func TestDecodeRejectsWrongManifestScalarTags(t *testing.T) { + t.Run("version", func(t *testing.T) { + tests := map[string]string{ + "fractional": "1.9", + "explicit float one": "!!float 1.0", + } + for name, replacement := range tests { + t.Run(name, func(t *testing.T) { + input := strings.Replace(validManifestYAML, "version: 1", "version: "+replacement, 1) + _, err := Decode(strings.NewReader(input), limits.V1().ManifestBytes) + requireManifestFailure(t, err, failure.InvalidContract) + }) + } + }) + + fields := []struct { + name string + line string + prefix string + }{ + {name: "scenario", line: "scenario: .github/demos/readme.tape", prefix: "scenario: "}, + {name: "fixtures", line: "fixtures: .github/demos/fixtures", prefix: "fixtures: "}, + {name: "outputs.gif", line: " gif: docs/assets/readme-demo.gif", prefix: " gif: "}, + {name: "outputs.png", line: " png: docs/assets/readme-demo.png", prefix: " png: "}, + {name: "readme.path", line: " path: docs/README.md", prefix: " path: "}, + {name: "readme.alt", line: " alt: Short description of the behavior shown in the terminal demo.", prefix: " alt: "}, + } + wrongValues := map[string]string{ + "bool": "true", + "number": "42", + "null": "null", + } + for _, field := range fields { + for kind, replacement := range wrongValues { + t.Run(field.name+"/"+kind, func(t *testing.T) { + input := strings.Replace(validManifestYAML, field.line, field.prefix+replacement, 1) + _, err := Decode(strings.NewReader(input), limits.V1().ManifestBytes) + requireManifestFailure(t, err, failure.InvalidContract) + }) + } + } + + for name, input := range map[string]string{ + "non-string root key": strings.Replace(validManifestYAML, "version: 1", "!!int 1: 1", 1), + "readme is not a mapping": strings.Replace( + validManifestYAML, + "readme:\n path: docs/README.md\n alt: Short description of the behavior shown in the terminal demo.", + "readme: true", + 1, + ), + } { + t.Run(name, func(t *testing.T) { + _, err := Decode(strings.NewReader(input), limits.V1().ManifestBytes) + requireManifestFailure(t, err, failure.InvalidContract) + }) + } +} + func TestDecodeEnforcesExactByteBoundsAndV1Clamp(t *testing.T) { if _, err := Decode(strings.NewReader(validManifestYAML), int64(len(validManifestYAML))); err != nil { t.Fatalf("Decode(exact caller byte bound) error = %v", err) @@ -336,6 +394,25 @@ func TestValidateRejectsEveryAltControlClass(t *testing.T) { } } +func TestValidateRequiresVisibleAltContent(t *testing.T) { + for name, alt := range map[string]string{ + "format only": "\u200b", + "bidi format only": "\u202e", + } { + t.Run(name, func(t *testing.T) { + value := validManifest() + value.Readme.Alt = alt + requireManifestFailure(t, Validate(value), failure.InvalidContract) + }) + } + + value := validManifest() + value.Readme.Alt = "Visible\u200dtext" + if err := Validate(value); err != nil { + t.Fatalf("Validate(visible text with ZWJ) error = %v", err) + } +} + func TestLoadRequiresInputsButNotOutputs(t *testing.T) { root := newFileMapReader(t) got, err := Load(root, "manifest.yml") @@ -580,13 +657,21 @@ func compileManifestSchema(t *testing.T) *jsonschema.Schema { if len(text) > limits.V1().AltTextBytes { return errors.New("alt text exceeds UTF-8 byte limit") } - if !utf8.ValidString(text) || strings.TrimSpace(text) == "" { - return errors.New("alt text must be non-empty plain UTF-8 text") + if !utf8.ValidString(text) { + return errors.New("alt text must be valid UTF-8") } + hasContent := false for _, character := range text { if unicode.IsControl(character) || character == '\u2028' || character == '\u2029' { return errors.New("alt text must be one line without controls") } + if !unicode.IsSpace(character) && + !unicode.In(character, unicode.Zs, unicode.Zl, unicode.Zp, unicode.Cf) { + hasContent = true + } + } + if !hasContent { + return errors.New("alt text must contain visible content") } return nil }, diff --git a/tools/readme-terminal-demo/internal/manifest/validate.go b/tools/readme-terminal-demo/internal/manifest/validate.go index 1bf17ec8e..ecca31c4d 100644 --- a/tools/readme-terminal-demo/internal/manifest/validate.go +++ b/tools/readme-terminal-demo/internal/manifest/validate.go @@ -102,13 +102,21 @@ func validateAltText(value string) error { if len(value) > limits.V1().AltTextBytes { return invalidManifest(errors.New("alt text exceeds byte limit")) } - if !utf8.ValidString(value) || strings.TrimSpace(value) == "" { - return invalidManifest(errors.New("alt text must be non-empty plain UTF-8 text")) + if !utf8.ValidString(value) { + return invalidManifest(errors.New("alt text must be valid UTF-8")) } + hasContent := false for _, character := range value { if unicode.IsControl(character) || character == '\u2028' || character == '\u2029' { return invalidManifest(errors.New("alt text must be a single line without control characters")) } + if !unicode.IsSpace(character) && + !unicode.In(character, unicode.Zs, unicode.Zl, unicode.Zp, unicode.Cf) { + hasContent = true + } + } + if !hasContent { + return invalidManifest(errors.New("alt text must contain visible content")) } return nil } diff --git a/tools/readme-terminal-demo/manifest.schema.json b/tools/readme-terminal-demo/manifest.schema.json index ee9424332..c9bc009b8 100644 --- a/tools/readme-terminal-demo/manifest.schema.json +++ b/tools/readme-terminal-demo/manifest.schema.json @@ -71,7 +71,7 @@ "$ref": "#/$defs/normalizedPath" }, { - "pattern": "^\\.github/demos/.+\\.tape$" + "pattern": "^\\.github/demos/([^/]+/)*[^/]+\\.tape$" } ] }, @@ -91,7 +91,7 @@ "$ref": "#/$defs/normalizedPath" }, { - "pattern": "^docs/assets/.+\\.gif$" + "pattern": "^docs/assets/([^/]+/)*[^/]+\\.gif$" } ] }, @@ -101,7 +101,7 @@ "$ref": "#/$defs/normalizedPath" }, { - "pattern": "^docs/assets/.+\\.png$" + "pattern": "^docs/assets/([^/]+/)*[^/]+\\.png$" } ] }, @@ -111,7 +111,7 @@ "$ref": "#/$defs/normalizedPath" }, { - "pattern": "^(README\\.md|docs/.+\\.(md|markdown))$" + "pattern": "^(README\\.md|docs/([^/]+/)*[^/]+\\.(md|markdown))$" } ] } diff --git a/tools/readme-terminal-demo/testdata/invalid/alt-bidi-format-only.yml b/tools/readme-terminal-demo/testdata/invalid/alt-bidi-format-only.yml new file mode 100644 index 000000000..2108d3c89 --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/alt-bidi-format-only.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: .github/demos/readme.tape +fixtures: .github/demos/fixtures +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/readme-demo.png +readme: + path: docs/README.md + alt: "\u202E" diff --git a/tools/readme-terminal-demo/testdata/invalid/alt-bool.yml b/tools/readme-terminal-demo/testdata/invalid/alt-bool.yml new file mode 100644 index 000000000..07ed485d8 --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/alt-bool.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: .github/demos/readme.tape +fixtures: .github/demos/fixtures +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/readme-demo.png +readme: + path: docs/README.md + alt: true diff --git a/tools/readme-terminal-demo/testdata/invalid/alt-format-only.yml b/tools/readme-terminal-demo/testdata/invalid/alt-format-only.yml new file mode 100644 index 000000000..8c2f7fab5 --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/alt-format-only.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: .github/demos/readme.tape +fixtures: .github/demos/fixtures +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/readme-demo.png +readme: + path: docs/README.md + alt: "\u200B" diff --git a/tools/readme-terminal-demo/testdata/invalid/alt-null.yml b/tools/readme-terminal-demo/testdata/invalid/alt-null.yml new file mode 100644 index 000000000..2a654c263 --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/alt-null.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: .github/demos/readme.tape +fixtures: .github/demos/fixtures +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/readme-demo.png +readme: + path: docs/README.md + alt: null diff --git a/tools/readme-terminal-demo/testdata/invalid/alt-number.yml b/tools/readme-terminal-demo/testdata/invalid/alt-number.yml new file mode 100644 index 000000000..8c5dfb57d --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/alt-number.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: .github/demos/readme.tape +fixtures: .github/demos/fixtures +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/readme-demo.png +readme: + path: docs/README.md + alt: 42 diff --git a/tools/readme-terminal-demo/testdata/invalid/fixtures-bool.yml b/tools/readme-terminal-demo/testdata/invalid/fixtures-bool.yml new file mode 100644 index 000000000..0b83c5a3e --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/fixtures-bool.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: .github/demos/readme.tape +fixtures: true +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/readme-demo.png +readme: + path: docs/README.md + alt: Boolean fixtures. diff --git a/tools/readme-terminal-demo/testdata/invalid/fixtures-null.yml b/tools/readme-terminal-demo/testdata/invalid/fixtures-null.yml new file mode 100644 index 000000000..16f78fa7c --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/fixtures-null.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: .github/demos/readme.tape +fixtures: null +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/readme-demo.png +readme: + path: docs/README.md + alt: Null fixtures. diff --git a/tools/readme-terminal-demo/testdata/invalid/fixtures-number.yml b/tools/readme-terminal-demo/testdata/invalid/fixtures-number.yml new file mode 100644 index 000000000..5d2c5310e --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/fixtures-number.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: .github/demos/readme.tape +fixtures: 42 +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/readme-demo.png +readme: + path: docs/README.md + alt: Numeric fixtures. diff --git a/tools/readme-terminal-demo/testdata/invalid/gif-nested-empty-stem.yml b/tools/readme-terminal-demo/testdata/invalid/gif-nested-empty-stem.yml new file mode 100644 index 000000000..b6a3b30c9 --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/gif-nested-empty-stem.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: .github/demos/readme.tape +fixtures: .github/demos/fixtures +outputs: + gif: docs/assets/nested/.gif + png: docs/assets/readme-demo.png +readme: + path: docs/README.md + alt: Nested GIF with an empty filename stem. diff --git a/tools/readme-terminal-demo/testdata/invalid/output-gif-bool.yml b/tools/readme-terminal-demo/testdata/invalid/output-gif-bool.yml new file mode 100644 index 000000000..9f7e492c3 --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/output-gif-bool.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: .github/demos/readme.tape +fixtures: .github/demos/fixtures +outputs: + gif: true + png: docs/assets/readme-demo.png +readme: + path: docs/README.md + alt: Boolean GIF output. diff --git a/tools/readme-terminal-demo/testdata/invalid/output-gif-null.yml b/tools/readme-terminal-demo/testdata/invalid/output-gif-null.yml new file mode 100644 index 000000000..b5f39c3ba --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/output-gif-null.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: .github/demos/readme.tape +fixtures: .github/demos/fixtures +outputs: + gif: null + png: docs/assets/readme-demo.png +readme: + path: docs/README.md + alt: Null GIF output. diff --git a/tools/readme-terminal-demo/testdata/invalid/output-gif-number.yml b/tools/readme-terminal-demo/testdata/invalid/output-gif-number.yml new file mode 100644 index 000000000..5cd4f0942 --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/output-gif-number.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: .github/demos/readme.tape +fixtures: .github/demos/fixtures +outputs: + gif: 42 + png: docs/assets/readme-demo.png +readme: + path: docs/README.md + alt: Numeric GIF output. diff --git a/tools/readme-terminal-demo/testdata/invalid/output-png-bool.yml b/tools/readme-terminal-demo/testdata/invalid/output-png-bool.yml new file mode 100644 index 000000000..007a24d58 --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/output-png-bool.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: .github/demos/readme.tape +fixtures: .github/demos/fixtures +outputs: + gif: docs/assets/readme-demo.gif + png: true +readme: + path: docs/README.md + alt: Boolean PNG output. diff --git a/tools/readme-terminal-demo/testdata/invalid/output-png-null.yml b/tools/readme-terminal-demo/testdata/invalid/output-png-null.yml new file mode 100644 index 000000000..ddd8c28a8 --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/output-png-null.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: .github/demos/readme.tape +fixtures: .github/demos/fixtures +outputs: + gif: docs/assets/readme-demo.gif + png: null +readme: + path: docs/README.md + alt: Null PNG output. diff --git a/tools/readme-terminal-demo/testdata/invalid/output-png-number.yml b/tools/readme-terminal-demo/testdata/invalid/output-png-number.yml new file mode 100644 index 000000000..801344778 --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/output-png-number.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: .github/demos/readme.tape +fixtures: .github/demos/fixtures +outputs: + gif: docs/assets/readme-demo.gif + png: 42 +readme: + path: docs/README.md + alt: Numeric PNG output. diff --git a/tools/readme-terminal-demo/testdata/invalid/png-nested-empty-stem.yml b/tools/readme-terminal-demo/testdata/invalid/png-nested-empty-stem.yml new file mode 100644 index 000000000..535da63a8 --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/png-nested-empty-stem.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: .github/demos/readme.tape +fixtures: .github/demos/fixtures +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/nested/.png +readme: + path: docs/README.md + alt: Nested PNG with an empty filename stem. diff --git a/tools/readme-terminal-demo/testdata/invalid/readme-nested-empty-stem.yml b/tools/readme-terminal-demo/testdata/invalid/readme-nested-empty-stem.yml new file mode 100644 index 000000000..f804c8f26 --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/readme-nested-empty-stem.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: .github/demos/readme.tape +fixtures: .github/demos/fixtures +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/readme-demo.png +readme: + path: docs/guides/.md + alt: Nested README with an empty filename stem. diff --git a/tools/readme-terminal-demo/testdata/invalid/readme-path-bool.yml b/tools/readme-terminal-demo/testdata/invalid/readme-path-bool.yml new file mode 100644 index 000000000..121ecd5fa --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/readme-path-bool.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: .github/demos/readme.tape +fixtures: .github/demos/fixtures +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/readme-demo.png +readme: + path: true + alt: Boolean README path. diff --git a/tools/readme-terminal-demo/testdata/invalid/readme-path-null.yml b/tools/readme-terminal-demo/testdata/invalid/readme-path-null.yml new file mode 100644 index 000000000..2197a15f3 --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/readme-path-null.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: .github/demos/readme.tape +fixtures: .github/demos/fixtures +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/readme-demo.png +readme: + path: null + alt: Null README path. diff --git a/tools/readme-terminal-demo/testdata/invalid/readme-path-number.yml b/tools/readme-terminal-demo/testdata/invalid/readme-path-number.yml new file mode 100644 index 000000000..8ee8b5b8f --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/readme-path-number.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: .github/demos/readme.tape +fixtures: .github/demos/fixtures +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/readme-demo.png +readme: + path: 42 + alt: Numeric README path. diff --git a/tools/readme-terminal-demo/testdata/invalid/scenario-bool.yml b/tools/readme-terminal-demo/testdata/invalid/scenario-bool.yml new file mode 100644 index 000000000..cb6c3b29f --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/scenario-bool.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: true +fixtures: .github/demos/fixtures +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/readme-demo.png +readme: + path: docs/README.md + alt: Boolean scenario. diff --git a/tools/readme-terminal-demo/testdata/invalid/scenario-nested-empty-stem.yml b/tools/readme-terminal-demo/testdata/invalid/scenario-nested-empty-stem.yml new file mode 100644 index 000000000..38e252908 --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/scenario-nested-empty-stem.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: .github/demos/nested/.tape +fixtures: .github/demos/fixtures +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/readme-demo.png +readme: + path: docs/README.md + alt: Nested scenario with an empty filename stem. diff --git a/tools/readme-terminal-demo/testdata/invalid/scenario-null.yml b/tools/readme-terminal-demo/testdata/invalid/scenario-null.yml new file mode 100644 index 000000000..27c3b6d7a --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/scenario-null.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: null +fixtures: .github/demos/fixtures +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/readme-demo.png +readme: + path: docs/README.md + alt: Null scenario. diff --git a/tools/readme-terminal-demo/testdata/invalid/scenario-number.yml b/tools/readme-terminal-demo/testdata/invalid/scenario-number.yml new file mode 100644 index 000000000..d0e1ab6c0 --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/scenario-number.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: 42 +fixtures: .github/demos/fixtures +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/readme-demo.png +readme: + path: docs/README.md + alt: Numeric scenario. diff --git a/tools/readme-terminal-demo/testdata/invalid/version-fractional.yml b/tools/readme-terminal-demo/testdata/invalid/version-fractional.yml new file mode 100644 index 000000000..e858fbf89 --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/version-fractional.yml @@ -0,0 +1,9 @@ +version: 1.9 +scenario: .github/demos/readme.tape +fixtures: .github/demos/fixtures +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/readme-demo.png +readme: + path: docs/README.md + alt: Fractional version. diff --git a/tools/readme-terminal-demo/testdata/valid/manifest-visible-zwj.yml b/tools/readme-terminal-demo/testdata/valid/manifest-visible-zwj.yml new file mode 100644 index 000000000..cd278bb9b --- /dev/null +++ b/tools/readme-terminal-demo/testdata/valid/manifest-visible-zwj.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: .github/demos/readme.tape +fixtures: .github/demos/fixtures +outputs: + gif: docs/assets/readme-demo.gif + png: docs/assets/readme-demo.png +readme: + path: docs/README.md + alt: "Visible\u200Dtext" From 56312bc38f02520e7cae3ac34eb0f39b84573d64 Mon Sep 17 00:00:00 2001 From: Sall Date: Sat, 25 Jul 2026 06:46:25 +0100 Subject: [PATCH 5/6] fix(terminal-demo): address Copilot review findings on PR #460 - go.mod: the language version directive must be major.minor; move the exact patch pin to a separate toolchain directive. - manifest.schema.json: the alt-text and path patterns only excluded ASCII controls, so a validator that does not implement the custom z-shell-alt-bytes/z-shell-path-bytes formats could accept C1 controls and U+2028/U+2029 that the Go validators already reject. Extend both patterns to match unicode.IsControl's actual coverage. - exec_linux.go: validateExecPath allowed non-normalized absolute paths (//, /./, /../). Reject anything path.Clean would change, matching the same check already used for manifest paths. --- tools/readme-terminal-demo/go.mod | 4 +++- .../internal/sandbox/exec_linux.go | 10 +++++++--- .../internal/sandbox/launcher_internal_test.go | 2 ++ tools/readme-terminal-demo/manifest.schema.json | 4 ++-- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/tools/readme-terminal-demo/go.mod b/tools/readme-terminal-demo/go.mod index eabb035a9..8ff231c26 100644 --- a/tools/readme-terminal-demo/go.mod +++ b/tools/readme-terminal-demo/go.mod @@ -1,6 +1,8 @@ module github.com/z-shell/.github/tools/readme-terminal-demo -go 1.26.5 +go 1.26 + +toolchain go1.26.5 require ( github.com/landlock-lsm/go-landlock v0.9.0 diff --git a/tools/readme-terminal-demo/internal/sandbox/exec_linux.go b/tools/readme-terminal-demo/internal/sandbox/exec_linux.go index 43d1aaa1d..18e3c20ed 100644 --- a/tools/readme-terminal-demo/internal/sandbox/exec_linux.go +++ b/tools/readme-terminal-demo/internal/sandbox/exec_linux.go @@ -2,6 +2,7 @@ package sandbox import ( "fmt" + "path" "runtime" "strings" "unsafe" @@ -101,12 +102,15 @@ func ownedCStringVector(values []string) ([][]byte, []*byte) { return storage, vector } -func validateExecPath(path string) error { - if path == "" || strings.ContainsRune(path, 0) || !strings.HasPrefix(path, "/") || path != strings.TrimSuffix(path, "/") { +func validateExecPath(target string) error { + if target == "" || strings.ContainsRune(target, 0) || !strings.HasPrefix(target, "/") || target != strings.TrimSuffix(target, "/") { return fmt.Errorf("%w: invalid target path", ErrRestrictedExec) } + if path.Clean(target) != target { + return fmt.Errorf("%w: target path is not normalized", ErrRestrictedExec) + } var stat unix.Stat_t - if err := unix.Fstatat(unix.AT_FDCWD, path, &stat, unix.AT_SYMLINK_NOFOLLOW); err != nil { + if err := unix.Fstatat(unix.AT_FDCWD, target, &stat, unix.AT_SYMLINK_NOFOLLOW); err != nil { return fmt.Errorf("%w: inspect target", ErrRestrictedExec) } if stat.Mode&unix.S_IFMT != unix.S_IFREG || stat.Mode&0o111 == 0 { diff --git a/tools/readme-terminal-demo/internal/sandbox/launcher_internal_test.go b/tools/readme-terminal-demo/internal/sandbox/launcher_internal_test.go index e6a93b707..c1ee8e6f5 100644 --- a/tools/readme-terminal-demo/internal/sandbox/launcher_internal_test.go +++ b/tools/readme-terminal-demo/internal/sandbox/launcher_internal_test.go @@ -648,6 +648,7 @@ func TestPrepareExecVectors(t *testing.T) { t.Fatalf("environment storage aliased caller slice: %q", got) } + traversalPath := filepath.Dir(executable) + "/./" + filepath.Base(executable) invalid := []struct { name string spec ExecSpec @@ -656,6 +657,7 @@ func TestPrepareExecVectors(t *testing.T) { {name: "argv-NUL", spec: ExecSpec{Path: executable, Args: []string{executable, "bad\x00arg"}}}, {name: "environment-NUL", spec: ExecSpec{Path: executable, Args: []string{executable}, Env: []string{"BAD=bad\x00value"}}}, {name: "duplicate-environment", spec: ExecSpec{Path: executable, Args: []string{executable}, Env: []string{"DUP=one", "DUP=two"}}}, + {name: "path-not-normalized", spec: ExecSpec{Path: traversalPath, Args: []string{traversalPath}}}, } for _, test := range invalid { t.Run(test.name, func(t *testing.T) { diff --git a/tools/readme-terminal-demo/manifest.schema.json b/tools/readme-terminal-demo/manifest.schema.json index c9bc009b8..7955fa162 100644 --- a/tools/readme-terminal-demo/manifest.schema.json +++ b/tools/readme-terminal-demo/manifest.schema.json @@ -41,7 +41,7 @@ "minLength": 1, "maxLength": 512, "format": "z-shell-alt-bytes", - "pattern": "^[^\\x00-\\x1F\\x7F]*\\S[^\\x00-\\x1F\\x7F]*$" + "pattern": "^[^\\x00-\\x1F\\x7F\u0080-\u009f\u2028\u2029]*\\S[^\\x00-\\x1F\\x7F\u0080-\u009f\u2028\u2029]*$" } } } @@ -60,7 +60,7 @@ }, { "not": { - "pattern": "//|\\\\|/$|[\\x00-\\x1F\\x7F]" + "pattern": "//|\\\\|/$|[\\x00-\\x1F\\x7F\u0080-\u009f]" } } ] From 3562e5cf7af6bd4dc8ec09759717303db18c7ed7 Mon Sep 17 00:00:00 2001 From: Sall Date: Sat, 25 Jul 2026 08:37:11 +0100 Subject: [PATCH 6/6] fix(terminal-demo): thread manifest error fields, distinguish escaped-pipe-holder invalidManifest() always passed an empty Field to failure.E, unlike unsafeManifestPath which already threads one through. Give it a field parameter and populate it at every call site that has one in scope (path opens, readme.alt validation), leaving it empty only where an error genuinely spans the whole document (empty body, multiple YAML documents, etc). Added a Field assertion to the existing UTF-8 byte limit test to cover both the newly-fixed alt-text path and the already-correct scenario path. mapChildOutcome collapsed every non-nil childErr, including exec.ErrWaitDelay from an escaped pipe holder still holding stdio open past RunRestrictedChild's wait delay, into the same generic "restricted child cleanup failed" message. Distinguish it with its own internal message (Class/Stage/Rule stay the same, so the public failure contract is unchanged) and added a test asserting both the unwrapped message and the public Error() string. --- .../cmd/readme-terminal-demo/main.go | 3 ++ .../cmd/readme-terminal-demo/main_test.go | 16 ++++++++ .../internal/manifest/decode.go | 40 +++++++++---------- .../internal/manifest/manifest_test.go | 18 ++++++--- .../internal/manifest/validate.go | 12 +++--- 5 files changed, 57 insertions(+), 32 deletions(-) diff --git a/tools/readme-terminal-demo/cmd/readme-terminal-demo/main.go b/tools/readme-terminal-demo/cmd/readme-terminal-demo/main.go index adeec0504..e4cca2597 100644 --- a/tools/readme-terminal-demo/cmd/readme-terminal-demo/main.go +++ b/tools/readme-terminal-demo/cmd/readme-terminal-demo/main.go @@ -156,6 +156,9 @@ func mapChildOutcome(outcome sandbox.Outcome, childErr error) error { if outcome.TimedOut || errors.Is(childErr, sandbox.ErrProcessTimeout) { return failure.E(failure.ExecutionFailed, failure.StageCapture, "", failure.RuleCaptureTimeout, errors.New("restricted child timed out")) } + if errors.Is(childErr, exec.ErrWaitDelay) { + return failure.E(failure.ExecutionFailed, failure.StageCapture, "", failure.RuleCaptureFailed, errors.New("restricted child exited but an escaped descendant held stdio open past the wait delay")) + } if childErr != nil { return failure.E(failure.ExecutionFailed, failure.StageCapture, "", failure.RuleCaptureFailed, errors.New("restricted child cleanup failed")) } diff --git a/tools/readme-terminal-demo/cmd/readme-terminal-demo/main_test.go b/tools/readme-terminal-demo/cmd/readme-terminal-demo/main_test.go index 3bb6dd673..e0bd45eed 100644 --- a/tools/readme-terminal-demo/cmd/readme-terminal-demo/main_test.go +++ b/tools/readme-terminal-demo/cmd/readme-terminal-demo/main_test.go @@ -34,6 +34,22 @@ func TestReservedChildExitMapping(t *testing.T) { } } +func TestMapChildOutcomeDistinguishesEscapedPipeHolder(t *testing.T) { + normalExit := sandbox.Outcome{Exited: true, ExitCode: 0} + + waitDelayErr := mapChildOutcome(normalExit, fmt.Errorf("wait restricted child: %w", exec.ErrWaitDelay)) + assertChildOutcomeMapping(t, waitDelayErr, failure.ExecutionFailed, "execution-failed: capture (capture.failed)") + if got := errors.Unwrap(waitDelayErr); got == nil || got.Error() != "restricted child exited but an escaped descendant held stdio open past the wait delay" { + t.Fatalf("wait-delay unwrapped error = %v, want the escaped-pipe-holder message", got) + } + + otherErr := mapChildOutcome(normalExit, errors.New("observe restricted child: boom")) + assertChildOutcomeMapping(t, otherErr, failure.ExecutionFailed, "execution-failed: capture (capture.failed)") + if got := errors.Unwrap(otherErr); got == nil || got.Error() != "restricted child cleanup failed" { + t.Fatalf("generic cleanup unwrapped error = %v, want the generic cleanup-failed message", got) + } +} + func TestSelfTestMapsChildOutcome(t *testing.T) { wantChild := sandbox.Child{ Path: "/trusted/readme-terminal-demo", diff --git a/tools/readme-terminal-demo/internal/manifest/decode.go b/tools/readme-terminal-demo/internal/manifest/decode.go index 28d2abf56..633b1c0ae 100644 --- a/tools/readme-terminal-demo/internal/manifest/decode.go +++ b/tools/readme-terminal-demo/internal/manifest/decode.go @@ -25,10 +25,10 @@ var coreYAMLTags = map[string]struct{}{ // Decode parses one bounded manifest document through a strict YAML preflight. func Decode(r io.Reader, maxBytes int64) (Manifest, error) { if r == nil { - return Manifest{}, invalidManifest(errors.New("manifest reader is nil")) + return Manifest{}, invalidManifest("", errors.New("manifest reader is nil")) } if maxBytes <= 0 { - return Manifest{}, invalidManifest(errors.New("manifest byte limit is invalid")) + return Manifest{}, invalidManifest("", errors.New("manifest byte limit is invalid")) } contractLimits := limits.V1() if maxBytes > contractLimits.ManifestBytes { @@ -37,39 +37,39 @@ func Decode(r io.Reader, maxBytes int64) (Manifest, error) { data, err := io.ReadAll(io.LimitReader(r, maxBytes+1)) if err != nil { - return Manifest{}, invalidManifest(fmt.Errorf("read manifest: %w", err)) + return Manifest{}, invalidManifest("", fmt.Errorf("read manifest: %w", err)) } if int64(len(data)) > maxBytes { - return Manifest{}, invalidManifest(errors.New("manifest exceeds byte limit")) + return Manifest{}, invalidManifest("", errors.New("manifest exceeds byte limit")) } decoder := yaml.NewDecoder(bytes.NewReader(data)) var document yaml.Node if err := decoder.Decode(&document); err != nil { - return Manifest{}, invalidManifest(fmt.Errorf("decode YAML node: %w", err)) + return Manifest{}, invalidManifest("", fmt.Errorf("decode YAML node: %w", err)) } if len(document.Content) == 0 { - return Manifest{}, invalidManifest(errors.New("manifest document is empty")) + return Manifest{}, invalidManifest("", errors.New("manifest document is empty")) } var extra yaml.Node if err := decoder.Decode(&extra); err == nil { - return Manifest{}, invalidManifest(errors.New("multiple YAML documents are not allowed")) + return Manifest{}, invalidManifest("", errors.New("multiple YAML documents are not allowed")) } else if !errors.Is(err, io.EOF) { - return Manifest{}, invalidManifest(fmt.Errorf("decode trailing YAML document: %w", err)) + return Manifest{}, invalidManifest("", fmt.Errorf("decode trailing YAML document: %w", err)) } if err := preflightYAML(&document, contractLimits); err != nil { - return Manifest{}, invalidManifest(err) + return Manifest{}, invalidManifest("", err) } if err := validateManifestNodeShape(&document); err != nil { - return Manifest{}, invalidManifest(err) + return Manifest{}, invalidManifest("", err) } typed := yaml.NewDecoder(bytes.NewReader(data)) typed.KnownFields(true) var value Manifest if err := typed.Decode(&value); err != nil { - return Manifest{}, invalidManifest(fmt.Errorf("decode typed manifest: %w", err)) + return Manifest{}, invalidManifest("", fmt.Errorf("decode typed manifest: %w", err)) } return value, nil } @@ -77,7 +77,7 @@ func Decode(r io.Reader, maxBytes int64) (Manifest, error) { // Load opens, decodes, validates, and proves all declared repository inputs exist. func Load(root Reader, manifestPath string) (Manifest, error) { if root == nil { - return Manifest{}, invalidManifest(errors.New("repository reader is nil")) + return Manifest{}, invalidManifest("", errors.New("repository reader is nil")) } manifestFile, err := openInput(root, "manifest", manifestPath, false) if err != nil { @@ -89,7 +89,7 @@ func Load(root Reader, manifestPath string) (Manifest, error) { return Manifest{}, decodeErr } if closeErr != nil { - return Manifest{}, invalidManifest(fmt.Errorf("close manifest: %w", closeErr)) + return Manifest{}, invalidManifest("manifest", fmt.Errorf("close manifest: %w", closeErr)) } if err := Validate(value); err != nil { return Manifest{}, err @@ -110,7 +110,7 @@ func Load(root Reader, manifestPath string) (Manifest, error) { return Manifest{}, err } if err := file.Close(); err != nil { - return Manifest{}, invalidManifest(fmt.Errorf("close %s input: %w", input.field, err)) + return Manifest{}, invalidManifest(input.field, fmt.Errorf("close %s input: %w", input.field, err)) } } return value, nil @@ -268,28 +268,28 @@ func openInput(root Reader, field, path string, wantDirectory bool) (*os.File, e if errors.As(err, &structured) { return nil, structured } - return nil, invalidManifest(fmt.Errorf("open %s input: %w", field, err)) + return nil, invalidManifest(field, fmt.Errorf("open %s input: %w", field, err)) } if file == nil { - return nil, invalidManifest(fmt.Errorf("open %s input: nil file", field)) + return nil, invalidManifest(field, fmt.Errorf("open %s input: nil file", field)) } info, err := file.Stat() if err != nil { _ = file.Close() - return nil, invalidManifest(fmt.Errorf("inspect %s input: %w", field, err)) + return nil, invalidManifest(field, fmt.Errorf("inspect %s input: %w", field, err)) } if wantDirectory != info.IsDir() || (!wantDirectory && !info.Mode().IsRegular()) { _ = file.Close() - return nil, invalidManifest(fmt.Errorf("%s input has the wrong file type", field)) + return nil, invalidManifest(field, fmt.Errorf("%s input has the wrong file type", field)) } return file, nil } -func invalidManifest(err error) error { +func invalidManifest(field string, err error) error { return failure.E( failure.InvalidContract, failure.StageManifest, - "", + field, failure.RuleManifestInvalid, err, ) diff --git a/tools/readme-terminal-demo/internal/manifest/manifest_test.go b/tools/readme-terminal-demo/internal/manifest/manifest_test.go index 138ed8bfd..5a643e76a 100644 --- a/tools/readme-terminal-demo/internal/manifest/manifest_test.go +++ b/tools/readme-terminal-demo/internal/manifest/manifest_test.go @@ -579,30 +579,36 @@ func TestSchemaParityFixtures(t *testing.T) { func TestSchemaAndTypedValidationUseUTF8ByteLimits(t *testing.T) { schema := compileManifestSchema(t) tests := []struct { - name string - mutate func(*Manifest) - class failure.Class + name string + mutate func(*Manifest) + class failure.Class + wantField string }{ { name: "alt text", mutate: func(value *Manifest) { value.Readme.Alt = strings.Repeat("é", limits.V1().AltTextBytes/2+1) }, - class: failure.InvalidContract, + class: failure.InvalidContract, + wantField: "readme.alt", }, { name: "path", mutate: func(value *Manifest) { value.Scenario = ".github/demos/" + strings.Repeat("é", limits.V1().PathBytes/2) + ".tape" }, - class: failure.UnsafePath, + class: failure.UnsafePath, + wantField: "scenario", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { value := validManifest() test.mutate(&value) - requireManifestFailure(t, Validate(value), test.class) + structured := requireManifestFailure(t, Validate(value), test.class) + if structured.Field != test.wantField { + t.Fatalf("failure field = %q, want %q", structured.Field, test.wantField) + } if err := schema.Validate(manifestDocument(value)); err == nil { t.Fatal("schema accepted a value over its UTF-8 byte limit") } diff --git a/tools/readme-terminal-demo/internal/manifest/validate.go b/tools/readme-terminal-demo/internal/manifest/validate.go index ecca31c4d..b59309424 100644 --- a/tools/readme-terminal-demo/internal/manifest/validate.go +++ b/tools/readme-terminal-demo/internal/manifest/validate.go @@ -14,11 +14,11 @@ import ( // Validate checks only the structural manifest contract; it does not open paths. func Validate(value Manifest) error { if value.Version != 1 { - return invalidManifest(errors.New("manifest version must be 1")) + return invalidManifest("version", errors.New("manifest version must be 1")) } if value.Scenario == "" || value.Fixtures == "" || value.Outputs.GIF == "" || value.Outputs.PNG == "" || value.Readme.Path == "" || value.Readme.Alt == "" { - return invalidManifest(errors.New("manifest field is required")) + return invalidManifest("", errors.New("manifest field is required")) } if err := validateRootedPath("scenario", value.Scenario, ".github/demos", ".tape"); err != nil { @@ -100,15 +100,15 @@ func validateRepositoryPath(field, value string) error { func validateAltText(value string) error { if len(value) > limits.V1().AltTextBytes { - return invalidManifest(errors.New("alt text exceeds byte limit")) + return invalidManifest("readme.alt", errors.New("alt text exceeds byte limit")) } if !utf8.ValidString(value) { - return invalidManifest(errors.New("alt text must be valid UTF-8")) + return invalidManifest("readme.alt", errors.New("alt text must be valid UTF-8")) } hasContent := false for _, character := range value { if unicode.IsControl(character) || character == '\u2028' || character == '\u2029' { - return invalidManifest(errors.New("alt text must be a single line without control characters")) + return invalidManifest("readme.alt", errors.New("alt text must be a single line without control characters")) } if !unicode.IsSpace(character) && !unicode.In(character, unicode.Zs, unicode.Zl, unicode.Zp, unicode.Cf) { @@ -116,7 +116,7 @@ func validateAltText(value string) error { } } if !hasContent { - return invalidManifest(errors.New("alt text must contain visible content")) + return invalidManifest("readme.alt", errors.New("alt text must contain visible content")) } return nil }