Skip to main content

bijux_dag_runtime/runtime_core/planning/
path_resolution.rs

1use bijux_dag_artifacts::{is_normalized_relative_path, RunDirLayout};
2use bijux_dag_core::is_known_path_variable;
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use std::path::Path;
6
7const CONTAINER_INPUTS_DIR: &str = "/bijux/node/inputs";
8const CONTAINER_OUTPUTS_DIR: &str = "/bijux/node/outputs";
9const CONTAINER_WORK_DIR: &str = "/bijux/node/work";
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
12#[serde(rename_all = "snake_case")]
13pub enum AbsolutePathPolicy {
14    #[default]
15    AllowLiteral,
16    #[serde(alias = "deny")]
17    DenyLiteral,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct NodePathBindings {
22    pub run_dir: Option<String>,
23    pub work_dir: String,
24    pub inputs_dir: String,
25    pub outputs_dir: String,
26    pub cache_dir: Option<String>,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct ResolvedPathUsage {
31    pub key_path: String,
32    pub expression: String,
33    pub resolved_path: String,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub(crate) enum PathBindingSurface {
38    Host,
39    Container,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43struct PathExpression<'a> {
44    variable: &'a str,
45    relative_path: Option<&'a str>,
46}
47
48impl NodePathBindings {
49    pub fn for_host(layout: &RunDirLayout, node_id: &str, cache_dir: Option<&Path>) -> Self {
50        Self {
51            run_dir: Some(layout.staging_path.display().to_string()),
52            work_dir: layout.node_work_dir(node_id).display().to_string(),
53            inputs_dir: layout.node_inputs_dir(node_id).display().to_string(),
54            outputs_dir: layout.node_outputs_dir(node_id).display().to_string(),
55            cache_dir: cache_dir.map(|path| path.display().to_string()),
56        }
57    }
58
59    pub fn for_container() -> Self {
60        Self {
61            run_dir: None,
62            work_dir: CONTAINER_WORK_DIR.to_string(),
63            inputs_dir: CONTAINER_INPUTS_DIR.to_string(),
64            outputs_dir: CONTAINER_OUTPUTS_DIR.to_string(),
65            cache_dir: None,
66        }
67    }
68
69    fn variable_value(&self, name: &str) -> Option<&str> {
70        match name {
71            "run_dir" => self.run_dir.as_deref(),
72            "work_dir" => Some(&self.work_dir),
73            "inputs_dir" => Some(&self.inputs_dir),
74            "outputs_dir" => Some(&self.outputs_dir),
75            "cache_dir" => self.cache_dir.as_deref(),
76            _ => None,
77        }
78    }
79}
80
81pub(crate) fn bind_path_variables_in_value(
82    value: &Value,
83    bindings: &NodePathBindings,
84) -> Result<Value, String> {
85    match value {
86        Value::String(text) => Ok(Value::String(resolve_path_variables_in_string(text, bindings)?)),
87        Value::Array(items) => {
88            let mut resolved = Vec::with_capacity(items.len());
89            for item in items {
90                resolved.push(bind_path_variables_in_value(item, bindings)?);
91            }
92            Ok(Value::Array(resolved))
93        }
94        Value::Object(map) => {
95            let mut resolved = serde_json::Map::new();
96            for (key, entry) in map {
97                resolved.insert(key.clone(), bind_path_variables_in_value(entry, bindings)?);
98            }
99            Ok(Value::Object(resolved))
100        }
101        literal => Ok(literal.clone()),
102    }
103}
104
105pub(crate) fn resolve_container_argv(
106    argv: &[String],
107    bindings: &NodePathBindings,
108) -> Result<Vec<String>, String> {
109    argv.iter().map(|entry| resolve_path_variables_in_string(entry, bindings)).collect()
110}
111
112pub(crate) fn collect_resolved_path_usages(
113    value: &Value,
114    bindings: &NodePathBindings,
115) -> Result<Vec<ResolvedPathUsage>, String> {
116    let mut usages = Vec::new();
117    collect_resolved_path_usages_inner(value, bindings, "$", &mut usages)?;
118    Ok(usages)
119}
120
121pub(crate) fn collect_container_argv_path_usages(
122    argv: &[String],
123    bindings: &NodePathBindings,
124) -> Result<Vec<ResolvedPathUsage>, String> {
125    let mut usages = Vec::new();
126    for (index, entry) in argv.iter().enumerate() {
127        if let Some(resolved_path) = resolve_path_expression(entry, bindings)? {
128            usages.push(ResolvedPathUsage {
129                key_path: format!("container.argv[{index}]"),
130                expression: entry.clone(),
131                resolved_path,
132            });
133        }
134    }
135    Ok(usages)
136}
137
138pub(crate) fn collect_container_workdir_usage(
139    workdir: Option<&str>,
140    bindings: &NodePathBindings,
141    absolute_path_policy: AbsolutePathPolicy,
142) -> Result<Option<ResolvedPathUsage>, String> {
143    let Some(workdir) = workdir else {
144        return Ok(None);
145    };
146    let resolved_path = resolve_container_workdir(Some(workdir), bindings, absolute_path_policy)?;
147    Ok(Some(ResolvedPathUsage {
148        key_path: "container.workdir".to_string(),
149        expression: workdir.to_string(),
150        resolved_path,
151    }))
152}
153
154pub(crate) fn resolve_container_workdir(
155    workdir: Option<&str>,
156    bindings: &NodePathBindings,
157    absolute_path_policy: AbsolutePathPolicy,
158) -> Result<String, String> {
159    let Some(workdir) = workdir else {
160        return Ok(bindings.work_dir.clone());
161    };
162    if let Some(resolved) = resolve_path_expression(workdir, bindings)? {
163        return Ok(resolved);
164    }
165    if workdir.starts_with('/') {
166        return match absolute_path_policy {
167            AbsolutePathPolicy::AllowLiteral => Ok(workdir.to_string()),
168            AbsolutePathPolicy::DenyLiteral => {
169                Err(format!("literal absolute workdir is denied by policy: {workdir}"))
170            }
171        };
172    }
173    if !is_normalized_relative_path(workdir) {
174        return Err(format!("invalid relative workdir: {workdir}"));
175    }
176    Ok(format!("{}/{}", bindings.work_dir, workdir))
177}
178
179fn resolve_path_expression(
180    value: &str,
181    bindings: &NodePathBindings,
182) -> Result<Option<String>, String> {
183    let Some(expression) = parse_path_expression(value)? else {
184        return Ok(None);
185    };
186    let base = bindings.variable_value(expression.variable).ok_or_else(|| {
187        format!("path variable unavailable for this execution surface: {}", expression.variable)
188    })?;
189    match expression.relative_path {
190        Some(relative_path) => Ok(Some(format!("{base}/{relative_path}"))),
191        None => Ok(Some(base.to_string())),
192    }
193}
194
195fn resolve_path_variables_in_string(
196    value: &str,
197    bindings: &NodePathBindings,
198) -> Result<String, String> {
199    if let Some(resolved) = resolve_path_expression(value, bindings)? {
200        return Ok(resolved);
201    }
202
203    let mut rendered = String::with_capacity(value.len());
204    let mut cursor = 0;
205    while let Some(open_offset) = value[cursor..].find('{') {
206        let open_index = cursor + open_offset;
207        rendered.push_str(&value[cursor..open_index]);
208        let Some(close_offset) = value[(open_index + 1)..].find('}') else {
209            rendered.push_str(&value[open_index..]);
210            return Ok(rendered);
211        };
212        let close_index = open_index + 1 + close_offset;
213        let variable = &value[(open_index + 1)..close_index];
214        if is_known_path_variable(variable) {
215            let base = bindings.variable_value(variable).ok_or_else(|| {
216                format!("path variable unavailable for this execution surface: {variable}")
217            })?;
218            rendered.push_str(base);
219        } else {
220            rendered.push_str(&value[open_index..=close_index]);
221        }
222        cursor = close_index + 1;
223    }
224    rendered.push_str(&value[cursor..]);
225    Ok(rendered)
226}
227
228fn collect_resolved_path_usages_inner(
229    value: &Value,
230    bindings: &NodePathBindings,
231    key_path: &str,
232    usages: &mut Vec<ResolvedPathUsage>,
233) -> Result<(), String> {
234    match value {
235        Value::String(text) => {
236            if let Some(resolved_path) = resolve_path_expression(text, bindings)? {
237                usages.push(ResolvedPathUsage {
238                    key_path: key_path.to_string(),
239                    expression: text.clone(),
240                    resolved_path,
241                });
242            }
243        }
244        Value::Array(items) => {
245            for (index, item) in items.iter().enumerate() {
246                collect_resolved_path_usages_inner(
247                    item,
248                    bindings,
249                    &format!("{key_path}[{index}]"),
250                    usages,
251                )?;
252            }
253        }
254        Value::Object(map) => {
255            for (field, item) in map {
256                collect_resolved_path_usages_inner(
257                    item,
258                    bindings,
259                    &format!("{key_path}.{field}"),
260                    usages,
261                )?;
262            }
263        }
264        _ => {}
265    }
266    Ok(())
267}
268
269fn parse_path_expression(value: &str) -> Result<Option<PathExpression<'_>>, String> {
270    if !value.starts_with('{') {
271        return Ok(None);
272    }
273    let Some(close_index) = value.find('}') else {
274        return Err(format!("invalid path variable expression: {value}"));
275    };
276    let variable = &value[1..close_index];
277    if variable.is_empty() || !is_known_path_variable(variable) {
278        return Err(format!("unknown path variable expression: {value}"));
279    }
280    let rest = &value[(close_index + 1)..];
281    if rest.is_empty() {
282        return Ok(Some(PathExpression { variable, relative_path: None }));
283    }
284    let Some(relative_path) = rest.strip_prefix('/') else {
285        return Err(format!("invalid path variable expression: {value}"));
286    };
287    if !is_normalized_relative_path(relative_path) {
288        return Err(format!("invalid path variable suffix: {relative_path}"));
289    }
290    Ok(Some(PathExpression { variable, relative_path: Some(relative_path) }))
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn host_bindings_resolve_path_expressions_recursively() {
299        let dir = tempfile::tempdir().expect("tmp");
300        let layout = RunDirLayout::preview(dir.path(), Some("paths")).expect("layout");
301        let bindings =
302            NodePathBindings::for_host(&layout, "node", Some(dir.path().join("cache").as_path()));
303        let value = serde_json::json!({
304            "argv": ["cp", "{inputs_dir}/seed.txt", "{outputs_dir}/value.txt"],
305            "nested": {"target": "{cache_dir}/reuse.json"}
306        });
307        let resolved = bind_path_variables_in_value(&value, &bindings).expect("resolve");
308        assert_eq!(
309            resolved["argv"][1].as_str(),
310            Some(layout.node_inputs_dir("node").join("seed.txt").display().to_string().as_str())
311        );
312        assert_eq!(
313            resolved["nested"]["target"].as_str(),
314            Some(dir.path().join("cache").join("reuse.json").display().to_string().as_str())
315        );
316    }
317
318    #[test]
319    fn host_bindings_interpolate_path_variables_inside_command_tokens() {
320        let dir = tempfile::tempdir().expect("tmp");
321        let layout = RunDirLayout::preview(dir.path(), Some("argv")).expect("layout");
322        let bindings = NodePathBindings::for_host(&layout, "node", None);
323        let argv = vec!["--out={outputs_dir}/result.txt".to_string()];
324
325        let resolved = resolve_container_argv(&argv, &bindings).expect("resolve argv");
326
327        assert_eq!(
328            resolved,
329            vec![format!("--out={}", layout.node_outputs_dir("node").join("result.txt").display())]
330        );
331    }
332
333    #[test]
334    fn container_workdir_rejects_denied_absolute_literals() {
335        let err = resolve_container_workdir(
336            Some("/workspace"),
337            &NodePathBindings::for_container(),
338            AbsolutePathPolicy::DenyLiteral,
339        )
340        .expect_err("absolute path must be denied");
341        assert!(err.contains("literal absolute workdir"));
342    }
343
344    #[test]
345    fn container_workdir_resolves_relative_and_variable_paths() {
346        let bindings = NodePathBindings::for_container();
347        assert_eq!(
348            resolve_container_workdir(Some("scratch"), &bindings, AbsolutePathPolicy::DenyLiteral,)
349                .expect("relative workdir"),
350            "/bijux/node/work/scratch"
351        );
352        assert_eq!(
353            resolve_container_workdir(
354                Some("{outputs_dir}/materialized"),
355                &bindings,
356                AbsolutePathPolicy::DenyLiteral,
357            )
358            .expect("variable workdir"),
359            "/bijux/node/outputs/materialized"
360        );
361    }
362}