Skip to main content

runx_runtime/registry/
install.rs

1// rust-style-allow: large-file because local registry installs keep digest
2// validation, binding checks, conflict planning, and atomic writes in one
3// transaction module.
4use 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
594// rust-style-allow: long-function - install planning compares all destination
595// files before writing so package, profile, and lock updates remain atomic.
596fn 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}