1use std::collections::BTreeSet;
5use std::fs;
6use std::io;
7use std::path::{Path, PathBuf};
8use std::time::{SystemTime, UNIX_EPOCH};
9
10use runx_contracts::sha256_prefixed;
11use runx_parser::{
12 SkillInstallOrigin, ValidatedSkillInstall, parse_runner_manifest_yaml,
13 validate_runner_manifest, validate_skill_install,
14};
15use serde_json::{Value, json};
16
17use super::package_files::{registry_package_digest, validate_registry_package_file_path};
18use super::refs::safe_skill_package_parts;
19use super::source_authority::RegistryManifestSourceAuthority;
20use super::trust_anchor::{
21 RegistryManifestVerificationFailure, TrustedRegistryManifestKey,
22 default_trusted_registry_manifest_keys, registry_manifest_key_allows,
23 verify_registry_signed_manifest,
24};
25use super::types::{RegistryPackageFile, RegistrySignedManifest, TrustTier};
26
27#[derive(Clone, Debug, PartialEq, Eq)]
28pub struct InstallCandidate {
29 pub markdown: String,
30 pub profile_document: Option<String>,
31 pub package_files: Vec<RegistryPackageFile>,
32 pub package_digest: Option<String>,
33 pub source: String,
34 pub source_label: String,
35 pub r#ref: String,
36 pub skill_id: Option<String>,
37 pub version: Option<String>,
38 pub signed_manifest: Option<RegistrySignedManifest>,
39 pub profile_digest: Option<String>,
40 pub runner_names: Vec<String>,
41 pub trust_tier: Option<TrustTier>,
42 pub manifest_source_authority: Option<RegistryManifestSourceAuthority>,
43}
44
45#[derive(Clone, Debug, PartialEq, Eq)]
46pub struct InstallLocalSkillOptions {
47 pub destination_root: PathBuf,
48 pub expected_digest: Option<String>,
49 pub trusted_manifest_keys: Vec<TrustedRegistryManifestKey>,
50}
51
52#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
53#[serde(rename_all = "snake_case")]
54pub enum InstallStatus {
55 Installed,
56 Unchanged,
57}
58
59#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
60pub struct InstallLocalSkillResult {
61 pub status: InstallStatus,
62 pub destination: PathBuf,
63 pub skill_name: String,
64 pub source: String,
65 pub source_label: String,
66 pub skill_id: Option<String>,
67 pub version: Option<String>,
68 pub digest: String,
69 pub profile_digest: Option<String>,
70 pub profile_state_path: Option<PathBuf>,
71 pub runner_names: Vec<String>,
72 pub trust_tier: Option<TrustTier>,
73}
74
75#[derive(Debug, thiserror::Error)]
76pub enum InstallError {
77 #[error("{0}")]
78 Parser(#[from] runx_parser::SkillInstallError),
79 #[error("{0}")]
80 Manifest(#[from] runx_parser::ValidationError),
81 #[error("{0}")]
82 ManifestParse(#[from] runx_parser::ParseError),
83 #[error("digest mismatch for {ref_name}: expected {expected}, received {actual}")]
84 DigestMismatch {
85 ref_name: String,
86 expected: String,
87 actual: String,
88 },
89 #[error("registry signed manifest is required for {0}")]
90 UnsignedManifest(String),
91 #[error("registry signed manifest for {ref_name} was signed by unknown key id '{key_id}'")]
92 UnknownManifestKey { ref_name: String, key_id: String },
93 #[error("registry signed manifest signature is invalid for {ref_name}: {reason}")]
94 InvalidManifestSignature { ref_name: String, reason: String },
95 #[error(
96 "registry signed manifest identity mismatch for {ref_name}: expected {expected}, manifest declares {actual}"
97 )]
98 ManifestIdentityMismatch {
99 ref_name: String,
100 expected: String,
101 actual: String,
102 },
103 #[error("registry signed manifest identity for {ref_name} is missing {field}")]
104 ManifestIdentityMissing {
105 ref_name: String,
106 field: &'static str,
107 },
108 #[error("registry signed manifest trust tier is required for {0}")]
109 ManifestTrustTierMissing(String),
110 #[error("registry signed manifest signer is out of scope for {ref_name}: {reason}")]
111 ManifestTrustScopeViolation { ref_name: String, reason: String },
112 #[error("binding digest mismatch for {ref_name}: expected {expected}, received {actual}")]
113 ProfileDigestMismatch {
114 ref_name: String,
115 expected: String,
116 actual: String,
117 },
118 #[error("package digest mismatch for {ref_name}: expected {expected}, received {actual}")]
119 PackageDigestMismatch {
120 ref_name: String,
121 expected: String,
122 actual: String,
123 },
124 #[error("registry package file is invalid for {ref_name}: {reason}")]
125 InvalidPackageFile { ref_name: String, reason: String },
126 #[error("runner manifest skill '{manifest_skill}' does not match skill '{skill_name}'")]
127 ManifestSkillMismatch {
128 manifest_skill: String,
129 skill_name: String,
130 },
131 #[error("runner manifest runners do not match advertised runner metadata for skill '{0}'")]
132 RunnerMetadataMismatch(String),
133 #[error("skill install destination already exists with different content: {0}")]
134 ConflictingSkill(PathBuf),
135 #[error("skill install profile state already exists with different content: {0}")]
136 ConflictingProfile(PathBuf),
137 #[error("skill install runner manifest already exists with different content: {0}")]
138 ConflictingRunnerManifest(PathBuf),
139 #[error("skill install package file already exists with different content: {0}")]
140 ConflictingPackageFile(PathBuf),
141 #[error("io error at {path}: {source}")]
142 Io { path: PathBuf, source: io::Error },
143 #[error("failed to serialize profile state: {0}")]
144 Serialize(#[from] serde_json::Error),
145}
146
147pub fn install_local_skill(
148 candidate: &InstallCandidate,
149 options: &InstallLocalSkillOptions,
150) -> Result<InstallLocalSkillResult, InstallError> {
151 let validated = validate_install_candidate(candidate, options)?;
152 let paths = install_paths(candidate, options, &validated.install.skill.name);
153 let write_plan = prepare_install_write_plan(
154 &paths,
155 &validated.install.markdown,
156 candidate.profile_document.as_deref(),
157 &candidate.package_files,
158 &candidate.r#ref,
159 validated.next_profile_state.as_deref(),
160 )?;
161 commit_install_write_plan(
162 &paths,
163 &write_plan,
164 &validated.install.markdown,
165 candidate.profile_document.as_deref(),
166 &candidate.package_files,
167 validated.next_profile_state.as_deref(),
168 )?;
169
170 Ok(InstallLocalSkillResult {
171 status: if write_plan.writes_skill
172 || write_plan.writes_profile_state
173 || write_plan.writes_runner_manifest
174 || write_plan.package_files.iter().any(|file| file.write)
175 {
176 InstallStatus::Installed
177 } else {
178 InstallStatus::Unchanged
179 },
180 destination: paths.destination,
181 skill_name: validated.install.skill.name,
182 source: validated.install.origin.source,
183 source_label: validated.install.origin.source_label,
184 skill_id: validated.install.origin.skill_id,
185 version: validated.install.origin.version,
186 digest: validated.actual_digest,
187 profile_digest: validated.profile_digest,
188 profile_state_path: paths.profile_state_path,
189 runner_names: validated.runner_names,
190 trust_tier: candidate.trust_tier.clone(),
191 })
192}
193
194struct ValidatedLocalInstall {
195 actual_digest: String,
196 profile_digest: Option<String>,
197 runner_names: Vec<String>,
198 install: ValidatedSkillInstall,
199 next_profile_state: Option<String>,
200}
201
202struct InstallPaths {
203 package_root: PathBuf,
204 destination: PathBuf,
205 profile_state_path: Option<PathBuf>,
206 runner_manifest_path: Option<PathBuf>,
207}
208
209struct InstallWritePlan {
210 writes_skill: bool,
211 writes_profile_state: bool,
212 writes_runner_manifest: bool,
213 package_files: Vec<PackageFileWritePlan>,
214}
215
216struct PackageFileWritePlan {
217 path: PathBuf,
218 content: String,
219 write: bool,
220}
221
222fn validate_install_candidate(
223 candidate: &InstallCandidate,
224 options: &InstallLocalSkillOptions,
225) -> Result<ValidatedLocalInstall, InstallError> {
226 let allow_unsigned_local = allows_unsigned_local_registry_candidate(candidate);
227 let actual_digest = verify_signed_manifest_anchor(candidate, options, allow_unsigned_local)?;
228 let profile_digest = validate_candidate_profile_digest(candidate, allow_unsigned_local)?;
229 validate_candidate_package_digest(candidate, allow_unsigned_local)?;
230 let origin = install_origin(candidate, &actual_digest, profile_digest.as_deref());
231 let install = validate_skill_install(&candidate.markdown, origin)?;
232 let runner_names = validate_install_binding_manifest(
233 &install.skill.name,
234 candidate.profile_document.as_deref(),
235 &candidate.runner_names,
236 )?;
237 let next_profile_state = next_profile_state(
238 candidate,
239 &install,
240 &actual_digest,
241 profile_digest.as_deref(),
242 &runner_names,
243 )?;
244 Ok(ValidatedLocalInstall {
245 actual_digest,
246 profile_digest,
247 runner_names,
248 install,
249 next_profile_state,
250 })
251}
252
253fn verify_signed_manifest_anchor(
254 candidate: &InstallCandidate,
255 options: &InstallLocalSkillOptions,
256 allow_unsigned_local: bool,
257) -> Result<String, InstallError> {
258 let Some(manifest) = candidate.signed_manifest.as_ref() else {
259 if allow_unsigned_local {
260 let actual_digest = sha256_prefixed(candidate.markdown.as_bytes());
261 if let Some(expected) = options
262 .expected_digest
263 .as_ref()
264 .filter(|expected| !digest_matches(expected, &actual_digest))
265 {
266 return Err(InstallError::DigestMismatch {
267 ref_name: candidate.r#ref.clone(),
268 expected: expected.clone(),
269 actual: actual_digest,
270 });
271 }
272 return Ok(actual_digest);
273 }
274 return Err(InstallError::UnsignedManifest(candidate.r#ref.clone()));
275 };
276 let trusted_keys = trusted_manifest_keys(options)?;
277 let key = verify_registry_signed_manifest(manifest, &trusted_keys).map_err(|failure| {
278 manifest_verification_error(candidate.r#ref.clone(), &manifest.signer.key_id, failure)
279 })?;
280 validate_manifest_identity(candidate, manifest)?;
281 validate_manifest_trust_scope(candidate, key)?;
282 let actual_digest = sha256_prefixed(candidate.markdown.as_bytes());
283 if !digest_matches(&manifest.digest, &actual_digest) {
284 return Err(InstallError::DigestMismatch {
285 ref_name: candidate.r#ref.clone(),
286 expected: manifest.digest.clone(),
287 actual: actual_digest,
288 });
289 }
290 if let Some(expected) = options
291 .expected_digest
292 .as_ref()
293 .filter(|expected| !digest_matches(expected, &manifest.digest))
294 {
295 return Err(InstallError::DigestMismatch {
296 ref_name: candidate.r#ref.clone(),
297 expected: expected.clone(),
298 actual: manifest.digest.clone(),
299 });
300 }
301 Ok(actual_digest)
302}
303
304fn allows_unsigned_local_registry_candidate(candidate: &InstallCandidate) -> bool {
305 matches!(
306 candidate.manifest_source_authority.as_ref(),
307 Some(RegistryManifestSourceAuthority::RegistrySource(source))
308 if source.starts_with("local:") || source.starts_with("file:")
309 )
310}
311
312fn validate_manifest_trust_scope(
313 candidate: &InstallCandidate,
314 key: &TrustedRegistryManifestKey,
315) -> Result<(), InstallError> {
316 let skill_id = candidate
317 .skill_id
318 .as_deref()
319 .ok_or(InstallError::ManifestIdentityMissing {
320 ref_name: candidate.r#ref.clone(),
321 field: "skill_id",
322 })?;
323 let trust_tier = candidate
324 .trust_tier
325 .as_ref()
326 .ok_or_else(|| InstallError::ManifestTrustTierMissing(candidate.r#ref.clone()))?;
327 registry_manifest_key_allows(
328 key,
329 skill_id,
330 trust_tier,
331 candidate.manifest_source_authority.as_ref(),
332 )
333 .map_err(|reason| InstallError::ManifestTrustScopeViolation {
334 ref_name: candidate.r#ref.clone(),
335 reason,
336 })
337}
338
339fn trusted_manifest_keys(
340 options: &InstallLocalSkillOptions,
341) -> Result<Vec<TrustedRegistryManifestKey>, InstallError> {
342 if options.trusted_manifest_keys.is_empty() {
343 return default_trusted_registry_manifest_keys().map_err(|_error| {
344 InstallError::InvalidManifestSignature {
345 ref_name: "default registry trust anchor".to_owned(),
346 reason: "malformed trusted key".to_owned(),
347 }
348 });
349 }
350 Ok(options.trusted_manifest_keys.clone())
351}
352
353struct DigestMismatch {
354 expected: String,
355 actual: String,
356}
357
358fn check_digest_link(
359 actual: Option<&str>,
360 expected: Option<&str>,
361 expected_label_when_missing: &str,
362) -> Result<(), DigestMismatch> {
363 match (actual, expected) {
364 (Some(actual), Some(expected)) if digest_matches(expected, actual) => Ok(()),
365 (Some(actual), Some(expected)) => Err(DigestMismatch {
366 expected: expected.to_owned(),
367 actual: actual.to_owned(),
368 }),
369 (Some(actual), None) => Err(DigestMismatch {
370 expected: expected_label_when_missing.to_owned(),
371 actual: actual.to_owned(),
372 }),
373 (None, Some(expected)) => Err(DigestMismatch {
374 expected: expected.to_owned(),
375 actual: "none".to_owned(),
376 }),
377 (None, None) => Ok(()),
378 }
379}
380
381fn validate_candidate_profile_digest(
382 candidate: &InstallCandidate,
383 allow_unsigned_local: bool,
384) -> Result<Option<String>, InstallError> {
385 let profile_digest = candidate
386 .profile_document
387 .as_ref()
388 .map(|document| sha256_prefixed(document.as_bytes()));
389 let expected_profile_digest = candidate
390 .signed_manifest
391 .as_ref()
392 .and_then(|manifest| manifest.profile_digest.as_ref())
393 .or_else(|| {
394 allow_unsigned_local
395 .then_some(candidate.profile_digest.as_ref())
396 .flatten()
397 });
398 check_digest_link(
399 profile_digest.as_deref(),
400 expected_profile_digest.map(String::as_str),
401 "signed manifest profile digest",
402 )
403 .map_err(|mismatch| InstallError::ProfileDigestMismatch {
404 ref_name: candidate.r#ref.clone(),
405 expected: mismatch.expected,
406 actual: mismatch.actual,
407 })?;
408 Ok(profile_digest)
409}
410
411fn validate_candidate_package_digest(
412 candidate: &InstallCandidate,
413 allow_unsigned_local: bool,
414) -> Result<(), InstallError> {
415 let actual = registry_package_digest(&candidate.package_files);
416 let signed = candidate
417 .signed_manifest
418 .as_ref()
419 .and_then(|manifest| manifest.package_digest.as_ref());
420 check_digest_link(
421 actual.as_deref(),
422 candidate.package_digest.as_deref(),
423 "registry package digest",
424 )
425 .map_err(|mismatch| InstallError::PackageDigestMismatch {
426 ref_name: candidate.r#ref.clone(),
427 expected: mismatch.expected,
428 actual: mismatch.actual,
429 })?;
430 if allow_unsigned_local && candidate.signed_manifest.is_none() {
431 return Ok(());
432 }
433 check_digest_link(
434 candidate.package_digest.as_deref(),
435 signed.map(String::as_str),
436 "signed manifest package digest",
437 )
438 .map_err(|mismatch| InstallError::PackageDigestMismatch {
439 ref_name: candidate.r#ref.clone(),
440 expected: mismatch.expected,
441 actual: mismatch.actual,
442 })
443}
444
445fn validate_manifest_identity(
446 candidate: &InstallCandidate,
447 manifest: &RegistrySignedManifest,
448) -> Result<(), InstallError> {
449 let Some(skill_id) = &candidate.skill_id else {
450 return Err(InstallError::ManifestIdentityMissing {
451 ref_name: candidate.r#ref.clone(),
452 field: "skill_id",
453 });
454 };
455 if &manifest.skill_id != skill_id {
456 return Err(InstallError::ManifestIdentityMismatch {
457 ref_name: candidate.r#ref.clone(),
458 expected: skill_id.clone(),
459 actual: manifest.skill_id.clone(),
460 });
461 }
462 let Some(version) = &candidate.version else {
463 return Err(InstallError::ManifestIdentityMissing {
464 ref_name: candidate.r#ref.clone(),
465 field: "version",
466 });
467 };
468 if &manifest.version != version {
469 return Err(InstallError::ManifestIdentityMismatch {
470 ref_name: candidate.r#ref.clone(),
471 expected: version.clone(),
472 actual: manifest.version.clone(),
473 });
474 }
475 Ok(())
476}
477
478fn manifest_verification_error(
479 ref_name: String,
480 key_id: &str,
481 failure: RegistryManifestVerificationFailure,
482) -> InstallError {
483 match failure {
484 RegistryManifestVerificationFailure::UnknownKey => InstallError::UnknownManifestKey {
485 ref_name,
486 key_id: key_id.to_owned(),
487 },
488 RegistryManifestVerificationFailure::UnsupportedSchema => {
489 InstallError::InvalidManifestSignature {
490 ref_name,
491 reason: "unsupported schema".to_owned(),
492 }
493 }
494 RegistryManifestVerificationFailure::UnsupportedAlgorithm => {
495 InstallError::InvalidManifestSignature {
496 ref_name,
497 reason: "unsupported algorithm".to_owned(),
498 }
499 }
500 RegistryManifestVerificationFailure::MalformedPayload => {
501 InstallError::InvalidManifestSignature {
502 ref_name,
503 reason: "malformed payload".to_owned(),
504 }
505 }
506 RegistryManifestVerificationFailure::MalformedKey => {
507 InstallError::InvalidManifestSignature {
508 ref_name,
509 reason: "malformed key".to_owned(),
510 }
511 }
512 RegistryManifestVerificationFailure::MalformedSignature => {
513 InstallError::InvalidManifestSignature {
514 ref_name,
515 reason: "malformed signature".to_owned(),
516 }
517 }
518 RegistryManifestVerificationFailure::SignatureMismatch => {
519 InstallError::InvalidManifestSignature {
520 ref_name,
521 reason: "signature mismatch".to_owned(),
522 }
523 }
524 }
525}
526
527fn next_profile_state(
528 candidate: &InstallCandidate,
529 install: &ValidatedSkillInstall,
530 actual_digest: &str,
531 profile_digest: Option<&str>,
532 runner_names: &[String],
533) -> Result<Option<String>, InstallError> {
534 let Some(document) = &candidate.profile_document else {
535 return Ok(None);
536 };
537 Ok(Some(profile_state(
538 &install.skill.name,
539 actual_digest,
540 document,
541 profile_digest,
542 runner_names,
543 &serde_json::to_value(&install.origin)?,
544 )?))
545}
546
547fn install_origin(
548 candidate: &InstallCandidate,
549 actual_digest: &str,
550 profile_digest: Option<&str>,
551) -> SkillInstallOrigin {
552 SkillInstallOrigin {
553 source: candidate.source.clone(),
554 source_label: candidate.source_label.clone(),
555 r#ref: candidate.r#ref.clone(),
556 skill_id: candidate.skill_id.clone(),
557 version: candidate.version.clone(),
558 digest: Some(actual_digest.to_owned()),
559 profile_digest: profile_digest.map(ToOwned::to_owned),
560 runner_names: Some(candidate.runner_names.clone()),
561 trust_tier: candidate.trust_tier.as_ref().map(trust_tier_string),
562 }
563}
564
565fn install_paths(
566 candidate: &InstallCandidate,
567 options: &InstallLocalSkillOptions,
568 skill_name: &str,
569) -> InstallPaths {
570 let package_parts =
571 safe_skill_package_parts(&candidate.r#ref, skill_name, candidate.version.as_deref());
572 let package_root = package_parts
573 .iter()
574 .fold(options.destination_root.clone(), |path, part| {
575 path.join(part)
576 });
577 let destination = package_root.join("SKILL.md");
578 let profile_state_path = candidate
579 .profile_document
580 .as_ref()
581 .map(|_| package_root.join(".runx").join("profile.json"));
582 let runner_manifest_path = candidate
583 .profile_document
584 .as_ref()
585 .map(|_| package_root.join("X.yaml"));
586 InstallPaths {
587 package_root,
588 destination,
589 profile_state_path,
590 runner_manifest_path,
591 }
592}
593
594fn prepare_install_write_plan(
597 paths: &InstallPaths,
598 markdown: &str,
599 profile_document: Option<&str>,
600 package_files: &[RegistryPackageFile],
601 ref_name: &str,
602 next_profile_state: Option<&str>,
603) -> Result<InstallWritePlan, InstallError> {
604 let existing = read_optional(&paths.destination)?;
605 let existing_profile = match &paths.profile_state_path {
606 Some(path) => read_optional(path)?,
607 None => None,
608 };
609 let existing_runner_manifest = match &paths.runner_manifest_path {
610 Some(path) => read_optional(path)?,
611 None => None,
612 };
613 if let Some(existing) = &existing {
614 if sha256_prefixed(existing.as_bytes()) != sha256_prefixed(markdown.as_bytes()) {
615 return Err(InstallError::ConflictingSkill(paths.destination.clone()));
616 }
617 }
618 if let (Some(path), Some(existing), Some(next)) = (
619 &paths.profile_state_path,
620 &existing_profile,
621 next_profile_state,
622 ) {
623 if existing != next {
624 return Err(InstallError::ConflictingProfile(path.clone()));
625 }
626 }
627 if let (Some(path), Some(existing), Some(next)) = (
628 &paths.runner_manifest_path,
629 &existing_runner_manifest,
630 profile_document,
631 ) {
632 if existing != next {
633 return Err(InstallError::ConflictingRunnerManifest(path.clone()));
634 }
635 }
636 let mut seen_package_paths = BTreeSet::new();
637 let mut package_file_plans = Vec::with_capacity(package_files.len());
638 for file in package_files {
639 validate_registry_package_file_path(&file.path).map_err(|reason| {
640 InstallError::InvalidPackageFile {
641 ref_name: ref_name.to_owned(),
642 reason,
643 }
644 })?;
645 if !seen_package_paths.insert(file.path.clone()) {
646 return Err(InstallError::InvalidPackageFile {
647 ref_name: ref_name.to_owned(),
648 reason: format!("duplicate package file '{}'", file.path),
649 });
650 }
651 let path = paths.package_root.join(&file.path);
652 let existing = read_optional(&path)?;
653 if let Some(existing) = &existing {
654 if existing != &file.content {
655 return Err(InstallError::ConflictingPackageFile(path));
656 }
657 }
658 package_file_plans.push(PackageFileWritePlan {
659 path,
660 content: file.content.clone(),
661 write: existing.is_none(),
662 });
663 }
664 Ok(InstallWritePlan {
665 writes_skill: existing.is_none(),
666 writes_profile_state: paths.profile_state_path.is_some() && existing_profile.is_none(),
667 writes_runner_manifest: paths.runner_manifest_path.is_some()
668 && existing_runner_manifest.is_none(),
669 package_files: package_file_plans,
670 })
671}
672
673fn commit_install_write_plan(
674 paths: &InstallPaths,
675 write_plan: &InstallWritePlan,
676 markdown: &str,
677 profile_document: Option<&str>,
678 package_files: &[RegistryPackageFile],
679 next_profile_state: Option<&str>,
680) -> Result<(), InstallError> {
681 debug_assert_eq!(write_plan.package_files.len(), package_files.len());
682 fs::create_dir_all(&paths.package_root).map_err(|source| InstallError::Io {
683 path: paths.package_root.clone(),
684 source,
685 })?;
686 if write_plan.writes_skill {
687 write_atomic(&paths.destination, markdown)?;
688 }
689 if let (Some(path), true, Some(next)) = (
690 &paths.profile_state_path,
691 write_plan.writes_profile_state,
692 next_profile_state,
693 ) {
694 let parent = path.parent().unwrap_or(&paths.package_root);
695 fs::create_dir_all(parent).map_err(|source| InstallError::Io {
696 path: parent.to_path_buf(),
697 source,
698 })?;
699 write_atomic(path, next)?;
700 }
701 if let (Some(path), true, Some(document)) = (
702 &paths.runner_manifest_path,
703 write_plan.writes_runner_manifest,
704 profile_document,
705 ) {
706 write_atomic(path, document)?;
707 }
708 for file in &write_plan.package_files {
709 if !file.write {
710 continue;
711 }
712 if let Some(parent) = file.path.parent() {
713 fs::create_dir_all(parent).map_err(|source| InstallError::Io {
714 path: parent.to_path_buf(),
715 source,
716 })?;
717 }
718 write_atomic(&file.path, &file.content)?;
719 }
720 Ok(())
721}
722
723fn validate_install_binding_manifest(
724 skill_name: &str,
725 profile_document: Option<&str>,
726 advertised_runner_names: &[String],
727) -> Result<Vec<String>, InstallError> {
728 let Some(profile_document) = profile_document else {
729 return Ok(advertised_runner_names.to_vec());
730 };
731 let manifest = validate_runner_manifest(parse_runner_manifest_yaml(profile_document)?)?;
732 if let Some(manifest_skill) = manifest.skill {
733 if manifest_skill != skill_name {
734 return Err(InstallError::ManifestSkillMismatch {
735 manifest_skill,
736 skill_name: skill_name.to_owned(),
737 });
738 }
739 }
740 let runner_names = manifest.runners.keys().cloned().collect::<Vec<_>>();
741 if !advertised_runner_names.is_empty() && advertised_runner_names != runner_names {
742 return Err(InstallError::RunnerMetadataMismatch(skill_name.to_owned()));
743 }
744 Ok(runner_names)
745}
746
747fn profile_state(
748 skill_name: &str,
749 digest: &str,
750 profile_document: &str,
751 profile_digest: Option<&str>,
752 runner_names: &[String],
753 origin: &Value,
754) -> Result<String, serde_json::Error> {
755 let value = json!({
756 "schema_version": "runx.skill-profile.v1",
757 "skill": {
758 "name": skill_name,
759 "path": "SKILL.md",
760 "digest": digest,
761 },
762 "profile": {
763 "document": profile_document,
764 "digest": profile_digest,
765 "runner_names": runner_names,
766 },
767 "origin": origin,
768 });
769 serde_json::to_string_pretty(&value).map(|mut contents| {
770 contents.push('\n');
771 contents
772 })
773}
774
775fn write_atomic(destination: &Path, contents: &str) -> Result<(), InstallError> {
776 let temp_path = destination.with_extension(format!("tmp-{}", unique_suffix()));
777 fs::write(&temp_path, contents).map_err(|source| InstallError::Io {
778 path: temp_path.clone(),
779 source,
780 })?;
781 if destination.exists() {
782 let _ = fs::remove_file(&temp_path);
783 return Err(InstallError::Io {
784 path: destination.to_path_buf(),
785 source: io::Error::new(io::ErrorKind::AlreadyExists, "destination exists"),
786 });
787 }
788 fs::rename(&temp_path, destination).map_err(|source| {
789 let _ = fs::remove_file(&temp_path);
790 InstallError::Io {
791 path: destination.to_path_buf(),
792 source,
793 }
794 })
795}
796
797fn read_optional(path: &Path) -> Result<Option<String>, InstallError> {
798 match fs::read_to_string(path) {
799 Ok(contents) => Ok(Some(contents)),
800 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
801 Err(source) => Err(InstallError::Io {
802 path: path.to_path_buf(),
803 source,
804 }),
805 }
806}
807
808fn digest_matches(expected: &str, actual_prefixed: &str) -> bool {
809 expected == actual_prefixed
810 || actual_prefixed
811 .strip_prefix("sha256:")
812 .is_some_and(|actual_hex| expected == actual_hex)
813}
814
815fn trust_tier_string(value: &TrustTier) -> String {
816 match value {
817 TrustTier::FirstParty => "first_party",
818 TrustTier::Verified => "verified",
819 TrustTier::Community => "community",
820 }
821 .to_owned()
822}
823
824fn unique_suffix() -> String {
825 let nanos = SystemTime::now()
826 .duration_since(UNIX_EPOCH)
827 .map(|duration| duration.as_nanos())
828 .unwrap_or(0);
829 format!("{}-{nanos}", std::process::id())
830}