pmpx-plugin 0.0.0

Plugin contract for pmpx: the PackageManager trait and the stable C ABI that carries it across dlopen.
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
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
//! 跨 `dlopen` 边界的**线格式**。
//!
//! # 这个文件是硬边界
//!
//! 宿主与插件是两个独立编译的世界。凡是穿过这条线的数据,其**布局必须由 ABI 决定**,
//! 而不能由编译器、依赖版本或 rustc 版本决定。
//!
//! 所以这里**只有 `#[repr(C)]` 的 POD 结构与普通整数**,没有:
//!
//! | 禁止出现在边界上的东西 | 为什么 |
//! | ---------------------- | ------ |
//! | `String` / `Vec` / `Box` | 它们的内存归谁、由哪个分配器释放,取决于两边是否同源 |
//! | `toml::Value`、`BTreeMap` | 布局随依赖的小版本变化 |
//! | `anyhow::Error` | 同上,而且是类型擦除的 |
//! | trait object | fat pointer 的 vtable 归属在两边之间没有保证 |
//! | `Future` / `async` | 同上,且需要一个两边都认的 runtime |
//!
//! 换来的东西很大:**宿主与插件不需要同一个 rustc**,也不需要共享分配器。
//! 唯一需要对齐的就是本文件里的结构形状,而它由 [`ABI_VERSION`] 守着。
//!
//! # 内存所有权的两个方向
//!
//! 这是最容易搞混、也最容易出 UB 的地方:
//!
//! | 数据 | 谁分配 | 谁释放 | 用哪个 API |
//! | ---- | ------ | ------ | ---------- |
//! | `project_root` / `matched` / `args`(**输入**) | 宿主 | 宿主 | 插件**只读**,绝不 free |
//! | `out`([`PmpxCommand`] 及其内部字符串,**输出**) | 插件 | 插件 | 宿主只读,[`free_command`] |
//! | [`PmpxPluginV1::name`] / [`PmpxPluginV1::family`] 的返回值 | 插件 | 插件 | 宿主只读,[`free_str`] |
//!
//! **没有任何一处内存是由"分配方之外"释放的** —— 这就是为什么不需要共享分配器。
//!
//! 相应地,`free_*` 函数里**绝不能**用宿主的 `Box::from_raw` 去接插件给的内存,反之亦然。

use std::ffi::{OsStr, OsString};
use std::path::PathBuf;

use crate::{CommandSpec, Context, PackageManager, Verb};

// ---------------------------------------------------------------------------
// 版本
// ---------------------------------------------------------------------------

/// 跨边界布局的版本。
///
/// **独立整数,与 crate 版本号彻底解耦**:改 README、加日志、修 bug 都不动它;
/// 只有 [`PmpxPluginV1`] / [`PmpxCommand`] / [`PmpxStr`] 的形状、动词编号或错误码语义
/// 真的变了才 `+1`。
///
/// 宿主用它做**唯一**的硬校验:不相等就拒绝加载。这一条直接消灭了
/// 「改一句 README 就 bump 0.1.1,导致所有 prebuilt 拒绝加载」那类问题。
pub const ABI_VERSION: u32 = 1;

// ---------------------------------------------------------------------------
// 错误码
// ---------------------------------------------------------------------------

/// 成功。
pub const PMPX_OK: u32 = 0;

/// 这个后端不支持该动词。
///
/// 宿主对它有**特殊处理**:`pmpx exec` 收到这个码会退化成裸透传(见 pmpx 设计文档 6.3),
/// 其余动词则原样报错。所以这个码必须与 [`PMPX_ERR_INTERNAL`] 分开。
pub const PMPX_ERR_UNSUPPORTED_VERB: u32 = 1;

/// 输入不合法 —— 动词编号不认识、`out` 是空指针、`matched` 里有非 UTF-8。
pub const PMPX_ERR_INVALID_ARGS: u32 = 2;

