Skip to main content

pidgin_lang/
resolver.rs

1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use serde::{Deserialize, Serialize};
5
6use crate::ast::{FieldValue, PgnPacket};
7
8#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
9pub enum ResolutionStatus {
10    Resolved,
11    Missing,
12    Unresolved,
13    Forbidden,
14}
15
16#[derive(Debug, Clone, Serialize)]
17pub struct ResolvedRef {
18    pub original: String,
19    pub namespace: String,
20    pub ref_id: String,
21    pub resolved_path: Option<std::path::PathBuf>,
22    pub confidence: f32,
23    pub required: bool,
24    pub status: ResolutionStatus,
25}
26
27#[derive(Debug, Clone, 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) => match parse_ref(&expanded) {
90                Some((ns, id)) => (ns, id),
91                None => {
92                    return ResolvedRef {
93                        original,
94                        namespace: String::new(),
95                        ref_id: ref_id.clone(),
96                        resolved_path: None,
97                        confidence: 0.3,
98                        required,
99                        status: ResolutionStatus::Unresolved,
100                    };
101                }
102            },
103            None => {
104                return ResolvedRef {
105                    original,
106                    namespace: String::new(),
107                    ref_id: ref_id.clone(),
108                    resolved_path: None,
109                    confidence: 0.0,
110                    required,
111                    status: ResolutionStatus::Unresolved,
112                };
113            }
114        }
115    } else {
116        (namespace, ref_id)
117    };
118
119    match namespace.as_str() {
120        "file" => resolve_path_ref(&original, &ref_id, &ctx.host_root, required, "file", |p| {
121            p.exists()
122        }),
123        "folder" => resolve_path_ref(
124            &original,
125            &ref_id,
126            &ctx.host_root,
127            required,
128            "folder",
129            |p| p.is_dir(),
130        ),
131        _ => ResolvedRef {
132            original,
133            namespace,
134            ref_id,
135            resolved_path: None,
136            confidence: 0.0,
137            required,
138            status: ResolutionStatus::Unresolved,
139        },
140    }
141}
142
143pub fn resolve_all(packet: &PgnPacket, ctx: &ResolverContext) -> Vec<ResolvedRef> {
144    let mut results = Vec::new();
145
146    for field_name in &["in", "out"] {
147        if let Some(FieldValue::List(refs)) = packet.fields.get(*field_name) {
148            for reference in refs {
149                results.push(resolve_ref(reference, ctx));
150            }
151        }
152    }
153
154    results
155}
156
157fn resolve_symlink_target(path: &Path, depth: usize) -> Option<PathBuf> {
158    if depth == 0 || !path.is_symlink() {
159        return None;
160    }
161    let target = std::fs::read_link(path).ok()?;
162    let absolute = if target.is_absolute() {
163        target
164    } else if let Some(parent) = path.parent() {
165        parent.join(&target)
166    } else {
167        target
168    };
169    if absolute.is_symlink() {
170        resolve_symlink_target(&absolute, depth - 1)
171    } else {
172        Some(absolute)
173    }
174}
175
176fn is_path_within_root(candidate: &Path, root: &Path) -> bool {
177    let canonical_root = match root.canonicalize() {
178        Ok(r) => r,
179        Err(_) => return false,
180    };
181
182    // Resolve symlinks explicitly if present
183    if let Some(sym_target) = resolve_symlink_target(candidate, 8)
184        && !sym_target.starts_with(&canonical_root)
185    {
186        return false;
187    }
188
189    // If the path exists, use canonicalize for proper symlink resolution
190    if let Ok(canonical) = candidate.canonicalize() {
191        return canonical.starts_with(&canonical_root);
192    }
193
194    // For non-existent paths: verify component-by-component that the path
195    // doesn't use ParentDir to escape above canonical_root
196    let root_comps: Vec<_> = canonical_root.components().collect();
197    let candidate_comps: Vec<_> = candidate.components().collect();
198
199    if candidate_comps.len() < root_comps.len() {
200        return false;
201    }
202
203    // First root_len components must match root exactly
204    for (i, rc) in root_comps.iter().enumerate() {
205        if candidate_comps.get(i) != Some(rc) {
206            return false;
207        }
208    }
209
210    // Remaining components must not escape above root via ParentDir
211    let mut relative_depth: isize = 0;
212    for comp in &candidate_comps[root_comps.len()..] {
213        match comp {
214            std::path::Component::ParentDir => {
215                relative_depth -= 1;
216                if relative_depth < 0 {
217                    return false;
218                }
219            }
220            std::path::Component::Normal(_) | std::path::Component::CurDir => {
221                relative_depth += 1;
222            }
223            _ => return false,
224        }
225    }
226
227    true
228}
229
230fn resolve_path_ref(
231    original: &str,
232    ref_id: &str,
233    host_root: &Path,
234    required: bool,
235    namespace: &str,
236    exists_fn: fn(&Path) -> bool,
237) -> ResolvedRef {
238    let canonical_root = match host_root.canonicalize() {
239        Ok(r) => r,
240        Err(_) => {
241            return ResolvedRef {
242                original: original.to_string(),
243                namespace: namespace.to_string(),
244                ref_id: ref_id.to_string(),
245                resolved_path: None,
246                confidence: 0.0,
247                required,
248                status: ResolutionStatus::Forbidden,
249            };
250        }
251    };
252    let resolved_path = canonical_root.join(ref_id);
253
254    if !is_path_within_root(&resolved_path, &canonical_root) {
255        return ResolvedRef {
256            original: original.to_string(),
257            namespace: namespace.to_string(),
258            ref_id: ref_id.to_string(),
259            resolved_path: None,
260            confidence: 0.0,
261            required,
262            status: ResolutionStatus::Forbidden,
263        };
264    }
265
266    let (status, confidence) = if exists_fn(&resolved_path) {
267        (ResolutionStatus::Resolved, 1.0)
268    } else {
269        (ResolutionStatus::Missing, 0.0)
270    };
271
272    ResolvedRef {
273        original: original.to_string(),
274        namespace: namespace.to_string(),
275        ref_id: ref_id.to_string(),
276        resolved_path: Some(resolved_path),
277        confidence,
278        required,
279        status,
280    }
281}
282
283pub fn load_aliases(path: &Path) -> Result<ReferenceAliases, crate::registry::ConfigError> {
284    let metadata = std::fs::metadata(path)?;
285    if metadata.len() > 10_485_760 {
286        return Err(crate::registry::ConfigError::Io(std::io::Error::new(
287            std::io::ErrorKind::InvalidData,
288            format!(
289                "config file {} exceeds maximum size ({} > 10 MiB)",
290                path.display(),
291                metadata.len()
292            ),
293        )));
294    }
295    let content = std::fs::read_to_string(path)?;
296    let aliases: ReferenceAliases = serde_yaml::from_str(&content)?;
297    Ok(aliases)
298}