car-ffi-common 0.22.1

Shared logic for FFI bindings (NAPI, PyO3) — JSON wrappers for verify, multi-agent, scheduler
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
//! Shared auth-token path resolution and IO for car-server's
//! per-launch ephemeral handshake (Parslee-ai/car-releases#32).
//!
//! Every consumer that touches the token agrees on the same path:
//! - `car-server` writes it on startup unless `--no-auth` is set
//!   (the default flipped from opt-in to opt-out on 2026-05 after
//!   a security audit)
//! - `car-ffi-common::proxy` reads it on connect so NAPI/PyO3
//!   daemon-mode just-works
//! - The colocated UI server exposes it at `GET /auth-token` so
//!   the local HTML renderer can `session.auth` before any other
//!   RPC
//! - External WS clients can read it directly with `0600` perms
//!   guaranteeing only the launching user can do so
//!
//! There is also a sibling **host token** (`host-token`, see
//! [`host_default_path`] / [`read_host`] / [`write_host`]) granting the
//! host-management role (Parslee-ai/car#254). It is deliberately **NOT**
//! exposed over `GET /auth-token` — readable only from its `0600` file —
//! so a client that can reach loopback HTTP but cannot read the user's
//! secret files (or a different local user) cannot obtain it. Do not add a
//! `/host-token` HTTP route: that would reopen the self-elevation hole.
//!
//! ## Path
//!
//! - **macOS**: `~/Library/Application Support/ai.parslee.car/auth-token`
//! - **Linux**: `$XDG_RUNTIME_DIR/ai.parslee.car/auth-token` if set,
//!   otherwise `~/.config/ai.parslee.car/auth-token`
//! - **Windows**: `%LOCALAPPDATA%\ai.parslee.car\auth-token` (per-user
//!   profile-private — Windows ACLs handle the equivalent of `0600`
//!   for files under the user's profile)
//!
//! ## File format
//!
//! Single line, no trailing newline. The token itself is 32 random
//! bytes encoded as base64url-no-pad (`base64::URL_SAFE_NO_PAD`),
//! which is 43 ASCII chars. Distinct per launch; never persisted
//! across daemon restarts.

use std::io;
use std::path::PathBuf;

const DIR_NAME: &str = "ai.parslee.car";
const FILE_NAME: &str = "auth-token";

/// Environment variable that lets a client override the auth-token
/// source. When set and non-empty, [`read_for_client`] returns its
/// value verbatim instead of reading the per-platform token file.
///
/// Required for cross-host deployments — e.g. an FFI client running on
/// Windows talking to a daemon on a Linux host. The client has no file
/// at the local `%LOCALAPPDATA%` path because that's the daemon's
/// concern, but the daemon's token can be transferred out-of-band
/// (ssh, secrets store, vault) and surfaced here.
///
/// Companion to `CAR_DAEMON_URL` from `car_proto::daemon`; both are
/// client-side env-var overrides with the same precedence semantics:
/// env var wins when set, fall back to the default file path
/// otherwise. Empty or whitespace-only values are treated as unset —
/// see [`read_for_client`] for the full precedence rules. See
/// Parslee-ai/car#231 §8.0.3 for the cross-host gap this closes.
pub const TOKEN_ENV_VAR: &str = "CAR_AUTH_TOKEN";

/// Resolve the auth-token path for this platform. Returns
/// `Err(io::Error)` if neither HOME nor the platform-specific
/// fallback environment variable is set — in which case auth can't
/// be turned on at all.
pub fn default_path() -> io::Result<PathBuf> {
    let dir = default_dir()?;
    Ok(dir.join(FILE_NAME))
}

/// Filename of the per-launch **host** token — a sibling of the
/// auth-token in the same per-platform directory. Distinct credential
/// granting the host-management role (Parslee-ai/car#254). Unlike the
/// auth token it is **never** exposed over `GET /auth-token`; the only
/// way to read it is this `0600` file, which a different local user
/// cannot — that's what makes host-role unforgeable across users.
const HOST_FILE_NAME: &str = "host-token";