/// 插件内部出错,或者它 panic 了。
///
/// panic 被 [`guard`] 捕获后就归到这里 —— 宿主只需要知道"它炸了",不需要知道细节;
/// 细节在插件的 stderr 上。
pub const PMPX_ERR_INTERNAL: u32 = 3;

// ---------------------------------------------------------------------------
// 动词编号
// ---------------------------------------------------------------------------

/// [`Verb::Install`] 的编号。
pub const VERB_INSTALL: u32 = 0;
/// [`Verb::Remove`] 的编号。
pub const VERB_REMOVE: u32 = 1;
/// [`Verb::Run`] 的编号。
pub const VERB_RUN: u32 = 2;
/// [`Verb::Build`] 的编号。
pub const VERB_BUILD: u32 = 3;
/// [`Verb::Test`] 的编号。
pub const VERB_TEST: u32 = 4;
/// [`Verb::Update`] 的编号。
pub const VERB_UPDATE: u32 = 5;
/// [`Verb::Exec`] 的编号。
pub const VERB_EXEC: u32 = 6;

// ---------------------------------------------------------------------------
// 数据结构
// ---------------------------------------------------------------------------

/// 跨边界字符串:**指针 + 长度**,不要求 NUL 结尾。
///
/// # 这是字节,不是 `String`
///
/// `ptr`/`len` 描述的是一段**裸字节**。它可能是路径、可能是命令行参数,
/// 所以在 Unix 上完全可以不是合法 UTF-8 —— 转换走 `std::os::unix::ffi::OsStrExt`,
/// 无损。
///
/// 只有 [`PmpxStr`] 明确要求是文本时(例如 `matched` 里的文件名)才会做 UTF-8 校验,
/// 校验失败返回 [`PMPX_ERR_INVALID_ARGS`] 而不是 UB。
///
/// # 为什么带长度而不是靠 NUL
///
/// `str::as_ptr()` 得到的指针**不保证**后面跟着 NUL。指望它等于把 UB 写进设计。
/// 带长度是唯一诚实的做法,顺带还省掉一次 `strlen`。
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct PmpxStr {
    /// 起始地址。`len == 0` 时可以是空指针。
    pub ptr: *const u8,
    /// 字节长度。
    pub len: usize,
}

// SAFETY: `PmpxStr` 是"一段只读字节 + 长度",跨线程共享它 unsafe 的地方只在于
// `ptr` 指向的内存必须仍然有效。而这个结构体的两份来源都是明确的:
//   - 宿主传进来的:在整个调用期间有效;
//   - 插件产出的:指向插件泄漏出来的 `Box<[u8]>`,在 `free_str` 之前一直有效。
// 两者都不会在共享期间被释放或写入。所以把它标成 `Sync` 是成立的。
unsafe impl Sync for PmpxStr {}

impl PmpxStr {
    /// 空。`len == 0` 且指针为空 —— 在 `cwd` 里表示"没有覆盖"。
    pub const EMPTY: PmpxStr = PmpxStr {
        ptr: std::ptr::null(),
        len: 0,
    };

    /// 从一段 `'static` 文本构造。
    ///
    /// `const` 是关键:`export!` 生成的 `static` vtable 要在编译期填好这两个字段。
    pub const fn from_static(s: &'static str) -> Self {
        Self {
            ptr: s.as_ptr(),
            len: s.len(),
        }
    }

    /// 是不是空的。
    pub const fn is_empty(&self) -> bool {
        self.len == 0
    }
}

/// 跨边界的命令描述。
///
/// 由插件填充、由插件释放([`free_command`]);宿主只读。
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct PmpxCommand {
    /// 可执行文件。
    pub program: PmpxStr,
    /// 参数数组,元素个数为 `args_len`。
    pub args: *const PmpxStr,
    /// `args` 的元素个数。
    pub args_len: usize,
    /// 工作目录覆盖。`len == 0` 表示用宿主给的项目根。
    pub cwd: PmpxStr,
}

