car-ffi-common 0.24.0

Shared logic for FFI bindings (NAPI, PyO3) — JSON wrappers for verify, multi-agent, scheduler
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
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
//! 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.
//!
//! ## Windows: DPAPI encryption at rest (Parslee-ai/car#295)
//!
//! On Windows the file contents are **not** the plaintext token. The
//! ACL hardening in [`write_at`] brings the DACL to `chmod 0o600` parity
//! (SYSTEM + owner only) but cannot defend against a *malicious elevated
//! administrator*: `SeTakeOwnership` / `SeBackupPrivilege` let such a
//! principal re-own or backup-read the file regardless of its ACL. To
//! defeat that adversary the token bytes are encrypted with DPAPI
//! (`CryptProtectData`) under the launching user's logon secret before
//! being written, and decrypted with `CryptUnprotectData` on read. The
//! file therefore holds DPAPI ciphertext (base64url-no-pad encoded to
//! preserve the "single ASCII line" contract); taking ownership of the
//! file yields a blob only the originating user (or SYSTEM, in a service
//! context) can decrypt. Encryption/decryption is transparent — every
//! consumer keeps calling [`read`] / [`write`] and sees the plaintext
//! token. macOS and Linux are unaffected (POSIX `chmod 0o600` already
//! blocks cross-user reads on those platforms).

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) => {
            let line = s.trim();
            // On Windows the file holds a DPAPI-protected, base64url-no-pad
            // blob (Parslee-ai/car#295); decrypt back to the plaintext
            // token. On macOS/Linux the file is the plaintext token itself.
            #[cfg(target_os = "windows")]
            {
                Ok(Some(windows_dpapi::unprotect_from_line(line)?))
            }
            #[cfg(not(target_os = "windows"))]
            {
                Ok(Some(line.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; on Windows
/// the token is DPAPI-encrypted at rest (Parslee-ai/car#295) and the
/// file DACL is hardened to SYSTEM + owner (see [`write_at`]).
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.
    //
    // On Windows the on-disk bytes are DPAPI ciphertext, not the raw
    // token (Parslee-ai/car#295). DPAPI binds the blob to the launching
    // user's logon secret, so a malicious elevated admin who takes
    // ownership of the file reads only an undecryptable blob. The ACL
    // hardening below is kept as defense-in-depth. On macOS/Linux the
    // plaintext token is written and POSIX `chmod 0o600` is the barrier.
    #[cfg(target_os = "windows")]
    let on_disk = windows_dpapi::protect_to_line(token)?;
    #[cfg(target_os = "windows")]
    std::fs::write(&tmp, &on_disk)?;
    #[cfg(not(target_os = "windows"))]
    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. That adversary is defeated by the DPAPI encryption-at-rest
    // layer (see [`windows_dpapi`] / Parslee-ai/car#295): the file holds
    // ciphertext bound to the launching user's logon secret, so taking
    // ownership of the file yields nothing decryptable. This ACL
    // hardening is retained as defense-in-depth (it keeps even the
    // ciphertext blob unreadable to non-owners on a single-admin box).
    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(())
    }
}

/// DPAPI encryption-at-rest for the Windows auth-token file
/// (Parslee-ai/car#295).
///
/// `CryptProtectData` encrypts the token bytes under the current user's
/// logon secret (no `CRYPTPROTECT_LOCAL_MACHINE` flag → per-user, not
/// per-machine). `CryptUnprotectData` reverses it. Because the key is
/// derived from the user's credentials, a *different* principal — even a
/// local administrator who has taken ownership of the file via
/// `SeTakeOwnership` / `SeBackupPrivilege` — gets back only an
/// undecryptable blob. `CRYPTPROTECT_UI_FORBIDDEN` is set so the calls
/// never block on a UI prompt in a headless/service daemon context.
///
/// The ciphertext is base64url-no-pad encoded so the on-disk file stays
/// a single ASCII line (the documented format contract). No entropy /
/// description / prompt args are supplied — they're optional and unused.
#[cfg(target_os = "windows")]
mod windows_dpapi {
    use super::io;
    use base64::Engine as _;
    use windows::core::PCWSTR;
    use windows::Win32::Foundation::{LocalFree, HLOCAL};
    use windows::Win32::Security::Cryptography::{
        CryptProtectData, CryptUnprotectData, CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB,
    };

    /// Encrypt `token` with DPAPI and return the base64url-no-pad
    /// ciphertext line to write to disk.
    pub(super) fn protect_to_line(token: &str) -> io::Result<String> {
        let plaintext = token.as_bytes();
        // SAFETY: `plaintext` outlives the call; `pbData` is only read
        // (cast away const for the C ABI, never mutated). `out` is
        // zeroed and populated by the API; on success we copy its bytes
        // out and `LocalFree` the API-allocated buffer.
        let in_blob = CRYPT_INTEGER_BLOB {
            cbData: plaintext.len() as u32,
            pbData: plaintext.as_ptr() as *mut u8,
        };
        let mut out = CRYPT_INTEGER_BLOB::default();

        let cipher = unsafe {
            CryptProtectData(
                &in_blob,
                // szdatadescr is a human-readable label, not security-
                // relevant; pass a null PCWSTR. (This arg is IntoParam
                // <PCWSTR>, not Option, in windows 0.54 — unlike the
                // other optional pointer args below.)
                PCWSTR::null(),
                None,
                None,
                None,
                CRYPTPROTECT_UI_FORBIDDEN,
                &mut out,
            )
            .map_err(|e| {
                io::Error::new(io::ErrorKind::Other, format!("CryptProtectData failed: {e}"))
            })?;
            let bytes = std::slice::from_raw_parts(out.pbData, out.cbData as usize).to_vec();
            let _ = LocalFree(HLOCAL(out.pbData as *mut core::ffi::c_void));
            bytes
        };

        Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&cipher))
    }

    /// Decrypt a base64url-no-pad DPAPI ciphertext `line` back to the
    /// plaintext token.
    pub(super) fn unprotect_from_line(line: &str) -> io::Result<String> {
        let mut cipher = base64::engine::general_purpose::URL_SAFE_NO_PAD
            .decode(line.as_bytes())
            .map_err(|e| {
                io::Error::new(
                    io::ErrorKind::InvalidData,
                    format!("auth-token is not valid base64url DPAPI ciphertext: {e}"),
                )
            })?;

        let in_blob = CRYPT_INTEGER_BLOB {
            cbData: cipher.len() as u32,
            pbData: cipher.as_mut_ptr(),
        };
        let mut out = CRYPT_INTEGER_BLOB::default();

        // SAFETY: same contract as `protect_to_line` — inputs outlive
        // the call, `out` is API-allocated and `LocalFree`d after copy.
        let plaintext = unsafe {
            CryptUnprotectData(
                &in_blob,
                None,
                None,
                None,
                None,
                CRYPTPROTECT_UI_FORBIDDEN,
                &mut out,
            )
            .map_err(|e| {
                io::Error::new(
                    io::ErrorKind::Other,
                    format!("CryptUnprotectData failed (token not decryptable by this user): {e}"),
                )
            })?;
            let bytes = std::slice::from_raw_parts(out.pbData, out.cbData as usize).to_vec();
            let _ = LocalFree(HLOCAL(out.pbData as *mut core::ffi::c_void));
            bytes
        };

        String::from_utf8(plaintext).map_err(|e| {
            io::Error::new(
                io::ErrorKind::InvalidData,
                format!("decrypted auth-token is not valid UTF-8: {e}"),
            )
        })
    }
}