/// Resolve the host-token path for this platform (sibling of
/// [`default_path`] in the same directory).
pub fn host_default_path() -> io::Result<PathBuf> {
    Ok(default_dir()?.join(HOST_FILE_NAME))
}

/// Read the host token from [`host_default_path`]. `Ok(None)` when the
/// file is absent (host-role unavailable — e.g. `--no-auth`).
pub fn read_host() -> io::Result<Option<String>> {
    read_at(&host_default_path()?)
}

/// Write the host token to [`host_default_path`] with the same hardened
/// `0600` / Windows-ACL guarantees as [`write`] (both go through
/// [`write_at`]).
pub fn write_host(token: &str) -> io::Result<PathBuf> {
    let path = host_default_path()?;
    write_at(&path, token)?;
    Ok(path)
}

fn default_dir() -> io::Result<PathBuf> {
    #[cfg(target_os = "macos")]
    {
        if let Some(home) = std::env::var_os("HOME") {
            return Ok(PathBuf::from(home)
                .join("Library")
                .join("Application Support")
                .join(DIR_NAME));
        }
    }
    #[cfg(target_os = "linux")]
    {
        if let Some(rt) = std::env::var_os("XDG_RUNTIME_DIR") {
            return Ok(PathBuf::from(rt).join(DIR_NAME));
        }
        if let Some(home) = std::env::var_os("HOME") {
            return Ok(PathBuf::from(home).join(".config").join(DIR_NAME));
        }
    }
    #[cfg(target_os = "windows")]
    {
        if let Some(local) = std::env::var_os("LOCALAPPDATA") {
            return Ok(PathBuf::from(local).join(DIR_NAME));
        }
    }
    // Last-resort fallback for any platform that didn't match any
    // of the above branches: `$HOME/.car/`. Less standard but always
    // produces a usable path on a typical unix-style host.
    if let Some(home) = std::env::var_os("HOME") {
        return Ok(PathBuf::from(home).join(".car"));
    }
    Err(io::Error::new(
        io::ErrorKind::NotFound,
        "no HOME / XDG_RUNTIME_DIR / LOCALAPPDATA — can't resolve auth-token directory",
    ))
}

/// Read the token from [`default_path`]. Returns `Ok(None)` when the
/// file doesn't exist (auth disabled on the daemon side); returns
/// the token string on success.
pub fn read() -> io::Result<Option<String>> {
    read_at(&default_path()?)
}

/// Read the token from an explicit path — used by tests; production
/// callers use [`read`].
pub fn read_at(path: &std::path::Path) -> io::Result<Option<String>> {
    match std::fs::read_to_string(path) {
        Ok(s) => Ok(Some(s.trim().to_string())),
        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
        Err(e) => Err(e),
    }
}

/// Read the token for a *client* connecting to a daemon, with
/// [`TOKEN_ENV_VAR`] taking precedence over the local file path.
///
/// Precedence (env var wins, same shape as `CAR_DAEMON_URL`):
///
/// 1. `$CAR_AUTH_TOKEN` if set and non-empty → returned verbatim
/// 2. otherwise → delegates to [`read`] (the local per-platform file)
///
/// Whitespace around the env-var value is trimmed; whitespace-only
/// values are treated as unset and fall through to the file read.
/// Empty values fall through for the same reason — an explicitly
/// empty env var is almost always a shell-quoting bug, not a
/// deliberate "no token" signal.
///
/// Server-side callers (the daemon reading its own token from disk,
/// the UI server exposing it at `GET /auth-token`) **must continue to
/// use [`read`] directly**, not this function — the daemon would not
/// want a client-side env-var override to influence what it advertises
/// as its own per-launch token.
pub fn read_for_client() -> io::Result<Option<String>> {
    if let Some(env_value) = std::env::var_os(TOKEN_ENV_VAR) {
        // var_os preserves bytes verbatim. Use `to_str()` (NOT
        // `to_string_lossy`) so a non-UTF-8 env var falls through to
        // the file read rather than substituting `U+FFFD` replacement
        // characters and shipping garbage as the auth token. CAR
        // tokens are ASCII base64url-no-pad — always valid UTF-8 — so
        // a non-UTF-8 value is by definition a corrupted env var, and
        // "treat as unset" is the safer failure mode. Cheaper too:
        // `to_str()` returns `Some(&str)` without allocating when the
        // OsString is already a valid `&str`.
        if let Some(s) = env_value.to_str() {
            let trimmed = s.trim();
            if !trimmed.is_empty() {
                return Ok(Some(trimmed.to_string()));
            }
        }
    }
    read()
}

