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..adeec0504 --- /dev/null +++ b/tools/readme-terminal-demo/cmd/readme-terminal-demo/main.go @@ -0,0 +1,753 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "errors" + "fmt" + "io" + "net" + "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" +) + +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 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() { + 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"), + AllowPrivateDevptsPTY: true, + }, + } + 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 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")) + } + 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")) + } + + 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 { + 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..3bb6dd673 --- /dev/null +++ b/tools/readme-terminal-demo/cmd/readme-terminal-demo/main_test.go @@ -0,0 +1,1124 @@ +package main + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "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" + testBrowserResolutionChroot = "README_TERMINAL_DEMO_TEST_BROWSER_CHROOT" + testBrowserResolutionRoot = "README_TERMINAL_DEMO_TEST_BROWSER_ROOT" +) + +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) + } + 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 + }, + }) + 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 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) + + 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 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() + + 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..eabb035a9 --- /dev/null +++ b/tools/readme-terminal-demo/go.mod @@ -0,0 +1,16 @@ +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 ( + 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/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/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..28d2abf56 --- /dev/null +++ b/tools/readme-terminal-demo/internal/manifest/decode.go @@ -0,0 +1,296 @@ +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) + } + if err := validateManifestNodeShape(&document); 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") + } + 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") + } + keys[fingerprint] = struct{}{} + } + } + for _, child := range node.Content { + if err := inspectYAMLNode(child, depth, nodes, bounds); err != nil { + return err + } + } + 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 { + 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..138ed8bfd --- /dev/null +++ b/tools/readme-terminal-demo/internal/manifest/manifest_test.go @@ -0,0 +1,841 @@ +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 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) + } + _, 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 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") + 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) { + 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 + }, + }) + 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..ecca31c4d --- /dev/null +++ b/tools/readme-terminal-demo/internal/manifest/validate.go @@ -0,0 +1,132 @@ +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) { + 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 +} + +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/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..94b8aceff --- /dev/null +++ b/tools/readme-terminal-demo/internal/sandbox/landlock_linux.go @@ -0,0 +1,363 @@ +// Package sandbox provides the fail-closed child boundary used by renderers. +package sandbox + +import ( + "errors" + "fmt" + "path/filepath" + "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 + privateDevptsPTYV3Access = llsyscall.AccessFSWriteFile + devptsSuperMagic = 0x1cd1 +) + +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 + // 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. +type ExecSpec struct { + Path string + Args []string + Env []string + Policy Policy +} + +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) +} + +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) + } + 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 + } + + 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 + } + + 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)) + 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}) + } + 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 fmt.Errorf("%w: open rule source", ErrLandlockV3Unavailable) + } + var stat unix.Stat_t + if statErr := ops.fstat(fd, &stat); statErr != nil { + _ = ops.closeFD(fd) + 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 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 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 fmt.Errorf("%w: add path rule", ErrLandlockV3Unavailable) + } + if closeErr := ops.closeFD(fd); closeErr != nil { + 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 failWithPrivatePTY(fmt.Errorf("%w: set ruleset CLOEXEC", ErrLandlockV3Unavailable)) + } + cloexec, err := ops.getCLOEXEC(rulesetFD) + if err != nil || !cloexec { + 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) || !filepath.IsAbs(path) || filepath.Clean(path) != 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 + } + 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 new file mode 100644 index 000000000..cbfb0ecad --- /dev/null +++ b/tools/readme-terminal-demo/internal/sandbox/landlock_syscall_linux.go @@ -0,0 +1,77 @@ +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) + 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) + closeFD func(int) error +} + +var productionPrepareV3Ops = prepareV3Ops{ + getABI: llsyscall.LandlockGetABIVersion, + createRuleset: llsyscall.LandlockCreateRuleset, + 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) + 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, +} + +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 new file mode 100644 index 000000000..e6a93b707 --- /dev/null +++ b/tools/readme-terminal-demo/internal/sandbox/launcher_internal_test.go @@ -0,0 +1,1360 @@ +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"}, + 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) + } + + 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/pts", allowed: llsyscall.AccessFSWriteFile}, + {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) + } + 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) + } + + for _, opened := range fake.opened { + 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{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 { + t.Fatalf("CLOEXEC set/read = %t/%t, want both true", fake.cloexecSet, fake.cloexecRead) + } + }) + } +} + +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 + 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 + fstatSeen map[int]bool + openFDs map[int]bool +} + +func newPrepareV3Fake(abi int) *prepareV3Fake { + return &prepareV3Fake{ + abi: abi, + rulesetFD: 7, + nextFD: 10, + fdPaths: make(map[int]string), + fstatSeen: make(map[int]bool), + openFDs: make(map[int]bool), + } +} + +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" || 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.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 + } + switch { + case fake.fault == "symlink": + stat.Mode = unix.S_IFLNK | 0o777 + 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 + stat.Rdev = unix.Mkdev(1, 3) + 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 || !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" || fake.fault == "add-devpts" && fake.fdPaths[attr.ParentFd] == "/dev/pts" { + 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) + 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/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..1fb42134c --- /dev/null +++ b/tools/readme-terminal-demo/internal/sandbox/sandbox_test.go @@ -0,0 +1,385 @@ +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" +( + 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" +) & +while [ ! -s "$3" ]; do sleep 0.01; done +case "$5" 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") + 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, descendantPIDPath, gatePath, 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) + } + 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("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 { + _, _ = 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) + } + 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) + 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 := requireCharacterDevice("/dev/pts/ptmx", 5, 2); 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), + ), + AllowPrivateDevptsPTY: true, + }, + }) + 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/manifest.schema.json b/tools/readme-terminal-demo/manifest.schema.json new file mode 100644 index 000000000..c9bc009b8 --- /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/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/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-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-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-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/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/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-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/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/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/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-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/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-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-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-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/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/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/smoke/readme.tape b/tools/readme-terminal-demo/testdata/smoke/readme.tape new file mode 100644 index 000000000..413e22fce --- /dev/null +++ b/tools/readme-terminal-demo/testdata/smoke/readme.tape @@ -0,0 +1,17 @@ +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 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. 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"