From 0b80f5cdc8985e2713f3206d1a06ca57ab743938 Mon Sep 17 00:00:00 2001 From: vybe Date: Fri, 22 May 2026 06:33:08 +0000 Subject: [PATCH] feat: extract UI strings to lib.js; harden clipboard to direct argv, drop clipmanctl (#34, #49) - safeClipboardTools now only dms + wl-copy (clipmanctl removed as niche optional history manager) - buildCopyCommand now returns pure argv arrays (no sh -c, no escaping, eliminates future injection risk) - Added getStrings() for centralised user-facing strings (prep for real i18n) - Updated all tests (TDD) and README - All 29 tests pass Written by AI agent working for @jtmorris. Model: Grok build-0.1. --- README.md | 2 +- tailscalectl/lib.js | 37 ++++++++++++++++++++++++++----------- test/lib.test.js | 33 +++++++++++++++++++++++++-------- 3 files changed, 52 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 8869ca8..4cdd9e1 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ A lightweight widget plugin that shows Tailscale connectivity status on the Dank - Dank Material Shell installed and running - `tailscale` CLI available on `PATH` -- A clipboard tool (`dms`, `wl-copy`, or `clipmanctl`) +- A clipboard tool (`dms` or `wl-copy`) ## Installation diff --git a/tailscalectl/lib.js b/tailscalectl/lib.js index fa684e8..7c9df54 100644 --- a/tailscalectl/lib.js +++ b/tailscalectl/lib.js @@ -31,12 +31,13 @@ function findActiveExitNode(peerMap) { return "" } -var safeClipboardTools = ["dms", "wl-copy", "clipmanctl"] +var safeClipboardTools = ["dms", "wl-copy"] -var clipboardCmdMap = { - "dms": "dms cl copy", - "wl-copy": "wl-copy", - "clipmanctl": "clipmanctl copy" +// Direct argv form — no sh -c, no escaping, no future injection surface (#49) +// clipmanctl intentionally dropped (niche optional history manager, not needed on DMS + Wayland) +var clipboardTools = { + "dms": { argv: ["dms", "cl", "copy"] }, + "wl-copy": { argv: ["wl-copy"] } } function validateClipboardTool(tool) { @@ -44,11 +45,10 @@ function validateClipboardTool(tool) { } function buildCopyCommand(text, tool) { - if (!validateClipboardTool(tool)) return null - var cmd = clipboardCmdMap[tool] - if (!cmd) return null - var escaped = text.replace(/'/g, "'\\''") - return ["sh", "-c", "printf '%s' '" + escaped + "' | " + cmd] + const entry = clipboardTools[tool] + if (!entry) return null + // Pure argv — the Process will pass the string verbatim. No shell involved. + return [...entry.argv, text] } function nextClipboardTool(currentTool) { @@ -61,6 +61,21 @@ function allClipboardTools() { return safeClipboardTools.slice() } +function getStrings() { + return { + header: "Tailscale", + connected: "Connected", + disconnected: "Disconnected", + exitNodePrefix: "Exit node: ", + none: "None", + copied: function (text) { return "Copied " + text + " to clipboard" }, + noClipboardTool: "No clipboard tool found", + invalidClipboardCommand: "Invalid clipboard command", + clearExitNode: "×", + setExitNode: "↗" + } +} + function parseStatusResult(jsonText) { try { const data = JSON.parse(jsonText) @@ -93,5 +108,5 @@ function errorMessage(cmd) { // CommonJS export for Node.js tests (ignored by QML) if (typeof module !== "undefined" && module.exports) { - module.exports = { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult } + module.exports = { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult, getStrings } } diff --git a/test/lib.test.js b/test/lib.test.js index 0530478..4d91ff9 100644 --- a/test/lib.test.js +++ b/test/lib.test.js @@ -1,7 +1,7 @@ import { test } from "node:test" import assert from "node:assert" import lib from "../tailscalectl/lib.js" -const { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult } = lib +const { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult, getStrings } = lib test("parsePeers extracts exitNode from ExitNodeOption", () => { const peerMap = { @@ -80,7 +80,7 @@ test("errorMessage returns generic message for unknown command", () => { test("validateClipboardTool accepts whitelisted tool names", () => { assert.strictEqual(validateClipboardTool("dms"), true) assert.strictEqual(validateClipboardTool("wl-copy"), true) - assert.strictEqual(validateClipboardTool("clipmanctl"), true) + // clipmanctl intentionally dropped (see #49) }) test("validateClipboardTool rejects malicious inputs", () => { @@ -101,8 +101,7 @@ test("validateClipboardTool rejects empty and falsy inputs", () => { test("buildCopyCommand returns safe command for whitelisted clipboard tool", () => { const cmd = buildCopyCommand("hello", "wl-copy") assert.ok(Array.isArray(cmd)) - assert.strictEqual(cmd[0], "sh") - assert.strictEqual(cmd[1], "-c") + assert.deepStrictEqual(cmd, ["wl-copy", "hello"]) // direct argv, no sh -c (see #49) }) test("buildCopyCommand returns null for invalid clipboard tool", () => { @@ -120,8 +119,7 @@ test("buildCopyCommand safely handles text with special characters", () => { test("nextClipboardTool cycles through tools", () => { assert.strictEqual(nextClipboardTool("dms"), "wl-copy") - assert.strictEqual(nextClipboardTool("wl-copy"), "clipmanctl") - assert.strictEqual(nextClipboardTool("clipmanctl"), "dms") + assert.strictEqual(nextClipboardTool("wl-copy"), "dms") // clipmanctl dropped (#49) }) test("nextClipboardTool falls back to first tool for unknown input", () => { @@ -134,10 +132,29 @@ test("nextClipboardTool falls back to first tool for unknown input", () => { test("allClipboardTools returns the list of safe tools", () => { const tools = allClipboardTools() assert.ok(Array.isArray(tools)) - assert.strictEqual(tools.length, 3) + assert.strictEqual(tools.length, 2) assert.ok(tools.includes("dms")) assert.ok(tools.includes("wl-copy")) - assert.ok(tools.includes("clipmanctl")) + // clipmanctl intentionally removed (#49) +}) + +// --- getStrings (for #34 i18n prep) --- + +test("getStrings returns canonical UI strings for the widget", () => { + const s = getStrings() + assert.ok(s.header) + assert.ok(s.connected) + assert.ok(s.disconnected) + assert.ok(s.exitNodePrefix) + assert.ok(s.copied) + assert.ok(s.noClipboardTool) + assert.ok(s.invalidClipboardCommand) +}) + +test("getStrings.copied interpolates the text", () => { + const s = getStrings() + const msg = s.copied("100.64.0.5") + assert.ok(msg.includes("100.64.0.5")) }) // --- buildToggleCommand ---