keyhog 0.5.44

keyhog detects leaked credentials in source trees, git history, archives, and remote sources
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
//! Regression e2e: drive the REAL `keyhog` binary through the git pre-commit
//! HOOK lifecycle and the DAEMON `{start,status,stop}` lifecycle, pinning the
//! exact bytes each surface emits and the exact exit codes each transition
//! reports. Everything here shells out to `CARGO_BIN_EXE_keyhog`: no in-process
//! shortcuts, so a drift in the generated hook script, the operator messages,
//! or the exit-code contract fails a concrete assertion.
//!
//! Source of truth read while writing these:
//!   * `crates/cli/src/subcommands/hook.rs`  (HOOK_CONTENT, install/uninstall)
//!   * `crates/cli/src/subcommands/daemon.rs` (start/stop/status messages)
//!   * `crates/cli/src/daemon/trust.rs`       (already-bound refusal)
//!   * `crates/cli/src/exit_codes.rs`         (EXIT_USER_ERROR = 2)

use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;

fn keyhog() -> PathBuf {
    PathBuf::from(env!("CARGO_BIN_EXE_keyhog"))
}

/// The EXACT bytes `keyhog hook install` writes to `.git/hooks/pre-commit`.
/// Mirrors `HOOK_CONTENT` in `crates/cli/src/subcommands/hook.rs` character for
/// character; if the shipped template drifts, `hook_install_writes_exact_bytes`
/// fails and points here.
const EXPECTED_HOOK_CONTENT: &str = concat!(
    "#!/bin/sh\n",
    "# KeyHog pre-commit hook, auto-generated by `keyhog hook install`\n",
    "#\n",
    "# If keyhog is not on PATH, block with a clear message. A missing scanner\n",
    "# means this security control did not run; letting the commit continue would\n",
    "# silently turn the installed hook into a stub.\n",
    "if ! command -v keyhog >/dev/null 2>&1; then\n",
    "    echo \"keyhog: not found on PATH - blocking commit because the pre-commit secret scan did not run.\" >&2\n",
    "    echo \"  Install keyhog (https://github.com/santhreal/keyhog), fix PATH,\" >&2\n",
    "    echo \"  or run 'keyhog hook uninstall' if this repository should not be protected.\" >&2\n",
    "    exit 127\n",
    "fi\n",
    "exec keyhog scan --fast --git-staged --backend cpu\n",
);

/// Create a real git repository at `dir` so `keyhog hook install` (which runs
/// `git rev-parse --git-dir`) accepts it. A bare `mkdir .git` is NOT enough
/// git only recognizes a directory initialized by `git init`.
fn init_git_repo(dir: &Path) {
    let out = Command::new("git")
        .arg("init")
        .arg("-q")
        .current_dir(dir)
        .output()
        .expect("spawn git init");
    assert!(
        out.status.success(),
        "git init must succeed to set up the hook test repo; stderr={}",
        String::from_utf8_lossy(&out.stderr)
    );
}

/// Run `keyhog hook <args...>` with the child cwd set to `repo` (so the
/// subcommand's `git rev-parse` resolves this repo) and `NO_COLOR` set (so the
/// styled operator messages are plain text we can byte-match).
fn run_hook(repo: &Path, args: &[&str]) -> std::process::Output {
    Command::new(keyhog())
        .current_dir(repo)
        .env("NO_COLOR", "1")
        .arg("hook")
        .args(args)
        .output()
        .expect("spawn keyhog hook")
}

fn hook_path(repo: &Path) -> PathBuf {
    repo.join(".git").join("hooks").join("pre-commit")
}

// ---------------------------------------------------------------------------
// HOOK: install / template bytes
// ---------------------------------------------------------------------------

/// `hook install` in a fresh git repo exits 0 and writes the pre-commit file
/// whose contents are byte-for-byte the shipped template.
#[test]
fn hook_install_writes_exact_bytes_and_exits_zero() {
    let dir = TempDir::new().unwrap();
    init_git_repo(dir.path());

    let out = run_hook(dir.path(), &["install"]);
    assert_eq!(
        out.status.code(),
        Some(0),
        "hook install must exit 0 on a fresh repo; stderr={}",
        String::from_utf8_lossy(&out.stderr)
    );

    let content = std::fs::read_to_string(hook_path(dir.path())).expect("read installed hook");
    assert_eq!(
        content, EXPECTED_HOOK_CONTENT,
        "installed pre-commit hook must equal the shipped template byte-for-byte"
    );
}