/// 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"));
    }

    /// #231 §3.1 regression guard: the Windows auth-token DACL must be
    /// exactly owner + SYSTEM. An elevated writer leaves an explicit
    /// `BUILTIN\Administrators:(F)` ACE behind; `harden_windows_acl`
    /// strips it (true `chmod 0o600` parity). Without this test a
    /// refactor of the icacls sequence could silently re-open the
    /// any-admin read path and nothing would catch it — the behavior
    /// shipped untested until now.
    ///
    /// Asserts against locale-independent well-known SIDs
    /// (`S-1-5-32-544` = BUILTIN\Administrators, `S-1-5-18` = SYSTEM) so
    /// the test is stable on non-English hosts where icacls/Get-Acl
    /// print localized display names. Reads the DACL back via PowerShell
    /// `Get-Acl` + `Translate(...)` to SID. Skips (does not fail) when
    /// that read-back tool is unavailable on the runner; hard-asserts
    /// when it is present. NOTE: this body only executes on a real
    /// Windows host — `check-windows` CI runs `cargo check` (compile
    /// only), so the e2e Windows test job (see ci.yml) is what actually
    /// exercises it.
    #[cfg(target_os = "windows")]
    #[test]
    fn windows_token_dacl_excludes_administrators() {
        use std::process::Command;

        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!(path.exists());

        let path_str = path.to_str().expect("tempdir path is UTF-8");

        // Enumerate the DACL as locale-independent SID strings. Get-Acl
        // returns NTAccount identities; Translate() canonicalizes each
        // to its SID so the assertions don't depend on display-name
        // locale. Fall back to the raw .Value if an identity can't be
        // translated (e.g. an orphaned SID), so it still appears in the
        // list we assert against.
        let script = format!(
            "$ErrorActionPreference='Stop'; \
             (Get-Acl -LiteralPath '{path_str}').Access | ForEach-Object {{ \
               try {{ $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value }} \
               catch {{ $_.IdentityReference.Value }} }}"
        );

        let output = match Command::new("powershell")
            .args(["-NoProfile", "-NonInteractive", "-Command", &script])
            .output()
        {
            Ok(o) => o,
            Err(e) => {
                eprintln!(
                    "skipping windows_token_dacl_excludes_administrators: \
                     powershell unavailable ({e})"
                );
                return;
            }
        };
        if !output.status.success() {
            eprintln!(
                "skipping windows_token_dacl_excludes_administrators: \
                 Get-Acl read-back failed: {}",
                String::from_utf8_lossy(&output.stderr)
            );
            return;
        }

        let stdout = String::from_utf8_lossy(&output.stdout);
        let sids: Vec<&str> = stdout
            .lines()
            .map(|l| l.trim())
            .filter(|l| !l.is_empty())
            .collect();
        assert!(
            !sids.is_empty(),
            "Get-Acl returned no ACEs for {path_str}; read-back produced \
             nothing to assert on (treat as a test-environment failure, \
             not a pass)"
        );

        // Load-bearing: BUILTIN\Administrators must be gone.
        assert!(
            !sids.iter().any(|s| s.eq_ignore_ascii_case("S-1-5-32-544")),
            "auth-token DACL still grants BUILTIN\\Administrators \
             (S-1-5-32-544) — harden_windows_acl regression, the \
             any-admin read path is re-opened. ACEs: {sids:?}"
        );

        // SYSTEM must remain so the daemon can re-read its own token.
        assert!(
            sids.iter().any(|s| s.eq_ignore_ascii_case("S-1-5-18")),
            "auth-token DACL is missing NT AUTHORITY\\SYSTEM (S-1-5-18); \
             the daemon could lose read access to its token. ACEs: {sids:?}"
        );
    }

    /// #295 security property: on Windows the token is DPAPI-encrypted at
    /// rest, so the *raw file bytes* must NOT contain the plaintext token.
    /// This is the load-bearing defense against a malicious elevated admin
    /// who takes ownership of the file — they get ciphertext, not the
    /// secret. The round-trip (`write_at` → `read_at`) is covered by
    /// `write_at_then_read_at_round_trips`; this test guards the at-rest
    /// confidentiality that DACLs alone could not provide. Runs only on a
    /// real Windows host (`check-windows` CI is compile-only; the e2e
    /// Windows job exercises it).
    #[cfg(target_os = "windows")]
    #[test]
    fn windows_token_is_encrypted_at_rest() {
        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();

        let raw = std::fs::read_to_string(&path).unwrap();
        assert!(
            !raw.contains(token.trim()),
            "auth-token file contains the plaintext token — DPAPI \
             encryption-at-rest regression (#295). Raw on-disk contents \
             must be ciphertext, not the secret."
        );
        // And the round-trip still recovers the plaintext.
        assert_eq!(read_at(&path).unwrap().as_deref(), Some(token.as_str()));
    }

    // 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:?}"
            ),
        }
    }
}