[keyman] portability, and stop keys from being silently invisible

Four things that each made keyman quietly less useful than it looked.

**Clipboard.** `pbcopy` was spawned unconditionally, with a comment admitting
it. Copy is now a list of commands per platform — pbcopy, clip, and wl-copy /
xclip / xsel tried in order on everything else, because there is no single
answer under Linux and trying them beats detecting the session type. Only an
absent tool advances to the next candidate: one that ran and refused has an
opinion. And if nothing is installed the key is printed, since "give me this
public key" is answerable without a clipboard and used to be a dead end
everywhere but macOS. Verified the round trip through real pbcopy/pbpaste.

**Home directories.** `/home/<user>` was hardcoded — wrong on the platform
this was written on. A named user is now looked for beside the current user's
home first, which is right wherever homes live together whatever that
directory is called, then in /home and /Users, and the failure names every
path tried instead of feeding a nonexistent one to readdir. For the current
user, `HOME` still wins, with `os.userInfo()` behind it: `process.env.HOME ||
''` made an unset HOME fatal, which it is not in a cron job or a container.

**Keys that are not named id_*.** A key called `deploy_ed25519` was absent
from every menu with nothing said. It still is — the vault stores
`<name minus id_>/id_<name>.age` and decrypt rebuilds the filename from the
directory, so relaxing discovery means changing the on-disk layout, which the
plan sizes as its largest single item and is not folded in here. What it does
do is say so: any file whose first line carries a private key header and whose
name lacks the prefix is now reported, per directory, with the reason. A
bounded 64-byte read, because classifying a key is no reason to load one.

**Plaintext hygiene.** A "Clear decrypted keys" entry, defaulting to no and
listing what it would delete first, and a vault `.gitignore` written on first
run covering the age identity and the tmp directory — which the README asked
the user to do by hand. Never overwritten, and silent about a configured
directory that sits outside the vault, since a .gitignore cannot speak for a
path above itself and pretending otherwise reads as protection that is absent.
This commit is contained in:
Benjamin Diedrichsen
2026-07-30 15:42:35 +02:00
parent 270cbe628a
commit 0993a4d3bb
14 changed files with 923 additions and 57 deletions
+85
View File
@@ -0,0 +1,85 @@
/**
* Tests for the clipboard layer.
*
* The platform is passed in rather than stubbed, so every branch is reachable from
* the one machine the suite runs on.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { execa } = vi.hoisted(() => ({ execa: vi.fn() }));
vi.mock('execa', () => ({ execa }));
import { clipboardTools, copyToClipboard } from '../src/keyman.clipboard.js';
describe('clipboardTools', () => {
it.each([
['darwin', ['pbcopy']],
['win32', ['clip']],
['linux', ['wl-copy', 'xclip', 'xsel']],
// Anything unrecognised gets the X11/Wayland list rather than nothing: a BSD
// running the same desktop stack is closer to linux than to no answer.
['freebsd', ['wl-copy', 'xclip', 'xsel']],
])('offers the right commands on %s', (platform, expected) => {
expect(clipboardTools(platform).map((t) => t.binary)).toEqual(expected);
});
it('passes the clipboard selection to the X11 tools', () => {
const byBinary = new Map(clipboardTools('linux').map((t) => [t.binary, t.args]));
// Without these, xclip and xsel write to the primary selection, which is not
// the clipboard a paste reads from.
expect(byBinary.get('xclip')).toEqual(['-selection', 'clipboard']);
expect(byBinary.get('xsel')).toEqual(['--clipboard', '--input']);
});
});
describe('copyToClipboard', () => {
const notFound = () =>
execa.mockImplementation(async () => {
throw Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' });
});
beforeEach(() => {
vi.clearAllMocks();
execa.mockResolvedValue({ stdout: '' });
});
it('pipes the text to the first available command', async () => {
const tool = await copyToClipboard('ssh-ed25519 AAAA', 'darwin');
expect(tool).toBe('pbcopy');
expect(execa).toHaveBeenCalledWith('pbcopy', [], { input: 'ssh-ed25519 AAAA' });
});
it('moves on from a command that is not installed', async () => {
execa.mockImplementation(async (binary: string) => {
if (binary !== 'xclip') {
throw Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' });
}
return { stdout: '' };
});
expect(await copyToClipboard('key', 'linux')).toBe('xclip');
expect(execa.mock.calls.map((c) => c[0])).toEqual(['wl-copy', 'xclip']);
});
it('reports that nothing was available rather than throwing', async () => {
notFound();
expect(await copyToClipboard('key', 'linux')).toBeNull();
expect(execa).toHaveBeenCalledTimes(3);
});
it('surfaces a command that ran and refused', async () => {
execa.mockImplementation(async () => {
throw Object.assign(new Error('failed'), { stderr: 'Error: No protocol specified' });
});
// A tool with an opinion is not an absent tool: trying the next one would
// hide a real problem behind a second failure.
await expect(copyToClipboard('key', 'linux')).rejects.toThrow('No protocol specified');
expect(execa).toHaveBeenCalledTimes(1);
});
});