/// The generated hook's operative line is EXACTLY the canonical scan invocation,
/// the file starts with the POSIX shebang, and the PATH-guard exits 127. These
/// are the load-bearing lines an adopter's commit actually executes.
#[test]
fn hook_install_emits_exact_shebang_exec_and_guard_lines() {
    let dir = TempDir::new().unwrap();
    init_git_repo(dir.path());
    run_hook(dir.path(), &["install"]);

    let content = std::fs::read_to_string(hook_path(dir.path())).expect("read hook");
    let mut lines = content.lines();
    assert_eq!(
        lines.next(),
        Some("#!/bin/sh"),
        "first line must be the POSIX shebang"
    );
    assert!(
        content.contains("\nexec keyhog scan --fast --git-staged --backend cpu\n"),
        "hook must exec the canonical scan verbatim; got:\n{content}"
    );
    assert!(
        content.contains("\n    exit 127\n"),
        "missing-keyhog PATH guard must block the commit with exit 127; got:\n{content}"
    );
    // Exactly one exec line (the hook runs one scan, not several).
    assert_eq!(
        content.matches("exec keyhog ").count(),
        1,
        "hook must contain exactly one `exec keyhog` line"
    );
}

/// `hook install` marks the pre-commit file executable (all three x bits set),
/// otherwise git silently ignores it.
#[cfg(unix)]
#[test]
fn hook_install_sets_all_executable_bits() {
    use std::os::unix::fs::PermissionsExt;
    let dir = TempDir::new().unwrap();
    init_git_repo(dir.path());
    run_hook(dir.path(), &["install"]);

    let mode = std::fs::metadata(hook_path(dir.path()))
        .expect("stat hook")
        .permissions()
        .mode();
    assert_eq!(
        mode & 0o111,
        0o111,
        "hook install must OR in the executable bits (u+g+o x); mode was {mode:#o}"
    );
}

/// The install success message is the exact operator string, printed to stderr,
/// and names the pre-commit path.
#[test]
fn hook_install_success_message_is_exact() {
    let dir = TempDir::new().unwrap();
    init_git_repo(dir.path());
    let out = run_hook(dir.path(), &["install"]);

    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("KeyHog pre-commit hook installed at"),
        "install must announce success; stderr={stderr}"
    );
    assert!(
        stderr.contains(".git/hooks/pre-commit"),
        "install message must name the pre-commit path; stderr={stderr}"
    );
    // The `--force`-only "installed/updated" wording must NOT appear on a
    // first, non-forced install.
    assert!(
        !stderr.contains("installed/updated"),
        "a plain install must say `installed`, not `installed/updated`; stderr={stderr}"
    );
}

// ---------------------------------------------------------------------------
// HOOK: idempotency / conflict / force
// ---------------------------------------------------------------------------

/// A second `hook install` over KeyHog's own hook is a no-op that exits 0 and
/// reports "already installed" (it must not error and must not rewrite bytes).
#[test]
fn hook_install_twice_reports_already_installed_exit_zero() {
    let dir = TempDir::new().unwrap();
    init_git_repo(dir.path());
    run_hook(dir.path(), &["install"]);

    let second = run_hook(dir.path(), &["install"]);
    assert_eq!(
        second.status.code(),
        Some(0),
        "re-installing over KeyHog's own hook must exit 0 (idempotent)"
    );
    let stderr = String::from_utf8_lossy(&second.stderr);
    assert!(
        stderr.contains("KeyHog pre-commit hook is already installed at"),
        "second install must report the already-installed state; stderr={stderr}"
    );
    // Bytes are still the pristine template.
    let content = std::fs::read_to_string(hook_path(dir.path())).expect("read hook");
    assert_eq!(content, EXPECTED_HOOK_CONTENT);
}

/// A KeyHog-owned hook whose body drifted from the current template (e.g. an
/// older scan line after upgrade) is rewritten without `--force` (KH-1333).
#[test]
fn hook_install_rewrites_stale_keyhog_owned_hook() {
    let dir = TempDir::new().unwrap();
    init_git_repo(dir.path());
    let stale = concat!(
        "#!/bin/sh\n",
        "# KeyHog pre-commit hook, auto-generated by `keyhog hook install`\n",
        "exec keyhog scan --git-staged --backend cpu\n",
    );
    std::fs::write(hook_path(dir.path()), stale).expect("write stale hook");

    let out = run_hook(dir.path(), &["install"]);
    assert_eq!(
        out.status.code(),
        Some(0),
        "stale KeyHog-owned hook must be rewritten; stderr={}",
        String::from_utf8_lossy(&out.stderr)
    );
    let content = std::fs::read_to_string(hook_path(dir.path())).expect("read hook");
    assert_eq!(
        content, EXPECTED_HOOK_CONTENT,
        "rewritten hook must match the current template"
    );
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("KeyHog pre-commit hook installed"),
        "update path must report installed/updated; stderr={stderr}"
    );
}

