@@ -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) ]
1526pub 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) ]
231279mod 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]
0 commit comments