Skip to main content

cml/
load.rs

1// Copyright 2026 The Fuchsia Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5use crate::Error;
6use std::collections::HashSet;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9
10use crate::types::document::{DocumentContext, parse_and_hydrate};
11
12pub trait FileResolver {
13    /// Returns the absolute path found and the file content
14    fn resolve(&self, path: &Path, current_dir: &Path) -> Result<(PathBuf, String), Error>;
15}
16
17pub struct OsResolver {
18    include_paths: Vec<PathBuf>,
19    include_root: PathBuf,
20}
21
22impl OsResolver {
23    pub fn new(include_paths: Vec<PathBuf>, include_root: PathBuf) -> Self {
24        let normalized_paths = include_paths.into_iter().map(|p| normalize_path(&p)).collect();
25        let normalized_root = normalize_path(&include_root);
26
27        Self { include_paths: normalized_paths, include_root: normalized_root }
28    }
29}
30
31impl FileResolver for OsResolver {
32    fn resolve(&self, path: &Path, current_dir: &Path) -> Result<(PathBuf, String), Error> {
33        let path_str = path.to_str().unwrap_or("");
34
35        let clean_path = if path_str.starts_with("//") {
36            let p = self.include_root.join(&path_str[2..]);
37            let norm = normalize_path(&p);
38            if norm.exists() { Some(norm) } else { None }
39        } else if path.is_absolute() {
40            let norm = normalize_path(path);
41            if norm.exists() { Some(norm) } else { None }
42        } else {
43            std::iter::once(current_dir.join(path))
44                .chain(std::iter::once(path.to_path_buf()))
45                .chain(self.include_paths.iter().map(|dir| dir.join(path)))
46                .map(|p| normalize_path(&p))
47                .find(|p| p.exists())
48        };
49
50        let clean_path =
51            clean_path.ok_or_else(|| Error::Internal(format!("File not found: {:?}", path)))?;
52
53        let content = std::fs::read_to_string(&clean_path)
54            .map_err(|e| Error::Internal(format!("Read error {:?}: {}", clean_path, e)))?;
55
56        Ok((clean_path, content))
57    }
58}
59
60/// The invoker of CmlLoader is responsible for writing their own depfile.
61pub struct CmlLoader<R: FileResolver> {
62    resolver: R,
63    visited: HashSet<PathBuf>,
64}
65
66impl<R: FileResolver> CmlLoader<R> {
67    pub fn new(resolver: R) -> Self {
68        Self { resolver, visited: HashSet::new() }
69    }
70
71    pub fn load_and_merge_all(&mut self, root_path: &Path) -> Result<DocumentContext, Error> {
72        let (root_path_rel, buffer) = self.resolver.resolve(root_path, Path::new(""))?;
73
74        self.visited.insert(root_path_rel.clone());
75        let file_arc = Arc::new(root_path_rel.clone());
76        let mut root_doc = parse_and_hydrate(file_arc, &buffer)?;
77
78        let mut stack = HashSet::new();
79        stack.insert(root_path_rel.clone());
80
81        self.resolve_includes_recursive(&mut root_doc, &root_path_rel, &mut stack)?;
82
83        Ok(root_doc)
84    }
85
86    fn resolve_includes_recursive(
87        &mut self,
88        target_doc: &mut DocumentContext,
89        current_file_path: &Path,
90        stack: &mut HashSet<PathBuf>,
91    ) -> Result<(), Error> {
92        let current_dir = current_file_path.parent().unwrap_or_else(|| Path::new(""));
93
94        if let Some(includes) = target_doc.include.take() {
95            for include_span in includes {
96                let include_path = Path::new(&include_span.value);
97
98                let (shard_path_abs, buffer) = self
99                    .resolver
100                    .resolve(include_path, &current_dir)
101                    .map_err(|e| e.with_origin(include_span.origin.clone()))?;
102
103                if stack.contains(&shard_path_abs) {
104                    return Err(Error::validate_context(
105                        format!("Circular include detected: {:?}", shard_path_abs),
106                        Some(include_span.origin.clone()),
107                    ));
108                }
109
110                if self.visited.contains(&shard_path_abs) {
111                    continue;
112                }
113
114                self.visited.insert(shard_path_abs.clone());
115                stack.insert(shard_path_abs.clone());
116
117                let file_arc = Arc::new(shard_path_abs.clone());
118                let mut shard_doc = parse_and_hydrate(file_arc, &buffer)
119                    .map_err(|e| e.with_origin(include_span.origin.clone()))?;
120
121                let result =
122                    self.resolve_includes_recursive(&mut shard_doc, &shard_path_abs, stack);
123
124                stack.remove(&shard_path_abs);
125
126                result?;
127
128                target_doc.merge_from(shard_doc, &shard_path_abs)?;
129            }
130        }
131        Ok(())
132    }
133
134    pub fn visited_files(&self) -> HashSet<PathBuf> {
135        self.visited.clone()
136    }
137}
138
139/// Needed for hermetic builds so that the path is relative.
140fn normalize_path(path: &Path) -> PathBuf {
141    let mut ret = PathBuf::new();
142
143    for component in path.components() {
144        match component {
145            std::path::Component::CurDir => {} // Ignore "."
146            std::path::Component::ParentDir => {
147                match ret.components().next_back() {
148                    Some(std::path::Component::Normal(_)) => {
149                        ret.pop();
150                    }
151                    Some(std::path::Component::RootDir) | Some(std::path::Component::Prefix(_)) => {
152                        /* Do nothing */
153                    }
154                    _ => {
155                        ret.push(component);
156                    }
157                }
158            }
159            _ => {
160                // Push RootDir, Prefix, and Normal components
161                ret.push(component);
162            }
163        }
164    }
165    ret
166}
167
168#[cfg(test)]
169mod tests {
170    use crate::OneOrMany;
171
172    use super::*;
173    use serde_json::json;
174    use std::collections::HashMap;
175
176    pub struct MockResolver {
177        pub files: HashMap<PathBuf, String>,
178    }
179
180    impl FileResolver for MockResolver {
181        fn resolve(&self, path: &Path, current_dir: &Path) -> Result<(PathBuf, String), Error> {
182            let relative = current_dir.join(path);
183            if let Some(content) = self.files.get(&relative) {
184                return Ok((relative, content.clone()));
185            }
186
187            if let Some(content) = self.files.get(path) {
188                return Ok((path.to_path_buf(), content.clone()));
189            }
190
191            Err(Error::internal(format!(
192                "Mock file not found: {:?}. (Current dir: {:?})",
193                path, current_dir
194            )))
195        }
196    }
197
198    #[test]
199    fn test_include_empty_array() {
200        let mut files = HashMap::new();
201        let main_path = PathBuf::from("main.cml");
202
203        files.insert(
204            main_path.clone(),
205            r#"{ "include": [], "program": { "runner": "elf" } }"#.to_string(),
206        );
207
208        let resolver = MockResolver { files };
209        let mut loader = CmlLoader::new(resolver);
210
211        let doc =
212            loader.load_and_merge_all(&main_path).expect("Failed to handle empty include array");
213        assert!(doc.program.is_some());
214    }
215
216    #[test]
217    fn test_no_includes() {
218        let mut files = HashMap::new();
219        let main_path = PathBuf::from("main.cml");
220
221        files.insert(main_path.clone(), r#"{ "program": { "runner": "elf" } }"#.to_string());
222
223        let resolver = MockResolver { files };
224        let mut loader = CmlLoader::new(resolver);
225
226        let doc = loader
227            .load_and_merge_all(&main_path)
228            .expect("Failed to load document without includes");
229        assert!(doc.program.is_some());
230    }
231
232    #[test]
233    fn test_recursive_merge() {
234        let mut files = HashMap::new();
235
236        let root_path = PathBuf::from("/app/main.cml");
237        let shard_path = PathBuf::from("/app/shards/network.shard.cml");
238
239        files.insert(
240            root_path.clone(),
241            r#"{ "include": [ "shards/network.shard.cml" ] }"#.to_string(),
242        );
243        files.insert(
244            shard_path.clone(),
245            r#"{ "capabilities": [ { "protocol": "fuchsia.test.Protocol" } ] }"#.to_string(),
246        );
247
248        let resolver = MockResolver { files };
249        let mut loader = CmlLoader::new(resolver);
250
251        let doc = loader.load_and_merge_all(&root_path).expect("Mock load failed");
252
253        let caps = doc.capabilities.as_ref().unwrap();
254        let protocol_field = caps[0].value.protocol.as_ref().expect("Protocol missing");
255
256        match &protocol_field.value {
257            OneOrMany::One(name) => {
258                assert_eq!(name.as_str(), "fuchsia.test.Protocol");
259            }
260            OneOrMany::Many(_) => {
261                panic!("Expected a single protocol, found a list");
262            }
263        }
264
265        assert_eq!(caps[0].origin.as_ref(), &shard_path);
266    }
267
268    #[test]
269    fn test_invalid_include_file_not_found() {
270        let mut files = HashMap::new();
271        let main_path = PathBuf::from("main.cml");
272
273        files.insert(main_path.clone(), r#"{ "include": ["doesnt_exist.cml"] }"#.to_string());
274
275        let resolver = MockResolver { files };
276        let mut loader = CmlLoader::new(resolver);
277
278        let result = loader.load_and_merge_all(&main_path);
279
280        assert!(result.is_err(), "Loader should fail when an include is missing");
281    }
282
283    #[test]
284    fn test_relative_include_chain() {
285        let mut files = HashMap::new();
286
287        let root_path = PathBuf::from("/root.cml");
288        let driver_path = PathBuf::from("/sys/driver.cml");
289        let logger_path = PathBuf::from("/sys/logger.cml");
290
291        files.insert(root_path.clone(), r#"{ "include": [ "sys/driver.cml" ] }"#.to_string());
292        // driver.cml includes "logger.cml" relative to itself (/sys)
293        files.insert(driver_path.clone(), r#"{ "include": [ "logger.cml" ] }"#.to_string());
294        files.insert(
295            logger_path.clone(),
296            r#"{
297            "capabilities": [ { "protocol": "fuchsia.sys.Logger" } ]
298        }"#
299            .to_string(),
300        );
301
302        let resolver = MockResolver { files };
303        let mut loader = CmlLoader::new(resolver);
304
305        let doc = loader.load_and_merge_all(&root_path).expect("Failed to follow relative chain");
306
307        let caps = doc.capabilities.as_ref().unwrap();
308        assert_eq!(caps.len(), 1);
309
310        assert_eq!(caps[0].origin.as_ref(), &logger_path);
311    }
312
313    #[test]
314    fn test_circular_include_error() {
315        let mut files = HashMap::new();
316        let a_path = PathBuf::from("/a.cml");
317        let b_path = PathBuf::from("/b.cml");
318
319        files.insert(a_path.clone(), r#"{ "include": [ "b.cml" ] }"#.to_string());
320        files.insert(b_path.clone(), r#"{ "include": [ "a.cml" ] }"#.to_string());
321
322        let resolver = MockResolver { files };
323        let mut loader = CmlLoader::new(resolver);
324
325        let result = loader.load_and_merge_all(&a_path);
326
327        assert!(result.is_err());
328    }
329
330    #[test]
331    fn test_include_from_search_path() {
332        let mut files = HashMap::new();
333        let root_path = PathBuf::from("/app/root.cml");
334
335        let sdk_path = PathBuf::from("/sdk/lib/common.cml");
336
337        files.insert(root_path.clone(), r#"{ "include": [ "common.cml" ] }"#.to_string());
338        files.insert(sdk_path.clone(), r#"{ "offer": [] }"#.to_string());
339
340        struct SearchPathMock {
341            files: HashMap<PathBuf, String>,
342        }
343        impl FileResolver for SearchPathMock {
344            fn resolve(&self, path: &Path, current_dir: &Path) -> Result<(PathBuf, String), Error> {
345                let local = current_dir.join(path);
346                if self.files.contains_key(&local.clone()) {
347                    return Ok((local.clone(), self.files[&local].clone()));
348                }
349
350                let sdk_candidate = PathBuf::from("/sdk/lib").join(path);
351                if let Some(content) = self.files.get(&sdk_candidate) {
352                    return Ok((sdk_candidate, content.clone()));
353                }
354                Err(Error::internal("Not found"))
355            }
356        }
357
358        let resolver = SearchPathMock { files };
359        let mut loader = CmlLoader::new(resolver);
360
361        let result = loader.load_and_merge_all(&root_path);
362        assert!(result.is_ok(), "Loader should have found common.cml in the SDK path");
363    }
364
365    #[test]
366    fn test_include_cml_with_dictionary() {
367        let mut files = HashMap::new();
368        let shard_path = PathBuf::from("shard.cml");
369        let main_path = PathBuf::from("main.cml");
370
371        files.insert(
372            shard_path.clone(),
373            json!({
374                "expose": [
375                    {
376                        "dictionary": "diagnostics",
377                        "from": "self",
378                    }
379                ],
380                "capabilities": [
381                    {
382                        "dictionary": "diagnostics",
383                    }
384                ]
385            })
386            .to_string(),
387        );
388        files.insert(
389            main_path.clone(),
390            json!({
391                "include": ["shard.cml"],
392                "program": {
393                    "binary": "bin/hello_world",
394                    "runner": "foo"
395                }
396            })
397            .to_string(),
398        );
399
400        let resolver = MockResolver { files };
401
402        let mut loader = CmlLoader::new(resolver);
403        let merged_doc = loader.load_and_merge_all(&main_path).unwrap();
404
405        let expected_cml = json!({
406            "program": {
407                "binary": "bin/hello_world",
408                "runner": "foo"
409            },
410            "expose": [
411                {
412                    "dictionary": "diagnostics",
413                    "from": "self",
414                }
415            ],
416            "capabilities": [
417                {
418                    "dictionary": "diagnostics",
419                }
420            ]
421        })
422        .to_string();
423
424        let expected_doc = crate::load_cml_with_context(&expected_cml, Path::new("expected.cml"))
425            .expect("failed to parse expected");
426
427        assert_eq!(merged_doc, expected_doc)
428    }
429
430    #[test]
431    fn test_diamond_dependency_is_safe() {
432        let mut files = HashMap::new();
433
434        let a = PathBuf::from("/a.cml");
435        let b = PathBuf::from("/b.cml");
436        let c = PathBuf::from("/c.cml");
437        let d = PathBuf::from("/d.cml");
438
439        files.insert(a.clone(), r#"{ "include": ["b.cml", "c.cml"] }"#.to_string());
440        files.insert(b.clone(), r#"{ "include": ["d.cml"] }"#.to_string());
441        files.insert(c.clone(), r#"{ "include": ["d.cml"] }"#.to_string());
442
443        files.insert(
444            d.clone(),
445            r#"{
446            "capabilities": [ { "protocol": "fuchsia.diamond.Protocol" } ]
447        }"#
448            .to_string(),
449        );
450
451        let resolver = MockResolver { files };
452        let mut loader = CmlLoader::new(resolver);
453
454        let doc = loader.load_and_merge_all(&a).expect("Diamond dependency should succeed");
455
456        let caps = doc.capabilities.as_ref().unwrap();
457
458        assert_eq!(caps.len(), 1, "Should have exactly one capability after merge");
459
460        assert!(loader.visited_files().contains(&d));
461    }
462
463    #[test]
464    fn test_include_multiple_shards() {
465        let mut files = HashMap::new();
466        let main_path = PathBuf::from("main.cml");
467
468        files.insert(
469            main_path.clone(),
470            r#"{ "include": ["shard1.cml", "shard2.cml"] }"#.to_string(),
471        );
472        files.insert(
473            PathBuf::from("shard1.cml"),
474            r#"{ "use": [{ "protocol": "fuchsia.foo.A" }] }"#.to_string(),
475        );
476        files.insert(
477            PathBuf::from("shard2.cml"),
478            r#"{ "use": [{ "protocol": "fuchsia.foo.B" }] }"#.to_string(),
479        );
480
481        let resolver = MockResolver { files };
482        let mut loader = CmlLoader::new(resolver);
483
484        let doc = loader.load_and_merge_all(&main_path).expect("Failed to load multiple shards");
485
486        let uses = doc.r#use.expect("Should have merged use block");
487        assert_eq!(uses.len(), 2, "Should have loaded and merged both shards");
488    }
489
490    #[test]
491    fn test_include_absolute_path() {
492        let mut files = HashMap::new();
493        let main_path = PathBuf::from("/some/nested/dir/main.cml");
494
495        files.insert(main_path.clone(), r#"{ "include": ["//path/to/shard.cml"] }"#.to_string());
496
497        // The mock file exists at the absolute root, not relative to main.cml
498        files.insert(
499            PathBuf::from("/path/to/shard.cml"),
500            r#"{ "capabilities": [{ "protocol": "foo" }] }"#.to_string(),
501        );
502
503        let resolver = MockResolver { files };
504        let mut loader = CmlLoader::new(resolver);
505
506        let doc =
507            loader.load_and_merge_all(&main_path).expect("Failed to resolve absolute include");
508        assert!(doc.capabilities.is_some());
509    }
510
511    #[test]
512    fn test_normalize_path() {
513        assert_eq!(normalize_path(Path::new("a/b/../c")), PathBuf::from("a/c"));
514
515        assert_eq!(
516            normalize_path(Path::new("/fuchsia/out/default/../../sdk/lib")),
517            PathBuf::from("/fuchsia/sdk/lib")
518        );
519
520        assert_eq!(normalize_path(Path::new("./a/./b/./c/.")), PathBuf::from("a/b/c"));
521
522        assert_eq!(normalize_path(Path::new("/../../foo")), PathBuf::from("/foo"));
523
524        let double_slash = normalize_path(Path::new("//sdk/lib/shard.cml"));
525        assert!(double_slash.to_str().unwrap().contains("sdk/lib/shard.cml"));
526
527        assert_eq!(normalize_path(Path::new("/a/b/c/../../d/./e/../f")), PathBuf::from("/a/d/f"));
528    }
529
530    #[test]
531    fn test_normalize_relative_leading_dots() {
532        assert_eq!(
533            normalize_path(Path::new("../../examples/hello.cml")),
534            PathBuf::from("../../examples/hello.cml")
535        );
536
537        assert_eq!(
538            normalize_path(Path::new("../../sdk/lib/../inspect/client.shard.cml")),
539            PathBuf::from("../../sdk/inspect/client.shard.cml")
540        );
541    }
542}