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 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 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 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 let Ok(canonical) = candidate.canonicalize() {
184 return canonical.starts_with(&canonical_root);
185 }
186
187 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 for (i, rc) in root_comps.iter().enumerate() {
198 if candidate_comps.get(i) != Some(rc) {
199 return false;
200 }
201 }
202
203 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}