Skip to main content

gate4agent_node/
bundle_catalog.rs

1use crate::protocol::{
2    DeliveryBlobDigestV1, DeliveryBundleManifestV2, DeliveryManifestDigestV2,
3    ResolvedBundleReceipt, SpawnBundleDigest, SpawnBundleId, SpawnBundleRevision,
4    MAX_LAUNCH_BUNDLES,
5};
6use ring::digest::{Context, SHA256};
7use serde_json::Value;
8use std::collections::{BTreeMap, BTreeSet};
9use std::fmt;
10use std::fs::{self, File, OpenOptions};
11use std::io::{self, Read};
12use std::path::{Component, Path, PathBuf};
13use thiserror::Error;
14
15pub const MAX_BUNDLE_CATALOG_ENTRIES: usize = MAX_LAUNCH_BUNDLES;
16pub const MAX_BUNDLE_FILES: usize = 128;
17pub const MAX_BUNDLE_FILE_BYTES: usize = 1024 * 1024;
18pub const MAX_BUNDLE_TOTAL_BYTES: usize = 32 * 1024 * 1024;
19pub const MAX_BUNDLE_PATH_BYTES: usize = 512;
20
21const AGENT_PLUGINS_SCHEMA: &str =
22    "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
23const MAX_SKILL_FRONTMATTER_BYTES: usize = 16 * 1024;
24const MAX_SKILL_NAME_CHARS: usize = 64;
25const MAX_SKILL_DESCRIPTION_CHARS: usize = 1024;
26const DIGEST_DOMAIN: &[u8] = b"g4a-bundle-v1\0";
27
28/// One immutable, normalized file captured from a validated bundle root.
29#[derive(Clone, Eq, PartialEq)]
30pub struct NodeBundleFile {
31    path: String,
32    bytes: Vec<u8>,
33}
34
35impl fmt::Debug for NodeBundleFile {
36    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
37        formatter
38            .debug_struct("NodeBundleFile")
39            .field("path", &self.path)
40            .field("byte_length", &self.bytes.len())
41            .finish()
42    }
43}
44
45impl NodeBundleFile {
46    pub fn path(&self) -> &str {
47        &self.path
48    }
49
50    pub fn bytes(&self) -> &[u8] {
51        &self.bytes
52    }
53}
54
55/// Immutable host-local bundle bytes and their public content receipt.
56#[derive(Clone, Eq, PartialEq)]
57pub struct NodeBundle {
58    id: SpawnBundleId,
59    revision: SpawnBundleRevision,
60    digest: SpawnBundleDigest,
61    files: Vec<NodeBundleFile>,
62    delivery_manifest: Option<DeliveryBundleManifestV2>,
63}
64
65impl fmt::Debug for NodeBundle {
66    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
67        formatter
68            .debug_struct("NodeBundle")
69            .field("id", &self.id)
70            .field("revision", &self.revision)
71            .field("digest", &self.digest)
72            .field("files", &self.files)
73            .field(
74                "delivery_manifest_digest",
75                &self
76                    .delivery_manifest
77                    .as_ref()
78                    .map(|manifest| &manifest.manifest_digest),
79            )
80            .finish()
81    }
82}
83
84impl NodeBundle {
85    pub fn new(
86        id: SpawnBundleId,
87        revision: SpawnBundleRevision,
88        expected_digest: SpawnBundleDigest,
89        root: impl AsRef<Path>,
90    ) -> Result<Self, NodeBundleError> {
91        let root = ProtectedBundleRoot::open(root.as_ref())?;
92
93        let mut scanner = BundleScanner::default();
94        scanner.scan_directory(&root, root.canonical(), &[])?;
95        root.verify_stable()?;
96        scanner.files.sort_by(|left, right| left.path.cmp(&right.path));
97        validate_bundle_contract(&scanner.files, &scanner.directories)?;
98
99        let actual_digest = digest_files(&scanner.files);
100        if actual_digest != expected_digest {
101            return Err(NodeBundleError::DigestMismatch {
102                expected: expected_digest,
103                actual: actual_digest,
104            });
105        }
106
107        Ok(Self {
108            id,
109            revision,
110            digest: actual_digest,
111            files: scanner.files,
112            delivery_manifest: None,
113        })
114    }
115
116    pub(crate) fn from_delivery(
117        manifest: DeliveryBundleManifestV2,
118        blobs: &BTreeMap<DeliveryBlobDigestV1, Vec<u8>>,
119    ) -> Result<Self, NodeBundleError> {
120        manifest
121            .validate()
122            .map_err(|_| NodeBundleError::InvalidDeliveryManifest)?;
123        let actual_manifest_digest = digest_delivery_manifest(&manifest);
124        if actual_manifest_digest != manifest.manifest_digest {
125            return Err(NodeBundleError::DeliveryManifestDigestMismatch);
126        }
127
128        let mut files = Vec::with_capacity(manifest.components.len());
129        for component in &manifest.components {
130            let bytes = blobs
131                .get(&component.blob.digest)
132                .ok_or(NodeBundleError::DeliveryBlobMissing)?;
133            if bytes.len() as u64 != component.blob.byte_len {
134                return Err(NodeBundleError::DeliveryBlobLengthMismatch);
135            }
136            if digest_delivery_blob(bytes) != component.blob.digest {
137                return Err(NodeBundleError::DeliveryBlobDigestMismatch);
138            }
139            if has_executable_shape(component.relative_path.as_str(), bytes) {
140                return Err(NodeBundleError::ExecutableFile {
141                    path: component.relative_path.as_str().to_owned(),
142                });
143            }
144            files.push(NodeBundleFile {
145                path: component.relative_path.as_str().to_owned(),
146                bytes: bytes.clone(),
147            });
148        }
149        let actual_bundle_digest = digest_files(&files);
150        if actual_bundle_digest != manifest.bundle_digest {
151            return Err(NodeBundleError::DigestMismatch {
152                expected: manifest.bundle_digest.clone(),
153                actual: actual_bundle_digest,
154            });
155        }
156
157        Ok(Self {
158            id: manifest.bundle_id.clone(),
159            revision: manifest.revision.clone(),
160            digest: manifest.bundle_digest.clone(),
161            files,
162            delivery_manifest: Some(manifest),
163        })
164    }
165
166    pub fn id(&self) -> &SpawnBundleId {
167        &self.id
168    }
169
170    pub fn revision(&self) -> &SpawnBundleRevision {
171        &self.revision
172    }
173
174    pub fn digest(&self) -> &SpawnBundleDigest {
175        &self.digest
176    }
177
178    pub fn files(&self) -> &[NodeBundleFile] {
179        &self.files
180    }
181
182    pub(crate) fn delivery_manifest(&self) -> Option<&DeliveryBundleManifestV2> {
183        self.delivery_manifest.as_ref()
184    }
185
186    pub(crate) fn validate_skill_bundle_contract(&self) -> Result<(), NodeBundleError> {
187        let directories = self
188            .files
189            .iter()
190            .filter_map(|file| Path::new(&file.path).parent())
191            .flat_map(|parent| {
192                let mut current = PathBuf::new();
193                parent
194                    .components()
195                    .filter_map(move |component| match component {
196                        Component::Normal(name) => {
197                            current.push(name);
198                            Some(current.to_string_lossy().replace('\\', "/"))
199                        }
200                        _ => None,
201                    })
202            })
203            .collect::<BTreeSet<_>>();
204        validate_bundle_contract(&self.files, &directories)
205    }
206
207    pub fn receipt(&self) -> ResolvedBundleReceipt {
208        ResolvedBundleReceipt {
209            id: self.id.clone(),
210            revision: self.revision.clone(),
211            digest: self.digest.clone(),
212        }
213    }
214}
215
216/// Bounded immutable lookup table for bundles installed before node startup.
217#[derive(Clone, Default, Eq, PartialEq)]
218pub struct BundleCatalog {
219    bundles: BTreeMap<SpawnBundleId, NodeBundle>,
220}
221
222impl fmt::Debug for BundleCatalog {
223    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
224        formatter
225            .debug_struct("BundleCatalog")
226            .field("bundles", &self.bundles.values().collect::<Vec<_>>())
227            .finish()
228    }
229}
230
231impl BundleCatalog {
232    pub fn new(
233        bundles: impl IntoIterator<Item = NodeBundle>,
234    ) -> Result<Self, BundleCatalogError> {
235        let mut catalog = BTreeMap::new();
236        for bundle in bundles {
237            if catalog.len() == MAX_BUNDLE_CATALOG_ENTRIES {
238                return Err(BundleCatalogError::TooMany {
239                    max: MAX_BUNDLE_CATALOG_ENTRIES,
240                });
241            }
242            let id = bundle.id.clone();
243            if catalog.insert(id.clone(), bundle).is_some() {
244                return Err(BundleCatalogError::Duplicate { id });
245            }
246        }
247        Ok(Self { bundles: catalog })
248    }
249
250    pub fn get(&self, id: &SpawnBundleId) -> Option<&NodeBundle> {
251        self.bundles.get(id)
252    }
253
254    pub fn iter(&self) -> impl ExactSizeIterator<Item = &NodeBundle> {
255        self.bundles.values()
256    }
257
258    pub(crate) fn insert_idempotent(
259        &mut self,
260        bundle: NodeBundle,
261    ) -> Result<bool, BundleCatalogError> {
262        if let Some(existing) = self.bundles.get(bundle.id()) {
263            if existing == &bundle {
264                return Ok(false);
265            }
266            return Err(BundleCatalogError::Conflict {
267                id: bundle.id().clone(),
268            });
269        }
270        if self.bundles.len() == MAX_BUNDLE_CATALOG_ENTRIES {
271            return Err(BundleCatalogError::TooMany {
272                max: MAX_BUNDLE_CATALOG_ENTRIES,
273            });
274        }
275        self.bundles.insert(bundle.id().clone(), bundle);
276        Ok(true)
277    }
278}
279
280/// Test-only preparation for local fixture bundle trees. Production callers
281/// must provision the same protection through their operator-owned startup
282/// path rather than mutating source permissions during bundle loading.
283#[cfg(feature = "fixture")]
284pub fn protect_bundle_source_tree_fixture(root: &Path) -> io::Result<()> {
285    protect_fixture_path(root)
286}
287
288#[cfg(feature = "fixture")]
289fn protect_fixture_path(path: &Path) -> io::Result<()> {
290    let metadata = fs::symlink_metadata(path)?;
291    if metadata.file_type().is_symlink() || is_reparse_point(&metadata) {
292        return Err(io::Error::new(
293            io::ErrorKind::PermissionDenied,
294            "fixture bundle source cannot contain links or reparse points",
295        ));
296    }
297    if metadata.is_dir() {
298        protect_fixture_permissions(path, true)?;
299        for entry in fs::read_dir(path)? {
300            protect_fixture_path(&entry?.path())?;
301        }
302        return Ok(());
303    }
304    if !metadata.is_file() {
305        return Err(io::Error::new(
306            io::ErrorKind::PermissionDenied,
307            "fixture bundle source must contain only regular files and directories",
308        ));
309    }
310    protect_fixture_permissions(path, false)
311}
312
313#[cfg(all(feature = "fixture", unix))]
314fn protect_fixture_permissions(path: &Path, directory: bool) -> io::Result<()> {
315    use std::os::unix::fs::{MetadataExt, PermissionsExt};
316    let metadata = fs::symlink_metadata(path)?;
317    if metadata.uid() != unsafe { libc::geteuid() } {
318        return Err(io::Error::new(
319            io::ErrorKind::PermissionDenied,
320            "fixture bundle source is not owned by the current user",
321        ));
322    }
323    let mode = if directory { 0o700 } else { 0o600 };
324    fs::set_permissions(path, fs::Permissions::from_mode(mode))
325}
326
327#[cfg(all(feature = "fixture", windows))]
328fn protect_fixture_permissions(path: &Path, _: bool) -> io::Result<()> {
329    windows_bundle_security::protect_owner_only(path)
330}
331
332#[cfg(all(feature = "fixture", not(any(unix, windows))))]
333fn protect_fixture_permissions(_: &Path, _: bool) -> io::Result<()> {
334    Err(io::Error::new(
335        io::ErrorKind::Unsupported,
336        "fixture bundle source protection is unsupported on this platform",
337    ))
338}
339
340#[derive(Clone, Debug, Eq, Error, PartialEq)]
341pub enum BundleCatalogError {
342    #[error("bundle catalog exceeds the {max}-bundle limit")]
343    TooMany { max: usize },
344    #[error("bundle catalog contains duplicate bundle {id}")]
345    Duplicate { id: SpawnBundleId },
346    #[error("bundle catalog contains conflicting content for bundle {id}")]
347    Conflict { id: SpawnBundleId },
348}
349
350#[derive(Debug, Error)]
351pub enum NodeBundleError {
352    #[error("bundle root must be an absolute path without dot or parent components")]
353    RootNotAbsolute,
354    #[error("bundle root is not a regular directory or is a symlink/reparse point")]
355    UnsafeRoot,
356    #[error("bundle root or entry is not protected from untrusted writes: {path:?}")]
357    InsecurePermissions { path: PathBuf },
358    #[error("bundle source changed identity while it was being captured: {path:?}")]
359    SourceChanged { path: PathBuf },
360    #[error("bundle source resolves outside its protected canonical root: {path:?}")]
361    EscapedRoot { path: PathBuf },
362    #[error("bundle path is not valid UTF-8: {path:?}")]
363    PathNotUtf8 { path: PathBuf },
364    #[error("bundle path exceeds the {max}-byte limit: {path}")]
365    PathTooLong { path: String, max: usize },
366    #[error("bundle path is not portable: {path}")]
367    UnsafePath { path: String },
368    #[error("bundle contains a case-folded path collision: {first} and {second}")]
369    CaseFoldCollision { first: String, second: String },
370    #[error("bundle contains a symlink, reparse point, or special file: {path}")]
371    UnsafeFileType { path: String },
372    #[error("bundle contains an executable file unsupported by the skills-only floor: {path}")]
373    ExecutableFile { path: String },
374    #[error("bundle exceeds the {max}-file limit")]
375    TooManyFiles { max: usize },
376    #[error("bundle file {path} exceeds the {max}-byte limit")]
377    FileTooLarge { path: String, max: usize },
378    #[error("bundle exceeds the {max}-byte total limit")]
379    TotalTooLarge { max: usize },
380    #[error("bundle root mcp.json is unsupported in the F6.1 skills-only floor")]
381    McpUnsupported,
382    #[error("bundle contains an unsupported root entry: {path}")]
383    UnsupportedRoot { path: String },
384    #[error("bundle requires a regular UTF-8 root plugin.json")]
385    MissingManifest,
386    #[error("bundle plugin.json is invalid: {reason}")]
387    InvalidManifest { reason: &'static str },
388    #[error("bundle .claude-plugin/plugin.json is invalid: {reason}")]
389    InvalidClaudeManifest { reason: &'static str },
390    #[error("bundle requires at least one skills/<name>/SKILL.md component")]
391    MissingSkills,
392    #[error("bundle skill {path} is invalid: {reason}")]
393    InvalidSkill { path: String, reason: &'static str },
394    #[error("bundle digest mismatch: expected {expected}, captured {actual}")]
395    DigestMismatch {
396        expected: SpawnBundleDigest,
397        actual: SpawnBundleDigest,
398    },
399    #[error("delivery manifest is invalid")]
400    InvalidDeliveryManifest,
401    #[error("delivery manifest digest mismatch")]
402    DeliveryManifestDigestMismatch,
403    #[error("delivery blob is missing")]
404    DeliveryBlobMissing,
405    #[error("delivery blob length mismatch")]
406    DeliveryBlobLengthMismatch,
407    #[error("delivery blob digest mismatch")]
408    DeliveryBlobDigestMismatch,
409    #[error("bundle filesystem operation failed at {path:?}: {source}")]
410    Io {
411        path: PathBuf,
412        #[source]
413        source: io::Error,
414    },
415}
416
417#[derive(Default)]
418struct BundleScanner {
419    files: Vec<NodeBundleFile>,
420    directories: BTreeSet<String>,
421    folded_paths: BTreeMap<String, String>,
422    total_bytes: usize,
423}
424
425impl BundleScanner {
426    fn scan_directory(
427        &mut self,
428        root: &ProtectedBundleRoot,
429        absolute: &Path,
430        relative_components: &[String],
431    ) -> Result<(), NodeBundleError> {
432        let directory = open_verified_path(root.canonical(), absolute, true)?;
433        let entries = fs::read_dir(absolute).map_err(|source| NodeBundleError::Io {
434            path: absolute.to_path_buf(),
435            source,
436        })?;
437        for entry in entries {
438            let entry = entry.map_err(|source| NodeBundleError::Io {
439                path: absolute.to_path_buf(),
440                source,
441            })?;
442            let component = entry.file_name().into_string().map_err(|name| {
443                NodeBundleError::PathNotUtf8 {
444                    path: PathBuf::from(name),
445                }
446            })?;
447            validate_component(&component)?;
448
449            let mut components = relative_components.to_vec();
450            components.push(component);
451            let relative = components.join("/");
452            if relative.as_bytes().len() > MAX_BUNDLE_PATH_BYTES {
453                return Err(NodeBundleError::PathTooLong {
454                    path: relative,
455                    max: MAX_BUNDLE_PATH_BYTES,
456                });
457            }
458            self.record_folded_path(&relative)?;
459
460            let absolute_entry = entry.path();
461            let metadata = fs::symlink_metadata(&absolute_entry).map_err(|source| {
462                NodeBundleError::Io {
463                    path: absolute_entry.clone(),
464                    source,
465                }
466            })?;
467            if metadata.file_type().is_symlink() || is_reparse_point(&metadata) {
468                return Err(NodeBundleError::UnsafeFileType { path: relative });
469            }
470            if metadata.is_dir() {
471                validate_directory_location(&relative, &components)?;
472                self.directories.insert(relative);
473                self.scan_directory(root, &absolute_entry, &components)?;
474            } else if metadata.is_file() {
475                validate_file_location(&relative, &components)?;
476                if self.files.len() == MAX_BUNDLE_FILES {
477                    return Err(NodeBundleError::TooManyFiles {
478                        max: MAX_BUNDLE_FILES,
479                    });
480                }
481                if metadata.len() > MAX_BUNDLE_FILE_BYTES as u64 {
482                    return Err(NodeBundleError::FileTooLarge {
483                        path: relative,
484                        max: MAX_BUNDLE_FILE_BYTES,
485                    });
486                }
487                let captured = read_regular_no_follow(
488                    root.canonical(),
489                    &absolute_entry,
490                    &relative,
491                )?;
492                if is_executable(&captured.metadata, &relative, &captured.bytes) {
493                    return Err(NodeBundleError::ExecutableFile { path: relative });
494                }
495                self.total_bytes = self.total_bytes.checked_add(captured.bytes.len()).ok_or(
496                    NodeBundleError::TotalTooLarge {
497                        max: MAX_BUNDLE_TOTAL_BYTES,
498                    },
499                )?;
500                if self.total_bytes > MAX_BUNDLE_TOTAL_BYTES {
501                    return Err(NodeBundleError::TotalTooLarge {
502                        max: MAX_BUNDLE_TOTAL_BYTES,
503                    });
504                }
505                self.files.push(NodeBundleFile {
506                    path: relative,
507                    bytes: captured.bytes,
508                });
509            } else {
510                return Err(NodeBundleError::UnsafeFileType { path: relative });
511            }
512        }
513        verify_opened_path(root.canonical(), absolute, &directory)?;
514        Ok(())
515    }
516
517    fn record_folded_path(&mut self, path: &str) -> Result<(), NodeBundleError> {
518        let folded = path.chars().flat_map(char::to_lowercase).collect::<String>();
519        if let Some(existing) = self.folded_paths.insert(folded, path.to_owned()) {
520            if existing != path {
521                return Err(NodeBundleError::CaseFoldCollision {
522                    first: existing,
523                    second: path.to_owned(),
524                });
525            }
526        }
527        Ok(())
528    }
529}
530
531struct ProtectedBundleRoot {
532    canonical: PathBuf,
533    opened: OpenedPath,
534}
535
536impl ProtectedBundleRoot {
537    fn open(root: &Path) -> Result<Self, NodeBundleError> {
538        validate_absolute_root(root)?;
539        reject_reparse_ancestors(root)?;
540        let canonical = fs::canonicalize(root).map_err(|source| NodeBundleError::Io {
541            path: root.to_path_buf(),
542            source,
543        })?;
544        reject_reparse_ancestors(&canonical)?;
545        let opened = open_verified_path(&canonical, &canonical, true)?;
546        let protected = Self { canonical, opened };
547        protected.verify_stable()?;
548        Ok(protected)
549    }
550
551    fn canonical(&self) -> &Path {
552        &self.canonical
553    }
554
555    fn verify_stable(&self) -> Result<(), NodeBundleError> {
556        verify_opened_path(&self.canonical, &self.canonical, &self.opened)
557    }
558}
559
560#[derive(Clone, Copy, Debug, Eq, PartialEq)]
561struct PathIdentity {
562    first: u64,
563    second: u64,
564}
565
566struct OpenedPath {
567    file: File,
568    metadata: fs::Metadata,
569    identity: PathIdentity,
570}
571
572fn validate_absolute_root(root: &Path) -> Result<(), NodeBundleError> {
573    if !root.is_absolute()
574        || root.components().any(|component| {
575            matches!(component, Component::CurDir | Component::ParentDir)
576        })
577    {
578        return Err(NodeBundleError::RootNotAbsolute);
579    }
580    Ok(())
581}
582
583fn reject_reparse_ancestors(path: &Path) -> Result<(), NodeBundleError> {
584    for ancestor in path.ancestors() {
585        let metadata = fs::symlink_metadata(ancestor).map_err(|source| NodeBundleError::Io {
586            path: ancestor.to_path_buf(),
587            source,
588        })?;
589        if metadata.file_type().is_symlink() || is_reparse_point(&metadata) {
590            return Err(NodeBundleError::UnsafeRoot);
591        }
592    }
593    Ok(())
594}
595
596fn open_verified_path(
597    canonical_root: &Path,
598    path: &Path,
599    directory: bool,
600) -> Result<OpenedPath, NodeBundleError> {
601    let path_metadata = fs::symlink_metadata(path).map_err(|source| NodeBundleError::Io {
602        path: path.to_path_buf(),
603        source,
604    })?;
605    if path_metadata.file_type().is_symlink() || is_reparse_point(&path_metadata) {
606        return Err(NodeBundleError::UnsafeFileType {
607            path: path.to_string_lossy().into_owned(),
608        });
609    }
610
611    let file = open_path_no_follow(path, directory).map_err(|source| NodeBundleError::Io {
612        path: path.to_path_buf(),
613        source,
614    })?;
615    let metadata = file.metadata().map_err(|source| NodeBundleError::Io {
616        path: path.to_path_buf(),
617        source,
618    })?;
619    if metadata.file_type().is_symlink()
620        || is_reparse_point(&metadata)
621        || metadata.is_dir() != directory
622        || metadata.is_file() == directory
623    {
624        return Err(NodeBundleError::UnsafeFileType {
625            path: path.to_string_lossy().into_owned(),
626        });
627    }
628    validate_source_permissions(&file, &metadata, path)?;
629    let final_path = fs::canonicalize(path).map_err(|source| NodeBundleError::Io {
630        path: path.to_path_buf(),
631        source,
632    })?;
633    if final_path != canonical_root && !final_path.starts_with(canonical_root) {
634        return Err(NodeBundleError::EscapedRoot {
635            path: path.to_path_buf(),
636        });
637    }
638    let identity = path_identity(&file, &metadata).map_err(|source| NodeBundleError::Io {
639        path: path.to_path_buf(),
640        source,
641    })?;
642    Ok(OpenedPath {
643        file,
644        metadata,
645        identity,
646    })
647}
648
649fn verify_opened_path(
650    canonical_root: &Path,
651    path: &Path,
652    original: &OpenedPath,
653) -> Result<(), NodeBundleError> {
654    let current = open_verified_path(canonical_root, path, original.metadata.is_dir())?;
655    if current.identity != original.identity {
656        return Err(NodeBundleError::SourceChanged {
657            path: path.to_path_buf(),
658        });
659    }
660    Ok(())
661}
662
663fn validate_component(component: &str) -> Result<(), NodeBundleError> {
664    let invalid_character = component.chars().any(|character| {
665        character <= '\u{1f}'
666            || matches!(character, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*')
667    });
668    let stem = component.split('.').next().unwrap_or(component);
669    let uppercase_stem = stem.to_ascii_uppercase();
670    let reserved = matches!(uppercase_stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
671        || reserved_numbered_name(&uppercase_stem, "COM")
672        || reserved_numbered_name(&uppercase_stem, "LPT");
673    if component.is_empty()
674        || component == "."
675        || component == ".."
676        || component.ends_with('.')
677        || component.ends_with(' ')
678        || invalid_character
679        || reserved
680    {
681        return Err(NodeBundleError::UnsafePath {
682            path: component.to_owned(),
683        });
684    }
685    Ok(())
686}
687
688fn reserved_numbered_name(value: &str, prefix: &str) -> bool {
689    value.strip_prefix(prefix).is_some_and(|suffix| {
690        suffix.len() == 1 && matches!(suffix.as_bytes()[0], b'1'..=b'9')
691    })
692}
693
694fn validate_directory_location(
695    relative: &str,
696    components: &[String],
697) -> Result<(), NodeBundleError> {
698    match components {
699        [root] if root == "skills" || root == ".claude-plugin" => Ok(()),
700        [root, skill] if root == "skills" && valid_skill_name(skill) => Ok(()),
701        [root, _, _, ..] if root == "skills" => Ok(()),
702        [root, ..] if root == ".claude-plugin" => {
703            Err(NodeBundleError::UnsupportedRoot {
704                path: relative.to_owned(),
705            })
706        }
707        _ => Err(NodeBundleError::UnsupportedRoot {
708            path: relative.to_owned(),
709        }),
710    }
711}
712
713fn validate_file_location(
714    relative: &str,
715    components: &[String],
716) -> Result<(), NodeBundleError> {
717    match components {
718        [file] if file == "plugin.json" => Ok(()),
719        [file] if file == "mcp.json" => Err(NodeBundleError::McpUnsupported),
720        [root, file] if root == ".claude-plugin" && file == "plugin.json" => Ok(()),
721        [root, _, _, ..] if root == "skills" => Ok(()),
722        _ => Err(NodeBundleError::UnsupportedRoot {
723            path: relative.to_owned(),
724        }),
725    }
726}
727
728fn validate_bundle_contract(
729    files: &[NodeBundleFile],
730    directories: &BTreeSet<String>,
731) -> Result<(), NodeBundleError> {
732    let by_path = files
733        .iter()
734        .map(|file| (file.path.as_str(), file))
735        .collect::<BTreeMap<_, _>>();
736    let manifest = by_path
737        .get("plugin.json")
738        .ok_or(NodeBundleError::MissingManifest)?;
739    validate_manifest(&manifest.bytes)?;
740
741    if let Some(manifest) = by_path.get(".claude-plugin/plugin.json") {
742        validate_claude_manifest(&manifest.bytes)?;
743    } else if directories.contains(".claude-plugin") {
744        return Err(NodeBundleError::InvalidClaudeManifest {
745            reason: "manifest file is missing",
746        });
747    }
748
749    let skill_directories = directories
750        .iter()
751        .filter_map(|path| path.strip_prefix("skills/"))
752        .filter(|path| !path.contains('/'))
753        .collect::<Vec<_>>();
754    if skill_directories.is_empty() {
755        return Err(NodeBundleError::MissingSkills);
756    }
757    for skill_name in skill_directories {
758        let path = format!("skills/{skill_name}/SKILL.md");
759        let skill = by_path.get(path.as_str()).ok_or_else(|| {
760            NodeBundleError::InvalidSkill {
761                path: path.clone(),
762                reason: "required SKILL.md file is missing",
763            }
764        })?;
765        validate_skill(&path, skill_name, &skill.bytes)?;
766    }
767    Ok(())
768}
769
770fn validate_manifest(bytes: &[u8]) -> Result<(), NodeBundleError> {
771    let text = std::str::from_utf8(bytes).map_err(|_| NodeBundleError::InvalidManifest {
772        reason: "manifest is not UTF-8",
773    })?;
774    let value: Value = serde_json::from_str(text).map_err(|_| {
775        NodeBundleError::InvalidManifest {
776            reason: "manifest is not valid JSON",
777        }
778    })?;
779    let object = value.as_object().ok_or(NodeBundleError::InvalidManifest {
780        reason: "manifest must be an object",
781    })?;
782    if object.len() != 2 || !object.contains_key("$schema") || !object.contains_key("name") {
783        return Err(NodeBundleError::InvalidManifest {
784            reason: "skills-only manifest permits exactly $schema and name",
785        });
786    }
787    if object.get("$schema").and_then(Value::as_str) != Some(AGENT_PLUGINS_SCHEMA) {
788        return Err(NodeBundleError::InvalidManifest {
789            reason: "unsupported Agent Plugins schema",
790        });
791    }
792    let name = object
793        .get("name")
794        .and_then(Value::as_str)
795        .ok_or(NodeBundleError::InvalidManifest {
796            reason: "name must be a string",
797        })?;
798    if !valid_plugin_name(name) {
799        return Err(NodeBundleError::InvalidManifest {
800            reason: "name violates Agent Plugins 1.0.0 constraints",
801        });
802    }
803    Ok(())
804}
805
806fn validate_claude_manifest(bytes: &[u8]) -> Result<(), NodeBundleError> {
807    let text = std::str::from_utf8(bytes).map_err(|_| {
808        NodeBundleError::InvalidClaudeManifest {
809            reason: "manifest is not UTF-8",
810        }
811    })?;
812    let value: Value = serde_json::from_str(text).map_err(|_| {
813        NodeBundleError::InvalidClaudeManifest {
814            reason: "manifest is not valid JSON",
815        }
816    })?;
817    let object = value.as_object().ok_or(NodeBundleError::InvalidClaudeManifest {
818        reason: "manifest must be an object",
819    })?;
820    if !object.contains_key("name")
821        || object.keys().any(|key| {
822            !matches!(key.as_str(), "name" | "version" | "description")
823        })
824    {
825        return Err(NodeBundleError::InvalidClaudeManifest {
826            reason: "manifest permits only name, version, and description",
827        });
828    }
829    for value in object.values() {
830        let value = value.as_str().ok_or(NodeBundleError::InvalidClaudeManifest {
831            reason: "manifest fields must be strings",
832        })?;
833        if value.is_empty() {
834            return Err(NodeBundleError::InvalidClaudeManifest {
835                reason: "manifest fields must not be empty",
836            });
837        }
838    }
839    Ok(())
840}
841
842fn valid_plugin_name(name: &str) -> bool {
843    let bytes = name.as_bytes();
844    !bytes.is_empty()
845        && name.chars().count() <= 64
846        && bytes[0].is_ascii_alphanumeric()
847        && bytes[bytes.len() - 1].is_ascii_alphanumeric()
848        && bytes
849            .iter()
850            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'.'))
851        && !name.contains("--")
852        && !name.contains("..")
853}
854
855fn valid_skill_name(name: &str) -> bool {
856    let bytes = name.as_bytes();
857    !bytes.is_empty()
858        && name.chars().count() <= MAX_SKILL_NAME_CHARS
859        && bytes[0] != b'-'
860        && bytes[bytes.len() - 1] != b'-'
861        && bytes
862            .iter()
863            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-')
864        && !name.contains("--")
865}
866
867fn validate_skill(path: &str, directory_name: &str, bytes: &[u8]) -> Result<(), NodeBundleError> {
868    let text = std::str::from_utf8(bytes).map_err(|_| NodeBundleError::InvalidSkill {
869        path: path.to_owned(),
870        reason: "SKILL.md is not UTF-8",
871    })?;
872    let frontmatter = extract_frontmatter(text).ok_or_else(|| NodeBundleError::InvalidSkill {
873        path: path.to_owned(),
874        reason: "bounded YAML frontmatter is missing",
875    })?;
876    let fields = parse_frontmatter_fields(frontmatter).map_err(|reason| {
877        NodeBundleError::InvalidSkill {
878            path: path.to_owned(),
879            reason,
880        }
881    })?;
882    let name = fields.name.ok_or_else(|| NodeBundleError::InvalidSkill {
883        path: path.to_owned(),
884        reason: "frontmatter name is missing",
885    })?;
886    let description = fields.description.ok_or_else(|| NodeBundleError::InvalidSkill {
887        path: path.to_owned(),
888        reason: "frontmatter description is missing",
889    })?;
890    if !valid_skill_name(&name) || name != directory_name {
891        return Err(NodeBundleError::InvalidSkill {
892            path: path.to_owned(),
893            reason: "frontmatter name must be valid and match its directory",
894        });
895    }
896    if description.trim().is_empty()
897        || description.chars().count() > MAX_SKILL_DESCRIPTION_CHARS
898    {
899        return Err(NodeBundleError::InvalidSkill {
900            path: path.to_owned(),
901            reason: "description must contain 1-1024 characters",
902        });
903    }
904    Ok(())
905}
906
907fn extract_frontmatter(text: &str) -> Option<&str> {
908    let (opening, remainder) = next_line(text)?;
909    if opening != "---" {
910        return None;
911    }
912    let mut consumed = 0usize;
913    let mut cursor = remainder;
914    loop {
915        if cursor.is_empty() {
916            return None;
917        }
918        let (line, rest) = next_line(cursor)?;
919        if line == "---" {
920            return (consumed <= MAX_SKILL_FRONTMATTER_BYTES).then_some(
921                &remainder[..remainder.len() - cursor.len()],
922            );
923        }
924        consumed = consumed.checked_add(cursor.len() - rest.len())?;
925        if consumed > MAX_SKILL_FRONTMATTER_BYTES {
926            return None;
927        }
928        cursor = rest;
929    }
930}
931
932fn next_line(text: &str) -> Option<(&str, &str)> {
933    if let Some(index) = text.find('\n') {
934        let line = text[..index].strip_suffix('\r').unwrap_or(&text[..index]);
935        Some((line, &text[index + 1..]))
936    } else {
937        Some((text.strip_suffix('\r').unwrap_or(text), ""))
938    }
939}
940
941#[derive(Default)]
942struct SkillFields {
943    name: Option<String>,
944    description: Option<String>,
945}
946
947fn parse_frontmatter_fields(frontmatter: &str) -> Result<SkillFields, &'static str> {
948    let lines = frontmatter.lines().collect::<Vec<_>>();
949    let mut fields = SkillFields::default();
950    let mut index = 0usize;
951    while index < lines.len() {
952        let line = lines[index].strip_suffix('\r').unwrap_or(lines[index]);
953        if line.contains('\t') {
954            return Err("frontmatter tabs are unsupported");
955        }
956        if line.trim().is_empty() || line.trim_start().starts_with('#') || line.starts_with(' ') {
957            index += 1;
958            continue;
959        }
960        let (key, raw_value) = line
961            .split_once(':')
962            .ok_or("frontmatter top-level entries must be mappings")?;
963        let key = key.trim();
964        let raw_value = raw_value.trim();
965        if key == "name" || key == "description" {
966            let (value, consumed_lines) = if matches!(raw_value, "|" | "|-" | "|+" | ">" | ">-" | ">+") {
967                parse_block_scalar(&lines[index + 1..], raw_value.starts_with('>'))
968            } else {
969                (parse_yaml_scalar(raw_value)?, 0)
970            };
971            let destination = if key == "name" {
972                &mut fields.name
973            } else {
974                &mut fields.description
975            };
976            if destination.replace(value).is_some() {
977                return Err("frontmatter contains a duplicate required field");
978            }
979            index += consumed_lines;
980        }
981        index += 1;
982    }
983    Ok(fields)
984}
985
986fn parse_block_scalar(lines: &[&str], folded: bool) -> (String, usize) {
987    let mut values = Vec::new();
988    for line in lines {
989        if !line.starts_with(' ') && !line.trim().is_empty() {
990            break;
991        }
992        values.push(line.trim().to_owned());
993    }
994    let value = if folded {
995        values.join(" ")
996    } else {
997        values.join("\n")
998    };
999    (value, values.len())
1000}
1001
1002fn parse_yaml_scalar(value: &str) -> Result<String, &'static str> {
1003    if value.is_empty() {
1004        return Ok(String::new());
1005    }
1006    if value.starts_with('"') {
1007        return serde_json::from_str::<String>(value)
1008            .map_err(|_| "frontmatter contains an invalid quoted scalar");
1009    }
1010    if value.starts_with('\'') {
1011        if value.len() < 2 || !value.ends_with('\'') {
1012            return Err("frontmatter contains an invalid quoted scalar");
1013        }
1014        return Ok(value[1..value.len() - 1].replace("''", "'"));
1015    }
1016    let without_comment = value.split_once(" #").map_or(value, |(value, _)| value);
1017    Ok(without_comment.trim().to_owned())
1018}
1019
1020struct CapturedFile {
1021    bytes: Vec<u8>,
1022    metadata: fs::Metadata,
1023}
1024
1025fn read_regular_no_follow(
1026    canonical_root: &Path,
1027    path: &Path,
1028    relative: &str,
1029) -> Result<CapturedFile, NodeBundleError> {
1030    let mut opened = open_verified_path(canonical_root, path, false)?;
1031    if opened.metadata.len() > MAX_BUNDLE_FILE_BYTES as u64 {
1032        return Err(NodeBundleError::FileTooLarge {
1033            path: relative.to_owned(),
1034            max: MAX_BUNDLE_FILE_BYTES,
1035        });
1036    }
1037    let mut bytes = Vec::with_capacity(opened.metadata.len() as usize);
1038    (&mut opened.file)
1039        .take(MAX_BUNDLE_FILE_BYTES as u64 + 1)
1040        .read_to_end(&mut bytes)
1041        .map_err(|source| NodeBundleError::Io {
1042            path: path.to_path_buf(),
1043            source,
1044        })?;
1045    if bytes.len() > MAX_BUNDLE_FILE_BYTES {
1046        return Err(NodeBundleError::FileTooLarge {
1047            path: relative.to_owned(),
1048            max: MAX_BUNDLE_FILE_BYTES,
1049        });
1050    }
1051    if bytes.len() as u64 != opened.metadata.len() {
1052        return Err(NodeBundleError::SourceChanged {
1053            path: path.to_path_buf(),
1054        });
1055    }
1056    verify_opened_path(canonical_root, path, &opened)?;
1057    Ok(CapturedFile {
1058        bytes,
1059        metadata: opened.metadata,
1060    })
1061}
1062
1063fn open_path_no_follow(path: &Path, directory: bool) -> io::Result<File> {
1064    let mut options = OpenOptions::new();
1065    options.read(true);
1066    set_no_follow(&mut options, directory);
1067    options.open(path)
1068}
1069
1070#[cfg(unix)]
1071fn set_no_follow(options: &mut OpenOptions, directory: bool) {
1072    use std::os::unix::fs::OpenOptionsExt;
1073    let directory_flag = if directory { libc::O_DIRECTORY } else { 0 };
1074    options.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | directory_flag);
1075}
1076
1077#[cfg(windows)]
1078fn set_no_follow(options: &mut OpenOptions, directory: bool) {
1079    use std::os::windows::fs::OpenOptionsExt;
1080    use windows_sys::Win32::Storage::FileSystem::{
1081        FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ,
1082    };
1083    let directory_flag = if directory { FILE_FLAG_BACKUP_SEMANTICS } else { 0 };
1084    options
1085        .share_mode(FILE_SHARE_READ)
1086        .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT | directory_flag);
1087}
1088
1089#[cfg(not(any(unix, windows)))]
1090fn set_no_follow(_: &mut OpenOptions, _: bool) {}
1091
1092#[cfg(unix)]
1093fn path_identity(_: &File, metadata: &fs::Metadata) -> io::Result<PathIdentity> {
1094    use std::os::unix::fs::MetadataExt;
1095    Ok(PathIdentity {
1096        first: metadata.dev(),
1097        second: metadata.ino(),
1098    })
1099}
1100
1101#[cfg(windows)]
1102fn path_identity(file: &File, _: &fs::Metadata) -> io::Result<PathIdentity> {
1103    use std::os::windows::io::AsRawHandle;
1104    use windows_sys::Win32::Storage::FileSystem::{
1105        GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION,
1106    };
1107    let mut information = BY_HANDLE_FILE_INFORMATION::default();
1108    if unsafe {
1109        GetFileInformationByHandle(file.as_raw_handle() as _, &mut information)
1110    } == 0
1111    {
1112        return Err(io::Error::last_os_error());
1113    }
1114    Ok(PathIdentity {
1115        first: information.dwVolumeSerialNumber as u64,
1116        second: ((information.nFileIndexHigh as u64) << 32)
1117            | information.nFileIndexLow as u64,
1118    })
1119}
1120
1121#[cfg(not(any(unix, windows)))]
1122fn path_identity(_: &File, metadata: &fs::Metadata) -> io::Result<PathIdentity> {
1123    Ok(PathIdentity {
1124        first: metadata.len(),
1125        second: 0,
1126    })
1127}
1128
1129#[cfg(unix)]
1130fn validate_source_permissions(
1131    _: &File,
1132    metadata: &fs::Metadata,
1133    path: &Path,
1134) -> Result<(), NodeBundleError> {
1135    use std::os::unix::fs::{MetadataExt, PermissionsExt};
1136    if metadata.uid() != unsafe { libc::geteuid() }
1137        || metadata.permissions().mode() & 0o022 != 0
1138    {
1139        return Err(NodeBundleError::InsecurePermissions {
1140            path: path.to_path_buf(),
1141        });
1142    }
1143    Ok(())
1144}
1145
1146#[cfg(windows)]
1147fn validate_source_permissions(
1148    file: &File,
1149    _: &fs::Metadata,
1150    path: &Path,
1151) -> Result<(), NodeBundleError> {
1152    windows_bundle_security::validate_owner_only(file).map_err(|_| {
1153        NodeBundleError::InsecurePermissions {
1154            path: path.to_path_buf(),
1155        }
1156    })
1157}
1158
1159#[cfg(not(any(unix, windows)))]
1160fn validate_source_permissions(
1161    _: &File,
1162    _: &fs::Metadata,
1163    path: &Path,
1164) -> Result<(), NodeBundleError> {
1165    Err(NodeBundleError::InsecurePermissions {
1166        path: path.to_path_buf(),
1167    })
1168}
1169
1170#[cfg(windows)]
1171mod windows_bundle_security {
1172    use super::*;
1173    use std::os::windows::io::AsRawHandle;
1174    use windows_sys::Win32::Foundation::LocalFree;
1175    use windows_sys::Win32::Security::Authorization::{GetSecurityInfo, SE_FILE_OBJECT};
1176    use windows_sys::Win32::Security::{
1177        CreateWellKnownSid, EqualSid, GetAce, GetAclInformation,
1178        GetSecurityDescriptorControl, ACCESS_ALLOWED_ACE, ACL_SIZE_INFORMATION,
1179        AclSizeInformation, DACL_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION,
1180        SECURITY_MAX_SID_SIZE, SE_DACL_PROTECTED, WinCreatorOwnerRightsSid,
1181    };
1182    use windows_sys::Win32::Storage::FileSystem::FILE_ALL_ACCESS;
1183
1184    pub(super) fn validate_owner_only(file: &File) -> io::Result<()> {
1185        let mut owner = std::ptr::null_mut();
1186        let mut dacl = std::ptr::null_mut();
1187        let mut descriptor = std::ptr::null_mut();
1188        let status = unsafe {
1189            GetSecurityInfo(
1190                file.as_raw_handle() as _,
1191                SE_FILE_OBJECT,
1192                OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION,
1193                &mut owner,
1194                std::ptr::null_mut(),
1195                &mut dacl,
1196                std::ptr::null_mut(),
1197                &mut descriptor,
1198            )
1199        };
1200        if status != 0 {
1201            return Err(io::Error::from_raw_os_error(status as i32));
1202        }
1203        let result = (|| {
1204            if owner.is_null() || dacl.is_null() {
1205                return Err(insecure("bundle DACL is missing"));
1206            }
1207            let mut control = 0u16;
1208            let mut revision = 0u32;
1209            if unsafe {
1210                GetSecurityDescriptorControl(descriptor, &mut control, &mut revision)
1211            } == 0
1212                || control & SE_DACL_PROTECTED == 0
1213            {
1214                return Err(insecure("bundle DACL is not protected"));
1215            }
1216            let mut information = ACL_SIZE_INFORMATION::default();
1217            if unsafe {
1218                GetAclInformation(
1219                    dacl,
1220                    &mut information as *mut _ as *mut _,
1221                    std::mem::size_of::<ACL_SIZE_INFORMATION>() as u32,
1222                    AclSizeInformation,
1223                )
1224            } == 0
1225                || information.AceCount != 1
1226            {
1227                return Err(insecure("bundle DACL is not owner-only"));
1228            }
1229            let mut ace = std::ptr::null_mut();
1230            if unsafe { GetAce(dacl, 0, &mut ace) } == 0 || ace.is_null() {
1231                return Err(io::Error::last_os_error());
1232            }
1233            let allowed = unsafe { &*(ace as *const ACCESS_ALLOWED_ACE) };
1234            let sid = &allowed.SidStart as *const u32 as *mut _;
1235            let mut owner_rights = [0u8; SECURITY_MAX_SID_SIZE as usize];
1236            let mut owner_rights_len = owner_rights.len() as u32;
1237            if unsafe {
1238                CreateWellKnownSid(
1239                    WinCreatorOwnerRightsSid,
1240                    std::ptr::null_mut(),
1241                    owner_rights.as_mut_ptr() as *mut _,
1242                    &mut owner_rights_len,
1243                )
1244            } == 0
1245            {
1246                return Err(io::Error::last_os_error());
1247            }
1248            if allowed.Header.AceType != 0
1249                || allowed.Header.AceFlags != 0
1250                || allowed.Mask != FILE_ALL_ACCESS
1251                || (unsafe { EqualSid(owner, sid) } == 0
1252                    && unsafe {
1253                        EqualSid(owner_rights.as_mut_ptr() as *mut _, sid)
1254                    } == 0)
1255            {
1256                return Err(insecure("bundle DACL is not owner-only"));
1257            }
1258            Ok(())
1259        })();
1260        unsafe {
1261            LocalFree(descriptor);
1262        }
1263        result
1264    }
1265
1266    fn insecure(message: &'static str) -> io::Error {
1267        io::Error::new(io::ErrorKind::PermissionDenied, message)
1268    }
1269
1270    #[cfg(any(test, feature = "fixture"))]
1271    pub(super) fn protect_owner_only(path: &Path) -> io::Result<()> {
1272        set_dacl(path, "D:P(A;;FA;;;OW)")
1273    }
1274
1275    #[cfg(test)]
1276    pub(super) fn make_world_writable_for_test(path: &Path) -> io::Result<()> {
1277        set_dacl(path, "D:P(A;;FA;;;WD)")
1278    }
1279
1280    #[cfg(any(test, feature = "fixture"))]
1281    fn set_dacl(path: &Path, descriptor_text: &str) -> io::Result<()> {
1282        use std::ffi::OsStr;
1283        use std::os::windows::ffi::OsStrExt;
1284        use windows_sys::Win32::Security::Authorization::{
1285            ConvertStringSecurityDescriptorToSecurityDescriptorW,
1286            SetNamedSecurityInfoW, SDDL_REVISION_1,
1287        };
1288        use windows_sys::Win32::Security::{
1289            GetSecurityDescriptorDacl, PROTECTED_DACL_SECURITY_INFORMATION,
1290        };
1291
1292        let sddl = OsStr::new(descriptor_text)
1293            .encode_wide()
1294            .chain(std::iter::once(0))
1295            .collect::<Vec<_>>();
1296        let mut descriptor = std::ptr::null_mut();
1297        if unsafe {
1298            ConvertStringSecurityDescriptorToSecurityDescriptorW(
1299                sddl.as_ptr(),
1300                SDDL_REVISION_1,
1301                &mut descriptor,
1302                std::ptr::null_mut(),
1303            )
1304        } == 0
1305        {
1306            return Err(io::Error::last_os_error());
1307        }
1308        let result = (|| {
1309            let mut present = 0i32;
1310            let mut defaulted = 0i32;
1311            let mut dacl = std::ptr::null_mut();
1312            if unsafe {
1313                GetSecurityDescriptorDacl(
1314                    descriptor,
1315                    &mut present,
1316                    &mut dacl,
1317                    &mut defaulted,
1318                )
1319            } == 0
1320                || present == 0
1321                || dacl.is_null()
1322            {
1323                return Err(io::Error::last_os_error());
1324            }
1325            let mut wide = path
1326                .as_os_str()
1327                .encode_wide()
1328                .chain(std::iter::once(0))
1329                .collect::<Vec<_>>();
1330            let status = unsafe {
1331                SetNamedSecurityInfoW(
1332                    wide.as_mut_ptr(),
1333                    SE_FILE_OBJECT,
1334                    DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
1335                    std::ptr::null_mut(),
1336                    std::ptr::null_mut(),
1337                    dacl,
1338                    std::ptr::null_mut(),
1339                )
1340            };
1341            if status != 0 {
1342                return Err(io::Error::from_raw_os_error(status as i32));
1343            }
1344            Ok(())
1345        })();
1346        unsafe {
1347            LocalFree(descriptor);
1348        }
1349        result
1350    }
1351}
1352
1353#[cfg(windows)]
1354fn is_reparse_point(metadata: &fs::Metadata) -> bool {
1355    use std::os::windows::fs::MetadataExt;
1356    metadata.file_attributes()
1357        & windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT
1358        != 0
1359}
1360
1361#[cfg(not(windows))]
1362fn is_reparse_point(_: &fs::Metadata) -> bool {
1363    false
1364}
1365
1366fn is_executable(metadata: &fs::Metadata, path: &str, bytes: &[u8]) -> bool {
1367    if unix_executable(metadata) {
1368        return true;
1369    }
1370    has_executable_shape(path, bytes)
1371}
1372
1373fn has_executable_shape(path: &str, bytes: &[u8]) -> bool {
1374    let extension = path.rsplit_once('.').map(|(_, extension)| extension.to_ascii_lowercase());
1375    if extension.as_deref().is_some_and(|extension| {
1376        matches!(
1377            extension,
1378            "exe" | "com" | "bat" | "cmd" | "ps1" | "sh" | "bash" | "zsh" | "fish"
1379                | "py" | "rb" | "pl" | "js" | "mjs" | "cjs"
1380        )
1381    }) {
1382        return true;
1383    }
1384    bytes.starts_with(b"#!")
1385        || bytes.starts_with(b"MZ")
1386        || bytes.starts_with(b"\x7fELF")
1387        || matches!(bytes.get(..4), Some([0xfe, 0xed, 0xfa, 0xce])
1388            | Some([0xfe, 0xed, 0xfa, 0xcf])
1389            | Some([0xce, 0xfa, 0xed, 0xfe])
1390            | Some([0xcf, 0xfa, 0xed, 0xfe])
1391            | Some([0xca, 0xfe, 0xba, 0xbe]))
1392}
1393
1394#[cfg(unix)]
1395fn unix_executable(metadata: &fs::Metadata) -> bool {
1396    use std::os::unix::fs::PermissionsExt;
1397    metadata.permissions().mode() & 0o111 != 0
1398}
1399
1400#[cfg(not(unix))]
1401fn unix_executable(_: &fs::Metadata) -> bool {
1402    false
1403}
1404
1405fn digest_files(files: &[NodeBundleFile]) -> SpawnBundleDigest {
1406    let mut context = Context::new(&SHA256);
1407    context.update(DIGEST_DOMAIN);
1408    for file in files {
1409        let path = file.path.as_bytes();
1410        context.update(&(path.len() as u32).to_be_bytes());
1411        context.update(path);
1412        context.update(&(file.bytes.len() as u64).to_be_bytes());
1413        context.update(&file.bytes);
1414    }
1415    let digest = context.finish();
1416    let mut value = String::with_capacity(71);
1417    value.push_str("sha256:");
1418    for byte in digest.as_ref() {
1419        use std::fmt::Write;
1420        write!(&mut value, "{byte:02x}").expect("writing to String cannot fail");
1421    }
1422    SpawnBundleDigest::new(value).expect("SHA-256 formatting is valid")
1423}
1424
1425pub(crate) fn digest_delivery_blob(bytes: &[u8]) -> DeliveryBlobDigestV1 {
1426    let actual = ring::digest::digest(&SHA256, bytes);
1427    DeliveryBlobDigestV1::new(format!("sha256:{}", hex_digest(actual.as_ref())))
1428        .expect("SHA-256 formatting is valid")
1429}
1430
1431pub(crate) fn digest_delivery_manifest(
1432    manifest: &DeliveryBundleManifestV2,
1433) -> DeliveryManifestDigestV2 {
1434    let actual = ring::digest::digest(
1435        &SHA256,
1436        &manifest.canonical_manifest_digest_material(),
1437    );
1438    DeliveryManifestDigestV2::new(format!("sha256:{}", hex_digest(actual.as_ref())))
1439        .expect("SHA-256 formatting is valid")
1440}
1441
1442fn hex_digest(bytes: &[u8]) -> String {
1443    let mut value = String::with_capacity(bytes.len() * 2);
1444    for byte in bytes {
1445        use std::fmt::Write as _;
1446        write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
1447    }
1448    value
1449}
1450
1451#[cfg(test)]
1452mod tests {
1453    use super::*;
1454    use std::sync::atomic::{AtomicU64, Ordering};
1455
1456    static NEXT_TEMP: AtomicU64 = AtomicU64::new(1);
1457
1458    struct TestRoot(PathBuf);
1459
1460    impl TestRoot {
1461        fn new() -> Self {
1462            let target = std::env::var_os("CARGO_TARGET_DIR")
1463                .map(PathBuf::from)
1464                .filter(|path| path.is_absolute())
1465                .unwrap_or_else(|| std::env::current_dir().unwrap().join("target"));
1466            let base = target.join("bundle-catalog-tests");
1467            fs::create_dir_all(&base).unwrap();
1468            let path = base.join(format!(
1469                "gate4agent-bundle-catalog-{}-{}",
1470                std::process::id(),
1471                NEXT_TEMP.fetch_add(1, Ordering::Relaxed),
1472            ));
1473            fs::create_dir(&path).unwrap();
1474            Self(path)
1475        }
1476
1477        fn path(&self) -> &Path {
1478            &self.0
1479        }
1480
1481        fn write(&self, relative: &str, bytes: &[u8]) {
1482            let path = self.0.join(relative.replace('/', std::path::MAIN_SEPARATOR_STR));
1483            fs::create_dir_all(path.parent().unwrap()).unwrap();
1484            fs::write(path, bytes).unwrap();
1485        }
1486
1487        fn valid() -> Self {
1488            let root = Self::new();
1489            root.write(
1490                "plugin.json",
1491                br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#,
1492            );
1493            root.write(
1494                "skills/review-code/SKILL.md",
1495                b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
1496            );
1497            root
1498        }
1499
1500        fn expected_digest(&self) -> SpawnBundleDigest {
1501            self.protect();
1502            let root = ProtectedBundleRoot::open(&self.0).unwrap();
1503            let mut scanner = BundleScanner::default();
1504            scanner.scan_directory(&root, root.canonical(), &[]).unwrap();
1505            scanner.files.sort_by(|left, right| left.path.cmp(&right.path));
1506            digest_files(&scanner.files)
1507        }
1508
1509        fn protect(&self) {
1510            #[cfg(windows)]
1511            protect_tree(&self.0).unwrap();
1512        }
1513    }
1514
1515    impl Drop for TestRoot {
1516        fn drop(&mut self) {
1517            let _ = fs::remove_dir_all(&self.0);
1518        }
1519    }
1520
1521    fn bundle(root: &TestRoot, digest: SpawnBundleDigest) -> Result<NodeBundle, NodeBundleError> {
1522        root.protect();
1523        NodeBundle::new(
1524            SpawnBundleId::new("review-tools").unwrap(),
1525            SpawnBundleRevision::new("review-tools-r1").unwrap(),
1526            digest,
1527            root.path(),
1528        )
1529    }
1530
1531    #[test]
1532    fn node_bundle_rejects_changed_digest() {
1533        let root = TestRoot::valid();
1534        let expected = root.expected_digest();
1535        root.write(
1536            "skills/review-code/SKILL.md",
1537            b"---\nname: review-code\ndescription: Changed after the digest was pinned.\n---\n",
1538        );
1539
1540        let error = bundle(&root, expected).unwrap_err();
1541        assert!(matches!(error, NodeBundleError::DigestMismatch { .. }));
1542    }
1543
1544    #[test]
1545    fn node_bundle_rejects_unsafe_paths_and_symlinks() {
1546        let traversal = TestRoot::valid();
1547        let error = NodeBundle::new(
1548            SpawnBundleId::new("review-tools").unwrap(),
1549            SpawnBundleRevision::new("review-tools-r1").unwrap(),
1550            zero_digest(),
1551            traversal.path().join("skills/.."),
1552        )
1553        .unwrap_err();
1554        assert!(matches!(error, NodeBundleError::RootNotAbsolute));
1555
1556        let reserved = TestRoot::valid();
1557        reserved.write("skills/review-code/CON.txt", b"unsafe");
1558        let error = bundle(&reserved, zero_digest()).unwrap_err();
1559        assert!(matches!(error, NodeBundleError::UnsafePath { .. }));
1560
1561        let executable = TestRoot::valid();
1562        executable.write("skills/review-code/scripts/run.sh", b"exit 0\n");
1563        let error = bundle(&executable, zero_digest()).unwrap_err();
1564        assert!(matches!(error, NodeBundleError::ExecutableFile { .. }));
1565
1566        let mcp = TestRoot::valid();
1567        mcp.write("mcp.json", b"{}");
1568        let error = bundle(&mcp, zero_digest()).unwrap_err();
1569        assert!(matches!(error, NodeBundleError::McpUnsupported));
1570
1571        let linked = TestRoot::valid();
1572        let linked_target = TestRoot::new();
1573        let target = linked_target.path().join("target.txt");
1574        fs::write(&target, b"target").unwrap();
1575        let link = linked.path().join("skills/review-code/link.txt");
1576        if create_file_symlink(&target, &link).is_ok() {
1577            let error = bundle(&linked, zero_digest()).unwrap_err();
1578            assert!(
1579                matches!(&error, NodeBundleError::UnsafeFileType { .. }),
1580                "symlink must fail closed as UnsafeFileType, got {error:?}",
1581            );
1582        }
1583
1584        #[cfg(unix)]
1585        {
1586            use std::os::unix::fs::PermissionsExt;
1587
1588            let writable = TestRoot::valid();
1589            fs::set_permissions(writable.path(), fs::Permissions::from_mode(0o777)).unwrap();
1590            let error = NodeBundle::new(
1591                SpawnBundleId::new("review-tools").unwrap(),
1592                SpawnBundleRevision::new("review-tools-r1").unwrap(),
1593                zero_digest(),
1594                writable.path(),
1595            )
1596            .unwrap_err();
1597            assert!(matches!(error, NodeBundleError::InsecurePermissions { .. }));
1598
1599            let swapped = TestRoot::valid();
1600            let expected = swapped.expected_digest();
1601            let swapped_target = TestRoot::new();
1602            let target = swapped_target.path().join("replacement.md");
1603            fs::write(&target, b"replacement").unwrap();
1604            let skill = swapped.path().join("skills/review-code/SKILL.md");
1605            fs::remove_file(&skill).unwrap();
1606            std::os::unix::fs::symlink(&target, &skill).unwrap();
1607            let error = bundle(&swapped, expected).unwrap_err();
1608            assert!(
1609                matches!(&error, NodeBundleError::UnsafeFileType { .. }),
1610                "swapped skill must fail closed as UnsafeFileType, got {error:?}",
1611            );
1612        }
1613
1614        #[cfg(windows)]
1615        {
1616            let insecure = TestRoot::valid();
1617            insecure.protect();
1618            windows_bundle_security::make_world_writable_for_test(insecure.path()).unwrap();
1619            let error = NodeBundle::new(
1620                SpawnBundleId::new("review-tools").unwrap(),
1621                SpawnBundleRevision::new("review-tools-r1").unwrap(),
1622                zero_digest(),
1623                insecure.path(),
1624            )
1625            .unwrap_err();
1626            assert!(matches!(error, NodeBundleError::InsecurePermissions { .. }));
1627        }
1628    }
1629
1630    #[test]
1631    fn node_bundle_rejects_case_fold_collision_when_source_can_represent_it() {
1632        let collision = TestRoot::valid();
1633        let references = collision.path().join("skills/review-code/references");
1634        collision.write("skills/review-code/references/A.txt", b"upper");
1635        collision.write("skills/review-code/references/a.txt", b"lower");
1636
1637        let names = fs::read_dir(references)
1638            .unwrap()
1639            .map(|entry| entry.unwrap().file_name())
1640            .collect::<BTreeSet<_>>();
1641        if !names.contains(std::ffi::OsStr::new("A.txt"))
1642            || !names.contains(std::ffi::OsStr::new("a.txt"))
1643        {
1644            return;
1645        }
1646
1647        let error = bundle(&collision, zero_digest()).unwrap_err();
1648        assert!(matches!(error, NodeBundleError::CaseFoldCollision { .. }));
1649    }
1650
1651    #[test]
1652    fn node_bundle_validates_schema_manifest_and_skill_contract() {
1653        let root = TestRoot::valid();
1654        root.write(
1655            ".claude-plugin/plugin.json",
1656            br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#,
1657        );
1658        let digest = root.expected_digest();
1659        let valid = bundle(&root, digest).unwrap();
1660        assert_eq!(valid.files().len(), 3);
1661        assert_eq!(valid.receipt().id.as_str(), "review-tools");
1662        let error = BundleCatalog::new([valid.clone(), valid]).unwrap_err();
1663        assert!(matches!(error, BundleCatalogError::Duplicate { .. }));
1664
1665        root.write(
1666            "plugin.json",
1667            br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools","version":"1.0.0"}"#,
1668        );
1669        let error = bundle(&root, root.expected_digest()).unwrap_err();
1670        assert!(matches!(error, NodeBundleError::InvalidManifest { .. }));
1671
1672        root.write(
1673            "plugin.json",
1674            br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#,
1675        );
1676        root.write(
1677            "skills/review-code/SKILL.md",
1678            b"---\nname: another-name\ndescription: Wrong directory binding.\n---\n",
1679        );
1680        let error = bundle(&root, root.expected_digest()).unwrap_err();
1681        assert!(matches!(error, NodeBundleError::InvalidSkill { .. }));
1682    }
1683
1684    #[test]
1685    fn node_bundle_digest_is_stable() {
1686        let first = TestRoot::valid();
1687        first.write("skills/review-code/references/z.txt", b"last");
1688        first.write("skills/review-code/references/a.txt", b"first");
1689        let expected = first.expected_digest();
1690
1691        let second = TestRoot::new();
1692        second.write("skills/review-code/references/a.txt", b"first");
1693        second.write("skills/review-code/references/z.txt", b"last");
1694        second.write(
1695            "skills/review-code/SKILL.md",
1696            b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
1697        );
1698        second.write(
1699            "plugin.json",
1700            br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#,
1701        );
1702
1703        assert_eq!(expected, second.expected_digest());
1704        let captured = bundle(&first, expected.clone()).unwrap();
1705        first.write("skills/review-code/references/a.txt", b"mutated later");
1706        assert_eq!(captured.digest(), &expected);
1707        assert_eq!(
1708            captured
1709                .files()
1710                .iter()
1711                .find(|file| file.path() == "skills/review-code/references/a.txt")
1712                .unwrap()
1713                .bytes(),
1714            b"first",
1715        );
1716
1717        let marker = "G4A_DEBUG_MUST_NOT_LEAK_MARKER";
1718        let debug_root = TestRoot::valid();
1719        debug_root.write(
1720            "skills/review-code/references/private.txt",
1721            marker.as_bytes(),
1722        );
1723        let debug_bundle = bundle(&debug_root, debug_root.expected_digest()).unwrap();
1724        let debug_file = debug_bundle
1725            .files()
1726            .iter()
1727            .find(|file| file.path().ends_with("private.txt"))
1728            .unwrap();
1729        let debug_catalog = BundleCatalog::new([debug_bundle.clone()]).unwrap();
1730        assert!(!format!("{debug_file:?}").contains(marker));
1731        assert!(!format!("{debug_bundle:?}").contains(marker));
1732        assert!(!format!("{debug_catalog:?}").contains(marker));
1733    }
1734
1735    #[cfg(feature = "fixture")]
1736    #[test]
1737    fn protect_bundle_source_tree_fixture_establishes_exact_loader_boundary() {
1738        let root = TestRoot::valid();
1739        #[cfg(unix)]
1740        {
1741            use std::os::unix::fs::PermissionsExt;
1742            fs::set_permissions(root.path(), fs::Permissions::from_mode(0o777)).unwrap();
1743            fs::set_permissions(
1744                root.path().join("plugin.json"),
1745                fs::Permissions::from_mode(0o666),
1746            )
1747            .unwrap();
1748        }
1749        #[cfg(windows)]
1750        windows_bundle_security::make_world_writable_for_test(root.path()).unwrap();
1751
1752        protect_bundle_source_tree_fixture(root.path()).unwrap();
1753        let protected = ProtectedBundleRoot::open(root.path()).unwrap();
1754        let mut scanner = BundleScanner::default();
1755        scanner
1756            .scan_directory(&protected, protected.canonical(), &[])
1757            .unwrap();
1758        scanner.files.sort_by(|left, right| left.path.cmp(&right.path));
1759        let digest = digest_files(&scanner.files);
1760        let loaded = NodeBundle::new(
1761            SpawnBundleId::new("review-tools").unwrap(),
1762            SpawnBundleRevision::new("review-tools-r1").unwrap(),
1763            digest,
1764            root.path(),
1765        )
1766        .unwrap();
1767        assert_eq!(loaded.files().len(), 2);
1768    }
1769
1770    #[cfg(unix)]
1771    fn create_file_symlink(target: &Path, link: &Path) -> io::Result<()> {
1772        std::os::unix::fs::symlink(target, link)
1773    }
1774
1775    #[cfg(windows)]
1776    fn create_file_symlink(target: &Path, link: &Path) -> io::Result<()> {
1777        std::os::windows::fs::symlink_file(target, link)
1778    }
1779
1780    fn zero_digest() -> SpawnBundleDigest {
1781        SpawnBundleDigest::new(format!("sha256:{}", "0".repeat(64))).unwrap()
1782    }
1783
1784    #[cfg(windows)]
1785    fn protect_tree(path: &Path) -> io::Result<()> {
1786        let metadata = fs::symlink_metadata(path)?;
1787        if metadata.is_dir() && !metadata.file_type().is_symlink() && !is_reparse_point(&metadata) {
1788            for entry in fs::read_dir(path)? {
1789                protect_tree(&entry?.path())?;
1790            }
1791        }
1792        if !metadata.file_type().is_symlink() && !is_reparse_point(&metadata) {
1793            windows_bundle_security::protect_owner_only(path)?;
1794        }
1795        Ok(())
1796    }
1797}