tauri-plugin-hasgard 0.1.0

Native automation and testing bridge for Tauri 2 applications
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
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
use super::{EvalFn, FocusFn, ListWindowsFn, handle_connection};

use crate::error::Error;
use crate::eval::EvalEngine;
use crate::recorder::Recorder;

use std::alloc::{Layout, alloc_zeroed, dealloc};
use std::ffi::c_void;
use std::mem;
use std::mem::MaybeUninit;
use std::os::windows::io::AsRawHandle;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tokio::net::windows::named_pipe::{NamedPipeServer, ServerOptions};
use windows::Win32::Foundation::{CloseHandle, GENERIC_READ, GENERIC_WRITE, HANDLE};
use windows::Win32::Security::{
    ACL, ACL_REVISION, AddAccessAllowedAce, EqualSid, GetLengthSid, GetTokenInformation, InitializeAcl,
    InitializeSecurityDescriptor, PSECURITY_DESCRIPTOR, PSID, RevertToSelf, SECURITY_ATTRIBUTES,
    SetSecurityDescriptorDacl, TOKEN_QUERY, TOKEN_USER, TokenUser,
};
use windows::Win32::System::Pipes::ImpersonateNamedPipeClient;
use windows::Win32::System::Threading::{GetCurrentProcess, GetCurrentThread, OpenProcessToken, OpenThreadToken};

pub fn socket_path(identifier: &str) -> PathBuf {
    PathBuf::from(format!(r"\\.\pipe\tauri-hasgard-{identifier}"))
}

#[derive(serde::Serialize, serde::Deserialize)]
pub(crate) struct InstanceEntry {
    pub pipe: String,
    pub pid: u32,
    pub created_at: u64,
}

fn instances_dir() -> std::io::Result<PathBuf> {
    let local_app_data = std::env::var_os("LOCALAPPDATA").filter(|v| !v.is_empty()).ok_or_else(|| {
        std::io::Error::new(std::io::ErrorKind::NotFound, "LOCALAPPDATA environment variable is not set or empty")
    })?;
    Ok(PathBuf::from(local_app_data).join("tauri-hasgard").join("instances"))
}

fn instance_file_path(identifier: &str) -> std::io::Result<PathBuf> {
    let dir = instances_dir()?;
    // The instances directory sits under %LOCALAPPDATA%, which already inherits
    // user-only ACLs from the user profile, so no extra DACL is needed here.
    // `create_dir_all` is recursive (unlike `CreateDirectoryW`) and is a no-op
    // when the directory already exists.
    std::fs::create_dir_all(&dir)?;
    Ok(dir.join(format!("{identifier}.json")))
}

fn atomic_write_instance(path: &Path, entry: &InstanceEntry) -> std::io::Result<()> {
    let json = serde_json::to_string(entry).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
    let tmp = path.with_extension("tmp");
    std::fs::write(&tmp, json)?;
    std::fs::rename(&tmp, path)?;
    Ok(())
}

fn register_instance(identifier: &str, pipe_path: &Path) -> std::io::Result<()> {
    let path = instance_file_path(identifier)?;
    let entry = InstanceEntry {
        pipe: pipe_path.to_string_lossy().into_owned(),
        pid: std::process::id(),
        created_at: std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs(),
    };
    atomic_write_instance(&path, &entry)
}

fn unregister_instance(identifier: &str) -> std::io::Result<()> {
    let path = instance_file_path(identifier)?;
    if path.exists() {
        std::fs::remove_file(&path)?;
    }
    Ok(())
}

pub struct RegistryGuard {
    identifier: String,
}

impl Drop for RegistryGuard {
    fn drop(&mut self) {
        if let Err(e) = unregister_instance(&self.identifier) {
            tracing::warn!(identifier = %self.identifier, error = %e, "failed to remove registry entry");
        } else {
            tracing::info!(identifier = %self.identifier, "registry entry removed");
        }
    }
}

// ---------------------------------------------------------------------------
// Security: restrict the named pipe to the creating user only (DACL-only)
// ---------------------------------------------------------------------------

