Skip to main content

kibana_sync/
bundle.rs

1//! Generic bundle access for filesystem and in-memory Kibana assets.
2
3use crate::kibana::agents::AgentsManifest;
4use crate::kibana::saved_objects::SavedObjectsManifest;
5use crate::kibana::skills::{
6    SKILL_FILE, SkillsManifest, skill_files_to_value,
7};
8use crate::kibana::spaces::{SpaceEntry, SpacesManifest};
9use crate::kibana::tools::ToolsManifest;
10use crate::kibana::workflows::WorkflowsManifest;
11use crate::sync::{SpaceBundle, SyncBundle, SyncSelection};
12use crate::{Error, Result, ResultContext, json5};
13use serde::de::DeserializeOwned;
14use serde_json::Value;
15use std::collections::{BTreeMap, BTreeSet};
16use std::path::{Component, Path, PathBuf};
17
18mod sealed {
19    pub trait Sealed {}
20}
21
22/// Read operations required by [`KibanaBundle`].
23///
24/// This trait is sealed. Use [`Filesystem`] or [`Entries`] as the source.
25pub trait BundleSource: sealed::Sealed {
26    /// Borrowed or owned content returned by this source.
27    type Content<'a>: AsRef<[u8]>
28    where
29        Self: 'a;
30
31    #[doc(hidden)]
32    fn is_file(&self, path: &Path) -> bool;
33    #[doc(hidden)]
34    fn is_dir(&self, path: &Path) -> bool;
35    #[doc(hidden)]
36    fn read(&self, path: &Path) -> Result<Self::Content<'_>>;
37    #[doc(hidden)]
38    fn files_under(&self, path: &Path) -> Result<Vec<PathBuf>>;
39    #[doc(hidden)]
40    fn immediate_directories(&self, path: &Path) -> Result<Vec<PathBuf>>;
41    #[doc(hidden)]
42    fn display_path(&self, path: &Path) -> String;
43    #[doc(hidden)]
44    fn validate_skill_directory(&self, _path: &Path) -> Result<()> {
45        Ok(())
46    }
47}
48
49/// A Kibana asset bundle backed by a source type.
50#[derive(Clone, Debug)]
51pub struct KibanaBundle<S> {
52    source: S,
53}
54
55/// Filesystem-backed bundle source.
56#[derive(Clone, Debug)]
57pub struct Filesystem {
58    root: PathBuf,
59}
60
61/// Entry-backed bundle source with caller-selected byte storage.
62#[derive(Clone, Debug)]
63pub struct Entries<B> {
64    files: BTreeMap<PathBuf, B>,
65    directories: BTreeSet<PathBuf>,
66}
67
68impl sealed::Sealed for Filesystem {}
69impl<B> sealed::Sealed for Entries<B> {}
70
71impl KibanaBundle<Filesystem> {
72    /// Open an existing filesystem bundle.
73    pub fn open(root: impl AsRef<Path>) -> Result<Self> {
74        let root = root.as_ref().to_path_buf();
75        if !root.exists() {
76            return Err(Error::message(format!(
77                "filesystem bundle root does not exist: {}",
78                root.display()
79            )));
80        }
81        if std::fs::symlink_metadata(&root)?.file_type().is_symlink() {
82            return Err(bundle_symlink_error(&root));
83        }
84        Ok(Self {
85            source: Filesystem { root },
86        })
87    }
88
89    /// Create or open a filesystem bundle.
90    pub fn create(root: impl AsRef<Path>) -> Result<Self> {
91        let root = root.as_ref().to_path_buf();
92        std::fs::create_dir_all(&root)
93            .with_context(|| format!("Failed to create bundle root: {}", root.display()))?;
94        if std::fs::symlink_metadata(&root)?.file_type().is_symlink() {
95            return Err(bundle_symlink_error(&root));
96        }
97        Ok(Self {
98            source: Filesystem { root },
99        })
100    }
101
102    /// Return the filesystem bundle root.
103    pub fn root(&self) -> &Path {
104        &self.source.root
105    }
106
107    /// Write a storage-neutral bundle to the stable filesystem layout.
108    pub fn write(&self, bundle: &SyncBundle) -> Result<()> {
109        crate::fs::FilesystemWriter::create(&self.source.root)?.write(bundle)
110    }
111}
112
113impl<B: AsRef<[u8]>> KibanaBundle<Entries<B>> {
114    /// Construct a bundle from root-relative path and content pairs.
115    pub fn from_entries<P>(entries: impl IntoIterator<Item = (P, B)>) -> Result<Self>
116    where
117        P: AsRef<Path>,
118    {
119        let mut files = BTreeMap::new();
120        let mut directories = BTreeSet::new();
121
122        for (path, content) in entries {
123            let original = path.as_ref();
124            let normalized = normalize_entry_path(original)?;
125            if directories.contains(&normalized) {
126                return Err(Error::message(format!(
127                    "bundle entry path conflicts with an implicit directory: {}",
128                    logical_path(&normalized)
129                )));
130            }
131            if files.insert(normalized.clone(), content).is_some() {
132                return Err(Error::message(format!(
133                    "duplicate bundle entry path: {}",
134                    logical_path(&normalized)
135                )));
136            }
137
138            let mut parent = normalized.parent();
139            while let Some(directory) = parent {
140                if directory.as_os_str().is_empty() {
141                    break;
142                }
143                if files.contains_key(directory) {
144                    return Err(Error::message(format!(
145                        "bundle entry path conflicts with a file: {}",
146                        logical_path(directory)
147                    )));
148                }
149                directories.insert(directory.to_path_buf());
150                parent = directory.parent();
151            }
152        }
153
154        Ok(Self {
155            source: Entries { files, directories },
156        })
157    }
158}
159
160impl<S: BundleSource> KibanaBundle<S> {
161    /// Read all resource families discoverable in the bundle.
162    pub fn read_all(&self) -> Result<SyncBundle> {
163        let spaces = self.discover_space_ids()?;
164        let selection = SyncSelection {
165            spaces,
166            saved_objects: Some(SavedObjectsManifest::new()),
167            include_spaces: self.manifest_is_file(Path::new("spaces.yml"))?,
168            include_workflows: true,
169            include_agents: true,
170            include_tools: true,
171            include_skills: true,
172        };
173        self.read(&selection)
174    }
175
176    /// Read selected resource families into a storage-neutral bundle.
177    pub fn read(&self, selection: &SyncSelection) -> Result<SyncBundle> {
178        let mut bundle = SyncBundle::default();
179        if selection.include_spaces {
180            bundle.spaces = self.read_spaces()?;
181        }
182
183        for space_id in &selection.spaces {
184            validate_space_id(space_id)?;
185            let mut space_bundle = SpaceBundle::default();
186            if let Some(selection_manifest) = &selection.saved_objects {
187                space_bundle.saved_objects =
188                    self.read_saved_objects(space_id, selection_manifest)?;
189            }
190            if selection.include_workflows {
191                space_bundle.workflows = self.read_workflows(space_id)?;
192            }
193            if selection.include_agents {
194                space_bundle.agents = self.read_agents(space_id)?;
195            }
196            if selection.include_tools {
197                space_bundle.tools = self.read_tools(space_id)?;
198            }
199            if selection.include_skills {
200                space_bundle.skills = self.read_skills(space_id)?;
201            }
202            bundle.by_space.insert(space_id.clone(), space_bundle);
203        }
204        Ok(bundle)
205    }
206
207    fn read_saved_objects(
208        &self,
209        space_id: &str,
210        selection_manifest: &SavedObjectsManifest,
211    ) -> Result<Vec<Value>> {
212        let directory = resource_dir(space_id, "objects");
213        let values = self.read_json_dir(&directory)?;
214        let manifest_path = manifest_path(space_id, "saved_objects.json");
215        let manifest = if self.manifest_is_file(&manifest_path)? {
216            Some(self.read_json_manifest(&manifest_path, "saved objects")?)
217        } else if selection_manifest.objects.is_empty() {
218            None
219        } else {
220            Some(selection_manifest.clone())
221        };
222
223        let Some(manifest) = manifest else {
224            return Ok(values);
225        };
226        manifest
227            .objects
228            .iter()
229            .map(|object| {
230                values
231                    .iter()
232                    .find(|value| saved_object_matches(value, &object.object_type, &object.id))
233                    .cloned()
234                    .ok_or_else(|| {
235                        self.missing_manifest_resource(
236                            "saved object",
237                            format!("{}/{}", object.object_type, object.id),
238                            &directory,
239                        )
240                    })
241            })
242            .collect()
243    }
244
245    fn read_workflows(&self, space_id: &str) -> Result<Vec<Value>> {
246        let directory = resource_dir(space_id, "workflows");
247        let values = self.read_json_dir(&directory)?;
248        let manifest_path = manifest_path(space_id, "workflows.yml");
249        if !self.manifest_is_file(&manifest_path)? {
250            return Ok(values);
251        }
252        let manifest: WorkflowsManifest = self.read_yaml_manifest(&manifest_path, "workflows")?;
253        manifest
254            .workflows
255            .iter()
256            .map(|entry| {
257                find_named_resource(&values, &entry.id, Some(&entry.name)).ok_or_else(|| {
258                    self.missing_manifest_resource("workflow", &entry.id, &directory)
259                })
260            })
261            .collect()
262    }
263
264    fn read_agents(&self, space_id: &str) -> Result<Vec<Value>> {
265        let directory = resource_dir(space_id, "agents");
266        let values = self.read_json_dir(&directory)?;
267        let manifest_path = manifest_path(space_id, "agents.yml");
268        if !self.manifest_is_file(&manifest_path)? {
269            return Ok(values);
270        }
271        let manifest: AgentsManifest = self.read_yaml_manifest(&manifest_path, "agents")?;
272        manifest
273            .agents
274            .iter()
275            .map(|entry| {
276                find_named_resource(&values, &entry.id, Some(&entry.name))
277                    .ok_or_else(|| self.missing_manifest_resource("agent", &entry.id, &directory))
278            })
279            .collect()
280    }
281
282    fn read_tools(&self, space_id: &str) -> Result<Vec<Value>> {
283        let directory = resource_dir(space_id, "tools");
284        let values = self.read_json_dir(&directory)?;
285        let manifest_path = manifest_path(space_id, "tools.yml");
286        if !self.manifest_is_file(&manifest_path)? {
287            return Ok(values);
288        }
289        let manifest: ToolsManifest = self.read_yaml_manifest(&manifest_path, "tools")?;
290        manifest
291            .tools
292            .iter()
293            .map(|id| {
294                find_named_resource(&values, id, None)
295                    .ok_or_else(|| self.missing_manifest_resource("tool", id, &directory))
296            })
297            .collect()
298    }
299
300    fn read_skills(&self, space_id: &str) -> Result<Vec<Value>> {
301        let root = resource_dir(space_id, "skills");
302        let manifest_path = manifest_path(space_id, "skills.yml");
303        let manifest: Option<SkillsManifest> = if self.manifest_is_file(&manifest_path)? {
304            Some(self.read_yaml_manifest(&manifest_path, "skills")?)
305        } else {
306            None
307        };
308
309        if !self.source.is_dir(&root) {
310            if let Some(entry) = manifest.as_ref().and_then(|value| value.skills.first()) {
311                return Err(self.missing_skill_manifest_resource(&entry.id, &root));
312            }
313            return Ok(Vec::new());
314        }
315
316        let mut directories = self.source.immediate_directories(&root)?;
317        directories.sort();
318        let mut values = Vec::new();
319        for directory in directories {
320            let skill_file = directory.join(SKILL_FILE);
321            if !self.source.is_file(&skill_file) {
322                continue;
323            }
324            self.source.validate_skill_directory(&directory)?;
325            let skill_markdown = self.read_text(&skill_file, "skill file")?;
326            let mut referenced = Vec::new();
327            for file in self.source.files_under(&directory)? {
328                if file.file_name().and_then(|value| value.to_str()) == Some(SKILL_FILE) {
329                    continue;
330                }
331                let relative = file
332                    .strip_prefix(&directory)
333                    .map_err(|_| {
334                        Error::message(format!(
335                            "referenced content escaped skill directory: {}",
336                            self.source.display_path(&file)
337                        ))
338                    })?
339                    .to_path_buf();
340                referenced.push((relative, self.read_text(&file, "referenced content")?));
341            }
342            values.push(skill_files_to_value(
343                &skill_markdown,
344                referenced,
345                true,
346            )?);
347        }
348
349        let Some(manifest) = manifest else {
350            return Ok(values);
351        };
352        manifest
353            .skills
354            .iter()
355            .map(|entry| {
356                values
357                    .iter()
358                    .find(|value| value.get("id").and_then(Value::as_str) == Some(&entry.id))
359                    .cloned()
360                    .ok_or_else(|| self.missing_skill_manifest_resource(&entry.id, &root))
361            })
362            .collect()
363    }
364
365    fn read_spaces(&self) -> Result<Vec<Value>> {
366        let path = Path::new("spaces.yml");
367        if !self.manifest_is_file(path)? {
368            return Ok(Vec::new());
369        }
370        let manifest: SpacesManifest = self.read_yaml_manifest(path, "spaces")?;
371        manifest
372            .spaces
373            .into_iter()
374            .map(|space| {
375                validate_space_id(&space.id)?;
376                self.read_space_definition(&space)
377            })
378            .collect()
379    }
380
381    fn read_space_definition(&self, space: &SpaceEntry) -> Result<Value> {
382        let path = Path::new(&space.id).join("space.json");
383        if !self.source.is_file(&path) {
384            return Ok(serde_json::json!({"id": space.id, "name": space.name}));
385        }
386
387        let content = self.source.read(&path).with_context(|| {
388            format!(
389                "Failed to read space definition: {}",
390                self.source.display_path(&path)
391            )
392        })?;
393        let text = utf8(content.as_ref(), &self.source.display_path(&path))?;
394        let mut definition = json5::from_json5_str(text).with_context(|| {
395            format!(
396                "Failed to parse space definition: {}",
397                self.source.display_path(&path)
398            )
399        })?;
400        let id = definition.get("id").and_then(Value::as_str);
401        if id != Some(space.id.as_str()) {
402            return Err(Error::message(format!(
403                "space definition {} must contain the manifest id '{}'",
404                self.source.display_path(&path),
405                space.id
406            )));
407        }
408        if definition.get("name").and_then(Value::as_str).is_none() {
409            definition["name"] = Value::String(space.name.clone());
410        }
411        Ok(definition)
412    }
413
414    fn discover_space_ids(&self) -> Result<Vec<String>> {
415        let mut ids = BTreeSet::new();
416        let spaces_path = Path::new("spaces.yml");
417        if self.manifest_is_file(spaces_path)? {
418            let manifest: SpacesManifest = self.read_yaml_manifest(spaces_path, "spaces")?;
419            ids.extend(manifest.spaces.into_iter().map(|space| space.id));
420        }
421        for directory in self.source.immediate_directories(Path::new(""))? {
422            let Some(id) = directory.file_name().and_then(|value| value.to_str()) else {
423                continue;
424            };
425            if self.has_space_resources(id) {
426                ids.insert(id.to_string());
427            }
428        }
429        Ok(ids.into_iter().collect())
430    }
431
432    fn has_space_resources(&self, space_id: &str) -> bool {
433        [
434            "objects",
435            "workflows",
436            "agents",
437            "tools",
438            "skills",
439            "manifest",
440        ]
441        .into_iter()
442        .any(|name| self.source.is_dir(&resource_dir(space_id, name)))
443    }
444
445    fn manifest_is_file(&self, path: &Path) -> Result<bool> {
446        if self.source.is_file(path) {
447            return Ok(true);
448        }
449        if self.source.is_dir(path) {
450            return Err(Error::message(format!(
451                "bundle manifest must be a file: {}",
452                self.source.display_path(path)
453            )));
454        }
455        Ok(false)
456    }
457
458    fn read_json_dir(&self, directory: &Path) -> Result<Vec<Value>> {
459        if !self.source.is_dir(directory) {
460            return Ok(Vec::new());
461        }
462        let mut files = self.source.files_under(directory)?;
463        files.retain(|path| path.extension().and_then(|value| value.to_str()) == Some("json"));
464        files.sort();
465        files
466            .into_iter()
467            .map(|path| {
468                let content = self.source.read(&path).with_context(|| {
469                    format!(
470                        "Failed to read JSON resource: {}",
471                        self.source.display_path(&path)
472                    )
473                })?;
474                let text = utf8(content.as_ref(), &self.source.display_path(&path))?;
475                json5::from_json5_str(text).with_context(|| {
476                    format!(
477                        "Failed to parse JSON resource: {}",
478                        self.source.display_path(&path)
479                    )
480                })
481            })
482            .collect()
483    }
484
485    fn read_text(&self, path: &Path, resource: &str) -> Result<String> {
486        let content = self.source.read(path).with_context(|| {
487            format!(
488                "Failed to read {resource}: {}",
489                self.source.display_path(path)
490            )
491        })?;
492        Ok(utf8(content.as_ref(), &self.source.display_path(path))?.to_string())
493    }
494
495    fn read_yaml_manifest<T: DeserializeOwned>(&self, path: &Path, kind: &str) -> Result<T> {
496        let content = self.source.read(path).with_context(|| {
497            format!(
498                "Failed to read {kind} manifest: {}",
499                self.source.display_path(path)
500            )
501        })?;
502        let text = utf8(content.as_ref(), &self.source.display_path(path))?;
503        yaml_serde::from_str(text).with_context(|| {
504            format!(
505                "Failed to parse {kind} manifest YAML: {}",
506                self.source.display_path(path)
507            )
508        })
509    }
510
511    fn read_json_manifest<T: DeserializeOwned>(&self, path: &Path, kind: &str) -> Result<T> {
512        let content = self.source.read(path).with_context(|| {
513            format!(
514                "Failed to read {kind} manifest: {}",
515                self.source.display_path(path)
516            )
517        })?;
518        let text = utf8(content.as_ref(), &self.source.display_path(path))?;
519        serde_json::from_str(text).with_context(|| {
520            format!(
521                "Failed to parse {kind} manifest JSON: {}",
522                self.source.display_path(path)
523            )
524        })
525    }
526
527    fn missing_manifest_resource(
528        &self,
529        resource: &str,
530        id: impl std::fmt::Display,
531        directory: &Path,
532    ) -> Error {
533        Error::message(format!(
534            "{resource} '{id}' is listed in the manifest but no matching JSON resource was found under {}",
535            self.source.display_path(directory)
536        ))
537    }
538
539    fn missing_skill_manifest_resource(
540        &self,
541        id: impl std::fmt::Display,
542        directory: &Path,
543    ) -> Error {
544        Error::message(format!(
545            "skill '{id}' is listed in the manifest but no matching skills/<skill-directory>/SKILL.md resource was found under {}",
546            self.source.display_path(directory)
547        ))
548    }
549}
550
551impl BundleSource for Filesystem {
552    type Content<'a> = Vec<u8>;
553
554    fn is_file(&self, path: &Path) -> bool {
555        std::fs::symlink_metadata(self.root.join(path))
556            .map(|metadata| metadata.is_file() || metadata.file_type().is_symlink())
557            .unwrap_or(false)
558    }
559
560    fn is_dir(&self, path: &Path) -> bool {
561        std::fs::symlink_metadata(self.root.join(path))
562            .map(|metadata| metadata.is_dir() || metadata.file_type().is_symlink())
563            .unwrap_or(false)
564    }
565
566    fn read(&self, path: &Path) -> Result<Self::Content<'_>> {
567        let mut full_path = self.root.clone();
568        for component in path.components() {
569            full_path.push(component);
570            let metadata = std::fs::symlink_metadata(&full_path)?;
571            if metadata.file_type().is_symlink() {
572                return Err(bundle_symlink_error(&full_path));
573            }
574        }
575        let metadata = std::fs::symlink_metadata(&full_path)?;
576        if metadata.file_type().is_symlink() {
577            return Err(bundle_symlink_error(&full_path));
578        }
579        std::fs::read(full_path).map_err(Into::into)
580    }
581
582    fn files_under(&self, path: &Path) -> Result<Vec<PathBuf>> {
583        let mut files = Vec::new();
584        collect_files(&self.root, &self.root.join(path), &mut files)?;
585        files.sort();
586        Ok(files)
587    }
588
589    fn immediate_directories(&self, path: &Path) -> Result<Vec<PathBuf>> {
590        let directory = self.root.join(path);
591        let metadata = match std::fs::symlink_metadata(&directory) {
592            Ok(metadata) => metadata,
593            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
594            Err(error) => return Err(error.into()),
595        };
596        if metadata.file_type().is_symlink() {
597            return Err(bundle_symlink_error(&directory));
598        }
599        if !metadata.is_dir() {
600            return Ok(Vec::new());
601        }
602        let mut directories = Vec::new();
603        for entry in std::fs::read_dir(directory)? {
604            let entry = entry?;
605            let entry_path = entry.path();
606            let metadata = std::fs::symlink_metadata(&entry_path)?;
607            if metadata.file_type().is_symlink() {
608                return Err(bundle_symlink_error(&entry_path));
609            }
610            if metadata.is_dir() {
611                directories.push(path.join(entry.file_name()));
612            }
613        }
614        directories.sort();
615        Ok(directories)
616    }
617
618    fn display_path(&self, path: &Path) -> String {
619        self.root.join(path).display().to_string()
620    }
621
622    fn validate_skill_directory(&self, path: &Path) -> Result<()> {
623        let directory = self.root.join(path);
624        let metadata = std::fs::symlink_metadata(&directory)?;
625        if metadata.file_type().is_symlink() {
626            return Err(Error::message(format!(
627                "skill directory cannot be a symlink: {}",
628                directory.display()
629            )));
630        }
631        let canonical = directory.canonicalize().with_context(|| {
632            format!("Failed to resolve skill directory: {}", directory.display())
633        })?;
634        validate_filesystem_tree(&canonical, &directory)
635    }
636}
637
638impl<B: AsRef<[u8]>> BundleSource for Entries<B> {
639    type Content<'a>
640        = &'a B
641    where
642        B: 'a;
643
644    fn is_file(&self, path: &Path) -> bool {
645        self.files.contains_key(path)
646    }
647
648    fn is_dir(&self, path: &Path) -> bool {
649        path.as_os_str().is_empty() || self.directories.contains(path)
650    }
651
652    fn read(&self, path: &Path) -> Result<Self::Content<'_>> {
653        self.files.get(path).ok_or_else(|| {
654            Error::message(format!(
655                "bundle entry does not exist: {}",
656                self.display_path(path)
657            ))
658        })
659    }
660
661    fn files_under(&self, path: &Path) -> Result<Vec<PathBuf>> {
662        Ok(self
663            .files
664            .keys()
665            .filter(|entry| entry.starts_with(path))
666            .cloned()
667            .collect())
668    }
669
670    fn immediate_directories(&self, path: &Path) -> Result<Vec<PathBuf>> {
671        Ok(self
672            .directories
673            .iter()
674            .filter(|directory| directory.parent().unwrap_or_else(|| Path::new("")) == path)
675            .cloned()
676            .collect())
677    }
678
679    fn display_path(&self, path: &Path) -> String {
680        path.to_string_lossy().replace('\\', "/")
681    }
682}
683
684fn normalize_entry_path(path: &Path) -> Result<PathBuf> {
685    let original = path.to_str().ok_or_else(|| {
686        Error::message(format!(
687            "bundle entry path is not UTF-8: {}",
688            path.display()
689        ))
690    })?;
691    if original.is_empty() {
692        return Err(Error::message("invalid bundle entry path: path is empty"));
693    }
694    let logical = original.replace('\\', "/");
695    if logical.starts_with('/')
696        || logical.as_bytes().get(1) == Some(&b':') && logical.as_bytes()[0].is_ascii_alphabetic()
697    {
698        return Err(Error::message(format!(
699            "invalid bundle entry path '{original}': path must be relative"
700        )));
701    }
702
703    let mut normalized = PathBuf::new();
704    for component in Path::new(&logical).components() {
705        match component {
706            Component::Normal(value) => normalized.push(value),
707            _ => {
708                return Err(Error::message(format!(
709                    "invalid bundle entry path '{original}': only normal relative components are allowed"
710                )));
711            }
712        }
713    }
714    if normalized.as_os_str().is_empty() || logical.ends_with('/') {
715        return Err(Error::message(format!(
716            "invalid bundle entry path '{original}': path must name a file"
717        )));
718    }
719    Ok(normalized)
720}
721
722pub(crate) fn validate_space_id(id: &str) -> Result<()> {
723    if id.is_empty()
724        || matches!(id, "." | "..")
725        || id.contains(['/', '\\', ':'])
726    {
727        return Err(Error::message(format!(
728            "invalid space id '{id}': space ids must be a single path component"
729        )));
730    }
731    Ok(())
732}
733
734fn logical_path(path: &Path) -> String {
735    path.to_string_lossy().replace('\\', "/")
736}
737
738fn collect_files(root: &Path, directory: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
739    let metadata = match std::fs::symlink_metadata(directory) {
740        Ok(metadata) => metadata,
741        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
742        Err(error) => return Err(error.into()),
743    };
744    if metadata.file_type().is_symlink() {
745        return Err(bundle_symlink_error(directory));
746    }
747    if !metadata.is_dir() {
748        return Ok(());
749    }
750    for entry in std::fs::read_dir(directory)? {
751        let path = entry?.path();
752        let metadata = std::fs::symlink_metadata(&path)?;
753        if metadata.file_type().is_symlink() {
754            return Err(bundle_symlink_error(&path));
755        }
756        if metadata.is_dir() {
757            collect_files(root, &path, files)?;
758        } else if metadata.is_file() {
759            files.push(
760                path.strip_prefix(root)
761                    .map_err(|_| {
762                        Error::message(format!("bundle file escaped root: {}", path.display()))
763                    })?
764                    .to_path_buf(),
765            );
766        }
767    }
768    Ok(())
769}
770
771fn bundle_symlink_error(path: &Path) -> Error {
772    Error::message(format!(
773        "bundle paths cannot be symlinks: {}",
774        path.display()
775    ))
776}
777
778fn validate_filesystem_tree(canonical_root: &Path, directory: &Path) -> Result<()> {
779    for entry in std::fs::read_dir(directory)? {
780        let path = entry?.path();
781        let metadata = std::fs::symlink_metadata(&path)
782            .with_context(|| format!("Failed to inspect path: {}", path.display()))?;
783        if metadata.file_type().is_symlink() {
784            return Err(Error::message(format!(
785                "path uses symlink traversal inside skill directory: {}",
786                path.display()
787            )));
788        }
789        let canonical = path
790            .canonicalize()
791            .with_context(|| format!("Failed to resolve path: {}", path.display()))?;
792        if !canonical.starts_with(canonical_root) {
793            return Err(Error::message(format!(
794                "path escapes skill directory: {}",
795                path.display()
796            )));
797        }
798        if metadata.is_dir() {
799            validate_filesystem_tree(canonical_root, &path)?;
800        }
801    }
802    Ok(())
803}
804
805fn utf8<'a>(content: &'a [u8], path: &str) -> Result<&'a str> {
806    std::str::from_utf8(content)
807        .map_err(|error| Error::message(format!("bundle text file is not UTF-8: {path}: {error}")))
808}
809
810fn resource_dir(space_id: &str, name: &str) -> PathBuf {
811    Path::new(space_id).join(name)
812}
813
814fn manifest_path(space_id: &str, name: &str) -> PathBuf {
815    Path::new(space_id).join("manifest").join(name)
816}
817
818fn saved_object_matches(value: &Value, object_type: &str, id: &str) -> bool {
819    value.get("type").and_then(Value::as_str) == Some(object_type)
820        && value.get("id").and_then(Value::as_str) == Some(id)
821}
822
823fn find_named_resource(values: &[Value], id: &str, name: Option<&str>) -> Option<Value> {
824    values
825        .iter()
826        .find(|value| value.get("id").and_then(Value::as_str) == Some(id))
827        .or_else(|| {
828            name.and_then(|name| {
829                values
830                    .iter()
831                    .find(|value| value.get("name").and_then(Value::as_str) == Some(name))
832            })
833        })
834        .cloned()
835}
836
837#[cfg(test)]
838mod tests {
839    use super::*;
840    use serde_json::json;
841    use std::sync::Arc;
842    use tempfile::TempDir;
843
844    struct ApplicationBytes(Vec<u8>);
845
846    impl AsRef<[u8]> for ApplicationBytes {
847        fn as_ref(&self) -> &[u8] {
848            &self.0
849        }
850    }
851
852    fn fixture() -> Vec<(String, Vec<u8>)> {
853        [
854            ("spaces.yml", "spaces:\n  - id: default\n    name: Default\n"),
855            ("default/manifest/saved_objects.json", r#"{"objects":[{"type":"dashboard","id":"dash-1"}]}"#),
856            ("default/objects/dashboard/dash-1.json", r#"{
857                // JSON5 comments, unquoted keys, and trailing commas are valid.
858                type: "dashboard",
859                id: "dash-1",
860                attributes: { title: "Dash", },
861            }"#),
862            ("default/manifest/workflows.yml", "workflows:\n  - id: workflow-1\n    name: Workflow\n"),
863            ("default/workflows/workflow.json", r#"{"id":"workflow-1","name":"Workflow"}"#),
864            ("default/manifest/agents.yml", "agents:\n  - id: agent-1\n    name: Agent\n"),
865            ("default/agents/agent.json", r#"{"id":"agent-1","name":"Agent"}"#),
866            ("default/manifest/tools.yml", "tools:\n  - tool-1\n"),
867            ("default/tools/tool.json", r#"{"id":"tool-1","name":"Tool"}"#),
868            ("default/manifest/skills.yml", "skills:\n  - id: skill-1\n    name: Skill\n"),
869            ("default/skills/skill-1/SKILL.md", "---\nid: skill-1\nname: Skill\ntool_ids:\n  - tool-1\n---\nInstructions\n"),
870            ("default/skills/skill-1/references/query.txt", "Query body\n"),
871            ("default/skills/skill-1/examples/intro.yml", "Intro body\n"),
872        ]
873        .into_iter()
874        .map(|(path, content)| (path.to_string(), content.as_bytes().to_vec()))
875        .collect()
876    }
877
878    fn write_entries(root: &Path, entries: &[(String, Vec<u8>)]) {
879        for (path, content) in entries {
880            let path = root.join(path);
881            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
882            std::fs::write(path, content).unwrap();
883        }
884    }
885
886    #[test]
887    fn entry_sources_support_owned_borrowed_and_shared_bytes() {
888        let owned = KibanaBundle::from_entries(fixture())
889            .unwrap()
890            .read_all()
891            .unwrap();
892        let fixture = fixture();
893        let borrowed = KibanaBundle::from_entries(
894            fixture
895                .iter()
896                .map(|(path, content)| (path, content.as_slice())),
897        )
898        .unwrap()
899        .read_all()
900        .unwrap();
901        let shared = KibanaBundle::from_entries(
902            fixture
903                .iter()
904                .map(|(path, content)| (path, Arc::<[u8]>::from(content.clone()))),
905        )
906        .unwrap()
907        .read_all()
908        .unwrap();
909
910        assert_eq!(owned, borrowed);
911        assert_eq!(owned, shared);
912        let referenced = owned.by_space["default"].skills[0]["referenced_content"]
913            .as_array()
914            .unwrap();
915        assert_eq!(referenced[0]["name"], "intro");
916        assert_eq!(referenced[0]["relativePath"], "./examples");
917        assert_eq!(referenced[1]["name"], "query");
918        assert_eq!(referenced[1]["relativePath"], "./references");
919
920        let application = KibanaBundle::from_entries(
921            fixture
922                .iter()
923                .map(|(path, content)| (path, ApplicationBytes(content.clone()))),
924        )
925        .unwrap()
926        .read_all()
927        .unwrap();
928        assert_eq!(owned, application);
929    }
930
931    #[test]
932    fn filesystem_and_entries_have_read_parity() {
933        let temp = TempDir::new().unwrap();
934        let entries = fixture();
935        write_entries(temp.path(), &entries);
936
937        let filesystem = KibanaBundle::open(temp.path()).unwrap().read_all().unwrap();
938        let memory = KibanaBundle::from_entries(
939            entries
940                .iter()
941                .map(|(path, content)| (path, content.as_slice())),
942        )
943        .unwrap()
944        .read_all()
945        .unwrap();
946
947        assert_eq!(filesystem, memory);
948    }
949
950    #[test]
951    fn space_definitions_preserve_full_space_configuration() {
952        let bundle = KibanaBundle::from_entries([
953            (
954                "spaces.yml",
955                b"spaces:\n  - id: default\n    name: Default\n".as_slice(),
956            ),
957            (
958                "default/space.json",
959                br#"{id: "default", name: "Default", description: "Full definition", solution: "oblt"}"#
960                    .as_slice(),
961            ),
962        ])
963        .unwrap()
964        .read_all()
965        .unwrap();
966
967        assert_eq!(bundle.spaces[0]["description"], "Full definition");
968        assert_eq!(bundle.spaces[0]["solution"], "oblt");
969    }
970
971    #[test]
972    fn space_definitions_default_missing_name_from_manifest() {
973        let bundle = KibanaBundle::from_entries([
974            (
975                "spaces.yml",
976                b"spaces:\n  - id: default\n    name: Default\n".as_slice(),
977            ),
978            (
979                "default/space.json",
980                br#"{id: "default", solution: "oblt"}"#.as_slice(),
981            ),
982        ])
983        .unwrap()
984        .read_all()
985        .unwrap();
986
987        assert_eq!(bundle.spaces[0]["name"], "Default");
988        assert_eq!(bundle.spaces[0]["solution"], "oblt");
989    }
990
991    #[test]
992    fn entry_paths_are_validated_and_directories_are_implicit() {
993        for path in [
994            "",
995            "/absolute.json",
996            "../escape.json",
997            "a/../b.json",
998            "C:\\root.json",
999            "dir/",
1000        ] {
1001            let result = KibanaBundle::from_entries([(path, b"{}".as_slice())]);
1002            assert!(result.is_err(), "{path} should be invalid");
1003        }
1004
1005        let duplicate = KibanaBundle::from_entries([
1006            ("default/tools/tool.json", b"{}".as_slice()),
1007            ("default\\tools\\tool.json", b"{}".as_slice()),
1008        ]);
1009        assert!(duplicate.unwrap_err().to_string().contains("duplicate"));
1010
1011        for entries in [
1012            [
1013                ("default/tools", b"{}".as_slice()),
1014                ("default/tools/tool.json", b"{}".as_slice()),
1015            ],
1016            [
1017                ("default/tools/tool.json", b"{}".as_slice()),
1018                ("default/tools", b"{}".as_slice()),
1019            ],
1020        ] {
1021            let collision = KibanaBundle::from_entries(entries).unwrap_err();
1022            assert!(collision.to_string().contains("conflicts"));
1023        }
1024
1025        let bundle = KibanaBundle::from_entries([(
1026            "default/tools/tool.json",
1027            br#"{"id":"tool-1"}"#.as_slice(),
1028        )])
1029        .unwrap();
1030        assert!(bundle.source.is_dir(Path::new("default/tools")));
1031        assert_eq!(
1032            bundle
1033                .source
1034                .files_under(Path::new("default"))
1035                .unwrap()
1036                .len(),
1037            1
1038        );
1039    }
1040
1041    #[test]
1042    fn entry_files_under_does_not_stop_at_lexicographic_siblings() {
1043        let bundle = KibanaBundle::from_entries([
1044            ("default/skills/incident-response/SKILL.md", b"skill".as_slice()),
1045            (
1046                "default/skills/incident-response-v2/notes.md",
1047                b"sibling".as_slice(),
1048            ),
1049        ])
1050        .unwrap();
1051
1052        assert_eq!(
1053            bundle
1054                .source
1055                .files_under(Path::new("default/skills/incident-response"))
1056                .unwrap(),
1057            vec![PathBuf::from("default/skills/incident-response/SKILL.md")]
1058        );
1059    }
1060
1061    #[test]
1062    fn entry_errors_include_logical_paths() {
1063        let invalid_utf8 = KibanaBundle::from_entries([("spaces.yml", [0xff])])
1064            .unwrap()
1065            .read_all()
1066            .unwrap_err();
1067        assert!(invalid_utf8.to_string().contains("spaces.yml"));
1068
1069        let invalid_json =
1070            KibanaBundle::from_entries([("default/objects/bad.json", b"{".as_slice())])
1071                .unwrap()
1072                .read_all()
1073                .unwrap_err();
1074        assert!(
1075            invalid_json
1076                .to_string()
1077                .contains("default/objects/bad.json")
1078        );
1079    }
1080
1081    #[test]
1082    fn manifest_parse_errors_include_logical_paths() {
1083        let yaml_error = KibanaBundle::from_entries([("spaces.yml", b"{".as_slice())])
1084            .unwrap()
1085            .read_all()
1086            .unwrap_err();
1087        assert!(yaml_error.to_string().contains("spaces.yml"));
1088
1089        let json_error =
1090            KibanaBundle::from_entries([("default/manifest/saved_objects.json", b"{".as_slice())])
1091                .unwrap()
1092                .read_all()
1093                .unwrap_err();
1094        assert!(
1095            json_error
1096                .to_string()
1097                .contains("default/manifest/saved_objects.json")
1098        );
1099    }
1100
1101    #[cfg(unix)]
1102    #[test]
1103    fn filesystem_sources_reject_symlink_traversal() {
1104        let temp = TempDir::new().unwrap();
1105        let outside = TempDir::new().unwrap();
1106        std::os::unix::fs::symlink(outside.path(), temp.path().join("default")).unwrap();
1107
1108        let error = KibanaBundle::open(temp.path())
1109            .unwrap()
1110            .read_all()
1111            .unwrap_err();
1112
1113        assert!(
1114            error
1115                .to_string()
1116                .contains("bundle paths cannot be symlinks")
1117        );
1118    }
1119
1120    #[cfg(unix)]
1121    #[test]
1122    fn filesystem_bundle_roots_cannot_be_symlinks() {
1123        let temp = TempDir::new().unwrap();
1124        let outside = TempDir::new().unwrap();
1125        let root = temp.path().join("bundle");
1126        std::os::unix::fs::symlink(outside.path(), &root).unwrap();
1127
1128        for result in [KibanaBundle::open(&root), KibanaBundle::create(&root)] {
1129            let error = result.unwrap_err();
1130            assert!(
1131                error
1132                    .to_string()
1133                    .contains("bundle paths cannot be symlinks")
1134            );
1135        }
1136    }
1137
1138    #[test]
1139    fn selection_and_manifest_order_match_filesystem_behavior() {
1140        let mut entries = fixture();
1141        entries
1142            .iter_mut()
1143            .find(|(path, _)| path == "default/manifest/tools.yml")
1144            .unwrap()
1145            .1 = b"tools:\n  - tool-2\n  - tool-1\n".to_vec();
1146        entries.push((
1147            "default/tools/z-tool-2.json".to_string(),
1148            br#"{"id":"tool-2","name":"Tool Two"}"#.to_vec(),
1149        ));
1150        entries.push((
1151            "default/tools/extra.json".to_string(),
1152            br#"{"id":"extra"}"#.to_vec(),
1153        ));
1154        entries.push((
1155            "secondary/tools/tool.json".to_string(),
1156            br#"{"id":"secondary-tool"}"#.to_vec(),
1157        ));
1158        let temp = TempDir::new().unwrap();
1159        write_entries(temp.path(), &entries);
1160        let bundle = KibanaBundle::from_entries(entries).unwrap();
1161        let selection = SyncSelection {
1162            spaces: vec!["default".to_string()],
1163            include_tools: true,
1164            ..SyncSelection::default()
1165        };
1166        let read = bundle.read(&selection).unwrap();
1167        let filesystem = KibanaBundle::open(temp.path())
1168            .unwrap()
1169            .read(&selection)
1170            .unwrap();
1171
1172        assert_eq!(
1173            read.by_space["default"].tools,
1174            vec![
1175                json!({"id": "tool-2", "name": "Tool Two"}),
1176                json!({"id": "tool-1", "name": "Tool"}),
1177            ]
1178        );
1179        assert!(read.by_space["default"].agents.is_empty());
1180        assert!(!read.by_space.contains_key("secondary"));
1181        assert_eq!(read, filesystem);
1182    }
1183
1184    #[test]
1185    fn empty_bundle_and_implicit_spaces_are_supported() {
1186        let empty =
1187            KibanaBundle::<Entries<&[u8]>>::from_entries(std::iter::empty::<(&str, &[u8])>())
1188                .unwrap()
1189                .read_all()
1190                .unwrap();
1191        assert!(empty.spaces.is_empty());
1192        assert!(empty.by_space.is_empty());
1193
1194        let discovered = KibanaBundle::from_entries([
1195            (
1196                "spaces.yml",
1197                b"spaces:\n  - id: listed\n    name: Listed\n".as_slice(),
1198            ),
1199            ("unlisted/tools/tool.json", br#"{"id":"tool-1"}"#.as_slice()),
1200        ])
1201        .unwrap()
1202        .read_all()
1203        .unwrap();
1204        assert!(discovered.by_space.contains_key("listed"));
1205        assert!(discovered.by_space.contains_key("unlisted"));
1206    }
1207
1208    #[test]
1209    fn missing_manifest_resources_report_logical_directory() {
1210        let entries = vec![
1211            (
1212                "default/manifest/tools.yml".to_string(),
1213                b"tools:\n  - missing-tool\n".to_vec(),
1214            ),
1215            (
1216                "default/tools/present.json".to_string(),
1217                br#"{"id":"present-tool"}"#.to_vec(),
1218            ),
1219        ];
1220        let error = KibanaBundle::from_entries(entries.clone())
1221            .unwrap()
1222            .read_all()
1223            .unwrap_err();
1224
1225        let temp = TempDir::new().unwrap();
1226        write_entries(temp.path(), &entries);
1227        let filesystem_error = KibanaBundle::open(temp.path())
1228            .unwrap()
1229            .read_all()
1230            .unwrap_err();
1231
1232        assert!(error.to_string().contains("missing-tool"));
1233        assert!(error.to_string().contains("default/tools"));
1234        assert!(filesystem_error.to_string().contains("missing-tool"));
1235        assert!(filesystem_error.to_string().contains("default/tools"));
1236    }
1237
1238    #[test]
1239    fn filesystem_write_round_trips_through_generic_reader() {
1240        let temp = TempDir::new().unwrap();
1241        let expected = KibanaBundle::from_entries(fixture())
1242            .unwrap()
1243            .read_all()
1244            .unwrap();
1245        let filesystem = KibanaBundle::create(temp.path()).unwrap();
1246
1247        filesystem.write(&expected).unwrap();
1248        let actual = KibanaBundle::open(temp.path()).unwrap().read_all().unwrap();
1249
1250        assert_eq!(actual, expected);
1251        assert_eq!(filesystem.root(), temp.path());
1252    }
1253
1254    #[test]
1255    fn filesystem_write_defaults_space_definition_name() {
1256        let temp = TempDir::new().unwrap();
1257        let filesystem = KibanaBundle::create(temp.path()).unwrap();
1258        let bundle = SyncBundle {
1259            spaces: vec![json!({"id": "default"})],
1260            ..SyncBundle::default()
1261        };
1262
1263        filesystem.write(&bundle).unwrap();
1264        let read = KibanaBundle::open(temp.path()).unwrap().read_all().unwrap();
1265
1266        assert_eq!(
1267            read.spaces,
1268            vec![json!({"id": "default", "name": "default"})]
1269        );
1270    }
1271
1272    #[test]
1273    fn filesystem_write_rejects_non_object_spaces() {
1274        let temp = TempDir::new().unwrap();
1275        let filesystem = KibanaBundle::create(temp.path()).unwrap();
1276        let bundle = SyncBundle {
1277            spaces: vec![json!("default")],
1278            ..SyncBundle::default()
1279        };
1280
1281        let error = filesystem.write(&bundle).unwrap_err();
1282
1283        assert_eq!(error.to_string(), "space must be a JSON object");
1284    }
1285
1286    #[test]
1287    fn bundle_reader_rejects_path_traversal_space_ids() {
1288        let selection = SyncSelection {
1289            spaces: vec!["../outside".to_string()],
1290            include_tools: true,
1291            ..SyncSelection::default()
1292        };
1293        let error = KibanaBundle::<Entries<&[u8]>>::from_entries(std::iter::empty::<(
1294            &str,
1295            &[u8],
1296        )>())
1297        .unwrap()
1298            .read(&selection)
1299            .unwrap_err();
1300
1301        assert!(error.to_string().contains("invalid space id '../outside'"));
1302    }
1303
1304    #[test]
1305    fn bundle_reader_rejects_path_traversal_space_manifest_ids() {
1306        let error = KibanaBundle::from_entries([(
1307            "spaces.yml",
1308            b"spaces:\n  - id: ../outside\n    name: Outside\n".as_slice(),
1309        )])
1310        .unwrap()
1311        .read_all()
1312        .unwrap_err();
1313
1314        assert!(error.to_string().contains("invalid space id '../outside'"));
1315    }
1316
1317    #[test]
1318    fn bundle_reader_rejects_manifest_directories() {
1319        for entries in [
1320            vec![("spaces.yml/marker", b"directory".as_slice())],
1321            vec![(
1322                "default/manifest/tools.yml/marker",
1323                b"directory".as_slice(),
1324            )],
1325        ] {
1326            let error = KibanaBundle::from_entries(entries)
1327                .unwrap()
1328                .read_all()
1329                .unwrap_err();
1330
1331            assert!(error.to_string().contains("bundle manifest must be a file"));
1332        }
1333    }
1334
1335    #[test]
1336    fn filesystem_write_rejects_path_traversal_space_ids() {
1337        let temp = TempDir::new().unwrap();
1338        let filesystem = KibanaBundle::create(temp.path()).unwrap();
1339        let bundle = SyncBundle {
1340            by_space: std::collections::HashMap::from([(
1341                "../outside".to_string(),
1342                SpaceBundle::default(),
1343            )]),
1344            ..SyncBundle::default()
1345        };
1346
1347        let error = filesystem.write(&bundle).unwrap_err();
1348
1349        assert!(error.to_string().contains("invalid space id '../outside'"));
1350    }
1351}