0993a4d3bb
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.
121 lines
4.2 KiB
TypeScript
121 lines
4.2 KiB
TypeScript
/**
|
|
* Tests for home directory resolution.
|
|
*
|
|
* Real directories under os.tmpdir() stand in for home directories, since the
|
|
* whole point of the module is that it checks whether a path exists rather than
|
|
* assuming a layout.
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { CURRENT_USER, resolveHomeDir } from '../src/keyman.home.js';
|
|
|
|
describe('resolveHomeDir', () => {
|
|
let homes: string;
|
|
let originalHome: string | undefined;
|
|
let errorSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
const messages = () => errorSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
|
|
beforeEach(() => {
|
|
originalHome = process.env.HOME;
|
|
homes = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-homes-')));
|
|
process.env.HOME = path.join(homes, 'alice');
|
|
fs.mkdirSync(process.env.HOME, { recursive: true });
|
|
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
if (originalHome === undefined) {
|
|
delete process.env.HOME;
|
|
} else {
|
|
process.env.HOME = originalHome;
|
|
}
|
|
fs.rmSync(homes, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('the current user', () => {
|
|
it('uses HOME when it is set', () => {
|
|
expect(resolveHomeDir(CURRENT_USER)).toBe(path.join(homes, 'alice'));
|
|
});
|
|
|
|
it('falls back to the passwd entry when HOME is unset', () => {
|
|
delete process.env.HOME;
|
|
|
|
// Not asserted as a literal: what matters is that an unset HOME is no longer
|
|
// a fatal error, which is what `process.env.HOME || ''` made it.
|
|
expect(resolveHomeDir(CURRENT_USER)).toBe(os.userInfo().homedir);
|
|
});
|
|
|
|
it('reports the failure when neither is available', () => {
|
|
delete process.env.HOME;
|
|
vi.spyOn(os, 'userInfo').mockImplementation(() => {
|
|
throw new Error('no passwd entry for uid');
|
|
});
|
|
|
|
expect(resolveHomeDir(CURRENT_USER)).toBeNull();
|
|
expect(messages()).toContain('Unable to determine HOME directory');
|
|
});
|
|
|
|
it('treats an empty passwd home as no answer', () => {
|
|
delete process.env.HOME;
|
|
vi.spyOn(os, 'userInfo').mockReturnValue({
|
|
...os.userInfo(),
|
|
homedir: '',
|
|
});
|
|
|
|
expect(resolveHomeDir(CURRENT_USER)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('another user', () => {
|
|
it('looks beside the current home, whatever that directory is called', () => {
|
|
const bob = path.join(homes, 'bob');
|
|
fs.mkdirSync(bob);
|
|
|
|
// The old code hardcoded /home/<user>, which is wrong on macOS — where homes
|
|
// live in /Users — and on any host that puts them anywhere else.
|
|
expect(resolveHomeDir('bob')).toBe(bob);
|
|
});
|
|
|
|
it('still tries the conventional locations with no current home to go by', () => {
|
|
delete process.env.HOME;
|
|
vi.spyOn(os, 'userInfo').mockImplementation(() => {
|
|
throw new Error('no passwd entry for uid');
|
|
});
|
|
|
|
// Not knowing where *this* user lives is no reason to give up on another.
|
|
expect(resolveHomeDir('nobody')).toBeNull();
|
|
expect(messages()).toContain('/home/nobody, /Users/nobody');
|
|
expect(messages()).not.toContain('Unable to determine HOME');
|
|
});
|
|
|
|
it('reports every path it tried when there is no such home', () => {
|
|
expect(resolveHomeDir('nobody')).toBeNull();
|
|
|
|
expect(messages()).toContain('No home directory found for nobody');
|
|
expect(messages()).toContain(path.join(homes, 'nobody'));
|
|
expect(messages()).toContain('/home/nobody');
|
|
expect(messages()).toContain('/Users/nobody');
|
|
});
|
|
|
|
it('still checks the conventional locations when HOME is somewhere odd', () => {
|
|
process.env.HOME = path.join(homes, 'alice');
|
|
const conventional = process.platform === 'darwin' ? '/Users' : '/home';
|
|
const existing = fs
|
|
.readdirSync(conventional)
|
|
.find((entry) =>
|
|
fs.statSync(path.join(conventional, entry), { throwIfNoEntry: false })?.isDirectory()
|
|
);
|
|
|
|
// Skipped rather than asserted blind if the machine has no such user.
|
|
if (existing) {
|
|
expect(resolveHomeDir(existing)).toBe(path.join(conventional, existing));
|
|
}
|
|
});
|
|
});
|
|
});
|