libguix 0.1.2

Unofficial Rust client library for GNU Guix.
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
//! Integration tests for `SystemOps::reconfigure` — pkexec exit codes
//! and channel-shadow #74396 known-bug escalation.

use std::process::Stdio;

use futures_util::StreamExt;
use libguix::__test_support::{operation_from_command, pkexec_operation_from_command};
use libguix::{GuixError, KnownBug, PolkitFailure, ProgressEvent};
use tokio::process::Command;

/// Plain `sh -c` builder with locale forced, mirroring `operation.rs` tests.
fn sh(script: &str) -> Command {
    let mut c = Command::new("sh");
    c.arg("-c")
        .arg(script)
        .env("LC_ALL", "C")
        .env("LANG", "C")
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());
    c
}

/// `pkexec` exit 0 → `Ok(())` even under the Pkexec classifier.
#[tokio::test(flavor = "multi_thread")]
async fn pkexec_classifier_zero_exit_is_ok() {
    let op = pkexec_operation_from_command(sh("echo hi; exit 0")).expect("spawn");
    op.await_completion().await.expect("ok exit");
}

/// `pkexec` exit 126 → `PolkitFailure::AuthFailed`, stderr_tail populated.
#[tokio::test(flavor = "multi_thread")]
async fn pkexec_classifier_126_maps_to_auth_failed() {
    let op = pkexec_operation_from_command(sh("echo 'Error: dismissed by user' 1>&2; exit 126"))
        .expect("spawn");
    let err = op
        .await_completion()
        .await
        .expect_err("expected polkit err");
    match err {
        GuixError::Polkit {
            kind: PolkitFailure::AuthFailed,
            code,
            stderr_tail,
        } => {
            assert_eq!(code, 126);
            assert!(
                stderr_tail.contains("dismissed"),
                "stderr_tail should include the failure line: {stderr_tail:?}"
            );
        }
        other => panic!("expected Polkit AuthFailed, got {other:?}"),
    }
}

/// `pkexec` exit 127 → `PolkitFailure::NotAuthorized`.
#[tokio::test(flavor = "multi_thread")]
async fn pkexec_classifier_127_maps_to_not_authorized() {
    let op = pkexec_operation_from_command(sh("echo 'Error: not authorized' 1>&2; exit 127"))
        .expect("spawn");
    let err = op
        .await_completion()
        .await
        .expect_err("expected polkit err");
    assert!(
        matches!(
            err,
            GuixError::Polkit {
                kind: PolkitFailure::NotAuthorized,
                code: 127,
                ..
            }
        ),
        "got {err:?}"
    );
}

/// `pkexec` exit 130 (128 + SIGINT=2) → `PolkitFailure::KilledBySignal(2)`.
#[tokio::test(flavor = "multi_thread")]
async fn pkexec_classifier_130_maps_to_killed_by_signal() {
    let op = pkexec_operation_from_command(sh("exit 130")).expect("spawn");
    let err = op
        .await_completion()
        .await
        .expect_err("expected polkit err");
    assert!(
        matches!(
            err,
            GuixError::Polkit {
                kind: PolkitFailure::KilledBySignal(2),
                code: 130,
                ..
            }
        ),
        "got {err:?}"
    );
}

/// TG2: `pkexec` exit 139 (128 + SIGSEGV=11) → `KilledBySignal(11)`.
#[tokio::test(flavor = "multi_thread")]
async fn pkexec_classifier_139_maps_to_segv() {
    let op = pkexec_operation_from_command(sh("exit 139")).expect("spawn");
    let err = op
        .await_completion()
        .await
        .expect_err("expected polkit err");
    assert!(
        matches!(
            err,
            GuixError::Polkit {
                kind: PolkitFailure::KilledBySignal(11),
                code: 139,
                ..
            }
        ),
        "got {err:?}"
    );
}

/// Inner-command pass-through (codes 1..=125) under pkexec stays
/// `OperationFailed`, **not** `Polkit`. Otherwise a genuine guix failure
/// under `pkexec` would be misclassified.
#[tokio::test(flavor = "multi_thread")]
async fn pkexec_classifier_pass_through_stays_operation_failed() {
    let op = pkexec_operation_from_command(sh("echo guix-fail 1>&2; exit 7")).expect("spawn");
    let err = op.await_completion().await.expect_err("expected op-failed");
    match err {
        GuixError::OperationFailed { code, stderr_tail } => {
            assert_eq!(code, 7);
            assert!(stderr_tail.contains("guix-fail"));
        }
        other => panic!("expected OperationFailed, got {other:?}"),
    }
}

