Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 11 additions & 28 deletions src/keyring/linux.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,40 +45,23 @@ async function secretTool(
return {
success: result.success,
code: result.code,
stdout: new TextDecoder().decode(result.stdout).trim(),
// secret-tool writes the stored secret verbatim when stdout is piped. It
// only appends a newline when writing to a terminal.
stdout: new TextDecoder().decode(result.stdout),
stderr: new TextDecoder().decode(result.stderr).trim(),
}
}

export const linuxBackend: KeyringBackend = {
async isAvailable() {
// Probe the keyring with a lookup that's expected to miss. We can't
// gate on `secret-tool --version` — that flag has never existed in
// libsecret, so the probe always fails and we'd report "no keyring"
// even on systems where the keyring works fine.
let probe
try {
probe = await secretTool([
"lookup",
"service",
SERVICE,
"account",
"__linear_cli_probe__",
])
// With no arguments secret-tool exits 2 after printing usage. A
// completed process is enough to prove the executable is on PATH.
await secretTool([])
return true
} catch {
// secret-tool binary missing (libsecret-tools not installed).
return false
}

// libsecret-tools installed, but no Secret Service is registered on
// the session bus (e.g. headless Linux without gnome-keyring-daemon).
if (probe.stderr.includes("was not provided by any .service files")) {
return false
}

// Either the probe key happens to exist (success) or the lookup
// ran cleanly and just didn't find anything (exit 1, empty stderr).
return probe.success || (probe.code === 1 && probe.stderr === "")
},

async get(account) {
Expand All @@ -90,8 +73,9 @@ export const linuxBackend: KeyringBackend = {
account,
])
if (!result.success) {
// secret-tool lookup returns exit 1 when no matching items are found
if (result.code === 1) return null
// secret-tool returns exit 1 both when no items match and when the
// lookup fails. Operational failures write to stderr; a miss does not.
if (result.code === 1 && result.stderr === "") return null
throw new Error(
`secret-tool lookup failed (exit ${result.code}): ${result.stderr}`,
)
Expand Down Expand Up @@ -129,8 +113,7 @@ export const linuxBackend: KeyringBackend = {
"account",
account,
])
// secret-tool clear returns exit 1 when no matching items are found
if (!result.success && result.code !== 1) {
if (!result.success) {
throw new Error(
`secret-tool clear failed (exit ${result.code}): ${result.stderr}`,
)
Expand Down
42 changes: 41 additions & 1 deletion test/keyring.integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { assertEquals } from "@std/assert"
import { assertEquals, assertRejects } from "@std/assert"
import {
deletePassword,
getPassword,
isAvailable,
setPassword,
} from "../src/keyring/index.ts"

Expand Down Expand Up @@ -63,6 +64,45 @@ Deno.test({

await deletePassword(TEST_ACCOUNT)
assertEquals(await getPassword(TEST_ACCOUNT), null)

if (Deno.build.os === "linux") {
const dbusAddress = Deno.env.get("DBUS_SESSION_BUS_ADDRESS")
Deno.env.set(
"DBUS_SESSION_BUS_ADDRESS",
`unix:path=/tmp/linear-cli-missing-dbus-${Date.now()}`,
)
try {
assertEquals(await isAvailable(), true)
await assertRejects(
() => getPassword(TEST_ACCOUNT),
Error,
"secret-tool lookup failed",
)
await assertRejects(
() => deletePassword(TEST_ACCOUNT),
Error,
"secret-tool clear failed",
)
} finally {
if (dbusAddress == null) {
Deno.env.delete("DBUS_SESSION_BUS_ADDRESS")
} else {
Deno.env.set("DBUS_SESSION_BUS_ADDRESS", dbusAddress)
}
}

const path = Deno.env.get("PATH")
Deno.env.set("PATH", "/nonexistent")
try {
assertEquals(await isAvailable(), false)
} finally {
if (path == null) {
Deno.env.delete("PATH")
} else {
Deno.env.set("PATH", path)
}
}
}
} finally {
// Ensure cleanup even if assertions fail
try {
Expand Down
Loading