/// Owns a raw allocation backing an ACL with the correct layout.
struct AclBuffer {
    ptr: *mut ACL,
    layout: Layout,
}

impl AclBuffer {
    fn as_ptr(&self) -> *mut ACL {
        self.ptr
    }
}

impl Drop for AclBuffer {
    fn drop(&mut self) {
        if !self.ptr.is_null() {
            // SAFETY: `ptr` was allocated by `alloc_zeroed` with the same `layout`
            // and has not been freed yet.
            unsafe {
                dealloc(self.ptr.cast::<u8>(), self.layout);
            }
        }
    }
}

/// RAII wrapper for a Win32 `HANDLE` that closes it on drop.
struct OwnedHandle(HANDLE);

impl OwnedHandle {
    fn raw(&self) -> HANDLE {
        self.0
    }
}

impl Drop for OwnedHandle {
    fn drop(&mut self) {
        if !self.0.0.is_null() {
            // SAFETY: `self.0` was returned by a successful `Open*Token` call
            // and has not been closed yet.
            unsafe {
                let _ = CloseHandle(self.0);
            }
        }
    }
}

/// Owns the buffers backing a [`SECURITY_ATTRIBUTES`].
struct SecurityAttributesGuard {
    /// Backing storage for the `SECURITY_DESCRIPTOR`; must outlive the pipe creation.
    _sd: Box<MaybeUninit<windows::Win32::Security::SECURITY_DESCRIPTOR>>,
    /// Backing storage for the ACL; must outlive the pipe creation.
    _acl: AclBuffer,
    /// Backing storage for the SID (referenced by the ACE we add to the ACL).
    _sid_buf: Vec<u8>,
    /// The token handle used to obtain the SID.
    _token: OwnedHandle,
}

/// Opens the current process token for reading the creator's SID.
fn open_process_token() -> std::io::Result<OwnedHandle> {
    // SAFETY: `GetCurrentProcess` returns a pseudo-handle that does not need closing.
    let process = unsafe { GetCurrentProcess() };
    let mut token = HANDLE(std::ptr::null_mut());
    // SAFETY: `process` is a valid pseudo-handle; `token` points to stack-local storage.
    unsafe { OpenProcessToken(process, TOKEN_QUERY, &raw mut token) }
        .map_err(|e| std::io::Error::other(e.to_string()))?;
    Ok(OwnedHandle(token))
}

/// Opens the current thread's impersonation token. Must be called after
/// [`ImpersonateNamedPipeClient`] so the thread is impersonating the peer.
fn open_thread_impersonation_token() -> std::io::Result<OwnedHandle> {
    // SAFETY: `GetCurrentThread` returns a pseudo-handle that does not need closing.
    let thread = unsafe { GetCurrentThread() };
    let mut token = HANDLE(std::ptr::null_mut());
    // SAFETY: `thread` is a valid pseudo-handle; `token` points to stack-local storage.
    // `OpenThreadToken` reads the impersonation token from the thread, which is the
    // client's token after `ImpersonateNamedPipeClient`.
    unsafe { OpenThreadToken(thread, TOKEN_QUERY, true, &raw mut token) }
        .map_err(|e| std::io::Error::other(e.to_string()))?;
    Ok(OwnedHandle(token))
}

