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