diff options
| author | user <user@clank> | 2026-08-19 22:49:46 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-19 22:49:57 +0200 |
| commit | 9c06e643cf5b8fe735b8088aef9ea686087e2997 (patch) | |
| tree | 6896d20a827f0e453507cb10bd95c25594ce8209 | |
| parent | parsers: fix seven bugs found by fuzzing (diff) | |
| download | slosh-9c06e643cf5b8fe735b8088aef9ea686087e2997.tar.gz slosh-9c06e643cf5b8fe735b8088aef9ea686087e2997.tar.xz slosh-9c06e643cf5b8fe735b8088aef9ea686087e2997.zip | |
fuzz: libFuzzer harness for all six parser surfaces
Targets: kdl, jsonval, expr, proto, osc5577, input — each compiled
standalone against its own .c with ASan + UBSan, no vendor deps.
Includes seed corpora from shipped configs/themes/chrome, real control
API lines, terminal escape sequences, and regression inputs from the
bugs already fixed. standalone_main.c replays files without the fuzzer
runtime for debugging and CI.
Top-level: make fuzz (run all, TIME=15 default), make fuzz-corpus
(replay seeds only, fast CI gate).
79 files changed, 1651 insertions, 0 deletions
@@ -303,6 +303,13 @@ fmt-check: ## ...or just say what is not formatted echo "run: make fmt"; exit 1; \ else echo "all $(words $(CSRC)) C and $(words $(PYSRC)) Python files formatted"; fi +fuzz: $(BIN) ## build and run all fuzz targets (TIME=15) + $(MAKE) -C tests/fuzz run-all TIME=$(or $(TIME),15) + +fuzz-corpus: ## replay fuzz seed corpora (CI-friendly, no fuzzing) + $(MAKE) -C tests/fuzz replay + $(MAKE) -C tests/fuzz corpus + hooks: ## install the pre-commit hook (formats staged C) $(Q)git config core.hooksPath .githooks @echo "core.hooksPath -> .githooks (git commit --no-verify to skip)" diff --git a/tests/fuzz/.gitignore b/tests/fuzz/.gitignore new file mode 100644 index 0000000..42f9b95 --- /dev/null +++ b/tests/fuzz/.gitignore @@ -0,0 +1,11 @@ +# Built binaries +fuzz_* +replay_* +!fuzz_*.c + +# libFuzzer findings (crashes go here, committed as regression seeds in corpus/) +findings/ + +# Grown corpora are local; only hand-written seeds and named regression +# inputs should be committed. The fuzzer names its files with hex hashes. +corpus/*/[0-9a-f][0-9a-f][0-9a-f][0-9a-f]* diff --git a/tests/fuzz/Makefile b/tests/fuzz/Makefile new file mode 100644 index 0000000..3a28dd2 --- /dev/null +++ b/tests/fuzz/Makefile @@ -0,0 +1,64 @@ +# Standalone fuzzing harness for slosh's parser surfaces. +# +# Deliberately separate from the main build: the main build uses zig cc for +# static musl linking, but libFuzzer wants clang's own runtime, and the six +# parsers under test include nothing but their own headers -- so this Makefile +# needs no vendor step, no libghostty-vt, nothing but clang and ../../src. +# +# make build every fuzz_* binary (libFuzzer + ASan + UBSan) +# make replay build every replay_* binary (same target, file-replay main) +# make corpus check the seed corpora replay cleanly (a fast CI gate) +# make run-kdl fuzz one target (TIME=60 seconds, default 30) +# make run-all fuzz every target for TIME seconds each, sequentially +# +# Findings land in findings/<target>/ and grown corpora stay in corpus/<target>/. + +CLANG ?= clang +ROOT := ../.. + +TARGETS := kdl jsonval expr proto osc5577 input +TIME ?= 30 + +# src/input.c includes <ghostty/vt.h> for key/mod constants only (it calls +# nothing), so the vendored header is enough and no library is linked. It +# exists once the main build's `make vendor` has run. +VT_INC := $(ROOT)/vendor/libghostty-vt/zig-out/include + +CFLAGS := -std=c23 -O1 -g -Wall -Wextra -Wno-unused-parameter \ + -I$(ROOT)/include -I$(VT_INC) \ + -fsanitize=address,undefined -fno-sanitize-recover=all + +FUZZ_BINS := $(TARGETS:%=fuzz_%) +REPLAY_BINS := $(TARGETS:%=replay_%) + +all: $(FUZZ_BINS) ## build libFuzzer binaries + +replay: $(REPLAY_BINS) ## build file-replay binaries (no libFuzzer runtime) + +# Each target is fuzz_<name>.c + src/<name>.c, nothing else. If that ever +# stops being true for a new target, list its extra objects explicitly. +fuzz_%: fuzz_%.c $(ROOT)/src/%.c + $(CLANG) $(CFLAGS) -fsanitize=fuzzer $^ -o $@ + +replay_%: fuzz_%.c $(ROOT)/src/%.c standalone_main.c + $(CLANG) $(CFLAGS) $^ -o $@ + +run-%: fuzz_% + @mkdir -p findings/$* corpus/$* + ./fuzz_$* -max_total_time=$(TIME) -max_len=65536 \ + -artifact_prefix=findings/$*/ corpus/$* + +run-all: $(FUZZ_BINS) ## fuzz every target for TIME seconds each + @for t in $(TARGETS); do $(MAKE) --no-print-directory run-$$t || exit 1; done + +corpus: $(REPLAY_BINS) ## replay all seed corpora (CI-friendly, no fuzzing) + @for t in $(TARGETS); do \ + files=$$(find corpus/$$t -type f 2>/dev/null); \ + if [ -n "$$files" ]; then ./replay_$$t $$files || exit 1; fi; \ + done + @echo "corpus replay: all clean" + +clean: + rm -f $(FUZZ_BINS) $(REPLAY_BINS) + +.PHONY: all replay run-all corpus clean diff --git a/tests/fuzz/README.md b/tests/fuzz/README.md new file mode 100644 index 0000000..6fb4f53 --- /dev/null +++ b/tests/fuzz/README.md @@ -0,0 +1,46 @@ +# Fuzz harness + +libFuzzer + ASan + UBSan targets for every self-contained parser surface in +slosh. Each target compiles only its own `.c` against its header — no vendor +libraries, no libghostty-vt linking — so the build is fast and the coverage +is tight. + +## Targets + +| target | source | attack surface | +|-----------|-------------------|----------------------------------------| +| `kdl` | `src/kdl.c` | config and layout files | +| `jsonval` | `src/jsonval.c` | control-API JSON (MSG_CMD on the socket) | +| `expr` | `src/expr.c` | shader expression compiler + evaluator | +| `proto` | `src/proto.c` | client↔server wire framing | +| `osc5577` | `src/osc5577.c` | pane status-bar escape sequences | +| `input` | `src/input.c` | outer-terminal keyboard/mouse/paste | + +## Quick start + +``` +cd tests/fuzz +make # build libFuzzer binaries +make run-kdl TIME=30 # fuzz one target for 30 seconds +make run-all TIME=15 # fuzz all targets, 15 seconds each +make replay # build file-replay binaries (no libFuzzer) +make corpus # replay seed corpora (CI gate, no fuzzing) +``` + +Crashes land in `findings/<target>/`; grown corpora stay in `corpus/<target>/`. + +## Reproducing a crash + +``` +./replay_kdl findings/kdl/crash-abc123 # under ASan, no fuzzer runtime +gdb --args ./replay_kdl findings/kdl/crash-abc123 +``` + +## Adding a target + +1. Write `fuzz_foo.c` with `LLVMFuzzerTestOneInput`. +2. Add `foo` to `TARGETS` in the Makefile. +3. Drop seed inputs in `corpus/foo/`. + +The Makefile convention is that `fuzz_foo.c` + `src/foo.c` compile together +with no other objects. If a new target needs more, add an explicit rule. diff --git a/tests/fuzz/corpus/expr/compare b/tests/fuzz/corpus/expr/compare new file mode 100644 index 0000000..e3ef80a --- /dev/null +++ b/tests/fuzz/corpus/expr/compare @@ -0,0 +1 @@ +(x < 3) * 10 + (y > 3) * 20 + (x <= y) * 30 + (x >= y) * 40 + (x == y) * 50 + (x != y) * 60
\ No newline at end of file diff --git a/tests/fuzz/corpus/expr/functions b/tests/fuzz/corpus/expr/functions new file mode 100644 index 0000000..db5bf1e --- /dev/null +++ b/tests/fuzz/corpus/expr/functions @@ -0,0 +1 @@ +min(max(abs(x - cols / 2), 1), 255) + clamp(y, 0, rows) * dist(x, y, curx, cury)
\ No newline at end of file diff --git a/tests/fuzz/corpus/expr/logic b/tests/fuzz/corpus/expr/logic new file mode 100644 index 0000000..775585c --- /dev/null +++ b/tests/fuzz/corpus/expr/logic @@ -0,0 +1 @@ +focused && cursor || !above ? t % 1000 : since / -7
\ No newline at end of file diff --git a/tests/fuzz/corpus/expr/nesting b/tests/fuzz/corpus/expr/nesting new file mode 100644 index 0000000..9f67db9 --- /dev/null +++ b/tests/fuzz/corpus/expr/nesting @@ -0,0 +1 @@ +((((((1 + 2) * 3) - 4) / 5) % 6))
\ No newline at end of file diff --git a/tests/fuzz/corpus/expr/regress_deep_nesting b/tests/fuzz/corpus/expr/regress_deep_nesting new file mode 100644 index 0000000..951aa65 --- /dev/null +++ b/tests/fuzz/corpus/expr/regress_deep_nesting @@ -0,0 +1 @@ +(((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((!!!!!!!(((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((!!!!!!!(((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((
\ No newline at end of file diff --git a/tests/fuzz/corpus/expr/shipped_0 b/tests/fuzz/corpus/expr/shipped_0 new file mode 100644 index 0000000..a14c1ee --- /dev/null +++ b/tests/fuzz/corpus/expr/shipped_0 @@ -0,0 +1 @@ +180
\ No newline at end of file diff --git a/tests/fuzz/corpus/expr/shipped_1 b/tests/fuzz/corpus/expr/shipped_1 new file mode 100644 index 0000000..ae4ee13 --- /dev/null +++ b/tests/fuzz/corpus/expr/shipped_1 @@ -0,0 +1 @@ +200
\ No newline at end of file diff --git a/tests/fuzz/corpus/expr/shipped_10 b/tests/fuzz/corpus/expr/shipped_10 new file mode 100644 index 0000000..0b09860 --- /dev/null +++ b/tests/fuzz/corpus/expr/shipped_10 @@ -0,0 +1 @@ +(abs(min((y == 0 ? x : x == cols - 1 ? cols - 1 + y : y == rows - 1 ? 2 * cols + rows - 3 - x : 2 * cols + 2 * rows - 4 - y), (2 * cols + 2 * rows - 4) - (y == 0 ? x : x == cols - 1 ? cols - 1 + y : y == rows - 1 ? 2 * cols + rows - 3 - x : 2 * cols + 2 * rows - 4 - y)) - (t / 18 % (cols + rows - 2))) < 3) * 220
\ No newline at end of file diff --git a/tests/fuzz/corpus/expr/shipped_11 b/tests/fuzz/corpus/expr/shipped_11 new file mode 100644 index 0000000..2969904 --- /dev/null +++ b/tests/fuzz/corpus/expr/shipped_11 @@ -0,0 +1 @@ +abs(sin(TAU * ((y == 0 ? x : x == cols - 1 ? cols - 1 + y : y == rows - 1 ? 2 * cols + rows - 3 - x : 2 * cols + 2 * rows - 4 - y)) * 3 / (2 * cols + 2 * rows - 4))) * abs(sin(t / 6)) / 255
\ No newline at end of file diff --git a/tests/fuzz/corpus/expr/shipped_12 b/tests/fuzz/corpus/expr/shipped_12 new file mode 100644 index 0000000..5677ad7 --- /dev/null +++ b/tests/fuzz/corpus/expr/shipped_12 @@ -0,0 +1 @@ +(abs(y - (t / 90) % rows) < 2) * 70
\ No newline at end of file diff --git a/tests/fuzz/corpus/expr/shipped_13 b/tests/fuzz/corpus/expr/shipped_13 new file mode 100644 index 0000000..e684a57 --- /dev/null +++ b/tests/fuzz/corpus/expr/shipped_13 @@ -0,0 +1 @@ +clamp(220 - min(min(dist(x, y, 0, 0), dist(x, y, cols - 1, 0)), min(dist(x, y, 0, rows - 1), dist(x, y, cols - 1, rows - 1))) * 55, 0, 220) * (55 + abs(t / 3 % 90 - 45)) / 100
\ No newline at end of file diff --git a/tests/fuzz/corpus/expr/shipped_14 b/tests/fuzz/corpus/expr/shipped_14 new file mode 100644 index 0000000..df40c26 --- /dev/null +++ b/tests/fuzz/corpus/expr/shipped_14 @@ -0,0 +1 @@ +clamp(235 - min(abs((y == 0 ? x : x == cols - 1 ? cols - 1 + y : y == rows - 1 ? 2 * cols + rows - 3 - x : 2 * cols + 2 * rows - 4 - y) - (t / 14 % (2 * cols + 2 * rows - 4))), (2 * cols + 2 * rows - 4) - abs((y == 0 ? x : x == cols - 1 ? cols - 1 + y : y == rows - 1 ? 2 * cols + rows - 3 - x : 2 * cols + 2 * rows - 4 - y) - (t / 14 % (2 * cols + 2 * rows - 4)))) * 30, 0, 235)
\ No newline at end of file diff --git a/tests/fuzz/corpus/expr/shipped_15 b/tests/fuzz/corpus/expr/shipped_15 new file mode 100644 index 0000000..89357dc --- /dev/null +++ b/tests/fuzz/corpus/expr/shipped_15 @@ -0,0 +1 @@ +clamp(240 - (((t / 70 + (x * 11) % 23 * 3) % rows - y + rows) % rows) * 45, 0, 240)
\ No newline at end of file diff --git a/tests/fuzz/corpus/expr/shipped_16 b/tests/fuzz/corpus/expr/shipped_16 new file mode 100644 index 0000000..715f4e9 --- /dev/null +++ b/tests/fuzz/corpus/expr/shipped_16 @@ -0,0 +1 @@ +clamp((5 - min(x, cols - 1 - x)) * 34, 0, 170)
\ No newline at end of file diff --git a/tests/fuzz/corpus/expr/shipped_17 b/tests/fuzz/corpus/expr/shipped_17 new file mode 100644 index 0000000..e5c9ef8 --- /dev/null +++ b/tests/fuzz/corpus/expr/shipped_17 @@ -0,0 +1 @@ +clamp(abs((x * 2 + t / 40) % 34 - 17) * 7 + abs((y * 3 - t / 55) % 26 - 13) * 6 - 40, 0, 170)
\ No newline at end of file diff --git a/tests/fuzz/corpus/expr/shipped_18 b/tests/fuzz/corpus/expr/shipped_18 new file mode 100644 index 0000000..bea1db2 --- /dev/null +++ b/tests/fuzz/corpus/expr/shipped_18 @@ -0,0 +1 @@ +clamp(abs(x - (t / 24) % cols) * 22, 0, 200)
\ No newline at end of file diff --git a/tests/fuzz/corpus/expr/shipped_19 b/tests/fuzz/corpus/expr/shipped_19 new file mode 100644 index 0000000..882213e --- /dev/null +++ b/tests/fuzz/corpus/expr/shipped_19 @@ -0,0 +1 @@ +clamp(abs(y - cury) * 26, 0, 190)
\ No newline at end of file diff --git a/tests/fuzz/corpus/expr/shipped_2 b/tests/fuzz/corpus/expr/shipped_2 new file mode 100644 index 0000000..3960544 --- /dev/null +++ b/tests/fuzz/corpus/expr/shipped_2 @@ -0,0 +1 @@ +220
\ No newline at end of file diff --git a/tests/fuzz/corpus/expr/shipped_20 b/tests/fuzz/corpus/expr/shipped_20 new file mode 100644 index 0000000..e021ba7 --- /dev/null +++ b/tests/fuzz/corpus/expr/shipped_20 @@ -0,0 +1 @@ +clamp(dist(x, y, cols / 2, rows / 2) * 4, 0, 150)
\ No newline at end of file diff --git a/tests/fuzz/corpus/expr/shipped_21 b/tests/fuzz/corpus/expr/shipped_21 new file mode 100644 index 0000000..3509972 --- /dev/null +++ b/tests/fuzz/corpus/expr/shipped_21 @@ -0,0 +1 @@ +clamp(dist(x, y, cols / 2, rows / 2) * 4, 0, 190)
\ No newline at end of file diff --git a/tests/fuzz/corpus/expr/shipped_22 b/tests/fuzz/corpus/expr/shipped_22 new file mode 100644 index 0000000..d60cb81 --- /dev/null +++ b/tests/fuzz/corpus/expr/shipped_22 @@ -0,0 +1 @@ +clamp(dist(x, y, cols / 2, rows / 2) * 5, 0, 165)
\ No newline at end of file diff --git a/tests/fuzz/corpus/expr/shipped_23 b/tests/fuzz/corpus/expr/shipped_23 new file mode 100644 index 0000000..d2d0c9d --- /dev/null +++ b/tests/fuzz/corpus/expr/shipped_23 @@ -0,0 +1 @@ +clamp((dist(x, y, curx, cury) - 8) * 22, 0, 215)
\ No newline at end of file diff --git a/tests/fuzz/corpus/expr/shipped_24 b/tests/fuzz/corpus/expr/shipped_24 new file mode 100644 index 0000000..d976856 --- /dev/null +++ b/tests/fuzz/corpus/expr/shipped_24 @@ -0,0 +1 @@ +clamp(dist(x, y, curx, cury) * 9, 0, 200)
\ No newline at end of file diff --git a/tests/fuzz/corpus/expr/shipped_25 b/tests/fuzz/corpus/expr/shipped_25 new file mode 100644 index 0000000..cd711fd --- /dev/null +++ b/tests/fuzz/corpus/expr/shipped_25 @@ -0,0 +1 @@ +clamp(sin(TAU * (((y == 0 ? x : x == cols - 1 ? cols - 1 + y : y == rows - 1 ? 2 * cols + rows - 3 - x : 2 * cols + 2 * rows - 4 - y)) - t / 9) / (2 * cols + 2 * rows - 4)) * 14 - 3000, 0, 255)
\ No newline at end of file diff --git a/tests/fuzz/corpus/expr/shipped_26 b/tests/fuzz/corpus/expr/shipped_26 new file mode 100644 index 0000000..649206a --- /dev/null +++ b/tests/fuzz/corpus/expr/shipped_26 @@ -0,0 +1 @@ +clamp((t / 6) % 300 - 45, 0, 160)
\ No newline at end of file diff --git a/tests/fuzz/corpus/expr/shipped_27 b/tests/fuzz/corpus/expr/shipped_27 new file mode 100644 index 0000000..18c20e0 --- /dev/null +++ b/tests/fuzz/corpus/expr/shipped_27 @@ -0,0 +1 @@ +clamp((x - cols * 3 / 4) * 30, 0, 170)
\ No newline at end of file diff --git a/tests/fuzz/corpus/expr/shipped_28 b/tests/fuzz/corpus/expr/shipped_28 new file mode 100644 index 0000000..d4d147d --- /dev/null +++ b/tests/fuzz/corpus/expr/shipped_28 @@ -0,0 +1 @@ +((dist(x, y, curx, cury) + t / 30) % 10 < 5) * 50
\ No newline at end of file diff --git a/tests/fuzz/corpus/expr/shipped_29 b/tests/fuzz/corpus/expr/shipped_29 new file mode 100644 index 0000000..cc8e3d0 --- /dev/null +++ b/tests/fuzz/corpus/expr/shipped_29 @@ -0,0 +1 @@ +(t % 1800 < 620) * abs(sin(t % 1800 * 3 / 5))
\ No newline at end of file diff --git a/tests/fuzz/corpus/expr/shipped_3 b/tests/fuzz/corpus/expr/shipped_3 new file mode 100644 index 0000000..88f98c7 --- /dev/null +++ b/tests/fuzz/corpus/expr/shipped_3 @@ -0,0 +1 @@ +235 - min((y == 0 ? x : x == cols - 1 ? cols - 1 + y : y == rows - 1 ? 2 * cols + rows - 3 - x : 2 * cols + 2 * rows - 4 - y), (2 * cols + 2 * rows - 4) - (y == 0 ? x : x == cols - 1 ? cols - 1 + y : y == rows - 1 ? 2 * cols + rows - 3 - x : 2 * cols + 2 * rows - 4 - y)) * 255 / (cols + rows - 2)
\ No newline at end of file diff --git a/tests/fuzz/corpus/expr/shipped_4 b/tests/fuzz/corpus/expr/shipped_4 new file mode 100644 index 0000000..b8e817d --- /dev/null +++ b/tests/fuzz/corpus/expr/shipped_4 @@ -0,0 +1 @@ +255
\ No newline at end of file diff --git a/tests/fuzz/corpus/expr/shipped_5 b/tests/fuzz/corpus/expr/shipped_5 new file mode 100644 index 0000000..8580e7b --- /dev/null +++ b/tests/fuzz/corpus/expr/shipped_5 @@ -0,0 +1 @@ +30
\ No newline at end of file diff --git a/tests/fuzz/corpus/expr/shipped_6 b/tests/fuzz/corpus/expr/shipped_6 new file mode 100644 index 0000000..e3f1e9b --- /dev/null +++ b/tests/fuzz/corpus/expr/shipped_6 @@ -0,0 +1 @@ +80
\ No newline at end of file diff --git a/tests/fuzz/corpus/expr/shipped_7 b/tests/fuzz/corpus/expr/shipped_7 new file mode 100644 index 0000000..0fa6a7b --- /dev/null +++ b/tests/fuzz/corpus/expr/shipped_7 @@ -0,0 +1 @@ +90
\ No newline at end of file diff --git a/tests/fuzz/corpus/expr/shipped_8 b/tests/fuzz/corpus/expr/shipped_8 new file mode 100644 index 0000000..90be1cd --- /dev/null +++ b/tests/fuzz/corpus/expr/shipped_8 @@ -0,0 +1 @@ +95
\ No newline at end of file diff --git a/tests/fuzz/corpus/expr/shipped_9 b/tests/fuzz/corpus/expr/shipped_9 new file mode 100644 index 0000000..39acc43 --- /dev/null +++ b/tests/fuzz/corpus/expr/shipped_9 @@ -0,0 +1 @@ +(abs(dist(x, y, curx, cury) - (t / 22) % 80) < 3) * 220
\ No newline at end of file diff --git a/tests/fuzz/corpus/expr/tiny b/tests/fuzz/corpus/expr/tiny new file mode 100644 index 0000000..049dbaf --- /dev/null +++ b/tests/fuzz/corpus/expr/tiny @@ -0,0 +1 @@ +below
\ No newline at end of file diff --git a/tests/fuzz/corpus/input/arrows b/tests/fuzz/corpus/input/arrows new file mode 100644 index 0000000..5866849 --- /dev/null +++ b/tests/fuzz/corpus/input/arrows @@ -0,0 +1 @@ +[A[B[C[DOA
\ No newline at end of file diff --git a/tests/fuzz/corpus/input/bare_esc b/tests/fuzz/corpus/input/bare_esc new file mode 100644 index 0000000..7b71c6e --- /dev/null +++ b/tests/fuzz/corpus/input/bare_esc @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/tests/fuzz/corpus/input/focus b/tests/fuzz/corpus/input/focus new file mode 100644 index 0000000..19f4f38 --- /dev/null +++ b/tests/fuzz/corpus/input/focus @@ -0,0 +1 @@ +[I[O
\ No newline at end of file diff --git a/tests/fuzz/corpus/input/kitty b/tests/fuzz/corpus/input/kitty new file mode 100644 index 0000000..c075365 --- /dev/null +++ b/tests/fuzz/corpus/input/kitty @@ -0,0 +1 @@ +[97;5u[13;2:3u[57441;1:1u
\ No newline at end of file diff --git a/tests/fuzz/corpus/input/modifiers b/tests/fuzz/corpus/input/modifiers new file mode 100644 index 0000000..1ae7b89 --- /dev/null +++ b/tests/fuzz/corpus/input/modifiers @@ -0,0 +1 @@ +[1;5A[1;3Cbf
\ No newline at end of file diff --git a/tests/fuzz/corpus/input/mouse b/tests/fuzz/corpus/input/mouse new file mode 100644 index 0000000..4a1801a --- /dev/null +++ b/tests/fuzz/corpus/input/mouse @@ -0,0 +1 @@ +[<0;10;5M[<0;10;5m[<32;11;6M[<64;1;1M
\ No newline at end of file diff --git a/tests/fuzz/corpus/input/paste b/tests/fuzz/corpus/input/paste new file mode 100644 index 0000000..5ef3faf --- /dev/null +++ b/tests/fuzz/corpus/input/paste @@ -0,0 +1 @@ +[200~pasted text[201~
\ No newline at end of file diff --git a/tests/fuzz/corpus/input/regress_mouse_overflow b/tests/fuzz/corpus/input/regress_mouse_overflow Binary files differnew file mode 100644 index 0000000..3f29675 --- /dev/null +++ b/tests/fuzz/corpus/input/regress_mouse_overflow diff --git a/tests/fuzz/corpus/input/regress_param_overflow b/tests/fuzz/corpus/input/regress_param_overflow new file mode 100644 index 0000000..8f02056 --- /dev/null +++ b/tests/fuzz/corpus/input/regress_param_overflow @@ -0,0 +1 @@ +S[111111111110<hÃ0<hél[
\ No newline at end of file diff --git a/tests/fuzz/corpus/input/utf8 b/tests/fuzz/corpus/input/utf8 new file mode 100644 index 0000000..fcdacf5 --- /dev/null +++ b/tests/fuzz/corpus/input/utf8 @@ -0,0 +1 @@ +héllo wörld €ÿþ€
\ No newline at end of file diff --git a/tests/fuzz/corpus/jsonval/quit.json b/tests/fuzz/corpus/jsonval/quit.json new file mode 100644 index 0000000..5ff6678 --- /dev/null +++ b/tests/fuzz/corpus/jsonval/quit.json @@ -0,0 +1 @@ +{"cmd":"quit"}
\ No newline at end of file diff --git a/tests/fuzz/corpus/jsonval/regress_overflow_cast b/tests/fuzz/corpus/jsonval/regress_overflow_cast new file mode 100644 index 0000000..b9d7a6d --- /dev/null +++ b/tests/fuzz/corpus/jsonval/regress_overflow_cast @@ -0,0 +1 @@ +2e22
\ No newline at end of file diff --git a/tests/fuzz/corpus/jsonval/regress_truncated_u b/tests/fuzz/corpus/jsonval/regress_truncated_u new file mode 100644 index 0000000..d062943 --- /dev/null +++ b/tests/fuzz/corpus/jsonval/regress_truncated_u @@ -0,0 +1 @@ +"\u
\ No newline at end of file diff --git a/tests/fuzz/corpus/jsonval/shapes.json b/tests/fuzz/corpus/jsonval/shapes.json new file mode 100644 index 0000000..f8e46e9 --- /dev/null +++ b/tests/fuzz/corpus/jsonval/shapes.json @@ -0,0 +1 @@ +{"a":[1,2.5,-3e10,true,false,null,"s\n\"q\""],"o":{"nested":{"deep":[{}]}},"dup":1,"dup":2}
\ No newline at end of file diff --git a/tests/fuzz/corpus/jsonval/snapshot.json b/tests/fuzz/corpus/jsonval/snapshot.json new file mode 100644 index 0000000..67642cd --- /dev/null +++ b/tests/fuzz/corpus/jsonval/snapshot.json @@ -0,0 +1 @@ +{"cmd":"snapshot","format":"json"}
\ No newline at end of file diff --git a/tests/fuzz/corpus/jsonval/unicode.json b/tests/fuzz/corpus/jsonval/unicode.json new file mode 100644 index 0000000..7eb4ca7 --- /dev/null +++ b/tests/fuzz/corpus/jsonval/unicode.json @@ -0,0 +1 @@ +{"l": "\u25c4", "r": "\u25ba", "t": "\u25b2", "b": "\u25bc"}
\ No newline at end of file diff --git a/tests/fuzz/corpus/kdl/chrome.kdl b/tests/fuzz/corpus/kdl/chrome.kdl new file mode 100644 index 0000000..b8bada6 --- /dev/null +++ b/tests/fuzz/corpus/kdl/chrome.kdl @@ -0,0 +1,18 @@ +// heartbeat — chrome +// +// Drop this into ~/.config/slosh/config.kdl (a running session picks it up when +// you save), or point $SLOSH_CONFIG at this file. To hang it off a pane state +// instead of every pane, put the same pass inside one: +// +// states { bell { ... } } +// +// Lub-dub, then a rest: `abs(sin())` over a full turn is two humps, and the window +// mask leaves 1.2 seconds of nothing after them. A pane that is alive and has +// nothing to say, which is most of what a long-running one is -- so it belongs on +// a state rather than on everything: +// +// states { suspended { ... } } + +shaders { + tint where="chrome" channel="fg" color="#ff0033" amount="(t % 1800 < 620) * abs(sin(t % 1800 * 3 / 5))" +} diff --git a/tests/fuzz/corpus/kdl/config.kdl b/tests/fuzz/corpus/kdl/config.kdl new file mode 100644 index 0000000..27f842d --- /dev/null +++ b/tests/fuzz/corpus/kdl/config.kdl @@ -0,0 +1,970 @@ +// This file is the *reasoning*: what each setting is for, what it cost to +// get right, and which ones are opinions. The values themselves come out of +// the code -- `slosh --dump-config` writes every knob with its default, and +// `C-a e` seeds a missing config with exactly that. +// +// So if the two ever disagree, the dump is right and this file is stale; +// tests/test_dump_config.py fails when a setting exists and is not rendered. +// +// ---- what reloads ---- +// +// Saving this file applies it to every running session, and nearly everything +// here takes effect on the next frame: colours, geometry, the status bar and +// line, the marks, shader chains and their states, hints, and the keys -- +// including the leader itself. +// +// The exceptions, both because of *when* they are read rather than any +// reluctance to reload: +// +// shell consulted when a pane is made, so it applies to the next +// pane you open rather than to the ones already running +// shader_dir plugins are loaded on reload but never unloaded, so a new +// .so is picked up and a replaced one needs a new session +// +// A session started before this file existed still notices it appearing: the +// watch is on the directory, and slosh creates that directory at startup so +// there is always something to watch. +// +// slosh — every value here is also the compiled-in default, so this file is +// documentation as much as configuration. Copy it to +// ~/.config/slosh/config.kdl (or point $SLOSH_CONFIG at it) and edit. +// +// A broken file costs a warning on stderr and nothing else: the defaults +// stand. Saving this file re-reads it into every running session on its own; +// `slosh --no-reload` starts one that does not watch, and +// `slosh cmd '{"cmd":"reload"}'` asks for a re-read explicitly. And +// refuses a file it cannot parse rather than half-applying it. + +// ── include ───────────────────────────────────────────────────────────────── +// +// A config can be made of files: +// +// include "themes/amber.kdl" // contrib/themes has seven +// include "keys/vim.kdl" "shaders/crt.kdl" +// +// which is how you switch a theme by editing one line instead of pasting one. +// Nothing about it is theme-specific: an include is another config file applied +// here, so the same line composes keys, shader chains, states or geometry. +// +// A relative path is relative to *the file doing the including*, never to the +// directory you happened to start the session from -- a config is a thing on +// disk that refers to its neighbours. A leading `~` is your home directory, and +// an absolute path is itself. Included files may include others. +// +// What you include is the *base*: the file with the `include` line in it wins +// wherever they disagree, and a later include wins over an earlier one. That is +// true wherever the line sits in the file, because the loader reads a document +// by asking it for the keys it knows rather than walking it top to bottom -- so +// "here" is not a position it could honour, and "the file you are editing wins" +// is the rule worth having anyway. +// +// `keys` blocks *add* to what came before, like every keys block; a `shaders` +// or `states` block replaces the one it inherited, like every one of those. +// +// A file that will not load is skipped and the rest of the config still +// applies -- losing your keybindings over a mistyped theme name would be the +// worse answer -- and the session says which file it could not honour, in the +// same place it says "config reloaded". That goes for anything a config +// complains about: an unknown shader, a binding that does not parse, an include +// that is not there. The file applied; one line of it did not; you get told. +// +// `slosh --check` says all of them at once, with a file and line each, and +// exits non-zero if there were any. A session only has room for the first. + +// ── geometry ──────────────────────────────────────────────────────────────── + +// Blank cells between panes and around the screen edge, in ROWS. +gap 1 + +// Columns per row. Terminal cells are about twice as tall as they are wide, so +// a gap of 1 row and 2 columns looks square. +gap_aspect 2 + +// Space between a pane's frame and its contents, in ROWS like `gap` -- a +// horizontal one is multiplied by `gap_aspect`, so `padding 1` is a ring that +// looks square rather than a squashed one. One number does every side; two are +// vertical then horizontal; four are CSS order, top right bottom left: +// +// padding 1 // all round +// padding 0 2 // none above or below, two either side +// padding 1 2 1 2 // the same thing, spelled out +// padding 2 0 0 1 // room for a title above, a gutter on the left +// +// A number means the same thing however many of them you write, which is why +// the first three of those are two paddings rather than three. Three values is +// refused: CSS reads it as top/horizontal/bottom, and a line whose meaning you +// have to look up is a line nobody can read. +padding 0 + +// ╭╮╰╯ versus ┌┐└┘ +rounded true + +// "left" | "center" | "right" +title_align "center" + +// Cells between a frame's corner and the start of its title, for the edge the +// title is anchored to. The title always carries a space on each side on top +// of this, so it can never end up welded to the rule beside it. +title_inset 2 + +// ── the bell ──────────────────────────────────────────────────────────────── +// +// A BEL from a program marks the pane it came from, and the tab that pane is +// in -- a bell exists for the pane you are *not* looking at, and one that only +// showed on screen would be useless in a background tab. Looking at the pane +// is what puts it out. Off makes a bell silent *and* invisible, which is a +// real choice rather than an oversight. +// +// The mark is one cell wide, which is not news you can see from the corner of +// your eye -- so by default the pane says it too, in theme's `bell` colour. +// The *frame*: three sharp blinks the moment it rings, then a slow breathe +// until it is answered. The *body*: a shimmer -- one soft diagonal sheen +// gliding across, the way light crosses something glossy, gone within the +// same 900ms the blinks take. It is quiet on purpose: peak 95 where the +// blinks are 255, its colour softened halfway towards default_fg so it reads +// as light rather than paint, and its width breathing on a sine as it +// travels. Motion is what pulls the eye, not brightness, so it draws you to +// the pane without shouting over what it says. Both are derived, like +// the scrolled wash: built from the theme after the theme is read, so a theme +// moves the mark, the flash and the shimmer together, and they are commented +// out in the states block below for the same reason scrolled is. Both read +// `since`, so the frame clock they cost runs only while a rung pane is +// actually on screen. +// +// Turning it off is either of the things it hangs from: `bell_indicator +// false` silences the bell everywhere (mark and flash -- that is what +// invisible means), and `states { bell { } }` removes just the flash. A +// `bell { ... }` chain of your own replaces it outright, like every state -- +// the chrome passes further down have a few worth stealing. +// ── the frame's own buttons ───────────────────────────────────────────────── +// +// Minimise, zoom and close, in the top-right of every pane's frame: the mark +// and one space each, seven columns for the three of them. They are drawn +// right to left and stop when they run out of room, so a narrow pane keeps +// close and zoom and loses minimise first. +// +// The split button was removed from this frame once already, on the grounds +// that one glyph cannot mean four directions and the border can. These are two +// verbs a single glyph *can* mean, which is why they are here and splitting is +// still done by clicking an edge. They do cost six columns of every frame, so +// this turns them off -- the keybindings (C-a z, C-a x) are unaffected, because +// the setting is about the affordance and not the verb. +// A word in the middle of the status line for whatever the pointer is on: +// which way a border would split, what a one-character button does, that the +// gap between two panes can be dragged. The frame's affordances are mostly one +// character wide and a border that splits when clicked looks exactly like a +// border, so this is where they say so. +// +// It sits in the middle of the *row*, not in the space the two ends leave +// over: centred in the gap it moved every time a pane title changed length, +// and a thing you glance at should be in the same place twice. When the row is +// too full for it there it is not drawn at all -- the ends say which session +// and pane you are in, and a hint you can get back by hovering is the part +// that can afford to disappear. +hints true + +// When there is no hint to show — which is most of the time, since a hint +// needs the pointer to be on something — that space says which slosh this +// is: `slosh 0.1.0+d9dee7f`. It reads as decoration and is not: a session +// keeps the binary it started with, so after a rebuild the version on screen +// is the one you are actually talking to, and `slosh --version` is the one +// on disk. When those disagree, that is the answer to whatever you were about +// to debug. (contrib/slosh-dev restarts into the new one.) +// +// It gives way to a hint, and both give way to the ends of the line -- they +// are centred on the row and simply not drawn when that would overlap. +version_banner true + +pane_buttons true + +// Four shapes, one verb each: down for away, an outline for "make it fill the +// tab", the same outline filled in for "it is filling it", a cross for gone. +// +// Deliberately not one tidy family. A set of boxed operators (⊞ ⊟ ⊡ ⊠) was +// tried and read as four identical smudges: they are *math* symbols, sized to +// sit beside x and y in an equation, so at a terminal's cell size the interior +// carrying the whole meaning is two or three pixels of it. Geometric shapes and +// dingbats are drawn at full cell size, which is what makes them readable at +// all, and picking glyphs that fill their cell is also the answer to the +// evenness that put plain ASCII here originally -- a glyph that does not fill +// its cell hands the slack to the gap beside it, by an amount the font decides. +// +// Single width, and never emoji: many terminals draw those two columns wide +// while chrome here is booked as one, which shifts everything after them. +// +// A mark may be more than one character; the button is measured rather than +// assumed, so "[]" and "][" work if you prefer them. Put the character itself +// here; this file has no \u escape. +zoom_mark "□" // click to fill the tab with this pane +zoom_on_mark "■" // ...and this is what it says while it is filling it +close_mark "✕" +min_mark "▼" // into the strip along the bottom, still running + +// The new-tab button at the end of the tab strip. Drawn with a space on each +// side, so a one-character mark is still a three-cell target -- a one-cell +// target is a thing you miss with a mouse. It reads as a bare "+" because +// nothing else in the UI is one: the frame's old "+" for splitting went when +// the border became the button. Try "[+]" or "new" if you prefer a word. +newtab_mark "+" + +// Which dead panes stay, and which just go. +// +// commands panes that were *given* a command -- from a layout, or the +// control API. Default. Their death is news: the pane keeps what +// it printed, says in its own backlog why it ended, and offers +// [re-run] and [close] in its frame. +// all shells too. A pane you split off and typed `exit` in stays +// until you dismiss it. +// none nothing stays; the pane is closed on the next paint, and with +// the last pane, the session. +// +// The line is the command, not who made the pane: a shell is a shell whether +// it came from a layout or from C-a \, and when you type `exit` in one you +// are done with it. A pane running `npm run dev` is a different thing -- you +// want the error it left, and the button that runs it again. +// +// (`true` and `false` are still read, as `all` and `none`, because they used +// to be the spelling.) +keep_dead "commands" + +// How far the panes you are *not* in are pushed back, 0..255, and 0 for not +// at all. On by default and gently: at 60 white text lands on #c3c3c3, which +// reads as "not this one" without reading as "not available", because half +// the time you are still reading the other pane. +// +// This is the one taste shipped as a default, and it is shipped because +// turning it off is a single obvious number rather than a shader incantation. +// Writing the chain yourself still wins -- `states { unfocused { ... } }` +// replaces it, and `states { unfocused { } }` means nothing at all. +dim_unfocused 60 + +bell_indicator true + +// What to draw. A short string rather than a fixed glyph, because the obvious +// choice is an emoji bell and emoji are drawn two columns wide by many +// terminals while chrome here is booked as one -- so the default is narrow and +// anyone who knows their own terminal can say otherwise. (This file has no +// \u escape, so put the character itself here.) +bell_mark "•" + +// The one-row bar with tabs, the new-tab button and the pane count. +status_bar true + +// The line along the bottom: which session and tab you are in, what the +// focused pane calls itself, and whether it is in a state worth knowing about +// - scrolled back, on an alternate screen, or not started yet. On the right it +// says which of *this tab's* panes is open, as `pane 2/5`; the strip above +// counts the whole session, so the two disagree as soon as you have two tabs. +status_line true + +// How far the strip and the line are held off the left and right edges. This +// is separate from `gap`, which is the space around the panes, and wider than +// it by default so the two bars read as chrome outside the layout rather than +// as another row of it. +status_pad 4 + +// Focus follows the pointer. It never steals focus mid-chord (while the prefix +// is held), never reaches past the finder, never interrupts a drag, and never +// expands a collapsed pane just because the pointer crossed its header. +focus_follows_mouse true + +// Whether the program running in a pane may set that pane's shader chains, by +// printing an escape sequence: +// +// printf '\033]5577;1;shader;chrome;tint color="#7aa2f7" amount=255\033\\' +// +// The payload after `chrome` (or `content`) is a chain in exactly the syntax the +// `shaders` block below uses, so a chain you arrive at this way is a line you can +// paste. `contrib/shader-repl` is a prompt that does it for you, and prints the +// block to paste when you like what you see. +// +// Off, because a program restyling the session it happens to be running in is a +// hazard and not a feature -- `cat` some hostile file and it can dim your panes. +// On, it is scoped to the pane that asked: it cannot touch another pane, and it +// cannot change anything this file said about everybody else. The pane is told +// `off` rather than ignored, so a program can tell "you said no" from "this +// build has never heard of it". +// +// The way back, whatever a pane did to itself and whether or not this is still +// true afterwards: the `clear-shaders` action (bind it, or run it from the +// palette), or `slosh cmd '{"cmd":"clear-shaders","id":0}'`. +in_band_shaders false + +// The floor below which a pane is not small but useless. A split that cannot +// give every child this much collapses into a stack instead, with one pane +// expanded and its siblings as one-row headers. Recomputed every frame, so +// widening the terminal always restores exactly the layout you had. +min_pane cols=24 rows=6 + +// The smallest pane a split is allowed to *produce*. min_pane above is where +// the layout gives up and collapses; this is where splitting stopped being +// worth offering, which is a larger number and a different question — two +// twenty-five column panes technically fit and are not worth having. +// +// Below this the border stops being a button: no guide arms, clicking it does +// nothing, and the keyboard says so with a toast. The control API and layout +// files are deliberately exempt, because a script asking for a pane is saying +// what it wants rather than being offered something, and the responsive +// collapse is what catches it there. +// +// Never used below min_pane: refusing to offer what the layout would collapse +// anyway is the floor under the floor. Raise it if you like fewer, bigger +// panes; a column split needs 2*cols + 2 columns of pane to be offered, a row +// split 2*rows + 1. +min_split cols=32 rows=8 + +// Rows per wheel notch, and per scroll-up/scroll-down. +scroll_lines 3 + +// How many lines of history a pane keeps. `0` keeps none: the screen and +// nothing behind it. +// +// lib-vt is handed no limit by default and picks its own -- 10,000 *bytes*, +// which measured at 622 lines of an 80-column pane, less than one `make` run. +// This is that number replaced with the one every other multiplexer settled on. +// +// Read when a pane is created, so it applies to the next pane you open rather +// than to the ones already holding history. Same as `shell`, and for the same +// reason: retroactively shrinking a pane's scrollback would throw away output +// somebody is reading. +scrollback 10000 + +// ...and the ceiling that line count runs into, per pane, in bytes. The two +// limits work together because either can be reached first and neither can see +// what the other depends on: a line count cannot know how wide your terminal is +// or how many styles a program used, and a byte count cannot know how many lines +// that bought. `0` removes the ceiling. +// +// Measured, 10,000 lines of a pane filled with styled output: +// +// 80 columns ~6 MB the ceiling is never reached +// 200 columns ~15 MB it costs about 2% of the lines +// 400 columns ~32 MB it costs about half of them +// +// So it is invisible at ordinary widths and a brake at extraordinary ones, which +// is its whole job. It is per *pane*: sixty panes full of history is sixty times +// this, which is the reason there is no "unlimited" -- with this many panes that +// is a memory leak with a friendly name. Raise it if you have one very wide pane +// and want every line of it; lower `scrollback` if you have very many. +scrollback_bytes 16777216 + +// How long the pointer must rest on a pane border before the split guide +// arms. Contact alone is not enough: a pointer crossing a border on its way +// somewhere else should not make the screen flash. 0 arms immediately. +hover_delay_ms 250 + +// How close together two clicks must be to count as a double-click. On a +// pane's title that renames it in place -- Enter keeps the new name, Escape +// abandons it, and an empty name gives the pane back to whatever its program +// calls itself. On a pane's contents it selects the word under the pointer and +// copies it, like any other selection. +double_click_ms 400 + +// What that word stops at. Listed as *separators*, not as word characters: +// anything unlisted belongs to a word, so text nobody enumerated -- CJK, +// accents, emoji -- needs no table and comes out whole. Whitespace always +// separates, listed or not, because a double-click that selected the line +// would be a different gesture arrived at by surprise. +// +// The default deliberately leaves `/ . - _ : = @ # % ? & + ~ $` *inside* words: +// a path, a dotted name, a snake_case identifier, a key=value pair, a URL and +// `src/app.c:1234` are each one thing you double-clicked to copy whole. `:` is +// the difference between copying an error location and copying half of one. +word_separators "\"'`()[]{}<>;,|" + +// How far everything behind a modal (the cheatsheet, C-a ?) is pushed back, +// 0..255, and 0 for not at all. A modal floating over a screen as bright as +// it is reads as another pane rather than as something in front. +// +// It is the shader pass aimed at the whole screen, so the same caveat applies +// as for shaders: cells with no colour of their own are resolved through +// theme's default_fg/default_bg first, and if those do not match your +// terminal's real colours the screen will shift slightly while a modal is up. +modal_scrim 120 + +// Extra shaders can be loaded from shared libraries: any `*.so` in the +// directory below that exports a slosh shader plugin. Once loaded they are +// named here exactly like a built-in. Default: a `shaders` directory beside +// this file, so dropping the .so next to your config is the whole install. +// +// A plugin is native code running in the session's own process, with your +// privileges and no sandbox -- which is the same thing `shell` and a layout's +// `command=` already are, and the reason the search path is a directory you +// own rather than a value that can point anywhere. Install ones you trust. +// +// Adding a plugin and saving this file loads it; nothing is ever unloaded, so +// *replacing* one needs a new session. Skeleton and Makefile: +// contrib/shader-plugin/ +// +// shader_dir "~/.config/slosh/shaders" + +// ── projects ──────────────────────────────────────────────────────────────── +// +// Where you keep your work. Each of these is scanned for subdirectories that +// are projects, and a project opens as a *workspace* -- a tab dedicated to it, +// reached with `C-a w`. `depth` is how many levels below each root to look; a +// directory that *is* a project is never descended into, which is what keeps +// the scan off node_modules without a rule about node_modules. +// +// A subdirectory is a project when it has a `slosh.layout` saying what it +// needs -- or a `.git`, which is the project that has not said yet. Both, +// because a ~/dev of forty checkouts and three layout files would otherwise be +// a picker with three rows, and `C-a W` writes the missing file from whatever +// you arranged by hand. +// +// Nothing is watched. The list is worked out when something asks for it, which +// is one readdir per root and a stat or two per entry -- next to the keystroke +// that asked, nothing -- and an answer nobody remembered cannot be stale. +// +// Unset, so nothing is scanned until you say where to look: +// project_roots "~/dev" "~/work" depth=2 + +// The layout a project with no file of its own opens as: yours, rather than a +// guess about your stack from whatever config files it happens to contain. A +// relative `cwd=` in it binds to whichever project is being opened, not to the +// directory this file lives in, so one file is the shape you start every +// checkout in -- and a project that grows its own `slosh.layout` wins +// over it, the way an included file is the base and the file including it wins. +// +// Unset, so a project with no layout is one pane running your shell in it: +// project_layout "~/.config/slosh/project.layout" + +// ── shaders ───────────────────────────────────────────────────────────────── +// +// Colour passes over a pane's contents by default; `where="chrome"` aims one +// at the frame instead (see the chrome passes further down), and the tab +// strip and status line are never shaded. Strengths run 0 to 255, where 0 is +// off and 255 is fully applied, and a pass resolves default-coloured text +// through theme's default_fg/default_bg -- set those to your terminal's real +// colours or a shaded pane will shift. + +// What a pane looks like while it is in a particular state. Each state takes +// the same chain of passes `shaders` does. +// +// A pane is usually in several states at once — unfocused *and* scrolled, +// suspended *and* something you could drop onto — so exactly one wins: the +// first that matches, tested in this order. +// +// dragging the pane you have hold of +// drop_hover the one under the pointer, where it would land +// drop_target the others, all of them somewhere it could go +// dead its program exited; waiting to be re-run or closed +// suspended laid out, never started (its label is its contents) +// bell a BEL arrived and nobody has looked since; ends when you do +// scrolled looking at scrollback rather than at the present +// unfocused the weakest thing that can be true of a pane +// +// The order is fixed rather than read from this block, because it is a ranking +// by urgency and not a preference: a pane you are holding should not be +// recoloured by anything, and a mode the whole screen is in outranks an +// ambient hint about one pane. They do not stack, for the same reason two +// reasons to be grey would compound into one muddy grey that reads as neither. +// +// Naming a state replaces its default outright — including with nothing, which +// is how you turn one off: states { drop_target { } } +// +// Which states have a default, and why the line falls where it does: +// +// a pane that is *not live* gets one -- dead, suspended, scrolled. In all +// three the cells are showing something other than a running program's +// present: output from something that exited, a pane that never started, or +// the past. None of that is discoverable by looking unless something says +// so, which is the entire argument for colouring it. +// +// a pane that is *news* gets one too -- bell. A ring is an event rather +// than a condition, so its default moves: a shimmer across the body that is +// gone within a second, and blinks then a breathe on the frame -- the one +// chrome pass in this table -- in theme's bell colour (see the bell section +// above, and `bell_indicator false` to silence it with the mark). +// +// a pane that is merely *not the one you are in* is a taste rather than a +// fact, and gets a default anyway -- but through `dim_unfocused` above, +// which is one number to change or zero out. The objection to shipping a +// taste was never the taste, it was that turning it off should not require +// knowing this block exists. `dragging` still has no default: the pane in +// your hand keeping its colour is what makes it lift off the page. +// +// All the defaults are gentle: a dead pane, a suspended one and scrollback are +// all things you still want to read. The scrolled default is *derived*, not +// stored, and has two parts. The wash: the scrollback indicator's own colour +// (theme's scroll_bg), worked out after the theme is read, so a theme moves +// the wash and the indicator together. The edge fades: the viewport's top and +// bottom rows melt towards the edge, the way a scrollable list fades where it +// continues -- and each fade exists only while there is content past that +// edge, so hitting the top of the buffer turns the top edge solid, and +// returning to the present ends the state and everything is opaque again. Up +// to three rows a side, never more than a quarter of the viewport each. All +// of it is commented below because naming a state replaces its default +// outright -- a declared copy would pin the wash to one theme's colour and +// quietly stop following the next. +states { + drop_hover { grayscale amount=200; dim amount=140 } + drop_target { grayscale amount=200; dim amount=140 } + dead { grayscale amount=200; dim amount=90 } + suspended { grayscale amount=170; dim amount=60 } + // scrolled { // derived: a wash of theme's scroll_bg, and edge fades where more content is + // tint amount=22 color="#7aa2f7" + // dim amount="(above > 0) * max(0, (min(3, rows / 4) - y) * 45)" + // dim amount="(below > 0) * max(0, (y - rows + min(3, rows / 4) + 1) * 45)" + // } + // dragging { } + // bell { // derived: a body shimmer, and a frame blink-then-breathe, in theme's bell colour + // tint color="#efd7b3" amount="(since < 900) * max(0, 95 - abs(x + 2 * y - since / 4) * (6 + sin(since / 2) / 96) - since / 64)" + // tint where="chrome" channel="fg" color="#e0af68" amount="since < 900 ? (since / 150 % 2 == 0) * 255 : 120 + sin(since / 8 + 158) / 3" + // } + // unfocused { dim amount=60 } // = dim_unfocused above; yours replaces it +} + +// While a pane is being dragged its border stays solid and every other pane's +// turns dashed, marking them as places it could be dropped. That is chrome, +// not a shader, so it happens whatever the states above are set to. + +// Colour passes every pane gets, in the order written — a chain is a sequence, +// so `grayscale` then `tint` is not `tint` then `grayscale`. Each node is one +// pass; `amount` is 0..255 and defaults to 128. Listing a shader twice is how +// you get two of it (two rulers, say). An unknown name is refused with a +// warning and skipped, never guessed at. +// +// dim amount darken towards black +// grayscale amount drain colour, weighted for the eye +// tint amount color="#rrggbb" pull towards a colour +// vignette amount darken towards the pane's edges +// gradient amount direction=0..3 fade towards the background +// 0 down, 1 up, 2 right, 3 left +// zebra amount band=1 darken alternate bands of `band` rows; +// band=1 is also a CRT scanline +// ruler amount at=80 color= mark a column, background only, so the +// text on it stays readable +// margin amount at=100 everything from that column recedes +// spotlight amount radius=12 brightness falls away from the cursor +// +// Two props are the *pass's* rather than the shader's, and work on any of them: +// +// where="content"|"chrome" the contents, or the frame (see further down) +// channel="both"|"fg"|"bg" which of the cell's two colours it may keep; +// `fg` recolours glyphs and leaves backgrounds +// alone, which is what a border wants +// +// `amount` can also be an *expression* instead of a number, evaluated for +// every cell. That is how you get an effect nobody built in, without building +// anything: +// +// shaders { +// dim amount="(y % 2) * 40" // scanlines +// dim amount="(x > cols - 10) * 120" // the right margin +// tint amount="255 - dist(x, y, curx, cury) * 12" color="#7aa2f7" +// } +// +// The expression produces the *strength*, never the colour: the mixing stays +// in C, so an expression cannot make an invalid cell, and its result is one +// byte per cell -- which is what lets the whole program be evaluated once into +// a per-cell map and then reused. A user-written positional shader therefore +// costs about what a compiled one does. One that reads the clock cannot be +// cached and is evaluated per cell per frame, which is the price of animation. +// +// variables x y the cell, inside the pane's content rect +// cols rows the size of that rect +// curx cury the cursor, and `cursor` (0 or 1) for whether +// this pane has it +// focused 0 or 1 +// t milliseconds, for animation +// since milliseconds the pane has been in its current +// state -- what a one-shot effect needs, because +// `t` is the time, not how long ago +// above below lines of scrollback hidden past the viewport's +// top and bottom edges, both 0 at the present -- +// what lets an effect say "there is more this way" +// operators + - * / % integer only; division by zero is 0, not a crash +// < > <= >= == != and && || !, which give 0 or 1 -- so +// `(x < 10) * 200` is how you write a rule +// & | ^ ~ << >> on the 32-bit value. They bind *tighter* than +// comparison, so `x & 7 == 0` means what it looks +// like -- C reads that as `x & (7 == 0)`, which +// Ritchie called a mistake and it is. The shift +// count is masked to five bits and `>>` keeps the +// sign, both matching the browser preview exactly. +// `(x ^ y) & 1` is the cheapest interesting texture +// there is. +// ? : both sides are evaluated, then one is chosen +// functions min(a,b) max(a,b) abs(a) clamp(v,lo,hi) +// dist(x1,y1,x2,y2) counts a row double, because a cell is +// about twice as tall as it is wide +// sin(deg) cos(deg) degrees in, -255..255 out. Degrees because +// they are what you guess, and 255 because it +// is the strength scale everything else here +// uses -- so `128 + sin(t / 4) / 2` is a +// breathe with nothing to rescale. Any angle +// wraps, negative ones included, since `t / 4` +// does not stop at 360. +// constants PI TAU 180 and 360: pi as an *angle*, which is all it can +// be here -- a half turn and a whole one. So a +// radian-shaped formula ports across as written, +// `sin(TAU * x / cols)` is one cycle over the pane, +// and the common fractions stay exact where +// `3.14159 / 6` in integers would be 0. There is no +// deg2rad/rad2deg: one unit, nothing to convert. +// +// There are no loops and no recursion, on purpose: a config cannot spin, and +// there is nothing to sandbox. An expression that does not compile drops that +// one shader with a warning -- never the config, and never a half-strength +// version of what you asked for. +// +// Empty by default: these are yours to ask for, not opinions we hold. +// +// shaders { +// vignette amount=70 +// zebra amount=22 +// ruler amount=60 at=80 color="#7aa2f7" +// spotlight amount=90 radius=14 +// } + +// ── chrome passes ─────────────────────────────────────────────────────────── +// +// `where="chrome"` on any entry above runs that pass over a pane's *frame* +// instead of its contents: the border, the padding inside it, the title and +// the buttons. Everything else is identical -- same shaders, same `amount`, +// same expressions -- because a frame is just another rect, so it is just +// another pass. `where="content"` is the default and never has to be written. +// +// The two never touch each other's cells: the contents are cut out of the +// frame pass, so a full-strength chrome tint leaves your text alone and a +// full-strength content tint leaves the border alone. +// +// `channel="fg"` (or `"bg"`, or `"both"`, the default) says which of a cell's +// two colours a pass may keep. On a frame you almost always want `fg`: a +// border's *background* is the terminal's own default, and mixing that towards +// a colour paints a rectangle behind the glyphs instead of colouring them -- +// and leaves it there until the pane changes state. `fg` also keeps the padding +// inside the border out of it, since a blank cell has no foreground to colour, +// so the effect lands on exactly the rules, the title and the buttons. +// +// `x`/`y` are the *whole frame's*, counted from its top-left corner, with +// `cols`/`rows` the size of the pane's rect -- not of one side. That is what +// lets an effect travel round a border rather than restart at every corner: +// +// shaders { +// // the top rule only, where the title is +// tint where="chrome" channel="fg" color="#7aa2f7" amount="(y==0)*255" +// // a corner glow. One node per line: a newline ends a node, so a +// // pass split over two lines is two broken nodes, not one pass. +// tint where="chrome" channel="fg" color="#00ffd7" amount="255-dist(x,y,0,0)*20" +// } +// +// For anything that *travels*, though, `x`/`y` are rarely what you want: it +// wants distance *along the ring*, which the language can work +// out -- clockwise from the top-left corner, in cells: +// +// (y == 0 ? x : x == cols - 1 ? cols - 1 + y +// : y == rows - 1 ? 2 * cols + rows - 3 - x : 2 * cols + 2 * rows - 4 - y) +// +// with the perimeter itself `2 * cols + 2 * rows - 4`. Two things fall out of +// that: `min(d, P - d)` is distance the short way round, so an effect crosses +// the corner it started from instead of stopping at a seam; and +// `(p - head + P) % P` is "how far ahead of the head", which makes an arc +// rotate rather than a blob slide. There is no way to name a subexpression, so +// it has to be written out in full -- which is why a small effect is a long +// line. contrib/chrome has a dozen-odd ready to paste -- a shine running +// round the border, ripples from a corner, a rotating comet, corner glints, a +// sheen across the title rule, and more; the directory is the list. +// +// Hung off a state, this is a border that says what a pane is doing -- which +// is what chrome is for, and it costs no room and no text: +// +// states { +// // a dead pane's frame breathes red until you deal with it: a +// // triangle wave, up and back down over about four seconds +// dead { tint where="chrome" channel="fg" color="#ff0033" amount="abs(t/8%510-255)" } +// // a bell nobody has answered yet: `since` is how long the pane has +// // been in this state, so this is a real *flash* -- full strength for +// // a quarter second and then gone, without the bell being answered. +// // `t` could not say that: it is the time, not how long ago. (The +// // default bell flash is this idea grown up: blinks, then a breathe.) +// bell { tint where="chrome" channel="fg" color="#ffcc00" amount="(since<250)*255" } +// // one that has not started yet is outlined, not filled +// suspended { tint where="chrome" channel="fg" color="#8a8a95" amount=160 } +// // the panes you are not in recede, frame and contents together. +// // Both passes are written out because naming a state replaces the +// // *whole* state: a block holding only chrome passes is a state with +// // no content pass, which for `unfocused` means dim_unfocused above +// // stops applying to it. +// unfocused { dim amount=60; dim where="chrome" amount=90 } +// } +// +// A pane a small window has collapsed to a single row (see min_pane) is chrome +// all the way through, so a chrome pass is the only kind that can still colour +// it -- which is the state where a colour is worth the most. +// +// The global chain is empty by default, like the content passes: yours to ask +// for. One chrome pass does ship, hung off a state rather than here: the bell +// flash (see the bell section, and the states table above). + +// How often to repaint while a shader is animated, in milliseconds -- which +// means one whose `amount` reads `t` or `since`. Nothing else needs a frame +// clock: every +// other repaint has an event behind it, and this one is only asked for while +// such a shader is actually on screen. 0 turns it off, which leaves an +// animated shader advancing only when something else causes a frame. +anim_ms 50 + +// How long an announcement ("copied 13 chars", a pane's OSC 9 notification, +// "config reloaded") stays in the bottom-right corner. +toast_ms 2500 + +// The logo, centered over everything for a moment when a client attaches, +// wearing a different shader effect each time -- a greeting, and an +// advertisement for the pass machinery that painted it (the effects are +// ordinary amount-expressions over the logo's rect). Any key or click ends +// it early and still does what it was going to do; a screen the logo does +// not fit on gets no greeting rather than a cropped one; headless and +// scripted sessions have nobody to greet. 0 for never. +splash_ms 900 + +// What a new pane runs. Unset means $SHELL. +// shell "/bin/bash" + +// ── theme ─────────────────────────────────────────────────────────────────── + +// Every colour the compositor draws has its own name here. They started as six +// names doing thirty jobs, which meant the split guide could not be recoloured +// without also recolouring the focused frame, because they were the same +// entry. Several still share a default: that says they look right together, +// not that they are the same thing. +theme { + // What a cell's "terminal default" colour is taken to be when a shader has + // to compute on it. See the shaders section above. + default_fg "#ffffff" + default_bg "#000000" + + // ── panes ─────────────────────────────────────────────────────────────── + frame_focus "#7aa2f7" // border of the focused pane + frame_idle "#45454a" // border of every other pane + title "#ffffff" // its title, and its OSC 5577 status text + title_idle "#45454a" // the same, on a pane that is not focused + + // A pane's own OSC 5577 buttons. + button_fg "#141418" + button_bg "#7aa2f7" + button_bg_idle "#55555c" // buttons on a pane that is not focused + + // ── things that appear under the pointer ──────────────────────────────── + guide "#7aa2f7" // split guide: armed edge, handle, its arrow, boundary + resize "#7aa2f7" // the handle in the gap between two panes + drop_target "#7aa2f7" // frame of a pane a drag could land on + + // ── a pane's scrollback indicator ─────────────────────────────────────── + scroll_fg "#141418" + scroll_bg "#7aa2f7" + + // ── rows of a tab that has collapsed into a list ──────────────────────── + header "#45454a" + header_hover "#7aa2f7" // the rule, under the pointer + header_hover_title "#ffffff" // the name, under the pointer + + // ── the strip along the top ───────────────────────────────────────────── + // The tab you are in is filled rather than merely bold: bold alone is easy + // to miss on a strip of short labels. It keeps that fill while the pointer + // is on it -- it is still the tab you are in -- so its hover lands on the + // text instead. + tab_active_fg "#141418" + tab_active_bg "#7aa2f7" + tab_active_hover_fg "#ffffff" + tab_idle "#45454a" + tab_hover "#7aa2f7" + prefix_fg "#141418" // the "C-a" badge, while the prefix is pending + prefix_bg "#7aa2f7" + tab_count "#45454a" // panes in the whole session + + // ── the line along the bottom ─────────────────────────────────────────── + status "#45454a" // session, tab, pane, and the pane index + status_state "#ffffff" // scrolled / alt screen / not started / exited + hint "#ffffff" // what the pointer is on, in the middle + + // ── marks on a pane's frame ───────────────────────────────────────────── + bell "#e0af68" // a pane rang and has not been looked at: + // amber, not the accent -- attention, not focus + dead "#ff875f" // its program exited: the line in its backlog, + // and the words in its frame and the status line + pane_button "#45454a" // the frame's own close/zoom/minimise marks + pane_button_hover "#7aa2f7" + + // ── the row of minimised panes ────────────────────────────────────────── + minbar "#45454a" + minbar_hover "#7aa2f7" + + // ── modals: a surface that floats over the layout ─────────────────────── + // Named apart from the pane colours they resemble because a modal is a + // different surface: opaque, on top of everything, and legible against a + // background that has been dimmed underneath it. (The first version drew + // the close button in the pane button colour on the finder background, + // which are the same value — an invisible button until you hovered it.) + modal_bg "#1c1c22" // a shade lighter than the dimmed screen + modal_fg "#e8e8ea" + modal_border "#7aa2f7" + modal_title "#ffffff" + modal_button "#8a8a95" // visible *without* the pointer on it + modal_button_hover "#7aa2f7" + + // ── the pane finder ───────────────────────────────────────────────────── + // The finder is a modal, so its box is the modal colours above; these are + // the parts only it has. `finder_bg` is the query field and the row under + // the pointer — a band that is not the box, so typing and hovering both + // have somewhere to show. + finder_fg "#ffffff" + finder_bg "#45454a" + finder_sel_fg "#141418" + finder_sel_bg "#7aa2f7" // the row enter would take + + // ── the in-place editor a double-click opens on a pane title ──────────── + rename_fg "#141418" + rename_bg "#7aa2f7" + + // ── announcements ─────────────────────────────────────────────────────── + toast_fg "#141418" + toast_bg "#7aa2f7" +} + +// ── keys ──────────────────────────────────────────────────────────────────── +// +// Everything is `prefix` then a key. Pressing the prefix twice sends the +// prefix itself to the pane. Modifiers are ctrl+ alt+ shift+ super+, and named +// keys are left/right/up/down/enter/tab/escape/space/backspace/home/end/ +// pageup/pagedown/delete/insert/backslash/minus/slash/comma/period. +// +// The cheatsheet's shorthand also parses -- C- M- S-, and ← → ↑ ↓ -- so a chord +// copied off C-a ? is a chord you can paste in here. Anything else is the +// character you press, shift included: "?" is shift+slash, "H" is shift+h. +// +// `slosh --check` reads this file and says what it could not honour. +// +// Actions: split split-cols split-rows close-pane focus-left focus-right focus-up +// focus-down focus-next resize-left resize-right resize-up resize-down equalize +// rotate-layout clear-shaders pane-to-next-tab pane-to-prev-tab pane-to-new-tab +// scroll-up scroll-down scroll-page-up scroll-page-down scroll-top +// scroll-bottom zoom minimize rerun new-tab next-tab prev-tab select-tab-1..9 finder +// palette help detach quit +// literal-prefix none + +// What C-a e opens this file with. Empty means $EDITOR, and then vi -- the +// one editor a POSIX system is required to have, so a wrong guess you can see +// beats a pane that opens empty. +// +// The pane it opens is *ephemeral*: quit the editor and the pane goes, with +// no corpse to dismiss and no trace in a dumped layout. You asked for an +// editor, not for a record of having had one. +// +// editor "nvim" + +keys { + // The leader. Every binding below is this key, then the key on the line. + // ctrl+a is a default and not a decision -- it is start-of-line to half + // the world, and if that is you, change it here and nothing else: the + // badge in the status bar and the cheatsheet (C-a ?) both name whatever + // is bound, and the old key goes straight through to your shell. + // + // Pressing it twice always sends the key itself, whichever key it is. + prefix "ctrl+a" + + // Enter splits whichever way there is room for, preferring the longer side; + // the next two are for when you mean a particular axis. + bind "enter" "split" + bind "\\" "split-cols" + bind "-" "split-rows" + bind "x" "close-pane" + // Run a dead pane's command again, in the same pane and on top of the + // same scrollback. Refused on a pane that is still running. + bind "r" "rerun" + bind "z" "zoom" + bind "m" "minimize" + + bind "h" "focus-left" + bind "l" "focus-right" + bind "k" "focus-up" + bind "j" "focus-down" + bind "left" "focus-left" + bind "right" "focus-right" + bind "up" "focus-up" + bind "down" "focus-down" + bind "o" "focus-next" + + // The boundary moves in the direction you press, whichever side of it the + // focused pane is on. + bind "shift+h" "resize-left" + bind "shift+l" "resize-right" + bind "shift+k" "resize-up" + bind "shift+j" "resize-down" + + // Undo every drag and nudge at once: each split divided between the panes + // behind it, so every visible pane gets an even share of the rows and + // columns it competes for. + bind "=" "equalize" + + // A quarter turn of the whole tab, clockwise: every split changes axis and + // a row split's children reverse, because the pane on top of a stack is the + // pane on the right once you turn it. Four presses come back exactly, so + // there is nothing to undo -- which is what earns it the space bar, the + // easiest key there is and the one tmux spends on cycling layouts. Refused + // with a word when the turn would leave a pane under the floor: a share of + // the height is not always a share the width can afford, and `=` first + // usually fixes that. + bind "space" "rotate-layout" + + // Push the focused pane one tab along, or out into a tab of its own. `>` and + // `<` are the shifted period and comma; a terminal does not report the shift + // for punctuation, so `.` and `,` answer to them too -- the same deal `/` + // already has with `?`. + bind ">" "pane-to-next-tab" + bind "<" "pane-to-prev-tab" + bind "b" "pane-to-new-tab" + bind "c" "new-tab" + // Tab cycles tabs, the way every other tabbed thing does. `n` and `p` + // used to, and are free again: `p` is worth more as the palette, which is + // pressed rather more often than "the tab before this one". + bind "tab" "next-tab" + bind "shift+tab" "prev-tab" + bind "1" "select-tab-1" + bind "2" "select-tab-2" + bind "3" "select-tab-3" + + bind "f" "finder" + + // Every action by name, including the ones nothing is bound to -- which + // is the whole point of it, since an unbound action has no other way in. + bind "p" "palette" + + // The cheatsheet, built from whatever is bound below rather than from a + // list of what the defaults were -- rebind something and it says so. + // Bound twice because a terminal may or may not tell us `?` was shifted. + bind "?" "help" + bind "/" "help" + + // Scrollback. The wheel does this too, unless the program in the pane + // wants the mouse itself, or is a full-screen program with no scrollback + // of ours to show — then the wheel means arrow keys to it. + bind "pageup" "scroll-page-up" + bind "pagedown" "scroll-page-down" + bind "home" "scroll-top" + bind "end" "scroll-bottom" + bind "d" "detach" + bind "q" "quit" + + // ── bindings that need no leader ──────────────────────────────────────── + // + // Anything in here fires the moment you press it, with no prefix first. + // That also means the chord is *gone* from every program in every pane: + // vim will never see it, your shell will never see it, and the only sign + // will be that the key does not work in there any more. + // + // It is a footgun and it is yours: this is your keyboard, and a + // multiplexer that refuses to get out of the way is its own kind of + // wrong. Prefer chords no program wants -- ctrl+alt+something is usually + // safe, a bare letter certainly is not. + // + // `none` gives a chord back, the same as anywhere else. + // + // direct { + // bind "ctrl+alt+left" "focus-left" + // bind "ctrl+alt+right" "focus-right" + // bind "ctrl+alt+up" "focus-up" + // bind "ctrl+alt+down" "focus-down" + // } +} diff --git a/tests/fuzz/corpus/kdl/example.layout b/tests/fuzz/corpus/kdl/example.layout new file mode 100644 index 0000000..04f268f --- /dev/null +++ b/tests/fuzz/corpus/kdl/example.layout @@ -0,0 +1,57 @@ +// A session described as a file: slosh --layout project.layout +// +// Name them `*.layout`. The syntax is the same KDL subset the config uses -- +// one parser reads both -- so the extension says how to read the file and the +// `.layout` says what is in it. Two documents sharing a syntax is only confusing +// while nothing says which is which: hand this file to `slosh --check` and it +// tells you it is a layout, and hand a config to `--layout` and it says that too. +// +// The same shape can be sent to a running session over the control socket: +// slosh cmd '{"cmd":"apply-layout","path":"/path/to/project.layout"}' +// slosh cmd '{"cmd":"apply-layout","kdl":"layout { tab name=\"x\" { pane } }"}' +// which is how an orchestrator adds one project tab at a time. + +layout { + // A tab is a project. `purpose` is its identity — declared here, it is + // locked: no program running inside can relabel itself into it. A workspace + // opened from a project root stamps this itself, so a file checked in with + // the project leaves it out and lets where the file *is* say which project + // it belongs to. + tab name="api" purpose="project:api.a1b2c3" cwd="/home/user/dev/api" { + + // The tab's panes split into columns by default. + pane purpose="agent:main" command="pi" + + pane split="rows" { + // `suspended` means the pane exists, is laid out, and has run + // nothing. It starts on its first keystroke. Twelve projects + // should not be twelve running dev servers. + pane purpose="service:web" command="npm run dev" suspended=true + + // No command: the configured shell, in the tab's cwd. + pane purpose="shell:scratch" + } + } + + tab name="notes" cwd="/home/user/notes" { + pane command="nvim ." suspended=true + } +} + +// The same file, checked in beside the project it describes: no absolute paths +// anywhere in it, because a relative `cwd=` means the directory *this file* is +// in and a pane with no `cwd=` at all starts there too. Which is what +// `save-workspace` writes, and what makes it the same file on another machine. +// +// layout { +// tab name="api" { +// pane purpose="agent:main" command="pi" +// pane split="rows" { +// pane purpose="service:web" cwd="web" command="npm run dev" suspended=true +// pane purpose="shell:scratch" +// } +// } +// } + +// Note on escapes: strings understand \n \t \r \e \\ \" — \e is an ESC, and +// there is no \033. A command that needs an escape should use \e. diff --git a/tests/fuzz/corpus/kdl/regress_trailing_backslash b/tests/fuzz/corpus/kdl/regress_trailing_backslash new file mode 100644 index 0000000..5642140 --- /dev/null +++ b/tests/fuzz/corpus/kdl/regress_trailing_backslash @@ -0,0 +1 @@ +a"\
\ No newline at end of file diff --git a/tests/fuzz/corpus/kdl/theme.kdl b/tests/fuzz/corpus/kdl/theme.kdl new file mode 100644 index 0000000..0d8f7a8 --- /dev/null +++ b/tests/fuzz/corpus/kdl/theme.kdl @@ -0,0 +1,81 @@ +// default -- what no config gets you: muted blue on neutral dark +// +// This file exists so the tour can show the starting point and so another +// theme is one include-swap away from coming back. It matches the compiled-in +// palette exactly (`slosh --dump-config` is the authority if they ever drift). +// +// Two colours mean something rather than match something: `bell` is amber +// (attention, not focus) and `dead` is warm red-orange. Everything else that +// says "this one" -- the focused frame, the guide, the active tab -- is one +// muted blue, calm enough to frame text without shouting over it. +theme { + // what a shader takes "terminal default" to mean + default_fg "#ffffff" + default_bg "#000000" + + // panes + frame_focus "#7aa2f7" + frame_idle "#45454a" + title "#ffffff" + title_idle "#45454a" + + // a pane's own OSC 5577 buttons + button_fg "#141418" + button_bg "#7aa2f7" + button_bg_idle "#55555c" + + // affordances under the pointer + guide "#7aa2f7" + resize "#7aa2f7" + drop_target "#7aa2f7" + + // scrollback indicator + scroll_fg "#141418" + scroll_bg "#7aa2f7" + + // rows of a tab collapsed into a list + header "#45454a" + header_hover "#7aa2f7" + header_hover_title "#ffffff" + + // the strip along the top + tab_active_fg "#141418" + tab_active_bg "#7aa2f7" + tab_active_hover_fg "#ffffff" + tab_idle "#45454a" + tab_hover "#7aa2f7" + prefix_fg "#141418" + prefix_bg "#7aa2f7" + tab_count "#45454a" + + // the line along the bottom + status "#45454a" + status_state "#ffffff" + hint "#ffffff" + + // the pane finder + finder_fg "#ffffff" + finder_bg "#45454a" + finder_sel_fg "#141418" + finder_sel_bg "#7aa2f7" + + // rename editor, bell, dead panes, frame buttons, the minimised bar + rename_fg "#141418" + rename_bg "#7aa2f7" + toast_fg "#141418" + toast_bg "#7aa2f7" + bell "#e0af68" + dead "#ff875f" + pane_button "#45454a" + pane_button_hover "#7aa2f7" + minbar "#45454a" + minbar_hover "#7aa2f7" + + // a modal floating over the layout (the cheatsheet, C-a ?) + modal_bg "#1c1c22" + modal_fg "#e8e8ea" + modal_border "#7aa2f7" + modal_title "#ffffff" + modal_button "#8a8a95" + modal_button_hover "#7aa2f7" +} diff --git a/tests/fuzz/corpus/osc5577/buttons b/tests/fuzz/corpus/osc5577/buttons new file mode 100644 index 0000000..2c720d8 --- /dev/null +++ b/tests/fuzz/corpus/osc5577/buttons @@ -0,0 +1 @@ +]5577;1;buttons;approve:Approve;cancel:Cancel\
\ No newline at end of file diff --git a/tests/fuzz/corpus/osc5577/clear b/tests/fuzz/corpus/osc5577/clear new file mode 100644 index 0000000..5e0bbce --- /dev/null +++ b/tests/fuzz/corpus/osc5577/clear @@ -0,0 +1 @@ +]5577;1;clear\
\ No newline at end of file diff --git a/tests/fuzz/corpus/osc5577/escapes b/tests/fuzz/corpus/osc5577/escapes new file mode 100644 index 0000000..e7c0a39 --- /dev/null +++ b/tests/fuzz/corpus/osc5577/escapes @@ -0,0 +1 @@ +]5577;1;status;a%3Bb%3Ac%25d%zz%\
\ No newline at end of file diff --git a/tests/fuzz/corpus/osc5577/mixed b/tests/fuzz/corpus/osc5577/mixed new file mode 100644 index 0000000..ed6d6fb --- /dev/null +++ b/tests/fuzz/corpus/osc5577/mixed @@ -0,0 +1 @@ +plain text ]0;title more ]5577;1;status;hi tail [31m
\ No newline at end of file diff --git a/tests/fuzz/corpus/osc5577/status b/tests/fuzz/corpus/osc5577/status new file mode 100644 index 0000000..3c93da9 --- /dev/null +++ b/tests/fuzz/corpus/osc5577/status @@ -0,0 +1 @@ +]5577;1;status;building 3/7\
\ No newline at end of file diff --git a/tests/fuzz/corpus/osc5577/unterminated b/tests/fuzz/corpus/osc5577/unterminated new file mode 100644 index 0000000..b2c939f --- /dev/null +++ b/tests/fuzz/corpus/osc5577/unterminated @@ -0,0 +1 @@ +]5577;1;status;AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
\ No newline at end of file diff --git a/tests/fuzz/corpus/proto/cmd b/tests/fuzz/corpus/proto/cmd Binary files differnew file mode 100644 index 0000000..f94a4ee --- /dev/null +++ b/tests/fuzz/corpus/proto/cmd diff --git a/tests/fuzz/corpus/proto/hello b/tests/fuzz/corpus/proto/hello Binary files differnew file mode 100644 index 0000000..4ffedfd --- /dev/null +++ b/tests/fuzz/corpus/proto/hello diff --git a/tests/fuzz/corpus/proto/lying_len b/tests/fuzz/corpus/proto/lying_len new file mode 100644 index 0000000..74bb2cf --- /dev/null +++ b/tests/fuzz/corpus/proto/lying_len @@ -0,0 +1 @@ +ÿÿÿÿxx
\ No newline at end of file diff --git a/tests/fuzz/corpus/proto/stream b/tests/fuzz/corpus/proto/stream Binary files differnew file mode 100644 index 0000000..f06f1d4 --- /dev/null +++ b/tests/fuzz/corpus/proto/stream diff --git a/tests/fuzz/corpus/proto/truncated b/tests/fuzz/corpus/proto/truncated Binary files differnew file mode 100644 index 0000000..294f68e --- /dev/null +++ b/tests/fuzz/corpus/proto/truncated diff --git a/tests/fuzz/fuzz_expr.c b/tests/fuzz/fuzz_expr.c new file mode 100644 index 0000000..3942524 --- /dev/null +++ b/tests/fuzz/fuzz_expr.c @@ -0,0 +1,71 @@ +/* Fuzz target: the shader expression language (src/expr.c). + * + * Compiles arbitrary text, and when a program comes back actually runs it: + * once per hand-picked environment (zeros, a plausible pane, and extremes) + * and once as an amount map over a small rect. The language is documented as + * total -- no way to hang, div/mod by zero is 0 -- so anything ASan/UBSan + * catches in eval is a real bug, including signed overflow in the arithmetic. + */ +#include "expr.h" + +#include <limits.h> +#include <stdint.h> +#include <stdlib.h> +#include <string.h> + +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { + char *text = malloc(size + 1); + if (!text) return 0; + memcpy(text, data, size); + text[size] = '\0'; + + char err[256]; + expr_prog_t *p = expr_compile(text, err, sizeof err); + if (p) { + unsigned deps = expr_deps(p); + if (deps == 0) (void)expr_constant(p); + (void)expr_source(p); + + const expr_env_t envs[] = { + {0}, + {.x = 79, + .y = 23, + .cols = 80, + .rows = 24, + .curx = 40, + .cury = 12, + .cursor = 1, + .focused = 1, + .t = 1234567890123LL, + .since = 42, + .above = 10000, + .below = 3}, + {.x = INT_MAX, + .y = INT_MIN, + .cols = INT_MAX, + .rows = INT_MAX, + .curx = -1, + .cury = -1, + .cursor = -1, + .focused = -1, + .t = INT64_MAX, + .since = INT64_MIN, + .above = INT_MAX, + .below = INT_MIN}, + }; + for (size_t i = 0; i < sizeof envs / sizeof envs[0]; i++) + (void)expr_eval(p, &envs[i]); + + /* the cached per-cell map, over a rect small enough to fill twice */ + expr_env_t env = {.cols = 8, .rows = 4, .cursor = 1, .t = 17}; + uint8_t *map = NULL; + (void)expr_amount_map(p, &env, &map); + env.curx = 3; + env.cury = 2; + (void)expr_amount_map(p, &env, &map); /* map is owned by the program */ + + expr_free(p); + } + free(text); + return 0; +} diff --git a/tests/fuzz/fuzz_input.c b/tests/fuzz/fuzz_input.c new file mode 100644 index 0000000..3d6dde3 --- /dev/null +++ b/tests/fuzz/fuzz_input.c @@ -0,0 +1,45 @@ +/* Fuzz target: the input decoder (src/input.c). + * + * Decodes the bytes the outer terminal sends: CSI, SS3, kitty keyboard, + * SGR mouse, bracketed paste, focus reports -- and any terminal can send any + * garbage. Feed in data-chosen chunks so split escape sequences are hit, + * then resolve the pending-ESC path via input_timeout, and render every + * event with input_event_describe so the event's fields are all read. + */ +#include "input.h" + +#include <stdint.h> +#include <stdlib.h> +#include <string.h> + +static void on_event(const input_event_t *ev, void *ud) { + (void)ud; + char buf[256]; + input_event_describe(ev, buf, sizeof buf); + input_event_describe(ev, buf, 4); /* tiny cap must truncate, not overrun */ + if (ev->kind == EV_PASTE && ev->paste_len) { + volatile char sink = ev->paste[0]; + sink = ev->paste[ev->paste_len - 1]; + (void)sink; + } + if (ev->kind == EV_KEY && ev->text_len) { + if (ev->text_len > sizeof ev->text) abort(); + } +} + +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { + input_parser_t *p = input_new(); + if (!p) return 0; + + size_t i = 0; + while (i < size) { + size_t chunk = (size_t)(data[i] % 11) + 1; /* small, split-heavy */ + if (chunk > size - i) chunk = size - i; + input_feed(p, data + i, chunk, on_event, NULL); + i += chunk; + } + + if (input_pending(p)) input_timeout(p, on_event, NULL); + input_free(p); + return 0; +} diff --git a/tests/fuzz/fuzz_jsonval.c b/tests/fuzz/fuzz_jsonval.c new file mode 100644 index 0000000..fee4279 --- /dev/null +++ b/tests/fuzz/fuzz_jsonval.c @@ -0,0 +1,48 @@ +/* Fuzz target: the JSON reader (src/jsonval.c). + * + * This is what the control API feeds every MSG_CMD line into, so its input + * is exactly "whatever a client wrote on the socket". Parse, then walk the + * whole value through the accessors cmd.c uses. + */ +#include "jsonval.h" + +#include <stdint.h> +#include <stdlib.h> +#include <string.h> + +static void walk(const jv_t *v, int depth) { + if (!v || depth > 200) return; + (void)jv_str(v, "fallback"); + (void)jv_int(v, -1); + (void)jv_bool(v, false); + if (v->kind == JV_OBJ) { + (void)jv_get(v, "no-such-key"); + (void)jv_gets(v, "no-such-key", "fb"); + (void)jv_geti(v, "no-such-key", 7); + (void)jv_getb(v, "no-such-key", true); + for (size_t i = 0; i < v->len; i++) { + (void)jv_get(v, v->keys[i]); + walk(v->kids[i], depth + 1); + } + } else if (v->kind == JV_ARR) { + for (size_t i = 0; i < v->len; i++) walk(v->kids[i], depth + 1); + } else if (v->kind == JV_STR) { + /* the string must really be len bytes and NUL-terminated */ + if (v->str && strlen(v->str) > v->len) abort(); + } +} + +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { + char *text = malloc(size + 1); + if (!text) return 0; + memcpy(text, data, size); + text[size] = '\0'; + + jv_t *v = jv_parse(text); + if (v) { + walk(v, 0); + jv_free(v); + } + free(text); + return 0; +} diff --git a/tests/fuzz/fuzz_kdl.c b/tests/fuzz/fuzz_kdl.c new file mode 100644 index 0000000..644166e --- /dev/null +++ b/tests/fuzz/fuzz_kdl.c @@ -0,0 +1,48 @@ +/* Fuzz target: the KDL parser (src/kdl.c). + * + * Parses arbitrary bytes as a document, and when a tree comes back walks all + * of it through the same accessors real callers use, so a node the parser + * built wrong (NULL name, dangling arg, bad count) is dereferenced here + * rather than later in config load. + */ +#include "kdl.h" + +#include <stdint.h> +#include <stdlib.h> +#include <string.h> + +static void walk(const kdl_node_t *n, int depth) { + if (!n || depth > 200) return; + (void)kdl_arg(n, 0, "fallback"); + (void)kdl_arg(n, n->nargs, "past-the-end"); + (void)kdl_arg_int(n, 0, -1); + (void)kdl_arg_bool(n, 0, false); + (void)kdl_prop(n, "key", NULL); + (void)kdl_prop_int(n, "key", -1); + (void)kdl_prop_bool(n, "key", true); + for (size_t i = 0; i < n->nprops; i++) { + (void)kdl_prop(n, n->props[i].key, NULL); + (void)kdl_prop_int(n, n->props[i].key, 0); + (void)kdl_prop_bool(n, n->props[i].key, false); + } + for (size_t i = 0; i < n->nkids; i++) { + (void)kdl_child(n, n->kids[i]->name); + walk(n->kids[i], depth + 1); + } +} + +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { + char *text = malloc(size + 1); + if (!text) return 0; + memcpy(text, data, size); + text[size] = '\0'; + + char err[256]; + kdl_node_t *root = kdl_parse(text, err, sizeof err); + if (root) { + walk(root, 0); + kdl_free(root); + } + free(text); + return 0; +} diff --git a/tests/fuzz/fuzz_osc5577.c b/tests/fuzz/fuzz_osc5577.c new file mode 100644 index 0000000..3b197fb --- /dev/null +++ b/tests/fuzz/fuzz_osc5577.c @@ -0,0 +1,38 @@ +/* Fuzz target: the OSC 5577 scanner (src/osc5577.c). + * + * This scans every byte a pane's program writes, so its input is the least + * trusted stream in the whole session. Feed the bytes twice -- once whole, + * once split at a data-chosen boundary so partial-sequence state is + * exercised -- and in the callback run the payload through the unescaper at + * several output capacities plus the id validator, exactly as pane.c would. + */ +#include "osc5577.h" + +#include <stdint.h> +#include <string.h> + +static void on_seq(const char *verb, const char *payload, void *ud) { + (void)ud; + (void)osc5577_valid_id(verb); + char out[OSC5577_MAX]; + size_t plen = strlen(payload); + (void)osc5577_unescape(payload, plen, out, sizeof out); + (void)osc5577_unescape(payload, plen, out, 1); /* documented minimum cap */ + (void)osc5577_unescape(payload, plen, out, 2); + (void)osc5577_unescape(payload, plen, out, 7); +} + +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { + osc_scan_t s; + + osc_scan_reset(&s); + osc_scan_feed(&s, data, size, on_seq, NULL); + + /* again, split so a sequence straddles the feed boundary */ + osc_scan_reset(&s); + size_t cut = size ? (size_t)(data[0] * (size - 1) / 255) : 0; + osc_scan_feed(&s, data, cut, on_seq, NULL); + osc_scan_feed(&s, data + cut, size - cut, on_seq, NULL); + + return 0; +} diff --git a/tests/fuzz/fuzz_proto.c b/tests/fuzz/fuzz_proto.c new file mode 100644 index 0000000..a1c4899 --- /dev/null +++ b/tests/fuzz/fuzz_proto.c @@ -0,0 +1,38 @@ +/* Fuzz target: the wire framing (src/proto.c). + * + * msg_reader_feed/next is the first thing that touches bytes off the unix + * socket, from either side, so it must survive arbitrary garbage: lying u32 + * lengths, truncated frames, frames split at every possible boundary. The + * input's own bytes choose the chunk sizes, so the fuzzer explores the + * split points too. + */ +#include "proto.h" + +#include <stdint.h> +#include <string.h> + +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { + msg_reader_t r; + msg_reader_init(&r); + + size_t i = 0; + while (i < size) { + size_t chunk = (size_t)(data[i] % 37) + 1; /* 1..37, data-driven splits */ + if (chunk > size - i) chunk = size - i; + msg_reader_feed(&r, data + i, chunk); + i += chunk; + + msg_t m; + while (msg_reader_next(&r, &m)) { + /* the payload must really be m.len readable bytes */ + if (m.len) { + volatile uint8_t sink = m.data[0]; + sink = m.data[m.len - 1]; + (void)sink; + } + } + } + + msg_reader_free(&r); + return 0; +} diff --git a/tests/fuzz/standalone_main.c b/tests/fuzz/standalone_main.c new file mode 100644 index 0000000..2ec7f9f --- /dev/null +++ b/tests/fuzz/standalone_main.c @@ -0,0 +1,50 @@ +/* A main() for running fuzz targets without libFuzzer: replays each file + * named on the command line (or stdin if none) through the target once. + * This is how a crashing input gets reproduced under a debugger, and how + * the corpus doubles as a regression suite in CI without clang's fuzzer + * runtime. Same contract as LLVM's StandaloneFuzzTargetMain.c. + */ +#include <stdint.h> +#include <stdio.h> +#include <stdlib.h> + +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size); + +static int run_stream(FILE *f, const char *name) { + size_t cap = 1 << 16, len = 0; + uint8_t *buf = malloc(cap); + if (!buf) return 1; + size_t n; + while ((n = fread(buf + len, 1, cap - len, f)) > 0) { + len += n; + if (len == cap) { + cap *= 2; + uint8_t *nb = realloc(buf, cap); + if (!nb) { + free(buf); + return 1; + } + buf = nb; + } + } + fprintf(stderr, "run %s (%zu bytes)\n", name, len); + LLVMFuzzerTestOneInput(buf, len); + free(buf); + return 0; +} + +int main(int argc, char **argv) { + if (argc < 2) return run_stream(stdin, "<stdin>"); + for (int i = 1; i < argc; i++) { + FILE *f = fopen(argv[i], "rb"); + if (!f) { + perror(argv[i]); + return 1; + } + int rc = run_stream(f, argv[i]); + fclose(f); + if (rc) return rc; + } + fprintf(stderr, "ok: %d input(s)\n", argc - 1); + return 0; +} |