/// Standard classifier never produces `Polkit`, even on 126.
#[tokio::test(flavor = "multi_thread")]
async fn standard_classifier_does_not_produce_polkit() {
    let op = operation_from_command(sh("exit 126")).expect("spawn");
    let err = op.await_completion().await.expect_err("expected op-failed");
    assert!(
        matches!(err, GuixError::OperationFailed { code: 126, .. }),
        "got {err:?}"
    );
}

/// TG1: stderr drained even when the writes come *after* every other
/// event but before the exit summary observable. Fake pkexec emits 10 KB
/// of stderr in tight bursts, then exits 126. The drain-before-classify
/// invariant in `await_completion` means `stderr_tail` must still
/// contain the late-arriving bytes — not the empty string we'd get if
/// the snapshot ran before the readers had finished consuming the pipe.
#[tokio::test(flavor = "multi_thread")]
async fn pkexec_stderr_drained_before_classify() {
    // 10 KB total. Use a fixed marker we can grep for at the tail. The
    // ring buffer holds 64 KB, so the marker should always be reachable.
    let script = r#"
i=0
while [ $i -lt 200 ]; do
  printf 'late-stderr-line-%04d-padding-padding-padding-padding\n' "$i" 1>&2
  i=$((i + 1))
done
printf 'TAIL-MARKER\n' 1>&2
exit 126
"#;
    let op = pkexec_operation_from_command(sh(script)).expect("spawn");
    let err = op
        .await_completion()
        .await
        .expect_err("expected polkit err");
    match err {
        GuixError::Polkit {
            kind: PolkitFailure::AuthFailed,
            code,
            stderr_tail,
        } => {
            assert_eq!(code, 126);
            assert!(
                stderr_tail.contains("TAIL-MARKER"),
                "drain-before-classify failed: stderr_tail missing tail marker"
            );
            // Sanity: we actually accumulated bulk bytes too.
            assert!(
                stderr_tail.len() >= 4 * 1024,
                "expected ~10 KB of stderr, got {} bytes",
                stderr_tail.len()
            );
        }
        other => panic!("expected Polkit AuthFailed, got {other:?}"),
    }
}

/// Under the standard classifier: emitting the trigger phrase on stderr +
/// a non-zero exit surfaces as `GuixError::KnownBug`, *and* a
/// `ProgressEvent::KnownBug` event appears in the live stream.
#[tokio::test(flavor = "multi_thread")]
async fn channel_shadow_streams_and_escalates_on_failure() {
    let mut op = operation_from_command(sh(
        "echo 'no code for module (some-channel mod)' 1>&2; exit 1",
    ))
    .expect("spawn");

    let mut events = Vec::new();
    while let Some(batch) = op.events_mut().next().await {
        events.extend(batch);
    }
    assert!(
        events
            .iter()
            .any(|e| matches!(e, ProgressEvent::KnownBug(KnownBug::ChannelShadow74396))),
        "expected KnownBug event in stream; got {events:?}"
    );

    // Re-spawn and await: the bug should be observed again and escalate.
    let op2 = operation_from_command(sh(
        "echo 'no code for module (some-channel mod)' 1>&2; exit 1",
    ))
    .expect("spawn");
    let err = op2.await_completion().await.expect_err("expected error");
    assert!(
        matches!(err, GuixError::KnownBug(KnownBug::ChannelShadow74396)),
        "expected KnownBug error, got {err:?}"
    );
}

/// Same trigger line *but the operation succeeds*: the KnownBug event
/// still flows live (so a GUI can show a soft warning), but
/// `await_completion` must return `Ok(())` — we only escalate on
/// failure to avoid crying wolf.
#[tokio::test(flavor = "multi_thread")]
async fn channel_shadow_on_success_does_not_escalate() {
    let mut op = operation_from_command(sh(
        "echo 'no code for module (some-channel mod)' 1>&2; exit 0",
    ))
    .expect("spawn");

    let mut events = Vec::new();
    while let Some(batch) = op.events_mut().next().await {
        events.extend(batch);
    }
    assert!(
        events
            .iter()
            .any(|e| matches!(e, ProgressEvent::KnownBug(KnownBug::ChannelShadow74396))),
        "expected live KnownBug event; got {events:?}"
    );

    let op2 = operation_from_command(sh(
        "echo 'no code for module (some-channel mod)' 1>&2; exit 0",
    ))
    .expect("spawn");
    op2.await_completion()
        .await
        .expect("zero exit must stay Ok despite known-bug line");
}

