From 2fd9026bf3d2a543e15060600fd364b8474f6187 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E7=9D=BF?= Date: Mon, 23 Mar 2026 17:20:34 +0800 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=20QEMU=20=E5=92=8C=20U-Boot=20=E7=9A=84=E9=80=BB?= =?UTF-8?q?=E8=BE=91=EF=BC=8C=E7=AE=80=E5=8C=96=E5=8F=82=E6=95=B0=E4=BC=A0?= =?UTF-8?q?=E9=80=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ostool/src/bin/cargo-osrun.rs | 26 ++++---- ostool/src/build/cargo_builder.rs | 80 ++++++++++++++----------- ostool/src/build/mod.rs | 20 +++---- ostool/src/ctx.rs | 63 +++++++++++++++++++- ostool/src/main.rs | 24 +++----- ostool/src/run/qemu.rs | 98 ++++++++++++++++++------------- ostool/src/run/uboot.rs | 92 +++++++++++++++-------------- 7 files changed, 238 insertions(+), 165 deletions(-) diff --git a/ostool/src/bin/cargo-osrun.rs b/ostool/src/bin/cargo-osrun.rs index bf76f3e..b2845c3 100644 --- a/ostool/src/bin/cargo-osrun.rs +++ b/ostool/src/bin/cargo-osrun.rs @@ -10,7 +10,7 @@ use ostool::{ ctx::{AppContext, OutputConfig, PathConfig}, run::{ qemu, - uboot::{self, RunUbootArgs}, + uboot::RunUbootArgs, }, }; @@ -143,24 +143,18 @@ async fn try_main() -> anyhow::Result<()> { match args.command { Some(SubCommands::Uboot(_)) => { - uboot::run_uboot( - app, - RunUbootArgs { - config: args.config, - show_output: args.show_output, - }, - ) + app.run_uboot(RunUbootArgs { + config: args.config, + show_output: args.show_output, + }) .await?; } None => { - qemu::run_qemu( - app, - qemu::RunQemuArgs { - qemu_config: args.config, - dtb_dump: args.dtb_dump, - show_output: args.show_output, - }, - ) + app.run_qemu(qemu::RunQemuArgs { + qemu_config: args.config, + dtb_dump: args.dtb_dump, + show_output: args.show_output, + }) .await?; } } diff --git a/ostool/src/build/cargo_builder.rs b/ostool/src/build/cargo_builder.rs index 2dfc157..20b5c9b 100644 --- a/ostool/src/build/cargo_builder.rs +++ b/ostool/src/build/cargo_builder.rs @@ -21,6 +21,12 @@ use crate::{ utils::{Command, PathResultExt}, }; +#[derive(Debug, Clone)] +struct ResolvedCargoArtifact { + elf_path: PathBuf, + cargo_artifact_dir: PathBuf, +} + /// A builder for constructing and executing Cargo commands. /// /// `CargoBuilder` provides a fluent API for configuring Cargo build or run @@ -44,7 +50,7 @@ pub struct CargoBuilder<'a> { extra_envs: HashMap, skip_objcopy: bool, resolve_artifact_from_json: bool, - resolved_elf_path: Option, + resolved_artifact: Option, config_path: Option, } @@ -64,8 +70,8 @@ impl<'a> CargoBuilder<'a> { extra_args: Vec::new(), extra_envs: HashMap::new(), skip_objcopy: false, - resolve_artifact_from_json: false, - resolved_elf_path: None, + resolve_artifact_from_json: true, + resolved_artifact: None, config_path, } } @@ -85,8 +91,8 @@ impl<'a> CargoBuilder<'a> { extra_args: Vec::new(), extra_envs: HashMap::new(), skip_objcopy: true, - resolve_artifact_from_json: false, - resolved_elf_path: None, + resolve_artifact_from_json: true, + resolved_artifact: None, config_path, } } @@ -182,13 +188,7 @@ impl<'a> CargoBuilder<'a> { } async fn run_cargo(&mut self) -> anyhow::Result<()> { - if self.resolve_artifact_from_json { - return self.run_cargo_and_resolve_artifact().await; - } - - let mut cmd = self.build_cargo_command().await?; - cmd.run()?; - Ok(()) + self.run_cargo_and_resolve_artifact().await } async fn run_cargo_and_resolve_artifact(&mut self) -> anyhow::Result<()> { @@ -209,7 +209,7 @@ impl<'a> CargoBuilder<'a> { .ok_or_else(|| anyhow!("failed to capture cargo stdout for message parsing"))?; let reader = BufReader::new(stdout); - let mut executable_artifacts: Vec<(String, PathBuf)> = Vec::new(); + let mut executable_artifacts: Vec<(String, ResolvedCargoArtifact)> = Vec::new(); for message in Message::parse_stream(reader) { let message = message.context("failed to parse cargo JSON message stream")?; match message { @@ -218,8 +218,23 @@ impl<'a> CargoBuilder<'a> { && artifact.target.is_bin() && let Some(executable) = artifact.executable { - executable_artifacts - .push((artifact.target.name, executable.into_std_path_buf())); + let elf_path = executable.into_std_path_buf(); + let cargo_artifact_dir = elf_path + .parent() + .ok_or_else(|| { + anyhow!( + "cargo reported executable without parent directory: {}", + elf_path.display() + ) + })? + .to_path_buf(); + executable_artifacts.push(( + artifact.target.name, + ResolvedCargoArtifact { + elf_path, + cargo_artifact_dir, + }, + )); } } Message::CompilerMessage(msg) => { @@ -244,13 +259,13 @@ impl<'a> CargoBuilder<'a> { let resolved = self.pick_executable_artifact(&executable_artifacts, default_run.as_deref()); let Some(resolved) = resolved else { bail!( - "no executable artifact found for package '{}' and target '{}'; please check system.Cargo.package/system.Cargo.target", + "no executable bin artifact found in cargo JSON output for package '{}' and target '{}'; ostool currently resolves only Cargo bin targets. Please check system.Cargo.package/system.Cargo.target", self.config.package, self.config.target ); }; - self.resolved_elf_path = Some(resolved); + self.resolved_artifact = Some(resolved); Ok(()) } @@ -324,10 +339,8 @@ impl<'a> CargoBuilder<'a> { cmd.arg("--release"); } - if self.resolve_artifact_from_json { - cmd.arg("--message-format"); - cmd.arg("json-render-diagnostics"); - } + cmd.arg("--message-format"); + cmd.arg("json-render-diagnostics"); // Extra args for arg in &self.extra_args { @@ -342,18 +355,17 @@ impl<'a> CargoBuilder<'a> { } async fn handle_output(&mut self) -> anyhow::Result<()> { - let elf_path = if let Some(path) = &self.resolved_elf_path { - path.clone() - } else { - let target_dir = self.ctx.paths.build_dir(); - - target_dir - .join(&self.config.target) - .join(if self.ctx.debug { "debug" } else { "release" }) - .join(&self.config.package) - }; + let resolved = self.resolved_artifact.clone().ok_or_else(|| { + anyhow!( + "cargo build finished without a resolved executable artifact for package '{}' and target '{}'", + self.config.package, + self.config.target + ) + })?; - self.ctx.set_elf_path(elf_path).await?; + self.ctx.set_elf_artifact_path(resolved.elf_path).await?; + self.ctx.paths.artifacts.cargo_artifact_dir = Some(resolved.cargo_artifact_dir.clone()); + self.ctx.paths.artifacts.runtime_artifact_dir = Some(resolved.cargo_artifact_dir); if self.config.to_bin && !self.skip_objcopy { self.ctx.objcopy_output_bin()?; @@ -387,9 +399,9 @@ impl<'a> CargoBuilder<'a> { fn pick_executable_artifact( &self, - executable_artifacts: &[(String, PathBuf)], + executable_artifacts: &[(String, ResolvedCargoArtifact)], default_run: Option<&str>, - ) -> Option { + ) -> Option { executable_artifacts .iter() .rev() diff --git a/ostool/src/build/mod.rs b/ostool/src/build/mod.rs index 6f56cf6..effeb87 100644 --- a/ostool/src/build/mod.rs +++ b/ostool/src/build/mod.rs @@ -209,25 +209,21 @@ impl AppContext { dtb_dump, .. } => { - crate::run::qemu::run_qemu( - self.clone(), - RunQemuArgs { + self.clone() + .run_qemu(RunQemuArgs { qemu_config: qemu_config.clone(), dtb_dump: *dtb_dump, show_output: true, - }, - ) - .await?; + }) + .await?; } CargoRunnerKind::Uboot { uboot_config } => { - crate::run::uboot::run_uboot( - self.clone(), - RunUbootArgs { + self.clone() + .run_uboot(RunUbootArgs { config: uboot_config.clone(), show_output: true, - }, - ) - .await?; + }) + .await?; } } diff --git a/ostool/src/ctx.rs b/ostool/src/ctx.rs index efb00a6..fceebb5 100644 --- a/ostool/src/ctx.rs +++ b/ostool/src/ctx.rs @@ -45,6 +45,10 @@ pub struct OutputArtifacts { pub elf: Option, /// Path to the converted binary file. pub bin: Option, + /// Cargo-reported directory containing the original ELF artifact. + pub cargo_artifact_dir: Option, + /// Directory containing the runtime artifact consumed by runners. + pub runtime_artifact_dir: Option, } /// Path configuration grouping all path-related fields. @@ -166,11 +170,23 @@ impl AppContext { Ok(res) } - /// Sets the ELF file path and detects its architecture. + /// Sets the ELF artifact path and synchronizes derived runtime metadata. /// /// This also reads the ELF file to detect the target CPU architecture. - pub async fn set_elf_path(&mut self, path: PathBuf) -> anyhow::Result<()> { + pub async fn set_elf_artifact_path(&mut self, path: PathBuf) -> anyhow::Result<()> { + let path = path + .canonicalize() + .with_path("failed to canonicalize file", &path)?; + let artifact_dir = path + .parent() + .ok_or_else(|| anyhow!("invalid ELF file path: {}", path.display()))? + .to_path_buf(); + self.paths.artifacts.elf = Some(path.clone()); + self.paths.artifacts.bin = None; + self.paths.artifacts.cargo_artifact_dir = Some(artifact_dir.clone()); + self.paths.artifacts.runtime_artifact_dir = Some(artifact_dir); + let binary_data = fs::read(&path) .await .with_path("failed to read ELF file", &path)?; @@ -180,6 +196,14 @@ impl AppContext { Ok(()) } + /// Sets the ELF file path and detects its architecture. + /// + /// This compatibility wrapper keeps older call sites working while also + /// updating artifact directories in the application context. + pub async fn set_elf_path(&mut self, path: PathBuf) -> anyhow::Result<()> { + self.set_elf_artifact_path(path).await + } + /// Strips debug symbols from the ELF file. /// /// Creates a new `.elf` file with debug symbols stripped using `rust-objcopy`. @@ -232,6 +256,9 @@ impl AppContext { objcopy.run()?; self.paths.artifacts.elf = Some(stripped_elf_path.clone()); + self.paths.artifacts.bin = None; + self.paths.artifacts.cargo_artifact_dir = stripped_elf_path.parent().map(PathBuf::from); + self.paths.artifacts.runtime_artifact_dir = stripped_elf_path.parent().map(PathBuf::from); Ok(stripped_elf_path) } @@ -306,6 +333,7 @@ impl AppContext { objcopy.run()?; self.paths.artifacts.bin = Some(bin_path.clone()); + self.paths.artifacts.runtime_artifact_dir = bin_path.parent().map(PathBuf::from); Ok(bin_path) } @@ -467,3 +495,34 @@ fn on_package_selected(app: &mut AppData, path: &str, selected: &str) { }; *value = Some(selected.to_string()); } + +#[cfg(test)] +mod tests { + use super::AppContext; + + #[tokio::test] + async fn set_elf_artifact_path_updates_dirs_and_arch() { + let temp = tempfile::tempdir().unwrap(); + let source = std::env::current_exe().unwrap(); + let copied = temp.path().join("sample-elf"); + std::fs::copy(&source, &copied).unwrap(); + + let mut ctx = AppContext::default(); + ctx.set_elf_artifact_path(copied.clone()).await.unwrap(); + + let expected_elf = copied.canonicalize().unwrap(); + let expected_dir = expected_elf.parent().unwrap().to_path_buf(); + + assert_eq!(ctx.paths.artifacts.elf.as_ref(), Some(&expected_elf)); + assert_eq!( + ctx.paths.artifacts.cargo_artifact_dir.as_ref(), + Some(&expected_dir) + ); + assert_eq!( + ctx.paths.artifacts.runtime_artifact_dir.as_ref(), + Some(&expected_dir) + ); + assert!(ctx.arch.is_some()); + assert!(ctx.paths.artifacts.bin.is_none()); + } +} diff --git a/ostool/src/main.rs b/ostool/src/main.rs index 1d613ab..434caa8 100644 --- a/ostool/src/main.rs +++ b/ostool/src/main.rs @@ -147,24 +147,18 @@ async fn try_main() -> Result<()> { match args.command { RunSubCommands::Qemu(qemu_args) => { - ostool::run::qemu::run_qemu( - ctx, - RunQemuArgs { - qemu_config: qemu_args.qemu_config, - dtb_dump: qemu_args.dtb_dump, - show_output: true, - }, - ) + ctx.run_qemu(RunQemuArgs { + qemu_config: qemu_args.qemu_config, + dtb_dump: qemu_args.dtb_dump, + show_output: true, + }) .await?; } RunSubCommands::Uboot(uboot_args) => { - ostool::run::uboot::run_uboot( - ctx, - RunUbootArgs { - config: uboot_args.uboot_config, - show_output: true, - }, - ) + ctx.run_uboot(RunUbootArgs { + config: uboot_args.uboot_config, + show_output: true, + }) .await?; } } diff --git a/ostool/src/run/qemu.rs b/ostool/src/run/qemu.rs index c644a7a..683d406 100644 --- a/ostool/src/run/qemu.rs +++ b/ostool/src/run/qemu.rs @@ -104,27 +104,30 @@ struct QemuDefaultOverrides { /// # Errors /// /// Returns an error if QEMU fails to start or exits with an error. -pub async fn run_qemu(ctx: AppContext, args: RunQemuArgs) -> anyhow::Result<()> { - run_qemu_with_defaults(ctx, args, QemuDefaultOverrides::default()).await -} +impl AppContext { + pub async fn run_qemu(self, args: RunQemuArgs) -> anyhow::Result<()> { + self.run_qemu_with_more_default_args(args, vec![], vec![], vec![]) + .await + } -pub async fn run_qemu_with_more_default_args( - ctx: AppContext, - run_args: RunQemuArgs, - args: Vec, - success_regex: Vec, - fail_regex: Vec, -) -> anyhow::Result<()> { - run_qemu_with_defaults( - ctx, - run_args, - QemuDefaultOverrides { - args, - success_regex, - fail_regex, - }, - ) - .await + pub async fn run_qemu_with_more_default_args( + self, + run_args: RunQemuArgs, + args: Vec, + success_regex: Vec, + fail_regex: Vec, + ) -> anyhow::Result<()> { + run_qemu_with_defaults( + self, + run_args, + QemuDefaultOverrides { + args, + success_regex, + fail_regex, + }, + ) + .await + } } async fn run_qemu_with_defaults( @@ -407,29 +410,17 @@ impl QemuRunner { } fn uefi_artifact_dir(&self, bin_path: &Path) -> anyhow::Result { - let metadata = self.ctx.metadata()?; - let target_dir = metadata.target_directory.into_std_path_buf(); - let target_dir = target_dir.canonicalize().unwrap_or(target_dir); + if let Some(dir) = &self.ctx.paths.artifacts.runtime_artifact_dir { + return Ok(dir.clone()); + } + let bin_path = bin_path .canonicalize() .with_path("failed to canonicalize file", bin_path)?; - let artifact_dir = match bin_path.strip_prefix(&target_dir) { - Ok(relative_bin_path) => { - let artifact_parent = relative_bin_path.parent().ok_or_else(|| { - anyhow!( - "invalid BIN path under target directory: {}", - bin_path.display() - ) - })?; - target_dir.join(artifact_parent) - } - Err(_) => bin_path - .parent() - .ok_or_else(|| anyhow!("invalid BIN path: {}", bin_path.display()))? - .to_path_buf(), - }; - - Ok(artifact_dir) + bin_path + .parent() + .map(PathBuf::from) + .ok_or_else(|| anyhow!("invalid BIN path: {}", bin_path.display())) } async fn prepare_uefi_vars(&self, vars_template: &Path) -> anyhow::Result { @@ -649,8 +640,12 @@ pub(crate) fn resolve_qemu_config_path( #[cfg(test)] mod tests { - use super::{QemuDefaultOverrides, build_default_qemu_config, resolve_qemu_config_path}; + use super::{ + QemuConfig, QemuDefaultOverrides, QemuRunner, build_default_qemu_config, + resolve_qemu_config_path, + }; use object::Architecture; + use std::path::PathBuf; use tempfile::TempDir; use crate::ctx::{AppContext, PathConfig}; @@ -697,6 +692,27 @@ mod tests { assert_eq!(config.args, vec!["-nographic", "-smp", "2"]); } + #[test] + fn uefi_artifact_dir_prefers_runtime_artifact_dir() { + let runtime_dir = PathBuf::from("/tmp/ostool-runtime"); + let mut ctx = AppContext::default(); + ctx.paths.artifacts.runtime_artifact_dir = Some(runtime_dir.clone()); + + let runner = QemuRunner { + ctx, + config: QemuConfig::default(), + args: vec![], + dtbdump: false, + success_regex: vec![], + fail_regex: vec![], + }; + + let resolved = runner + .uefi_artifact_dir(PathBuf::from("/tmp/ignored/kernel.bin").as_path()) + .unwrap(); + assert_eq!(resolved, runtime_dir); + } + // === QEMU 配置路径解析测试 === #[test] diff --git a/ostool/src/run/uboot.rs b/ostool/src/run/uboot.rs index f264ca2..7fbb324 100644 --- a/ostool/src/run/uboot.rs +++ b/ostool/src/run/uboot.rs @@ -97,53 +97,55 @@ pub struct RunUbootArgs { pub show_output: bool, } -pub async fn run_uboot(ctx: AppContext, args: RunUbootArgs) -> anyhow::Result<()> { - // Build logic will be implemented here - let config_path = match args.config.clone() { - Some(path) => path, - None => ctx.paths.workspace.join(".uboot.toml"), - }; - - let config = if config_path.exists() { - println!("Using U-Boot config: {}", config_path.display()); - let mut config_content = fs::read_to_string(&config_path) - .await - .with_path("failed to read file", &config_path)?; - - config_content = replace_env_placeholders(&config_content)?; - - let config: UbootConfig = toml::from_str(&config_content) - .with_context(|| format!("failed to parse U-Boot config: {}", config_path.display()))?; - config - } else { - let config = UbootConfig { - serial: "/dev/ttyUSB0".to_string(), - baud_rate: "115200".into(), - ..Default::default() +impl AppContext { + pub async fn run_uboot(self, args: RunUbootArgs) -> anyhow::Result<()> { + let config_path = match args.config.clone() { + Some(path) => path, + None => self.paths.workspace.join(".uboot.toml"), }; - fs::write(&config_path, toml::to_string_pretty(&config)?) - .await - .with_path("failed to write file", &config_path)?; - config - }; - - let baud_rate = config.baud_rate.parse::().with_context(|| { - format!( - "baud_rate is not a valid integer in {}", - config_path.display() - ) - })?; - - let mut runner = Runner { - ctx, - config, - baud_rate, - success_regex: vec![], - fail_regex: vec![], - }; - runner.run().await?; - Ok(()) + let config = if config_path.exists() { + println!("Using U-Boot config: {}", config_path.display()); + let mut config_content = fs::read_to_string(&config_path) + .await + .with_path("failed to read file", &config_path)?; + + config_content = replace_env_placeholders(&config_content)?; + + let config: UbootConfig = toml::from_str(&config_content).with_context(|| { + format!("failed to parse U-Boot config: {}", config_path.display()) + })?; + config + } else { + let config = UbootConfig { + serial: "/dev/ttyUSB0".to_string(), + baud_rate: "115200".into(), + ..Default::default() + }; + + fs::write(&config_path, toml::to_string_pretty(&config)?) + .await + .with_path("failed to write file", &config_path)?; + config + }; + + let baud_rate = config.baud_rate.parse::().with_context(|| { + format!( + "baud_rate is not a valid integer in {}", + config_path.display() + ) + })?; + + let mut runner = Runner { + ctx: self, + config, + baud_rate, + success_regex: vec![], + fail_regex: vec![], + }; + runner.run().await?; + Ok(()) + } } struct Runner { From fc3df7ab1ec2d483031032e666af9f1322c7e568 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E7=9D=BF?= Date: Tue, 24 Mar 2026 08:53:20 +0800 Subject: [PATCH 2/9] Refactor application context to Tool struct - Replaced instances of AppContext with Tool in menuconfig, qemu, tftp, and uboot modules. - Updated method signatures to accept Tool instead of AppContext. - Introduced Tool struct to encapsulate application context and configuration. - Adjusted related functions to utilize Tool's methods for accessing paths and artifacts. - Enhanced tests to accommodate the new Tool structure and ensure functionality remains intact. --- ostool/src/bin/cargo-osrun.rs | 41 +-- ostool/src/build/cargo_builder.rs | 74 ++--- ostool/src/build/mod.rs | 82 +---- ostool/src/ctx.rs | 514 +----------------------------- ostool/src/lib.rs | 2 + ostool/src/main.rs | 48 ++- ostool/src/menuconfig.rs | 29 +- ostool/src/run/qemu.rs | 350 ++++++-------------- ostool/src/run/tftp.rs | 10 +- ostool/src/run/uboot.rs | 28 +- ostool/src/tool.rs | 503 +++++++++++++++++++++++++++++ 11 files changed, 730 insertions(+), 951 deletions(-) create mode 100644 ostool/src/tool.rs diff --git a/ostool/src/bin/cargo-osrun.rs b/ostool/src/bin/cargo-osrun.rs index b2845c3..5f1ae26 100644 --- a/ostool/src/bin/cargo-osrun.rs +++ b/ostool/src/bin/cargo-osrun.rs @@ -7,11 +7,8 @@ use std::{ use clap::{Parser, Subcommand}; use log::{LevelFilter, debug}; use ostool::{ - ctx::{AppContext, OutputConfig, PathConfig}, - run::{ - qemu, - uboot::RunUbootArgs, - }, + Tool, ToolConfig, + run::{qemu, uboot::RunUbootArgs}, }; #[derive(Debug, Parser, Clone)] @@ -112,45 +109,35 @@ async fn try_main() -> anyhow::Result<()> { } let manifest_dir: PathBuf = env::var("CARGO_MANIFEST_DIR")?.into(); - - let workspace_folder = match env::var("WORKSPACE_FOLDER") { - Ok(dir) => PathBuf::from(dir), - Err(_) => manifest_dir.clone(), - }; + let manifest = manifest_dir.join("Cargo.toml"); let bin_dir: Option = args.bin_dir.map(PathBuf::from); let build_dir: Option = args.build_dir.map(PathBuf::from); - let output_config = OutputConfig { build_dir, bin_dir }; - - let mut app = AppContext { - paths: PathConfig { - workspace: workspace_folder, - manifest: manifest_dir, - config: output_config, - ..Default::default() - }, - ..Default::default() - }; + let mut tool = Tool::new(ToolConfig { + manifest: Some(manifest), + build_dir, + bin_dir, + debug: args.debug, + })?; - app.set_elf_path(args.elf).await?; - app.objcopy_elf()?; + tool.set_elf_path(args.elf).await?; + tool.objcopy_elf()?; - app.debug = args.debug; if args.to_bin { - app.objcopy_output_bin()?; + tool.objcopy_output_bin()?; } match args.command { Some(SubCommands::Uboot(_)) => { - app.run_uboot(RunUbootArgs { + tool.run_uboot(RunUbootArgs { config: args.config, show_output: args.show_output, }) .await?; } None => { - app.run_qemu(qemu::RunQemuArgs { + tool.run_qemu(qemu::RunQemuArgs { qemu_config: args.config, dtb_dump: args.dtb_dump, show_output: args.show_output, diff --git a/ostool/src/build/cargo_builder.rs b/ostool/src/build/cargo_builder.rs index 20b5c9b..230c659 100644 --- a/ostool/src/build/cargo_builder.rs +++ b/ostool/src/build/cargo_builder.rs @@ -16,8 +16,8 @@ use cargo_metadata::{Message, PackageId}; use colored::Colorize; use crate::{ + Tool, build::{config::Cargo, someboot}, - ctx::AppContext, utils::{Command, PathResultExt}, }; @@ -37,13 +37,13 @@ struct ResolvedCargoArtifact { /// ```rust,no_run /// use ostool::build::cargo_builder::CargoBuilder; /// use ostool::build::config::Cargo; -/// use ostool::ctx::AppContext; +/// use ostool::Tool; /// -/// // CargoBuilder is typically used internally by AppContext -/// // See AppContext::cargo_build() and AppContext::cargo_run() +/// // CargoBuilder is typically used internally by Tool +/// // See Tool::cargo_build() and Tool::cargo_run() /// ``` pub struct CargoBuilder<'a> { - ctx: &'a mut AppContext, + tool: &'a mut Tool, config: &'a Cargo, command: String, extra_args: Vec, @@ -59,12 +59,12 @@ impl<'a> CargoBuilder<'a> { /// /// # Arguments /// - /// * `ctx` - The application context. + /// * `tool` - The tool context. /// * `config` - The Cargo build configuration. /// * `config_path` - Optional path to the configuration file. - pub fn build(ctx: &'a mut AppContext, config: &'a Cargo, config_path: Option) -> Self { + pub fn build(tool: &'a mut Tool, config: &'a Cargo, config_path: Option) -> Self { Self { - ctx, + tool, config, command: "build".to_string(), extra_args: Vec::new(), @@ -80,12 +80,12 @@ impl<'a> CargoBuilder<'a> { /// /// # Arguments /// - /// * `ctx` - The application context. + /// * `tool` - The tool context. /// * `config` - The Cargo build configuration. /// * `config_path` - Optional path to the configuration file. - pub fn run(ctx: &'a mut AppContext, config: &'a Cargo, config_path: Option) -> Self { + pub fn run(tool: &'a mut Tool, config: &'a Cargo, config_path: Option) -> Self { Self { - ctx, + tool, config, command: "run".to_string(), extra_args: Vec::new(), @@ -106,20 +106,20 @@ impl<'a> CargoBuilder<'a> { /// /// When enabled, builds in debug mode and enables GDB server for QEMU. pub fn debug(self, debug: bool) -> Self { - self.ctx.debug = debug; + self.tool.config.debug = debug; self } /// Creates a build command using the context's stored config path. - pub fn build_auto(ctx: &'a mut AppContext, config: &'a Cargo) -> Self { - let config_path = ctx.build_config_path.clone(); - Self::build(ctx, config, config_path) + pub fn build_auto(tool: &'a mut Tool, config: &'a Cargo) -> Self { + let config_path = tool.ctx.build_config_path.clone(); + Self::build(tool, config, config_path) } /// Creates a run command using the context's stored config path. - pub fn run_auto(ctx: &'a mut AppContext, config: &'a Cargo) -> Self { - let config_path = ctx.build_config_path.clone(); - Self::run(ctx, config, config_path) + pub fn run_auto(tool: &'a mut Tool, config: &'a Cargo) -> Self { + let config_path = tool.ctx.build_config_path.clone(); + Self::run(tool, config, config_path) } /// Adds a single argument to the Cargo command. @@ -182,7 +182,7 @@ impl<'a> CargoBuilder<'a> { fn run_pre_build_cmds(&mut self) -> anyhow::Result<()> { for cmd in &self.config.pre_build_cmds { - self.ctx.shell_run_cmd(cmd)?; + self.tool.shell_run_cmd(cmd)?; } Ok(()) } @@ -270,7 +270,7 @@ impl<'a> CargoBuilder<'a> { } async fn build_cargo_command(&mut self) -> anyhow::Result { - let mut cmd = self.ctx.command("cargo"); + let mut cmd = self.tool.command("cargo"); cmd.arg(&self.command); @@ -297,10 +297,8 @@ impl<'a> CargoBuilder<'a> { cmd.arg("-Z"); cmd.arg("unstable-options"); - if let Some(build_dir) = &self.ctx.paths.config.build_dir { - cmd.arg("--target-dir"); - cmd.arg(build_dir.display().to_string()); - } + cmd.arg("--target-dir"); + cmd.arg(self.tool.build_dir().display().to_string()); // Features let features = self.build_features(); @@ -315,7 +313,7 @@ impl<'a> CargoBuilder<'a> { } // Auto-detected args from someboot/build-info.toml - let workspace_manifest = self.ctx.paths.workspace.join("Cargo.toml"); + let workspace_manifest = self.tool.workspace_dir().join("Cargo.toml"); if workspace_manifest.exists() { let detected_args = someboot::detect_build_config_for_package( &workspace_manifest, @@ -335,7 +333,7 @@ impl<'a> CargoBuilder<'a> { } // Release mode - if !self.ctx.debug { + if !self.tool.debug_enabled() { cmd.arg("--release"); } @@ -347,7 +345,7 @@ impl<'a> CargoBuilder<'a> { cmd.arg(arg); } - if self.is_run() && self.ctx.debug { + if self.is_run() && self.tool.debug_enabled() { cmd.arg("--debug"); } @@ -363,12 +361,12 @@ impl<'a> CargoBuilder<'a> { ) })?; - self.ctx.set_elf_artifact_path(resolved.elf_path).await?; - self.ctx.paths.artifacts.cargo_artifact_dir = Some(resolved.cargo_artifact_dir.clone()); - self.ctx.paths.artifacts.runtime_artifact_dir = Some(resolved.cargo_artifact_dir); + self.tool.set_elf_artifact_path(resolved.elf_path).await?; + self.tool.ctx.artifacts.cargo_artifact_dir = Some(resolved.cargo_artifact_dir.clone()); + self.tool.ctx.artifacts.runtime_artifact_dir = Some(resolved.cargo_artifact_dir); if self.config.to_bin && !self.skip_objcopy { - self.ctx.objcopy_output_bin()?; + self.tool.objcopy_output_bin()?; } Ok(()) @@ -376,13 +374,13 @@ impl<'a> CargoBuilder<'a> { fn run_post_build_cmds(&mut self) -> anyhow::Result<()> { for cmd in &self.config.post_build_cmds { - self.ctx.shell_run_cmd(cmd)?; + self.tool.shell_run_cmd(cmd)?; } Ok(()) } fn target_package_info(&self) -> anyhow::Result<(PackageId, Option)> { - let metadata = self.ctx.metadata()?; + let metadata = self.tool.metadata()?; let Some(package) = metadata .packages .iter() @@ -391,7 +389,7 @@ impl<'a> CargoBuilder<'a> { bail!( "package '{}' not found in cargo metadata under {}", self.config.package, - self.ctx.paths.manifest.display() + self.tool.manifest_dir().display() ); }; Ok((package.id.clone(), package.default_run.clone())) @@ -429,7 +427,7 @@ impl<'a> CargoBuilder<'a> { fn log_level_feature(&self) -> Option { let level = self.config.log.clone()?; - let meta = self.ctx.metadata().ok()?; + let meta = self.tool.metadata().ok()?; let pkg = meta .packages .iter() @@ -440,7 +438,11 @@ impl<'a> CargoBuilder<'a> { if has_log { Some(format!( "log/{}max_level_{}", - if self.ctx.debug { "" } else { "release_" }, + if self.tool.debug_enabled() { + "" + } else { + "release_" + }, format!("{:?}", level).to_lowercase() )) } else { diff --git a/ostool/src/build/mod.rs b/ostool/src/build/mod.rs index effeb87..6714b7c 100644 --- a/ostool/src/build/mod.rs +++ b/ostool/src/build/mod.rs @@ -12,7 +12,7 @@ //! //! ```rust,no_run //! use ostool::build::config::{BuildConfig, BuildSystem, Cargo}; -//! use ostool::ctx::AppContext; +//! use ostool::Tool; //! //! // Build configurations are typically loaded from TOML files //! // See .build.toml for example configuration format @@ -20,16 +20,13 @@ use std::path::PathBuf; -use anyhow::Context; - use crate::{ + Tool, build::{ cargo_builder::CargoBuilder, config::{Cargo, Custom}, }, - ctx::AppContext, run::{qemu::RunQemuArgs, uboot::RunUbootArgs}, - utils::PathResultExt, }; /// Cargo builder implementation for building projects. @@ -61,7 +58,7 @@ pub enum CargoRunnerKind { }, } -impl AppContext { +impl Tool { /// Builds the project using the specified build configuration. /// /// # Arguments @@ -145,54 +142,7 @@ impl AppContext { config: &Cargo, runner: &CargoRunnerKind, ) -> anyhow::Result<()> { - let build_config_path = self.build_config_path.clone(); - - let normalize = |dir: &PathBuf| -> anyhow::Result { - let bin_path = if dir.is_relative() { - self.paths.manifest.join(dir) - } else { - dir.clone() - }; - - match bin_path.canonicalize() { - Ok(path) => Ok(path), - Err(file_err) => { - let Some(parent) = bin_path.parent() else { - return Err(file_err).with_path("failed to canonicalize path", &bin_path); - }; - let Some(file_name) = bin_path.file_name() else { - return Err(file_err).with_path("failed to canonicalize path", &bin_path); - }; - - parent - .canonicalize() - .map(|parent_dir| parent_dir.join(file_name)) - .with_path("failed to canonicalize parent path", parent) - .with_context(|| { - format!("failed to normalize path: {}", bin_path.display()) - }) - } - } - }; - - let build_dir = self - .paths - .config - .build_dir - .as_ref() - .map(&normalize) - .transpose()?; - - let bin_dir = self - .paths - .config - .bin_dir - .as_ref() - .map(normalize) - .transpose()?; - - self.paths.config.build_dir = build_dir; - self.paths.config.bin_dir = bin_dir; + let build_config_path = self.ctx.build_config_path.clone(); let debug = matches!(runner, CargoRunnerKind::Qemu { debug: true, .. }); @@ -209,21 +159,19 @@ impl AppContext { dtb_dump, .. } => { - self.clone() - .run_qemu(RunQemuArgs { - qemu_config: qemu_config.clone(), - dtb_dump: *dtb_dump, - show_output: true, - }) - .await?; + self.run_qemu(RunQemuArgs { + qemu_config: qemu_config.clone(), + dtb_dump: *dtb_dump, + show_output: true, + }) + .await?; } CargoRunnerKind::Uboot { uboot_config } => { - self.clone() - .run_uboot(RunUbootArgs { - config: uboot_config.clone(), - show_output: true, - }) - .await?; + self.run_uboot(RunUbootArgs { + config: uboot_config.clone(), + show_output: true, + }) + .await?; } } diff --git a/ostool/src/ctx.rs b/ostool/src/ctx.rs index fceebb5..01a9683 100644 --- a/ostool/src/ctx.rs +++ b/ostool/src/ctx.rs @@ -1,45 +1,16 @@ -//! Application context and state management. +//! Application context and runtime state. //! -//! This module provides the [`AppContext`] type which holds the global state -//! for the ostool application, including paths, build configuration, and -//! architecture information. +//! This module provides the [`AppContext`] type which stores runtime state +//! and build artifacts produced while ostool is operating. -use std::{path::PathBuf, sync::Arc}; +use std::path::PathBuf; -use anyhow::{Context, anyhow}; -use cargo_metadata::Metadata; -use colored::Colorize; -use cursive::Cursive; -use jkconfig::{ - ElemHock, - data::{app_data::AppData, item::ItemType, types::ElementType}, - ui::components::editors::{show_feature_select, show_list_select}, -}; +use object::Architecture; -use object::{Architecture, Object}; -use tokio::fs; - -use crate::{ - build::{ - config::{BuildConfig, BuildSystem, Cargo}, - someboot, - }, - utils::PathResultExt, -}; - -/// Configuration for output directories. -/// -/// Specifies where build outputs should be placed. -#[derive(Default, Clone)] -pub struct OutputConfig { - /// Custom build directory (overrides default `target/`). - pub build_dir: Option, - /// Custom binary output directory. - pub bin_dir: Option, -} +use crate::build::config::BuildConfig; /// Build artifacts generated during the build process. -#[derive(Default, Clone)] +#[derive(Default, Clone, Debug)] pub struct OutputArtifacts { /// Path to the built ELF file. pub elf: Option, @@ -51,478 +22,15 @@ pub struct OutputArtifacts { pub runtime_artifact_dir: Option, } -/// Path configuration grouping all path-related fields. -#[derive(Default, Clone)] -pub struct PathConfig { - /// Workspace root directory. - pub workspace: PathBuf, - /// Cargo manifest directory. - pub manifest: PathBuf, - /// Output directory configuration. - pub config: OutputConfig, - /// Generated build artifacts. - pub artifacts: OutputArtifacts, -} - -impl PathConfig { - /// Gets the build directory. - /// - /// Returns the configured build directory, or defaults to `manifest/target`. - pub fn build_dir(&self) -> PathBuf { - self.config - .build_dir - .clone() - .unwrap_or_else(|| self.manifest.join("target")) - } - - /// Gets the binary output directory if configured. - pub fn bin_dir(&self) -> Option { - self.config.bin_dir.clone() - } -} - -/// The main application context holding all state. -/// -/// `AppContext` is the central state container for ostool operations. -/// It manages paths, build configuration, architecture detection, and -/// provides methods for building and running OS projects. -#[derive(Default, Clone)] +/// The runtime context holding transient and final execution state. +#[derive(Default, Clone, Debug)] pub struct AppContext { - /// Path configuration for workspace, manifest, and outputs. - pub paths: PathConfig, - /// Whether debug mode is enabled. - pub debug: bool, /// Detected CPU architecture from the ELF file. pub arch: Option, /// Current build configuration. pub build_config: Option, /// Path to the build configuration file. pub build_config_path: Option, - /// 可选的配置文件搜索目录,设置后优先于 workspace/manifest 进行配置发现 - pub config_search_dir: Option, -} - -impl AppContext { - /// Executes a shell command in the current context. - /// - /// The command is run in the manifest directory with the `KERNEL_ELF` - /// environment variable set if an ELF artifact is available. - /// - /// # Arguments - /// - /// * `cmd` - The shell command to execute. - /// - /// # Errors - /// - /// Returns an error if the command fails to execute. - pub fn shell_run_cmd(&self, cmd: &str) -> anyhow::Result<()> { - let mut command = match std::env::consts::OS { - "windows" => { - let mut command = self.command("powershell"); - command.arg("-Command"); - command - } - _ => { - let mut command = self.command("sh"); - command.arg("-c"); - command - } - }; - - command.arg(cmd); - - if let Some(elf) = &self.paths.artifacts.elf { - command.env("KERNEL_ELF", elf.display().to_string()); - } - - command.run()?; - - Ok(()) - } - - /// Creates a new command builder for the given program. - /// - /// The command is configured to run in the manifest directory with - /// variable substitution support. - pub fn command(&self, program: &str) -> crate::utils::Command { - let this = self.clone(); - crate::utils::Command::new(program, &self.paths.manifest, move |s| { - this.value_replace_with_var(s) - }) - } - - /// Gets the Cargo metadata for the current workspace. - /// - /// # Errors - /// - /// Returns an error if `cargo metadata` fails. - pub fn metadata(&self) -> anyhow::Result { - let res = cargo_metadata::MetadataCommand::new() - .current_dir(&self.paths.manifest) - .no_deps() - .exec() - .with_context(|| { - format!( - "failed to load cargo metadata from {}", - self.paths.manifest.display() - ) - })?; - Ok(res) - } - - /// Sets the ELF artifact path and synchronizes derived runtime metadata. - /// - /// This also reads the ELF file to detect the target CPU architecture. - pub async fn set_elf_artifact_path(&mut self, path: PathBuf) -> anyhow::Result<()> { - let path = path - .canonicalize() - .with_path("failed to canonicalize file", &path)?; - let artifact_dir = path - .parent() - .ok_or_else(|| anyhow!("invalid ELF file path: {}", path.display()))? - .to_path_buf(); - - self.paths.artifacts.elf = Some(path.clone()); - self.paths.artifacts.bin = None; - self.paths.artifacts.cargo_artifact_dir = Some(artifact_dir.clone()); - self.paths.artifacts.runtime_artifact_dir = Some(artifact_dir); - - let binary_data = fs::read(&path) - .await - .with_path("failed to read ELF file", &path)?; - let file = object::File::parse(binary_data.as_slice()) - .with_context(|| format!("failed to parse ELF file: {}", path.display()))?; - self.arch = Some(file.architecture()); - Ok(()) - } - - /// Sets the ELF file path and detects its architecture. - /// - /// This compatibility wrapper keeps older call sites working while also - /// updating artifact directories in the application context. - pub async fn set_elf_path(&mut self, path: PathBuf) -> anyhow::Result<()> { - self.set_elf_artifact_path(path).await - } - - /// Strips debug symbols from the ELF file. - /// - /// Creates a new `.elf` file with debug symbols stripped using `rust-objcopy`. - /// - /// # Returns - /// - /// Returns the path to the stripped ELF file. - /// - /// # Errors - /// - /// Returns an error if no ELF file is set or `rust-objcopy` fails. - pub fn objcopy_elf(&mut self) -> anyhow::Result { - let elf_path = self - .paths - .artifacts - .elf - .as_ref() - .ok_or(anyhow!("elf not exist"))?; - let elf_path = elf_path - .canonicalize() - .with_path("failed to canonicalize file", elf_path)?; - - let stripped_elf_path = elf_path.with_file_name( - elf_path - .file_stem() - .ok_or_else(|| anyhow!("invalid ELF file path: {}", elf_path.display()))? - .to_string_lossy() - .to_string() - + ".elf", - ); - println!( - "{}", - format!( - "Stripping ELF file...\r\n original elf: {}\r\n stripped elf: {}", - elf_path.display(), - stripped_elf_path.display() - ) - .bold() - .purple() - ); - - let mut objcopy = self.command("rust-objcopy"); - - objcopy.arg(format!( - "--binary-architecture={}", - format!("{:?}", self.arch.unwrap()).to_lowercase() - )); - objcopy.arg(&elf_path); - objcopy.arg(&stripped_elf_path); - - objcopy.run()?; - self.paths.artifacts.elf = Some(stripped_elf_path.clone()); - self.paths.artifacts.bin = None; - self.paths.artifacts.cargo_artifact_dir = stripped_elf_path.parent().map(PathBuf::from); - self.paths.artifacts.runtime_artifact_dir = stripped_elf_path.parent().map(PathBuf::from); - - Ok(stripped_elf_path) - } - - /// Converts the ELF file to raw binary format. - /// - /// Uses `rust-objcopy` to convert the ELF file to a flat binary file - /// suitable for direct loading by bootloaders. - /// - /// # Returns - /// - /// Returns the path to the generated binary file. - /// - /// # Errors - /// - /// Returns an error if no ELF file is set or `rust-objcopy` fails. - pub fn objcopy_output_bin(&mut self) -> anyhow::Result { - if let Some(bin) = &self.paths.artifacts.bin { - debug!("BIN file already exists: {:?}", bin); - return Ok(bin.clone()); - } - - let elf_path = self - .paths - .artifacts - .elf - .as_ref() - .ok_or(anyhow!("elf not exist"))?; - let elf_path = elf_path - .canonicalize() - .with_path("failed to canonicalize file", elf_path)?; - - let bin_name = elf_path - .file_stem() - .ok_or_else(|| anyhow!("invalid ELF file path: {}", elf_path.display()))? - .to_string_lossy() - .to_string() - + ".bin"; - - let bin_path = if let Some(bin_dir) = self.paths.config.bin_dir.clone() { - bin_dir.join(bin_name) - } else { - elf_path.with_file_name(bin_name) - }; - - if let Some(parent) = bin_path.parent() { - std::fs::create_dir_all(parent).with_path("failed to create directory", parent)?; - } - - println!( - "{}", - format!( - "Converting ELF to BIN format...\r\n elf: {}\r\n bin: {}", - elf_path.display(), - bin_path.display() - ) - .bold() - .purple() - ); - - let mut objcopy = self.command("rust-objcopy"); - - if !self.debug { - objcopy.arg("--strip-all"); - } - - objcopy - .arg("-O") - .arg("binary") - .arg(&elf_path) - .arg(&bin_path); - - objcopy.run()?; - self.paths.artifacts.bin = Some(bin_path.clone()); - self.paths.artifacts.runtime_artifact_dir = bin_path.parent().map(PathBuf::from); - - Ok(bin_path) - } - - /// Resolves the build configuration file path with search priority. - /// - /// Configuration search priority: - /// 1. Explicit path (if provided) - /// 2. config_search_dir/.build.toml (if set and exists) - /// 3. workspace/.build.toml (fallback) - /// - /// This function is used internally and for testing build config path resolution. - pub(crate) fn resolve_build_config_path(&self, explicit_path: Option) -> PathBuf { - match explicit_path { - Some(path) => path, // 显式路径优先级最高 - None => { - // 先搜索 config_search_dir,再搜索 workspace - if let Some(ref search_dir) = self.config_search_dir { - let search_path = search_dir.join(".build.toml"); - if search_path.exists() { - search_path - } else { - self.paths.workspace.join(".build.toml") - } - } else { - self.paths.workspace.join(".build.toml") - } - } - } - } - - /// Loads and prepares the build configuration. - /// - /// This method loads the build configuration from a TOML file. If `menu` is - /// true, an interactive TUI is shown for configuration editing. - /// - /// # Arguments - /// - /// * `config_path` - Optional path to the configuration file. Defaults to - /// `.build.toml` in the workspace directory. - /// * `menu` - If true, shows an interactive configuration menu. - /// - /// # Errors - /// - /// Returns an error if the configuration file cannot be loaded or parsed. - pub async fn prepare_build_config( - &mut self, - config_path: Option, - menu: bool, - ) -> anyhow::Result { - let config_path = self.resolve_build_config_path(config_path); - self.build_config_path = Some(config_path.clone()); - - let Some(mut c): Option = jkconfig::run( - config_path.clone(), - menu, - &[self.ui_hock_feature_select(), self.ui_hock_pacage_select()], - ) - .await - .with_context(|| format!("failed to load build config: {}", config_path.display()))? - else { - anyhow::bail!("No build configuration obtained"); - }; - - if let BuildSystem::Cargo(cargo) = &mut c.system { - let iter = self.someboot_cargo_args(cargo)?.into_iter(); - cargo.args.extend(iter); - } - - self.build_config = Some(c.clone()); - Ok(c) - } - - fn someboot_cargo_args(&self, cargo: &Cargo) -> anyhow::Result> { - let manifest_path = self.paths.manifest.join("Cargo.toml"); - let target = &cargo.target; - someboot::detect_build_config_for_package( - &manifest_path, - &cargo.package, - &cargo.features, - target, - ) - } - - /// Replaces variable placeholders in a string. - /// - /// Currently supports `${workspaceFolder}` which is replaced with the - /// workspace directory path. - pub fn value_replace_with_var(&self, value: S) -> String - where - S: AsRef, - { - let raw = value.as_ref().to_string_lossy(); - raw.replace( - "${workspaceFolder}", - format!("{}", self.paths.workspace.display()).as_ref(), - ) - } - - /// Returns UI hooks for the configuration editor. - /// - /// These hooks provide interactive selection dialogs for features and packages. - pub fn ui_hocks(&self) -> Vec { - vec![self.ui_hock_feature_select(), self.ui_hock_pacage_select()] - } - - fn ui_hock_feature_select(&self) -> ElemHock { - let path = "system.features"; - let cargo_toml = self.paths.workspace.join("Cargo.toml"); - ElemHock { - path: path.to_string(), - callback: Arc::new(move |siv: &mut Cursive, _path: &str| { - let mut package = String::new(); - if let Some(app) = siv.user_data::() - && let Some(pkg) = app.root.get_by_key("system.package") - && let ElementType::Item(item) = pkg - && let ItemType::String { value: Some(v), .. } = &item.item_type - { - package = v.clone(); - } - - // 调用显示特性选择对话框的函数 - show_feature_select(siv, &package, &cargo_toml, None); - }), - } - } - - fn ui_hock_pacage_select(&self) -> ElemHock { - let path = "system.package"; - let cargo_toml = self.paths.workspace.join("Cargo.toml"); - - ElemHock { - path: path.to_string(), - callback: Arc::new(move |siv: &mut Cursive, path: &str| { - let mut items = Vec::new(); - if let Ok(metadata) = cargo_metadata::MetadataCommand::new() - .manifest_path(&cargo_toml) - .no_deps() - .exec() - { - for pkg in &metadata.packages { - items.push(pkg.name.to_string()); - } - } - - // 调用显示包选择对话框的函数 - show_list_select(siv, "Pacage", &items, path, on_package_selected); - }), - } - } -} - -fn on_package_selected(app: &mut AppData, path: &str, selected: &str) { - let ElementType::Item(item) = app.root.get_mut_by_key(path).unwrap() else { - panic!("Not an item element"); - }; - let ItemType::String { value, .. } = &mut item.item_type else { - panic!("Not a string item"); - }; - *value = Some(selected.to_string()); -} - -#[cfg(test)] -mod tests { - use super::AppContext; - - #[tokio::test] - async fn set_elf_artifact_path_updates_dirs_and_arch() { - let temp = tempfile::tempdir().unwrap(); - let source = std::env::current_exe().unwrap(); - let copied = temp.path().join("sample-elf"); - std::fs::copy(&source, &copied).unwrap(); - - let mut ctx = AppContext::default(); - ctx.set_elf_artifact_path(copied.clone()).await.unwrap(); - - let expected_elf = copied.canonicalize().unwrap(); - let expected_dir = expected_elf.parent().unwrap().to_path_buf(); - - assert_eq!(ctx.paths.artifacts.elf.as_ref(), Some(&expected_elf)); - assert_eq!( - ctx.paths.artifacts.cargo_artifact_dir.as_ref(), - Some(&expected_dir) - ); - assert_eq!( - ctx.paths.artifacts.runtime_artifact_dir.as_ref(), - Some(&expected_dir) - ); - assert!(ctx.arch.is_some()); - assert!(ctx.paths.artifacts.bin.is_none()); - } + /// Generated build artifacts. + pub artifacts: OutputArtifacts, } diff --git a/ostool/src/lib.rs b/ostool/src/lib.rs index d3071f6..a2a45b4 100644 --- a/ostool/src/lib.rs +++ b/ostool/src/lib.rs @@ -40,6 +40,7 @@ pub mod build; /// Application context and state management. pub mod ctx; +mod tool; /// TUI-based menu configuration system. /// @@ -68,3 +69,4 @@ extern crate log; extern crate anyhow; pub use jkconfig::cursive; +pub use tool::{Tool, ToolConfig}; diff --git a/ostool/src/main.rs b/ostool/src/main.rs index 434caa8..8356b0d 100644 --- a/ostool/src/main.rs +++ b/ostool/src/main.rs @@ -1,12 +1,12 @@ -use std::{env::current_dir, path::PathBuf, process::ExitCode}; +use std::{path::PathBuf, process::ExitCode}; -use anyhow::{Context, Result}; +use anyhow::Result; use clap::*; use log::info; use ostool::{ + Tool, ToolConfig, build::{self, CargoRunnerKind}, - ctx::AppContext, menuconfig::{MenuConfigHandler, MenuConfigMode}, run::{qemu::RunQemuArgs, uboot::RunUbootArgs}, }; @@ -15,7 +15,7 @@ use ostool::{ #[command(version, about, long_about = None)] struct Cli { #[arg(short, long)] - workdir: Option, + manifest: Option, #[command(subcommand)] command: SubCommands, } @@ -96,28 +96,17 @@ async fn try_main() -> Result<()> { let cli = Cli::parse(); - let pwd = current_dir().context("failed to get current working directory")?; - - let workspace_folder = match cli.workdir { - Some(dir) => dir, - None => pwd.clone(), - }; - - let mut ctx = AppContext { - paths: ostool::ctx::PathConfig { - workspace: workspace_folder.clone(), - manifest: workspace_folder.clone(), - ..Default::default() - }, + let mut tool = Tool::new(ToolConfig { + manifest: cli.manifest, ..Default::default() - }; + })?; match cli.command { SubCommands::Build { config } => { - ctx.build(config).await?; + tool.build(config).await?; } SubCommands::Run(args) => { - let config = ctx.prepare_build_config(args.config, false).await?; + let config = tool.prepare_build_config(args.config, false).await?; match config.system { build::config::BuildSystem::Cargo(config) => { let kind = match args.command { @@ -130,24 +119,25 @@ async fn try_main() -> Result<()> { uboot_config: uboot_args.uboot_config, }, }; - ctx.cargo_run(&config, &kind).await?; + tool.cargo_run(&config, &kind).await?; } build::config::BuildSystem::Custom(custom_cfg) => { - ctx.shell_run_cmd(&custom_cfg.build_cmd)?; - ctx.set_elf_path(custom_cfg.elf_path.clone().into()).await?; + tool.shell_run_cmd(&custom_cfg.build_cmd)?; + tool.set_elf_path(custom_cfg.elf_path.clone().into()) + .await?; info!( "ELF {:?}: {}", - ctx.arch, - ctx.paths.artifacts.elf.as_ref().unwrap().display() + tool.ctx().arch, + tool.ctx().artifacts.elf.as_ref().unwrap().display() ); if custom_cfg.to_bin { - ctx.objcopy_output_bin()?; + tool.objcopy_output_bin()?; } match args.command { RunSubCommands::Qemu(qemu_args) => { - ctx.run_qemu(RunQemuArgs { + tool.run_qemu(RunQemuArgs { qemu_config: qemu_args.qemu_config, dtb_dump: qemu_args.dtb_dump, show_output: true, @@ -155,7 +145,7 @@ async fn try_main() -> Result<()> { .await?; } RunSubCommands::Uboot(uboot_args) => { - ctx.run_uboot(RunUbootArgs { + tool.run_uboot(RunUbootArgs { config: uboot_args.uboot_config, show_output: true, }) @@ -166,7 +156,7 @@ async fn try_main() -> Result<()> { } } SubCommands::Menuconfig { mode } => { - MenuConfigHandler::handle_menuconfig(&mut ctx, mode).await?; + MenuConfigHandler::handle_menuconfig(&mut tool, mode).await?; } } diff --git a/ostool/src/menuconfig.rs b/ostool/src/menuconfig.rs index 3745c4d..9ab852d 100644 --- a/ostool/src/menuconfig.rs +++ b/ostool/src/menuconfig.rs @@ -14,7 +14,7 @@ use clap::ValueEnum; use log::info; use tokio::fs; -use crate::ctx::AppContext; +use crate::Tool; use crate::run::qemu::QemuConfig; use crate::run::uboot::UbootConfig; use crate::utils::PathResultExt; @@ -36,43 +36,38 @@ impl MenuConfigHandler { /// /// # Arguments /// - /// * `ctx` - The application context. + /// * `tool` - The tool instance. /// * `mode` - Optional mode specifying which configuration to edit. /// If `None`, shows the default build configuration menu. /// /// # Errors /// /// Returns an error if the configuration cannot be loaded or saved. - pub async fn handle_menuconfig( - ctx: &mut AppContext, - mode: Option, - ) -> Result<()> { + pub async fn handle_menuconfig(tool: &mut Tool, mode: Option) -> Result<()> { match mode { Some(MenuConfigMode::Qemu) => { - Self::handle_qemu_config(ctx).await?; + Self::handle_qemu_config(tool).await?; } Some(MenuConfigMode::Uboot) => { - Self::handle_uboot_config(ctx).await?; + Self::handle_uboot_config(tool).await?; } None => { - // 默认模式:显示当前构建配置 - Self::handle_default_config(ctx).await?; + Self::handle_default_config(tool).await?; } } Ok(()) } - async fn handle_default_config(ctx: &mut AppContext) -> Result<()> { - ctx.prepare_build_config(None, true).await?; + async fn handle_default_config(tool: &mut Tool) -> Result<()> { + tool.prepare_build_config(None, true).await?; Ok(()) } - async fn handle_qemu_config(ctx: &mut AppContext) -> Result<()> { + async fn handle_qemu_config(tool: &mut Tool) -> Result<()> { info!("配置 QEMU 运行参数"); - // 使用来自 qemu 模块的共享解析器 - let config_path = crate::run::qemu::resolve_qemu_config_path(ctx, None)?; + let config_path = crate::run::qemu::resolve_qemu_config_path(tool, None)?; if config_path.exists() { println!("\n当前 QEMU 配置文件: {}", config_path.display()); @@ -96,13 +91,13 @@ impl MenuConfigHandler { Ok(()) } - async fn handle_uboot_config(ctx: &mut AppContext) -> Result<()> { + async fn handle_uboot_config(tool: &mut Tool) -> Result<()> { info!("配置 U-Boot 运行参数"); println!("=== U-Boot 配置模式 ==="); // 检查是否存在 U-Boot 配置文件 - let uboot_config_path = ctx.paths.workspace.join(".uboot.toml"); + let uboot_config_path = tool.workspace_dir().join(".uboot.toml"); if uboot_config_path.exists() { println!("\n当前 U-Boot 配置文件: {}", uboot_config_path.display()); // 这里可以读取并显示当前的 U-Boot 配置 diff --git a/ostool/src/run/qemu.rs b/ostool/src/run/qemu.rs index 683d406..2f9355b 100644 --- a/ostool/src/run/qemu.rs +++ b/ostool/src/run/qemu.rs @@ -40,7 +40,7 @@ use serde::{Deserialize, Serialize}; use tokio::fs; use crate::{ - ctx::AppContext, + Tool, run::{ output_matcher::{ByteStreamMatcher, StreamMatch, StreamMatchKind}, ovmf_prebuilt::{Arch, FileType, Prebuilt, Source}, @@ -98,20 +98,20 @@ struct QemuDefaultOverrides { /// /// # Arguments /// -/// * `ctx` - The application context containing paths and build artifacts. +/// * `tool` - The tool containing paths and build artifacts. /// * `args` - QEMU run arguments. /// /// # Errors /// /// Returns an error if QEMU fails to start or exits with an error. -impl AppContext { - pub async fn run_qemu(self, args: RunQemuArgs) -> anyhow::Result<()> { +impl Tool { + pub async fn run_qemu(&mut self, args: RunQemuArgs) -> anyhow::Result<()> { self.run_qemu_with_more_default_args(args, vec![], vec![], vec![]) .await } pub async fn run_qemu_with_more_default_args( - self, + &mut self, run_args: RunQemuArgs, args: Vec, success_regex: Vec, @@ -131,20 +131,20 @@ impl AppContext { } async fn run_qemu_with_defaults( - ctx: AppContext, + tool: &mut Tool, run_args: RunQemuArgs, overrides: QemuDefaultOverrides, ) -> anyhow::Result<()> { - let config = load_or_create_qemu_config(&ctx, run_args.qemu_config.clone(), overrides).await?; - run_qemu_with_config(ctx, run_args, config).await + let config = load_or_create_qemu_config(tool, run_args.qemu_config.clone(), overrides).await?; + run_qemu_with_config(tool, run_args, config).await } async fn load_or_create_qemu_config( - ctx: &AppContext, + tool: &Tool, explicit_config_path: Option, overrides: QemuDefaultOverrides, ) -> anyhow::Result { - let config_path = resolve_qemu_config_path(ctx, explicit_config_path)?; + let config_path = resolve_qemu_config_path(tool, explicit_config_path)?; info!("Using QEMU config file: {}", config_path.display()); @@ -157,7 +157,7 @@ async fn load_or_create_qemu_config( return Ok(config); } - let config = build_default_qemu_config(ctx.arch, overrides); + let config = build_default_qemu_config(tool.ctx.arch, overrides); fs::write(&config_path, toml::to_string_pretty(&config)?) .await .with_path("failed to write file", &config_path)?; @@ -193,12 +193,12 @@ fn build_default_qemu_config( } async fn run_qemu_with_config( - ctx: AppContext, + tool: &mut Tool, run_args: RunQemuArgs, config: QemuConfig, ) -> anyhow::Result<()> { let mut runner = QemuRunner { - ctx, + tool, config, args: vec![], dtbdump: run_args.dtb_dump, @@ -208,8 +208,8 @@ async fn run_qemu_with_config( runner.run().await } -struct QemuRunner { - ctx: AppContext, +struct QemuRunner<'a> { + tool: &'a mut Tool, config: QemuConfig, args: Vec, dtbdump: bool, @@ -217,15 +217,15 @@ struct QemuRunner { fail_regex: Vec, } -impl QemuRunner { +impl QemuRunner<'_> { async fn run(&mut self) -> anyhow::Result<()> { self.preper_regex()?; if self.config.to_bin { - self.ctx.objcopy_output_bin()?; + self.tool.objcopy_output_bin()?; } - let detected_arch = self.ctx.arch.ok_or_else(|| { + let detected_arch = self.tool.ctx.arch.ok_or_else(|| { anyhow!("Please specify `arch` in QEMU config or provide a valid ELF file.") })?; let arch = format!("{detected_arch:?}").to_lowercase(); @@ -262,7 +262,7 @@ impl QemuRunner { } } - let mut cmd = self.ctx.command(&qemu_executable); + let mut cmd = self.tool.command(&qemu_executable); for arg in &self.config.args { cmd.arg(arg); @@ -284,7 +284,7 @@ impl QemuRunner { cmd.arg("-machine").arg(machine); } - if self.ctx.debug { + if self.tool.debug_enabled() { cmd.arg("-s").arg("-S"); } @@ -312,9 +312,9 @@ impl QemuRunner { } if use_kernel_loader { - if let Some(bin_path) = &self.ctx.paths.artifacts.bin { + if let Some(bin_path) = &self.tool.ctx.artifacts.bin { cmd.arg("-kernel").arg(bin_path); - } else if let Some(elf_path) = &self.ctx.paths.artifacts.elf { + } else if let Some(elf_path) = &self.tool.ctx.artifacts.elf { cmd.arg("-kernel").arg(elf_path); } } @@ -346,7 +346,7 @@ impl QemuRunner { } let arch = - self.ctx.arch.as_ref().ok_or_else(|| { + self.tool.ctx.arch.as_ref().ok_or_else(|| { anyhow::anyhow!("Cannot determine architecture for OVMF preparation") })?; let tmp = std::env::temp_dir(); @@ -381,8 +381,8 @@ impl QemuRunner { async fn prepare_uefi_esp(&self, arch: Arch) -> anyhow::Result { let bin_path = self + .tool .ctx - .paths .artifacts .bin .as_ref() @@ -410,7 +410,7 @@ impl QemuRunner { } fn uefi_artifact_dir(&self, bin_path: &Path) -> anyhow::Result { - if let Some(dir) = &self.ctx.paths.artifacts.runtime_artifact_dir { + if let Some(dir) = &self.tool.ctx.artifacts.runtime_artifact_dir { return Ok(dir.clone()); } @@ -425,8 +425,8 @@ impl QemuRunner { async fn prepare_uefi_vars(&self, vars_template: &Path) -> anyhow::Result { let bin_path = self + .tool .ctx - .paths .artifacts .bin .as_ref() @@ -579,20 +579,18 @@ impl QemuRunner { /// /// Configuration search priority: /// 1. Explicit path (if provided) -/// 2. config_search_dir (if set): qemu-.toml → .qemu-.toml → qemu.toml → .qemu.toml -/// 3. paths.manifest: qemu-.toml → .qemu-.toml → qemu.toml → .qemu.toml +/// 2. workspace_dir: qemu-.toml → .qemu-.toml → qemu.toml → .qemu.toml /// /// When architecture is detected, architecture-specific files are checked first. pub(crate) fn resolve_qemu_config_path( - ctx: &AppContext, + tool: &Tool, explicit_path: Option, ) -> anyhow::Result { - // 优先级 1: 显式路径 if let Some(path) = explicit_path { return Ok(path); } - let arch_str = ctx.arch.map(|arch| format!("{arch:?}").to_lowercase()); + let arch_str = tool.ctx.arch.map(|arch| format!("{arch:?}").to_lowercase()); // 文件名优先级顺序 let candidates: Vec = if let Some(ref arch) = arch_str { @@ -606,36 +604,20 @@ pub(crate) fn resolve_qemu_config_path( vec!["qemu.toml".to_string(), ".qemu.toml".to_string()] }; - // 优先级 2: 搜索 config_search_dir - if let Some(ref search_dir) = ctx.config_search_dir { - for filename in &candidates { - let path = search_dir.join(filename); - if path.exists() { - return Ok(path); - } - } - } - - // 优先级 3: 搜索 paths.manifest for filename in &candidates { - let path = ctx.paths.manifest.join(filename); + let path = tool.workspace_dir().join(filename); if path.exists() { return Ok(path); } } - // 优先级 4: 返回默认创建路径 let default_filename = if let Some(ref arch) = arch_str { format!(".qemu-{}.toml", arch) } else { ".qemu.toml".to_string() }; - if let Some(ref search_dir) = ctx.config_search_dir { - Ok(search_dir.join(default_filename)) - } else { - Ok(ctx.paths.manifest.join(default_filename)) - } + Ok(tool.workspace_dir().join(default_filename)) } #[cfg(test)] @@ -648,7 +630,25 @@ mod tests { use std::path::PathBuf; use tempfile::TempDir; - use crate::ctx::{AppContext, PathConfig}; + use crate::{Tool, ToolConfig}; + + fn write_single_crate_manifest(dir: &std::path::Path) { + std::fs::write( + dir.join("Cargo.toml"), + "[package]\nname = \"sample\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + ) + .unwrap(); + std::fs::create_dir_all(dir.join("src")).unwrap(); + std::fs::write(dir.join("src/lib.rs"), "").unwrap(); + } + + fn make_tool(dir: &std::path::Path) -> Tool { + Tool::new(ToolConfig { + manifest: Some(dir.to_path_buf()), + ..Default::default() + }) + .unwrap() + } #[test] fn default_qemu_config_keeps_existing_defaults_without_overrides() { @@ -695,11 +695,13 @@ mod tests { #[test] fn uefi_artifact_dir_prefers_runtime_artifact_dir() { let runtime_dir = PathBuf::from("/tmp/ostool-runtime"); - let mut ctx = AppContext::default(); - ctx.paths.artifacts.runtime_artifact_dir = Some(runtime_dir.clone()); + let tmp = TempDir::new().unwrap(); + write_single_crate_manifest(tmp.path()); + let mut tool = make_tool(tmp.path()); + tool.ctx.artifacts.runtime_artifact_dir = Some(runtime_dir.clone()); let runner = QemuRunner { - ctx, + tool: &mut tool, config: QemuConfig::default(), args: vec![], dtbdump: false, @@ -718,253 +720,95 @@ mod tests { #[test] fn qemu_config_explicit_path_wins() { let tmp = TempDir::new().unwrap(); - let workspace = tmp.path().to_path_buf(); - let manifest = workspace.join("manifest"); - std::fs::create_dir(&manifest).unwrap(); - let search_dir = workspace.join("config"); - std::fs::create_dir(&search_dir).unwrap(); - - // 创建多个配置文件 - std::fs::write(search_dir.join(".qemu.toml"), "").unwrap(); - std::fs::write(manifest.join(".qemu.toml"), "").unwrap(); - - let ctx = AppContext { - paths: PathConfig { - workspace: workspace.clone(), - manifest, - ..Default::default() - }, - config_search_dir: Some(search_dir), - ..Default::default() - }; + write_single_crate_manifest(tmp.path()); + let tool = make_tool(tmp.path()); - // 显式路径应该优先 - let explicit = workspace.join("custom.qemu.toml"); - let result = resolve_qemu_config_path(&ctx, Some(explicit.clone())).unwrap(); + let explicit = tmp.path().join("custom.qemu.toml"); + let result = resolve_qemu_config_path(&tool, Some(explicit.clone())).unwrap(); assert_eq!(result, explicit); } #[test] - fn qemu_config_search_dir_beats_manifest() { + fn qemu_config_workspace_path_used() { let tmp = TempDir::new().unwrap(); - let workspace = tmp.path().to_path_buf(); - let manifest = workspace.join("manifest"); - std::fs::create_dir(&manifest).unwrap(); - let search_dir = workspace.join("config"); - std::fs::create_dir(&search_dir).unwrap(); - - // 创建多个配置文件 - std::fs::write(search_dir.join("qemu-aarch64.toml"), "").unwrap(); - std::fs::write(manifest.join(".qemu-aarch64.toml"), "").unwrap(); - - let ctx = AppContext { - paths: PathConfig { - workspace, - manifest, - ..Default::default() - }, - arch: Some(Architecture::Aarch64), - config_search_dir: Some(search_dir.clone()), - ..Default::default() - }; + write_single_crate_manifest(tmp.path()); + std::fs::write(tmp.path().join("qemu-aarch64.toml"), "").unwrap(); - let result = resolve_qemu_config_path(&ctx, None).unwrap(); - assert_eq!(result, search_dir.join("qemu-aarch64.toml")); + let mut tool = make_tool(tmp.path()); + tool.ctx.arch = Some(Architecture::Aarch64); + + let result = resolve_qemu_config_path(&tool, None).unwrap(); + assert_eq!(result, tmp.path().join("qemu-aarch64.toml")); } #[test] fn qemu_config_filename_priority() { let tmp = TempDir::new().unwrap(); - let workspace = tmp.path().to_path_buf(); - let manifest = workspace.join("manifest"); - std::fs::create_dir(&manifest).unwrap(); - - let ctx = AppContext { - paths: PathConfig { - workspace, - manifest: manifest.clone(), - ..Default::default() - }, - arch: Some(Architecture::Aarch64), - ..Default::default() - }; + write_single_crate_manifest(tmp.path()); + let manifest = tmp.path().to_path_buf(); + let mut tool = make_tool(tmp.path()); + tool.ctx.arch = Some(Architecture::Aarch64); - // 按顺序创建文件,每次验证优先级 std::fs::write(manifest.join("qemu.toml"), "").unwrap(); - let result = resolve_qemu_config_path(&ctx, None).unwrap(); + let result = resolve_qemu_config_path(&tool, None).unwrap(); assert_eq!(result, manifest.join("qemu.toml")); std::fs::write(manifest.join("qemu-aarch64.toml"), "").unwrap(); - let result = resolve_qemu_config_path(&ctx, None).unwrap(); + let result = resolve_qemu_config_path(&tool, None).unwrap(); assert_eq!(result, manifest.join("qemu-aarch64.toml")); } #[test] fn qemu_config_default_path_with_search_dir() { let tmp = TempDir::new().unwrap(); - let workspace = tmp.path().to_path_buf(); - let manifest = workspace.join("manifest"); - std::fs::create_dir(&manifest).unwrap(); - let search_dir = workspace.join("config"); - std::fs::create_dir(&search_dir).unwrap(); - - let ctx = AppContext { - paths: PathConfig { - workspace, - manifest, - ..Default::default() - }, - config_search_dir: Some(search_dir.clone()), - ..Default::default() - }; - - let result = resolve_qemu_config_path(&ctx, None).unwrap(); - assert_eq!(result, search_dir.join(".qemu.toml")); - } - - #[test] - fn qemu_config_default_path_without_search_dir() { - let tmp = TempDir::new().unwrap(); - let workspace = tmp.path().to_path_buf(); - let manifest = workspace.join("manifest"); - std::fs::create_dir(&manifest).unwrap(); - - let ctx = AppContext { - paths: PathConfig { - workspace, - manifest: manifest.clone(), - ..Default::default() - }, - ..Default::default() - }; + write_single_crate_manifest(tmp.path()); + let tool = make_tool(tmp.path()); - let result = resolve_qemu_config_path(&ctx, None).unwrap(); - assert_eq!(result, manifest.join(".qemu.toml")); + let result = resolve_qemu_config_path(&tool, None).unwrap(); + assert_eq!(result, tmp.path().join(".qemu.toml")); } #[test] fn qemu_config_default_path_with_arch() { let tmp = TempDir::new().unwrap(); - let workspace = tmp.path().to_path_buf(); - let manifest = workspace.join("manifest"); - std::fs::create_dir(&manifest).unwrap(); - - let ctx = AppContext { - paths: PathConfig { - workspace, - manifest: manifest.clone(), - ..Default::default() - }, - arch: Some(Architecture::Aarch64), - ..Default::default() - }; + write_single_crate_manifest(tmp.path()); + let mut tool = make_tool(tmp.path()); + tool.ctx.arch = Some(Architecture::Aarch64); - let result = resolve_qemu_config_path(&ctx, None).unwrap(); - assert_eq!(result, manifest.join(".qemu-aarch64.toml")); + let result = resolve_qemu_config_path(&tool, None).unwrap(); + assert_eq!(result, tmp.path().join(".qemu-aarch64.toml")); } #[test] fn qemu_config_without_arch() { let tmp = TempDir::new().unwrap(); - let workspace = tmp.path().to_path_buf(); - let manifest = workspace.join("manifest"); - std::fs::create_dir(&manifest).unwrap(); - - // 创建架构特定文件 - std::fs::write(manifest.join("qemu-aarch64.toml"), "").unwrap(); + write_single_crate_manifest(tmp.path()); + std::fs::write(tmp.path().join("qemu-aarch64.toml"), "").unwrap(); + std::fs::write(tmp.path().join("qemu.toml"), "").unwrap(); - let ctx = AppContext { - paths: PathConfig { - workspace, - manifest: manifest.clone(), - ..Default::default() - }, - arch: None, // 无架构 - ..Default::default() - }; - - // 应该跳过架构特定文件,使用通用文件 - std::fs::write(manifest.join("qemu.toml"), "").unwrap(); - let result = resolve_qemu_config_path(&ctx, None).unwrap(); - assert_eq!(result, manifest.join("qemu.toml")); + let tool = make_tool(tmp.path()); + let result = resolve_qemu_config_path(&tool, None).unwrap(); + assert_eq!(result, tmp.path().join("qemu.toml")); } - // === Build 配置解析测试 === - #[test] fn build_config_explicit_path_wins() { let tmp = TempDir::new().unwrap(); - let workspace = tmp.path().to_path_buf(); - let manifest = workspace.join("manifest"); - std::fs::create_dir(&manifest).unwrap(); - let search_dir = workspace.join("config"); - std::fs::create_dir(&search_dir).unwrap(); - - // 创建多个配置文件 - std::fs::write(search_dir.join(".build.toml"), "").unwrap(); - std::fs::write(workspace.join(".build.toml"), "").unwrap(); - - let ctx = AppContext { - paths: PathConfig { - workspace: workspace.clone(), - manifest, - ..Default::default() - }, - config_search_dir: Some(search_dir), - ..Default::default() - }; + write_single_crate_manifest(tmp.path()); + let tool = make_tool(tmp.path()); - // 显式路径应该优先 - let explicit = workspace.join("custom.build.toml"); - let result = ctx.resolve_build_config_path(Some(explicit.clone())); + let explicit = tmp.path().join("custom.build.toml"); + let result = tool.resolve_build_config_path(Some(explicit.clone())); assert_eq!(result, explicit); } #[test] - fn build_config_search_dir_beats_workspace() { + fn build_config_defaults_to_workspace_root() { let tmp = TempDir::new().unwrap(); - let workspace = tmp.path().to_path_buf(); - let manifest = workspace.join("manifest"); - std::fs::create_dir(&manifest).unwrap(); - let search_dir = workspace.join("config"); - std::fs::create_dir(&search_dir).unwrap(); - - // 创建多个配置文件 - std::fs::write(search_dir.join(".build.toml"), "[system]").unwrap(); - std::fs::write(workspace.join(".build.toml"), "[system]").unwrap(); - - let ctx = AppContext { - paths: PathConfig { - workspace: workspace.clone(), - manifest, - ..Default::default() - }, - config_search_dir: Some(search_dir.clone()), - ..Default::default() - }; - - let result = ctx.resolve_build_config_path(None); - assert_eq!(result, search_dir.join(".build.toml")); - } - - #[test] - fn build_config_fallback_to_workspace() { - let tmp = TempDir::new().unwrap(); - let workspace = tmp.path().to_path_buf(); - let manifest = workspace.join("manifest"); - std::fs::create_dir(&manifest).unwrap(); - - let ctx = AppContext { - paths: PathConfig { - workspace: workspace.clone(), - manifest, - ..Default::default() - }, - config_search_dir: None, - ..Default::default() - }; + write_single_crate_manifest(tmp.path()); + let tool = make_tool(tmp.path()); - let result = ctx.resolve_build_config_path(None); - assert_eq!(result, workspace.join(".build.toml")); + let result = tool.resolve_build_config_path(None); + assert_eq!(result, tmp.path().join(".build.toml")); } } diff --git a/ostool/src/run/tftp.rs b/ostool/src/run/tftp.rs index d445dca..531204e 100644 --- a/ostool/src/run/tftp.rs +++ b/ostool/src/run/tftp.rs @@ -17,7 +17,7 @@ use std::net::{IpAddr, Ipv4Addr}; use colored::Colorize as _; use tftpd::{Config, Server}; -use crate::ctx::AppContext; +use crate::Tool; /// Starts a TFTP server serving files from the build output directory. /// @@ -26,16 +26,16 @@ use crate::ctx::AppContext; /// /// # Arguments /// -/// * `app` - The application context containing the file paths. +/// * `tool` - The tool containing the file paths. /// /// # Errors /// /// Returns an error if the server fails to start (e.g., port already in use /// or insufficient permissions). -pub fn run_tftp_server(app: &AppContext) -> anyhow::Result<()> { +pub fn run_tftp_server(tool: &Tool) -> anyhow::Result<()> { // TFTP server implementation goes here - let mut file_dir = app.paths.manifest.clone(); - if let Some(elf_path) = &app.paths.artifacts.elf { + let mut file_dir = tool.manifest_dir().clone(); + if let Some(elf_path) = &tool.ctx().artifacts.elf { file_dir = elf_path .parent() .ok_or(anyhow!("{} no parent dir", elf_path.display()))? diff --git a/ostool/src/run/uboot.rs b/ostool/src/run/uboot.rs index 7fbb324..e6264ad 100644 --- a/ostool/src/run/uboot.rs +++ b/ostool/src/run/uboot.rs @@ -18,7 +18,7 @@ use tokio::fs; use uboot_shell::UbootShell; use crate::{ - ctx::AppContext, + Tool, run::{ output_matcher::{ByteStreamMatcher, MATCH_DRAIN_DURATION, StreamMatchKind}, tftp, @@ -97,11 +97,11 @@ pub struct RunUbootArgs { pub show_output: bool, } -impl AppContext { - pub async fn run_uboot(self, args: RunUbootArgs) -> anyhow::Result<()> { +impl Tool { + pub async fn run_uboot(&mut self, args: RunUbootArgs) -> anyhow::Result<()> { let config_path = match args.config.clone() { Some(path) => path, - None => self.paths.workspace.join(".uboot.toml"), + None => self.workspace_dir().join(".uboot.toml"), }; let config = if config_path.exists() { @@ -137,7 +137,7 @@ impl AppContext { })?; let mut runner = Runner { - ctx: self, + tool: self, config, baud_rate, success_regex: vec![], @@ -148,15 +148,15 @@ impl AppContext { } } -struct Runner { - ctx: AppContext, +struct Runner<'a> { + tool: &'a mut Tool, config: UbootConfig, success_regex: Vec, fail_regex: Vec, baud_rate: u32, } -impl Runner { +impl Runner<'_> { /// 生成压缩的 FIT image 包含 kernel 和 FDT /// /// # 参数 @@ -193,7 +193,7 @@ impl Runner { Byte::from(kernel_data.len()) ); - let arch = match self.ctx.arch.as_ref().unwrap() { + let arch = match self.tool.ctx.arch.as_ref().unwrap() { object::Architecture::Aarch64 => "arm64", object::Architecture::Arm => "arm", object::Architecture::LoongArch64 => "loongarch64", @@ -272,7 +272,7 @@ impl Runner { if let Some(ref cmd) = self.config.board_power_off_cmd && !cmd.trim().is_empty() { - let _ = self.ctx.shell_run_cmd(cmd); + let _ = self.tool.shell_run_cmd(cmd); info!("Board powered off"); } res @@ -280,11 +280,11 @@ impl Runner { async fn _run(&mut self) -> anyhow::Result<()> { self.preper_regex()?; - self.ctx.objcopy_output_bin()?; + self.tool.objcopy_output_bin()?; let kernel = self + .tool .ctx - .paths .artifacts .bin .as_ref() @@ -305,7 +305,7 @@ impl Runner { if !is_tftp && let Some(ip) = ip_string.as_ref() { info!("TFTP server IP: {}", ip); - tftp::run_tftp_server(&self.ctx)?; + tftp::run_tftp_server(self.tool)?; } info!( @@ -330,7 +330,7 @@ impl Runner { if let Some(cmd) = self.config.board_reset_cmd.clone() && !cmd.trim().is_empty() { - self.ctx.shell_run_cmd(&cmd)?; + self.tool.shell_run_cmd(&cmd)?; } let mut net_ok = false; diff --git a/ostool/src/tool.rs b/ostool/src/tool.rs new file mode 100644 index 0000000..1810941 --- /dev/null +++ b/ostool/src/tool.rs @@ -0,0 +1,503 @@ +use std::{env::current_dir, ffi::OsStr, path::PathBuf, sync::Arc}; + +use anyhow::{Context, anyhow, bail}; +use cargo_metadata::Metadata; +use colored::Colorize; +use cursive::Cursive; +use jkconfig::{ + ElemHock, + data::{app_data::AppData, item::ItemType, types::ElementType}, + ui::components::editors::{show_feature_select, show_list_select}, +}; +use object::Object; +use tokio::fs; + +use crate::{ + build::{ + config::{BuildConfig, BuildSystem, Cargo}, + someboot, + }, + ctx::AppContext, + utils::PathResultExt, +}; + +/// Static configuration used to initialize a [`Tool`]. +#[derive(Default, Clone, Debug)] +pub struct ToolConfig { + /// Optional manifest path or manifest directory. + pub manifest: Option, + /// Optional custom build output directory. + pub build_dir: Option, + /// Optional custom binary output directory. + pub bin_dir: Option, + /// Whether debug mode is enabled. + pub debug: bool, +} + +/// Main library object orchestrating build and run operations. +#[derive(Clone, Debug)] +pub struct Tool { + pub(crate) config: ToolConfig, + pub(crate) manifest_path: PathBuf, + pub(crate) manifest_dir: PathBuf, + pub(crate) workspace_dir: PathBuf, + pub(crate) ctx: AppContext, +} + +impl Tool { + /// Creates a new tool from the provided configuration. + pub fn new(config: ToolConfig) -> anyhow::Result { + let manifest_path = resolve_manifest_path(config.manifest.clone())?; + let manifest_dir = manifest_path + .parent() + .ok_or_else(|| anyhow!("manifest has no parent: {}", manifest_path.display()))? + .to_path_buf(); + + let metadata = cargo_metadata::MetadataCommand::new() + .manifest_path(&manifest_path) + .no_deps() + .exec() + .with_context(|| { + format!( + "failed to load cargo metadata from {}", + manifest_path.display() + ) + })?; + + Ok(Self { + config, + manifest_path, + manifest_dir, + workspace_dir: PathBuf::from(metadata.workspace_root.as_std_path()), + ctx: AppContext::default(), + }) + } + + pub fn ctx(&self) -> &AppContext { + &self.ctx + } + + pub fn ctx_mut(&mut self) -> &mut AppContext { + &mut self.ctx + } + + pub fn into_context(self) -> AppContext { + self.ctx + } + + pub(crate) fn debug_enabled(&self) -> bool { + self.config.debug + } + + pub(crate) fn manifest_dir(&self) -> &PathBuf { + &self.manifest_dir + } + + pub(crate) fn workspace_dir(&self) -> &PathBuf { + &self.workspace_dir + } + + pub(crate) fn build_dir(&self) -> PathBuf { + self.config + .build_dir + .as_ref() + .map(|dir| self.resolve_dir(dir)) + .unwrap_or_else(|| self.manifest_dir.join("target")) + } + + pub(crate) fn bin_dir(&self) -> Option { + self.config + .bin_dir + .as_ref() + .map(|dir| self.resolve_dir(dir)) + } + + fn resolve_dir(&self, dir: &PathBuf) -> PathBuf { + if dir.is_relative() { + self.manifest_dir.join(dir) + } else { + dir.clone() + } + } + + /// Executes a shell command in the current context. + pub fn shell_run_cmd(&self, cmd: &str) -> anyhow::Result<()> { + let mut command = match std::env::consts::OS { + "windows" => { + let mut command = self.command("powershell"); + command.arg("-Command"); + command + } + _ => { + let mut command = self.command("sh"); + command.arg("-c"); + command + } + }; + + command.arg(cmd); + + if let Some(elf) = &self.ctx.artifacts.elf { + command.env("KERNEL_ELF", elf.display().to_string()); + } + + command.run()?; + Ok(()) + } + + /// Creates a new command builder for the given program. + pub fn command(&self, program: &str) -> crate::utils::Command { + let workspace_dir = self.workspace_dir.clone(); + let mut command = crate::utils::Command::new(program, &self.manifest_dir, move |s| { + let raw = s.to_string_lossy(); + raw.replace( + "${workspaceFolder}", + format!("{}", workspace_dir.display()).as_ref(), + ) + }); + command.env("WORKSPACE_FOLDER", self.workspace_dir.display().to_string()); + command + } + + /// Gets the Cargo metadata for the current manifest. + pub fn metadata(&self) -> anyhow::Result { + cargo_metadata::MetadataCommand::new() + .manifest_path(&self.manifest_path) + .no_deps() + .exec() + .with_context(|| { + format!( + "failed to load cargo metadata from {}", + self.manifest_path.display() + ) + }) + } + + /// Sets the ELF artifact path and synchronizes derived runtime metadata. + pub async fn set_elf_artifact_path(&mut self, path: PathBuf) -> anyhow::Result<()> { + let path = path + .canonicalize() + .with_path("failed to canonicalize file", &path)?; + let artifact_dir = path + .parent() + .ok_or_else(|| anyhow!("invalid ELF file path: {}", path.display()))? + .to_path_buf(); + + self.ctx.artifacts.elf = Some(path.clone()); + self.ctx.artifacts.bin = None; + self.ctx.artifacts.cargo_artifact_dir = Some(artifact_dir.clone()); + self.ctx.artifacts.runtime_artifact_dir = Some(artifact_dir); + + let binary_data = fs::read(&path) + .await + .with_path("failed to read ELF file", &path)?; + let file = object::File::parse(binary_data.as_slice()) + .with_context(|| format!("failed to parse ELF file: {}", path.display()))?; + self.ctx.arch = Some(file.architecture()); + Ok(()) + } + + /// Sets the ELF file path and detects its architecture. + pub async fn set_elf_path(&mut self, path: PathBuf) -> anyhow::Result<()> { + self.set_elf_artifact_path(path).await + } + + /// Strips debug symbols from the ELF file. + pub fn objcopy_elf(&mut self) -> anyhow::Result { + let elf_path = self + .ctx + .artifacts + .elf + .as_ref() + .ok_or_else(|| anyhow!("elf not exist"))?; + let elf_path = elf_path + .canonicalize() + .with_path("failed to canonicalize file", elf_path)?; + + let stripped_elf_path = elf_path.with_file_name( + elf_path + .file_stem() + .ok_or_else(|| anyhow!("invalid ELF file path: {}", elf_path.display()))? + .to_string_lossy() + .to_string() + + ".elf", + ); + println!( + "{}", + format!( + "Stripping ELF file...\r\n original elf: {}\r\n stripped elf: {}", + elf_path.display(), + stripped_elf_path.display() + ) + .bold() + .purple() + ); + + let mut objcopy = self.command("rust-objcopy"); + objcopy.arg(format!( + "--binary-architecture={}", + format!( + "{:?}", + self.ctx + .arch + .ok_or_else(|| anyhow!("architecture not detected"))? + ) + .to_lowercase() + )); + objcopy.arg(&elf_path); + objcopy.arg(&stripped_elf_path); + objcopy.run()?; + + self.ctx.artifacts.elf = Some(stripped_elf_path.clone()); + self.ctx.artifacts.bin = None; + self.ctx.artifacts.cargo_artifact_dir = stripped_elf_path.parent().map(PathBuf::from); + self.ctx.artifacts.runtime_artifact_dir = stripped_elf_path.parent().map(PathBuf::from); + + Ok(stripped_elf_path) + } + + /// Converts the ELF file to raw binary format. + pub fn objcopy_output_bin(&mut self) -> anyhow::Result { + if let Some(bin) = &self.ctx.artifacts.bin { + debug!("BIN file already exists: {:?}", bin); + return Ok(bin.clone()); + } + + let elf_path = self + .ctx + .artifacts + .elf + .as_ref() + .ok_or_else(|| anyhow!("elf not exist"))?; + let elf_path = elf_path + .canonicalize() + .with_path("failed to canonicalize file", elf_path)?; + + let bin_name = elf_path + .file_stem() + .ok_or_else(|| anyhow!("invalid ELF file path: {}", elf_path.display()))? + .to_string_lossy() + .to_string() + + ".bin"; + + let bin_path = if let Some(bin_dir) = self.bin_dir() { + bin_dir.join(bin_name) + } else { + elf_path.with_file_name(bin_name) + }; + + if let Some(parent) = bin_path.parent() { + std::fs::create_dir_all(parent).with_path("failed to create directory", parent)?; + } + + println!( + "{}", + format!( + "Converting ELF to BIN format...\r\n elf: {}\r\n bin: {}", + elf_path.display(), + bin_path.display() + ) + .bold() + .purple() + ); + + let mut objcopy = self.command("rust-objcopy"); + + if !self.debug_enabled() { + objcopy.arg("--strip-all"); + } + + objcopy + .arg("-O") + .arg("binary") + .arg(&elf_path) + .arg(&bin_path); + objcopy.run()?; + + self.ctx.artifacts.bin = Some(bin_path.clone()); + self.ctx.artifacts.runtime_artifact_dir = bin_path.parent().map(PathBuf::from); + Ok(bin_path) + } + + pub(crate) fn resolve_build_config_path(&self, explicit_path: Option) -> PathBuf { + explicit_path.unwrap_or_else(|| self.workspace_dir.join(".build.toml")) + } + + /// Loads and prepares the build configuration. + pub async fn prepare_build_config( + &mut self, + config_path: Option, + menu: bool, + ) -> anyhow::Result { + let config_path = self.resolve_build_config_path(config_path); + self.ctx.build_config_path = Some(config_path.clone()); + + let Some(mut c): Option = jkconfig::run( + config_path.clone(), + menu, + &[self.ui_hock_feature_select(), self.ui_hock_pacage_select()], + ) + .await + .with_context(|| format!("failed to load build config: {}", config_path.display()))? + else { + bail!("No build configuration obtained"); + }; + + if let BuildSystem::Cargo(cargo) = &mut c.system { + let iter = self.someboot_cargo_args(cargo)?.into_iter(); + cargo.args.extend(iter); + } + + self.ctx.build_config = Some(c.clone()); + Ok(c) + } + + fn someboot_cargo_args(&self, cargo: &Cargo) -> anyhow::Result> { + let manifest_path = self.workspace_dir.join("Cargo.toml"); + someboot::detect_build_config_for_package( + &manifest_path, + &cargo.package, + &cargo.features, + &cargo.target, + ) + } + + pub fn value_replace_with_var(&self, value: S) -> String + where + S: AsRef, + { + let raw = value.as_ref().to_string_lossy(); + raw.replace( + "${workspaceFolder}", + format!("{}", self.workspace_dir.display()).as_ref(), + ) + } + + pub fn ui_hocks(&self) -> Vec { + vec![self.ui_hock_feature_select(), self.ui_hock_pacage_select()] + } + + fn ui_hock_feature_select(&self) -> ElemHock { + let path = "system.features"; + let cargo_toml = self.workspace_dir.join("Cargo.toml"); + ElemHock { + path: path.to_string(), + callback: Arc::new(move |siv: &mut Cursive, _path: &str| { + let mut package = String::new(); + if let Some(app) = siv.user_data::() + && let Some(pkg) = app.root.get_by_key("system.package") + && let ElementType::Item(item) = pkg + && let ItemType::String { value: Some(v), .. } = &item.item_type + { + package = v.clone(); + } + + show_feature_select(siv, &package, &cargo_toml, None); + }), + } + } + + fn ui_hock_pacage_select(&self) -> ElemHock { + let path = "system.package"; + let cargo_toml = self.workspace_dir.join("Cargo.toml"); + + ElemHock { + path: path.to_string(), + callback: Arc::new(move |siv: &mut Cursive, path: &str| { + let mut items = Vec::new(); + if let Ok(metadata) = cargo_metadata::MetadataCommand::new() + .manifest_path(&cargo_toml) + .no_deps() + .exec() + { + for pkg in &metadata.packages { + items.push(pkg.name.to_string()); + } + } + + show_list_select(siv, "Pacage", &items, path, on_package_selected); + }), + } + } +} + +fn on_package_selected(app: &mut AppData, path: &str, selected: &str) { + let ElementType::Item(item) = app.root.get_mut_by_key(path).unwrap() else { + panic!("Not an item element"); + }; + let ItemType::String { value, .. } = &mut item.item_type else { + panic!("Not a string item"); + }; + *value = Some(selected.to_string()); +} + +fn resolve_manifest_path(input: Option) -> anyhow::Result { + let path = match input { + Some(path) => path, + None => current_dir().context("failed to get current working directory")?, + }; + + let manifest_path = if path.is_dir() { + path.join("Cargo.toml") + } else { + path + }; + + if manifest_path.file_name().and_then(|name| name.to_str()) != Some("Cargo.toml") { + bail!( + "manifest must be a Cargo.toml file or a directory containing Cargo.toml: {}", + manifest_path.display() + ); + } + + if !manifest_path.exists() { + bail!("Cargo.toml not found: {}", manifest_path.display()); + } + + manifest_path + .canonicalize() + .with_path("failed to canonicalize manifest path", &manifest_path) +} + +#[cfg(test)] +mod tests { + use super::{Tool, ToolConfig}; + + #[tokio::test] + async fn set_elf_artifact_path_updates_dirs_and_arch() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write( + temp.path().join("Cargo.toml"), + "[package]\nname = \"sample\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + ) + .unwrap(); + std::fs::create_dir_all(temp.path().join("src")).unwrap(); + std::fs::write(temp.path().join("src/lib.rs"), "").unwrap(); + + let source = std::env::current_exe().unwrap(); + let copied = temp.path().join("sample-elf"); + std::fs::copy(&source, &copied).unwrap(); + + let mut tool = Tool::new(ToolConfig { + manifest: Some(temp.path().to_path_buf()), + ..Default::default() + }) + .unwrap(); + tool.set_elf_artifact_path(copied.clone()).await.unwrap(); + + let expected_elf = copied.canonicalize().unwrap(); + let expected_dir = expected_elf.parent().unwrap().to_path_buf(); + + assert_eq!(tool.ctx.artifacts.elf.as_ref(), Some(&expected_elf)); + assert_eq!( + tool.ctx.artifacts.cargo_artifact_dir.as_ref(), + Some(&expected_dir) + ); + assert_eq!( + tool.ctx.artifacts.runtime_artifact_dir.as_ref(), + Some(&expected_dir) + ); + assert!(tool.ctx.arch.is_some()); + assert!(tool.ctx.artifacts.bin.is_none()); + } +} From 594d4ff61f3e249a8a4baf16fca2274a6208a5ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E7=9D=BF?= Date: Tue, 24 Mar 2026 10:39:27 +0800 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E6=96=87=E4=BB=B6=E6=97=A5=E5=BF=97=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E5=99=A8=EF=BC=8C=E6=94=B9=E8=BF=9B=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=E5=92=8C=E6=97=A5=E5=BF=97=E5=88=9D=E5=A7=8B?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + Cargo.lock | 11 +++-- ostool/Cargo.toml | 1 + ostool/src/bin/cargo-osrun.rs | 61 +++++++++++++++++------- ostool/src/lib.rs | 9 +++- ostool/src/logger.rs | 87 +++++++++++++++++++++++++++++++++ ostool/src/main.rs | 41 +++++++++++----- ostool/src/tool.rs | 90 +++++++++++++++++++++++++++-------- 8 files changed, 245 insertions(+), 56 deletions(-) create mode 100644 ostool/src/logger.rs diff --git a/.gitignore b/.gitignore index 839942e..1bf1912 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ target/ .*-schema.json .qemu*.toml .uboot.toml +.sisyphus # These are backup files generated by rustfmt **/*.rs.bk diff --git a/Cargo.lock b/Cargo.lock index 63deac4..535cf96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -445,9 +445,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "js-sys", @@ -2063,6 +2063,7 @@ dependencies = [ "anyhow", "byte-unit", "cargo_metadata", + "chrono", "clap", "colored", "crossterm 0.29.0", @@ -2277,7 +2278,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -2648,7 +2649,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3759,7 +3760,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/ostool/Cargo.toml b/ostool/Cargo.toml index 02a8932..1b51319 100644 --- a/ostool/Cargo.toml +++ b/ostool/Cargo.toml @@ -59,6 +59,7 @@ sha2 = "0.10" tar = "0.4" thiserror = { workspace = true } ureq = "3.0" +chrono = "0.4.44" [dev-dependencies] tempfile = "3" diff --git a/ostool/src/bin/cargo-osrun.rs b/ostool/src/bin/cargo-osrun.rs index 5f1ae26..5c2b0ec 100644 --- a/ostool/src/bin/cargo-osrun.rs +++ b/ostool/src/bin/cargo-osrun.rs @@ -2,11 +2,15 @@ use std::{ env, path::PathBuf, process::{ExitCode, exit}, + sync::OnceLock, }; use clap::{Parser, Subcommand}; -use log::{LevelFilter, debug}; +use colored::Colorize as _; +use log::debug; use ostool::{ + logger, + resolve_manifest_context, Tool, ToolConfig, run::{qemu, uboot::RunUbootArgs}, }; @@ -70,6 +74,8 @@ enum SubCommands { Uboot(CliUboot), } +static LOG_PATH: OnceLock = OnceLock::new(); + #[derive(Debug, Parser, Clone)] struct CliUboot { #[arg(allow_hyphen_values = true)] @@ -81,41 +87,45 @@ async fn main() -> ExitCode { match try_main().await { Ok(()) => ExitCode::SUCCESS, Err(err) => { - eprintln!("Error: {err:#}"); - eprintln!("\nTrace:\n{err:?}"); + report_error(&err); ExitCode::FAILURE } } } async fn try_main() -> anyhow::Result<()> { - env_logger::builder() - .format_module_path(false) - .filter_level(LevelFilter::Info) - .parse_default_env() - .init(); - let args = RunnerArgs::parse(); - - debug!("Parsed arguments: {:#?}", args); - - if args.no_run { - exit(0); - } - if env::var("CARGO").is_err() { - eprintln!("This binary may only be called via `cargo ndk-runner`."); + println!( + "{}", + "This binary may only be called via `cargo ndk-runner`." + .red() + .bold() + ); exit(1); } let manifest_dir: PathBuf = env::var("CARGO_MANIFEST_DIR")?.into(); let manifest = manifest_dir.join("Cargo.toml"); + let manifest = resolve_manifest_context(Some(manifest))?; + let log_path = logger::init_file_logger(&manifest.workspace_dir)?; + let _ = LOG_PATH.set(log_path.clone()); + debug!( + "Logging initialized at {} for manifest {}", + log_path.display(), + manifest.manifest_path.display() + ); + debug!("Parsed arguments: {:#?}", args); + + if args.no_run { + exit(0); + } let bin_dir: Option = args.bin_dir.map(PathBuf::from); let build_dir: Option = args.build_dir.map(PathBuf::from); let mut tool = Tool::new(ToolConfig { - manifest: Some(manifest), + manifest: Some(manifest.manifest_path), build_dir, bin_dir, debug: args.debug, @@ -148,3 +158,18 @@ async fn try_main() -> anyhow::Result<()> { Ok(()) } + +fn report_error(err: &anyhow::Error) { + log::error!("{err:#}"); + log::error!("Trace:\n{err:?}"); + + println!("{}", format!("Error: {err:#}").red().bold()); + println!("{}", format!("\nTrace:\n{err:?}").red()); + + if let Some(log_path) = LOG_PATH.get() { + println!( + "{}", + format!("Log file: {}", log_path.display()).yellow().bold() + ); + } +} diff --git a/ostool/src/lib.rs b/ostool/src/lib.rs index a2a45b4..4cca180 100644 --- a/ostool/src/lib.rs +++ b/ostool/src/lib.rs @@ -40,6 +40,13 @@ pub mod build; /// Application context and state management. pub mod ctx; + +/// Custom file logger for ostool. +/// +/// Provides a file-based logger that writes all log output to +/// `{workspace_root}/target/ostool.ans`. +pub mod logger; + mod tool; /// TUI-based menu configuration system. @@ -69,4 +76,4 @@ extern crate log; extern crate anyhow; pub use jkconfig::cursive; -pub use tool::{Tool, ToolConfig}; +pub use tool::{ManifestContext, Tool, ToolConfig, resolve_manifest_context}; diff --git a/ostool/src/logger.rs b/ostool/src/logger.rs new file mode 100644 index 0000000..6e369fe --- /dev/null +++ b/ostool/src/logger.rs @@ -0,0 +1,87 @@ +//! Custom file logger for ostool. +//! +//! This module provides a file-based logger that writes all log output to +//! `{workspace_root}/target/ostool.ans`, keeping the terminal clean and +//! avoiding conflicts with TUI (cursive/ratatui). + +use std::{ + fs::{File, OpenOptions}, + io::Write, + path::{Path, PathBuf}, + sync::Mutex, +}; + +use anyhow::Context as _; +use chrono::Local; +use log::{Level, LevelFilter, Log, Metadata, Record}; + +/// File logger that writes log records to a file. +pub struct FileLogger { + file: Mutex, +} + +impl FileLogger { + /// Creates a new file logger that writes to the specified path. + fn new(log_path: &Path) -> std::io::Result { + if let Some(parent) = log_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let file = OpenOptions::new() + .create(true) + .append(true) + .open(log_path)?; + + Ok(Self { + file: Mutex::new(file), + }) + } +} + +impl Log for FileLogger { + fn enabled(&self, _metadata: &Metadata) -> bool { + true + } + + fn log(&self, record: &Record) { + if self.enabled(record.metadata()) { + let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S"); + let level_str = match record.level() { + Level::Error => "ERROR", + Level::Warn => "WARN ", + Level::Info => "INFO ", + Level::Debug => "DEBUG", + Level::Trace => "TRACE", + }; + + let message = format!("[{} {}] {}\n", level_str, timestamp, record.args()); + + if let Ok(mut file) = self.file.lock() { + let _ = file.write_all(message.as_bytes()); + } + } + } + + fn flush(&self) { + if let Ok(mut file) = self.file.lock() { + let _ = file.flush(); + } + } +} + +/// Returns the canonical log file path for a workspace root. +pub fn log_file_path(workspace_root: &Path) -> PathBuf { + workspace_root.join("target").join("ostool.ans") +} + +/// Initializes the file logger to write logs to `{workspace_root}/target/ostool.ans`. +pub fn init_file_logger(workspace_root: &Path) -> anyhow::Result { + let log_path = log_file_path(workspace_root); + let logger = FileLogger::new(&log_path) + .with_context(|| format!("failed to create log file: {}", log_path.display()))?; + + log::set_boxed_logger(Box::new(logger)).context("failed to install ostool file logger")?; + log::set_max_level(LevelFilter::Debug); + + Ok(log_path) +} diff --git a/ostool/src/main.rs b/ostool/src/main.rs index 8356b0d..253d97b 100644 --- a/ostool/src/main.rs +++ b/ostool/src/main.rs @@ -1,10 +1,13 @@ -use std::{path::PathBuf, process::ExitCode}; +use std::{path::PathBuf, process::ExitCode, sync::OnceLock}; use anyhow::Result; use clap::*; +use colored::Colorize as _; use log::info; use ostool::{ + logger, + resolve_manifest_context, Tool, ToolConfig, build::{self, CargoRunnerKind}, menuconfig::{MenuConfigHandler, MenuConfigMode}, @@ -20,6 +23,8 @@ struct Cli { command: SubCommands, } +static LOG_PATH: OnceLock = OnceLock::new(); + #[derive(Subcommand)] enum SubCommands { Build { @@ -78,23 +83,22 @@ async fn main() -> ExitCode { match try_main().await { Ok(()) => ExitCode::SUCCESS, Err(err) => { - eprintln!("Error: {err:#}"); - eprintln!("\nTrace:\n{err:?}"); + report_error(&err); ExitCode::FAILURE } } } async fn try_main() -> Result<()> { - #[cfg(not(feature = "ui-log"))] - { - env_logger::builder() - .filter_level(log::LevelFilter::Info) - .parse_default_env() - .init(); - } - let cli = Cli::parse(); + let manifest = resolve_manifest_context(cli.manifest.clone())?; + let log_path = logger::init_file_logger(&manifest.workspace_dir)?; + let _ = LOG_PATH.set(log_path.clone()); + info!( + "Logging initialized at {} for manifest {}", + log_path.display(), + manifest.manifest_path.display() + ); let mut tool = Tool::new(ToolConfig { manifest: cli.manifest, @@ -163,6 +167,21 @@ async fn try_main() -> Result<()> { Ok(()) } +fn report_error(err: &anyhow::Error) { + log::error!("{err:#}"); + log::error!("Trace:\n{err:?}"); + + println!("{}", format!("Error: {err:#}").red().bold()); + println!("{}", format!("\nTrace:\n{err:?}").red()); + + if let Some(log_path) = LOG_PATH.get() { + println!( + "{}", + format!("Log file: {}", log_path.display()).yellow().bold() + ); + } +} + impl From for RunQemuArgs { fn from(value: QemuArgs) -> Self { RunQemuArgs { diff --git a/ostool/src/tool.rs b/ostool/src/tool.rs index 1810941..f6c19bb 100644 --- a/ostool/src/tool.rs +++ b/ostool/src/tool.rs @@ -1,4 +1,9 @@ -use std::{env::current_dir, ffi::OsStr, path::PathBuf, sync::Arc}; +use std::{ + env::current_dir, + ffi::OsStr, + path::PathBuf, + sync::Arc, +}; use anyhow::{Context, anyhow, bail}; use cargo_metadata::Metadata; @@ -44,31 +49,24 @@ pub struct Tool { pub(crate) ctx: AppContext, } +/// Resolved Cargo manifest and workspace paths derived from `cargo metadata`. +#[derive(Clone, Debug)] +pub struct ManifestContext { + pub manifest_path: PathBuf, + pub manifest_dir: PathBuf, + pub workspace_dir: PathBuf, +} + impl Tool { /// Creates a new tool from the provided configuration. pub fn new(config: ToolConfig) -> anyhow::Result { - let manifest_path = resolve_manifest_path(config.manifest.clone())?; - let manifest_dir = manifest_path - .parent() - .ok_or_else(|| anyhow!("manifest has no parent: {}", manifest_path.display()))? - .to_path_buf(); - - let metadata = cargo_metadata::MetadataCommand::new() - .manifest_path(&manifest_path) - .no_deps() - .exec() - .with_context(|| { - format!( - "failed to load cargo metadata from {}", - manifest_path.display() - ) - })?; + let manifest = resolve_manifest_context(config.manifest.clone())?; Ok(Self { config, - manifest_path, - manifest_dir, - workspace_dir: PathBuf::from(metadata.workspace_root.as_std_path()), + manifest_path: manifest.manifest_path, + manifest_dir: manifest.manifest_dir, + workspace_dir: manifest.workspace_dir, ctx: AppContext::default(), }) } @@ -431,6 +429,31 @@ fn on_package_selected(app: &mut AppData, path: &str, selected: &str) { *value = Some(selected.to_string()); } +pub fn resolve_manifest_context(input: Option) -> anyhow::Result { + let manifest_path = resolve_manifest_path(input)?; + let manifest_dir = manifest_path + .parent() + .ok_or_else(|| anyhow!("manifest has no parent: {}", manifest_path.display()))? + .to_path_buf(); + + let metadata = cargo_metadata::MetadataCommand::new() + .manifest_path(&manifest_path) + .no_deps() + .exec() + .with_context(|| { + format!( + "failed to load cargo metadata from {}", + manifest_path.display() + ) + })?; + + Ok(ManifestContext { + manifest_path, + manifest_dir, + workspace_dir: PathBuf::from(metadata.workspace_root.as_std_path()), + }) +} + fn resolve_manifest_path(input: Option) -> anyhow::Result { let path = match input { Some(path) => path, @@ -461,7 +484,7 @@ fn resolve_manifest_path(input: Option) -> anyhow::Result { #[cfg(test)] mod tests { - use super::{Tool, ToolConfig}; + use super::{Tool, ToolConfig, resolve_manifest_context}; #[tokio::test] async fn set_elf_artifact_path_updates_dirs_and_arch() { @@ -500,4 +523,29 @@ mod tests { assert!(tool.ctx.arch.is_some()); assert!(tool.ctx.artifacts.bin.is_none()); } + + #[test] + fn resolve_manifest_context_uses_workspace_root() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write( + temp.path().join("Cargo.toml"), + "[workspace]\nmembers = [\"app\"]\nresolver = \"3\"\n", + ) + .unwrap(); + + let app_dir = temp.path().join("app"); + std::fs::create_dir_all(app_dir.join("src")).unwrap(); + std::fs::write( + app_dir.join("Cargo.toml"), + "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + ) + .unwrap(); + std::fs::write(app_dir.join("src/main.rs"), "fn main() {}\n").unwrap(); + + let manifest = resolve_manifest_context(Some(app_dir.clone())).unwrap(); + + assert_eq!(manifest.manifest_path, app_dir.join("Cargo.toml")); + assert_eq!(manifest.manifest_dir, app_dir); + assert_eq!(manifest.workspace_dir, temp.path()); + } } From b8bc573f5f786a32c47d46aa4dfd8e52c3d0229b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E7=9D=BF?= Date: Tue, 24 Mar 2026 17:34:25 +0800 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=AF=B9?= =?UTF-8?q?=E7=8E=B0=E6=9C=89=20TFTP=20=E6=A0=B9=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E7=9A=84=E6=94=AF=E6=8C=81=EF=BC=8C=E7=AE=80=E5=8C=96=20Linux?= =?UTF-8?q?=20=E7=B3=BB=E7=BB=9F=E7=9A=84=20tftpd-hpa=20=E6=A3=80=E6=B5=8B?= =?UTF-8?q?=E5=92=8C=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ostool/src/run/tftp.rs | 751 ++++++++++++++++++++++++++++++++++++++-- ostool/src/run/uboot.rs | 190 +++++++++- 2 files changed, 893 insertions(+), 48 deletions(-) diff --git a/ostool/src/run/tftp.rs b/ostool/src/run/tftp.rs index 531204e..2344b59 100644 --- a/ostool/src/run/tftp.rs +++ b/ostool/src/run/tftp.rs @@ -1,39 +1,153 @@ -//! TFTP server for network booting. +//! TFTP server helpers for network booting. //! -//! This module provides a simple TFTP server for network booting scenarios, -//! typically used with U-Boot to transfer kernel images over the network. -//! -//! # Permissions -//! -//! The TFTP server binds to port 69, which requires elevated privileges. -//! On Linux, you can grant the necessary capabilities with: -//! -//! ```bash -//! sudo setcap cap_net_bind_service=+eip $(which ostool) -//! ``` +//! On Linux, this module prepares a system `tftpd-hpa` installation and stages +//! build artifacts into the configured TFTP root. Other platforms keep using +//! the built-in Rust TFTP server. -use std::net::{IpAddr, Ipv4Addr}; +use std::{ + env, fs, + io::{self, IsTerminal, Write}, + net::{IpAddr, Ipv4Addr}, + path::{Component, Path, PathBuf}, + process::{Command, Stdio}, + time::{SystemTime, UNIX_EPOCH}, +}; +use anyhow::{Context, anyhow, bail}; use colored::Colorize as _; use tftpd::{Config, Server}; -use crate::Tool; - -/// Starts a TFTP server serving files from the build output directory. -/// -/// The server runs in a background thread and serves files from the directory -/// containing the ELF/binary artifacts. -/// -/// # Arguments -/// -/// * `tool` - The tool containing the file paths. -/// -/// # Errors -/// -/// Returns an error if the server fails to start (e.g., port already in use -/// or insufficient permissions). +use crate::{Tool, utils::PathResultExt}; + +const TFTP_HPA_CONFIG_PATH: &str = "/etc/default/tftpd-hpa"; +const DEFAULT_TFTP_DIRECTORY: &str = "/srv/tftp"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LinuxTftpPrepared { + pub tftp_root: PathBuf, + pub target_dir: PathBuf, + pub absolute_fit_path: PathBuf, + pub relative_filename: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TftpdHpaConfig { + pub username: Option, + pub directory: PathBuf, + pub address: Option, + pub options: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DistroKind { + Debian, + Rhel, + Arch, + OpenSuse, + Alpine, + Unsupported, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CommandSpec { + program: String, + args: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct InstallPlan { + distro: DistroKind, + commands: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct EffectiveUser { + name: String, + group: String, +} + +#[cfg(target_os = "linux")] +pub fn ensure_linux_tftpd_hpa() -> anyhow::Result { + let binary = find_tftpd_binary(); + let is_root = is_root_user()?; + + if let Some(path) = binary { + info!("Using system tftpd-hpa binary: {}", path.display()); + } else { + let distro = detect_distro_kind()?; + let install_plan = build_install_plan(distro)?; + + println!("{}", "未检测到 tftpd-hpa (in.tftpd)".yellow()); + println!("发行版: {}", distro.label()); + println!( + "当前用户是否为 root: {}", + if is_root { "yes" } else { "no" } + ); + + if install_plan.commands.is_empty() { + bail!( + "当前发行版暂不支持自动安装 tftpd-hpa,请手动安装后重试(发行版: {})", + distro.label() + ); + } + + let display = render_command_chain(&install_plan.commands, is_root); + println!("将执行安装命令:"); + println!(" {display}"); + + if !(io::stdin().is_terminal() && io::stdout().is_terminal()) { + bail!("当前终端不是交互式终端,请手动执行上述命令安装 tftpd-hpa"); + } + + if !prompt_yes_no("是否继续安装 tftpd-hpa? [y/N] ")? { + bail!("已取消安装 tftpd-hpa"); + } + + for command in &install_plan.commands { + run_privileged_command(command, is_root) + .with_context(|| format!("failed to install tftpd-hpa via `{display}`"))?; + } + + let path = find_tftpd_binary() + .ok_or_else(|| anyhow!("安装完成后仍未找到 in.tftpd,请确认 tftpd-hpa 是否安装成功"))?; + info!("Installed system tftpd-hpa binary: {}", path.display()); + } + + let (config, created) = ensure_tftpd_hpa_config(Path::new(TFTP_HPA_CONFIG_PATH), is_root)?; + if created { + if command_exists("systemctl") { + let restart = CommandSpec { + program: "systemctl".into(), + args: vec!["restart".into(), "tftpd-hpa".into()], + }; + run_privileged_command(&restart, is_root) + .context("failed to restart tftpd-hpa after creating default config")?; + } else { + println!( + "{}", + "已创建 /etc/default/tftpd-hpa,请手动重启 tftpd-hpa 服务".yellow() + ); + } + } + + ensure_tftpd_hpa_service_ready(is_root)?; + + Ok(config) +} + +#[cfg(target_os = "linux")] +pub fn stage_linux_fit_image( + fitimage: &Path, + tftp_root: &Path, +) -> anyhow::Result { + let prepared = prepare_linux_tftp_paths(fitimage, tftp_root)?; + ensure_tftp_target_dir(&prepared.target_dir)?; + fs::copy(fitimage, &prepared.absolute_fit_path).with_path("failed to copy file", fitimage)?; + Ok(prepared) +} + +/// Starts a built-in TFTP server serving files from the build output directory. pub fn run_tftp_server(tool: &Tool) -> anyhow::Result<()> { - // TFTP server implementation goes here let mut file_dir = tool.manifest_dir().clone(); if let Some(elf_path) = &tool.ctx().artifacts.elf { file_dir = elf_path @@ -55,13 +169,582 @@ pub fn run_tftp_server(tool: &Tool) -> anyhow::Result<()> { std::thread::spawn(move || { let mut server = Server::new(&config) - .inspect_err(|e| { - println!("{}", e); - println!("{}","TFTP server 启动失败:{e:?}。若权限不足,尝试执行 `sudo setcap cap_net_bind_service=+eip $(which cargo-osrun)&&sudo setcap cap_net_bind_service=+eip $(which ostool)` 并重启终端".red()); - std::process::exit(1); - }).unwrap(); + .inspect_err(|e| { + println!("{e}"); + println!("{}", "TFTP server 启动失败:若权限不足,尝试执行 `sudo setcap cap_net_bind_service=+eip $(which cargo-osrun)&&sudo setcap cap_net_bind_service=+eip $(which ostool)` 并重启终端".red()); + std::process::exit(1); + }) + .unwrap(); server.listen(); }); Ok(()) } + +fn find_tftpd_binary() -> Option { + find_command_path("in.tftpd").or_else(|| { + [ + "/usr/sbin/in.tftpd", + "/sbin/in.tftpd", + "/usr/bin/in.tftpd", + "/usr/sbin/tftpd", + ] + .into_iter() + .map(PathBuf::from) + .find(|path| path.exists()) + }) +} + +fn find_command_path(program: &str) -> Option { + let path = env::var_os("PATH")?; + env::split_paths(&path) + .map(|dir| dir.join(program)) + .find(|candidate| candidate.is_file()) +} + +fn command_exists(program: &str) -> bool { + find_command_path(program).is_some() +} + +fn prompt_yes_no(prompt: &str) -> anyhow::Result { + print!("{prompt}"); + io::stdout().flush().context("failed to flush stdout")?; + let mut answer = String::new(); + io::stdin() + .read_line(&mut answer) + .context("failed to read user input")?; + let answer = answer.trim().to_ascii_lowercase(); + Ok(matches!(answer.as_str(), "y" | "yes")) +} + +fn detect_distro_kind() -> anyhow::Result { + let os_release = fs::read_to_string("/etc/os-release") + .context("failed to read /etc/os-release for distro detection")?; + Ok(DistroKind::from_os_release(&os_release)) +} + +fn build_install_plan(distro: DistroKind) -> anyhow::Result { + let commands = match distro { + DistroKind::Debian => vec![ + CommandSpec { + program: "apt-get".into(), + args: vec!["update".into()], + }, + CommandSpec { + program: "apt-get".into(), + args: vec!["install".into(), "-y".into(), "tftpd-hpa".into()], + }, + ], + DistroKind::Rhel => { + let package_manager = if command_exists("dnf") { "dnf" } else { "yum" }; + vec![CommandSpec { + program: package_manager.into(), + args: vec!["install".into(), "-y".into(), "tftp-server".into()], + }] + } + DistroKind::Arch => vec![CommandSpec { + program: "pacman".into(), + args: vec!["-Sy".into(), "--noconfirm".into(), "tftp-hpa".into()], + }], + DistroKind::OpenSuse => vec![CommandSpec { + program: "zypper".into(), + args: vec!["install".into(), "-y".into(), "tftp".into()], + }], + DistroKind::Alpine => vec![CommandSpec { + program: "apk".into(), + args: vec!["add".into(), "tftp-hpa".into()], + }], + DistroKind::Unsupported => vec![], + }; + + Ok(InstallPlan { distro, commands }) +} + +fn render_command_chain(commands: &[CommandSpec], is_root: bool) -> String { + commands + .iter() + .map(|command| render_command(command, is_root)) + .collect::>() + .join(" && ") +} + +fn render_command(command: &CommandSpec, is_root: bool) -> String { + let mut parts = Vec::with_capacity(command.args.len() + 2); + if !is_root { + parts.push("sudo".to_string()); + } + parts.push(command.program.clone()); + parts.extend(command.args.clone()); + parts.join(" ") +} + +fn run_privileged_command(command: &CommandSpec, is_root: bool) -> anyhow::Result<()> { + eprintln!("{}", render_command(command, is_root).purple()); + let mut process = if is_root { + let mut process = Command::new(&command.program); + process.args(&command.args); + process + } else { + let mut process = Command::new("sudo"); + process.arg(&command.program).args(&command.args); + process + }; + + let status = process + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .with_context(|| format!("failed to start `{}`", command.program))?; + + if status.success() { + Ok(()) + } else { + bail!("command `{}` exited with status {status}", command.program) + } +} + +fn run_capture(program: &str, args: &[&str]) -> anyhow::Result { + let output = Command::new(program) + .args(args) + .output() + .with_context(|| format!("failed to execute `{program}`"))?; + + if !output.status.success() { + bail!("command `{program}` exited with status {}", output.status); + } + + let text = String::from_utf8(output.stdout) + .with_context(|| format!("failed to decode output from `{program}`"))?; + Ok(text.trim().to_string()) +} + +fn is_root_user() -> anyhow::Result { + Ok(run_capture("id", &["-u"])? == "0") +} + +fn ensure_tftpd_hpa_service_ready(is_root: bool) -> anyhow::Result<()> { + if udp_port_69_is_listening()? { + info!("tftpd-hpa is already listening on UDP port 69"); + return Ok(()); + } + + if command_exists("systemctl") { + println!( + "{}", + "tftpd-hpa 当前未监听 UDP 69,正在尝试启动/重启服务".yellow() + ); + let restart = CommandSpec { + program: "systemctl".into(), + args: vec!["restart".into(), "tftpd-hpa".into()], + }; + run_privileged_command(&restart, is_root) + .context("failed to restart tftpd-hpa service")?; + + if udp_port_69_is_listening()? { + info!("tftpd-hpa is now listening on UDP port 69"); + return Ok(()); + } + + let active = run_capture("systemctl", &["is-active", "tftpd-hpa"]) + .unwrap_or_else(|_| "unknown".to_string()); + bail!( + "tftpd-hpa 服务重启后仍未监听 UDP 69(systemctl is-active: {active})" + ); + } + + bail!("未检测到可用的服务管理器,且 tftpd-hpa 当前未监听 UDP 69,请手动启动服务"); +} + +fn udp_port_69_is_listening() -> anyhow::Result { + let output = run_capture("ss", &["-lun"])?; + Ok(ss_output_has_udp_port_69(&output)) +} + +fn ss_output_has_udp_port_69(output: &str) -> bool { + output.lines().any(|line| { + let line = line.trim(); + !line.is_empty() + && !line.starts_with("State") + && line.split_whitespace().any(|field| { + field.ends_with(":69") + || field.ends_with(":69,") + || field.ends_with("]:69") + || field == "*:69" + || field == "0.0.0.0:69" + || field == "[::]:69" + }) + }) +} + +fn ensure_tftpd_hpa_config(path: &Path, is_root: bool) -> anyhow::Result<(TftpdHpaConfig, bool)> { + if path.exists() { + let content = fs::read_to_string(path).with_path("failed to read file", path)?; + let config = TftpdHpaConfig::parse(&content)?; + return Ok((config, false)); + } + + let content = TftpdHpaConfig::render_default(); + write_root_owned_file(path, &content, is_root)?; + let config = TftpdHpaConfig::parse(&content)?; + Ok((config, true)) +} + +fn write_root_owned_file(path: &Path, content: &str, is_root: bool) -> anyhow::Result<()> { + let parent = path + .parent() + .ok_or_else(|| anyhow!("path {} has no parent directory", path.display()))?; + + if is_root { + fs::create_dir_all(parent).with_path("failed to create directory", parent)?; + fs::write(path, content).with_path("failed to write file", path)?; + return Ok(()); + } + + let temp_path = temp_file_path("ostool-tftpd-hpa"); + fs::write(&temp_path, content).with_path("failed to write temp file", &temp_path)?; + + let mkdir = CommandSpec { + program: "mkdir".into(), + args: vec!["-p".into(), parent.display().to_string()], + }; + let copy = CommandSpec { + program: "cp".into(), + args: vec![temp_path.display().to_string(), path.display().to_string()], + }; + + let mkdir_result = run_privileged_command(&mkdir, false); + let copy_result = run_privileged_command(©, false); + let _ = fs::remove_file(&temp_path); + + mkdir_result?; + copy_result?; + Ok(()) +} + +fn temp_file_path(prefix: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + env::temp_dir().join(format!("{prefix}-{}-{nanos}.tmp", std::process::id())) +} + +fn prepare_linux_tftp_paths( + fitimage: &Path, + tftp_root: &Path, +) -> anyhow::Result { + let artifact_dir = fitimage + .parent() + .ok_or_else(|| anyhow!("invalid FIT image path: {}", fitimage.display()))?; + let relative_dir = relative_tftp_directory(artifact_dir)?; + let relative_path = relative_dir.join( + fitimage + .file_name() + .ok_or_else(|| anyhow!("invalid FIT image filename: {}", fitimage.display()))?, + ); + let target_dir = tftp_root.join(&relative_dir); + let absolute_fit_path = tftp_root.join(&relative_path); + let relative_filename = relative_path.to_string_lossy().replace('\\', "/"); + + Ok(LinuxTftpPrepared { + tftp_root: tftp_root.to_path_buf(), + target_dir, + absolute_fit_path, + relative_filename, + }) +} + +fn relative_tftp_directory(artifact_dir: &Path) -> anyhow::Result { + if !artifact_dir.is_absolute() { + bail!( + "artifact directory must be absolute for Linux system TFTP: {}", + artifact_dir.display() + ); + } + + let mut relative = PathBuf::from("ostool"); + for component in artifact_dir.components() { + match component { + Component::RootDir => {} + Component::Normal(part) => relative.push(part), + Component::CurDir => {} + Component::ParentDir => { + bail!( + "artifact directory must not contain parent segments: {}", + artifact_dir.display() + ) + } + Component::Prefix(prefix) => relative.push(prefix.as_os_str()), + } + } + Ok(relative) +} + +fn ensure_tftp_target_dir(target_dir: &Path) -> anyhow::Result<()> { + match fs::create_dir_all(target_dir) { + Ok(()) => return Ok(()), + Err(err) if err.kind() == io::ErrorKind::PermissionDenied => {} + Err(err) => return Err(err).with_path("failed to create directory", target_dir), + } + + let user = effective_user()?; + let mkdir = CommandSpec { + program: "mkdir".into(), + args: vec!["-p".into(), target_dir.display().to_string()], + }; + run_privileged_command(&mkdir, false) + .with_context(|| format!("failed to create directory {}", target_dir.display()))?; + + let chown = CommandSpec { + program: "chown".into(), + args: vec![ + "-R".into(), + format!("{}:{}", user.name, user.group), + target_dir.display().to_string(), + ], + }; + run_privileged_command(&chown, false) + .with_context(|| format!("failed to change ownership for {}", target_dir.display()))?; + Ok(()) +} + +fn effective_user() -> anyhow::Result { + let name = env::var("SUDO_USER") + .ok() + .filter(|value| !value.trim().is_empty()) + .or_else(|| { + env::var("USER") + .ok() + .filter(|value| !value.trim().is_empty()) + }) + .unwrap_or(run_capture("id", &["-un"])?); + + let group = run_capture("id", &["-gn", &name])?; + Ok(EffectiveUser { name, group }) +} + +impl TftpdHpaConfig { + fn parse(content: &str) -> anyhow::Result { + let mut username = None; + let mut directory = None; + let mut address = None; + let mut options = None; + + for line in content.lines() { + let Some((key, value)) = parse_key_value(line) else { + continue; + }; + match key { + "TFTP_USERNAME" => username = Some(value), + "TFTP_DIRECTORY" => directory = Some(value), + "TFTP_ADDRESS" => address = Some(value), + "TFTP_OPTIONS" => options = Some(value), + _ => {} + } + } + + let directory = directory + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| anyhow!("tftpd-hpa config is missing TFTP_DIRECTORY"))?; + + Ok(Self { + username, + directory: PathBuf::from(directory), + address, + options, + }) + } + + fn render_default() -> String { + format!( + "TFTP_USERNAME=\"tftp\"\nTFTP_DIRECTORY=\"{DEFAULT_TFTP_DIRECTORY}\"\nTFTP_ADDRESS=\":69\"\nTFTP_OPTIONS=\"-l -s -c\"\n" + ) + } +} + +fn parse_key_value(line: &str) -> Option<(&str, String)> { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + return None; + } + let (key, value) = trimmed.split_once('=')?; + Some((key.trim(), unquote(value.trim()))) +} + +fn unquote(value: &str) -> String { + let mut chars = value.chars(); + match (chars.next(), value.chars().last()) { + (Some('"'), Some('"')) | (Some('\''), Some('\'')) if value.len() >= 2 => { + value[1..value.len() - 1].to_string() + } + _ => value.to_string(), + } +} + +impl DistroKind { + fn from_os_release(content: &str) -> Self { + let mut ids = Vec::new(); + + for line in content.lines() { + let Some((key, value)) = parse_key_value(line) else { + continue; + }; + match key { + "ID" => ids.push(value), + "ID_LIKE" => ids.extend(value.split_whitespace().map(ToOwned::to_owned)), + _ => {} + } + } + + if ids + .iter() + .any(|id| matches!(id.as_str(), "debian" | "ubuntu")) + { + return Self::Debian; + } + if ids.iter().any(|id| { + matches!( + id.as_str(), + "fedora" | "rhel" | "centos" | "rocky" | "almalinux" + ) + }) { + return Self::Rhel; + } + if ids + .iter() + .any(|id| matches!(id.as_str(), "arch" | "archlinux" | "manjaro")) + { + return Self::Arch; + } + if ids.iter().any(|id| { + matches!( + id.as_str(), + "opensuse" | "opensuse-tumbleweed" | "sles" | "suse" + ) + }) { + return Self::OpenSuse; + } + if ids.iter().any(|id| id == "alpine") { + return Self::Alpine; + } + + Self::Unsupported + } + + fn label(self) -> &'static str { + match self { + Self::Debian => "debian/ubuntu", + Self::Rhel => "rhel/fedora", + Self::Arch => "arch", + Self::OpenSuse => "opensuse/sles", + Self::Alpine => "alpine", + Self::Unsupported => "unsupported", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn distro_detection_uses_id_and_id_like() { + let ubuntu = r#" +ID=ubuntu +ID_LIKE=debian +"#; + let rocky = r#" +ID=rocky +ID_LIKE="rhel fedora" +"#; + let arch = "ID=manjaro\nID_LIKE=arch\n"; + + assert_eq!(DistroKind::from_os_release(ubuntu), DistroKind::Debian); + assert_eq!(DistroKind::from_os_release(rocky), DistroKind::Rhel); + assert_eq!(DistroKind::from_os_release(arch), DistroKind::Arch); + } + + #[test] + fn render_command_chain_adds_sudo_for_non_root() { + let commands = vec![ + CommandSpec { + program: "apt-get".into(), + args: vec!["update".into()], + }, + CommandSpec { + program: "apt-get".into(), + args: vec!["install".into(), "-y".into(), "tftpd-hpa".into()], + }, + ]; + + assert_eq!( + render_command_chain(&commands, false), + "sudo apt-get update && sudo apt-get install -y tftpd-hpa" + ); + assert_eq!( + render_command_chain(&commands, true), + "apt-get update && apt-get install -y tftpd-hpa" + ); + } + + #[test] + fn default_tftpd_hpa_config_matches_plan() { + assert_eq!( + TftpdHpaConfig::render_default(), + "TFTP_USERNAME=\"tftp\"\nTFTP_DIRECTORY=\"/srv/tftp\"\nTFTP_ADDRESS=\":69\"\nTFTP_OPTIONS=\"-l -s -c\"\n" + ); + } + + #[test] + fn parse_existing_tftpd_hpa_directory() { + let config = TftpdHpaConfig::parse( + r#" +TFTP_USERNAME="tftp" +TFTP_DIRECTORY="/mnt/d/tftpboot/" +TFTP_ADDRESS=":69" +TFTP_OPTIONS="-l -s -c" +"#, + ) + .unwrap(); + + assert_eq!(config.directory, PathBuf::from("/mnt/d/tftpboot/")); + assert_eq!(config.options.as_deref(), Some("-l -s -c")); + } + + #[test] + fn relative_filename_keeps_absolute_artifact_hierarchy() { + let fitimage = + Path::new("/home/zhourui/opensource/tgoskits2/target/aarch64/release/image.fit"); + let prepared = prepare_linux_tftp_paths(fitimage, Path::new("/srv/tftp")).unwrap(); + + assert_eq!( + prepared.relative_filename, + "ostool/home/zhourui/opensource/tgoskits2/target/aarch64/release/image.fit" + ); + assert_eq!( + prepared.target_dir, + PathBuf::from( + "/srv/tftp/ostool/home/zhourui/opensource/tgoskits2/target/aarch64/release" + ) + ); + assert_eq!( + prepared.absolute_fit_path, + PathBuf::from( + "/srv/tftp/ostool/home/zhourui/opensource/tgoskits2/target/aarch64/release/image.fit" + ) + ); + } + + #[test] + fn ss_port_detection_matches_udp_69_listener() { + let output = "\ +State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess\n\ +UNCONN 0 0 0.0.0.0:69 0.0.0.0:*\n"; + + assert!(ss_output_has_udp_port_69(output)); + assert!(!ss_output_has_udp_port_69( + "State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess\n" + )); + } +} diff --git a/ostool/src/run/uboot.rs b/ostool/src/run/uboot.rs index e6264ad..448b6ab 100644 --- a/ostool/src/run/uboot.rs +++ b/ostool/src/run/uboot.rs @@ -88,6 +88,8 @@ pub struct Net { pub board_ip: Option, pub gatewayip: Option, pub netmask: Option, + /// Use an existing TFTP root directory directly. On Linux this skips all + /// tftpd-hpa detection, installation, config, and service checks. pub tftp_dir: Option, } @@ -156,6 +158,13 @@ struct Runner<'a> { baud_rate: u32, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct NetworkBootRequest { + bootfile: String, + bootcmd: String, + ipaddr: Option, +} + impl Runner<'_> { /// 生成压缩的 FIT image 包含 kernel 和 FDT /// @@ -300,12 +309,46 @@ impl Runner<'_> { .config .net .as_ref() - .and_then(|net| net.tftp_dir.as_ref()) - .is_some(); + .and_then(|net| net.tftp_dir.as_deref()) + .map(str::trim) + .filter(|path| !path.is_empty()) + .map(PathBuf::from); + + #[cfg(target_os = "linux")] + let linux_system_tftp = if let Some(directory) = is_tftp.clone() { + info!( + "Linux detected: using net.tftp_dir={} and skipping all tftpd-hpa checks", + directory.display() + ); + Some(tftp::TftpdHpaConfig { + username: None, + directory, + address: None, + options: None, + }) + } else if self.config.net.is_some() && ip_string.is_some() { + Some(tftp::ensure_linux_tftpd_hpa()?) + } else { + None + }; + + let mut builtin_tftp_started = false; - if !is_tftp && let Some(ip) = ip_string.as_ref() { + #[cfg(not(target_os = "linux"))] + if is_tftp.is_none() && let Some(ip) = ip_string.as_ref() { info!("TFTP server IP: {}", ip); tftp::run_tftp_server(self.tool)?; + builtin_tftp_started = true; + } + + #[cfg(target_os = "linux")] + if linux_system_tftp.is_none() + && is_tftp.is_none() + && let Some(ip) = ip_string.as_ref() + { + info!("TFTP server IP: {}", ip); + tftp::run_tftp_server(self.tool)?; + builtin_tftp_started = true; } info!( @@ -431,7 +474,19 @@ impl Runner<'_> { ) .await?; - let fitname = if is_tftp { + #[cfg(target_os = "linux")] + let mut linux_system_tftp_active = false; + + #[cfg(target_os = "linux")] + let fitname = if let Some(system_tftp) = linux_system_tftp.as_ref() { + let prepared = tftp::stage_linux_fit_image(&fitimage, &system_tftp.directory)?; + linux_system_tftp_active = true; + info!( + "Staged FIT image to: {}", + prepared.absolute_fit_path.display() + ); + prepared.relative_filename + } else if is_tftp.is_some() { let tftp_dir = self .config .net @@ -454,17 +509,56 @@ impl Runner<'_> { name.to_string() }; - let bootcmd = - if let Some(ref board_ip) = self.config.net.as_ref().and_then(|e| e.board_ip.clone()) { + #[cfg(not(target_os = "linux"))] + let fitname = if is_tftp.is_some() { + let tftp_dir = self + .config + .net + .as_ref() + .and_then(|net| net.tftp_dir.as_ref()) + .unwrap(); + + let fitimage = fitimage.file_name().unwrap(); + let tftp_path = PathBuf::from(tftp_dir).join(fitimage); + + info!("Setting TFTP file path: {}", tftp_path.display()); + tftp_path.display().to_string() + } else { + let name = fitimage + .file_name() + .and_then(|n| n.to_str()) + .ok_or(anyhow!("Invalid fitimage filename"))?; + + info!("Using fitimage filename: {}", name); + name.to_string() + }; + + #[cfg(target_os = "linux")] + let network_transfer_ready = + linux_system_tftp_active || is_tftp.is_some() || builtin_tftp_started; + + #[cfg(not(target_os = "linux"))] + let network_transfer_ready = is_tftp.is_some() || builtin_tftp_started; + + let bootcmd = if let Some(request) = build_network_boot_request( + self.config + .net + .as_ref() + .and_then(|net| net.board_ip.as_deref()), + net_ok, + network_transfer_ready, + &fitname, + ) { + if let Some(ref board_ip) = request.ipaddr { uboot.set_env("ipaddr", board_ip)?; - format!("tftp {fitname} && bootm",) - } else if net_ok { - format!("dhcp {fitname} && bootm",) - } else { - info!("No TFTP config, using loady to upload FIT image..."); - Self::uboot_loady(&mut uboot, fit_loadaddr as usize, fitimage); - "bootm".to_string() - }; + } + uboot.set_env("bootfile", &request.bootfile)?; + request.bootcmd + } else { + info!("No TFTP config, using loady to upload FIT image..."); + Self::uboot_loady(&mut uboot, fit_loadaddr as usize, fitimage); + "bootm".to_string() + }; info!("Booting kernel with command: {}", bootcmd); uboot.cmd_without_reply(&bootcmd)?; @@ -616,3 +710,71 @@ impl Runner<'_> { println!("send ok"); } } + +fn build_network_boot_request( + board_ip: Option<&str>, + net_ok: bool, + network_transfer_ready: bool, + fitname: &str, +) -> Option { + if !network_transfer_ready { + return None; + } + + if let Some(board_ip) = board_ip { + return Some(NetworkBootRequest { + bootfile: fitname.to_string(), + bootcmd: format!("tftp {fitname} && bootm"), + ipaddr: Some(board_ip.to_string()), + }); + } + + if net_ok { + return Some(NetworkBootRequest { + bootfile: fitname.to_string(), + bootcmd: format!("dhcp {fitname} && bootm"), + ipaddr: None, + }); + } + + None +} + +#[cfg(test)] +mod tests { + use super::build_network_boot_request; + + #[test] + fn network_boot_request_uses_same_filename_for_bootfile() { + let request = build_network_boot_request( + Some("192.168.1.10"), + false, + true, + "ostool/home/user/workspace/target/image.fit", + ) + .unwrap(); + + assert_eq!( + request.bootfile, + "ostool/home/user/workspace/target/image.fit" + ); + assert_eq!( + request.bootcmd, + "tftp ostool/home/user/workspace/target/image.fit && bootm" + ); + } + + #[test] + fn network_boot_request_requires_ready_transport() { + assert!( + build_network_boot_request(Some("192.168.1.10"), false, false, "image.fit").is_none() + ); + assert!(build_network_boot_request(None, false, true, "image.fit").is_none()); + assert_eq!( + build_network_boot_request(None, true, true, "image.fit") + .unwrap() + .bootcmd, + "dhcp image.fit && bootm" + ); + } +} From 9180882b925435f7b34859dbfd62b9f7e486ea7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E7=9D=BF?= Date: Wed, 25 Mar 2026 14:53:54 +0800 Subject: [PATCH 5/9] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20shell=20?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=88=9D=E5=A7=8B=E5=8C=96=E5=8C=B9=E9=85=8D?= =?UTF-8?q?=E5=99=A8=EF=BC=8C=E6=94=AF=E6=8C=81=20QEMU=20=E5=92=8C=20U-Boo?= =?UTF-8?q?t=20=E7=9A=84=20shell=20=E5=91=BD=E4=BB=A4=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=8F=91=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ostool/src/run/mod.rs | 3 + ostool/src/run/qemu.rs | 162 ++++++++++++++++++++++++-- ostool/src/run/shell_init.rs | 215 +++++++++++++++++++++++++++++++++++ ostool/src/run/uboot.rs | 168 ++++++++++++++++++++------- 4 files changed, 500 insertions(+), 48 deletions(-) create mode 100644 ostool/src/run/shell_init.rs diff --git a/ostool/src/run/mod.rs b/ostool/src/run/mod.rs index 6478661..c632646 100644 --- a/ostool/src/run/mod.rs +++ b/ostool/src/run/mod.rs @@ -21,3 +21,6 @@ mod output_matcher; /// OVMF prebuilt firmware downloader (internal). mod ovmf_prebuilt; + +/// Shared shell auto-init matcher and delayed command sender. +mod shell_init; diff --git a/ostool/src/run/qemu.rs b/ostool/src/run/qemu.rs index 2f9355b..9144b1b 100644 --- a/ostool/src/run/qemu.rs +++ b/ostool/src/run/qemu.rs @@ -22,18 +22,19 @@ use std::{ ffi::OsString, - io::{self, BufReader, ErrorKind, Read, Write}, + io::{self, BufReader, ErrorKind, IsTerminal, Read, Write}, path::Path, path::PathBuf, process::{Child, Stdio}, - sync::mpsc, + sync::{Arc, Mutex, mpsc}, thread, time::Duration, }; use anyhow::{Context, anyhow}; use colored::Colorize; -use crossterm::terminal::disable_raw_mode; +use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; +use log::warn; use object::Architecture; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -44,6 +45,7 @@ use crate::{ run::{ output_matcher::{ByteStreamMatcher, StreamMatch, StreamMatchKind}, ovmf_prebuilt::{Arch, FileType, Prebuilt, Source}, + shell_init::{ShellAutoInitMatcher, normalize_shell_init_config, spawn_delayed_send}, }, utils::PathResultExt, }; @@ -71,6 +73,24 @@ pub struct QemuConfig { pub success_regex: Vec, /// Regex patterns that indicate failed execution. pub fail_regex: Vec, + /// String prefix that indicates the guest shell is ready. + pub shell_prefix: Option, + /// Command sent once after `shell_prefix` is detected. + pub shell_init_cmd: Option, +} + +impl QemuConfig { + fn normalize(&mut self, config_name: &str) -> anyhow::Result<()> { + normalize_shell_init_config( + &mut self.shell_prefix, + &mut self.shell_init_cmd, + config_name, + ) + } + + fn shell_auto_init(&self) -> Option { + ShellAutoInitMatcher::new(self.shell_prefix.clone(), self.shell_init_cmd.clone()) + } } /// Arguments for running QEMU. @@ -152,12 +172,14 @@ async fn load_or_create_qemu_config( let config_content = fs::read_to_string(&config_path) .await .with_path("failed to read file", &config_path)?; - let config: QemuConfig = toml::from_str(&config_content) + let mut config: QemuConfig = toml::from_str(&config_content) .with_context(|| format!("failed to parse QEMU config: {}", config_path.display()))?; + config.normalize(&format!("QEMU config {}", config_path.display()))?; return Ok(config); } - let config = build_default_qemu_config(tool.ctx.arch, overrides); + let mut config = build_default_qemu_config(tool.ctx.arch, overrides); + config.normalize(&format!("QEMU config {}", config_path.display()))?; fs::write(&config_path, toml::to_string_pretty(&config)?) .await .with_path("failed to write file", &config_path)?; @@ -217,6 +239,27 @@ struct QemuRunner<'a> { fail_regex: Vec, } +struct RawModeGuard { + enabled: bool, +} + +impl RawModeGuard { + fn new() -> Self { + let enabled = + io::stdin().is_terminal() && io::stdout().is_terminal() && enable_raw_mode().is_ok(); + Self { enabled } + } +} + +impl Drop for RawModeGuard { + fn drop(&mut self) { + if self.enabled { + let _ = disable_raw_mode(); + let _ = io::stdout().flush(); + } + } +} + impl QemuRunner<'_> { async fn run(&mut self) -> anyhow::Result<()> { self.preper_regex()?; @@ -318,13 +361,20 @@ impl QemuRunner<'_> { cmd.arg("-kernel").arg(elf_path); } } + cmd.stdin(Stdio::piped()); cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::piped()); cmd.print_cmd(); let mut child = cmd.spawn()?; + let stdin = child.stdin.take().map(|stdin| Arc::new(Mutex::new(stdin))); + let _raw_mode = RawModeGuard::new(); + if let Some(stdin) = stdin.as_ref() { + Self::spawn_stdin_passthrough(stdin.clone()); + } let mut matcher = ByteStreamMatcher::new(self.success_regex.clone(), self.fail_regex.clone()); - Self::process_output_stream(&mut child, &mut matcher)?; + let mut shell_auto_init = self.config.shell_auto_init(); + Self::process_output_stream(&mut child, &mut matcher, &mut shell_auto_init, stdin)?; let out = child.wait_with_output()?; if let Some(res) = matcher.final_result() { @@ -464,6 +514,8 @@ impl QemuRunner<'_> { fn process_output_stream( child: &mut Child, matcher: &mut ByteStreamMatcher, + shell_auto_init: &mut Option, + stdin: Option>>, ) -> anyhow::Result<()> { let stdout = child .stdout @@ -499,6 +551,13 @@ impl QemuRunner<'_> { if let Some(matched) = matcher.observe_byte(byte) { Self::print_match_event(&matched); } + + if let Some(shell_auto_init) = shell_auto_init.as_mut() + && let Some(command) = shell_auto_init.observe_byte(byte) + && let Some(stdin) = stdin.as_ref() + { + spawn_delayed_send(stdin.clone(), command); + } } Ok(None) => break, Err(mpsc::RecvTimeoutError::Timeout) => {} @@ -514,6 +573,39 @@ impl QemuRunner<'_> { Ok(()) } + fn spawn_stdin_passthrough(stdin: Arc>) { + thread::spawn(move || { + let mut stdin_reader = io::stdin().lock(); + let mut buffer = [0u8; 1024]; + + loop { + match stdin_reader.read(&mut buffer) { + Ok(0) => break, + Ok(n) => { + let result = (|| -> io::Result<()> { + let mut child_stdin = stdin.lock().unwrap(); + child_stdin.write_all(&buffer[..n])?; + child_stdin.flush()?; + Ok(()) + })(); + + if let Err(err) = result { + if err.kind() != ErrorKind::BrokenPipe { + warn!("failed to forward stdin to QEMU: {err}"); + } + break; + } + } + Err(err) if err.kind() == ErrorKind::Interrupted => continue, + Err(err) => { + warn!("failed to read stdin for QEMU passthrough: {err}"); + break; + } + } + } + }); + } + fn print_match_event(matched: &StreamMatch) { match matched.kind { StreamMatchKind::Success => println!( @@ -630,7 +722,13 @@ mod tests { use std::path::PathBuf; use tempfile::TempDir; - use crate::{Tool, ToolConfig}; + use crate::{ + Tool, ToolConfig, + run::{ + output_matcher::{ByteStreamMatcher, StreamMatchKind}, + shell_init::ShellAutoInitMatcher, + }, + }; fn write_single_crate_manifest(dir: &std::path::Path) { std::fs::write( @@ -692,6 +790,56 @@ mod tests { assert_eq!(config.args, vec!["-nographic", "-smp", "2"]); } + #[test] + fn qemu_config_normalize_rejects_shell_init_without_prefix() { + let mut config = QemuConfig { + shell_init_cmd: Some("root".into()), + ..Default::default() + }; + + let err = config.normalize("test config").unwrap_err(); + assert!(err.to_string().contains("shell_prefix")); + } + + #[test] + fn qemu_config_normalize_trims_shell_fields() { + let mut config = QemuConfig { + shell_prefix: Some(" login: ".into()), + shell_init_cmd: Some(" root ".into()), + ..Default::default() + }; + + config.normalize("test config").unwrap(); + + assert_eq!(config.shell_prefix.as_deref(), Some("login:")); + assert_eq!(config.shell_init_cmd.as_deref(), Some("root")); + } + + #[test] + fn qemu_shell_auto_init_can_coexist_with_success_matcher() { + let mut matcher = ByteStreamMatcher::new( + vec![regex::Regex::new("ready").unwrap()], + vec![regex::Regex::new("__never_fail__").unwrap()], + ); + let mut shell_init = + ShellAutoInitMatcher::new(Some("login:".to_string()), Some("root".to_string())) + .unwrap(); + let mut sent = None; + + for byte in b"login: system ready\n" { + if sent.is_none() { + sent = shell_init.observe_byte(*byte); + } else { + let _ = shell_init.observe_byte(*byte); + } + let _ = matcher.observe_byte(*byte); + } + + let matched = matcher.matched().unwrap(); + assert_eq!(matched.kind, StreamMatchKind::Success); + assert_eq!(sent.as_deref(), Some(&b"root\n"[..])); + } + #[test] fn uefi_artifact_dir_prefers_runtime_artifact_dir() { let runtime_dir = PathBuf::from("/tmp/ostool-runtime"); diff --git a/ostool/src/run/shell_init.rs b/ostool/src/run/shell_init.rs new file mode 100644 index 0000000..f6cce72 --- /dev/null +++ b/ostool/src/run/shell_init.rs @@ -0,0 +1,215 @@ +use std::{ + io::{self, Write}, + sync::{Arc, Mutex}, + thread, + time::Duration, +}; + +use anyhow::{Result, bail}; +use log::warn; + +pub(crate) const SHELL_INIT_DELAY: Duration = Duration::from_millis(100); + +pub(crate) fn normalize_shell_init_config( + shell_prefix: &mut Option, + shell_init_cmd: &mut Option, + config_name: &str, +) -> Result<()> { + normalize_optional_field(shell_prefix); + normalize_optional_field(shell_init_cmd); + + if shell_init_cmd.is_some() && shell_prefix.is_none() { + bail!("`shell_init_cmd` requires `shell_prefix` in {config_name}"); + } + + Ok(()) +} + +fn normalize_optional_field(value: &mut Option) { + if let Some(raw) = value { + let trimmed = raw.trim(); + if trimmed.is_empty() { + *value = None; + } else if trimmed.len() != raw.len() { + *raw = trimmed.to_string(); + } + } +} + +pub(crate) fn prepare_shell_init_cmd(command: &str) -> Vec { + let mut normalized = command.trim_end_matches(['\r', '\n']).as_bytes().to_vec(); + normalized.push(b'\n'); + normalized +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ShellAutoInitMatcher { + shell_prefix: String, + shell_init_cmd: Vec, + history: Vec, + triggered: bool, +} + +impl ShellAutoInitMatcher { + pub(crate) fn new( + shell_prefix: Option, + shell_init_cmd: Option, + ) -> Option { + match (shell_prefix, shell_init_cmd) { + (Some(shell_prefix), Some(shell_init_cmd)) => Some(Self { + history: Vec::with_capacity(shell_prefix.len().max(64)), + shell_prefix, + shell_init_cmd: prepare_shell_init_cmd(&shell_init_cmd), + triggered: false, + }), + _ => None, + } + } + + pub(crate) fn observe_byte(&mut self, byte: u8) -> Option> { + if self.triggered { + return None; + } + + self.history.push(byte); + self.trim_history(); + + if String::from_utf8_lossy(&self.history).contains(&self.shell_prefix) { + self.triggered = true; + return Some(self.shell_init_cmd.clone()); + } + + None + } + + fn trim_history(&mut self) { + let max_len = self.shell_prefix.len().max(64) * 8; + if self.history.len() > max_len { + let excess = self.history.len() - max_len; + self.history.drain(..excess); + } + } +} + +pub(crate) fn spawn_delayed_send(writer: Arc>, command: Vec) +where + W: Write + Send + 'static, +{ + thread::spawn(move || { + thread::sleep(SHELL_INIT_DELAY); + + let result = (|| -> io::Result<()> { + let mut writer = writer.lock().unwrap(); + writer.write_all(&command)?; + writer.flush()?; + Ok(()) + })(); + + if let Err(err) = result { + warn!("failed to send shell_init_cmd: {err}"); + } + }); +} + +#[cfg(test)] +mod tests { + use super::{ + SHELL_INIT_DELAY, ShellAutoInitMatcher, normalize_shell_init_config, + prepare_shell_init_cmd, spawn_delayed_send, + }; + use std::{ + io::{self, Write}, + sync::{Arc, Mutex}, + time::{Duration, Instant}, + }; + + #[derive(Default)] + struct MemoryWriter { + bytes: Vec, + } + + impl Write for MemoryWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.bytes.extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + #[test] + fn normalize_shell_init_config_rejects_missing_prefix() { + let mut shell_prefix = None; + let mut shell_init_cmd = Some("echo ready".to_string()); + + let err = + normalize_shell_init_config(&mut shell_prefix, &mut shell_init_cmd, "QEMU config") + .unwrap_err(); + + assert!(err.to_string().contains("shell_prefix")); + } + + #[test] + fn normalize_shell_init_config_trims_fields() { + let mut shell_prefix = Some(" login: ".to_string()); + let mut shell_init_cmd = Some(" root ".to_string()); + + normalize_shell_init_config(&mut shell_prefix, &mut shell_init_cmd, "QEMU config").unwrap(); + + assert_eq!(shell_prefix.as_deref(), Some("login:")); + assert_eq!(shell_init_cmd.as_deref(), Some("root")); + } + + #[test] + fn prepare_shell_init_cmd_appends_single_newline() { + assert_eq!(prepare_shell_init_cmd("root"), b"root\n"); + assert_eq!(prepare_shell_init_cmd("root\n"), b"root\n"); + assert_eq!(prepare_shell_init_cmd("root\r\n"), b"root\n"); + } + + #[test] + fn shell_auto_init_matcher_triggers_once() { + let mut matcher = + ShellAutoInitMatcher::new(Some("login:".to_string()), Some("root".to_string())) + .unwrap(); + + let mut matched = None; + for byte in b"noise login: login:" { + if let Some(command) = matcher.observe_byte(*byte) { + matched = Some(command); + } + } + + assert_eq!(matched.as_deref(), Some(&b"root\n"[..])); + assert_eq!(matcher.observe_byte(b':'), None); + } + + #[test] + fn spawn_delayed_send_writes_after_delay() { + let writer = Arc::new(Mutex::new(MemoryWriter::default())); + let start = Instant::now(); + + spawn_delayed_send(writer.clone(), b"root\n".to_vec()); + + std::thread::sleep(SHELL_INIT_DELAY / 2); + assert!(writer.lock().unwrap().bytes.is_empty()); + + let deadline = Instant::now() + Duration::from_secs(1); + loop { + if !writer.lock().unwrap().bytes.is_empty() { + break; + } + assert!( + Instant::now() < deadline, + "timed out waiting for delayed send" + ); + std::thread::sleep(Duration::from_millis(10)); + } + + let elapsed = start.elapsed(); + assert!(elapsed >= SHELL_INIT_DELAY); + assert_eq!(writer.lock().unwrap().bytes, b"root\n"); + } +} diff --git a/ostool/src/run/uboot.rs b/ostool/src/run/uboot.rs index 448b6ab..d7c351b 100644 --- a/ostool/src/run/uboot.rs +++ b/ostool/src/run/uboot.rs @@ -1,4 +1,5 @@ use std::{ + io::{self, Write}, path::{Path, PathBuf}, sync::{Arc, Mutex}, thread, @@ -21,6 +22,7 @@ use crate::{ Tool, run::{ output_matcher::{ByteStreamMatcher, MATCH_DRAIN_DURATION, StreamMatchKind}, + shell_init::{ShellAutoInitMatcher, normalize_shell_init_config, spawn_delayed_send}, tftp, }, sterm::SerialTerm, @@ -60,6 +62,10 @@ pub struct UbootConfig { pub success_regex: Vec, pub fail_regex: Vec, pub uboot_cmd: Option>, + /// String prefix that indicates the target shell is ready after boot. + pub shell_prefix: Option, + /// Command sent once after `shell_prefix` is detected. + pub shell_init_cmd: Option, } impl UbootConfig { @@ -80,6 +86,18 @@ impl UbootConfig { } }) } + + fn normalize(&mut self, config_name: &str) -> anyhow::Result<()> { + normalize_shell_init_config( + &mut self.shell_prefix, + &mut self.shell_init_cmd, + config_name, + ) + } + + fn shell_auto_init(&self) -> Option { + ShellAutoInitMatcher::new(self.shell_prefix.clone(), self.shell_init_cmd.clone()) + } } #[derive(Default, Serialize, Deserialize, JsonSchema, Debug, Clone)] @@ -114,16 +132,18 @@ impl Tool { config_content = replace_env_placeholders(&config_content)?; - let config: UbootConfig = toml::from_str(&config_content).with_context(|| { + let mut config: UbootConfig = toml::from_str(&config_content).with_context(|| { format!("failed to parse U-Boot config: {}", config_path.display()) })?; + config.normalize(&format!("U-Boot config {}", config_path.display()))?; config } else { - let config = UbootConfig { + let mut config = UbootConfig { serial: "/dev/ttyUSB0".to_string(), baud_rate: "115200".into(), ..Default::default() }; + config.normalize(&format!("U-Boot config {}", config_path.display()))?; fs::write(&config_path, toml::to_string_pretty(&config)?) .await @@ -165,6 +185,26 @@ struct NetworkBootRequest { ipaddr: Option, } +struct SharedWrite { + inner: Arc>>, +} + +impl SharedWrite { + fn new(inner: Arc>>) -> Self { + Self { inner } + } +} + +impl Write for SharedWrite { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.inner.lock().unwrap().write(buf) + } + + fn flush(&mut self) -> io::Result<()> { + self.inner.lock().unwrap().flush() + } +} + impl Runner<'_> { /// 生成压缩的 FIT image 包含 kernel 和 FDT /// @@ -335,7 +375,9 @@ impl Runner<'_> { let mut builtin_tftp_started = false; #[cfg(not(target_os = "linux"))] - if is_tftp.is_none() && let Some(ip) = ip_string.as_ref() { + if is_tftp.is_none() + && let Some(ip) = ip_string.as_ref() + { info!("TFTP server IP: {}", ip); tftp::run_tftp_server(self.tool)?; builtin_tftp_started = true; @@ -590,47 +632,62 @@ impl Runner<'_> { let res = Arc::new(Mutex::>>::new(None)); let res_clone = res.clone(); let matcher_clone = matcher.clone(); - let mut shell = SerialTerm::new_with_byte_callback(tx, rx, move |h, byte| { - let mut matcher = matcher_clone.lock().unwrap(); - if let Some(matched) = matcher.observe_byte(byte) { - match matched.kind { - StreamMatchKind::Success => { - println!( - "{}", - format!( - "\r\n=== SUCCESS PATTERN MATCHED: {} ===", - matched.matched_regex - ) - .green() - ); - let mut res_lock = res_clone.lock().unwrap(); - *res_lock = Some(Ok(())); - } - StreamMatchKind::Fail => { - println!( - "{}", - format!( - "\r\n=== FAIL PATTERN MATCHED: {} ===", - matched.matched_regex - ) - .red() - ); - let mut res_lock = res_clone.lock().unwrap(); - *res_lock = Some(Err(anyhow!( - "Fail pattern matched '{}': {}", - matched.matched_regex, - matched.matched_text.trim_end() - ))); + let shared_tx = Arc::new(Mutex::new(tx)); + let shell_init = Arc::new(Mutex::new(self.config.shell_auto_init())); + let shell_init_clone = shell_init.clone(); + let shared_tx_clone = shared_tx.clone(); + let mut shell = SerialTerm::new_with_byte_callback( + Box::new(SharedWrite::new(shared_tx)), + rx, + move |h, byte| { + let mut matcher = matcher_clone.lock().unwrap(); + if let Some(matched) = matcher.observe_byte(byte) { + match matched.kind { + StreamMatchKind::Success => { + println!( + "{}", + format!( + "\r\n=== SUCCESS PATTERN MATCHED: {} ===", + matched.matched_regex + ) + .green() + ); + let mut res_lock = res_clone.lock().unwrap(); + *res_lock = Some(Ok(())); + } + StreamMatchKind::Fail => { + println!( + "{}", + format!( + "\r\n=== FAIL PATTERN MATCHED: {} ===", + matched.matched_regex + ) + .red() + ); + let mut res_lock = res_clone.lock().unwrap(); + *res_lock = Some(Err(anyhow!( + "Fail pattern matched '{}': {}", + matched.matched_regex, + matched.matched_text.trim_end() + ))); + } } + + h.stop_after(MATCH_DRAIN_DURATION); } - h.stop_after(MATCH_DRAIN_DURATION); - } + let mut shell_init = shell_init_clone.lock().unwrap(); + if let Some(shell_init) = shell_init.as_mut() + && let Some(command) = shell_init.observe_byte(byte) + { + spawn_delayed_send(shared_tx_clone.clone(), command); + } - if matcher.should_stop() { - h.stop(); - } - }); + if matcher.should_stop() { + h.stop(); + } + }, + ); shell.run().await?; { let mut res_lock = res.lock().unwrap(); @@ -742,7 +799,7 @@ fn build_network_boot_request( #[cfg(test)] mod tests { - use super::build_network_boot_request; + use super::{UbootConfig, build_network_boot_request}; #[test] fn network_boot_request_uses_same_filename_for_bootfile() { @@ -777,4 +834,33 @@ mod tests { "dhcp image.fit && bootm" ); } + + #[test] + fn uboot_config_normalize_rejects_shell_init_without_prefix() { + let mut config = UbootConfig { + serial: "/dev/null".into(), + baud_rate: "115200".into(), + shell_init_cmd: Some("root".into()), + ..Default::default() + }; + + let err = config.normalize("test config").unwrap_err(); + assert!(err.to_string().contains("shell_prefix")); + } + + #[test] + fn uboot_config_normalize_trims_shell_fields() { + let mut config = UbootConfig { + serial: "/dev/null".into(), + baud_rate: "115200".into(), + shell_prefix: Some(" login: ".into()), + shell_init_cmd: Some(" root ".into()), + ..Default::default() + }; + + config.normalize("test config").unwrap(); + + assert_eq!(config.shell_prefix.as_deref(), Some("login:")); + assert_eq!(config.shell_init_cmd.as_deref(), Some("root")); + } } From fb8e4d839277a3ffccecb72bc6d2edf64067282b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E7=9D=BF?= Date: Wed, 25 Mar 2026 15:35:05 +0800 Subject: [PATCH 6/9] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E4=BB=8E=20Cargo?= =?UTF-8?q?=20=E5=8C=85=E7=9B=AE=E5=BD=95=E8=A7=A3=E6=9E=90=20QEMU=20?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ostool/src/build/mod.rs | 36 +++++++++++--- ostool/src/main.rs | 5 ++ ostool/src/run/qemu.rs | 60 ++++++++++++++++++++-- ostool/src/tool.rs | 107 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 198 insertions(+), 10 deletions(-) diff --git a/ostool/src/build/mod.rs b/ostool/src/build/mod.rs index 6714b7c..71d391d 100644 --- a/ostool/src/build/mod.rs +++ b/ostool/src/build/mod.rs @@ -26,7 +26,10 @@ use crate::{ cargo_builder::CargoBuilder, config::{Cargo, Custom}, }, - run::{qemu::RunQemuArgs, uboot::RunUbootArgs}, + run::{ + qemu::{RunQemuArgs, resolve_qemu_config_path_in_dir}, + uboot::RunUbootArgs, + }, }; /// Cargo builder implementation for building projects. @@ -50,6 +53,12 @@ pub enum CargoRunnerKind { debug: bool, /// Whether to dump the device tree blob. dtb_dump: bool, + /// Extra default QEMU command-line arguments. + args: Vec, + /// Regex patterns that indicate successful execution. + success_regex: Vec, + /// Regex patterns that indicate failed execution. + fail_regex: Vec, }, /// Run the built artifact on real hardware via U-Boot. Uboot { @@ -157,13 +166,28 @@ impl Tool { CargoRunnerKind::Qemu { qemu_config, dtb_dump, + args, + success_regex, + fail_regex, .. } => { - self.run_qemu(RunQemuArgs { - qemu_config: qemu_config.clone(), - dtb_dump: *dtb_dump, - show_output: true, - }) + let package_dir = self.resolve_package_manifest_dir(&config.package)?; + let resolved_qemu_config = resolve_qemu_config_path_in_dir( + &package_dir, + self.ctx.arch, + qemu_config.clone(), + )?; + + self.run_qemu_with_more_default_args( + RunQemuArgs { + qemu_config: Some(resolved_qemu_config), + dtb_dump: *dtb_dump, + show_output: true, + }, + args.clone(), + success_regex.clone(), + fail_regex.clone(), + ) .await?; } CargoRunnerKind::Uboot { uboot_config } => { diff --git a/ostool/src/main.rs b/ostool/src/main.rs index 253d97b..f958923 100644 --- a/ostool/src/main.rs +++ b/ostool/src/main.rs @@ -60,6 +60,8 @@ pub struct QemuArgs { /// Path to the qemu configuration file /// /// Default behavior when not specified: + /// - Cargo build system: use the target package directory + /// - Custom build system: use the workspace directory /// - With architecture detected: .qemu-{arch}.toml (e.g., .qemu-aarch64.toml) /// - Without architecture: .qemu.toml #[arg(short, long)] @@ -118,6 +120,9 @@ async fn try_main() -> Result<()> { qemu_config: qemu_args.qemu_config, debug: qemu_args.debug, dtb_dump: qemu_args.dtb_dump, + args: vec![], + success_regex: vec![], + fail_regex: vec![], }, RunSubCommands::Uboot(uboot_args) => CargoRunnerKind::Uboot { uboot_config: uboot_args.uboot_config, diff --git a/ostool/src/run/qemu.rs b/ostool/src/run/qemu.rs index 9144b1b..1b4cc7a 100644 --- a/ostool/src/run/qemu.rs +++ b/ostool/src/run/qemu.rs @@ -677,12 +677,20 @@ impl QemuRunner<'_> { pub(crate) fn resolve_qemu_config_path( tool: &Tool, explicit_path: Option, +) -> anyhow::Result { + resolve_qemu_config_path_in_dir(tool.workspace_dir(), tool.ctx.arch, explicit_path) +} + +pub(crate) fn resolve_qemu_config_path_in_dir( + search_dir: &Path, + arch: Option, + explicit_path: Option, ) -> anyhow::Result { if let Some(path) = explicit_path { return Ok(path); } - let arch_str = tool.ctx.arch.map(|arch| format!("{arch:?}").to_lowercase()); + let arch_str = arch.map(|arch| format!("{arch:?}").to_lowercase()); // 文件名优先级顺序 let candidates: Vec = if let Some(ref arch) = arch_str { @@ -697,7 +705,7 @@ pub(crate) fn resolve_qemu_config_path( }; for filename in &candidates { - let path = tool.workspace_dir().join(filename); + let path = search_dir.join(filename); if path.exists() { return Ok(path); } @@ -709,14 +717,14 @@ pub(crate) fn resolve_qemu_config_path( ".qemu.toml".to_string() }; - Ok(tool.workspace_dir().join(default_filename)) + Ok(search_dir.join(default_filename)) } #[cfg(test)] mod tests { use super::{ QemuConfig, QemuDefaultOverrides, QemuRunner, build_default_qemu_config, - resolve_qemu_config_path, + resolve_qemu_config_path, resolve_qemu_config_path_in_dir, }; use object::Architecture; use std::path::PathBuf; @@ -939,6 +947,50 @@ mod tests { assert_eq!(result, tmp.path().join("qemu.toml")); } + #[test] + fn qemu_config_search_dir_prefers_arch_specific_files() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("qemu-aarch64.toml"), "").unwrap(); + std::fs::write(tmp.path().join("qemu.toml"), "").unwrap(); + + let result = resolve_qemu_config_path_in_dir( + tmp.path(), + Some(Architecture::Aarch64), + None, + ) + .unwrap(); + assert_eq!(result, tmp.path().join("qemu-aarch64.toml")); + } + + #[test] + fn qemu_config_search_dir_uses_hidden_generic_before_hidden_default_creation() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join(".qemu.toml"), "").unwrap(); + + let result = + resolve_qemu_config_path_in_dir(tmp.path(), Some(Architecture::Aarch64), None) + .unwrap(); + assert_eq!(result, tmp.path().join(".qemu.toml")); + } + + #[test] + fn qemu_config_search_dir_defaults_to_arch_specific_hidden_file() { + let tmp = TempDir::new().unwrap(); + + let result = + resolve_qemu_config_path_in_dir(tmp.path(), Some(Architecture::Aarch64), None) + .unwrap(); + assert_eq!(result, tmp.path().join(".qemu-aarch64.toml")); + } + + #[test] + fn qemu_config_search_dir_defaults_without_arch() { + let tmp = TempDir::new().unwrap(); + + let result = resolve_qemu_config_path_in_dir(tmp.path(), None, None).unwrap(); + assert_eq!(result, tmp.path().join(".qemu.toml")); + } + #[test] fn build_config_explicit_path_wins() { let tmp = TempDir::new().unwrap(); diff --git a/ostool/src/tool.rs b/ostool/src/tool.rs index f6c19bb..d0b9733 100644 --- a/ostool/src/tool.rs +++ b/ostool/src/tool.rs @@ -171,6 +171,28 @@ impl Tool { }) } + pub(crate) fn resolve_package_manifest_dir(&self, package: &str) -> anyhow::Result { + let metadata = self.metadata()?; + let Some(pkg) = metadata.packages.iter().find(|pkg| pkg.name == package) else { + bail!( + "package '{}' not found in cargo metadata under {}", + package, + self.manifest_dir().display() + ); + }; + + pkg.manifest_path + .parent() + .map(|path| path.as_std_path().to_path_buf()) + .ok_or_else(|| { + anyhow!( + "package '{}' manifest has no parent: {}", + package, + pkg.manifest_path + ) + }) + } + /// Sets the ELF artifact path and synchronizes derived runtime metadata. pub async fn set_elf_artifact_path(&mut self, path: PathBuf) -> anyhow::Result<()> { let path = path @@ -485,6 +507,8 @@ fn resolve_manifest_path(input: Option) -> anyhow::Result { #[cfg(test)] mod tests { use super::{Tool, ToolConfig, resolve_manifest_context}; + use crate::run::qemu::resolve_qemu_config_path_in_dir; + use object::Architecture; #[tokio::test] async fn set_elf_artifact_path_updates_dirs_and_arch() { @@ -548,4 +572,87 @@ mod tests { assert_eq!(manifest.manifest_dir, app_dir); assert_eq!(manifest.workspace_dir, temp.path()); } + + #[test] + fn resolve_package_manifest_dir_uses_selected_package() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write( + temp.path().join("Cargo.toml"), + "[workspace]\nmembers = [\"app\", \"kernel\"]\nresolver = \"3\"\n", + ) + .unwrap(); + + let app_dir = temp.path().join("app"); + std::fs::create_dir_all(app_dir.join("src")).unwrap(); + std::fs::write( + app_dir.join("Cargo.toml"), + "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + ) + .unwrap(); + std::fs::write(app_dir.join("src/main.rs"), "fn main() {}\n").unwrap(); + + let kernel_dir = temp.path().join("kernel"); + std::fs::create_dir_all(kernel_dir.join("src")).unwrap(); + std::fs::write( + kernel_dir.join("Cargo.toml"), + "[package]\nname = \"kernel\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + ) + .unwrap(); + std::fs::write(kernel_dir.join("src/main.rs"), "fn main() {}\n").unwrap(); + + let tool = Tool::new(ToolConfig { + manifest: Some(app_dir.clone()), + ..Default::default() + }) + .unwrap(); + + let resolved = tool.resolve_package_manifest_dir("kernel").unwrap(); + assert_eq!(resolved, kernel_dir); + } + + #[test] + fn cargo_qemu_config_resolution_prefers_package_dir_over_workspace_root() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write( + temp.path().join("Cargo.toml"), + "[workspace]\nmembers = [\"app\", \"kernel\"]\nresolver = \"3\"\n", + ) + .unwrap(); + std::fs::write(temp.path().join("qemu-aarch64.toml"), "").unwrap(); + + let app_dir = temp.path().join("app"); + std::fs::create_dir_all(app_dir.join("src")).unwrap(); + std::fs::write( + app_dir.join("Cargo.toml"), + "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + ) + .unwrap(); + std::fs::write(app_dir.join("src/main.rs"), "fn main() {}\n").unwrap(); + + let kernel_dir = temp.path().join("kernel"); + std::fs::create_dir_all(kernel_dir.join("src")).unwrap(); + std::fs::write( + kernel_dir.join("Cargo.toml"), + "[package]\nname = \"kernel\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + ) + .unwrap(); + std::fs::write(kernel_dir.join("src/main.rs"), "fn main() {}\n").unwrap(); + std::fs::write(kernel_dir.join(".qemu-aarch64.toml"), "").unwrap(); + + let tool = Tool::new(ToolConfig { + manifest: Some(app_dir), + ..Default::default() + }) + .unwrap(); + + let package_dir = tool.resolve_package_manifest_dir("kernel").unwrap(); + let resolved = resolve_qemu_config_path_in_dir( + &package_dir, + Some(Architecture::Aarch64), + None, + ) + .unwrap(); + + assert_eq!(resolved, kernel_dir.join(".qemu-aarch64.toml")); + } } From 5ca88b8407a359fd2ecec4f5689349fe36681c4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E7=9D=BF?= Date: Wed, 25 Mar 2026 15:56:11 +0800 Subject: [PATCH 7/9] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=8F=AF?= =?UTF-8?q?=E9=80=89=E7=9A=84=20QEMU=20=E9=85=8D=E7=BD=AE=20to=5Fbin=20?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E9=80=89=E9=A1=B9=EF=BC=8C=E6=94=B9=E8=BF=9B?= =?UTF-8?q?=E8=BE=93=E5=87=BA=E5=8C=B9=E9=85=8D=E5=99=A8=E5=92=8C=20U-Boot?= =?UTF-8?q?=20=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ostool/src/build/mod.rs | 4 + ostool/src/main.rs | 1 + ostool/src/run/output_matcher.rs | 48 +++++++ ostool/src/run/qemu.rs | 100 +++++---------- ostool/src/run/uboot.rs | 214 ++++++++++--------------------- 5 files changed, 153 insertions(+), 214 deletions(-) diff --git a/ostool/src/build/mod.rs b/ostool/src/build/mod.rs index 71d391d..c99a62c 100644 --- a/ostool/src/build/mod.rs +++ b/ostool/src/build/mod.rs @@ -53,6 +53,8 @@ pub enum CargoRunnerKind { debug: bool, /// Whether to dump the device tree blob. dtb_dump: bool, + /// Optional override for the generated QEMU config `to_bin` default. + to_bin: Option, /// Extra default QEMU command-line arguments. args: Vec, /// Regex patterns that indicate successful execution. @@ -166,6 +168,7 @@ impl Tool { CargoRunnerKind::Qemu { qemu_config, dtb_dump, + to_bin, args, success_regex, fail_regex, @@ -184,6 +187,7 @@ impl Tool { dtb_dump: *dtb_dump, show_output: true, }, + *to_bin, args.clone(), success_regex.clone(), fail_regex.clone(), diff --git a/ostool/src/main.rs b/ostool/src/main.rs index f958923..da22cf0 100644 --- a/ostool/src/main.rs +++ b/ostool/src/main.rs @@ -120,6 +120,7 @@ async fn try_main() -> Result<()> { qemu_config: qemu_args.qemu_config, debug: qemu_args.debug, dtb_dump: qemu_args.dtb_dump, + to_bin: None, args: vec![], success_regex: vec![], fail_regex: vec![], diff --git a/ostool/src/run/output_matcher.rs b/ostool/src/run/output_matcher.rs index 0f1bb92..5d06641 100644 --- a/ostool/src/run/output_matcher.rs +++ b/ostool/src/run/output_matcher.rs @@ -1,6 +1,7 @@ use std::time::{Duration, Instant}; use anyhow::anyhow; +use colored::Colorize; use regex::Regex; pub(crate) const MATCH_DRAIN_DURATION: Duration = Duration::from_millis(500); @@ -11,6 +12,19 @@ pub(crate) enum StreamMatchKind { Fail, } +impl StreamMatchKind { + pub(crate) fn into_result(self, matched: &StreamMatch) -> anyhow::Result<()> { + match self { + StreamMatchKind::Success => Ok(()), + StreamMatchKind::Fail => Err(anyhow!( + "Fail pattern matched '{}': {}", + matched.matched_regex, + matched.matched_text.trim_end() + )), + } + } +} + #[derive(Debug, Clone)] pub(crate) struct StreamMatch { pub(crate) kind: StreamMatchKind, @@ -19,6 +33,40 @@ pub(crate) struct StreamMatch { pub(crate) deadline: Instant, } +pub(crate) fn compile_regexes( + success_patterns: &[String], + fail_patterns: &[String], +) -> anyhow::Result<(Vec, Vec)> { + let success_regex = success_patterns + .iter() + .map(|p| Regex::new(p).map_err(|e| anyhow!("success regex error: {e}"))) + .collect::, _>>()?; + + let fail_regex = fail_patterns + .iter() + .map(|p| Regex::new(p).map_err(|e| anyhow!("fail regex error: {e}"))) + .collect::, _>>()?; + + Ok((success_regex, fail_regex)) +} + +pub(crate) fn print_match_event(matched: &StreamMatch) { + match matched.kind { + StreamMatchKind::Success => println!( + "{}", + format!( + "\n=== SUCCESS PATTERN MATCHED: {} ===", + matched.matched_regex + ) + .green() + ), + StreamMatchKind::Fail => println!( + "{}", + format!("\n=== FAIL PATTERN MATCHED: {}", matched.matched_regex).red() + ), + } +} + #[derive(Debug, Clone)] enum StreamMatchState { Pending, diff --git a/ostool/src/run/qemu.rs b/ostool/src/run/qemu.rs index 1b4cc7a..640e4ca 100644 --- a/ostool/src/run/qemu.rs +++ b/ostool/src/run/qemu.rs @@ -43,7 +43,7 @@ use tokio::fs; use crate::{ Tool, run::{ - output_matcher::{ByteStreamMatcher, StreamMatch, StreamMatchKind}, + output_matcher::{ByteStreamMatcher, StreamMatch, StreamMatchKind, compile_regexes, print_match_event}, ovmf_prebuilt::{Arch, FileType, Prebuilt, Source}, shell_init::{ShellAutoInitMatcher, normalize_shell_init_config, spawn_delayed_send}, }, @@ -106,6 +106,7 @@ pub struct RunQemuArgs { #[derive(Debug, Clone, Default)] struct QemuDefaultOverrides { + to_bin: Option, args: Vec, success_regex: Vec, fail_regex: Vec, @@ -126,13 +127,14 @@ struct QemuDefaultOverrides { /// Returns an error if QEMU fails to start or exits with an error. impl Tool { pub async fn run_qemu(&mut self, args: RunQemuArgs) -> anyhow::Result<()> { - self.run_qemu_with_more_default_args(args, vec![], vec![], vec![]) + self.run_qemu_with_more_default_args(args, None, vec![], vec![], vec![]) .await } pub async fn run_qemu_with_more_default_args( &mut self, run_args: RunQemuArgs, + to_bin: Option, args: Vec, success_regex: Vec, fail_regex: Vec, @@ -141,6 +143,7 @@ impl Tool { self, run_args, QemuDefaultOverrides { + to_bin, args, success_regex, fail_regex, @@ -168,22 +171,24 @@ async fn load_or_create_qemu_config( info!("Using QEMU config file: {}", config_path.display()); - if config_path.exists() { - let config_content = fs::read_to_string(&config_path) - .await - .with_path("failed to read file", &config_path)?; - let mut config: QemuConfig = toml::from_str(&config_content) - .with_context(|| format!("failed to parse QEMU config: {}", config_path.display()))?; - config.normalize(&format!("QEMU config {}", config_path.display()))?; - return Ok(config); - } - - let mut config = build_default_qemu_config(tool.ctx.arch, overrides); - config.normalize(&format!("QEMU config {}", config_path.display()))?; - fs::write(&config_path, toml::to_string_pretty(&config)?) - .await - .with_path("failed to write file", &config_path)?; - Ok(config) + let config_content = match fs::read_to_string(&config_path).await { + Ok(content) => { + let mut config: QemuConfig = toml::from_str(&content) + .with_context(|| format!("failed to parse QEMU config: {}", config_path.display()))?; + config.normalize(&format!("QEMU config {}", config_path.display()))?; + return Ok(config); + } + Err(e) if e.kind() == io::ErrorKind::NotFound => { + let mut config = build_default_qemu_config(tool.ctx.arch, overrides); + config.normalize(&format!("QEMU config {}", config_path.display()))?; + fs::write(&config_path, toml::to_string_pretty(&config)?) + .await + .with_path("failed to write file", &config_path)?; + config + } + Err(e) => return Err(e.into()), + }; + Ok(config_content) } fn build_default_qemu_config( @@ -191,7 +196,7 @@ fn build_default_qemu_config( overrides: QemuDefaultOverrides, ) -> QemuConfig { let mut config = QemuConfig { - to_bin: true, + to_bin: overrides.to_bin.unwrap_or(true), success_regex: overrides.success_regex, fail_regex: overrides.fail_regex, ..Default::default() @@ -222,7 +227,6 @@ async fn run_qemu_with_config( let mut runner = QemuRunner { tool, config, - args: vec![], dtbdump: run_args.dtb_dump, success_regex: vec![], fail_regex: vec![], @@ -233,7 +237,6 @@ async fn run_qemu_with_config( struct QemuRunner<'a> { tool: &'a mut Tool, config: QemuConfig, - args: Vec, dtbdump: bool, success_regex: Vec, fail_regex: Vec, @@ -262,7 +265,7 @@ impl Drop for RawModeGuard { impl QemuRunner<'_> { async fn run(&mut self) -> anyhow::Result<()> { - self.preper_regex()?; + self.prepare_regex()?; if self.config.to_bin { self.tool.objcopy_output_bin()?; @@ -281,14 +284,6 @@ impl QemuRunner<'_> { let mut need_machine = true; - for arg in &self.config.args { - if arg == "-machine" || arg == "-M" { - need_machine = false; - } - - self.args.push(arg.clone()); - } - #[allow(unused_mut)] let mut qemu_executable = format!("qemu-system-{}", arch); @@ -308,6 +303,9 @@ impl QemuRunner<'_> { let mut cmd = self.tool.command(&qemu_executable); for arg in &self.config.args { + if arg == "-machine" || arg == "-M" { + need_machine = false; + } cmd.arg(arg); } @@ -549,7 +547,7 @@ impl QemuRunner<'_> { let _ = std::io::stdout().flush(); if let Some(matched) = matcher.observe_byte(byte) { - Self::print_match_event(&matched); + print_match_event(&matched); } if let Some(shell_auto_init) = shell_auto_init.as_mut() @@ -606,23 +604,6 @@ impl QemuRunner<'_> { }); } - fn print_match_event(matched: &StreamMatch) { - match matched.kind { - StreamMatchKind::Success => println!( - "{}", - format!( - "\n=== SUCCESS PATTERN MATCHED: {} ===", - matched.matched_regex - ) - .green() - ), - StreamMatchKind::Fail => println!( - "{}", - format!("\n=== FAIL PATTERN MATCHED: {} ===", matched.matched_regex).red() - ), - } - } - fn kill_qemu(child: &mut Child) -> anyhow::Result<()> { if let Err(err) = child.kill() && err.kind() != ErrorKind::InvalidInput @@ -646,23 +627,10 @@ impl QemuRunner<'_> { Ok(()) } - fn preper_regex(&mut self) -> anyhow::Result<()> { - // Prepare regex patterns if needed - // Compile success regex patterns - for pattern in self.config.success_regex.iter() { - // Compile and store the regex - let regex = - regex::Regex::new(pattern).map_err(|e| anyhow!("success regex error: {e}"))?; - self.success_regex.push(regex); - } - - // Compile fail regex patterns - for pattern in self.config.fail_regex.iter() { - // Compile and store the regex - let regex = regex::Regex::new(pattern).map_err(|e| anyhow!("fail regex error: {e}"))?; - self.fail_regex.push(regex); - } - + fn prepare_regex(&mut self) -> anyhow::Result<()> { + let (success, fail) = compile_regexes(&self.config.success_regex, &self.config.fail_regex)?; + self.success_regex = success; + self.fail_regex = fail; Ok(()) } } @@ -790,11 +758,13 @@ mod tests { let config = build_default_qemu_config( Some(Architecture::X86_64), QemuDefaultOverrides { + to_bin: Some(false), args: vec!["-smp".into(), "2".into()], ..Default::default() }, ); + assert!(!config.to_bin); assert_eq!(config.args, vec!["-nographic", "-smp", "2"]); } diff --git a/ostool/src/run/uboot.rs b/ostool/src/run/uboot.rs index d7c351b..ee80904 100644 --- a/ostool/src/run/uboot.rs +++ b/ostool/src/run/uboot.rs @@ -21,7 +21,7 @@ use uboot_shell::UbootShell; use crate::{ Tool, run::{ - output_matcher::{ByteStreamMatcher, MATCH_DRAIN_DURATION, StreamMatchKind}, + output_matcher::{ByteStreamMatcher, MATCH_DRAIN_DURATION, StreamMatchKind, compile_regexes, print_match_event}, shell_init::{ShellAutoInitMatcher, normalize_shell_init_config, spawn_delayed_send}, tftp, }, @@ -124,31 +124,29 @@ impl Tool { None => self.workspace_dir().join(".uboot.toml"), }; - let config = if config_path.exists() { - println!("Using U-Boot config: {}", config_path.display()); - let mut config_content = fs::read_to_string(&config_path) - .await - .with_path("failed to read file", &config_path)?; - - config_content = replace_env_placeholders(&config_content)?; - - let mut config: UbootConfig = toml::from_str(&config_content).with_context(|| { - format!("failed to parse U-Boot config: {}", config_path.display()) - })?; - config.normalize(&format!("U-Boot config {}", config_path.display()))?; - config - } else { - let mut config = UbootConfig { - serial: "/dev/ttyUSB0".to_string(), - baud_rate: "115200".into(), - ..Default::default() - }; - config.normalize(&format!("U-Boot config {}", config_path.display()))?; - - fs::write(&config_path, toml::to_string_pretty(&config)?) - .await - .with_path("failed to write file", &config_path)?; - config + let config = match fs::read_to_string(&config_path).await { + Ok(content) => { + println!("Using U-Boot config: {}", config_path.display()); + let config_content = replace_env_placeholders(&content)?; + let mut config: UbootConfig = toml::from_str(&config_content).with_context(|| { + format!("failed to parse U-Boot config: {}", config_path.display()) + })?; + config.normalize(&format!("U-Boot config {}", config_path.display()))?; + config + } + Err(e) if e.kind() == io::ErrorKind::NotFound => { + let mut config = UbootConfig { + serial: "/dev/ttyUSB0".to_string(), + baud_rate: "115200".into(), + ..Default::default() + }; + config.normalize(&format!("U-Boot config {}", config_path.display()))?; + fs::write(&config_path, toml::to_string_pretty(&config)?) + .await + .with_path("failed to write file", &config_path)?; + config + } + Err(e) => return Err(e.into()), }; let baud_rate = config.baud_rate.parse::().with_context(|| { @@ -206,15 +204,7 @@ impl Write for SharedWrite { } impl Runner<'_> { - /// 生成压缩的 FIT image 包含 kernel 和 FDT - /// - /// # 参数 - /// - `kernel_path`: kernel 文件路径 - /// - `dtb_path`: DTB 文件路径(可选) - /// - `kernel_load_addr`: kernel 加载地址 - /// - /// # 返回值 - /// 返回生成的 FIT image 文件路径 + /// 生成包含 kernel 和 FDT 的压缩 FIT image。 async fn generate_fit_image( &self, kernel_path: &Path, @@ -249,7 +239,6 @@ impl Runner<'_> { _ => todo!(), }; - // 创建配置,与 test.its 文件中的参数一致 let mut config = FitImageConfig::new("Various kernels, ramdisks and FDT blobs") .with_kernel( ComponentConfig::new("kernel", kernel_data) @@ -275,7 +264,7 @@ impl Runner<'_> { ); fdt_name = Some("fdt"); - // Can not compress DTB, U-Boot will not accept it + // U-Boot 不接受压缩的 DTB let mut fdt_config = ComponentConfig::new("fdt", data.clone()) .with_description("This fdt") .with_type("flat_dt") @@ -300,13 +289,10 @@ impl Runner<'_> { None::, ); - // 使用新的 mkimage API 构建 FIT image let mut builder = FitImageBuilder::new(); let fit_data = builder .build(config) .with_context(|| errors::FIT_BUILD_ERROR.to_string())?; - - // 保存到文件 let output_path = Path::new(output_dir).join("image.fit"); fs::write(&output_path, fit_data) .await @@ -328,7 +314,7 @@ impl Runner<'_> { } async fn _run(&mut self) -> anyhow::Result<()> { - self.preper_regex()?; + self.prepare_regex()?; self.tool.objcopy_output_bin()?; let kernel = self @@ -516,71 +502,54 @@ impl Runner<'_> { ) .await?; - #[cfg(target_os = "linux")] - let mut linux_system_tftp_active = false; - - #[cfg(target_os = "linux")] - let fitname = if let Some(system_tftp) = linux_system_tftp.as_ref() { - let prepared = tftp::stage_linux_fit_image(&fitimage, &system_tftp.directory)?; - linux_system_tftp_active = true; - info!( - "Staged FIT image to: {}", - prepared.absolute_fit_path.display() - ); - prepared.relative_filename - } else if is_tftp.is_some() { - let tftp_dir = self - .config - .net - .as_ref() - .and_then(|net| net.tftp_dir.as_ref()) - .unwrap(); - - let fitimage = fitimage.file_name().unwrap(); - let tftp_path = PathBuf::from(tftp_dir).join(fitimage); - - info!("Setting TFTP file path: {}", tftp_path.display()); - tftp_path.display().to_string() - } else { - let name = fitimage - .file_name() - .and_then(|n| n.to_str()) - .ok_or(anyhow!("Invalid fitimage filename"))?; - - info!("Using fitimage filename: {}", name); - name.to_string() - }; - - #[cfg(not(target_os = "linux"))] - let fitname = if is_tftp.is_some() { + let (fitname, linux_tftp_active) = if cfg!(target_os = "linux") { + if let Some(system_tftp) = linux_system_tftp.as_ref() { + let prepared = tftp::stage_linux_fit_image(&fitimage, &system_tftp.directory)?; + info!( + "Staged FIT image to: {}", + prepared.absolute_fit_path.display() + ); + (prepared.relative_filename, true) + } else if let Some(tftp_dir) = is_tftp.as_deref() { + let tftp_dir = self + .config + .net + .as_ref() + .and_then(|net| net.tftp_dir.as_ref()) + .unwrap(); + let fitimage = fitimage.file_name().unwrap(); + let tftp_path = PathBuf::from(tftp_dir).join(fitimage); + info!("Setting TFTP file path: {}", tftp_path.display()); + (tftp_path.display().to_string(), false) + } else { + let name = fitimage + .file_name() + .and_then(|n| n.to_str()) + .ok_or(anyhow!("Invalid fitimage filename"))?; + info!("Using fitimage filename: {}", name); + (name.to_string(), false) + } + } else if let Some(tftp_dir) = is_tftp.as_deref() { let tftp_dir = self .config .net .as_ref() .and_then(|net| net.tftp_dir.as_ref()) .unwrap(); - let fitimage = fitimage.file_name().unwrap(); let tftp_path = PathBuf::from(tftp_dir).join(fitimage); - info!("Setting TFTP file path: {}", tftp_path.display()); - tftp_path.display().to_string() + (tftp_path.display().to_string(), false) } else { let name = fitimage .file_name() .and_then(|n| n.to_str()) .ok_or(anyhow!("Invalid fitimage filename"))?; - info!("Using fitimage filename: {}", name); - name.to_string() + (name.to_string(), false) }; - #[cfg(target_os = "linux")] - let network_transfer_ready = - linux_system_tftp_active || is_tftp.is_some() || builtin_tftp_started; - - #[cfg(not(target_os = "linux"))] - let network_transfer_ready = is_tftp.is_some() || builtin_tftp_started; + let network_transfer_ready = linux_tftp_active || is_tftp.is_some() || builtin_tftp_started; let bootcmd = if let Some(request) = build_network_boot_request( self.config @@ -604,18 +573,6 @@ impl Runner<'_> { info!("Booting kernel with command: {}", bootcmd); uboot.cmd_without_reply(&bootcmd)?; - // if self.config.net.is_some() { - // info!("TFTP upload FIT image to board..."); - // let filename = fitimage.file_name().unwrap().to_str().unwrap(); - - // let tftp_cmd = format!("tftp {filename}"); - // uboot.cmd(&tftp_cmd)?; - // uboot.cmd_without_reply("bootm")?; - // } else { - // info!("No TFTP config, using loady to upload FIT image..."); - // Self::uboot_loady(&mut uboot, fit_loadaddr as usize, fitimage); - // uboot.cmd_without_reply("bootm")?; - // } let tx = uboot.tx.take().unwrap(); let rx = uboot.rx.take().unwrap(); @@ -642,37 +599,9 @@ impl Runner<'_> { move |h, byte| { let mut matcher = matcher_clone.lock().unwrap(); if let Some(matched) = matcher.observe_byte(byte) { - match matched.kind { - StreamMatchKind::Success => { - println!( - "{}", - format!( - "\r\n=== SUCCESS PATTERN MATCHED: {} ===", - matched.matched_regex - ) - .green() - ); - let mut res_lock = res_clone.lock().unwrap(); - *res_lock = Some(Ok(())); - } - StreamMatchKind::Fail => { - println!( - "{}", - format!( - "\r\n=== FAIL PATTERN MATCHED: {} ===", - matched.matched_regex - ) - .red() - ); - let mut res_lock = res_clone.lock().unwrap(); - *res_lock = Some(Err(anyhow!( - "Fail pattern matched '{}': {}", - matched.matched_regex, - matched.matched_text.trim_end() - ))); - } - } - + print_match_event(&matched); + let mut res_lock = res_clone.lock().unwrap(); + *res_lock = Some(matched.kind.into_result(&matched)); h.stop_after(MATCH_DRAIN_DURATION); } @@ -698,23 +627,10 @@ impl Runner<'_> { Ok(()) } - fn preper_regex(&mut self) -> anyhow::Result<()> { - // Prepare regex patterns if needed - // Compile success regex patterns - for pattern in self.config.success_regex.iter() { - // Compile and store the regex - let regex = - regex::Regex::new(pattern).map_err(|e| anyhow!("success regex error: {e}"))?; - self.success_regex.push(regex); - } - - // Compile fail regex patterns - for pattern in self.config.fail_regex.iter() { - // Compile and store the regex - let regex = regex::Regex::new(pattern).map_err(|e| anyhow!("fail regex error: {e}"))?; - self.fail_regex.push(regex); - } - + fn prepare_regex(&mut self) -> anyhow::Result<()> { + let (success, fail) = compile_regexes(&self.config.success_regex, &self.config.fail_regex)?; + self.success_regex = success; + self.fail_regex = fail; Ok(()) } From 91b78baf39ab5bc27453557627afa60714ee21ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E7=9D=BF?= Date: Wed, 25 Mar 2026 15:57:51 +0800 Subject: [PATCH 8/9] =?UTF-8?q?refactor:=20=E4=BC=98=E5=8C=96=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E6=A0=BC=E5=BC=8F=EF=BC=8C=E8=B0=83=E6=95=B4=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E9=A1=BA=E5=BA=8F=E5=92=8C=E5=87=BD=E6=95=B0=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E7=9A=84=E6=8E=92=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ostool/src/bin/cargo-osrun.rs | 4 +--- ostool/src/main.rs | 4 ++-- ostool/src/run/qemu.rs | 23 ++++++++++------------- ostool/src/run/tftp.rs | 7 ++----- ostool/src/run/uboot.rs | 12 ++++++++---- ostool/src/tool.rs | 16 ++++------------ 6 files changed, 27 insertions(+), 39 deletions(-) diff --git a/ostool/src/bin/cargo-osrun.rs b/ostool/src/bin/cargo-osrun.rs index 5c2b0ec..9984dd9 100644 --- a/ostool/src/bin/cargo-osrun.rs +++ b/ostool/src/bin/cargo-osrun.rs @@ -9,9 +9,7 @@ use clap::{Parser, Subcommand}; use colored::Colorize as _; use log::debug; use ostool::{ - logger, - resolve_manifest_context, - Tool, ToolConfig, + Tool, ToolConfig, logger, resolve_manifest_context, run::{qemu, uboot::RunUbootArgs}, }; diff --git a/ostool/src/main.rs b/ostool/src/main.rs index da22cf0..bcaceed 100644 --- a/ostool/src/main.rs +++ b/ostool/src/main.rs @@ -6,11 +6,11 @@ use colored::Colorize as _; use log::info; use ostool::{ - logger, - resolve_manifest_context, Tool, ToolConfig, build::{self, CargoRunnerKind}, + logger, menuconfig::{MenuConfigHandler, MenuConfigMode}, + resolve_manifest_context, run::{qemu::RunQemuArgs, uboot::RunUbootArgs}, }; diff --git a/ostool/src/run/qemu.rs b/ostool/src/run/qemu.rs index 640e4ca..746e4b3 100644 --- a/ostool/src/run/qemu.rs +++ b/ostool/src/run/qemu.rs @@ -43,7 +43,9 @@ use tokio::fs; use crate::{ Tool, run::{ - output_matcher::{ByteStreamMatcher, StreamMatch, StreamMatchKind, compile_regexes, print_match_event}, + output_matcher::{ + ByteStreamMatcher, StreamMatch, StreamMatchKind, compile_regexes, print_match_event, + }, ovmf_prebuilt::{Arch, FileType, Prebuilt, Source}, shell_init::{ShellAutoInitMatcher, normalize_shell_init_config, spawn_delayed_send}, }, @@ -173,8 +175,9 @@ async fn load_or_create_qemu_config( let config_content = match fs::read_to_string(&config_path).await { Ok(content) => { - let mut config: QemuConfig = toml::from_str(&content) - .with_context(|| format!("failed to parse QEMU config: {}", config_path.display()))?; + let mut config: QemuConfig = toml::from_str(&content).with_context(|| { + format!("failed to parse QEMU config: {}", config_path.display()) + })?; config.normalize(&format!("QEMU config {}", config_path.display()))?; return Ok(config); } @@ -923,12 +926,8 @@ mod tests { std::fs::write(tmp.path().join("qemu-aarch64.toml"), "").unwrap(); std::fs::write(tmp.path().join("qemu.toml"), "").unwrap(); - let result = resolve_qemu_config_path_in_dir( - tmp.path(), - Some(Architecture::Aarch64), - None, - ) - .unwrap(); + let result = + resolve_qemu_config_path_in_dir(tmp.path(), Some(Architecture::Aarch64), None).unwrap(); assert_eq!(result, tmp.path().join("qemu-aarch64.toml")); } @@ -938,8 +937,7 @@ mod tests { std::fs::write(tmp.path().join(".qemu.toml"), "").unwrap(); let result = - resolve_qemu_config_path_in_dir(tmp.path(), Some(Architecture::Aarch64), None) - .unwrap(); + resolve_qemu_config_path_in_dir(tmp.path(), Some(Architecture::Aarch64), None).unwrap(); assert_eq!(result, tmp.path().join(".qemu.toml")); } @@ -948,8 +946,7 @@ mod tests { let tmp = TempDir::new().unwrap(); let result = - resolve_qemu_config_path_in_dir(tmp.path(), Some(Architecture::Aarch64), None) - .unwrap(); + resolve_qemu_config_path_in_dir(tmp.path(), Some(Architecture::Aarch64), None).unwrap(); assert_eq!(result, tmp.path().join(".qemu-aarch64.toml")); } diff --git a/ostool/src/run/tftp.rs b/ostool/src/run/tftp.rs index 2344b59..4386ffd 100644 --- a/ostool/src/run/tftp.rs +++ b/ostool/src/run/tftp.rs @@ -338,8 +338,7 @@ fn ensure_tftpd_hpa_service_ready(is_root: bool) -> anyhow::Result<()> { program: "systemctl".into(), args: vec!["restart".into(), "tftpd-hpa".into()], }; - run_privileged_command(&restart, is_root) - .context("failed to restart tftpd-hpa service")?; + run_privileged_command(&restart, is_root).context("failed to restart tftpd-hpa service")?; if udp_port_69_is_listening()? { info!("tftpd-hpa is now listening on UDP port 69"); @@ -348,9 +347,7 @@ fn ensure_tftpd_hpa_service_ready(is_root: bool) -> anyhow::Result<()> { let active = run_capture("systemctl", &["is-active", "tftpd-hpa"]) .unwrap_or_else(|_| "unknown".to_string()); - bail!( - "tftpd-hpa 服务重启后仍未监听 UDP 69(systemctl is-active: {active})" - ); + bail!("tftpd-hpa 服务重启后仍未监听 UDP 69(systemctl is-active: {active})"); } bail!("未检测到可用的服务管理器,且 tftpd-hpa 当前未监听 UDP 69,请手动启动服务"); diff --git a/ostool/src/run/uboot.rs b/ostool/src/run/uboot.rs index ee80904..bf1fa0d 100644 --- a/ostool/src/run/uboot.rs +++ b/ostool/src/run/uboot.rs @@ -21,7 +21,10 @@ use uboot_shell::UbootShell; use crate::{ Tool, run::{ - output_matcher::{ByteStreamMatcher, MATCH_DRAIN_DURATION, StreamMatchKind, compile_regexes, print_match_event}, + output_matcher::{ + ByteStreamMatcher, MATCH_DRAIN_DURATION, StreamMatchKind, compile_regexes, + print_match_event, + }, shell_init::{ShellAutoInitMatcher, normalize_shell_init_config, spawn_delayed_send}, tftp, }, @@ -128,9 +131,10 @@ impl Tool { Ok(content) => { println!("Using U-Boot config: {}", config_path.display()); let config_content = replace_env_placeholders(&content)?; - let mut config: UbootConfig = toml::from_str(&config_content).with_context(|| { - format!("failed to parse U-Boot config: {}", config_path.display()) - })?; + let mut config: UbootConfig = + toml::from_str(&config_content).with_context(|| { + format!("failed to parse U-Boot config: {}", config_path.display()) + })?; config.normalize(&format!("U-Boot config {}", config_path.display()))?; config } diff --git a/ostool/src/tool.rs b/ostool/src/tool.rs index d0b9733..0054324 100644 --- a/ostool/src/tool.rs +++ b/ostool/src/tool.rs @@ -1,9 +1,4 @@ -use std::{ - env::current_dir, - ffi::OsStr, - path::PathBuf, - sync::Arc, -}; +use std::{env::current_dir, ffi::OsStr, path::PathBuf, sync::Arc}; use anyhow::{Context, anyhow, bail}; use cargo_metadata::Metadata; @@ -646,12 +641,9 @@ mod tests { .unwrap(); let package_dir = tool.resolve_package_manifest_dir("kernel").unwrap(); - let resolved = resolve_qemu_config_path_in_dir( - &package_dir, - Some(Architecture::Aarch64), - None, - ) - .unwrap(); + let resolved = + resolve_qemu_config_path_in_dir(&package_dir, Some(Architecture::Aarch64), None) + .unwrap(); assert_eq!(resolved, kernel_dir.join(".qemu-aarch64.toml")); } From 0ebaaa1b77ea991cb6872c021ef94565ed2479f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E7=9D=BF?= Date: Wed, 25 Mar 2026 16:58:14 +0800 Subject: [PATCH 9/9] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=B6=85?= =?UTF-8?q?=E6=97=B6=E9=85=8D=E7=BD=AE=E6=94=AF=E6=8C=81=E5=88=B0=20QEMU?= =?UTF-8?q?=20=E5=92=8C=20U-Boot=20=E8=BF=90=E8=A1=8C=E5=99=A8=EF=BC=8C?= =?UTF-8?q?=E6=94=B9=E8=BF=9B=E4=B8=B2=E5=8F=A3=E7=BB=88=E7=AB=AF=E7=9A=84?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ostool/src/run/qemu.rs | 82 +++++++++++++++++++++++++++++++---------- ostool/src/run/uboot.rs | 53 ++++++++++++++++++-------- ostool/src/sterm/mod.rs | 51 ++++++++++++++++++++++--- 3 files changed, 147 insertions(+), 39 deletions(-) diff --git a/ostool/src/run/qemu.rs b/ostool/src/run/qemu.rs index 746e4b3..9477b0a 100644 --- a/ostool/src/run/qemu.rs +++ b/ostool/src/run/qemu.rs @@ -28,10 +28,11 @@ use std::{ process::{Child, Stdio}, sync::{Arc, Mutex, mpsc}, thread, - time::Duration, + time::{Duration, Instant}, }; use anyhow::{Context, anyhow}; +#[cfg(windows)] use colored::Colorize; use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; use log::warn; @@ -43,12 +44,11 @@ use tokio::fs; use crate::{ Tool, run::{ - output_matcher::{ - ByteStreamMatcher, StreamMatch, StreamMatchKind, compile_regexes, print_match_event, - }, + output_matcher::{ByteStreamMatcher, compile_regexes, print_match_event}, ovmf_prebuilt::{Arch, FileType, Prebuilt, Source}, shell_init::{ShellAutoInitMatcher, normalize_shell_init_config, spawn_delayed_send}, }, + sterm::restore_terminal_mode, utils::PathResultExt, }; @@ -79,6 +79,8 @@ pub struct QemuConfig { pub shell_prefix: Option, /// Command sent once after `shell_prefix` is detected. pub shell_init_cmd: Option, + /// Timeout in seconds. `None` or `0` disables the timeout. + pub timeout: Option, } impl QemuConfig { @@ -375,7 +377,16 @@ impl QemuRunner<'_> { let mut matcher = ByteStreamMatcher::new(self.success_regex.clone(), self.fail_regex.clone()); let mut shell_auto_init = self.config.shell_auto_init(); - Self::process_output_stream(&mut child, &mut matcher, &mut shell_auto_init, stdin)?; + let timeout = timeout_duration(self.config.timeout); + let start = Instant::now(); + Self::process_output_stream( + &mut child, + &mut matcher, + &mut shell_auto_init, + stdin, + timeout, + start, + )?; let out = child.wait_with_output()?; if let Some(res) = matcher.final_result() { @@ -517,6 +528,8 @@ impl QemuRunner<'_> { matcher: &mut ByteStreamMatcher, shell_auto_init: &mut Option, stdin: Option>>, + timeout: Option, + start: Instant, ) -> anyhow::Result<()> { let stdout = child .stdout @@ -569,6 +582,13 @@ impl QemuRunner<'_> { Self::kill_qemu(child)?; break; } + + if let Some(timeout) = timeout + && start.elapsed() >= timeout + { + Self::kill_qemu(child)?; + return Err(anyhow!("QEMU timed out after {}s", timeout.as_secs())); + } } Ok(()) @@ -614,17 +634,7 @@ impl QemuRunner<'_> { return Err(err.into()); } - // 尝试恢复终端状态 - let _ = disable_raw_mode(); - - // 使用 stty 命令恢复终端回显 (最可靠的方法) - let _ = std::process::Command::new("stty") - .arg("echo") - .arg("icanon") - .status(); - - // 刷新输出 - let _ = io::stdout().flush(); + restore_terminal_mode(); println!(); Ok(()) @@ -691,14 +701,21 @@ pub(crate) fn resolve_qemu_config_path_in_dir( Ok(search_dir.join(default_filename)) } +fn timeout_duration(timeout: Option) -> Option { + match timeout { + Some(0) | None => None, + Some(secs) => Some(Duration::from_secs(secs)), + } +} + #[cfg(test)] mod tests { use super::{ QemuConfig, QemuDefaultOverrides, QemuRunner, build_default_qemu_config, - resolve_qemu_config_path, resolve_qemu_config_path_in_dir, + resolve_qemu_config_path, resolve_qemu_config_path_in_dir, timeout_duration, }; use object::Architecture; - use std::path::PathBuf; + use std::{path::PathBuf, time::Duration}; use tempfile::TempDir; use crate::{ @@ -735,6 +752,7 @@ mod tests { assert_eq!(config.args, vec!["-nographic", "-cpu", "cortex-a53"]); assert!(config.success_regex.is_empty()); assert!(config.fail_regex.is_empty()); + assert_eq!(config.timeout, None); } #[test] @@ -745,6 +763,7 @@ mod tests { args: vec!["-m".into(), "512M".into()], success_regex: vec!["PASS".into()], fail_regex: vec!["FAIL".into()], + ..Default::default() }, ); @@ -754,6 +773,7 @@ mod tests { ); assert_eq!(config.success_regex, vec!["PASS"]); assert_eq!(config.fail_regex, vec!["FAIL"]); + assert_eq!(config.timeout, None); } #[test] @@ -769,6 +789,31 @@ mod tests { assert!(!config.to_bin); assert_eq!(config.args, vec!["-nographic", "-smp", "2"]); + assert_eq!(config.timeout, None); + } + + #[test] + fn qemu_timeout_zero_disables_timeout() { + assert_eq!(timeout_duration(None), None); + assert_eq!(timeout_duration(Some(0)), None); + assert_eq!(timeout_duration(Some(3)), Some(Duration::from_secs(3))); + } + + #[test] + fn qemu_config_parses_timeout_from_toml() { + let config: QemuConfig = toml::from_str( + r#" +args = ["-nographic"] +uefi = false +to_bin = true +success_regex = [] +fail_regex = [] +timeout = 0 +"#, + ) + .unwrap(); + + assert_eq!(config.timeout, Some(0)); } #[test] @@ -832,7 +877,6 @@ mod tests { let runner = QemuRunner { tool: &mut tool, config: QemuConfig::default(), - args: vec![], dtbdump: false, success_regex: vec![], fail_regex: vec![], diff --git a/ostool/src/run/uboot.rs b/ostool/src/run/uboot.rs index bf1fa0d..15ade62 100644 --- a/ostool/src/run/uboot.rs +++ b/ostool/src/run/uboot.rs @@ -22,8 +22,7 @@ use crate::{ Tool, run::{ output_matcher::{ - ByteStreamMatcher, MATCH_DRAIN_DURATION, StreamMatchKind, compile_regexes, - print_match_event, + ByteStreamMatcher, MATCH_DRAIN_DURATION, compile_regexes, print_match_event, }, shell_init::{ShellAutoInitMatcher, normalize_shell_init_config, spawn_delayed_send}, tftp, @@ -69,6 +68,8 @@ pub struct UbootConfig { pub shell_prefix: Option, /// Command sent once after `shell_prefix` is detected. pub shell_init_cmd: Option, + /// Timeout in seconds after entering kernel output. `None` or `0` disables the timeout. + pub timeout: Option, } impl UbootConfig { @@ -515,12 +516,6 @@ impl Runner<'_> { ); (prepared.relative_filename, true) } else if let Some(tftp_dir) = is_tftp.as_deref() { - let tftp_dir = self - .config - .net - .as_ref() - .and_then(|net| net.tftp_dir.as_ref()) - .unwrap(); let fitimage = fitimage.file_name().unwrap(); let tftp_path = PathBuf::from(tftp_dir).join(fitimage); info!("Setting TFTP file path: {}", tftp_path.display()); @@ -534,12 +529,6 @@ impl Runner<'_> { (name.to_string(), false) } } else if let Some(tftp_dir) = is_tftp.as_deref() { - let tftp_dir = self - .config - .net - .as_ref() - .and_then(|net| net.tftp_dir.as_ref()) - .unwrap(); let fitimage = fitimage.file_name().unwrap(); let tftp_path = PathBuf::from(tftp_dir).join(fitimage); info!("Setting TFTP file path: {}", tftp_path.display()); @@ -621,6 +610,9 @@ impl Runner<'_> { } }, ); + if let Some(timeout) = timeout_duration(self.config.timeout) { + shell = shell.with_timeout(timeout, "kernel boot"); + } shell.run().await?; { let mut res_lock = res.lock().unwrap(); @@ -688,6 +680,13 @@ impl Runner<'_> { } } +fn timeout_duration(timeout: Option) -> Option { + match timeout { + Some(0) | None => None, + Some(secs) => Some(Duration::from_secs(secs)), + } +} + fn build_network_boot_request( board_ip: Option<&str>, net_ok: bool, @@ -719,7 +718,8 @@ fn build_network_boot_request( #[cfg(test)] mod tests { - use super::{UbootConfig, build_network_boot_request}; + use super::{UbootConfig, build_network_boot_request, timeout_duration}; + use std::time::Duration; #[test] fn network_boot_request_uses_same_filename_for_bootfile() { @@ -783,4 +783,27 @@ mod tests { assert_eq!(config.shell_prefix.as_deref(), Some("login:")); assert_eq!(config.shell_init_cmd.as_deref(), Some("root")); } + + #[test] + fn uboot_timeout_zero_disables_timeout() { + assert_eq!(timeout_duration(None), None); + assert_eq!(timeout_duration(Some(0)), None); + assert_eq!(timeout_duration(Some(5)), Some(Duration::from_secs(5))); + } + + #[test] + fn uboot_config_parses_timeout_from_toml() { + let config: UbootConfig = toml::from_str( + r#" +serial = "/dev/null" +baud_rate = "115200" +success_regex = [] +fail_regex = [] +timeout = 0 +"#, + ) + .unwrap(); + + assert_eq!(config.timeout, Some(0)); + } } diff --git a/ostool/src/sterm/mod.rs b/ostool/src/sterm/mod.rs index 2066970..1b41d2a 100644 --- a/ostool/src/sterm/mod.rs +++ b/ostool/src/sterm/mod.rs @@ -12,11 +12,13 @@ //! Press `Ctrl+A` followed by `x` to exit the serial terminal. use std::io::{self, Read, Write}; -use std::sync::atomic::AtomicBool; +use std::process::Command; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; +use anyhow::anyhow; use crossterm::{ event::{Event, EventStream, KeyCode, KeyEventKind, KeyModifiers}, terminal::{disable_raw_mode, enable_raw_mode}, @@ -45,6 +47,8 @@ pub struct SerialTerm { tx: Arc>, rx: Arc>, on_byte: Option, + timeout: Option, + timeout_label: Option, } /// Handle for controlling the terminal session. @@ -52,6 +56,7 @@ pub struct SerialTerm { /// Provides methods to stop the terminal from within callbacks. pub struct TermHandle { is_running: AtomicBool, + timed_out: AtomicBool, stop_deadline: Mutex>, } @@ -61,8 +66,7 @@ impl TermHandle { /// This can be called from within a receive callback to terminate the session /// when a specific pattern is detected. pub fn stop(&self) { - self.is_running - .store(false, std::sync::atomic::Ordering::Release); + self.is_running.store(false, Ordering::Release); } /// Schedules the terminal to stop after the specified duration. @@ -75,7 +79,7 @@ impl TermHandle { /// Returns whether the terminal session is still running. pub fn is_running(&self) -> bool { - self.is_running.load(std::sync::atomic::Ordering::Acquire) + self.is_running.load(Ordering::Acquire) } fn should_stop_now(&self) -> bool { @@ -84,6 +88,14 @@ impl TermHandle { .unwrap() .is_some_and(|deadline| Instant::now() >= deadline) } + + fn mark_timed_out(&self) { + self.timed_out.store(true, Ordering::Release); + } + + fn timed_out(&self) -> bool { + self.timed_out.load(Ordering::Acquire) + } } // 特殊键序列状态 @@ -125,6 +137,8 @@ impl SerialTerm { tx: Arc::new(Mutex::new(tx)), rx: Arc::new(Mutex::new(rx)), on_byte: Some(Box::new(on_byte)), + timeout: None, + timeout_label: None, } } @@ -137,9 +151,18 @@ impl SerialTerm { tx: Arc::new(Mutex::new(tx)), rx: Arc::new(Mutex::new(rx)), on_byte: Some(Box::new(on_byte)), + timeout: None, + timeout_label: None, } } + /// Configures a session timeout for the interactive terminal. + pub fn with_timeout(mut self, timeout: Duration, label: impl Into) -> Self { + self.timeout = Some(timeout); + self.timeout_label = Some(label.into()); + self + } + /// Runs the interactive serial terminal. /// /// This method blocks until the user exits (Ctrl+A x) or the line callback @@ -160,7 +183,7 @@ impl SerialTerm { // 确保清理终端状态 if cleanup_needed { - let _ = disable_raw_mode(); + restore_terminal_mode(); println!(); // 添加换行符 eprintln!("✓ 已退出串口终端模式"); } @@ -176,8 +199,12 @@ impl SerialTerm { let handle = Arc::new(TermHandle { is_running: AtomicBool::new(true), + timed_out: AtomicBool::new(false), stop_deadline: Mutex::new(None), }); + if let Some(timeout) = self.timeout { + handle.stop_after(timeout); + } // 使用 EventStream 异步处理键盘事件 let tx_handle = tokio::spawn(Self::tx_work_async(handle.clone(), tx_port)); @@ -191,6 +218,11 @@ impl SerialTerm { // 等待接收线程结束 let _ = rx_handle.await?; let _ = tx_handle.await; + if handle.timed_out() { + let timeout = self.timeout.unwrap(); + let label = self.timeout_label.as_deref().unwrap_or("serial terminal"); + return Err(anyhow!("{label} timed out after {}s", timeout.as_secs())); + } info!("Serial terminal exited"); Ok(()) } @@ -222,6 +254,7 @@ impl SerialTerm { io::stdout().write_all(&byte)?; (on_byte)(handle.as_ref(), b); if handle.should_stop_now() { + handle.mark_timed_out(); handle.stop(); break; } @@ -232,6 +265,7 @@ impl SerialTerm { Ok(_) => { // 没有数据可读,短暂休眠 if handle.should_stop_now() { + handle.mark_timed_out(); handle.stop(); break; } @@ -240,6 +274,7 @@ impl SerialTerm { Err(e) if e.kind() == io::ErrorKind::TimedOut => { // 超时是正常的,继续 if handle.should_stop_now() { + handle.mark_timed_out(); handle.stop(); break; } @@ -617,3 +652,9 @@ impl SerialTerm { Ok(()) } } + +pub fn restore_terminal_mode() { + let _ = disable_raw_mode(); + let _ = Command::new("stty").arg("echo").arg("icanon").status(); + let _ = io::stdout().flush(); +}