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, Copy, 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_path_ref(&original, &ref_id, &ctx.host_root, required, "file", |p| p.exists()),
123        "folder" => resolve_path_ref(&original, &ref_id, &ctx.host_root, required, "folder", |p| p.is_dir()),
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_path_ref(
224    original: &str,
225    ref_id: &str,
226    host_root: &Path,
227    required: bool,
228    namespace: &str,
229    exists_fn: fn(&Path) -> bool,
230) -> ResolvedRef {
231    let canonical_root = match host_root.canonicalize() {
232        Ok(r) => r,
233        Err(_) => {
234            return ResolvedRef {
235                original: original.to_string(),
236                namespace: namespace.to_string(),
237                ref_id: ref_id.to_string(),
238                resolved_path: None,
239                confidence: 0.0,
240                required,
241                status: ResolutionStatus::Forbidden,
242            };
243        }
244    };
245    let resolved_path = canonical_root.join(ref_id);
246
247    if !is_path_within_root(&resolved_path, &canonical_root) {
248        return ResolvedRef {
249            original: original.to_string(),
250            namespace: namespace.to_string(),
251            ref_id: ref_id.to_string(),
252            resolved_path: None,
253            confidence: 0.0,
254            required,
255            status: ResolutionStatus::Forbidden,
256        };
257    }
258
259    let (status, confidence) = if exists_fn(&resolved_path) {
260        (ResolutionStatus::Resolved, 1.0)
261    } else {
262        (ResolutionStatus::Missing, 0.0)
263    };
264
265    ResolvedRef {
266        original: original.to_string(),
267        namespace: namespace.to_string(),
268        ref_id: ref_id.to_string(),
269        resolved_path: Some(resolved_path),
270        confidence,
271        required,
272        status,
273    }
274}
275
276pub fn load_aliases(path: &Path) -> Result<ReferenceAliases, crate::registry::ConfigError> {
277    let metadata = std::fs::metadata(path)?;
278    if metadata.len() > 10_485_760 {
279        return Err(crate::registry::ConfigError::Io(std::io::Error::new(
280            std::io::ErrorKind::InvalidData,
281            format!(
282                "config file {} exceeds maximum size ({} > 10 MiB)",
283                path.display(),
284                metadata.len()
285            ),
286        )));
287    }
288    let content = std::fs::read_to_string(path)?;
289    let aliases: ReferenceAliases = serde_yaml::from_str(&content)?;
290    Ok(aliases)
291}