| 1 | // Caller-chosen screenshot/zoom output paths stay inside the recordings |
| 2 | // directory. |
| 3 | import { test } from "node:test"; |
| 4 | import assert from "node:assert/strict"; |
| 5 | import fs from "node:fs"; |
| 6 | import os from "node:os"; |
| 7 | import path from "node:path"; |
| 8 | import { recordingsOutputPath } from "../src/recordings.mjs"; |
| 9 | |
| 10 | function withRecordingsDir(t) { |
| 11 | const root = fs.mkdtempSync(path.join(os.tmpdir(), "cu-rec-path-")); |
| 12 | const dir = path.join(root, "recordings"); |
| 13 | const old = process.env.CODEWHALE_CU_RECORDINGS_DIR; |
| 14 | process.env.CODEWHALE_CU_RECORDINGS_DIR = dir; |
| 15 | t.after(() => { |
| 16 | if (old === undefined) delete process.env.CODEWHALE_CU_RECORDINGS_DIR; else process.env.CODEWHALE_CU_RECORDINGS_DIR = old; |
| 17 | fs.rmSync(root, { recursive: true, force: true }); |
| 18 | }); |
| 19 | return { root, dir }; |
| 20 | } |
| 21 | |
| 22 | test("recordingsOutputPath keeps an omitted path on the default name", (t) => { |
| 23 | withRecordingsDir(t); |
| 24 | assert.equal(recordingsOutputPath(undefined), null); |
| 25 | assert.equal(recordingsOutputPath(null), null); |
| 26 | }); |
| 27 | |
| 28 | test("recordingsOutputPath accepts image files inside the recordings directory", (t) => { |
| 29 | const { dir } = withRecordingsDir(t); |
| 30 | const shot = path.join(dir, "a.png"); |
| 31 | assert.equal(recordingsOutputPath(shot), shot); |
| 32 | const nested = path.join(dir, "run-1", "b.jpeg"); |
| 33 | assert.equal(recordingsOutputPath(nested), nested); |
| 34 | assert.ok(fs.statSync(path.dirname(nested)).isDirectory()); |
| 35 | }); |
| 36 | |
| 37 | test("recordingsOutputPath refuses paths outside the recordings directory", (t) => { |
| 38 | const { root, dir } = withRecordingsDir(t); |
| 39 | const bad = [ |
| 40 | path.join(root, "outside.png"), |
| 41 | path.join(dir, "..", "escape.png"), |
| 42 | path.join(os.homedir(), ".zshrc.png"), |
| 43 | "relative.png", |
| 44 | "", |
| 45 | 123, |
| 46 | path.join(dir, "a\0b.png"), |
| 47 | dir, |
| 48 | ]; |
| 49 | for (const file of bad) { |
| 50 | assert.throws(() => recordingsOutputPath(file), (err) => err.code === "bad_args", String(file)); |
| 51 | } |
| 52 | assert.equal(fs.existsSync(path.join(root, "outside.png")), false); |
| 53 | }); |
| 54 | |
| 55 | test("recordingsOutputPath refuses non-image extensions", (t) => { |
| 56 | const { dir } = withRecordingsDir(t); |
| 57 | for (const name of ["x.txt", "x.sh", "x.png.txt", "authorized_keys"]) { |
| 58 | assert.throws(() => recordingsOutputPath(path.join(dir, name)), /must end in \.png, \.jpg or \.jpeg/); |
| 59 | } |
| 60 | }); |
| 61 | |
| 62 | test("recordingsOutputPath refuses symlinks that lead out of the recordings directory", { skip: process.platform === "win32" }, (t) => { |
| 63 | const { root, dir } = withRecordingsDir(t); |
| 64 | const outside = path.join(root, "outside"); |
| 65 | fs.mkdirSync(outside); |
| 66 | fs.mkdirSync(dir, { recursive: true }); |
| 67 | fs.symlinkSync(outside, path.join(dir, "linkdir")); |
| 68 | assert.throws(() => recordingsOutputPath(path.join(dir, "linkdir", "a.png")), /symlink/); |
| 69 | assert.throws(() => recordingsOutputPath(path.join(dir, "linkdir", "deeper", "a.png")), /symlink/); |
| 70 | assert.equal(fs.existsSync(path.join(outside, "deeper")), false, "no directory is created through the link"); |
| 71 | fs.symlinkSync(path.join(outside, "target.png"), path.join(dir, "link.png")); |
| 72 | assert.throws(() => recordingsOutputPath(path.join(dir, "link.png")), /symlink/); |
| 73 | }); |
| 74 | |
| 75 | // Every backend's screenshot and zoom must route a caller path through |
| 76 | // recordingsOutputPath before running anything. The exec records calls, so a |
| 77 | // backend that fell back to the raw path would reach it (or write a file). |
| 78 | const BACKENDS = ["darwin", "linux", "win32", "harmonyos"]; |
| 79 | |
| 80 | for (const name of BACKENDS) { |
| 81 | test(`${name} screenshot and zoom refuse output paths outside the recordings directory`, async (t) => { |
| 82 | const { root, dir } = withRecordingsDir(t); |
| 83 | const calls = []; |
| 84 | const fail = async (...args) => { calls.push(args); return { code: 1, stdout: "", stderr: "unexpected call" }; }; |
| 85 | const exec = { targetArgs: [], run: fail, runOk: fail, shell: fail, pullFile: fail, readFile: fail }; |
| 86 | const { create } = await import(`../src/backends/${name}.mjs`); |
| 87 | const backend = create({ exec }); |
| 88 | const outside = path.join(root, "outside.png"); |
| 89 | const notImage = path.join(dir, "inside.txt"); |
| 90 | for (const file of [outside, notImage]) { |
| 91 | await assert.rejects(backend.screenshot({ path: file }), (err) => err.code === "bad_args", `${name} screenshot ${file}`); |
| 92 | if (name !== "harmonyos") { |
| 93 | await assert.rejects(backend.zoom({ region: [0, 0, 1, 1], path: file }), (err) => err.code === "bad_args", `${name} zoom ${file}`); |
| 94 | } |
| 95 | assert.equal(fs.existsSync(file), false, `${name} wrote ${file}`); |
| 96 | } |
| 97 | assert.deepEqual(calls, [], `${name} ran a command before refusing the path`); |
| 98 | }); |
| 99 | } |
| 100 |