/// Returns the SID owned by `token`, along with the backing buffer that the SID
/// pointer references. The caller MUST keep the returned `Vec<u8>` alive for as
/// long as the pointer is dereferenced (fix for the prior use-after-free).
fn get_user_sid(token: &OwnedHandle) -> std::io::Result<(Vec<u8>, PSID)> {
    let mut return_length = 0u32;
    // First call is expected to fail with ERROR_INSUFFICIENT_BUFFER — it just
    // writes the required size into `return_length`.
    // SAFETY: `token` is a valid handle; `return_length` points to stack storage.
    unsafe {
        let _ = GetTokenInformation(token.raw(), TokenUser, None, 0, &raw mut return_length);
    }

    if return_length == 0 {
        return Err(std::io::Error::other("GetTokenInformation returned zero size"));
    }

    let mut buf = vec![0u8; return_length as usize];
    // SAFETY: `buf` is a valid, sized byte buffer; `token` is a valid handle.
    unsafe {
        GetTokenInformation(
            token.raw(),
            TokenUser,
            Some(buf.as_mut_ptr().cast::<c_void>()),
            return_length,
            &raw mut return_length,
        )
    }
    .map_err(|e| std::io::Error::other(e.to_string()))?;

    // SAFETY: `buf` holds a valid `TOKEN_USER` laid out by the kernel with the
    // correct alignment for `TOKEN_USER` (padded by `GetTokenInformation`). The
    // `Sid` pointer it contains references memory inside `buf`, which we keep
    // alive by returning the buffer to the caller.
    #[allow(clippy::cast_ptr_alignment)]
    let sid = unsafe { (*buf.as_ptr().cast::<TOKEN_USER>()).User.Sid };
    Ok((buf, sid))
}

/// Checks whether the connected client's SID matches the current user's SID.
/// Returns `false` only if we proved they are different. Any failure along the
/// way is treated as "matches" so that DACL (which is the primary defence)
/// remains the source of truth and this serves purely as a defence-in-depth check.
fn client_sid_matches_current_user(pipe: &NamedPipeServer) -> bool {
    // SAFETY: `pipe.as_raw_handle()` returns the kernel handle for the pipe server.
    if unsafe { ImpersonateNamedPipeClient(HANDLE(pipe.as_raw_handle())) }.is_err() {
        tracing::warn!("failed to impersonate named pipe client");
        return true;
    }

    // Open the client's token from the thread (NOT the process).
    let client_token = match open_thread_impersonation_token() {
        Ok(t) => t,
        Err(e) => {
            tracing::warn!(error = %e, "failed to open thread impersonation token");
            // SAFETY: we are still impersonating; revert before returning.
            unsafe {
                let _ = RevertToSelf();
            }
            return true;
        }
    };

    let client_sid_result = get_user_sid(&client_token);

    // Revert impersonation as soon as we have read the client SID (or failed to).
    // SAFETY: we called `ImpersonateNamedPipeClient` above; this undoes it.
    unsafe {
        let _ = RevertToSelf();
    }

    let (_client_buf, client_sid) = match client_sid_result {
        Ok(v) => v,
        Err(e) => {
            tracing::warn!(error = %e, "failed to read client SID");
            return true;
        }
    };

    let our_token = match open_process_token() {
        Ok(t) => t,
        Err(e) => {
            tracing::warn!(error = %e, "failed to open process token");
            return true;
        }
    };

    let (_our_buf, our_sid) = match get_user_sid(&our_token) {
        Ok(v) => v,
        Err(e) => {
            tracing::warn!(error = %e, "failed to read own SID");
            return true;
        }
    };

    // SAFETY: both SID pointers are backed by `_client_buf` and `_our_buf`, which
    // stay alive for the duration of this call.
    unsafe { EqualSid(client_sid, our_sid) }.is_ok()
}

