Skip to main content

rskit_skill/
loader.rs

1//! Filesystem loading and activation for skill packs.
2
3use std::path::{Path, PathBuf};
4
5use rskit_config::{AppConfig, ConfigLoader, ServiceConfig};
6use rskit_errors::{AppError, ErrorCode};
7use rskit_fs::{path as fs_path, sync_io};
8use rskit_util::hash::sha256::sha256_hex;
9use rskit_validation::Validate;
10use rskit_validation::validator::{ValidationError, ValidationErrors};
11use serde::Deserialize;
12
13use crate::{
14    MANIFEST_FILE_NAME, Manifest, SKILL_MD_FILE_NAME, SkillError, VerificationOutcome, Verifier,
15    WarnOnlyVerifier,
16};
17
18const MAX_MANIFEST_BYTES: u64 = 1024 * 1024;
19const MAX_BODY_BYTES: u64 = 4 * 1024 * 1024;
20const MAX_ASSET_BYTES: u64 = 16 * 1024 * 1024;
21const MAX_ASSET_TOTAL_BYTES: u64 = 64 * 1024 * 1024;
22
23/// Config-loader compatible skill activation source.
24#[derive(Debug, Clone, Deserialize)]
25pub struct SkillLoaderConfig {
26    /// Embedded service config for canonical `rskit-config` loading.
27    #[serde(default)]
28    pub service: ServiceConfig,
29    /// Root directory of the skill pack to activate.
30    pub root: String,
31}
32
33impl Validate for SkillLoaderConfig {
34    fn validate(&self) -> Result<(), ValidationErrors> {
35        let mut errors = ValidationErrors::new();
36        if let Err(error) = self.service.validate() {
37            let mut validation_error = ValidationError::new("invalid_service");
38            validation_error.message = Some(error.to_string().into());
39            errors.add("service", validation_error);
40        }
41        if self.root.trim().is_empty() {
42            errors.add("root", ValidationError::new("length"));
43        }
44        if errors.is_empty() {
45            Ok(())
46        } else {
47            Err(errors)
48        }
49    }
50}
51
52impl AppConfig for SkillLoaderConfig {
53    fn apply_defaults(&mut self) {}
54
55    fn service_config(&self) -> &ServiceConfig {
56        &self.service
57    }
58}
59
60/// Loaded skill pack.
61#[derive(Debug, Clone)]
62#[non_exhaustive]
63pub struct Pack {
64    /// Pack root directory.
65    pub root: PathBuf,
66    /// Parsed manifest.
67    pub manifest: Manifest,
68    /// Body loaded from `SKILL.md` on activation.
69    pub body: Option<String>,
70    /// Inert asset inventory.
71    pub assets: Vec<Asset>,
72    /// Non-fatal verification warnings observed while loading the pack.
73    pub verification_warnings: Vec<String>,
74}
75
76impl Pack {
77    /// Create a pack from its root and manifest with no loaded body, assets, or warnings.
78    #[must_use]
79    pub fn new(root: impl Into<PathBuf>, manifest: Manifest) -> Self {
80        Self {
81            root: root.into(),
82            manifest,
83            body: None,
84            assets: Vec::new(),
85            verification_warnings: Vec::new(),
86        }
87    }
88
89    /// Attach loaded skill body content.
90    #[must_use]
91    pub fn with_body(mut self, body: impl Into<String>) -> Self {
92        self.body = Some(body.into());
93        self
94    }
95
96    /// Attach collected inert assets.
97    #[must_use]
98    pub fn with_assets(mut self, assets: Vec<Asset>) -> Self {
99        self.assets = assets;
100        self
101    }
102
103    /// Attach non-fatal verification warnings.
104    #[must_use]
105    pub fn with_verification_warnings(mut self, warnings: Vec<String>) -> Self {
106        self.verification_warnings = warnings;
107        self
108    }
109}
110
111/// Inert asset recorded during activation.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct Asset {
114    /// Asset path.
115    pub path: PathBuf,
116    /// Lowercase hex SHA-256 digest.
117    pub sha256: String,
118}
119
120/// Filesystem loader for skill packs.
121pub struct Loader<V = WarnOnlyVerifier> {
122    verifier: V,
123}
124
125impl Default for Loader<WarnOnlyVerifier> {
126    fn default() -> Self {
127        Self {
128            verifier: WarnOnlyVerifier,
129        }
130    }
131}
132
133impl<V: Verifier> Loader<V> {
134    /// Create a loader with an injected verifier.
135    pub fn new(verifier: V) -> Self {
136        Self { verifier }
137    }
138
139    /// Load only manifest metadata.
140    pub fn load_metadata(&self, root: impl AsRef<Path>) -> Result<Manifest, SkillError> {
141        let (manifest, warnings) = self.load_metadata_with_warnings(root)?;
142        for warning in warnings {
143            tracing::warn!(warning = %warning, "skill verification warning");
144        }
145        Ok(manifest)
146    }
147
148    /// Activate a skill by loading body and inert asset inventory.
149    pub fn activate(&self, root: impl AsRef<Path>) -> Result<Pack, SkillError> {
150        let root = root.as_ref();
151        let (manifest, verification_warnings) = self.load_metadata_with_warnings(root)?;
152        for warning in &verification_warnings {
153            tracing::warn!(warning = %warning, "skill verification warning");
154        }
155        let body_path = root.join(SKILL_MD_FILE_NAME);
156        let body = read_utf8_bounded(&body_path, MAX_BODY_BYTES)?;
157        let pack_root = canonicalize_path(root)?;
158        let mut assets = Vec::new();
159        let mut total_asset_bytes = 0;
160        collect_assets(
161            &pack_root,
162            root.join("references"),
163            &mut assets,
164            &mut total_asset_bytes,
165        )?;
166        collect_assets(
167            &pack_root,
168            root.join("scripts"),
169            &mut assets,
170            &mut total_asset_bytes,
171        )?;
172        assets.sort_by(|left, right| left.path.cmp(&right.path));
173        Ok(Pack::new(root, manifest)
174            .with_body(body)
175            .with_assets(assets)
176            .with_verification_warnings(verification_warnings))
177    }
178
179    /// Activate a skill using canonical `rskit-config` source resolution.
180    pub fn activate_from_config(&self, loader: &ConfigLoader) -> Result<Pack, SkillError> {
181        let config = loader
182            .load_validated::<SkillLoaderConfig>()
183            .map_err(|error| SkillError::Config(error.to_string()))?;
184        self.activate(config.root)
185    }
186
187    fn load_metadata_with_warnings(
188        &self,
189        root: impl AsRef<Path>,
190    ) -> Result<(Manifest, Vec<String>), SkillError> {
191        let root = root.as_ref();
192        let manifest_path = root.join(MANIFEST_FILE_NAME);
193        let data = read_utf8_bounded(&manifest_path, MAX_MANIFEST_BYTES)?;
194        let manifest: Manifest =
195            serde_norway::from_str(&data).map_err(|source| SkillError::ParseManifest {
196                path: manifest_path,
197                source,
198            })?;
199        manifest.validate()?;
200        match self.verifier.verify(&manifest, root)? {
201            VerificationOutcome::Denied(reason) => Err(SkillError::Verification(reason)),
202            VerificationOutcome::Verified => Ok((manifest, Vec::new())),
203            VerificationOutcome::Warning(warnings) => Ok((manifest, warnings)),
204        }
205    }
206}
207
208fn collect_assets(
209    pack_root: &Path,
210    dir: PathBuf,
211    assets: &mut Vec<Asset>,
212    total_asset_bytes: &mut u64,
213) -> Result<(), SkillError> {
214    if !sync_io::dir::exists(&dir).map_err(|error| fs_error(&dir, error))? {
215        return match sync_io::file::metadata(&dir) {
216            Ok(metadata) if metadata.is_symlink => {
217                Err(invalid_pack_file(&dir, "symlinks are not allowed"))
218            }
219            Ok(_) => Err(invalid_pack_file(&dir, "expected directory")),
220            Err(error) if is_not_found_error(&error) => Ok(()),
221            Err(error) => Err(fs_error(&dir, error)),
222        };
223    }
224
225    let metadata = sync_io::file::metadata(&dir).map_err(|error| fs_error(&dir, error))?;
226    if metadata.is_symlink {
227        return Err(invalid_pack_file(&dir, "symlinks are not allowed"));
228    }
229    if !metadata.is_dir {
230        return Err(invalid_pack_file(&dir, "expected directory"));
231    }
232    ensure_under_root(pack_root, &dir)?;
233
234    for entry in sync_io::dir::list(&dir).map_err(|error| fs_error(&dir, error))? {
235        let path = entry.path;
236        if entry.is_symlink {
237            return Err(invalid_pack_file(&path, "symlinks are not allowed"));
238        }
239        if entry.is_dir {
240            ensure_under_root(pack_root, &path)?;
241            collect_assets(pack_root, path, assets, total_asset_bytes)?;
242        } else if entry.is_file {
243            ensure_under_root(pack_root, &path)?;
244            let digest = hash_file_bounded(&path, total_asset_bytes)?;
245            assets.push(Asset {
246                path,
247                sha256: digest,
248            });
249        } else {
250            return Err(invalid_pack_file(
251                &path,
252                "expected regular file or directory",
253            ));
254        }
255    }
256    Ok(())
257}
258
259fn read_utf8_bounded(path: &Path, max_bytes: u64) -> Result<String, SkillError> {
260    let bytes = sync_io::file::read_bounded(path, max_bytes)
261        .map_err(|error| bounded_read_error(path, max_bytes, error))?;
262    String::from_utf8(bytes).map_err(|source| SkillError::InvalidUtf8 {
263        path: path.to_path_buf(),
264        source,
265    })
266}
267
268fn hash_file_bounded(path: &Path, total_asset_bytes: &mut u64) -> Result<String, SkillError> {
269    let bytes = sync_io::file::read_bounded(path, MAX_ASSET_BYTES)
270        .map_err(|error| bounded_read_error(path, MAX_ASSET_BYTES, error))?;
271    *total_asset_bytes += bytes.len() as u64;
272    if *total_asset_bytes > MAX_ASSET_TOTAL_BYTES {
273        return Err(SkillError::AssetsTooLarge {
274            path: path.to_path_buf(),
275            total_bytes: *total_asset_bytes,
276            limit_bytes: MAX_ASSET_TOTAL_BYTES,
277        });
278    }
279
280    Ok(sha256_hex(&bytes))
281}
282
283fn ensure_under_root(pack_root: &Path, path: &Path) -> Result<(), SkillError> {
284    let canonical = canonicalize_path(path)?;
285    if canonical.starts_with(pack_root) {
286        Ok(())
287    } else {
288        Err(invalid_pack_file(path, "path escapes skill pack root"))
289    }
290}
291
292fn canonicalize_path(path: &Path) -> Result<PathBuf, SkillError> {
293    fs_path::canonicalize(path).map_err(|error| fs_error(path, error))
294}
295
296fn invalid_pack_file(path: &Path, reason: impl Into<String>) -> SkillError {
297    SkillError::InvalidPackFile {
298        path: path.to_path_buf(),
299        reason: reason.into(),
300    }
301}
302
303fn bounded_read_error(path: &Path, limit_bytes: u64, error: AppError) -> SkillError {
304    if sync_io::file::is_file_too_large_error(&error) {
305        SkillError::FileTooLarge {
306            path: path.to_path_buf(),
307            limit_bytes,
308        }
309    } else {
310        fs_error(path, error)
311    }
312}
313
314fn fs_error(path: &Path, error: AppError) -> SkillError {
315    if error.code() == ErrorCode::InvalidInput {
316        if sync_io::file::is_symlink_not_allowed_error(&error) {
317            invalid_pack_file(path, "symlinks are not allowed")
318        } else if sync_io::file::is_not_regular_file_error(&error) {
319            invalid_pack_file(path, "expected regular file")
320        } else {
321            invalid_pack_file(path, error.message())
322        }
323    } else {
324        SkillError::Io {
325            path: path.to_path_buf(),
326            source: std::io::Error::other(error),
327        }
328    }
329}
330
331fn is_not_found_error(error: &AppError) -> bool {
332    error
333        .cause()
334        .and_then(|cause| cause.downcast_ref::<std::io::Error>())
335        .is_some_and(|cause| cause.kind() == std::io::ErrorKind::NotFound)
336}