/// Write `token` atomically to [`default_path`]. Creates the parent
/// directory if missing. Sets `0600` permissions on POSIX (Windows
/// inherits the per-user-profile ACL).
pub fn write(token: &str) -> io::Result<PathBuf> {
    let path = default_path()?;
    write_at(&path, token)?;
    Ok(path)
}

/// Write to an explicit path — used by tests; production callers
/// use [`write`].
pub fn write_at(path: &std::path::Path, token: &str) -> io::Result<()> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
        }
    }
    let tmp = path.with_extension("tmp");
    // Write under tight perms first, then rename — POSIX
    // guarantees the rename is atomic on the same fs, so concurrent
    // readers never see a partial write.
    std::fs::write(&tmp, token)?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
    }
    std::fs::rename(&tmp, path)?;
    #[cfg(target_os = "windows")]
    {
        // On Windows the file inherits the per-user-profile ACL from
        // %LOCALAPPDATA%, which (despite the "per-user" framing)
        // grants `BUILTIN\Administrators: FullControl`. On a single-
        // admin dev box that's effectively the same as 0600, but on a
        // shared / managed / enterprise host with multiple admins
        // any local admin can read the token and impersonate the
        // daemon to the WS surface — a real privesc that the Unix
        // chmod 0o600 was designed to block. Drop inheritance and
        // re-grant explicit ACEs for SYSTEM + the launching user.
        // Failures are logged and swallowed: the file was written
        // successfully; weaker ACLs are a hardening miss, not a
        // functional failure. Fixes finding 2 of Parslee-ai/car#231.
        harden_windows_acl(path);
    }
    Ok(())
}