/// Installing over a FOREIGN (non-KeyHog) pre-commit hook without `--force` must
/// fail with EXIT_USER_ERROR (2), a message telling the user to pass `--force`,
/// and it must leave the foreign hook untouched.
#[test]
fn hook_install_refuses_foreign_hook_without_force() {
    let dir = TempDir::new().unwrap();
    init_git_repo(dir.path());
    // A pre-existing hook not written by KeyHog.
    let foreign = "#!/bin/sh\necho other-tool\n";
    std::fs::write(hook_path(dir.path()), foreign).expect("write foreign hook");

    let out = run_hook(dir.path(), &["install"]);
    assert_eq!(
        out.status.code(),
        Some(2),
        "clobbering a foreign hook without --force must exit 2 (user error); stderr={}",
        String::from_utf8_lossy(&out.stderr)
    );
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("a pre-commit hook already exists at") && stderr.contains("--force"),
        "refusal must name the conflict and offer --force; stderr={stderr}"
    );
    let content = std::fs::read_to_string(hook_path(dir.path())).expect("read hook");
    assert_eq!(
        content, foreign,
        "a refused install must not touch the foreign hook's bytes"
    );
}

/// `hook install --force` over a foreign hook replaces it with the KeyHog
/// template, exits 0, and reports the "installed/updated" wording.
#[test]
fn hook_install_force_replaces_foreign_hook() {
    let dir = TempDir::new().unwrap();
    init_git_repo(dir.path());
    std::fs::write(hook_path(dir.path()), "#!/bin/sh\necho other-tool\n")
        .expect("write foreign hook");

    let out = run_hook(dir.path(), &["install", "--force"]);
    assert_eq!(
        out.status.code(),
        Some(0),
        "hook install --force must exit 0; stderr={}",
        String::from_utf8_lossy(&out.stderr)
    );
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("KeyHog pre-commit hook installed/updated at"),
        "forced replace must report installed/updated; stderr={stderr}"
    );
    let content = std::fs::read_to_string(hook_path(dir.path())).expect("read hook");
    assert_eq!(
        content, EXPECTED_HOOK_CONTENT,
        "forced install must overwrite the foreign hook with the KeyHog template"
    );
}

// ---------------------------------------------------------------------------
// HOOK: uninstall
// ---------------------------------------------------------------------------

/// `hook uninstall` removes KeyHog's own hook, exits 0, reports the removal, and
/// the file is gone afterwards.
#[test]
fn hook_uninstall_removes_keyhog_hook() {
    let dir = TempDir::new().unwrap();
    init_git_repo(dir.path());
    run_hook(dir.path(), &["install"]);
    assert!(hook_path(dir.path()).exists());

    let out = run_hook(dir.path(), &["uninstall"]);
    assert_eq!(
        out.status.code(),
        Some(0),
        "uninstall of a KeyHog hook must exit 0; stderr={}",
        String::from_utf8_lossy(&out.stderr)
    );
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("KeyHog pre-commit hook removed from"),
        "uninstall must report the removal; stderr={stderr}"
    );
    assert!(
        !hook_path(dir.path()).exists(),
        "uninstall must delete the pre-commit file"
    );
}

/// `hook uninstall` when there is no hook at all exits 0 and reports that none
/// was found (a no-op, not an error).
#[test]
fn hook_uninstall_no_hook_reports_none_found_exit_zero() {
    let dir = TempDir::new().unwrap();
    init_git_repo(dir.path());

    let out = run_hook(dir.path(), &["uninstall"]);
    assert_eq!(
        out.status.code(),
        Some(0),
        "uninstall with no hook present must exit 0; stderr={}",
        String::from_utf8_lossy(&out.stderr)
    );
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("No pre-commit hook found at"),
        "uninstall with nothing installed must say so; stderr={stderr}"
    );
}

/// `hook uninstall` must REFUSE to delete a foreign hook: it exits 2, says the
/// hook was not installed by KeyHog, and leaves the file intact.
#[test]
fn hook_uninstall_refuses_foreign_hook() {
    let dir = TempDir::new().unwrap();
    init_git_repo(dir.path());
    let foreign = "#!/bin/sh\necho other-tool\n";
    std::fs::write(hook_path(dir.path()), foreign).expect("write foreign hook");

    let out = run_hook(dir.path(), &["uninstall"]);
    assert_eq!(
        out.status.code(),
        Some(2),
        "uninstall of a foreign hook must exit 2 (user error); stderr={}",
        String::from_utf8_lossy(&out.stderr)
    );
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("was not installed by KeyHog"),
        "refusal must explain KeyHog did not install it; stderr={stderr}"
    );
    let content = std::fs::read_to_string(hook_path(dir.path())).expect("read hook");
    assert_eq!(
        content, foreign,
        "a refused uninstall must not delete the foreign hook"
    );
}

