Skip to main content

pidgin_lang/
resolver.rs

1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use serde::Deserialize;
5
6use crate::ast::{FieldValue, PgnPacket};
7
8#[derive(Debug, Clone, PartialEq)]
9pub enum ResolutionStatus {
10    Resolved,
11    Missing,
12    Unresolved,
13    Forbidden,
14}
15
16#[derive(Debug, Clone)]
17pub struct ResolvedRef {
18    pub original: String,
19    pub namespace: String,
20    pub ref_id: String,
21    pub resolved_path: Option<PathBuf>,
22    pub confidence: f32,
23    pub required: bool,
24    pub status: ResolutionStatus,
25}
26
27#[derive(Debug, Deserialize)]
28pub struct ReferenceAliases {
29    pub aliases: BTreeMap<String, String>,
30    pub common: BTreeMap<String, String>,
31}
32
33#[derive(Debug)]
34pub struct ResolverContext {
35    pub host_root: PathBuf,
36    pub aliases: ReferenceAliases,
37    pub required_inputs: Vec<String>,
38}
39
40pub fn parse_ref(reference: &str) -> Option<(String, String)> {
41    let trimmed = reference.trim();
42    if trimmed.is_empty() {
43        return None;
44    }
45    if let Some(idx) = trimmed.find(':') {
46        let namespace = trimmed[..idx].trim().to_string();
47        let ref_id = trimmed[idx + 1..].trim().to_string();
48        if !namespace.is_empty() && !ref_id.is_empty() {
49            return Some((namespace, ref_id));
50        }
51    }
52    // Bare alias — use empty namespace to signal alias lookup
53    Some((String::new(), trimmed.to_string()))
54}
55
56pub fn expand_alias(bare: &str, aliases: &ReferenceAliases) -> Option<String> {
57    aliases
58        .aliases
59        .get(bare)
60        .or_else(|| aliases.common.get(bare))
61        .cloned()
62}
63
64pub fn resolve_ref(reference: &str, ctx: &ResolverContext) -> ResolvedRef {
65    let original = reference.to_string();
66    let required = ctx.required_inputs.contains(&original);
67
68    let parsed = parse_ref(reference);
69    let parsed = match parsed {
70        Some(p) => p,
71        None => {
72            return ResolvedRef {
73                original,
74                namespace: String::new(),
75                ref_id: String::new(),
76                resolved_path: None,
77                confidence: 0.0,
78                required,
79                status: ResolutionStatus::Unresolved,
80            };
81        }
82    };
83
84    let (namespace, ref_id) = parsed;
85
86    // Bare alias — try to expand
87    let (namespace, ref_id) = if namespace.is_empty() {
88        match expand_alias(&ref_id, &ctx.aliases) {
89            Some(expanded) => {
90                match parse_ref(&expanded) {
91                    Some((ns, id)) => (ns, id),
92                    None => {
93                        return ResolvedRef {
94                            original,
95                            namespace: String::new(),
96                            ref_id: ref_id.clone(),
97                            resolved_path: None,
98                            confidence: 0.3,
99                            required,
100                            status: ResolutionStatus::Unresolved,
101                        };
102                    }
103                }
104            }
105            None => {
106                return ResolvedRef {
107                    original,
108                    namespace: String::new(),
109                    ref_id: ref_id.clone(),
110                    resolved_path: None,
111                    confidence: 0.0,
112                    required,
113                    status: ResolutionStatus::Unresolved,
114                };
115            }
116        }
117    } else {
118        (namespace, ref_id)
119    };
120
121    match namespace.as_str() {
122        "file" => resolve_file_ref(&original, &ref_id, &ctx.host_root, required),
123        "folder" => resolve_folder_ref(&original, &ref_id, &ctx.host_root, required),
124        _ => ResolvedRef {
125            original,
126            namespace,
127            ref_id,
128            resolved_path: None,
129            confidence: 0.0,
130            required,
131            status: ResolutionStatus::Unresolved,
132        },
133    }
134}
135
136pub fn resolve_all(packet: &PgnPacket, ctx: &ResolverContext) -> Vec<ResolvedRef> {
137    let mut results = Vec::new();
138
139    for field_name in &["in", "out"] {
140        if let Some(FieldValue::List(refs)) = packet.fields.get(*field_name) {
141            for reference in refs {
142                results.push(resolve_ref(reference, ctx));
143            }
144        }
145    }
146
147    results
148}
149
150fn resolve_symlink_target(path: &Path, depth: usize) -> Option<PathBuf> {
151    if depth == 0 || !path.is_symlink() {
152        return None;
153    }
154    let target = std::fs::read_link(path).ok()?;
155    let absolute = if target.is_absolute() {
156        target
157    } else if let Some(parent) = path.parent() {
158        parent.join(&target)
159    } else {
160        target
161    };
162    if absolute.is_symlink() {
163        resolve_symlink_target(&absolute, depth - 1)
164    } else {
165        Some(absolute)
166    }
167}
168
169fn is_path_within_root(candidate: &Path, root: &Path) -> bool {
170    let canonical_root = match root.canonicalize() {
171        Ok(r) => r,
172        Err(_) => return false,
173    };
174
175    // Resolve symlinks explicitly if present
176    if let Some(sym_target) = resolve_symlink_target(candidate, 8)
177        && !sym_target.starts_with(&canonical_root)
178    {
179        return false;
180    }
181
182    // If the path exists, use canonicalize for proper symlink resolution
183    if let Ok(canonical) = candidate.canonicalize() {
184        return canonical.starts_with(&canonical_root);
185    }
186
187    // For non-existent paths: verify component-by-component that the path
188    // doesn't use ParentDir to escape above canonical_root
189    let root_comps: Vec<_> = canonical_root.components().collect();
190    let candidate_comps: Vec<_> = candidate.components().collect();
191
192    if candidate_comps.len() < root_comps.len() {
193        return false;
194    }
195
196    // First root_len components must match root exactly
197    for (i, rc) in root_comps.iter().enumerate() {
198        if candidate_comps.get(i) != Some(rc) {
199            return false;
200        }
201    }
202
203    // Remaining components must not escape above root via ParentDir
204    let mut relative_depth: isize = 0;
205    for comp in &candidate_comps[root_comps.len()..] {
206        match comp {
207            std::path::Component::ParentDir => {
208                relative_depth -= 1;
209                if relative_depth < 0 {
210                    return false;
211                }
212            }
213            std::path::Component::Normal(_) | std::path::Component::CurDir => {
214                relative_depth += 1;
215            }
216            _ => return false,
217        }
218    }
219
220    true
221}
222
223fn resolve_file_ref(
224    original: &str,
225    ref_id: &str,
226    host_root: &Path,
227    required: bool,
228) -> ResolvedRef {
229    let canonical_root = match host_root.canonicalize() {
230        Ok(r) => r,
231        Err(_) => {
232            return ResolvedRef {
233                original: original.to_string(),
234                namespace: "file".to_string(),
235                ref_id: ref_id.to_string(),
236                resolved_path: None,
237                confidence: 0.0,
238                required,
239                status: ResolutionStatus::Forbidden,
240            };
241        }
242    };
243    let resolved_path = canonical_root.join(ref_id);
244
245    if !is_path_within_root(&resolved_path, &canonical_root) {
246        return ResolvedRef {
247            original: original.to_string(),
248            namespace: "file".to_string(),
249            ref_id: ref_id.to_string(),
250            resolved_path: None,
251            confidence: 0.0,
252            required,
253            status: ResolutionStatus::Forbidden,
254        };
255    }
256
257    let (status, confidence) = if resolved_path.exists() {
258        (ResolutionStatus::Resolved, 1.0)
259    } else {
260        (ResolutionStatus::Missing, 0.0)
261    };
262
263    ResolvedRef {
264        original: original.to_string(),
265        namespace: "file".to_string(),
266        ref_id: ref_id.to_string(),
267        resolved_path: Some(resolved_path),
268        confidence,
269        required,
270        status,
271    }
272}
273
274fn resolve_folder_ref(
275    original: &str,
276    ref_id: &str,
277    host_root: &Path,
278    required: bool,
279) -> ResolvedRef {
280    let canonical_root = match host_root.canonicalize() {
281        Ok(r) => r,
282        Err(_) => {
283            return ResolvedRef {
284                original: original.to_string(),
285                namespace: "folder".to_string(),
286                ref_id: ref_id.to_string(),
287                resolved_path: None,
288                confidence: 0.0,
289                required,
290                status: ResolutionStatus::Forbidden,
291            };
292        }
293    };
294    let resolved_path = canonical_root.join(ref_id);
295
296    if !is_path_within_root(&resolved_path, &canonical_root) {
297        return ResolvedRef {
298            original: original.to_string(),
299            namespace: "folder".to_string(),
300            ref_id: ref_id.to_string(),
301            resolved_path: None,
302            confidence: 0.0,
303            required,
304            status: ResolutionStatus::Forbidden,
305        };
306    }
307
308    let (status, confidence) = if resolved_path.is_dir() {
309        (ResolutionStatus::Resolved, 1.0)
310    } else {
311        (ResolutionStatus::Missing, 0.0)
312    };
313
314    ResolvedRef {
315        original: original.to_string(),
316        namespace: "folder".to_string(),
317        ref_id: ref_id.to_string(),
318        resolved_path: Some(resolved_path),
319        confidence,
320        required,
321        status,
322    }
323}
324
325pub fn load_aliases(path: &Path) -> Result<ReferenceAliases, crate::registry::ConfigError> {
326    let metadata = std::fs::metadata(path)?;
327    if metadata.len() > 10_485_760 {
328        return Err(crate::registry::ConfigError::Io(std::io::Error::new(
329            std::io::ErrorKind::InvalidData,
330            format!(
331                "config file {} exceeds maximum size ({} > 10 MiB)",
332                path.display(),
333                metadata.len()
334            ),
335        )));
336    }
337    let content = std::fs::read_to_string(path)?;
338    let aliases: ReferenceAliases = serde_yaml::from_str(&content)?;
339    Ok(aliases)
340}