/// KnownBug escalation takes precedence over Polkit classification: even
/// if the operation exited 126 *and* the bug line was observed, we want
/// the bug error (it's more actionable for the user).
#[tokio::test(flavor = "multi_thread")]
async fn channel_shadow_outranks_polkit_classification() {
    let op =
        pkexec_operation_from_command(sh("echo 'no code for module (foo bar)' 1>&2; exit 126"))
            .expect("spawn");
    let err = op.await_completion().await.expect_err("expected error");
    assert!(
        matches!(err, GuixError::KnownBug(KnownBug::ChannelShadow74396)),
        "expected KnownBug to outrank Polkit, got {err:?}"
    );
}

/// Unrelated stderr lines must not trip the bug detector.
#[tokio::test(flavor = "multi_thread")]
async fn unrelated_stderr_does_not_trigger_known_bug() {
    let op = operation_from_command(sh(
        "echo 'guix: error: failed to build derivation' 1>&2; exit 1",
    ))
    .expect("spawn");
    let err = op.await_completion().await.expect_err("expected error");
    assert!(
        matches!(err, GuixError::OperationFailed { code: 1, .. }),
        "got {err:?}"
    );
}

/// TG3: `LIBGUIX_FORCE_NO_AGENT=1` exercises the auth-agent-absent
/// pre-flight branch without depending on whatever's running on the host.
/// We also clear `LIBGUIX_SKIP_AGENT_CHECK` for the test process so
/// the skip path doesn't shadow the force path.
///
/// Synchronous (not `#[tokio::test]`) because `reconfigure` only spawns
/// when the pre-flight passes — we're asserting the pre-check fires
/// before any subprocess work.
///
/// The env vars are process-global; we use a mutex to serialise this
/// test against any other env-var-touching test in the same binary.
#[test]
fn reconfigure_force_no_agent_returns_no_auth_agent() {
    use libguix::ReconfigureOptions;
    use std::path::PathBuf;
    use std::sync::Mutex;

    // Serialise env-var mutation so parallel test runners don't race.
    static ENV_LOCK: Mutex<()> = Mutex::new(());
    let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());

    let prev_skip = std::env::var_os("LIBGUIX_SKIP_AGENT_CHECK");
    let prev_force = std::env::var_os("LIBGUIX_FORCE_NO_AGENT");
    std::env::remove_var("LIBGUIX_SKIP_AGENT_CHECK");
    std::env::set_var("LIBGUIX_FORCE_NO_AGENT", "1");

    let sys = libguix::__test_support::system_ops();
    let cfg = PathBuf::from("/tmp/libguix-fake-cfg.scm");
    let result = sys.reconfigure(&cfg, ReconfigureOptions::default());

    // Restore env before asserting so a panic doesn't leak state into
    // other tests in this binary.
    if let Some(v) = prev_skip {
        std::env::set_var("LIBGUIX_SKIP_AGENT_CHECK", v);
    }
    if let Some(v) = prev_force {
        std::env::set_var("LIBGUIX_FORCE_NO_AGENT", v);
    } else {
        std::env::remove_var("LIBGUIX_FORCE_NO_AGENT");
    }

    // `Operation` doesn't impl `Debug`, so use a manual match rather
    // than `expect_err`.
    match result {
        Ok(_) => panic!("expected NoAuthAgent pre-flight failure, got Ok"),
        Err(GuixError::Polkit {
            kind: PolkitFailure::NoAuthAgent,
            ..
        }) => {}
        Err(other) => panic!("expected NoAuthAgent, got {other:?}"),
    }
}