// SAFETY: 同 `PmpxStr` —— 这个结构体只是"若干只读字节的引用 + 一个数组长度"。
unsafe impl Sync for PmpxCommand {}

/// 插件导出的**唯一**结构。
///
/// 宿主取到它之后,所有交互都通过这里的函数指针进行 —— 没有 trait object,
/// 没有 vtable 互转,也就没有那类 UB。
///
/// # 为什么 `command` 的参数这么多
///
/// 每一个都是必要的:项目根、命中文件列表、动词、参数、输出。把它们打包成一个
/// 结构体再传指针当然也行,但那样就要再定义、再版本化一个结构体;参数列表是 ABI 里
/// 描述得最清楚的形式。
#[repr(C)]
pub struct PmpxPluginV1 {
    /// 必须等于 [`ABI_VERSION`]。宿主第一件事就是比对这个字段。
    pub abi_version: u32,

    /// 编出这个插件的 rustc 版本,由 `pmpx-plugin` 的 build.rs 注入。
    ///
    /// **只用于诊断展示,不做硬校验。** 在这套 C ABI 下,不同 rustc 编出来的插件是
    /// 可以安全加载的(见模块文档),硬校验只会误杀那些本来能用的组合。
    /// 想知道值就 `pmpx plugin info` 看一眼。
    pub rustc_version: PmpxStr,

    /// 编出这个插件的 target triple。同样只做诊断。
    pub target: PmpxStr,

    /// 插件名。内存归插件,宿主读完用 [`free_str`] 释放。
    ///
    /// 宿主应当拿它与 manifest 里声明的名字比对 —— 不一致说明装错了东西。
    pub name: unsafe extern "C" fn() -> PmpxStr,

    /// 生态分组。内存归插件,宿主读完用 [`free_str`] 释放。
    pub family: unsafe extern "C" fn() -> PmpxStr,

    /// 把「动词 + 参数」翻译成一条命令。
    ///
    /// 返回 [`PMPX_OK`] 时 `out` 被填充,宿主用完调 [`free_command`];
    /// 否则返回 `PMPX_ERR_*`,`out` 不动。
    ///
    /// # Safety
    ///
    /// - `project_root` / `matched` / `args` 必须由宿主分配、在调用期间有效且只读;
    /// - `out` 必须指向一块可写的 [`PmpxCommand`];
    /// - **panic 不许穿过这个边界** —— 由 `export!` 宏统一包 `catch_unwind`。
    ///   从 Rust 1.81 起,让 panic 越过 `extern "C"` 会直接 abort,宿主侧的
    ///   `catch_unwind` 救不了。
    pub command: unsafe extern "C" fn(
        project_root: PmpxStr,
        matched: *const PmpxStr,
        matched_len: usize,
        verb: u32,
        args: *const PmpxStr,
        args_len: usize,
        out: *mut PmpxCommand,
    ) -> u32,

    /// 释放 [`PmpxPluginV1::name`] / [`PmpxPluginV1::family`] 返回值占的内存。
    ///
    /// # Safety
    ///
    /// `s` 必须来自**同一个插件**的产出,且只能释放一次。
    pub free_str: unsafe extern "C" fn(PmpxStr),

    /// 释放 [`PmpxPluginV1::command`] 填充的 [`PmpxCommand`] 占的内存。
    ///
    /// 只释放结构体**指向**的内存,不释放结构体本身 —— 那个是宿主的。
    ///
    /// # Safety
    ///
    /// `c` 必须来自**同一个插件**的一次成功 `command` 调用,且只能释放一次。
    pub free_command: unsafe extern "C" fn(*mut PmpxCommand),
}

// SAFETY: 这个结构体是编译期常量填出来的只读表:几个整数、两段 `'static` 字节、
// 五个函数指针。填好之后从不修改。函数指针本身是 `Sync` 的。
unsafe impl Sync for PmpxPluginV1 {}

