Skip to content

Commit 122753d

Browse files
committed
feat(board): run shared session programs
1 parent 3a09db0 commit 122753d

7 files changed

Lines changed: 494 additions & 20 deletions

File tree

README.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,12 +317,31 @@ ostool board config
317317
`server` 应使用包含 `http://``https://` 的完整 URL;可选的 `port` 会覆盖 URL 中的端口。为兼容旧的局域网配置,裸 IPv4 或 IPv6 地址会自动补为 `http://`。基线版本写出的 `server_ip` / `port` 也会在读取时迁移为 `server` / `port`,下一次保存配置时只写新格式;无 scheme 的主机名不支持。项目级 `.board.toml` 中的 `server` / `port` 仍可用于 `ostool board run`,其优先级低于命令行参数,高于全局配置。
318318

319319
`.board.toml` 可以用 `session_files` 声明相对于配置文件目录的共享文件。调用方通过
320-
`BoardRunRequest::with_session_files` 提供该目录,ostool 会在 board session
320+
`BoardRunRequest::with_session_root` 提供该目录,ostool 会在 board session
321321
建立后按原相对路径上传,并在 `shell_init_cmd` 中展开
322322
`${boardServerIp}``${boardServerHttpBaseUrl}`
323323
`${sessionFile:<relative-path>}`。绝对路径、`..`、符号链接逃逸、重复路径及缺失
324324
文件都会在运行前被拒绝;接口不提供 alias 或上传改名。
325325

326+
只需运行一个共享程序时,可改用声明式配置:
327+
328+
```toml
329+
board_type = "AKA-00-SG2002"
330+
shell_prefix = "root@starry:"
331+
success_regex = ["(?m)^PROGRAM_OK\\s*$"]
332+
333+
[session_program]
334+
path = "bin/probe"
335+
args = ["--server", "${boardServerIp}"]
336+
```
337+
338+
`session_program.path` 会自动加入共享文件,无需同时写入 `session_files`。检测到 shell
339+
prompt 后,ostool 在 `/tmp/ostool-session/<session_id>/` 下按原相对路径下载所有
340+
session 文件,为程序添加执行权限,并以经过 POSIX shell 引号保护的 argv 运行程序。
341+
下载会在 60 秒内依次尝试 curl 和 wget;下载、赋权或程序退出失败都会触发 board
342+
测试失败并进入正常的 session release 流程。`session_program`
343+
`shell_init_cmd` 互斥。
344+
326345
### 公网开发板认证
327346

328347
局域网直接连接 `ostool-server` 时保留上述匿名 HTTP 配置。公网认证网关使用完整 HTTPS 地址:

ostool/src/board/config.rs

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,17 @@ use crate::{
1010
run::shell_init::normalize_shell_init_config,
1111
};
1212

13+
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
14+
#[serde(deny_unknown_fields)]
15+
pub struct BoardSessionProgram {
16+
/// Program path relative to the board configuration's session root.
17+
pub path: PathBuf,
18+
/// Literal argv entries. Project and board-session placeholders are expanded
19+
/// before each entry is POSIX-shell quoted.
20+
#[serde(default)]
21+
pub args: Vec<String>,
22+
}
23+
1324
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default, PartialEq, Eq)]
1425
#[serde(deny_unknown_fields)]
1526
pub struct BoardRunConfig {
@@ -20,6 +31,9 @@ pub struct BoardRunConfig {
2031
/// relative path on the session HTTP endpoint.
2132
#[serde(default)]
2233
pub session_files: Vec<PathBuf>,
34+
/// Program uploaded, downloaded, and executed for this board session.
35+
#[serde(default)]
36+
pub session_program: Option<BoardSessionProgram>,
2337
pub dtb_file: Option<String>,
2438
pub kernel_load_addr: Option<String>,
2539
pub fit_load_addr: Option<String>,
@@ -168,6 +182,13 @@ impl BoardRunConfig {
168182
.as_deref()
169183
.map(|value| variables::expand_variables(value, scope))
170184
.transpose()?;
185+
if let Some(program) = self.session_program.as_mut() {
186+
program.args = program
187+
.args
188+
.iter()
189+
.map(|value| variables::expand_variables(value, scope))
190+
.collect::<anyhow::Result<Vec<_>>>()?;
191+
}
171192
self.server = self
172193
.server
173194
.as_deref()
@@ -212,7 +233,34 @@ impl BoardRunConfig {
212233
&mut self.shell_prefix,
213234
&mut self.shell_init_cmd,
214235
config_name,
215-
)
236+
)?;
237+
if let Some(program) = self.session_program.as_ref() {
238+
if self.shell_init_cmd.is_some() {
239+
anyhow::bail!(
240+
"`session_program` and `shell_init_cmd` are mutually exclusive in {config_name}"
241+
);
242+
}
243+
if self.shell_prefix.is_none() {
244+
anyhow::bail!("`session_program` requires `shell_prefix` in {config_name}");
245+
}
246+
if program.path.as_os_str().is_empty() {
247+
anyhow::bail!("`session_program.path` must not be empty in {config_name}");
248+
}
249+
if program.path.to_string_lossy().contains("${") {
250+
anyhow::bail!("`session_program.path` must not contain variables in {config_name}");
251+
}
252+
if let Some(argument) = program
253+
.args
254+
.iter()
255+
.find(|argument| argument.contains(['\0', '\r', '\n']))
256+
{
257+
anyhow::bail!(
258+
"`session_program.args` must not contain NUL or newline characters in \
259+
{config_name}: {argument:?}"
260+
);
261+
}
262+
}
263+
Ok(())
216264
}
217265
}
218266

