1use 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
20pub trait BundleSource: sealed::Sealed {
24 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#[derive(Clone, Debug)]
49pub struct KibanaBundle<S> {
50 source: S,
51}
52
53#[derive(Clone, Debug)]
55pub struct Filesystem {
56 root: PathBuf,
57}
58
59#[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 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 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 pub fn root(&self) -> &Path {
102 &self.source.root
103 }
104
105 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 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 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 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}