/// 唯一入口符号的名字。
///
/// ⚠️ **这个符号由 `pmpx_plugin::export!` 在插件里定义,不在这里。**
/// `pmpx-plugin` 是个库,会被链接进每一个插件;如果它自己定义了同名 `#[no_mangle]`
/// 符号,就会和 `export!` 生成的那个撞车(`#[no_mangle]` 不允许重复)。
///
/// 所以这个 crate 只提供名字与形状的约定,定义权在插件侧:
///
/// ```text
/// #[no_mangle]
/// pub extern "C" fn pmpx_plugin_entry_v1() -> *const PmpxPluginV1;
/// ```
///
/// 名字里的 `v1` 与 [`ABI_VERSION`] 对应:将来若真的要引入形状完全不同的 v2,
/// 就再加一个 `pmpx_plugin_entry_v2`,让 v1 插件继续能装。
pub const ENTRY_SYMBOL: &str = "pmpx_plugin_entry_v1";

// ---------------------------------------------------------------------------
// 编出这个 crate 的那个人是谁(诊断用)
// ---------------------------------------------------------------------------

/// 编译期注入的 rustc 版本。
///
/// `const fn` 是刻意的:`export!` 生成的 `static` vtable 要在编译期求值。
pub const fn build_rustc() -> PmpxStr {
    PmpxStr::from_static(env!("PMPX_BUILD_RUSTC"))
}

/// 编译期注入的 target triple。
pub const fn build_target() -> PmpxStr {
    PmpxStr::from_static(env!("PMPX_BUILD_TARGET"))
}

// ---------------------------------------------------------------------------
// 内存:分配与释放
// ---------------------------------------------------------------------------

/// 把一段字节泄漏成 [`PmpxStr`],交给边界对面去读。
///
/// # 统一的分配类型是 `Box<[u8]>`
///
/// 所有从插件流出的字符串 —— `name()`、`family()`、`program`、每个 `arg`、`cwd` ——
/// **都用同一种分配**,这样 [`free_str`] 只有一条路径,不会出现"按 `Box<str>` 释放
/// `Box<[u8]>`"这种 UB。
pub fn leak_bytes(bytes: &[u8]) -> PmpxStr {
    let boxed: Box<[u8]> = bytes.to_vec().into_boxed_slice();
    let out = PmpxStr {
        ptr: boxed.as_ptr(),
        len: boxed.len(),
    };
    // 所有权交给边界对面,由 free_str 取回。
    std::mem::forget(boxed);
    out
}

/// [`leak_bytes`] 的 `&str` 版本。
pub fn leak_str(s: &str) -> PmpxStr {
    leak_bytes(s.as_bytes())
}

/// 释放一个由本侧 [`leak_bytes`] / [`leak_str`] 产出的 [`PmpxStr`]。
///
/// # Safety
///
/// - `s` 必须来自**本侧**的 `leak_*`,不能是宿主传来的输入;
/// - 只能释放一次。
///
/// 空指针被当成"没有东西"直接返回([`PmpxStr::EMPTY`] 就是这么用的);
/// 长度为 0 但指针非空的情况是合法分配,会正常走 `Box::from_raw`。
pub unsafe fn free_str(s: PmpxStr) {
    if s.ptr.is_null() {
        return;
    }
    let raw = std::ptr::slice_from_raw_parts_mut(s.ptr as *mut u8, s.len);
    // 与 leak_bytes 里的 Box<[u8]> 严格配对。
    drop(unsafe { Box::from_raw(raw) });
}

/// 释放一个由本侧 [`write_command`] 填充的 [`PmpxCommand`] 的内容。
///
/// **不释放 `c` 本身** —— 那个结构体在宿主那边(通常是栈上)。
///
/// # Safety
///
/// `c` 必须来自本侧一次成功的 `command` 调用,且只能释放一次。
pub unsafe fn free_command(c: *mut PmpxCommand) {
    if c.is_null() {
        return;
    }
    // 只读地看它一眼,然后把里面的东西逐个还回去。
    let cmd = unsafe { &*c };

    unsafe { free_str(cmd.program) };
    unsafe { free_str(cmd.cwd) };

    if !cmd.args.is_null() && cmd.args_len > 0 {
        // 与 write_command 里的 Box<[PmpxStr]> 严格配对。
        let raw = std::ptr::slice_from_raw_parts_mut(cmd.args as *mut PmpxStr, cmd.args_len);
        let args = unsafe { Box::from_raw(raw) };
        for s in args.iter() {
            unsafe { free_str(*s) };
        }
        // args 在这里 drop,数组本身的内存随之归还。
    }
}

// ---------------------------------------------------------------------------
// 输入方向:字节 ↔ OsString
// ---------------------------------------------------------------------------

/// 把宿主传来的字节读成 `OsString`。
///
/// # Safety
///
/// `s` 必须描述一段在本次调用期间有效的只读内存,或 `len == 0`。
///
/// # 为什么是 `OsString` 而不是 `String`
///
/// Unix 上路径与命令行参数**可以不是合法 UTF-8**。用 `String` 就只能有损转换,
/// 而 `pmpx exec some-tool /latin1/path` 这种调用会被悄悄改坏参数。
/// `OsString` 在 Unix 上是无损的裸字节。
///
/// Windows 上 `OsString` 底层是 WTF-8,非 UTF-8(未配对代理项)会退化成有损替换 ——
/// 那是 Windows 平台的边界,不是这里的取舍。
pub unsafe fn read_os(s: PmpxStr) -> OsString {
    if s.len == 0 {
        return OsString::new();
    }
    let bytes = unsafe { std::slice::from_raw_parts(s.ptr, s.len) };
    bytes_to_os(bytes)
}

/// 把宿主传来的字节读成 `&str`,**校验 UTF-8**。
///
/// 校验失败返回 [`PMPX_ERR_INVALID_ARGS`],绝不 `from_utf8_unchecked` ——
/// 那等于假定宿主永远正确,而 ABI 的职责恰恰是不做这种假定。
///
/// # Safety
///
/// 同 [`read_os`]。
pub unsafe fn read_str<'a>(s: PmpxStr) -> Result<&'a str, u32> {
    if s.len == 0 {
        return Ok("");
    }
    let bytes = unsafe { std::slice::from_raw_parts(s.ptr, s.len) };
    std::str::from_utf8(bytes).map_err(|_| PMPX_ERR_INVALID_ARGS)
}

/// 把裸字节转成 `OsString`。
///
/// **宿主侧也需要这个**(它得把 `project_root` 与 `args` 变成字节送过边界),
/// 所以它是公开的 —— 让两边各写一份平台 cfg 是必然漂移的那种重复。
///
/// - Unix:无损(`OsString` 底层就是裸字节);
/// - 其它平台:`OsString` 底层是 WTF-8,非 UTF-8 会退化成 U+FFFD。
#[cfg(unix)]
pub fn bytes_to_os(bytes: &[u8]) -> OsString {
    use std::os::unix::ffi::OsStringExt;
    OsString::from_vec(bytes.to_vec())
}

/// 见 [`bytes_to_os`] 的平台说明。
#[cfg(not(unix))]
pub fn bytes_to_os(bytes: &[u8]) -> OsString {
    String::from_utf8_lossy(bytes).into_owned().into()
}

/// 把 `OsStr` 转成裸字节。与 [`bytes_to_os`] 严格配对。
///
/// - Unix:无损;
/// - 其它平台:经 `to_string_lossy`,非 UTF-8 会退化成 U+FFFD。
#[cfg(unix)]
pub fn os_to_bytes(s: &OsStr) -> Vec<u8> {
    use std::os::unix::ffi::OsStrExt;
    s.as_bytes().to_vec()
}

/// 见 [`os_to_bytes`] 的平台说明。
#[cfg(not(unix))]
pub fn os_to_bytes(s: &OsStr) -> Vec<u8> {
    s.to_string_lossy().into_owned().into_bytes()
}

// ---------------------------------------------------------------------------
// 输出方向
// ---------------------------------------------------------------------------

/// 把一个 [`CommandSpec`] 写成跨边界的形式,**内存由本侧分配**。
///
/// # Safety
///
/// `out` 必须指向一块可写的 [`PmpxCommand`]。
pub unsafe fn write_command(out: *mut PmpxCommand, spec: CommandSpec) {
    let program = leak_bytes(&os_to_bytes(&spec.program));

    let args: Vec<PmpxStr> = spec
        .args
        .iter()
        .map(|a| leak_bytes(&os_to_bytes(a)))
        .collect();
    let args_boxed: Box<[PmpxStr]> = args.into_boxed_slice();
    let args_len = args_boxed.len();
    let args_ptr = args_boxed.as_ptr();
    std::mem::forget(args_boxed);

    let cwd = match &spec.cwd {
        Some(p) => leak_bytes(&os_to_bytes(p.as_os_str())),
        None => PmpxStr::EMPTY,
    };

    unsafe {
        *out = PmpxCommand {
            program,
            args: args_ptr,
            args_len,
            cwd,
        };
    }
}

// ---------------------------------------------------------------------------
// 调度
// ---------------------------------------------------------------------------

/// 一次 `command` 调用的全部接线:读输入 → 调 [`crate::PackageManager::command`] → 写输出。
///
/// **这段逻辑住在这个 crate 里,而不是在 `export!` 宏里**,是为了它能被直接测试 ——
/// 宏里生成的代码只能通过真的加载一个 cdylib 来覆盖。
///
/// # Safety
///
/// 见 [`PmpxPluginV1::command`] 的 Safety 段。此外 `plugin` 必须是本进程里有效的实例。
// 参数就是 ABI 签名的那八个,一个不多一个不少 —— 这里的"参数过多"正是被镜像的东西本身。
// 把它们打包成结构体反而要多定义、多版本化一个类型,得不偿失。
#[allow(clippy::too_many_arguments)]
pub unsafe fn dispatch_command(
    plugin: &dyn PackageManager,
    project_root: PmpxStr,
    matched: *const PmpxStr,
    matched_len: usize,
    verb: u32,
    args: *const PmpxStr,
    args_len: usize,
    out: *mut PmpxCommand,
) -> u32 {
    if out.is_null() {
        return PMPX_ERR_INVALID_ARGS;
    }

    let Some(verb) = Verb::from_abi(verb) else {
        return PMPX_ERR_INVALID_ARGS;
    };

    let project_root = PathBuf::from(unsafe { read_os(project_root) });

    // `matched` 是**文本**(manifest 里声明的文件名),所以这里要校验 UTF-8。
    let mut matched_names = Vec::with_capacity(matched_len);
    for i in 0..matched_len {
        let raw = unsafe { *matched.add(i) };
        match unsafe { read_str(raw) } {
            Ok(s) => matched_names.push(s.to_string()),
            Err(code) => return code,
        }
    }

    // `args` 是**参数**,可以是任意字节 —— 原样转成 OsString,无损。
    let mut arg_list = Vec::with_capacity(args_len);
    for i in 0..args_len {
        let raw = unsafe { *args.add(i) };
        arg_list.push(unsafe { read_os(raw) });
    }

    let ctx = Context {
        project_root,
        matched: matched_names,
    };

    match plugin.command(&ctx, verb, &arg_list) {
        Ok(spec) => {
            unsafe { write_command(out, spec) };
            PMPX_OK
        }
        Err(e) => e.code(),
    }
}