/// Allocates and initializes the ACL granting the given SID access.
/// Fixes the prior heap overflow: the layout now matches the `acl_size` we pass
/// to `InitializeAcl`, not `sizeof::<ACL>()`.
fn build_acl(user_sid: PSID) -> std::io::Result<AclBuffer> {
    // SAFETY: `user_sid` points to a valid SID owned by the caller's buffer.
    let sid_length = unsafe { GetLengthSid(user_sid) } as usize;

    // ACL header (8) + ACE header (4) + ACE access mask (4) + SID, rounded up
    // to a DWORD boundary. This is exactly what `InitializeAcl` will expect.
    let acl_size = (8 + 4 + 4 + sid_length + 3) & !3;

    let layout = Layout::from_size_align(acl_size, mem::align_of::<ACL>())
        .map_err(|e| std::io::Error::other(format!("invalid ACL layout: {e}")))?;

    // SAFETY: `layout` has a non-zero size and an alignment that matches `ACL`'s
    // alignment requirement (enforced by `from_size_align` above).
    #[allow(clippy::cast_ptr_alignment)]
    let ptr = unsafe { alloc_zeroed(layout) }.cast::<ACL>();
    if ptr.is_null() {
        // Do NOT dealloc a null pointer — it is UB. Just return.
        return Err(std::io::Error::other("failed to allocate ACL"));
    }

    // Wrap immediately so any ? below runs the destructor.
    let buffer = AclBuffer { ptr, layout };

    // SAFETY: `ptr` is valid, aligned, zeroed memory of exactly `acl_size` bytes.
    // `acl_size` fits in u32: ACL header (8) + ACE header (4) + access mask (4)
    // + SID (max ~68 bytes) rounded up to DWORD is well under 64 KB.
    unsafe { InitializeAcl(buffer.as_ptr(), u32::try_from(acl_size).expect("ACL size fits in u32"), ACL_REVISION) }
        .map_err(|e| std::io::Error::other(e.to_string()))?;

    // SAFETY: `buffer.as_ptr()` is a freshly-initialised ACL with room for this ACE
    // (our `acl_size` accounted for the SID length), and `user_sid` is valid.
    unsafe { AddAccessAllowedAce(buffer.as_ptr(), ACL_REVISION, (GENERIC_READ | GENERIC_WRITE).0, user_sid) }
        .map_err(|e| std::io::Error::other(e.to_string()))?;

    Ok(buffer)
}

/// Allocates a `SECURITY_DESCRIPTOR` and attaches the given ACL as its DACL.
fn build_security_descriptor(
    acl: &AclBuffer,
) -> std::io::Result<Box<MaybeUninit<windows::Win32::Security::SECURITY_DESCRIPTOR>>> {
    let sd_box = Box::new(MaybeUninit::<windows::Win32::Security::SECURITY_DESCRIPTOR>::uninit());
    let sd_ptr = PSECURITY_DESCRIPTOR(sd_box.as_ptr() as *mut c_void);

    // SAFETY: `sd_ptr` points to properly aligned, writable storage for a
    // `SECURITY_DESCRIPTOR`; `InitializeSecurityDescriptor` will initialise it.
    unsafe { InitializeSecurityDescriptor(sd_ptr, 1) }.map_err(|e| std::io::Error::other(e.to_string()))?;

    // SAFETY: `sd_ptr` has just been initialised; `acl.as_ptr()` is a valid ACL.
    unsafe { SetSecurityDescriptorDacl(sd_ptr, true, Some(acl.as_ptr().cast_const()), false) }
        .map_err(|e| std::io::Error::other(e.to_string()))?;

    Ok(sd_box)
}

fn create_user_only_security_attributes() -> std::io::Result<(SECURITY_ATTRIBUTES, SecurityAttributesGuard)> {
    let token = open_process_token()?;
    let (sid_buf, user_sid) = get_user_sid(&token)?;
    let acl = build_acl(user_sid)?;
    let sd = build_security_descriptor(&acl)?;

    let sd_ptr = PSECURITY_DESCRIPTOR(sd.as_ptr() as *mut c_void);

    let sa = SECURITY_ATTRIBUTES {
        nLength: u32::try_from(mem::size_of::<SECURITY_ATTRIBUTES>())
            .expect("SECURITY_ATTRIBUTES size must fit in u32"),
        lpSecurityDescriptor: sd_ptr.0,
        bInheritHandle: windows::core::BOOL(0),
    };

    let guard = SecurityAttributesGuard { _sd: sd, _acl: acl, _sid_buf: sid_buf, _token: token };

    Ok((sa, guard))
}

// ---------------------------------------------------------------------------

