Compare commits
6 commits
7c12793b98
...
93b257ba5d
| Author | SHA1 | Date | |
|---|---|---|---|
| 93b257ba5d | |||
| b521af3364 | |||
| ce8707c618 | |||
| dddd9218f6 | |||
| 3c31209ff1 | |||
| a86d5e279d |
3 changed files with 179 additions and 22 deletions
|
|
@ -66,7 +66,7 @@ PluginComponent {
|
|||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
root.clipboardCmd = this.text.trim()
|
||||
root.clipboardCmd = TailscaleLib.parseClipboardDetection(this.text)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -82,19 +82,11 @@ PluginComponent {
|
|||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
try {
|
||||
const data = JSON.parse(this.text)
|
||||
root.isConnected = data.BackendState === "Running"
|
||||
root.tailscaleIP = (data.Self?.TailscaleIPs?.[0]) || ""
|
||||
root.currentExitNode = TailscaleLib.findActiveExitNode(data.Peer || {})
|
||||
root.peers = TailscaleLib.parsePeers(data.Peer || {})
|
||||
} catch (e) {
|
||||
root.isConnected = false
|
||||
root.tailscaleIP = ""
|
||||
root.currentExitNode = ""
|
||||
root.peers = []
|
||||
ToastService.showError("tailscalectl", "Failed to parse Tailscale status")
|
||||
}
|
||||
const state = TailscaleLib.parseStatusResult(this.text)
|
||||
root.isConnected = state.isConnected
|
||||
root.tailscaleIP = state.tailscaleIP
|
||||
root.currentExitNode = state.currentExitNode
|
||||
root.peers = state.peers
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -119,11 +111,7 @@ PluginComponent {
|
|||
ToastService.showError("tailscalectl", "Tailscale not available")
|
||||
return
|
||||
}
|
||||
if (root.isConnected) {
|
||||
toggleProcess.command = ["tailscale", "down"]
|
||||
} else {
|
||||
toggleProcess.command = ["tailscale", "up"]
|
||||
}
|
||||
toggleProcess.command = TailscaleLib.buildToggleCommand(root.isConnected)
|
||||
toggleProcess.running = true
|
||||
}
|
||||
|
||||
|
|
@ -141,7 +129,12 @@ PluginComponent {
|
|||
ToastService.showError("tailscalectl", "No clipboard tool found")
|
||||
return
|
||||
}
|
||||
copyProcess.command = ["sh", "-c", "printf '%s' '" + text.replace(/'/g, "'\\''") + "' | " + root.clipboardCmd]
|
||||
var cmd = TailscaleLib.buildCopyCommand(text, root.clipboardCmd)
|
||||
if (!cmd) {
|
||||
ToastService.showError("tailscalectl", "Invalid clipboard command")
|
||||
return
|
||||
}
|
||||
copyProcess.command = cmd
|
||||
copyProcess.running = true
|
||||
ToastService.showInfo("Copied " + text + " to clipboard")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,41 @@ function findActiveExitNode(peerMap) {
|
|||
return ""
|
||||
}
|
||||
|
||||
var safeClipboardCmds = ["dms cl copy", "wl-copy", "clipmanctl copy", "none"]
|
||||
|
||||
function validateClipboardCmd(cmd) {
|
||||
return typeof cmd === "string" && safeClipboardCmds.includes(cmd)
|
||||
}
|
||||
|
||||
function buildCopyCommand(text, clipboardCmd) {
|
||||
if (!validateClipboardCmd(clipboardCmd)) return null
|
||||
var escaped = text.replace(/'/g, "'\\''")
|
||||
return ["sh", "-c", "printf '%s' '" + escaped + "' | " + clipboardCmd]
|
||||
}
|
||||
|
||||
function parseStatusResult(jsonText) {
|
||||
try {
|
||||
const data = JSON.parse(jsonText)
|
||||
return {
|
||||
isConnected: data.BackendState === "Running",
|
||||
tailscaleIP: (data.Self && data.Self.TailscaleIPs && data.Self.TailscaleIPs[0]) || "",
|
||||
currentExitNode: findActiveExitNode(data.Peer || {}),
|
||||
peers: parsePeers(data.Peer || {})
|
||||
}
|
||||
} catch (e) {
|
||||
return { isConnected: false, tailscaleIP: "", currentExitNode: "", peers: [] }
|
||||
}
|
||||
}
|
||||
|
||||
function buildToggleCommand(isConnected) {
|
||||
return isConnected ? ["tailscale", "down"] : ["tailscale", "up"]
|
||||
}
|
||||
|
||||
function parseClipboardDetection(stdout) {
|
||||
var trimmed = typeof stdout === "string" ? stdout.trim() : ""
|
||||
return validateClipboardCmd(trimmed) ? trimmed : "none"
|
||||
}
|
||||
|
||||
function errorMessage(cmd) {
|
||||
var messages = {
|
||||
"up": "Failed to connect to Tailscale",
|
||||
|
|
@ -40,5 +75,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 }
|
||||
module.exports = { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, validateClipboardCmd, buildCopyCommand, parseClipboardDetection, buildToggleCommand, parseStatusResult }
|
||||
}
|
||||
|
|
|
|||
131
test/lib.test.js
131
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 } = lib
|
||||
const { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, validateClipboardCmd, buildCopyCommand, parseClipboardDetection, buildToggleCommand, parseStatusResult } = lib
|
||||
|
||||
test("parsePeers extracts exitNode from ExitNodeOption", () => {
|
||||
const peerMap = {
|
||||
|
|
@ -74,3 +74,132 @@ test("errorMessage returns generic message for unknown command", () => {
|
|||
const msg = errorMessage("unknown", 1)
|
||||
assert.strictEqual(msg, "Tailscale command failed")
|
||||
})
|
||||
|
||||
// --- validateClipboardCmd ---
|
||||
|
||||
test("validateClipboardCmd accepts whitelisted values", () => {
|
||||
assert.strictEqual(validateClipboardCmd("dms cl copy"), true)
|
||||
assert.strictEqual(validateClipboardCmd("wl-copy"), true)
|
||||
assert.strictEqual(validateClipboardCmd("clipmanctl copy"), true)
|
||||
assert.strictEqual(validateClipboardCmd("none"), true)
|
||||
})
|
||||
|
||||
test("validateClipboardCmd rejects malicious inputs", () => {
|
||||
assert.strictEqual(validateClipboardCmd("rm -rf /"), false)
|
||||
assert.strictEqual(validateClipboardCmd("echo hi; malicious"), false)
|
||||
assert.strictEqual(validateClipboardCmd("$(whoami)"), false)
|
||||
assert.strictEqual(validateClipboardCmd("wl-copy || rm -rf /"), false)
|
||||
})
|
||||
|
||||
test("validateClipboardCmd rejects empty and falsy inputs", () => {
|
||||
assert.strictEqual(validateClipboardCmd(""), false)
|
||||
assert.strictEqual(validateClipboardCmd(null), false)
|
||||
assert.strictEqual(validateClipboardCmd(undefined), false)
|
||||
})
|
||||
|
||||
// --- buildCopyCommand ---
|
||||
|
||||
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")
|
||||
})
|
||||
|
||||
test("buildCopyCommand returns null for invalid clipboard command", () => {
|
||||
assert.strictEqual(buildCopyCommand("hello", "rm -rf /"), null)
|
||||
assert.strictEqual(buildCopyCommand("hello", ""), null)
|
||||
assert.strictEqual(buildCopyCommand("hello", null), null)
|
||||
})
|
||||
|
||||
test("buildCopyCommand safely handles text with special characters", () => {
|
||||
const cmd = buildCopyCommand("it's a 'test' with \"quotes\" and\nnewlines", "wl-copy")
|
||||
assert.ok(Array.isArray(cmd))
|
||||
})
|
||||
|
||||
// --- parseClipboardDetection ---
|
||||
|
||||
test("parseClipboardDetection passes through expected output strings", () => {
|
||||
assert.strictEqual(parseClipboardDetection("dms cl copy"), "dms cl copy")
|
||||
assert.strictEqual(parseClipboardDetection("wl-copy"), "wl-copy")
|
||||
assert.strictEqual(parseClipboardDetection("clipmanctl copy"), "clipmanctl copy")
|
||||
assert.strictEqual(parseClipboardDetection("none"), "none")
|
||||
})
|
||||
|
||||
test("parseClipboardDetection trims extra whitespace", () => {
|
||||
assert.strictEqual(parseClipboardDetection(" wl-copy \n"), "wl-copy")
|
||||
})
|
||||
|
||||
test("parseClipboardDetection falls back to none for multi-line output", () => {
|
||||
assert.strictEqual(parseClipboardDetection("wl-copy\nrm -rf /"), "none")
|
||||
})
|
||||
|
||||
test("parseClipboardDetection falls back to empty output", () => {
|
||||
assert.strictEqual(parseClipboardDetection(""), "none")
|
||||
assert.strictEqual(parseClipboardDetection(" "), "none")
|
||||
})
|
||||
|
||||
test("parseClipboardDetection falls back to none for unexpected strings", () => {
|
||||
assert.strictEqual(parseClipboardDetection("xclip -selection clipboard"), "none")
|
||||
assert.strictEqual(parseClipboardDetection("custom-tool"), "none")
|
||||
})
|
||||
|
||||
// --- buildToggleCommand ---
|
||||
|
||||
test("buildToggleCommand returns down command when connected", () => {
|
||||
assert.deepStrictEqual(buildToggleCommand(true), ["tailscale", "down"])
|
||||
})
|
||||
|
||||
test("buildToggleCommand returns up command when disconnected", () => {
|
||||
assert.deepStrictEqual(buildToggleCommand(false), ["tailscale", "up"])
|
||||
})
|
||||
|
||||
test("buildToggleCommand treats null and undefined as disconnected", () => {
|
||||
assert.deepStrictEqual(buildToggleCommand(null), ["tailscale", "up"])
|
||||
assert.deepStrictEqual(buildToggleCommand(undefined), ["tailscale", "up"])
|
||||
})
|
||||
|
||||
// --- parseStatusResult ---
|
||||
|
||||
test("parseStatusResult produces correct state from valid JSON", () => {
|
||||
const json = JSON.stringify({
|
||||
BackendState: "Running",
|
||||
Self: { TailscaleIPs: ["100.64.0.5"] },
|
||||
Peer: {
|
||||
"key-1": { HostName: "router", TailscaleIPs: ["100.64.0.1"], Online: true, ExitNode: true, ExitNodeOption: true }
|
||||
}
|
||||
})
|
||||
const state = parseStatusResult(json)
|
||||
assert.strictEqual(state.isConnected, true)
|
||||
assert.strictEqual(state.tailscaleIP, "100.64.0.5")
|
||||
assert.strictEqual(state.currentExitNode, "router")
|
||||
assert.strictEqual(state.peers.length, 1)
|
||||
})
|
||||
|
||||
test("parseStatusResult returns safe defaults for invalid JSON", () => {
|
||||
const state = parseStatusResult("not json at all")
|
||||
assert.strictEqual(state.isConnected, false)
|
||||
assert.strictEqual(state.tailscaleIP, "")
|
||||
assert.strictEqual(state.currentExitNode, "")
|
||||
assert.strictEqual(state.peers.length, 0)
|
||||
})
|
||||
|
||||
test("parseStatusResult handles missing Self gracefully", () => {
|
||||
const json = JSON.stringify({ BackendState: "Running", Peer: {} })
|
||||
const state = parseStatusResult(json)
|
||||
assert.strictEqual(state.isConnected, true)
|
||||
assert.strictEqual(state.tailscaleIP, "")
|
||||
})
|
||||
|
||||
test("parseStatusResult handles missing and empty Peer gracefully", () => {
|
||||
const json = JSON.stringify({ BackendState: "Running", Self: { TailscaleIPs: ["100.64.0.5"] } })
|
||||
const state = parseStatusResult(json)
|
||||
assert.strictEqual(state.peers.length, 0)
|
||||
assert.strictEqual(state.currentExitNode, "")
|
||||
})
|
||||
|
||||
test("parseStatusResult sets isConnected false for non-Running BackendState", () => {
|
||||
const json = JSON.stringify({ BackendState: "NeedsLogin", Self: { TailscaleIPs: ["100.64.0.5"] }, Peer: {} })
|
||||
const state = parseStatusResult(json)
|
||||
assert.strictEqual(state.isConnected, false)
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue