floss_cli/
lib.rs

1//! 在 Rust 中以子进程方式调用 `floss`(FLARE Obfuscated String Solver)CLI。
2//!
3//! 你可以把它当作一个“参数拼装 + 进程执行 + 输出抓取(可选 JSON 解析)”的薄封装:
4//! - 支持透传任意 FLOSS CLI 参数(因此能覆盖 `floss -h/-H` 暴露的全部功能)
5//! - 可选解析 `-j/--json` 的输出为任意 `serde` 可反序列化类型
6//! - 内置 `ResultDocument`:直接反序列化 FLOSS 的 JSON 结果
7//!
8//! # 示例
9//! ```no_run
10//! use floss_cli::{FlossCli, Result, ResultDocument};
11//!
12//! #[tokio::main]
13//! async fn main() -> Result<()> {
14//!     let cli = FlossCli::detect().await?;
15//!     let doc: ResultDocument = cli.command().sample("malware.exe").run_results().await?;
16//!     println!("decoded strings: {}", doc.strings.decoded_strings.len());
17//!     Ok(())
18//! }
19//! ```
20
21extern crate alloc;
22
23pub mod results;
24
25pub use crate::results::ResultDocument;
26
27use alloc::borrow::Cow;
28use alloc::string::{FromUtf8Error, String};
29use core::result::Result as CoreResult;
30use std::env;
31use std::ffi::{OsStr, OsString};
32use std::fmt;
33use std::io::Error as IoError;
34use std::path::{Path, PathBuf};
35use std::process::{ExitStatus, Stdio};
36use std::time::Duration;
37
38use serde::de::DeserializeOwned;
39use serde_json::Error as SerdeJsonError;
40use tokio::io::AsyncReadExt;
41use tokio::process::{Child, Command};
42use tokio::task::JoinHandle;
43use tokio::time;
44
45/// 本库统一的 `Result` 类型别名。
46pub type Result<T> = CoreResult<T, FlossError>;
47
48/// 调用 FLOSS 过程中可能出现的错误。
49#[derive(thiserror::Error, Debug)]
50#[non_exhaustive]
51pub enum FlossError {
52    #[error("自动探测失败: {message}")]
53    AutoDetectFailed { message: String },
54
55    #[error("启动进程失败: {0}")]
56    Io(#[from] IoError),
57
58    #[error("解析 JSON 失败: {0}")]
59    Json(#[from] SerdeJsonError),
60
61    #[error("floss 退出码非 0: {status} ({command})")]
62    NonZeroExit {
63        command: Box<CommandLine>,
64        status: ExitStatus,
65        stderr: Vec<u8>,
66        stdout: Vec<u8>,
67    },
68
69    #[error("floss 执行超时: timeout={timeout:?} ({command})")]
70    TimedOut {
71        command: Box<CommandLine>,
72        stderr: Vec<u8>,
73        stdout: Vec<u8>,
74        timeout: Duration,
75    },
76
77    #[error("floss 执行超时且无法终止进程: timeout={timeout:?} ({command}): {source}")]
78    TimedOutKillFailed {
79        command: Box<CommandLine>,
80        stderr: Vec<u8>,
81        stdout: Vec<u8>,
82        timeout: Duration,
83        #[source]
84        source: IoError,
85    },
86
87    #[error("输出不是有效的 UTF-8: {0}")]
88    Utf8(#[from] FromUtf8Error),
89}
90
91/// FLOSS 进程输出。
92#[derive(Debug, Clone)]
93#[non_exhaustive]
94pub struct FlossOutput {
95    pub args: Vec<OsString>,
96    pub program: OsString,
97    pub status: ExitStatus,
98    pub stderr: Vec<u8>,
99    pub stdout: Vec<u8>,
100}
101
102/// 已解析的命令行信息,便于日志与审计。
103#[derive(Debug, Clone)]
104#[non_exhaustive]
105pub struct CommandLine {
106    pub args: Vec<OsString>,
107    pub program: OsString,
108}
109
110impl fmt::Display for CommandLine {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        write!(f, "program={} args=[", self.program.to_string_lossy())?;
113        for (index, arg) in self.args.iter().enumerate() {
114            if index > 0 {
115                write!(f, ", ")?;
116            }
117            write!(f, "{}", arg.to_string_lossy())?;
118        }
119        write!(f, "]")
120    }
121}
122
123/// 限量读取输出时的结果(stdout/stderr 可能被截断)。
124#[derive(Debug, Clone)]
125#[non_exhaustive]
126pub struct FlossOutputLimited {
127    pub args: Vec<OsString>,
128    pub program: OsString,
129    pub status: ExitStatus,
130    pub stderr: Vec<u8>,
131    pub stderr_truncated: bool,
132    pub stdout: Vec<u8>,
133    pub stdout_truncated: bool,
134}
135
136impl FlossOutput {
137    /// 将 stderr 以“容错方式”解码(遇到非法 UTF-8 会替换为 U+FFFD)。
138    #[must_use]
139    pub fn stderr_lossy(&self) -> Cow<'_, str> {
140        String::from_utf8_lossy(&self.stderr)
141    }
142
143    /// 将 stderr 按 UTF-8 严格解码。
144    ///
145    /// # Errors
146    /// - 当 stderr 不是有效的 UTF-8 时,返回 `FlossError::Utf8`。
147    pub fn stderr_string(&self) -> Result<String> {
148        Ok(String::from_utf8(self.stderr.clone())?)
149    }
150
151    /// 将 stdout 以“容错方式”解码(遇到非法 UTF-8 会替换为 U+FFFD)。
152    #[must_use]
153    pub fn stdout_lossy(&self) -> Cow<'_, str> {
154        String::from_utf8_lossy(&self.stdout)
155    }
156
157    /// 将 stdout 按 UTF-8 严格解码。
158    ///
159    /// # Errors
160    /// - 当 stdout 不是有效的 UTF-8 时,返回 `FlossError::Utf8`。
161    pub fn stdout_string(&self) -> Result<String> {
162        Ok(String::from_utf8(self.stdout.clone())?)
163    }
164}
165
166/// FLOSS CLI 入口配置(可执行文件名/路径、基础参数、工作目录与环境变量)。
167#[derive(Debug, Clone)]
168#[non_exhaustive]
169pub struct FlossCli {
170    /// 启动时的基础参数(例如 `python -m floss` 的 `-m floss`)。
171    base_args: Vec<OsString>,
172    /// 可选工作目录。
173    current_dir: Option<PathBuf>,
174    /// 额外注入的环境变量。
175    env: Vec<(OsString, OsString)>,
176    /// 可执行文件名/路径,或 Python 解释器路径(当使用 `python -m floss` 时)。
177    program: OsString,
178    /// 进程执行超时(超过该时间会尝试终止子进程)。
179    timeout: Option<Duration>,
180}
181
182impl FlossCli {
183    /// 构建一次调用(可继续追加参数、可选追加样本路径),最后用 `run/run_raw/run_json` 执行。
184    #[must_use]
185    pub fn command(&self) -> FlossCommand {
186        FlossCommand {
187            args: Vec::new(),
188            base_args: self.base_args.clone(),
189            current_dir: self.current_dir.clone(),
190            env: self.env.clone(),
191            program: self.program.clone(),
192            sample: None,
193            timeout: self.timeout,
194        }
195    }
196
197    /// 自动探测调用方式:
198    /// 1) 若设置 `FLOSS_EXE`,优先使用该值作为可执行文件;
199    /// 2) 若设置 `FLOSS_PYTHON`,优先尝试 `FLOSS_PYTHON {FLOSS_PYTHON_ARGS} -m floss -h`;
200    /// 3) 否则检测 PATH 中是否存在 `floss.exe`(Windows)或 `floss`(其他系统);
201    /// 4) 最后尝试 Python:Windows 依次尝试 `python/python3/py -3`,其他系统依次尝试 `python3/python`。
202    ///
203    /// # Errors
204    /// - 当既找不到可执行的 `floss`,也找不到可运行 `python -m floss` 的 Python 时,返回 `FlossError::AutoDetectFailed`。
205    pub async fn detect() -> Result<Self> {
206        if let Some(exe) = env::var_os("FLOSS_EXE") {
207            if exe.is_empty() {
208                return Err(FlossError::AutoDetectFailed {
209                    message: "FLOSS_EXE 为空,无法作为可执行文件".to_owned(),
210                });
211            }
212            return Ok(Self::new(exe));
213        }
214
215        if let Some(python) = env::var_os("FLOSS_PYTHON") {
216            if python.is_empty() {
217                return Err(FlossError::AutoDetectFailed {
218                    message: "FLOSS_PYTHON 为空,无法作为 Python 解释器".to_owned(),
219                });
220            }
221            let python_args = env::var_os("FLOSS_PYTHON_ARGS")
222                .map(|value| parse_env_args(&value))
223                .unwrap_or_default();
224            if python_module_available_os_args(python.as_os_str(), &python_args).await {
225                return Ok(Self::python_module_with_os_args(python, &python_args));
226            }
227            return Err(FlossError::AutoDetectFailed {
228                message: format!(
229                    "FLOSS_PYTHON 指定的 Python 无法执行 `-m floss -h`: {}",
230                    python.as_os_str().display()
231                ),
232            });
233        }
234
235        if let Some(path_os_string) = env::var_os("PATH") {
236            let path_os_str = path_os_string.as_os_str();
237            if cfg!(windows) {
238                if let Some(found) = find_in_path(path_os_str, OsStr::new("floss.exe")) {
239                    return Ok(Self::new(found));
240                }
241            }
242
243            if let Some(found) = find_in_path(path_os_str, OsStr::new("floss")) {
244                return Ok(Self::new(found));
245            }
246        }
247
248        let python_candidates: &[PythonCandidate] = if cfg!(windows) {
249            &[
250                PythonCandidate::new("python", &[]),
251                PythonCandidate::new("python3", &[]),
252                PythonCandidate::new("py", &["-3"]),
253            ]
254        } else {
255            &[
256                PythonCandidate::new("python3", &[]),
257                PythonCandidate::new("python", &[]),
258            ]
259        };
260
261        for candidate in python_candidates {
262            if python_module_available(candidate.program, candidate.extra_args).await {
263                return Ok(Self::python_module_with_args(
264                    candidate.program,
265                    candidate.extra_args,
266                ));
267            }
268        }
269
270        Err(FlossError::AutoDetectFailed {
271            message: "PATH 中未找到 floss.exe,且未找到可成功执行 `python -m floss -h` 的 Python".to_owned(),
272        })
273    }
274
275    /// 等价于执行 `floss -h`,返回 stdout。
276    ///
277    /// # Errors
278    /// - 当启动进程失败时,返回 `FlossError::Io`。
279    /// - 当 stdout 不是有效 UTF-8 时,返回 `FlossError::Utf8`。
280    pub async fn help(&self) -> Result<String> {
281        self.command().arg("-h").run_raw().await?.stdout_string()
282    }
283
284    /// 等价于执行 `floss -H`,返回 stdout(包含高级参数)。
285    ///
286    /// # Errors
287    /// - 当启动进程失败时,返回 `FlossError::Io`。
288    /// - 当 stdout 不是有效 UTF-8 时,返回 `FlossError::Utf8`。
289    pub async fn help_all(&self) -> Result<String> {
290        self.command().arg("-H").run_raw().await?.stdout_string()
291    }
292
293    /// 使用一个可执行程序名/路径创建实例,例如:`floss` 或 `floss.exe` 或绝对路径。
294    #[must_use]
295    pub fn new<Program>(program: Program) -> Self
296    where
297        Program: Into<OsString>,
298    {
299        Self {
300            base_args: Vec::new(),
301            current_dir: None,
302            env: Vec::new(),
303            program: program.into(),
304            timeout: None,
305        }
306    }
307
308    /// 返回当前配置使用的可执行程序(或 Python 解释器)路径/名称。
309    #[must_use]
310    pub fn program(&self) -> &OsStr {
311        self.program.as_os_str()
312    }
313
314    /// 通过 `python -m floss` 方式调用(适合只安装了 Python 包、没有独立 floss 可执行文件的场景)。
315    #[must_use]
316    pub fn python_module<Python>(python: Python) -> Self
317    where
318        Python: Into<OsString>,
319    {
320        Self::python_module_with_args(python, &[])
321    }
322
323    fn python_module_with_args<Python>(python: Python, extra_args: &[&str]) -> Self
324    where
325        Python: Into<OsString>,
326    {
327        let mut base_args = Vec::with_capacity(extra_args.len() + 2);
328        for arg in extra_args {
329            base_args.push(OsString::from(*arg));
330        }
331        base_args.push(OsString::from("-m"));
332        base_args.push(OsString::from("floss"));
333
334        Self {
335            base_args,
336            current_dir: None,
337            env: Vec::new(),
338            program: python.into(),
339            timeout: None,
340        }
341    }
342
343    fn python_module_with_os_args<Python>(python: Python, extra_args: &[OsString]) -> Self
344    where
345        Python: Into<OsString>,
346    {
347        let mut base_args = Vec::with_capacity(extra_args.len() + 2);
348        for arg in extra_args {
349            base_args.push(arg.clone());
350        }
351        base_args.push(OsString::from("-m"));
352        base_args.push(OsString::from("floss"));
353
354        Self {
355            base_args,
356            current_dir: None,
357            env: Vec::new(),
358            program: python.into(),
359            timeout: None,
360        }
361    }
362
363    /// 等价于执行 `floss --version`,返回 stdout。
364    ///
365    /// # Errors
366    /// - 当启动进程失败时,返回 `FlossError::Io`。
367    /// - 当 stdout 不是有效 UTF-8 时,返回 `FlossError::Utf8`。
368    pub async fn version(&self) -> Result<String> {
369        self.command().arg("--version").run_raw().await?.stdout_string()
370    }
371
372    /// 设置工作目录(`Command::current_dir`)。
373    #[must_use]
374    pub fn with_current_dir<Dir>(mut self, dir: Dir) -> Self
375    where
376        Dir: Into<PathBuf>,
377    {
378        self.current_dir = Some(dir.into());
379        self
380    }
381
382    /// 增加环境变量(`Command::env`)。
383    #[must_use]
384    pub fn with_env<Key, Value>(mut self, key: Key, value: Value) -> Self
385    where
386        Key: Into<OsString>,
387        Value: Into<OsString>,
388    {
389        self.env.push((key.into(), value.into()));
390        self
391    }
392
393    /// 设置执行超时:超过该时间会尝试终止子进程并返回 `FlossError::TimedOut`。
394    /// 若无法终止子进程,则返回 `FlossError::TimedOutKillFailed`。
395    /// 若无法终止子进程,则返回 `FlossError::TimedOutKillFailed`。
396    #[must_use]
397    pub const fn with_timeout(mut self, timeout: Duration) -> Self {
398        self.timeout = Some(timeout);
399        self
400    }
401}
402
403/// 一次具体的 FLOSS 调用(可透传任意参数,保证“全功能”覆盖)。
404#[derive(Debug, Clone)]
405#[non_exhaustive]
406pub struct FlossCommand {
407    /// 透传给 FLOSS 的参数(不做语义校验)。
408    args: Vec<OsString>,
409    /// 启动时的基础参数(例如 `python -m floss` 的 `-m floss`)。
410    base_args: Vec<OsString>,
411    /// 可选工作目录。
412    current_dir: Option<PathBuf>,
413    /// 额外注入的环境变量。
414    env: Vec<(OsString, OsString)>,
415    /// 可执行文件名/路径,或 Python 解释器路径(当使用 `python -m floss` 时)。
416    program: OsString,
417    /// 样本路径(FLOSS 的位置参数 `sample`)。
418    sample: Option<PathBuf>,
419    /// 进程执行超时(超过该时间会尝试终止子进程)。
420    timeout: Option<Duration>,
421}
422
423impl FlossCommand {
424    /// 追加一个原始参数(完全透传,不做语义校验)。
425    #[must_use]
426    pub fn arg<Argument>(mut self, arg: Argument) -> Self
427    where
428        Argument: Into<OsString>,
429    {
430        self.args.push(arg.into());
431        self
432    }
433
434    /// 追加多个原始参数(完全透传,不做语义校验)。
435    #[must_use]
436    pub fn args<Arguments, Argument>(mut self, args: Arguments) -> Self
437    where
438        Arguments: IntoIterator<Item = Argument>,
439        Argument: Into<OsString>,
440    {
441        self.args.extend(args.into_iter().map(Into::into));
442        self
443    }
444
445    /// 返回本次调用的命令行信息(含 `--` 与 sample)。
446    #[must_use]
447    pub fn command_line(&self) -> CommandLine {
448        let prepared = self.clone().prepare();
449        CommandLine {
450            args: prepared.args,
451            program: prepared.program,
452        }
453    }
454
455    /// 执行命令并检查退出码(非 0 返回 `FlossError::NonZeroExit`)。
456    ///
457    /// # Errors
458    /// - 当启动进程失败时,返回 `FlossError::Io`。
459    /// - 当进程退出码非 0 时,返回 `FlossError::NonZeroExit`(包含 stdout/stderr)。
460    pub async fn run(self) -> Result<FlossOutput> {
461        let out = self.run_raw().await?;
462        if out.status.success() {
463            Ok(out)
464        } else {
465            let FlossOutput {
466                args,
467                program,
468                status,
469                stderr,
470                stdout,
471            } = out;
472            Err(FlossError::NonZeroExit {
473                command: Box::new(CommandLine { args, program }),
474                status,
475                stderr,
476                stdout,
477            })
478        }
479    }
480
481    /// 执行命令并允许指定退出码视为成功。
482    ///
483    /// # Errors
484    /// - 当启动进程失败时,返回 `FlossError::Io`。
485    /// - 当退出码不在允许列表时,返回 `FlossError::NonZeroExit`(包含 stdout/stderr)。
486    pub async fn run_allow_exit_codes<Codes>(self, codes: Codes) -> Result<FlossOutput>
487    where
488        Codes: IntoIterator<Item = i32> + Send,
489        Codes::IntoIter: Send,
490    {
491        let out = self.run_raw().await?;
492        if out.status.success() {
493            return Ok(out);
494        }
495
496        if let Some(code) = out.status.code() {
497            if codes.into_iter().any(|allowed| allowed == code) {
498                return Ok(out);
499            }
500        }
501
502        let FlossOutput {
503            args,
504            program,
505            status,
506            stderr,
507            stdout,
508        } = out;
509        Err(FlossError::NonZeroExit {
510            command: Box::new(CommandLine { args, program }),
511            status,
512            stderr,
513            stdout,
514        })
515    }
516
517    /// 以 JSON 方式运行 FLOSS 并反序列化输出。
518    ///
519    /// - 若未显式传入 `-j/--json`,会自动补上 `-j`。
520    /// - 该方法默认会检查退出码(非 0 直接返回错误)。
521    ///
522    /// # Errors
523    /// - 当启动进程失败时,返回 `FlossError::Io`。
524    /// - 当进程退出码非 0 时,返回 `FlossError::NonZeroExit`(包含 stdout/stderr)。
525    /// - 当 stdout 不是有效 JSON 时,返回 `FlossError::Json`。
526    pub async fn run_json<Output>(self) -> Result<Output>
527    where
528        Output: DeserializeOwned,
529    {
530        let has_json_flag = self.args.iter().any(|argument| {
531            let argument_os_str = argument.as_os_str();
532            argument_os_str == OsStr::new("-j") || argument_os_str == OsStr::new("--json")
533        });
534
535        let out = if has_json_flag {
536            self.run().await?
537        } else {
538            self.arg("-j").run().await?
539        };
540        Ok(serde_json::from_slice(&out.stdout)?)
541    }
542
543    /// 以 JSON 方式运行 FLOSS 并解析为 `ResultDocument`。
544    ///
545    /// - 若未显式传入 `-j/--json`,会自动补上 `-j`。
546    /// - 该方法默认会检查退出码(非 0 直接返回错误)。
547    ///
548    /// # Errors
549    /// - 当启动进程失败时,返回 `FlossError::Io`。
550    /// - 当进程退出码非 0 时,返回 `FlossError::NonZeroExit`(包含 stdout/stderr)。
551    /// - 当 stdout 不是有效 JSON 时,返回 `FlossError::Json`。
552    pub async fn run_results(self) -> Result<ResultDocument> {
553        self.run_json::<ResultDocument>().await
554    }
555
556    /// 执行命令并返回输出(不检查退出码)。
557    ///
558    /// # Errors
559    /// - 当启动进程失败时,返回 `FlossError::Io`。
560    /// - 当超时且无法终止进程时,返回 `FlossError::TimedOutKillFailed`。
561    pub async fn run_raw(self) -> Result<FlossOutput> {
562        let prepared = self.prepare();
563        let mut cmd = prepared.command();
564        configure_process_group(&mut cmd);
565        cmd.stdout(Stdio::piped());
566        cmd.stderr(Stdio::piped());
567
568        let mut child = cmd.spawn()?;
569        let job = create_job_for_child(&child);
570        let stdout_reader = child
571            .stdout
572            .take()
573            .ok_or_else(|| IoError::other("无法捕获 stdout"))?;
574        let stderr_reader = child
575            .stderr
576            .take()
577            .ok_or_else(|| IoError::other("无法捕获 stderr"))?;
578
579        let stdout_handle = tokio::spawn(async move { read_all(stdout_reader).await });
580        let stderr_handle = tokio::spawn(async move { read_all(stderr_reader).await });
581
582        let wait_outcome = match prepared.timeout {
583            Some(timeout) => wait_with_timeout(&mut child, timeout, job.as_ref()).await?,
584            None => WaitOutcome::Exited(child.wait().await?),
585        };
586
587        let PreparedCommand {
588            args,
589            program,
590            timeout,
591            ..
592        } = prepared;
593
594        match wait_outcome {
595            WaitOutcome::Exited(status) => {
596                let stdout = join_io(stdout_handle, "stdout").await?;
597                let stderr = join_io(stderr_handle, "stderr").await?;
598                Ok(FlossOutput {
599                    args,
600                    program,
601                    status,
602                    stderr,
603                    stdout,
604                })
605            }
606            WaitOutcome::TimedOut { kill_error, reaped } => {
607                let timeout = timeout.unwrap_or_default();
608                let command = Box::new(CommandLine { args, program });
609                if reaped {
610                    let stdout = join_io(stdout_handle, "stdout").await?;
611                    let stderr = join_io(stderr_handle, "stderr").await?;
612                    return Err(FlossError::TimedOut {
613                        command,
614                        stderr,
615                        stdout,
616                        timeout,
617                    });
618                }
619
620                stdout_handle.abort();
621                stderr_handle.abort();
622
623                let stdout = Vec::new();
624                let stderr = Vec::new();
625                if let Some(source) = kill_error {
626                    return Err(FlossError::TimedOutKillFailed {
627                        command,
628                        stderr,
629                        stdout,
630                        timeout,
631                        source,
632                    });
633                }
634
635                Err(FlossError::TimedOut {
636                    command,
637                    stderr,
638                    stdout,
639                    timeout,
640                })
641            }
642        }
643    }
644
645    /// 执行命令并以固定上限读取 stdout/stderr(不检查退出码)。
646    ///
647    /// - 若输出超过 `max_bytes`,仅保留前 `max_bytes` 字节,剩余字节会被丢弃。
648    ///
649    /// # Errors
650    /// - 当启动进程失败时,返回 `FlossError::Io`。
651    /// - 当超过超时时间时,返回 `FlossError::TimedOut`(stdout/stderr 可能被截断)。
652    /// - 当超时且无法终止进程时,返回 `FlossError::TimedOutKillFailed`。
653    pub async fn run_raw_limited(self, max_bytes: usize) -> Result<FlossOutputLimited> {
654        let prepared = self.prepare();
655        let mut cmd = prepared.command();
656        configure_process_group(&mut cmd);
657        cmd.stdout(Stdio::piped());
658        cmd.stderr(Stdio::piped());
659
660        let mut child = cmd.spawn()?;
661        let job = create_job_for_child(&child);
662        let stdout_reader = child
663            .stdout
664            .take()
665            .ok_or_else(|| IoError::other("无法捕获 stdout"))?;
666        let stderr_reader = child
667            .stderr
668            .take()
669            .ok_or_else(|| IoError::other("无法捕获 stderr"))?;
670
671        let stdout_handle =
672            tokio::spawn(async move { read_all_limited(stdout_reader, max_bytes).await });
673        let stderr_handle =
674            tokio::spawn(async move { read_all_limited(stderr_reader, max_bytes).await });
675
676        let wait_outcome = match prepared.timeout {
677            Some(timeout) => wait_with_timeout(&mut child, timeout, job.as_ref()).await?,
678            None => WaitOutcome::Exited(child.wait().await?),
679        };
680
681        let PreparedCommand {
682            args,
683            program,
684            timeout,
685            ..
686        } = prepared;
687
688        match wait_outcome {
689            WaitOutcome::Exited(status) => {
690                let stdout = join_io(stdout_handle, "stdout").await?;
691                let stderr = join_io(stderr_handle, "stderr").await?;
692                Ok(FlossOutputLimited {
693                    args,
694                    program,
695                    status,
696                    stderr: stderr.data,
697                    stderr_truncated: stderr.truncated,
698                    stdout: stdout.data,
699                    stdout_truncated: stdout.truncated,
700                })
701            }
702            WaitOutcome::TimedOut { kill_error, reaped } => {
703                let timeout = timeout.unwrap_or_default();
704                let command = Box::new(CommandLine { args, program });
705                if reaped {
706                    let stdout = join_io(stdout_handle, "stdout").await?;
707                    let stderr = join_io(stderr_handle, "stderr").await?;
708                    return Err(FlossError::TimedOut {
709                        command,
710                        stderr: stderr.data,
711                        stdout: stdout.data,
712                        timeout,
713                    });
714                }
715
716                stdout_handle.abort();
717                stderr_handle.abort();
718
719                let stdout = Vec::new();
720                let stderr = Vec::new();
721                if let Some(source) = kill_error {
722                    return Err(FlossError::TimedOutKillFailed {
723                        command,
724                        stderr,
725                        stdout,
726                        timeout,
727                        source,
728                    });
729                }
730
731                Err(FlossError::TimedOut {
732                    command,
733                    stderr,
734                    stdout,
735                    timeout,
736                })
737            }
738        }
739    }
740
741    /// 执行命令并将 stdout/stderr 直连终端(不检查退出码)。
742    ///
743    /// # Errors
744    /// - 当启动进程失败时,返回 `FlossError::Io`。
745    /// - 当超过超时时间时,返回 `FlossError::TimedOut`(stderr/stdout 为空)。
746    /// - 当超时且无法终止进程时,返回 `FlossError::TimedOutKillFailed`(stderr/stdout 为空)。
747    pub async fn run_inherit(self) -> Result<ExitStatus> {
748        self.run_inherit_impl(false).await
749    }
750
751    /// 执行命令并将 stdout/stderr 直连终端(退出码非 0 返回错误)。
752    ///
753    /// # Errors
754    /// - 当启动进程失败时,返回 `FlossError::Io`。
755    /// - 当超过超时时间时,返回 `FlossError::TimedOut`(stderr/stdout 为空)。
756    /// - 当超时且无法终止进程时,返回 `FlossError::TimedOutKillFailed`(stderr/stdout 为空)。
757    /// - 当退出码非 0 时,返回 `FlossError::NonZeroExit`(stderr/stdout 为空)。
758    pub async fn run_inherit_checked(self) -> Result<ExitStatus> {
759        self.run_inherit_impl(true).await
760    }
761
762    /// 启动子进程并将 stdout/stderr 直连终端,返回 `Child` 由调用方管理。
763    ///
764    /// 注意:该方法不处理超时,由调用方自行管理。
765    ///
766    /// # Errors
767    /// - 当启动进程失败时,返回 `FlossError::Io`。
768    pub fn spawn(self) -> Result<Child> {
769        let prepared = self.prepare();
770        let mut cmd = prepared.command();
771        cmd.stdin(Stdio::inherit());
772        cmd.stdout(Stdio::inherit());
773        cmd.stderr(Stdio::inherit());
774        Ok(cmd.spawn()?)
775    }
776
777    /// 启动子进程并将 stdout/stderr 设为 piped,返回 `Child` 由调用方管理。
778    ///
779    /// 注意:该方法不处理超时,由调用方自行管理。
780    ///
781    /// # Errors
782    /// - 当启动进程失败时,返回 `FlossError::Io`。
783    pub fn spawn_piped(self) -> Result<Child> {
784        let prepared = self.prepare();
785        let mut cmd = prepared.command();
786        cmd.stdin(Stdio::null());
787        cmd.stdout(Stdio::piped());
788        cmd.stderr(Stdio::piped());
789        Ok(cmd.spawn()?)
790    }
791
792    /// 设置样本(FLOSS 的位置参数 `sample`)。
793    ///
794    /// 内部会自动插入 `--`,避免样本路径以 `-` 开头时被误解析为参数。
795    #[must_use]
796    pub fn sample<Sample>(mut self, sample: Sample) -> Self
797    where
798        Sample: AsRef<Path>,
799    {
800        self.sample = Some(sample.as_ref().to_path_buf());
801        self
802    }
803
804    /// 设置执行超时:超过该时间会尝试终止子进程并返回 `FlossError::TimedOut`。
805    #[must_use]
806    pub const fn with_timeout(mut self, timeout: Duration) -> Self {
807        self.timeout = Some(timeout);
808        self
809    }
810
811    async fn run_inherit_impl(self, check_exit: bool) -> Result<ExitStatus> {
812        let prepared = self.prepare();
813        let mut cmd = prepared.command();
814        configure_process_group(&mut cmd);
815        cmd.stdin(Stdio::inherit());
816        cmd.stdout(Stdio::inherit());
817        cmd.stderr(Stdio::inherit());
818
819        let mut child = cmd.spawn()?;
820        let job = create_job_for_child(&child);
821        let wait_outcome = match prepared.timeout {
822            Some(timeout) => wait_with_timeout(&mut child, timeout, job.as_ref()).await?,
823            None => WaitOutcome::Exited(child.wait().await?),
824        };
825
826        let PreparedCommand {
827            args,
828            program,
829            timeout,
830            ..
831        } = prepared;
832
833        let status = match wait_outcome {
834            WaitOutcome::Exited(status) => status,
835            WaitOutcome::TimedOut { kill_error, .. } => {
836                let timeout = timeout.unwrap_or_default();
837                let command = Box::new(CommandLine { args, program });
838                if let Some(source) = kill_error {
839                    return Err(FlossError::TimedOutKillFailed {
840                        command,
841                        stderr: Vec::new(),
842                        stdout: Vec::new(),
843                        timeout,
844                        source,
845                    });
846                }
847                return Err(FlossError::TimedOut {
848                    command,
849                    stderr: Vec::new(),
850                    stdout: Vec::new(),
851                    timeout,
852                });
853            }
854        };
855
856        if check_exit && !status.success() {
857            let command = Box::new(CommandLine { args, program });
858            return Err(FlossError::NonZeroExit {
859                command,
860                status,
861                stderr: Vec::new(),
862                stdout: Vec::new(),
863            });
864        }
865
866        Ok(status)
867    }
868}
869
870/// 在给定 `PATH` 中搜索指定文件名,返回第一个可执行路径。
871fn find_in_path(path: &OsStr, file_name: &OsStr) -> Option<PathBuf> {
872    for dir in env::split_paths(path) {
873        let candidate = dir.join(file_name);
874        if candidate.is_file() {
875            return Some(candidate);
876        }
877    }
878    None
879}
880
881struct PreparedCommand {
882    args: Vec<OsString>,
883    current_dir: Option<PathBuf>,
884    env: Vec<(OsString, OsString)>,
885    program: OsString,
886    timeout: Option<Duration>,
887}
888
889impl PreparedCommand {
890    fn command(&self) -> Command {
891        let mut cmd = Command::new(&self.program);
892        cmd.args(&self.args);
893
894        if let Some(dir) = self.current_dir.as_ref() {
895            cmd.current_dir(dir);
896        }
897
898        for (key, value) in &self.env {
899            cmd.env(key, value);
900        }
901
902        cmd
903    }
904}
905
906impl FlossCommand {
907    fn prepare(self) -> PreparedCommand {
908        let mut final_args = Vec::with_capacity(self.base_args.len() + self.args.len() + 2);
909        final_args.extend(self.base_args);
910        final_args.extend(self.args);
911        if let Some(sample) = self.sample {
912            final_args.push(OsString::from("--"));
913            final_args.push(sample.into_os_string());
914        }
915
916        PreparedCommand {
917            args: final_args,
918            current_dir: self.current_dir,
919            env: self.env,
920            program: self.program,
921            timeout: self.timeout,
922        }
923    }
924}
925
926fn parse_env_args(value: &OsStr) -> Vec<OsString> {
927    let input = value.to_string_lossy();
928    let mut args = Vec::new();
929    let mut buf = String::new();
930    let mut in_single = false;
931    let mut in_double = false;
932    let mut arg_started = false;
933    let mut escape = false;
934
935    for ch in input.chars() {
936        if escape {
937            buf.push(ch);
938            arg_started = true;
939            escape = false;
940            continue;
941        }
942
943        if ch == '\\' {
944            if in_single {
945                buf.push(ch);
946                arg_started = true;
947            } else if in_double {
948                escape = true;
949            } else {
950                escape = true;
951                arg_started = true;
952            }
953            continue;
954        }
955
956        if in_single {
957            if ch == '\'' {
958                in_single = false;
959            } else {
960                buf.push(ch);
961            }
962            arg_started = true;
963            continue;
964        }
965
966        if in_double {
967            if ch == '"' {
968                in_double = false;
969            } else {
970                buf.push(ch);
971            }
972            arg_started = true;
973            continue;
974        }
975
976        if ch.is_whitespace() {
977            if arg_started {
978                args.push(OsString::from(std::mem::take(&mut buf)));
979                arg_started = false;
980            }
981            continue;
982        }
983
984        if ch == '\'' {
985            in_single = true;
986            arg_started = true;
987            continue;
988        }
989
990        if ch == '"' {
991            in_double = true;
992            arg_started = true;
993            continue;
994        }
995
996        buf.push(ch);
997        arg_started = true;
998    }
999
1000    if escape && !in_single {
1001        buf.push('\\');
1002    }
1003
1004    if arg_started {
1005        args.push(OsString::from(buf));
1006    }
1007
1008    args
1009}
1010
1011struct PythonCandidate {
1012    extra_args: &'static [&'static str],
1013    program: &'static str,
1014}
1015
1016impl PythonCandidate {
1017    const fn new(program: &'static str, extra_args: &'static [&'static str]) -> Self {
1018        Self { extra_args, program }
1019    }
1020}
1021
1022async fn python_module_available<Program>(program: Program, extra_args: &[&str]) -> bool
1023where
1024    Program: AsRef<OsStr>,
1025{
1026    let status = Command::new(program.as_ref())
1027        .args(extra_args)
1028        .args(["-m", "floss", "-h"])
1029        .stdin(Stdio::null())
1030        .stdout(Stdio::null())
1031        .stderr(Stdio::null())
1032        .status()
1033        .await;
1034
1035    matches!(status, Ok(exit_status) if exit_status.success())
1036}
1037
1038async fn python_module_available_os_args(program: &OsStr, extra_args: &[OsString]) -> bool {
1039    let status = Command::new(program)
1040        .args(extra_args)
1041        .args(["-m", "floss", "-h"])
1042        .stdin(Stdio::null())
1043        .stdout(Stdio::null())
1044        .stderr(Stdio::null())
1045        .status()
1046        .await;
1047
1048    matches!(status, Ok(exit_status) if exit_status.success())
1049}
1050
1051async fn join_io<T>(handle: JoinHandle<std::io::Result<T>>, name: &str) -> std::io::Result<T> {
1052    match handle.await {
1053        Ok(result) => result,
1054        Err(_panic) => Err(IoError::other(format!("{name} 读取任务发生 panic"))),
1055    }
1056}
1057
1058struct ReadLimited {
1059    data: Vec<u8>,
1060    truncated: bool,
1061}
1062
1063async fn read_all<R>(mut reader: R) -> std::io::Result<Vec<u8>>
1064where
1065    R: tokio::io::AsyncRead + Unpin,
1066{
1067    let mut buf = Vec::new();
1068    reader.read_to_end(&mut buf).await?;
1069    Ok(buf)
1070}
1071
1072async fn read_all_limited<R>(mut reader: R, max_bytes: usize) -> std::io::Result<ReadLimited>
1073where
1074    R: tokio::io::AsyncRead + Unpin,
1075{
1076    let mut buf = Vec::new();
1077    let mut truncated = false;
1078    let mut scratch = [0_u8; 8192];
1079
1080    loop {
1081        let count = reader.read(&mut scratch).await?;
1082        if count == 0 {
1083            break;
1084        }
1085
1086        if buf.len() < max_bytes {
1087            let remaining = max_bytes - buf.len();
1088            let to_copy = remaining.min(count);
1089            buf.extend_from_slice(&scratch[..to_copy]);
1090            if to_copy < count {
1091                truncated = true;
1092            }
1093        } else {
1094            truncated = true;
1095        }
1096    }
1097
1098    Ok(ReadLimited { data: buf, truncated })
1099}
1100
1101enum WaitOutcome {
1102    Exited(ExitStatus),
1103    TimedOut { kill_error: Option<IoError>, reaped: bool },
1104}
1105
1106async fn wait_for_exit(child: &mut Child, grace: Duration) -> std::io::Result<bool> {
1107    match time::timeout(grace, child.wait()).await {
1108        Ok(status) => {
1109            status?;
1110            Ok(true)
1111        }
1112        Err(_elapsed) => Ok(false),
1113    }
1114}
1115
1116async fn wait_with_timeout(
1117    child: &mut Child,
1118    timeout: Duration,
1119    job: Option<&JobObject>,
1120) -> std::io::Result<WaitOutcome> {
1121    match time::timeout(timeout, child.wait()).await {
1122        Ok(status) => Ok(WaitOutcome::Exited(status?)),
1123        Err(_elapsed) => {
1124            if let Some(status) = child.try_wait()? {
1125                return Ok(WaitOutcome::Exited(status));
1126            }
1127
1128            if let Err(error) = kill_process_tree(child, job).await {
1129                return Ok(WaitOutcome::TimedOut {
1130                    kill_error: Some(error),
1131                    reaped: false,
1132                });
1133            }
1134
1135            let reaped = wait_for_exit(child, Duration::from_millis(200)).await?;
1136            Ok(WaitOutcome::TimedOut {
1137                kill_error: None,
1138                reaped,
1139            })
1140        }
1141    }
1142}
1143
1144#[cfg(unix)]
1145fn configure_process_group(cmd: &mut Command) {
1146    unsafe {
1147        cmd.pre_exec(|| {
1148            let _ = libc::setpgid(0, 0);
1149            Ok(())
1150        });
1151    }
1152}
1153
1154#[cfg(not(unix))]
1155const fn configure_process_group(_cmd: &mut Command) {}
1156
1157#[cfg(windows)]
1158struct JobObject(windows_sys::Win32::Foundation::HANDLE);
1159
1160#[cfg(not(windows))]
1161struct JobObject;
1162
1163#[cfg(windows)]
1164unsafe impl Send for JobObject {}
1165
1166#[cfg(windows)]
1167unsafe impl Sync for JobObject {}
1168
1169#[cfg(windows)]
1170impl JobObject {
1171    fn create() -> std::io::Result<Self> {
1172        use std::mem::size_of;
1173        use std::ptr::null_mut;
1174        use windows_sys::Win32::Foundation::CloseHandle;
1175        use windows_sys::Win32::System::JobObjects::{
1176            CreateJobObjectW, JobObjectExtendedLimitInformation, SetInformationJobObject,
1177            JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
1178        };
1179
1180        let handle = unsafe { CreateJobObjectW(null_mut(), null_mut()) };
1181        if handle.is_null() {
1182            return Err(IoError::last_os_error());
1183        }
1184
1185        let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
1186        info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
1187        let info_size = u32::try_from(size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>())
1188            .map_err(|_error| IoError::other("JOBOBJECT_EXTENDED_LIMIT_INFORMATION 大小溢出"))?;
1189        let info_ptr = std::ptr::addr_of!(info).cast();
1190        let result = unsafe {
1191            SetInformationJobObject(
1192                handle,
1193                JobObjectExtendedLimitInformation,
1194                info_ptr,
1195                info_size,
1196            )
1197        };
1198
1199        if result == 0 {
1200            let error = IoError::last_os_error();
1201            unsafe {
1202                CloseHandle(handle);
1203            }
1204            return Err(error);
1205        }
1206
1207        Ok(Self(handle))
1208    }
1209
1210    fn assign_pid(&self, pid: u32) -> std::io::Result<()> {
1211        use windows_sys::Win32::Foundation::CloseHandle;
1212        use windows_sys::Win32::System::JobObjects::AssignProcessToJobObject;
1213        use windows_sys::Win32::System::Threading::{
1214            OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE,
1215        };
1216
1217        let process_handle = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid) };
1218        if process_handle.is_null() {
1219            return Err(IoError::last_os_error());
1220        }
1221
1222        let result = unsafe { AssignProcessToJobObject(self.0, process_handle) };
1223        unsafe {
1224            CloseHandle(process_handle);
1225        }
1226        if result == 0 {
1227            return Err(IoError::last_os_error());
1228        }
1229
1230        Ok(())
1231    }
1232
1233    fn terminate(&self) -> std::io::Result<()> {
1234        use windows_sys::Win32::System::JobObjects::TerminateJobObject;
1235
1236        let result = unsafe { TerminateJobObject(self.0, 1) };
1237        if result == 0 {
1238            Err(IoError::last_os_error())
1239        } else {
1240            Ok(())
1241        }
1242    }
1243}
1244
1245#[cfg(windows)]
1246impl Drop for JobObject {
1247    fn drop(&mut self) {
1248        use windows_sys::Win32::Foundation::CloseHandle;
1249        unsafe {
1250            CloseHandle(self.0);
1251        }
1252    }
1253}
1254
1255#[cfg(windows)]
1256fn create_job_for_child(child: &Child) -> Option<JobObject> {
1257    let job = JobObject::create().ok()?;
1258    let pid = child.id()?;
1259    job.assign_pid(pid).ok()?;
1260    Some(job)
1261}
1262
1263#[cfg(not(windows))]
1264const fn create_job_for_child(_child: &Child) -> Option<JobObject> {
1265    None
1266}
1267
1268#[cfg(unix)]
1269async fn kill_process_tree(child: &mut Child, _job: Option<&JobObject>) -> std::io::Result<()> {
1270    let pid = match child.id() {
1271        Some(pid) => match i32::try_from(pid) {
1272            Ok(pid) => pid,
1273            Err(_error) => {
1274                return child
1275                    .kill()
1276                    .await
1277                    .map_err(|err| IoError::other(format!("killpg 目标 PID 无效: {err}")));
1278            }
1279        },
1280        None => {
1281            return child
1282                .kill()
1283                .await
1284                .map_err(|err| IoError::other(format!("无法获取子进程 PID: {err}")));
1285        }
1286    };
1287
1288    let term_result = unsafe { libc::killpg(pid, libc::SIGTERM) };
1289    if term_result == 0 {
1290        if wait_for_exit(child, Duration::from_millis(100)).await? {
1291            return Ok(());
1292        }
1293    }
1294
1295    let kill_result = unsafe { libc::killpg(pid, libc::SIGKILL) };
1296    if kill_result == 0 {
1297        return Ok(());
1298    }
1299
1300    let killpg_error = IoError::last_os_error();
1301    if killpg_error.raw_os_error() == Some(libc::ESRCH) {
1302        return Ok(());
1303    }
1304
1305    match child.kill().await {
1306        Ok(()) => Ok(()),
1307        Err(kill_error) => Err(IoError::other(format!(
1308            "killpg 失败: {killpg_error}; kill 失败: {kill_error}"
1309        ))),
1310    }
1311}
1312
1313#[cfg(windows)]
1314async fn kill_process_tree(child: &mut Child, job: Option<&JobObject>) -> std::io::Result<()> {
1315    let mut errors = Vec::new();
1316    if let Some(job) = job {
1317        if let Err(error) = job.terminate() {
1318            errors.push(format!("TerminateJobObject 失败: {error}"));
1319        } else {
1320            return Ok(());
1321        }
1322    }
1323
1324    if let Some(pid) = child.id() {
1325        let mut cmd = Command::new("taskkill");
1326        cmd.args(["/T", "/F", "/PID", &pid.to_string()]);
1327        match cmd.status().await {
1328            Ok(status) if status.success() => return Ok(()),
1329            Ok(status) => {
1330                let code = status
1331                    .code()
1332                    .map_or_else(|| "unknown".to_owned(), |value| value.to_string());
1333                errors.push(format!("taskkill 失败: exit={code}"));
1334            }
1335            Err(error) => {
1336                errors.push(format!("taskkill 启动失败: {error}"));
1337            }
1338        }
1339    } else {
1340        errors.push("无法获取子进程 PID".to_owned());
1341    }
1342
1343    if let Err(error) = child.kill().await {
1344        errors.push(format!("kill 失败: {error}"));
1345    } else {
1346        return Ok(());
1347    }
1348
1349    Err(IoError::other(errors.join("; ")))
1350}
1351
1352#[cfg(not(any(unix, windows)))]
1353async fn kill_process_tree(child: &mut Child, _job: Option<&JobObject>) -> std::io::Result<()> {
1354    child.kill().await
1355}
1356
1357#[cfg(test)]
1358mod tests {
1359    use super::*;
1360    use std::fs;
1361    use std::time::{SystemTime, UNIX_EPOCH};
1362    use tokio::sync::Mutex;
1363
1364    static ENV_LOCK: Mutex<()> = Mutex::const_new(());
1365
1366    struct EnvGuard {
1367        key: &'static str,
1368        original: Option<OsString>,
1369    }
1370
1371    impl EnvGuard {
1372        fn remove(key: &'static str) -> Self {
1373            let original = env::var_os(key);
1374            env::remove_var(key);
1375            Self { key, original }
1376        }
1377
1378        fn set(key: &'static str, value: OsString) -> Self {
1379            let original = env::var_os(key);
1380            env::set_var(key, value);
1381            Self { key, original }
1382        }
1383    }
1384
1385    impl Drop for EnvGuard {
1386        fn drop(&mut self) {
1387            if let Some(value) = self.original.take() {
1388                env::set_var(self.key, value);
1389            } else {
1390                env::remove_var(self.key);
1391            }
1392        }
1393    }
1394
1395    async fn lock_env() -> tokio::sync::MutexGuard<'static, ()> {
1396        ENV_LOCK.lock().await
1397    }
1398
1399    fn success_command() -> FlossCommand {
1400        if cfg!(windows) {
1401            FlossCli::new("cmd")
1402                .command()
1403                .args(["/C", "exit", "/B", "0"])
1404        } else {
1405            FlossCli::new("sh").command().args(["-c", "exit 0"])
1406        }
1407    }
1408
1409    fn failure_command() -> FlossCommand {
1410        if cfg!(windows) {
1411            FlossCli::new("cmd")
1412                .command()
1413                .args(["/C", "exit", "/B", "1"])
1414        } else {
1415            FlossCli::new("sh").command().args(["-c", "exit 1"])
1416        }
1417    }
1418
1419    #[test]
1420    fn sample_is_appended_after_double_dash() {
1421        let cmd = FlossCli::new("floss")
1422            .command()
1423            .arg("--only")
1424            .args(["static", "decoded"])
1425            .sample("a.exe");
1426
1427        assert!(cmd.sample.is_some());
1428    }
1429
1430    #[tokio::test]
1431    async fn detects_floss_from_path() -> Result<()> {
1432        let _env_lock = lock_env().await;
1433        let suffix = SystemTime::now()
1434            .duration_since(UNIX_EPOCH)
1435            .unwrap_or_default()
1436            .as_nanos();
1437        let tmp = env::temp_dir().join(format!("floss-cli-test-{suffix}"));
1438        fs::create_dir_all(&tmp)?;
1439
1440        let file_name = if cfg!(windows) { "floss.exe" } else { "floss" };
1441        let expected = tmp.join(file_name);
1442        fs::write(&expected, b"")?;
1443
1444        let original_path = env::var_os("PATH");
1445        let original_exe = env::var_os("FLOSS_EXE");
1446        let original_python = env::var_os("FLOSS_PYTHON");
1447        env::remove_var("FLOSS_EXE");
1448        env::remove_var("FLOSS_PYTHON");
1449        env::set_var("PATH", &tmp);
1450
1451        let detected = FlossCli::detect().await;
1452        match original_path {
1453            Some(value) => env::set_var("PATH", value),
1454            None => env::remove_var("PATH"),
1455        }
1456        match original_exe {
1457            Some(value) => env::set_var("FLOSS_EXE", value),
1458            None => env::remove_var("FLOSS_EXE"),
1459        }
1460        match original_python {
1461            Some(value) => env::set_var("FLOSS_PYTHON", value),
1462            None => env::remove_var("FLOSS_PYTHON"),
1463        }
1464
1465        let cli = detected?;
1466        if cli.program() == expected.as_os_str() {
1467            return Ok(());
1468        }
1469
1470        Err(FlossError::AutoDetectFailed {
1471            message: format!(
1472                "自动探测结果不符合预期: expected={expected:?} actual={actual:?}",
1473                expected = expected.as_os_str(),
1474                actual = cli.program()
1475            ),
1476        })
1477    }
1478
1479    #[tokio::test]
1480    async fn detect_prefers_floss_exe_env() -> Result<()> {
1481        let _env_lock = lock_env().await;
1482        let suffix = SystemTime::now()
1483            .duration_since(UNIX_EPOCH)
1484            .unwrap_or_default()
1485            .as_nanos();
1486        let tmp = env::temp_dir().join(format!("floss-cli-test-exe-{suffix}"));
1487        fs::create_dir_all(&tmp)?;
1488        let file_name = if cfg!(windows) { "floss.exe" } else { "floss" };
1489        let exe_path = tmp.join(file_name);
1490        fs::write(&exe_path, b"")?;
1491
1492        let _guard_exe = EnvGuard::set("FLOSS_EXE", exe_path.as_os_str().to_os_string());
1493        let _guard_python = EnvGuard::remove("FLOSS_PYTHON");
1494
1495        let cli = FlossCli::detect().await?;
1496        assert_eq!(cli.program(), exe_path.as_os_str());
1497        Ok(())
1498    }
1499
1500    #[tokio::test]
1501    async fn detect_rejects_empty_floss_exe() {
1502        let _env_lock = lock_env().await;
1503        let _guard_exe = EnvGuard::set("FLOSS_EXE", OsString::new());
1504        let _guard_python = EnvGuard::remove("FLOSS_PYTHON");
1505
1506        let result = FlossCli::detect().await;
1507        assert!(matches!(result, Err(FlossError::AutoDetectFailed { .. })));
1508    }
1509
1510    #[tokio::test]
1511    async fn detect_rejects_unavailable_floss_python() {
1512        let _env_lock = lock_env().await;
1513        let suffix = SystemTime::now()
1514            .duration_since(UNIX_EPOCH)
1515            .unwrap_or_default()
1516            .as_nanos();
1517        let python_name = format!("definitely-not-a-python-{suffix}");
1518
1519        let _guard_exe = EnvGuard::remove("FLOSS_EXE");
1520        let _guard_python = EnvGuard::set("FLOSS_PYTHON", OsString::from(python_name));
1521
1522        let result = FlossCli::detect().await;
1523        assert!(matches!(result, Err(FlossError::AutoDetectFailed { .. })));
1524    }
1525
1526    #[test]
1527    fn python_module_with_args_includes_extra_args() {
1528        let cli = FlossCli::python_module_with_args("py", &["-3"]);
1529        let expected = vec![
1530            OsString::from("-3"),
1531            OsString::from("-m"),
1532            OsString::from("floss"),
1533        ];
1534        assert_eq!(cli.base_args, expected);
1535        assert_eq!(cli.program, OsString::from("py"));
1536    }
1537
1538    #[test]
1539    fn python_module_with_os_args_includes_extra_args() {
1540        let args = vec![OsString::from("-3.11")];
1541        let cli = FlossCli::python_module_with_os_args("py", &args);
1542        let expected = vec![
1543            OsString::from("-3.11"),
1544            OsString::from("-m"),
1545            OsString::from("floss"),
1546        ];
1547        assert_eq!(cli.base_args, expected);
1548        assert_eq!(cli.program, OsString::from("py"));
1549    }
1550
1551    #[test]
1552    fn command_inherits_timeout() {
1553        let timeout = Duration::from_millis(250);
1554        let cli = FlossCli::new("floss").with_timeout(timeout);
1555        let cmd = cli.command();
1556        assert_eq!(cmd.timeout, Some(timeout));
1557    }
1558
1559    #[test]
1560    fn prepare_inserts_double_dash_before_sample() {
1561        let sample = PathBuf::from("-sample.bin");
1562        let cmd = FlossCli::new("floss")
1563            .command()
1564            .arg("--only")
1565            .args(["static", "decoded"])
1566            .sample(&sample);
1567        let prepared = cmd.prepare();
1568        let args = prepared.args;
1569        assert!(args.len() >= 2);
1570        assert_eq!(args[args.len() - 2], OsString::from("--"));
1571        assert_eq!(args[args.len() - 1], sample.into_os_string());
1572    }
1573
1574    #[test]
1575    fn prepare_appends_args_after_base_args() {
1576        let cli = FlossCli::python_module_with_args("py", &["-3"]);
1577        let extra_args = vec![OsString::from("--only"), OsString::from("static")];
1578        let cmd = cli.command().args(extra_args.clone());
1579        let expected_prefix = cli.base_args.as_slice();
1580        let prepared = cmd.prepare();
1581        assert!(prepared.args.starts_with(expected_prefix));
1582        assert_eq!(
1583            &prepared.args[expected_prefix.len()..],
1584            extra_args.as_slice()
1585        );
1586    }
1587
1588    #[test]
1589    fn prepare_carries_env_and_current_dir() -> Result<()> {
1590        let suffix = SystemTime::now()
1591            .duration_since(UNIX_EPOCH)
1592            .unwrap_or_default()
1593            .as_nanos();
1594        let tmp = env::temp_dir().join(format!("floss-cli-test-dir-{suffix}"));
1595        fs::create_dir_all(&tmp)?;
1596        let cli = FlossCli::new("floss")
1597            .with_current_dir(&tmp)
1598            .with_env("FLOSS_CLI_TEST_KEY", "VALUE");
1599        let prepared = cli.command().prepare();
1600        assert_eq!(prepared.current_dir, Some(tmp));
1601        assert_eq!(
1602            prepared.env,
1603            vec![(OsString::from("FLOSS_CLI_TEST_KEY"), OsString::from("VALUE"))]
1604        );
1605        Ok(())
1606    }
1607
1608    #[test]
1609    fn command_line_includes_sample_and_base_args() {
1610        let cmd = FlossCli::python_module_with_args("py", &["-3"])
1611            .command()
1612            .arg("--only")
1613            .sample("a.exe");
1614        let line = cmd.command_line();
1615        let expected = vec![
1616            OsString::from("-3"),
1617            OsString::from("-m"),
1618            OsString::from("floss"),
1619            OsString::from("--only"),
1620            OsString::from("--"),
1621            OsString::from("a.exe"),
1622        ];
1623        assert_eq!(line.program, OsString::from("py"));
1624        assert_eq!(line.args, expected);
1625    }
1626
1627    #[tokio::test]
1628    async fn run_inherit_returns_success_status() -> Result<()> {
1629        let status = success_command().run_inherit().await?;
1630        assert!(status.success());
1631        Ok(())
1632    }
1633
1634    #[tokio::test]
1635    async fn run_inherit_checked_reports_nonzero() {
1636        let result = failure_command().run_inherit_checked().await;
1637        assert!(matches!(result, Err(FlossError::NonZeroExit { .. })));
1638    }
1639
1640    #[tokio::test]
1641    async fn run_allow_exit_codes_accepts_nonzero() -> Result<()> {
1642        let out = failure_command().run_allow_exit_codes([1]).await?;
1643        assert_eq!(out.status.code(), Some(1));
1644        Ok(())
1645    }
1646
1647    #[tokio::test]
1648    async fn spawn_returns_child_with_success_status() -> Result<()> {
1649        let mut child = success_command().spawn()?;
1650        let status = child.wait().await?;
1651        assert!(status.success());
1652        Ok(())
1653    }
1654
1655    #[test]
1656    fn parse_env_args_splits_whitespace() {
1657        let args = parse_env_args(OsStr::new("-3   -m floss"));
1658        let expected = vec![
1659            OsString::from("-3"),
1660            OsString::from("-m"),
1661            OsString::from("floss"),
1662        ];
1663        assert_eq!(args, expected);
1664    }
1665
1666    #[test]
1667    fn parse_env_args_supports_double_quotes() {
1668        let args = parse_env_args(OsStr::new(r#"-m "floss cli""#));
1669        let expected = vec![OsString::from("-m"), OsString::from("floss cli")];
1670        assert_eq!(args, expected);
1671    }
1672
1673    #[test]
1674    fn parse_env_args_supports_single_quotes() {
1675        let args = parse_env_args(OsStr::new(r"--path 'C:\Program Files\Floss'"));
1676        let expected = vec![
1677            OsString::from("--path"),
1678            OsString::from(r"C:\Program Files\Floss"),
1679        ];
1680        assert_eq!(args, expected);
1681    }
1682
1683    #[test]
1684    fn parse_env_args_supports_double_quote_escape() {
1685        let args = parse_env_args(OsStr::new(r#"--name "a\"b c""#));
1686        let expected = vec![OsString::from("--name"), OsString::from("a\"b c")];
1687        assert_eq!(args, expected);
1688    }
1689
1690    #[test]
1691    fn parse_env_args_keeps_mixed_tokens() {
1692        let args = parse_env_args(OsStr::new(r#"a"b c" 'd e' f"#));
1693        let expected = vec![
1694            OsString::from("ab c"),
1695            OsString::from("d e"),
1696            OsString::from("f"),
1697        ];
1698        assert_eq!(args, expected);
1699    }
1700
1701    #[tokio::test]
1702    async fn read_all_limited_truncates() -> Result<()> {
1703        let data = [1_u8, 2, 3, 4, 5, 6];
1704        let limited = read_all_limited(&data[..], 4).await?;
1705        assert_eq!(limited.data, vec![1_u8, 2, 3, 4]);
1706        assert!(limited.truncated);
1707        Ok(())
1708    }
1709}