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}