1use 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#[derive(Debug, Clone, Deserialize)]
25pub struct SkillLoaderConfig {
26 #[serde(default)]
28 pub service: ServiceConfig,
29 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#[derive(Debug, Clone)]
62#[non_exhaustive]
63pub struct Pack {
64 pub root: PathBuf,
66 pub manifest: Manifest,
68 pub body: Option<String>,
70 pub assets: Vec<Asset>,
72 pub verification_warnings: Vec<String>,
74}
75
76impl Pack {
77 #[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 #[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 #[must_use]
98 pub fn with_assets(mut self, assets: Vec<Asset>) -> Self {
99 self.assets = assets;
100 self
101 }
102
103 #[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#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct Asset {
114 pub path: PathBuf,
116 pub sha256: String,
118}
119
120pub 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 pub fn new(verifier: V) -> Self {
136 Self { verifier }
137 }
138
139 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 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 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}