#[cfg(target_os = "windows")]
fn harden_windows_acl(path: &std::path::Path) {
    use std::process::Command;

    let path_str = match path.to_str() {
        Some(s) => s,
        None => {
            tracing::warn!(
                ?path,
                "auth-token ACL hardening skipped: path is not valid UTF-8"
            );
            return;
        }
    };

    // Order matters: grant the required ACEs first, THEN drop
    // inheritance. The reverse order would leave a window where the
    // file has no ACEs at all and the daemon can't re-open it.

    // SYSTEM via well-known SID (locale-independent).
    let _ = run_icacls(&[path_str, "/grant:r", "*S-1-5-18:F"]);

    // Launching user — USERPROFILE/USERNAME isn't guaranteed inside
    // every service context (SYSTEM-launched daemons have neither),
    // so skip this step when missing. The SYSTEM grant above keeps
    // the daemon process able to read the file.
    if let Some(username) = std::env::var_os("USERNAME") {
        if let Some(u) = username.to_str() {
            let grant = format!("{u}:F");
            let _ = run_icacls(&[path_str, "/grant:r", &grant]);
        }
    }

    // Drop inherited ACEs. After this, only the explicit ACEs granted
    // above remain — inherited BUILTIN\Administrators access is gone.
    if let Err(e) = run_icacls(&[path_str, "/inheritance:r"]) {
        tracing::warn!(?e, ?path, "auth-token ACL hardening: /inheritance:r failed");
    }

    // `/inheritance:r` only removes *inherited* ACEs. On the #231 §3.1
    // test host an *explicit* `BUILTIN\Administrators:(F)` ACE survived
    // inheritance removal (re-verified against the shipped v0.18.0
    // binary: `AreAccessRulesProtected: True`, yet Administrators still
    // listed with FullControl). It materializes when the daemon writes
    // the file while elevated — the kernel stamps an explicit owner/
    // admin ACE that is not inherited and so is immune to
    // `/inheritance:r`. Strip it explicitly via the locale-independent
    // well-known SID (S-1-5-32-544 = BUILTIN\Administrators) so the
    // final DACL is exactly SYSTEM + owner — true parity with the Unix
    // `chmod 0o600`. Remove both the allow (`:g`) and any deny (`:d`)
    // grant. No-ops cleanly when the ACE isn't present.
    let _ = run_icacls(&[path_str, "/remove:g", "*S-1-5-32-544"]);
    let _ = run_icacls(&[path_str, "/remove:d", "*S-1-5-32-544"]);

    // Belt-and-suspenders: an elevated writer leaves the file *owned*
    // by the Administrators group, and an owner retains WRITE_DAC —
    // any local admin could re-grant themselves read and then read the
    // token. Re-home ownership to the launching user so that implicit
    // owner power tracks a single principal, not the whole admin group.
    // Best-effort: setting an owner other than self needs
    // SeRestorePrivilege, which a non-elevated user-context daemon may
    // lack; the explicit ACE removal above is the load-bearing fix, so
    // a failure here is logged-and-swallowed like the rest. `USERNAME`
    // is the bare account name (same value the grant above uses) —
    // adequate for the single-admin dev/box case this targets; it can
    // be ambiguous for domain/Microsoft accounts, but icacls resolves
    // it against the local machine and a miss just leaves ownership
    // unchanged (logged).
    //
    // Scope note: this brings the *DACL* to chmod-0600 parity (only
    // SYSTEM + owner in the access list) and removes the casual
    // any-admin read path. It is NOT a guarantee against a *malicious*
    // elevated administrator — SeTakeOwnership / SeBackupPrivilege let
    // such a principal re-own or back-up-read the file regardless of
    // ACLs. Defeating that adversary needs OS-level secret storage
    // (DPAPI / Credential Manager), which is a larger change tracked
    // separately; #231 §3.1 asked specifically for chmod-0600 ACL
    // parity, which this delivers.
    if let Some(username) = std::env::var_os("USERNAME") {
        if let Some(u) = username.to_str() {
            if let Err(e) = run_icacls(&[path_str, "/setowner", u]) {
                tracing::warn!(?e, ?path, "auth-token ACL hardening: /setowner failed");
            }
        }
    }

    fn run_icacls(args: &[&str]) -> io::Result<()> {
        let status = Command::new("icacls").args(args).status()?;
        if !status.success() {
            return Err(io::Error::new(
                io::ErrorKind::Other,
                format!("icacls {args:?} exited with status {status}"),
            ));
        }
        Ok(())
    }
}

/// Remove the token file. Idempotent — `Ok(())` whether or not the
/// file existed. Used by the `car-server` shutdown path so a stale
/// token doesn't outlive the daemon that minted it.
pub fn remove() -> io::Result<()> {
    let path = default_path()?;
    match std::fs::remove_file(&path) {
        Ok(()) => Ok(()),
        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
        Err(e) => Err(e),
    }
}