@@ -229,7 +277,7 @@ fn normalize_optional_string(value: &mut Option<String>) {
229277

230278
#[cfg(test)]
231279
mod tests {
232-
use super::BoardRunConfig;
280+
use super::{BoardRunConfig, BoardSessionProgram};
233281
use crate::{
234282
board::global_config::BoardGlobalConfig,
235283
board::{ensure_run_config_in_dir, read_run_config_from_path},
@@ -317,6 +365,28 @@ port = 9000
317365
assert_eq!(decoded, config);
318366
}
319367

368+
#[test]
369+
fn board_run_config_session_program_toml_round_trip() {
370+
let config = BoardRunConfig {
371+
board_type: "aka-00-sg2002".to_string(),
372+
shell_prefix: Some("root@starry:".to_string()),
373+
session_program: Some(BoardSessionProgram {
374+
path: PathBuf::from("bin/sg2002-libuvc-init"),
375+
args: vec![
376+
"--server".to_string(),
377+
"${boardServerIp}".to_string(),
378+
"argument with spaces".to_string(),
379+
],
380+
}),
381+
..Default::default()
382+
};
383+
384+
let encoded = toml::to_string(&config).unwrap();
385+
let decoded: BoardRunConfig = toml::from_str(&encoded).unwrap();
386+
387+
assert_eq!(decoded, config);
388+
}
389+
320390
#[test]
321391
fn legacy_board_run_config_defaults_to_no_session_files() {
322392
let fixture = LegacyBoardRunConfigFixture {
@@ -327,6 +397,44 @@ port = 9000
327397
let decoded: BoardRunConfig = toml::from_str(&encoded).unwrap();
328398

329399
assert!(decoded.session_files.is_empty());
400+
assert!(decoded.session_program.is_none());
401+
}
402+
403+
#[test]
404+
fn session_program_rejects_shell_init_command_and_missing_prefix() {
405+
let mut conflicting = BoardRunConfig {
406+
board_type: "aka-00-sg2002".to_string(),
407+
shell_prefix: Some("root@starry:".to_string()),
408+
shell_init_cmd: Some("echo legacy".to_string()),
409+
session_program: Some(BoardSessionProgram {
410+
path: PathBuf::from("bin/probe"),
411+
args: Vec::new(),
412+
}),
413+
..Default::default()
414+
};
415+
assert!(
416+
conflicting
417+
.normalize("test board config")
418+
.unwrap_err()
419+
.to_string()
420+
.contains("mutually exclusive")
421+
);
422+
423+
let mut missing_prefix = BoardRunConfig {
424+
board_type: "aka-00-sg2002".to_string(),
425+
session_program: Some(BoardSessionProgram {
426+
path: PathBuf::from("bin/probe"),
427+
args: Vec::new(),
428+
}),
429+
..Default::default()
430+
};
431+
assert!(
432+
missing_prefix
433+
.normalize("test board config")
434+
.unwrap_err()
435+
.to_string()
436+
.contains("shell_prefix")
437+
);
330438
}
331439

332440
#[test]

ostool/src/board/mod.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -325,9 +325,9 @@ pub async fn run_prepared_board(
325325
board_config: &BoardRunConfig,
326326
options: RunBoardOptions,
327327
) -> anyhow::Result<()> {
328-
if !board_config.session_files.is_empty() {
328+
if !board_config.session_files.is_empty() || board_config.session_program.is_some() {
329329
anyhow::bail!(
330-
"board config contains `session_files`; use BoardRunRequest::with_session_files to provide the configuration directory"
330+
"board config contains session assets; use BoardRunRequest::with_session_root to provide the session root"
331331
);
332332
}
333333
run_prepared_board_with_request(
@@ -382,6 +382,7 @@ pub async fn run_prepared_board_with_request(
382382

383383
fn board_session_setup_required(board_config: &BoardRunConfig, has_session_files: bool) -> bool {
384384
has_session_files
385+
|| board_config.session_program.is_some()
385386
|| board_config
386387
.shell_init_cmd
387388
.as_deref()

ostool/src/board/request.rs

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,84 @@ impl BoardRunRequest {
2727
root: &Path,
2828
relative_paths: &[PathBuf],
2929
) -> anyhow::Result<Self> {
30-
self.session_files = collect_session_files(root, relative_paths)?;
30+
let mut declared_paths = relative_paths.to_vec();
31+
if let Some(program) = self.board_config.session_program.as_ref() {
32+
declared_paths.push(program.path.clone());
33+
}
34+
self.session_files = collect_session_files(root, &declared_paths)?;
35+
Ok(self)
36+
}
37+
38+
/// Resolves every file declared by the board configuration under `root`.
39+
///
40+
/// This includes both `session_files` and `session_program.path`.
41+
pub fn with_session_root(mut self, root: &Path) -> anyhow::Result<Self> {
42+
let declared_files = self.board_config.session_files.clone();
43+
self = self.with_session_files(root, &declared_files)?;
3144
Ok(self)
3245
}
3346

3447
pub(crate) fn into_parts(self) -> (BoardRunConfig, RunBoardOptions, Vec<SessionFileUpload>) {
3548
(self.board_config, self.options, self.session_files)
3649
}
3750
}
51+
52+
#[cfg(test)]
53+
mod tests {
54+
use std::{fs, path::PathBuf};
55+
56+
use tempfile::tempdir;
57+
58+
use super::*;
59+
use crate::board::config::BoardSessionProgram;
60+
61+
#[test]
62+
fn session_root_collects_declared_files_and_program() {
63+
let root = tempdir().unwrap();
64+
fs::create_dir_all(root.path().join("bin")).unwrap();
65+
fs::write(root.path().join("config.toml"), b"config").unwrap();
66+
fs::write(root.path().join("bin/probe"), b"probe").unwrap();
67+
let config = BoardRunConfig {
68+
board_type: "test-board".into(),
69+
session_files: vec![PathBuf::from("config.toml")],
70+
session_program: Some(BoardSessionProgram {
71+
path: PathBuf::from("bin/probe"),
72+
args: Vec::new(),
73+
}),
74+
..Default::default()
75+
};
76+
77+
let request = BoardRunRequest::new(config, RunBoardOptions::default())
78+
.with_session_root(root.path())
79+
.unwrap();
80+
let (_, _, uploads) = request.into_parts();
81+
let paths = uploads
82+
.iter()
83+
.map(SessionFileUpload::relative_path)
84+
.collect::<Vec<_>>();
85+
86+
assert_eq!(paths, ["config.toml", "bin/probe"]);
87+
}
88+
89+
#[test]
90+
fn session_root_rejects_duplicate_program_path() {
91+
let root = tempdir().unwrap();
92+
fs::create_dir_all(root.path().join("bin")).unwrap();
93+
fs::write(root.path().join("bin/probe"), b"probe").unwrap();
94+
let config = BoardRunConfig {
95+
board_type: "test-board".into(),
96+
session_files: vec![PathBuf::from("bin/probe")],
97+
session_program: Some(BoardSessionProgram {
98+
path: PathBuf::from("bin/probe"),
99+
args: Vec::new(),
100+
}),
101+
..Default::default()
102+
};
103+
104+
let error = BoardRunRequest::new(config, RunBoardOptions::default())
105+
.with_session_root(root.path())
106+
.unwrap_err();
107+
108+
assert!(error.to_string().contains("duplicate"));
109+
}
110+
}

0 commit comments

Comments
 (0)