pub fn bind(pipe_path: &Path) -> Result<(NamedPipeServer, RegistryGuard), Error> {
    // Refuse to downgrade security. If the DACL setup fails we fail hard rather
    // than creating a pipe with the default (broader) DACL.
    let (mut sa, _sec_guard) = create_user_only_security_attributes().map_err(Error::from)?;

    // SAFETY: `sa` and its backing buffers (owned by `_sec_guard`) are valid for
    // the duration of this call. The kernel copies the security descriptor, so
    // `_sec_guard` may be dropped after the pipe is created.
    let server = unsafe {
        ServerOptions::new()
            .first_pipe_instance(true)
            .pipe_mode(tokio::net::windows::named_pipe::PipeMode::Byte)
            .create_with_security_attributes_raw(pipe_path, (&raw mut sa).cast::<c_void>())
    }
    .map_err(Error::from)?;

    tracing::info!(version = env!("CARGO_PKG_VERSION"), path = %pipe_path.display(), "tauri-hasgard named pipe listening");

    let identifier = pipe_path
        .file_name()
        .and_then(|n| n.to_str())
        .and_then(|n| n.strip_prefix("tauri-hasgard-"))
        .unwrap_or("unknown")
        .to_string();

    register_instance(&identifier, pipe_path)?;
    let guard = RegistryGuard { identifier };

    Ok((server, guard))
}

/// Bind the named pipe, then run the accept loop.
///
/// Unlike the Unix server, the bind happens here — inside the spawned task —
/// rather than in the plugin `setup`. tokio's `NamedPipeServer` registers with
/// the reactor the moment it is created, so creating it outside a running tokio
/// runtime panics with "there is no reactor running, must be called from the
/// context of a Tokio 1.x runtime" (#115). A bind failure is logged and ends the
/// task instead of taking down the host app.
pub async fn run(
    pipe_path: PathBuf, engine: EvalEngine, eval_fn: Option<EvalFn>, list_fn: Option<ListWindowsFn>,
    focus_fn: Option<FocusFn>, recorder: Recorder,
) {
    let (first_server, guard) = match bind(&pipe_path) {
        Ok(bound) => bound,
        Err(e) => {
            tracing::error!(path = %pipe_path.display(), "failed to bind named pipe: {e}");
            return;
        }
    };
    let identifier = guard.identifier.clone();
    if let Err(e) = accept_loop(first_server, &identifier, engine, eval_fn, list_fn, focus_fn, recorder).await {
        tracing::error!("named pipe server error: {e}");
    }
}