/// Generate a fresh 32-byte token, base64url-no-pad encoded
/// (43 chars). Uses the system CSPRNG via `getrandom` indirectly
/// through `uuid::Uuid::new_v4` which seeds from `getrandom` — we
/// concatenate two UUIDs (16 bytes each) and re-encode.
pub fn generate() -> String {
    use base64::Engine as _;
    let a = uuid::Uuid::new_v4();
    let b = uuid::Uuid::new_v4();
    let mut bytes = [0u8; 32];
    bytes[..16].copy_from_slice(a.as_bytes());
    bytes[16..].copy_from_slice(b.as_bytes());
    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn generated_token_is_43_chars_base64url() {
        let t = generate();
        assert_eq!(t.len(), 43);
        for c in t.chars() {
            assert!(
                c.is_ascii_alphanumeric() || c == '-' || c == '_',
                "non-base64url char: {c:?}"
            );
        }
    }

    #[test]
    fn read_at_missing_returns_none() {
        let tmp = tempfile::TempDir::new().unwrap();
        let path = tmp.path().join("nonexistent.token");
        let result = read_at(&path).unwrap();
        assert_eq!(result, None);
    }

    #[test]
    fn write_at_then_read_at_round_trips() {
        let tmp = tempfile::TempDir::new().unwrap();
        let path = tmp.path().join("ai.parslee.car").join("auth-token");
        let token = generate();
        write_at(&path, &token).unwrap();
        assert_eq!(read_at(&path).unwrap().as_deref(), Some(token.as_str()));
        assert!(path.exists());
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mode = std::fs::metadata(&path).unwrap().permissions().mode();
            assert_eq!(mode & 0o777, 0o600, "token must be 0600");
        }
    }

    #[test]
    fn write_at_overwrites_atomically() {
        // Repeated writes must replace cleanly — the rename-into-place
        // pattern guarantees no partial-write window. Two consecutive
        // writes with different content; second wins.
        let tmp = tempfile::TempDir::new().unwrap();
        let path = tmp.path().join("auth-token");
        write_at(&path, "first").unwrap();
        write_at(&path, "second").unwrap();
        assert_eq!(read_at(&path).unwrap().as_deref(), Some("second"));
    }

    // Tests that touch `$CAR_AUTH_TOKEN` (or read an env-derived path
    // like `HOME` while another test mutates it) serialize on the
    // crate-wide [`crate::env_test_lock`] — a single process-global lock
    // shared with `memory_path::tests` / `proxy::tests` so cross-module
    // env races (e.g. this module reading `HOME` while `memory_path`
    // writes it) can't happen, and so a panic in one test doesn't poison
    // a per-module lock and cascade.

    /// Panic-safe snapshot/restore helper for a single env var.
    /// Captures the current value on construction, restores it on
    /// `Drop` — so a test panic between mutation and explicit
    /// cleanup doesn't leak the override to subsequent tests.
    struct EnvGuard {
        var: &'static str,
        prev: Option<std::ffi::OsString>,
    }
    impl EnvGuard {
        fn capture(var: &'static str) -> Self {
            Self {
                var,
                prev: std::env::var_os(var),
            }
        }
    }
    impl Drop for EnvGuard {
        fn drop(&mut self) {
            match &self.prev {
                Some(v) => unsafe { std::env::set_var(self.var, v) },
                None => unsafe { std::env::remove_var(self.var) },
            }
        }
    }

    /// F2 (#231 §8.0.3): `$CAR_AUTH_TOKEN` wins over the local file
    /// path when set and non-empty. Required for cross-host clients —
    /// the FFI process can't read the daemon's token off the local
    /// disk when the daemon is on a different machine.
    #[test]
    fn read_for_client_prefers_env_var_when_set() {
        let _lock = crate::env_test_lock();
        let _env = EnvGuard::capture(TOKEN_ENV_VAR);

        unsafe { std::env::set_var(TOKEN_ENV_VAR, "from-env") };
        assert_eq!(
            read_for_client().unwrap().as_deref(),
            Some("from-env"),
            "env var should win when set"
        );
    }

    /// Empty and whitespace-only env values must NOT satisfy auth —
    /// a shell-quoting bug shouldn't silently disable the handshake.
    /// We assert this by the property that matters: `read_for_client`
    /// with an empty/whitespace env var must return *exactly the same
    /// outcome* as `read()` would with no env var at all. This holds
    /// regardless of whether the host has a token file at the
    /// well-known path, which lets the test stay hermetic — no
    /// mutation of `HOME`, `XDG_RUNTIME_DIR`, or `LOCALAPPDATA`
    /// required. (Touching those vars would create races with
    /// `memory_path::tests` and other consumers that mutate HOME
    /// outside this lock.)
    #[test]
    fn read_for_client_treats_empty_env_as_unset() {
        let _lock = crate::env_test_lock();
        let _env = EnvGuard::capture(TOKEN_ENV_VAR);

        for empty_value in ["", "   ", "\t\n", " \r\n "] {
            unsafe { std::env::set_var(TOKEN_ENV_VAR, empty_value) };
            let via_client = read_for_client();
            // Snapshot `read()` with no env interference for comparison.
            unsafe { std::env::remove_var(TOKEN_ENV_VAR) };
            let via_direct = read();
            // Restore the empty value so the next iteration's snapshot
            // semantics hold.
            unsafe { std::env::set_var(TOKEN_ENV_VAR, empty_value) };

            match (&via_client, &via_direct) {
                (Ok(a), Ok(b)) => assert_eq!(
                    a, b,
                    "with empty env value {empty_value:?}, read_for_client \
                     must match read()'s outcome (env var should be treated \
                     as unset)"
                ),
                (Err(_), Err(_)) => {} // both fail the same way — acceptable
                (a, b) => panic!(
                    "read_for_client and read disagreed for empty env value \
                     {empty_value:?}: client={a:?} direct={b:?}"
                ),
            }
        }
    }

    /// Without `$CAR_AUTH_TOKEN`, `read_for_client` behaves exactly
    /// like `read()` — no change for same-host loopback callers.
    #[test]
    fn read_for_client_falls_back_to_read_when_unset() {
        let _lock = crate::env_test_lock();
        let _env = EnvGuard::capture(TOKEN_ENV_VAR);

        unsafe { std::env::remove_var(TOKEN_ENV_VAR) };
        let via_client = read_for_client();
        let via_direct = read();
        match (&via_client, &via_direct) {
            (Ok(a), Ok(b)) => assert_eq!(
                a, b,
                "read_for_client without env var must match read() exactly"
            ),
            (Err(_), Err(_)) => {} // both fail the same way — acceptable
            (a, b) => panic!(
                "read_for_client and read disagreed: client={a:?} direct={b:?}"
            ),
        }
    }

    /// Trimming: the env var value is trimmed before use. A token
    /// pasted via SSH with trailing newline (the §8.0.3 standard
    /// transfer mechanism) still works. Also covers Windows-style
    /// CRLF endings if a token file was cat'd on Windows and piped
    /// over.
    #[test]
    fn read_for_client_trims_whitespace_around_token() {
        let _lock = crate::env_test_lock();
        let _env = EnvGuard::capture(TOKEN_ENV_VAR);

        for (input, expected) in [
            ("  some-token\n", "some-token"),
            ("some-token\r\n", "some-token"),
            ("\tsome-token\t", "some-token"),
        ] {
            unsafe { std::env::set_var(TOKEN_ENV_VAR, input) };
            assert_eq!(
                read_for_client().unwrap().as_deref(),
                Some(expected),
                "trim should normalize {input:?} → {expected:?}"
            );
        }
    }

    /// Non-UTF-8 env var must fall through to the file read, not
    /// substitute replacement chars and ship garbage. CAR tokens are
    /// ASCII base64url-no-pad so a non-UTF-8 value is by definition
    /// corrupt. Unix-only because `set_var` on Windows already
    /// requires UTF-16 — non-UTF-8 isn't constructible there.
    #[cfg(unix)]
    #[test]
    fn read_for_client_treats_non_utf8_env_as_unset() {
        use std::os::unix::ffi::OsStrExt;
        let _lock = crate::env_test_lock();
        let _env = EnvGuard::capture(TOKEN_ENV_VAR);

        // 0xFF is not a valid UTF-8 start byte; this OsStr cannot be
        // converted via `to_str()` and must fall through.
        let bad = std::ffi::OsStr::from_bytes(&[b'a', 0xFF, b'b']);
        unsafe { std::env::set_var(TOKEN_ENV_VAR, bad) };

        let via_client = read_for_client();
        // Snapshot read() with no env for comparison.
        unsafe { std::env::remove_var(TOKEN_ENV_VAR) };
        let via_direct = read();
        unsafe { std::env::set_var(TOKEN_ENV_VAR, bad) };

        match (&via_client, &via_direct) {
            (Ok(a), Ok(b)) => assert_eq!(
                a, b,
                "non-UTF-8 env value must be treated as unset and \
                 fall through to read()"
            ),
            (Err(_), Err(_)) => {}
            (a, b) => panic!(
                "read_for_client and read disagreed for non-UTF-8: \
                 client={a:?} direct={b:?}"
            ),
        }
    }
}