Skip to main content

treetop_bundle/
manifest.rs

1use crate::{BundleError, Diagnostic, FORMAT_VERSION, Result};
2use cedar_policy::EntityTypeName;
3use serde::{Deserialize, Serialize};
4use std::collections::HashSet;
5use std::fs;
6use std::path::{Component, Path, PathBuf};
7
8fn default_vec<T>() -> Vec<T> {
9    Vec::new()
10}
11
12#[derive(Debug, Deserialize)]
13#[serde(deny_unknown_fields)]
14struct RawModuleManifest {
15    format_version: u32,
16    name: String,
17    namespace: String,
18    #[serde(default = "default_vec")]
19    imports: Vec<String>,
20    #[serde(default = "default_vec")]
21    policies: Vec<String>,
22    #[serde(default = "default_vec")]
23    schemas: Vec<String>,
24    #[serde(default = "default_vec")]
25    labels: Vec<String>,
26}
27
28/// A validated project-level source manifest.
29#[derive(Debug, Clone, Serialize)]
30pub struct ModuleManifest {
31    format_version: u32,
32    name: String,
33    namespace: String,
34    imports: Vec<String>,
35    policies: Vec<String>,
36    schemas: Vec<String>,
37    labels: Vec<String>,
38    #[serde(skip)]
39    path: PathBuf,
40    #[serde(skip)]
41    directory: PathBuf,
42}
43
44impl ModuleManifest {
45    /// Load a module manifest and resolve all inputs without allowing escapes.
46    pub fn from_path(path: impl AsRef<Path>) -> Result<Self> {
47        let requested_path = path.as_ref();
48        let bytes =
49            fs::read(requested_path).map_err(|error| BundleError::io(requested_path, error))?;
50        let source = std::str::from_utf8(&bytes).map_err(|error| BundleError::Manifest {
51            path: requested_path.to_path_buf(),
52            message: error.to_string(),
53        })?;
54        let raw: RawModuleManifest =
55            toml::from_str(source).map_err(|error| BundleError::Manifest {
56                path: requested_path.to_path_buf(),
57                message: error.to_string(),
58            })?;
59
60        let path = requested_path
61            .canonicalize()
62            .map_err(|error| BundleError::io(requested_path, error))?;
63        let directory = path.parent().map(Path::to_path_buf).ok_or_else(|| {
64            BundleError::Validation(vec![Diagnostic::error(
65                "manifest.no_parent",
66                "module manifest must have a parent directory",
67            )])
68        })?;
69
70        let mut diagnostics = Vec::new();
71        if raw.format_version != FORMAT_VERSION {
72            diagnostics.push(Diagnostic::error(
73                "manifest.unsupported_version",
74                format!(
75                    "module format_version {} is unsupported; expected {FORMAT_VERSION}",
76                    raw.format_version
77                ),
78            ));
79        }
80        if raw.name.trim().is_empty() {
81            diagnostics.push(Diagnostic::error(
82                "manifest.empty_name",
83                "module name must not be empty",
84            ));
85        }
86        validate_namespace("module namespace", &raw.namespace, &mut diagnostics);
87        let mut imports = HashSet::new();
88        for import in &raw.imports {
89            validate_namespace("module import", import, &mut diagnostics);
90            if !imports.insert(import) {
91                diagnostics.push(Diagnostic::error(
92                    "manifest.duplicate_import",
93                    format!("duplicate import {import:?}"),
94                ));
95            }
96        }
97        if raw.imports.iter().any(|import| import == &raw.namespace) {
98            diagnostics.push(Diagnostic::error(
99                "manifest.self_import",
100                "a module cannot import its own namespace",
101            ));
102        }
103
104        for input in raw.policies.iter().chain(&raw.schemas).chain(&raw.labels) {
105            validate_input_path(&directory, input, &mut diagnostics);
106        }
107
108        if diagnostics.is_empty() {
109            Ok(Self {
110                format_version: raw.format_version,
111                name: raw.name,
112                namespace: raw.namespace,
113                imports: raw.imports,
114                policies: raw.policies,
115                schemas: raw.schemas,
116                labels: raw.labels,
117                path,
118                directory,
119            })
120        } else {
121            Err(BundleError::Validation(
122                diagnostics
123                    .into_iter()
124                    .map(|diagnostic| {
125                        diagnostic
126                            .in_module(raw.name.clone())
127                            .at_path(requested_path.display().to_string())
128                    })
129                    .collect(),
130            ))
131        }
132    }
133
134    pub fn format_version(&self) -> u32 {
135        self.format_version
136    }
137
138    pub fn name(&self) -> &str {
139        &self.name
140    }
141
142    pub fn namespace(&self) -> &str {
143        &self.namespace
144    }
145
146    pub fn imports(&self) -> &[String] {
147        &self.imports
148    }
149
150    pub fn policies(&self) -> &[String] {
151        &self.policies
152    }
153
154    pub fn schemas(&self) -> &[String] {
155        &self.schemas
156    }
157
158    pub fn labels(&self) -> &[String] {
159        &self.labels
160    }
161
162    pub fn path(&self) -> &Path {
163        &self.path
164    }
165
166    pub(crate) fn input_path(&self, relative: &str) -> PathBuf {
167        self.directory.join(relative)
168    }
169}
170
171/// Module policy scope selected by the organization bundle manifest.
172#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(rename_all = "lowercase")]
174pub enum ModuleRole {
175    #[default]
176    Ordinary,
177    Global,
178}
179
180#[derive(Debug, Deserialize)]
181#[serde(deny_unknown_fields)]
182struct RawModuleSelection {
183    manifest: String,
184    #[serde(default)]
185    role: ModuleRole,
186}
187
188/// A module selected by a bundle manifest.
189#[derive(Debug, Clone, Serialize)]
190pub struct ModuleSelection {
191    manifest: ModuleManifest,
192    role: ModuleRole,
193}
194
195impl ModuleSelection {
196    pub fn manifest(&self) -> &ModuleManifest {
197        &self.manifest
198    }
199
200    pub fn role(&self) -> ModuleRole {
201        self.role
202    }
203}
204
205#[derive(Debug, Deserialize)]
206#[serde(deny_unknown_fields)]
207struct RawBundleManifest {
208    format_version: u32,
209    name: String,
210    modules: Vec<RawModuleSelection>,
211}
212
213/// A validated organization-level source manifest.
214#[derive(Debug, Clone, Serialize)]
215pub struct BundleManifest {
216    format_version: u32,
217    name: String,
218    modules: Vec<ModuleSelection>,
219    #[serde(skip)]
220    path: PathBuf,
221}
222
223impl BundleManifest {
224    /// Load the root manifest and every selected module manifest.
225    pub fn from_path(path: impl AsRef<Path>) -> Result<Self> {
226        let requested_path = path.as_ref();
227        let bytes =
228            fs::read(requested_path).map_err(|error| BundleError::io(requested_path, error))?;
229        let source = std::str::from_utf8(&bytes).map_err(|error| BundleError::Manifest {
230            path: requested_path.to_path_buf(),
231            message: error.to_string(),
232        })?;
233        let raw: RawBundleManifest =
234            toml::from_str(source).map_err(|error| BundleError::Manifest {
235                path: requested_path.to_path_buf(),
236                message: error.to_string(),
237            })?;
238        let path = requested_path
239            .canonicalize()
240            .map_err(|error| BundleError::io(requested_path, error))?;
241        let directory = path.parent().ok_or_else(|| {
242            BundleError::Validation(vec![Diagnostic::error(
243                "manifest.no_parent",
244                "bundle manifest must have a parent directory",
245            )])
246        })?;
247
248        let mut diagnostics = Vec::new();
249        if raw.format_version != FORMAT_VERSION {
250            diagnostics.push(Diagnostic::error(
251                "manifest.unsupported_version",
252                format!(
253                    "bundle format_version {} is unsupported; expected {FORMAT_VERSION}",
254                    raw.format_version
255                ),
256            ));
257        }
258        if raw.name.trim().is_empty() {
259            diagnostics.push(Diagnostic::error(
260                "manifest.empty_name",
261                "bundle name must not be empty",
262            ));
263        }
264        if raw.modules.is_empty() {
265            diagnostics.push(Diagnostic::error(
266                "manifest.empty_modules",
267                "bundle must select at least one module",
268            ));
269        }
270
271        let mut modules = Vec::with_capacity(raw.modules.len());
272        for selected in raw.modules {
273            let selected_path = Path::new(&selected.manifest);
274            if selected_path.is_absolute() {
275                diagnostics.push(Diagnostic::error(
276                    "manifest.absolute_module_path",
277                    format!(
278                        "module manifest path {:?} must be relative",
279                        selected.manifest
280                    ),
281                ));
282                continue;
283            }
284            match ModuleManifest::from_path(directory.join(selected_path)) {
285                Ok(manifest) => modules.push(ModuleSelection {
286                    manifest,
287                    role: selected.role,
288                }),
289                Err(BundleError::Validation(mut nested)) => diagnostics.append(&mut nested),
290                Err(error) => return Err(error),
291            }
292        }
293
294        validate_module_set(&modules, &mut diagnostics);
295        if diagnostics.is_empty() {
296            modules.sort_by(|left, right| left.manifest.name.cmp(&right.manifest.name));
297            Ok(Self {
298                format_version: raw.format_version,
299                name: raw.name,
300                modules,
301                path,
302            })
303        } else {
304            Err(BundleError::Validation(diagnostics))
305        }
306    }
307
308    pub fn format_version(&self) -> u32 {
309        self.format_version
310    }
311
312    pub fn name(&self) -> &str {
313        &self.name
314    }
315
316    pub fn modules(&self) -> &[ModuleSelection] {
317        &self.modules
318    }
319
320    pub fn path(&self) -> &Path {
321        &self.path
322    }
323
324    pub(crate) fn for_single_module(module: ModuleManifest) -> Self {
325        Self {
326            format_version: FORMAT_VERSION,
327            name: module.name.clone(),
328            path: module.path.clone(),
329            modules: vec![ModuleSelection {
330                manifest: module,
331                role: ModuleRole::Ordinary,
332            }],
333        }
334    }
335}
336
337fn validate_namespace(label: &str, value: &str, diagnostics: &mut Vec<Diagnostic>) {
338    if value.trim().is_empty() {
339        diagnostics.push(Diagnostic::error(
340            "manifest.empty_namespace",
341            format!("{label} must not be empty"),
342        ));
343    } else if value.parse::<EntityTypeName>().is_err() {
344        diagnostics.push(Diagnostic::error(
345            "manifest.invalid_namespace",
346            format!("{label} {value:?} is not a valid Cedar name"),
347        ));
348    }
349}
350
351fn validate_input_path(directory: &Path, input: &str, diagnostics: &mut Vec<Diagnostic>) {
352    let input_path = Path::new(input);
353    if input.is_empty()
354        || input_path.is_absolute()
355        || input_path.components().any(|component| {
356            matches!(
357                component,
358                Component::ParentDir | Component::RootDir | Component::Prefix(_)
359            )
360        })
361        || input.contains(['*', '?', '[', ']'])
362    {
363        diagnostics.push(Diagnostic::error(
364            "manifest.invalid_input_path",
365            format!("module input path {input:?} must be an explicit relative path"),
366        ));
367        return;
368    }
369
370    let full_path = directory.join(input_path);
371    match full_path.canonicalize() {
372        Ok(canonical) if canonical.starts_with(directory) && canonical.is_file() => {}
373        Ok(_) => diagnostics.push(Diagnostic::error(
374            "manifest.input_escape",
375            format!("module input path {input:?} escapes the module directory"),
376        )),
377        Err(error) => diagnostics.push(Diagnostic::error(
378            "manifest.input_unreadable",
379            format!("module input path {input:?} cannot be resolved: {error}"),
380        )),
381    }
382}
383
384fn validate_module_set(modules: &[ModuleSelection], diagnostics: &mut Vec<Diagnostic>) {
385    let mut names = HashSet::with_capacity(modules.len());
386    for module in modules {
387        if !names.insert(module.manifest.name.as_str()) {
388            diagnostics.push(Diagnostic::error(
389                "manifest.duplicate_module_name",
390                format!("duplicate module name {:?}", module.manifest.name),
391            ));
392        }
393    }
394
395    let mut ordered_namespaces = modules
396        .iter()
397        .map(|module| module.manifest.namespace.as_str())
398        .collect::<Vec<_>>();
399    ordered_namespaces.sort_unstable();
400    for pair in ordered_namespaces.windows(2) {
401        if namespaces_overlap(pair[0], pair[1]) {
402            diagnostics.push(Diagnostic::error(
403                "manifest.overlapping_namespaces",
404                format!(
405                    "module namespace roots {:?} and {:?} overlap",
406                    pair[0], pair[1]
407                ),
408            ));
409        }
410    }
411
412    let namespaces = modules
413        .iter()
414        .map(|module| module.manifest.namespace.as_str())
415        .collect::<HashSet<_>>();
416    for module in modules {
417        for import in &module.manifest.imports {
418            if !namespaces.contains(import.as_str()) {
419                diagnostics.push(
420                    Diagnostic::error(
421                        "manifest.unresolved_import",
422                        format!("import {import:?} does not exactly match a selected module"),
423                    )
424                    .in_module(module.manifest.name.clone()),
425                );
426            }
427        }
428    }
429}
430
431pub(crate) fn namespace_owns(root: &str, name: &str) -> bool {
432    name == root
433        || name
434            .strip_prefix(root)
435            .is_some_and(|suffix| suffix.starts_with("::"))
436}
437
438fn namespaces_overlap(left: &str, right: &str) -> bool {
439    namespace_owns(left, right) || namespace_owns(right, left)
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    #[test]
447    fn namespace_prefixes_are_segment_aware() {
448        assert!(namespaces_overlap("A::B", "A::B::C"));
449        assert!(!namespaces_overlap("A::B", "A::Bee"));
450    }
451}