Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 0 additions & 18 deletions crates/openjd-model/src/job/create_job/instantiate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -501,9 +501,6 @@ fn filter_symtab_for_step(
if let Some(d) = &f.data {
d.copy_used_symtab_values(full, &mut filtered);
}
if let Some(n) = &f.filename {
n.copy_used_symtab_values(full, &mut filtered);
}
}
}
if let Some(bindings) = &s.let_bindings {
Expand All @@ -525,9 +522,6 @@ fn filter_symtab_for_step(
if let Some(d) = &f.data {
d.copy_used_symtab_values(full, &mut filtered);
}
if let Some(n) = &f.filename {
n.copy_used_symtab_values(full, &mut filtered);
}
}
}
}
Expand Down Expand Up @@ -617,9 +611,6 @@ fn collect_all_accessed_symbols(
if let Some(d) = &f.data {
collect_from_fs(d, &mut symbols);
}
if let Some(n) = &f.filename {
collect_from_fs(n, &mut symbols);
}
}
}
if let Some(bindings) = &s.let_bindings {
Expand Down Expand Up @@ -650,9 +641,6 @@ fn collect_all_accessed_symbols(
if let Some(d) = &f.data {
collect_from_fs(d, &mut symbols);
}
if let Some(n) = &f.filename {
collect_from_fs(n, &mut symbols);
}
}
}
}
Expand Down Expand Up @@ -717,9 +705,6 @@ fn filter_symtab_for_environment(env: &job::Environment, full: &SymbolTable) ->
if let Some(d) = &f.data {
d.copy_used_symtab_values(full, &mut filtered);
}
if let Some(n) = &f.filename {
n.copy_used_symtab_values(full, &mut filtered);
}
}
}
if let Some(bindings) = &es.let_bindings {
Expand Down Expand Up @@ -763,9 +748,6 @@ fn collect_env_accessed_symbols(env: &job::Environment) -> std::collections::Has
if let Some(d) = &f.data {
symbols.extend(d.accessed_symbols());
}
if let Some(n) = &f.filename {
symbols.extend(n.accessed_symbols());
}
}
}
if let Some(bindings) = &es.let_bindings {
Expand Down
2 changes: 1 addition & 1 deletion crates/openjd-model/src/job/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ pub struct EmbeddedFile {
pub name: String,
#[serde(alias = "type")]
pub file_type: FileType,
pub filename: Option<FormatString>,
pub filename: Option<String>,
pub data: Option<FormatString>,
pub runnable: Option<bool>,
pub end_of_line: Option<EndOfLine>,
Expand Down
2 changes: 1 addition & 1 deletion crates/openjd-model/src/template/environment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ pub struct EmbeddedFile {
pub name: String,
#[serde(rename = "type")]
pub file_type: FileType,
pub filename: Option<FormatString>,
pub filename: Option<String>,
pub data: Option<FormatString>,
pub runnable: Option<bool>,
#[serde(rename = "endOfLine")]
Expand Down
2 changes: 1 addition & 1 deletion crates/openjd-model/src/template/step.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ impl StepTemplate {
embedded_files: Some(vec![EmbeddedFile {
name: embedded_name,
file_type: crate::types::FileType::Text,
filename: Some(FormatString::new(&filename).unwrap()),
filename: Some(filename),
data: Some(FormatString::new(&sa.script).map_err(|e| {
crate::ModelError::DecodeValidation(format!(
"SimpleAction script format string error: {e}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,11 @@ fn build_task_scope_symtab(
}
}

// Task.File.* from step script embedded files
// Task.File.* from step script embedded files. In scope for script-level
// `let` bindings too: `filename` is a plain string (never an expression),
// so the runtime allocates embedded file paths — defining Task.File.* —
// before evaluating `let`, mirroring the environment runner's Env.File.*
// ordering.
if let Some(script) = step
.resolve_syntax_sugar()
.ok()
Expand Down Expand Up @@ -662,7 +666,10 @@ pub fn validate_format_strings(
if let Some(script) = &step.script {
let script_path = path_field(&step_path, "script");

// Script-level let bindings (TASK scope — host_lib)
// Script-level let bindings (TASK scope — host_lib). Task.File.*
// is in scope: file paths are allocated before `let` evaluation
// at runtime (filenames are plain strings, so allocation cannot
// depend on `let` values).
if let Some(bindings) = &script.let_bindings {
let let_path = path_field(&script_path, "let");
if !expr_active {
Expand Down Expand Up @@ -763,15 +770,8 @@ pub fn validate_format_strings(
errors,
);
}
if let Some(filename) = &f.filename {
validate_fs(
filename,
&task_symtab,
&host_lib,
&path_field(&f_path, "filename"),
errors,
);
}
// `filename` is a plain string per the 2023-09 schema
// (not @fmtstring) — no format-string validation.
}
}

Expand Down Expand Up @@ -1186,16 +1186,8 @@ fn validate_env_format_strings(
}
validate_fs(data, symtab, lib, &data_path, errors);
}
if let Some(filename) = &f.filename {
let filename_path = path_field(&f_path, "filename");
if !expr_active && filename.has_complex_expressions() {
errors.add(
&filename_path,
"complex expressions require the EXPR extension.",
);
}
validate_fs(filename, symtab, lib, &filename_path, errors);
}
// `filename` is a plain string per the 2023-09 schema
// (not @fmtstring) — no format-string validation.
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ pub fn enforce_limits(jt: &JobTemplate, limits: &EffectiveLimits, errors: &mut V
);
}
if let Some(filename) = &f.filename {
if filename.raw().len() > limits.max_filename_len {
if filename.len() > limits.max_filename_len {
errors.add(
&path_field(&f_path, "filename"),
format!("exceeds {} characters.", limits.max_filename_len),
Expand Down Expand Up @@ -139,7 +139,7 @@ fn enforce_environment_limits(
);
}
if let Some(filename) = &f.filename {
if filename.raw().len() > limits.max_filename_len {
if filename.len() > limits.max_filename_len {
errors.add(
&path_field(&f_path, "filename"),
format!("exceeds {} characters.", limits.max_filename_len),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -925,7 +925,7 @@ fn validate_embedded_files(
}
}
if let Some(filename) = &f.filename {
let fname = filename.raw();
let fname = filename.as_str();
if fname.is_empty() {
errors.add(&path_field(&f_path, "filename"), "must not be empty.");
}
Expand Down
10 changes: 5 additions & 5 deletions crates/openjd-model/tests/integration/test_create_job.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1641,9 +1641,9 @@ fn test_scope_boundary_embedded_file_filename_not_resolved() {
);
let files = job.steps[0].script.embedded_files.as_ref().unwrap();
assert_eq!(
files[0].filename.as_ref().unwrap().raw(),
"{{Param.Val}}",
"EmbeddedFile filename is SESSION/TASK scope — must NOT be resolved during create_job"
files[0].filename.as_deref(),
Some("{{Param.Val}}"),
"EmbeddedFile filename is a plain string (not @fmtstring) — braces are literal text, never resolved"
);
}

Expand Down Expand Up @@ -1760,8 +1760,8 @@ fn test_scope_boundary_env_embedded_files_not_resolved() {
.embedded_files
.as_ref()
.unwrap();
assert_eq!(files[0].filename.as_ref().unwrap().raw(), "{{Param.Val}}",
"Environment embedded file filename is SESSION scope — must NOT be resolved during create_job");
assert_eq!(files[0].filename.as_deref(), Some("{{Param.Val}}"),
"Environment embedded file filename is a plain string (not @fmtstring) — braces are literal text, never resolved");
assert_eq!(
files[0].data.as_ref().unwrap().raw(),
"echo {{Param.Val}}",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -639,7 +639,10 @@ fn test_env_file_has_path_properties() {

#[test]
fn test_task_file_has_path_properties() {
// Task.File.* is PATH type, so .stem should work in script-level let bindings
// Task.File.* is PATH type and IS in scope for script-level let bindings:
// `filename` is a plain string (never an expression), so the runtime
// allocates embedded file paths — defining Task.File.* — before `let`
// evaluation, mirroring Env.File.* in environment scripts.
let s = r#"{
"specificationVersion": "jobtemplate-2023-09",
"extensions": ["EXPR"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,11 @@ fn task_scope_embedded_file_data_accepts_apply_path_mapping() {
}

#[test]
fn task_scope_embedded_file_filename_accepts_apply_path_mapping() {
fn task_scope_embedded_file_filename_is_plain_string() {
// `filename` is a plain string per the 2023-09 schema (not @fmtstring):
// brace syntax is literal text, so no format-string/library validation
// applies (though it still fails the runtime basename check if it
// contains separators — none here).
let t = job_with_path_param(
r#""test""#,
&simple_step(
Expand Down
32 changes: 14 additions & 18 deletions crates/openjd-sessions/src/embedded_files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,14 +178,14 @@ fn random_hex_filename() -> String {
/// rejecting `/` and `\` in the filename.
///
/// This function is a defense-in-depth check at the point where the
/// resolved filename is joined to the target directory. It protects against:
/// filename is joined to the target directory. It protects against:
/// - Bugs or gaps in model-layer validation.
/// - Call paths that reach the session layer without going through full
/// model validation.
/// - Any implementation-level format-string substitution in the filename
/// (the current model stores `filename` as a `FormatString`) that could
/// introduce separators or traversal components from symbol values at
/// session time.
/// model validation (e.g. an `EmbeddedFile` constructed directly).
///
/// Note: `filename` is a plain string per the 2023-09 schema (not
/// `@fmtstring`), so no expression substitution can introduce separators
/// at session time — the value checked here is exactly the template value.
///
/// Rejects filenames that:
/// - Are empty.
Expand Down Expand Up @@ -268,27 +268,23 @@ impl EmbeddedFiles {
);
for file in files {
let symbol = symtab_key(self.scope, &file.name);
let filename = if let Some(ref fname_fs) = file.filename {
let resolved = fname_fs
.resolve_string_with(symtab, &openjd_expr::FormatStringOptions::new())
.map_err(|e| SessionError::FormatString {
context: format!("embedded file '{}' filename", file.name),
reason: e.to_string(),
})?;
let filename = if let Some(ref fname) = file.filename {
// `filename` is a plain string per the 2023-09 schema (not
// @fmtstring) — use it literally, no expression resolution.
//
// Defense-in-depth: the spec requires `filename` to be a
// plain basename and the model layer rejects path separators
// in the raw template. Re-check the value here before it is
// joined to the target directory, in case model validation
// was bypassed or the resolved value differs from the raw
// template.
validate_resolved_filename(&resolved).map_err(|reason| {
// was bypassed.
validate_resolved_filename(fname).map_err(|reason| {
SessionError::EmbeddedFilePath {
name: file.name.clone(),
filename: resolved.clone(),
filename: fname.clone(),
reason,
}
})?;
self.target_directory.join(resolved)
self.target_directory.join(fname)
} else {
let name = random_hex_filename();
let path = self.target_directory.join(&name);
Expand Down
4 changes: 3 additions & 1 deletion crates/openjd-sessions/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ pub use error::SessionError;
pub use logging::LogContent;
pub use openjd_expr::path_mapping::{PathFormat, PathMappingRule};
pub use runner::{CancelMethod, ScriptRunnerState};
pub use session::{EnvironmentIdentifier, Session, SessionConfig, SessionState};
pub use session::{
EnvironmentIdentifier, Session, SessionCancelHandle, SessionConfig, SessionState,
};
#[cfg(windows)]
pub use session_user::BadCredentialsError;
#[cfg(unix)]
Expand Down
39 changes: 27 additions & 12 deletions crates/openjd-sessions/src/runner/step_script.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,25 +119,40 @@ impl StepScriptRunner {
env_vars: &HashMap<String, Option<String>>,
message_tx: mpsc::UnboundedSender<ActionMessage>,
) -> Result<SubprocessResult, SessionError> {
// Step scripts: evaluate let bindings first, then materialize files
let mut final_symtab = if let Some(bindings) = &script.let_bindings {
evaluate_let_bindings(bindings, symtab, library, openjd_expr::PathFormat::host())
.map_err(|e| SessionError::FormatString {
context: "let bindings".into(),
reason: e.to_string(),
})?
} else {
symtab.clone()
};

if let Some(files) = &script.embedded_files {
// Step scripts: allocate embedded file paths first — `filename` is a
// plain string (never an expression) so allocation has no dependency
// on `let` values, and it defines the `Task.File.*` symbols that
// `let` bindings may reference. File *contents* are written after
// `let` evaluation so `data` expressions can use let-bound values.
// This matches the environment runner's ordering for `Env.File.*`.
let mut final_symtab = symtab.clone();
let ef = if let Some(files) = &script.embedded_files {
let mut ef = EmbeddedFiles::new(
EmbeddedFilesScope::Step,
self.base.files_directory.clone(),
&self.base.session_id,
)
.with_user(self.base.user.clone());
ef.allocate_file_paths(files, &mut final_symtab)?;
Some(ef)
} else {
None
};

if let Some(bindings) = &script.let_bindings {
final_symtab = evaluate_let_bindings(
bindings,
&final_symtab,
library,
openjd_expr::PathFormat::host(),
)
.map_err(|e| SessionError::FormatString {
context: "let bindings".into(),
reason: e.to_string(),
})?;
}

if let Some(ef) = ef {
ef.write_file_contents(&final_symtab, library)?;
}

Expand Down
Loading
Loading