/// `hook install` outside any git repository fails with EXIT_USER_ERROR (2) and
/// says it is not a git repository.
#[test]
fn hook_install_outside_git_repo_fails_user_error() {
    let dir = TempDir::new().unwrap(); // NOT a git repo
    let out = run_hook(dir.path(), &["install"]);
    assert_eq!(
        out.status.code(),
        Some(2),
        "hook install outside a git repo must exit 2 (user error); stderr={}",
        String::from_utf8_lossy(&out.stderr)
    );
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("not a git repository"),
        "error must name the missing-repo cause; stderr={stderr}"
    );
}

// ---------------------------------------------------------------------------
// DAEMON: start / status / stop lifecycle + error transitions
// ---------------------------------------------------------------------------

#[cfg(unix)]
fn daemon_slot() -> std::sync::MutexGuard<'static, ()> {
    static LOCK: std::sync::LazyLock<std::sync::Mutex<()>> =
        std::sync::LazyLock::new(|| std::sync::Mutex::new(()));
    // LAW10: a prior test panic must not make the independent lifecycle oracle
    // unavailable; recovering the guard still preserves process serialization.
    LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}

/// Start `keyhog daemon start --socket <sock> --backend cpu` and wait until the
/// socket is a live listener. Returns the child + socket path.
#[cfg(unix)]
fn start_daemon(dir: &Path) -> (std::process::Child, PathBuf) {
    use std::io::Read;
    use std::os::unix::net::UnixStream as StdUnixStream;
    use std::time::{Duration, Instant};

    let socket = dir.join("d.sock");
    let mut child = Command::new(keyhog())
        .args(["daemon", "start", "--socket"])
        .arg(&socket)
        .args(["--backend", "cpu"])
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::piped())
        .spawn()
        .expect("spawn daemon start");

    let deadline = Instant::now() + Duration::from_secs(30);
    while Instant::now() < deadline {
        if let Some(status) = child.try_wait().expect("poll daemon process") {
            let mut stderr = String::new();
            child
                .stderr
                .take()
                .expect("daemon stderr pipe")
                .read_to_string(&mut stderr)
                .expect("read daemon stderr");
            panic!("daemon exited before readiness with {status}: {stderr}");
        }
        if socket.exists() && StdUnixStream::connect(&socket).is_ok() {
            return (child, socket);
        }
        std::thread::sleep(Duration::from_millis(100));
    }
    if child.try_wait().expect("poll timed-out daemon").is_none() {
        child.kill().expect("kill timed-out daemon");
    }
    let status = child.wait().expect("reap timed-out daemon");
    let mut stderr = String::new();
    child
        .stderr
        .take()
        .expect("daemon stderr pipe")
        .read_to_string(&mut stderr)
        .expect("read timed-out daemon stderr");
    panic!("daemon did not become ready within 30s; final status {status}: {stderr}");
}

/// Full happy path: a started daemon answers `status` with the exact status line
/// shape (exit 0), and `stop` shuts it down with the exact confirmation (exit 0)
/// and removes the socket file.
#[cfg(unix)]
#[test]
fn daemon_start_status_stop_reports_exact_lines_and_codes() {
    use std::time::{Duration, Instant};
    let _daemon_slot = daemon_slot();

    let dir = TempDir::new().unwrap();
    let (mut child, socket) = start_daemon(dir.path());

    // status against a live daemon: exit 0, exact leading status line.
    let status = Command::new(keyhog())
        .args(["daemon", "status", "--socket"])
        .arg(&socket)
        .output()
        .expect("spawn daemon status");
    assert_eq!(
        status.status.code(),
        Some(0),
        "status against a live daemon must exit 0; stderr={}",
        String::from_utf8_lossy(&status.stderr)
    );
    let stdout = String::from_utf8_lossy(&status.stdout);
    assert!(
        stdout.starts_with("keyhog daemon: uptime "),
        "status line must start with the exact uptime prefix; got: {stdout}"
    );
    assert!(
        stdout.contains(" scans served · ")
            && stdout.contains(" active · ")
            && stdout.contains(" detectors"),
        "status line must carry the served/active/detectors columns; got: {stdout}"
    );
    assert!(
        stdout.contains("backend policy: forced cpu-fallback")
            && stdout.contains("daemon startup diagnostic override"),
        "status must disclose the daemon-owned forced backend policy; got: {stdout}"
    );

    // stop: exit 0, exact confirmation on stderr.
    let stop = Command::new(keyhog())
        .args(["daemon", "stop", "--socket"])
        .arg(&socket)
        .output()
        .expect("spawn daemon stop");
    assert_eq!(
        stop.status.code(),
        Some(0),
        "daemon stop must exit 0; stderr={}",
        String::from_utf8_lossy(&stop.stderr)
    );
    assert!(
        String::from_utf8_lossy(&stop.stderr).contains("keyhog daemon stopped"),
        "stop must print the exact confirmation; stderr={}",
        String::from_utf8_lossy(&stop.stderr)
    );

    let _ = child.wait();
    let deadline = Instant::now() + Duration::from_secs(10);
    while socket.exists() && Instant::now() < deadline {
        std::thread::sleep(Duration::from_millis(50));
    }
    assert!(
        !socket.exists(),
        "daemon stop must remove the socket so a later start does not refuse it"
    );
}