async fn accept_loop(
    first_server: NamedPipeServer, identifier: &str, engine: EvalEngine, eval_fn: Option<EvalFn>,
    list_fn: Option<ListWindowsFn>, focus_fn: Option<FocusFn>, recorder: Recorder,
) -> Result<(), Error> {
    let ctx = Arc::new((engine, eval_fn, list_fn, focus_fn, recorder));
    let mut server = first_server;
    let pipe_path = socket_path(identifier);

    loop {
        // A failure here means the current pipe is genuinely dead (handle closed,
        // etc.) — propagate it so the supervising task can react.
        server.connect().await?;

        // Build the next pipe instance. Transient failures (e.g. `ERROR_PIPE_BUSY`
        // or handle exhaustion) must NOT take the whole server down: log and
        // retry after a short back-off. Silent downgrade to default DACL is
        // refused — we'd rather drop a connection than weaken security.
        let next_server = loop {
            // Build SA, create the pipe, and drop the guard buffers in one scope so
            // no `*mut ACL` or `*mut c_void` crosses the retry `.await` below —
            // `CreateNamedPipe` copies the security descriptor by the time it returns.
            let create_result: std::io::Result<NamedPipeServer> = (|| {
                let (mut sa, _sec_guard) = create_user_only_security_attributes()?;
                // SAFETY: `sa` and its backing buffers are valid for this call.
                unsafe {
                    ServerOptions::new()
                        .pipe_mode(tokio::net::windows::named_pipe::PipeMode::Byte)
                        .create_with_security_attributes_raw(&pipe_path, (&raw mut sa).cast::<c_void>())
                }
                .map_err(std::io::Error::other)
            })();

            match create_result {
                Ok(s) => break s,
                Err(e) => {
                    tracing::warn!(
                        path = %pipe_path.display(),
                        error = %e,
                        "transient failure creating next pipe instance, retrying"
                    );
                    tokio::time::sleep(Duration::from_millis(50)).await;
                }
            }
        };

        let current = server;
        server = next_server;

        if !client_sid_matches_current_user(&current) {
            tracing::warn!("client SID does not match current user, closing connection");
            continue;
        }

        let ctx = Arc::clone(&ctx);
        tokio::spawn(async move {
            if let Err(e) =
                handle_connection(current, &ctx.0, ctx.1.as_ref(), ctx.2.as_ref(), ctx.3.as_ref(), &ctx.4).await
            {
                tracing::warn!("connection error: {e}");
            }
        });
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::protocol::Response;
    use serial_test::serial;
    use std::sync::atomic::{AtomicU32, Ordering};
    use std::time::Duration;
    use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
    use tokio::net::windows::named_pipe::ClientOptions;

    static TEST_COUNTER: AtomicU32 = AtomicU32::new(0);

    fn unique_pipe_path() -> PathBuf {
        let n = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
        let name = format!("tauri-hasgard-test-{}-{n}", std::process::id());
        PathBuf::from(format!(r"\\.\pipe\{name}"))
    }

    async fn start_test_server(path: &Path) -> tokio::task::JoinHandle<()> {
        let engine = EvalEngine::new();
        let path = path.to_path_buf();
        let handle = tokio::spawn(async move {
            run(path, engine, None, None, None, Recorder::new()).await;
        });
        tokio::time::sleep(Duration::from_millis(50)).await;
        handle
    }

    #[tokio::test]
    #[serial]
    async fn test_server_responds_ping_ok() {
        let pipe = unique_pipe_path();
        let handle = start_test_server(&pipe).await;

        let client = ClientOptions::new().open(&pipe).expect("open test pipe");
        let (reader, mut writer) = tokio::io::split(client);
        let mut reader = BufReader::new(reader);

        writer.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}\n").await.expect("write ping request");
        writer.flush().await.expect("flush");

        let mut line = String::new();
        reader.read_line(&mut line).await.expect("read response");
        let resp: Response = serde_json::from_str(&line).expect("parse response");

        assert_eq!(resp.id, serde_json::json!(1));
        assert!(resp.error.is_none());
        let result = resp.result.expect("ping returns a result");
        assert_eq!(result["status"], serde_json::json!("ok"));
        assert_eq!(result["plugin_version"], serde_json::json!(env!("CARGO_PKG_VERSION")));

        handle.abort();
        let _ = handle.await;
    }

    #[tokio::test]
    #[serial]
    async fn test_server_handles_invalid_json() {
        let pipe = unique_pipe_path();
        let handle = start_test_server(&pipe).await;

        let client = ClientOptions::new().open(&pipe).expect("open test pipe");
        let (reader, mut writer) = tokio::io::split(client);
        let mut reader = BufReader::new(reader);

        writer.write_all(b"not json\n").await.expect("write invalid request");
        writer.flush().await.expect("flush");

        let mut line = String::new();
        reader.read_line(&mut line).await.expect("read response");
        let resp: Response = serde_json::from_str(&line).expect("parse response");

        assert_eq!(resp.id, serde_json::Value::Null);
        let err = resp.error.expect("error payload present");
        assert_eq!(err.code, -32700);

        handle.abort();
        let _ = handle.await;
    }

    #[tokio::test]
    #[serial]
    async fn test_server_handles_multiple_requests() {
        let pipe = unique_pipe_path();
        let handle = start_test_server(&pipe).await;

        let client = ClientOptions::new().open(&pipe).expect("open test pipe");
        let (reader, mut writer) = tokio::io::split(client);
        let mut reader = BufReader::new(reader);

        for i in 1..=3 {
            let req = format!("{{\"jsonrpc\":\"2.0\",\"id\":{i},\"method\":\"test\"}}\n");
            writer.write_all(req.as_bytes()).await.expect("write request");
            writer.flush().await.expect("flush");

            let mut line = String::new();
            reader.read_line(&mut line).await.expect("read response");
            let resp: Response = serde_json::from_str(&line).expect("parse response");
            assert_eq!(resp.id, serde_json::json!(i));
        }

        handle.abort();
        let _ = handle.await;
    }

    #[tokio::test]
    #[serial]
    #[cfg(windows)]
    async fn test_run_returns_when_bind_fails_instead_of_panicking() {
        // #115: the named pipe is now bound inside `run` (on the tokio runtime),
        // not in the plugin `setup`. A bind failure must end the task gracefully
        // — never panic or hang. Hold the first pipe instance, then race a second
        // `run` on the same path: `first_pipe_instance(true)` rejects the
        // duplicate, so the second task must log the error and return on its own.
        let pipe = unique_pipe_path();
        let holder = start_test_server(&pipe).await; // owns the first instance

        let dup_path = pipe.clone();
        let dup = tokio::spawn(async move {
            run(dup_path, EvalEngine::new(), None, None, None, Recorder::new()).await;
        });

        let joined = tokio::time::timeout(Duration::from_secs(5), dup)
            .await
            .expect("run must return after a failed bind (#115), not hang");
        assert!(joined.is_ok(), "run must not panic when binding fails (#115)");

        holder.abort();
        let _ = holder.await;
    }

    #[tokio::test]
    #[serial]
    #[cfg(windows)]
    async fn test_bound_pipe_carries_user_only_dacl() {
        use windows::Win32::Foundation::LocalFree;
        use windows::Win32::Security::Authorization::{GetSecurityInfo, SE_KERNEL_OBJECT};
        use windows::Win32::Security::{
            ACL_SIZE_INFORMATION, AclSizeInformation, DACL_SECURITY_INFORMATION, GetAclInformation,
        };

        let pipe = unique_pipe_path();
        let (server, guard) = bind(&pipe).expect("bind test pipe");

        let raw_handle = server.as_raw_handle();
        let handle = HANDLE(raw_handle);

        // Retrieve the DACL from the freshly-bound pipe and assert:
        //   - the DACL pointer is non-NULL (the pipe is not running with a NULL DACL),
        //   - the DACL contains exactly one ACE (our owner-only ACE).
        let mut dacl_ptr: *mut ACL = std::ptr::null_mut();
        let mut sd_ptr = PSECURITY_DESCRIPTOR::default();
        unsafe {
            GetSecurityInfo(
                handle,
                SE_KERNEL_OBJECT,
                DACL_SECURITY_INFORMATION,
                None,
                None,
                Some(&raw mut dacl_ptr),
                None,
                Some(&raw mut sd_ptr),
            )
        }
        .ok()
        .expect("GetSecurityInfo must succeed on a bound pipe");
        assert!(!dacl_ptr.is_null(), "bound pipe must carry a non-NULL DACL");

        let mut info = ACL_SIZE_INFORMATION::default();
        let info_size =
            u32::try_from(std::mem::size_of::<ACL_SIZE_INFORMATION>()).expect("ACL_SIZE_INFORMATION fits in u32");
        unsafe { GetAclInformation(dacl_ptr, (&raw mut info).cast::<c_void>(), info_size, AclSizeInformation) }
            .expect("GetAclInformation must succeed");
        assert_eq!(info.AceCount, 1, "bound pipe DACL must contain exactly one ACE (owner-only)");

        // SAFETY: `sd_ptr` was allocated by `GetSecurityInfo`; documented contract
        // requires the caller to release it with `LocalFree`. `dacl_ptr` points
        // into the same allocation and must not be freed separately.
        unsafe {
            let _ = LocalFree(Some(windows::Win32::Foundation::HLOCAL(sd_ptr.0)));
        }

        drop(server);
        drop(guard);
    }
}