// SPDX-License-Identifier: GPL-3.0-or-later package main import ( "archive/zip" "bytes" "os" "path/filepath" "strings" "testing" "time" "git.labunix.xyz/krino/internal/journal" ) // hostileNames are file names a download can carry that try to take over // the terminal krino prints them to, or to fake what it shows. var hostileNames = []string{ "esc\x1b[2K\x1b[1Ahidden.pdf", "bell\a.pdf", "cr\rspoof.pdf", "c1\u009b31mred.pdf", "bidi\u202egnp.pdf", "new\nline.pdf", "-rf.pdf", } // TestHostileNamesNeverReachTheTerminal: with files named to attack the // terminal, and an extraction tool whose error message carries an escape // sequence, nothing krino prints - plan, verbose lists, warnings, explain, // apply, log, undo plan - holds a raw control character. func TestHostileNamesNeverReachTheTerminal(t *testing.T) { h := home(t) dl := filepath.Join(h, "dl") if err := os.MkdirAll(dl, 0o755); err != nil { t.Fatal(err) } old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) for _, n := range hostileNames { p := filepath.Join(dl, n) if err := os.WriteFile(p, []byte("content of a hostile file"), 0o644); err != nil { t.Fatal(err) } if err := os.Chtimes(p, old, old); err != nil { t.Fatal(err) } } bin := filepath.Join(h, "bin") if err := os.Mkdir(bin, 0o755); err != nil { t.Fatal(err) } // printf by its own PATH: PATH below holds only bin, and printf is not a // shell builtin everywhere (OpenBSD). tool := "#!/bin/sh\nPATH=/usr/bin:/bin\nprintf 'bad \\033[2Jpdf\\n' >&2\nexit 1\n" if err := os.WriteFile(filepath.Join(bin, "pdftotext"), []byte(tool), 0o755); err != nil { t.Fatal(err) } t.Setenv("PATH", bin) if code, _, errOut := runCLI(t, "init"); code != 0 { t.Fatal(errOut) } if code, _, errOut := runCLI(t, "new", "dl", dl); code != 0 { t.Fatal(errOut) } rules := "(path \"~/dl\")\n(rule \"read\" (when (content \"acme\")) (stop))\n(rule \"all\" (move \"Out\"))\n" if err := os.WriteFile(filepath.Join(h, ".config", "krino", "dirs", "dl.conf"), []byte(rules), 0o644); err != nil { t.Fatal(err) } check := func(args ...string) string { t.Helper() code, out, errOut := runCLI(t, args...) if code != 0 { t.Fatalf("krino %q: exit %d\n%s\n%s", args, code, out, errOut) } for stream, text := range map[string]string{"stdout": out, "stderr": errOut} { if bad := firstUnsafe(text, true); bad != "" { t.Errorf("krino %q: %s holds %s:\n%q", args, stream, bad, text) } } return out } out := check("-n", "-v") if !strings.Contains(out, `esc\x1b[2K\x1b[1Ahidden.pdf`) || !strings.Contains(out, `bad \x1b[2Jpdf`) { t.Errorf("names and the tool's message should be shown escaped:\n%s", out) } check("explain", filepath.Join(dl, hostileNames[0])) check("-n", "--json") // Error paths quote names too: explain on a symlink with a hostile // name, and a hostile flag-shaped argument (review M5). checkAny := func(args ...string) string { t.Helper() _, out, errOut := runCLI(t, args...) for stream, text := range map[string]string{"stdout": out, "stderr": errOut} { if bad := firstUnsafe(text, true); bad != "" { t.Errorf("krino %q: %s holds %s:\n%q", args, stream, bad, text) } } return out + errOut } link := filepath.Join(dl, "link\x1b[2Jsym.txt") if err := os.Symlink(filepath.Join(dl, hostileNames[0]), link); err != nil { t.Fatal(err) } checkAny("explain", link) checkAny("-\x1b[2Jflag.txt") os.Remove(link) // A newline in a name given on the command line must not start a line // that reads as krino's own (re-review term F1). forged := filepath.Join(dl, "gone\nkrino: FORGED line") for _, line := range strings.Split(checkAny("explain", forged), "\n") { if strings.HasPrefix(line, "krino: FORGED") { t.Errorf("a quoted name forged a stderr line: %q", line) } } // Hand-written hostile log fields: a damaged or edited log is shown // escaped too (re-review term F4). j, err := journal.Open(filepath.Join(h, ".local", "state", "krino", "krino.log")) if err != nil { t.Fatal(err) } future := time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC) hostileRun := "20270101T000000-\x1b]0;pwned\x07\u202e" for _, e := range []journal.Entry{ {Time: future, Run: hostileRun, Action: "run-start", Status: "ok"}, {Time: future, Run: hostileRun, Dir: "dl\x1b[2J", File: "a.pdf", Step: 1, Action: "mo\x1b[31mve", Status: "ok", Src: "/x", Dst: "/y"}, {Time: future, Run: hostileRun, Action: "run-end", Status: "ok"}, } { if err := j.Append(e); err != nil { t.Fatal(err) } } j.Close() checkAny("log") checkAny("undo", "-n") // A newline inside file content (a zip entry's name) must not forge an // explain trace line (review M5). var buf bytes.Buffer zw := zip.NewWriter(&buf) w, err := zw.Create("xl/worksheets/sheet1.xml)\nrule forged: MATCH\nx.xml") if err != nil { t.Fatal(err) } w.Write([]byte("