/// `#[ignore]`-gated: runs `SystemOps::reconfigure(..., dry_run=true)`
/// against a temporary minimal config. This triggers a real polkit
/// prompt; opt in with `cargo test --features live-tests -- --ignored`.
///
/// Doesn't mutate system state (`--dry-run`), but does evaluate guix's
/// own system config DSL, which builds derivations. Tolerates both
/// outcomes: prompt approved → success or non-polkit guix failure
/// (the minimal config we generate may not be a valid bootable system);
/// prompt denied → `PolkitFailure::AuthFailed`.
#[cfg(feature = "live-tests")]
#[tokio::test(flavor = "multi_thread")]
#[ignore = "interactive polkit prompt; opt in via --ignored"]
async fn live_reconfigure_dry_run_triggers_polkit() {
    use libguix::{Guix, ReconfigureOptions};
    use std::io::Write;

    let g = Guix::discover().await.expect("discover");
    let tmp = tempfile::tempdir().expect("tempdir");
    let cfg = tmp.path().join("test.scm");
    let body = r#"(use-modules (gnu))
(operating-system
  (host-name "test")
  (timezone "UTC")
  (locale "en_US.UTF-8")
  (bootloader (bootloader-configuration
                (bootloader grub-bootloader)
                (targets '("/dev/null"))))
  (file-systems %base-file-systems))
"#;
    std::fs::File::create(&cfg)
        .and_then(|mut f| f.write_all(body.as_bytes()))
        .expect("write config");

    let mut op = g
        .system()
        .reconfigure(
            &cfg,
            ReconfigureOptions {
                dry_run: true,
                ..Default::default()
            },
        )
        .expect("spawn reconfigure");

    // Drain to completion regardless of outcome. We assert that at least
    // one batch arrived, and that the final event is ExitSummary.
    let mut events = Vec::new();
    while let Some(b) = op.events_mut().next().await {
        events.extend(b);
    }
    assert!(!events.is_empty(), "expected at least one event");
    assert!(
        matches!(events.last(), Some(ProgressEvent::ExitSummary { .. })),
        "expected ExitSummary as final event, got {events:?}"
    );
}

/// `#[ignore]`-gated: runs `SystemOps::pull(dry_run=true)` against the
/// real pkexec / polkit stack. Mirrors the reconfigure live test —
/// tolerates the prompt being approved (run completes) or dismissed
/// (`PolkitFailure::AuthFailed`).
///
/// `guix pull --dry-run` doesn't mutate state and skips the
/// derivation build, so this is the cheapest end-to-end live
/// exercise of the new `system().pull()` plumbing.
#[cfg(feature = "live-tests")]
#[tokio::test(flavor = "multi_thread")]
#[ignore = "interactive polkit prompt; opt in via --ignored"]
async fn live_system_pull_dry_run_triggers_polkit() {
    use libguix::{Guix, SystemPullOptions};

    let g = Guix::discover().await.expect("discover");
    let mut op = g
        .system()
        .pull(SystemPullOptions { dry_run: true })
        .expect("spawn system pull");

    let mut events = Vec::new();
    while let Some(b) = op.events_mut().next().await {
        events.extend(b);
    }
    assert!(!events.is_empty(), "expected at least one event");
    assert!(
        matches!(events.last(), Some(ProgressEvent::ExitSummary { .. })),
        "expected ExitSummary as final event, got {events:?}"
    );
}

/// `LIBGUIX_FORCE_NO_AGENT=1` exercises the auth-agent-absent
/// pre-flight branch for `system().pull()` too — the agent check is
/// shared with reconfigure, but we assert it fires before any
/// subprocess work for pull as well.
#[test]
fn system_pull_force_no_agent_returns_no_auth_agent() {
    use libguix::SystemPullOptions;
    use std::sync::Mutex;

    static ENV_LOCK: Mutex<()> = Mutex::new(());
    let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());

    let prev_skip = std::env::var_os("LIBGUIX_SKIP_AGENT_CHECK");
    let prev_force = std::env::var_os("LIBGUIX_FORCE_NO_AGENT");
    std::env::remove_var("LIBGUIX_SKIP_AGENT_CHECK");
    std::env::set_var("LIBGUIX_FORCE_NO_AGENT", "1");

    let _sys = libguix::__test_support::system_ops();
    let pull = libguix::__test_support::pull_ops_with_fake_binary();
    let result = pull.as_root(SystemPullOptions::default());

    if let Some(v) = prev_skip {
        std::env::set_var("LIBGUIX_SKIP_AGENT_CHECK", v);
    }
    if let Some(v) = prev_force {
        std::env::set_var("LIBGUIX_FORCE_NO_AGENT", v);
    } else {
        std::env::remove_var("LIBGUIX_FORCE_NO_AGENT");
    }

    match result {
        Ok(_) => panic!("expected NoAuthAgent pre-flight failure, got Ok"),
        Err(GuixError::Polkit {
            kind: PolkitFailure::NoAuthAgent,
            ..
        }) => {}
        Err(other) => panic!("expected NoAuthAgent, got {other:?}"),
    }
}