1extern 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
45pub type Result<T> = CoreResult<T, FlossError>;
47
48#[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#[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#[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#[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 #[must_use]
139 pub fn stderr_lossy(&self) -> Cow<'_, str> {
140 String::from_utf8_lossy(&self.stderr)
141 }
142
143 pub fn stderr_string(&self) -> Result<String> {
148 Ok(String::from_utf8(self.stderr.clone())?)
149 }
150
151 #[must_use]
153 pub fn stdout_lossy(&self) -> Cow<'_, str> {
154 String::from_utf8_lossy(&self.stdout)
155 }
156
157 pub fn stdout_string(&self) -> Result<String> {
162 Ok(String::from_utf8(self.stdout.clone())?)
163 }
164}
165
166#[derive(Debug, Clone)]
168#[non_exhaustive]
169pub struct FlossCli {
170 base_args: Vec<OsString>,
172 current_dir: Option<PathBuf>,
174 env: Vec<(OsString, OsString)>,
176 program: OsString,
178 timeout: Option<Duration>,
180}
181
182impl FlossCli {
183 #[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 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 pub async fn help(&self) -> Result<String> {
281 self.command().arg("-h").run_raw().await?.stdout_string()
282 }
283
284 pub async fn help_all(&self) -> Result<String> {
290 self.command().arg("-H").run_raw().await?.stdout_string()
291 }
292
293 #[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 #[must_use]
310 pub fn program(&self) -> &OsStr {
311 self.program.as_os_str()
312 }
313
314 #[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 pub async fn version(&self) -> Result<String> {
369 self.command().arg("--version").run_raw().await?.stdout_string()
370 }
371
372 #[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 #[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 #[must_use]
397 pub const fn with_timeout(mut self, timeout: Duration) -> Self {
398 self.timeout = Some(timeout);
399 self
400 }
401}
402
403#[derive(Debug, Clone)]
405#[non_exhaustive]
406pub struct FlossCommand {
407 args: Vec<OsString>,
409 base_args: Vec<OsString>,
411 current_dir: Option<PathBuf>,
413 env: Vec<(OsString, OsString)>,
415 program: OsString,
417 sample: Option<PathBuf>,
419 timeout: Option<Duration>,
421}
422
423impl FlossCommand {
424 #[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 #[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 #[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 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 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 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 pub async fn run_results(self) -> Result<ResultDocument> {
553 self.run_json::<ResultDocument>().await
554 }
555
556 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 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 pub async fn run_inherit(self) -> Result<ExitStatus> {
748 self.run_inherit_impl(false).await
749 }
750
751 pub async fn run_inherit_checked(self) -> Result<ExitStatus> {
759 self.run_inherit_impl(true).await
760 }
761
762 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 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 #[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 #[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
870fn 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}