1use 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
22pub trait BundleSource: sealed::Sealed {
26 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#[derive(Clone, Debug)]
51pub struct KibanaBundle<S> {
52 source: S,
53}
54
55#[derive(Clone, Debug)]
57pub struct Filesystem {
58 root: PathBuf,
59}
60
61#[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 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 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 pub fn root(&self) -> &Path {
104 &self.source.root
105 }
106
107 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 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 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 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}