/// A SECOND `daemon start` on a socket already bound by a live daemon must fail
/// closed with EXIT_USER_ERROR (2) and the exact "already bound" refusal, it
/// must not clobber the running daemon's socket.
#[cfg(unix)]
#[test]
fn daemon_second_start_refuses_already_bound_socket() {
    let _daemon_slot = daemon_slot();
    let dir = TempDir::new().unwrap();
    let (mut child, socket) = start_daemon(dir.path());

    let second = Command::new(keyhog())
        .args(["daemon", "start", "--socket"])
        .arg(&socket)
        .args(["--backend", "cpu"])
        .output()
        .expect("spawn second daemon start");
    assert_eq!(
        second.status.code(),
        Some(2),
        "a second start on a live socket must exit 2 (user error), not clobber; stderr={}",
        String::from_utf8_lossy(&second.stderr)
    );
    let stderr = String::from_utf8_lossy(&second.stderr);
    assert!(
        stderr.contains("is already bound by another keyhog daemon"),
        "second start must report the already-bound refusal; stderr={stderr}"
    );
    assert!(
        stderr.contains("keyhog daemon stop"),
        "refusal must tell the operator to stop the existing daemon first; stderr={stderr}"
    );

    // The original daemon is still alive and answering.
    let status = Command::new(keyhog())
        .args(["daemon", "status", "--socket"])
        .arg(&socket)
        .output()
        .expect("status after refused second start");
    assert_eq!(
        status.status.code(),
        Some(0),
        "the first daemon must still be serving after the refused second start"
    );

    let _ = Command::new(keyhog())
        .args(["daemon", "stop", "--socket"])
        .arg(&socket)
        .output();
    let _ = child.wait();
}

/// `daemon stop` when no daemon is running fails with EXIT_USER_ERROR (2) and a
/// message naming the missing socket and the "already stopped?" hint.
#[cfg(unix)]
#[test]
fn daemon_stop_without_daemon_exits_user_error() {
    let dir = TempDir::new().unwrap();
    let socket = dir.path().join("absent.sock"); // never created

    let out = Command::new(keyhog())
        .args(["daemon", "stop", "--socket"])
        .arg(&socket)
        .output()
        .expect("spawn daemon stop");
    assert_eq!(
        out.status.code(),
        Some(2),
        "stop with no daemon must exit 2 (user error); stderr={}",
        String::from_utf8_lossy(&out.stderr)
    );
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("daemon stop: no daemon at") && stderr.contains("already stopped"),
        "stop must report no-daemon with the already-stopped hint; stderr={stderr}"
    );
}

/// `daemon status` when no daemon is running fails with EXIT_USER_ERROR (2) and
/// tells the operator to start one.
#[cfg(unix)]
#[test]
fn daemon_status_without_daemon_exits_user_error() {
    let dir = TempDir::new().unwrap();
    let socket = dir.path().join("absent.sock"); // never created

    let out = Command::new(keyhog())
        .args(["daemon", "status", "--socket"])
        .arg(&socket)
        .output()
        .expect("spawn daemon status");
    assert_eq!(
        out.status.code(),
        Some(2),
        "status with no daemon must exit 2 (user error); stderr={}",
        String::from_utf8_lossy(&out.stderr)
    );
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("daemon status: no daemon at") && stderr.contains("keyhog daemon start"),
        "status must report no-daemon and how to start one; stderr={stderr}"
    );
}