/// 把一次跨边界调用包进 `catch_unwind`。
///
/// # 为什么这一层不能省
///
/// 插件是 `dlopen` 进来的代码。它 panic 一次,宿主整个进程就没了。而从 Rust 1.81 起,
/// **让 panic 越过 `extern "C"` 边界会直接 abort** —— 也就是说宿主那边的 `catch_unwind`
/// 完全救不了。所以**插件必须在自己的 `extern "C` 函数里**把它兜住,这正是这里做的事。
///
/// 宿主侧仍然会再包一层(那份代码在 pmpx 里),但那是为了兜住"插件忘了包"
/// 或"插件用 `panic=abort` 编的"这类情况,不是第一道防线。
pub fn guard(f: impl FnOnce() -> u32) -> u32 {
    // `AssertUnwindSafe`:我们只承诺"panic 不要掀翻进程",
    // 拿到 PMPX_ERR_INTERNAL 之后调用方就会中止这次操作,不会继续碰捕获现场。
    std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).unwrap_or(PMPX_ERR_INTERNAL)
}

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

    #[test]
    fn verb_numbers_match_the_public_enum() {
        // 这组编号一旦错位,宿主与插件对"install"的理解就会分叉 ——
        // 而且不会有任何编译错误。所以用测试钉住。
        assert_eq!(Verb::Install.to_abi(), VERB_INSTALL);
        assert_eq!(Verb::Remove.to_abi(), VERB_REMOVE);
        assert_eq!(Verb::Run.to_abi(), VERB_RUN);
        assert_eq!(Verb::Build.to_abi(), VERB_BUILD);
        assert_eq!(Verb::Test.to_abi(), VERB_TEST);
        assert_eq!(Verb::Update.to_abi(), VERB_UPDATE);
        assert_eq!(Verb::Exec.to_abi(), VERB_EXEC);
    }

    #[test]
    fn verb_round_trips() {
        for v in Verb::ALL {
            assert_eq!(Verb::from_abi(v.to_abi()), Some(*v));
        }
        assert_eq!(Verb::from_abi(99), None);
    }

    #[test]
    fn empty_str_reads_as_empty() {
        assert_eq!(unsafe { read_os(PmpxStr::EMPTY) }, OsString::new());
        assert_eq!(unsafe { read_str(PmpxStr::EMPTY) }.unwrap(), "");
    }

    #[test]
    fn leak_and_free_round_trip() {
        let s = leak_str("hello");
        assert_eq!(s.len, 5);
        assert_eq!(
            unsafe { std::slice::from_raw_parts(s.ptr, s.len) },
            b"hello"
        );
        unsafe { free_str(s) };
    }

    #[test]
    fn free_str_tolerates_null() {
        // EMPTY 会被 free_command 无条件传给 free_str
        unsafe { free_str(PmpxStr::EMPTY) };
    }

    #[test]
    fn leak_and_free_an_empty_string() {
        // 空串的 Box<[u8]> 是一个悬垂指针(非空、len 0),必须能正常释放
        let s = leak_str("");
        assert_eq!(s.len, 0);
        assert!(!s.ptr.is_null(), "空 Box 的指针是悬垂但非空的");
        unsafe { free_str(s) };
    }

    #[test]
    fn read_str_rejects_invalid_utf8() {
        let bytes = [0xff, 0xfe];
        let s = PmpxStr {
            ptr: bytes.as_ptr(),
            len: bytes.len(),
        };
        assert_eq!(unsafe { read_str(s) }, Err(PMPX_ERR_INVALID_ARGS));
    }

    /// 正常 UTF-8 在两个平台上都必须无损往返。
    #[test]
    fn read_os_round_trips_valid_utf8() {
        let bytes = "/tmp/项目/ünïcode".as_bytes();
        let s = PmpxStr {
            ptr: bytes.as_ptr(),
            len: bytes.len(),
        };
        let got = unsafe { read_os(s) };
        assert_eq!(os_to_bytes(&got), bytes);
    }

    /// Unix 上路径与参数可以是**任意字节**,`OsString` 必须无损保住它们。
    ///
    /// 这正是 `args` 用 `&[OsString]` 而不是 `&[String]` 的原因:后者只能有损转换,
    /// `pmpx exec some-tool /latin1/path` 会被悄悄改坏参数。
    #[cfg(unix)]
    #[test]
    fn read_os_keeps_arbitrary_bytes_on_unix() {
        // 0xFF 不是合法 UTF-8 起始字节,但它是个合法的路径字节
        let bytes = [0x2f, 0x62, 0x61, 0x64, 0xff];
        let s = PmpxStr {
            ptr: bytes.as_ptr(),
            len: bytes.len(),
        };
        let got = unsafe { read_os(s) };
        assert_eq!(os_to_bytes(&got), bytes, "Unix 上必须无损");
    }

    /// Windows 上 `OsString` 底层是 WTF-8,非 UTF-8 会退化成 U+FFFD。
    ///
    /// 这是**平台边界**,不是这里的取舍:Win32 的路径本来就是 UTF-16,
    /// 拿不到合法 UTF-8 的情况在实践中基本不存在。测试把它钉成"已知行为"而不是假装无事。
    #[cfg(not(unix))]
    #[test]
    fn read_os_replaces_invalid_utf8_off_unix() {
        let bytes = [0x2f, 0x62, 0xff];
        let s = PmpxStr {
            ptr: bytes.as_ptr(),
            len: bytes.len(),
        };
        let got = unsafe { read_os(s) };
        let expected = String::from_utf8_lossy(&bytes).into_owned().into_bytes();
        assert_eq!(os_to_bytes(&got), expected);
        assert_ne!(os_to_bytes(&got), bytes, "非 Unix 上确实是有损的");
    }

    #[test]
    fn writes_and_frees_a_command() {
        let spec = CommandSpec::new("cargo")
            .arg("add")
            .arg("serde")
            .cwd("/tmp/project");

        let mut out = std::mem::MaybeUninit::<PmpxCommand>::uninit();
        unsafe { write_command(out.as_mut_ptr(), spec) };
        let mut cmd = unsafe { out.assume_init() };

        assert_eq!(cmd.args_len, 2);
        let program = unsafe { std::slice::from_raw_parts(cmd.program.ptr, cmd.program.len) };
        assert_eq!(program, b"cargo");

        let arg0 = unsafe { *cmd.args.add(0) };
        let a0 = unsafe { std::slice::from_raw_parts(arg0.ptr, arg0.len) };
        assert_eq!(a0, b"add");

        let cwd = unsafe { std::slice::from_raw_parts(cmd.cwd.ptr, cmd.cwd.len) };
        assert_eq!(cwd, b"/tmp/project");

        unsafe { free_command(&mut cmd as *mut _) };
    }

    #[test]
    fn writes_a_command_with_no_args_and_no_cwd() {
        let spec = CommandSpec::new("cargo");

        let mut out = std::mem::MaybeUninit::<PmpxCommand>::uninit();
        unsafe { write_command(out.as_mut_ptr(), spec) };
        let mut cmd = unsafe { out.assume_init() };

        assert_eq!(cmd.args_len, 0);
        assert!(cmd.cwd.is_empty(), "没有 cwd 时应当是 EMPTY");

        unsafe { free_command(&mut cmd as *mut _) };
    }

    #[test]
    fn free_command_tolerates_null() {
        unsafe { free_command(std::ptr::null_mut()) };
    }

    #[test]
    fn guard_turns_a_panic_into_internal_error() {
        assert_eq!(guard(|| PMPX_OK), PMPX_OK);
        assert_eq!(guard(|| panic!("插件炸了")), PMPX_ERR_INTERNAL);
    }

    #[test]
    fn build_info_is_populated() {
        // 这两个值由 build.rs 注入,真实构建里不该是 "unknown"
        let rustc = build_rustc();
        let target = build_target();
        assert!(rustc.len > 0);
        assert!(target.len > 0);

        let rustc = unsafe { std::slice::from_raw_parts(rustc.ptr, rustc.len) };
        let target = unsafe { std::slice::from_raw_parts(target.ptr, target.len) };
        assert!(
            std::str::from_utf8(rustc).unwrap().contains("rustc"),
            "rustc_version 应当是 `rustc 1.x.y (...)` 这种形式"
        );
        assert!(std::str::from_utf8(target).unwrap().contains('-'));
    }
}