1use std::collections::BTreeMap;
4use std::fs;
5use std::io;
6use std::path::{Path, PathBuf};
7
8use aes_gcm::Aes256Gcm;
9use aes_gcm::aead::{Aead, Generate, KeyInit, Nonce};
10use base64::Engine;
11use base64::engine::general_purpose::URL_SAFE_NO_PAD;
12use runx_contracts::JsonValue;
13use runx_contracts::schema::NonEmptyString;
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16use thiserror::Error;
17
18use crate::credentials::SecretString;
19
20#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(deny_unknown_fields)]
22pub struct RunxConfigFile {
23 #[serde(skip_serializing_if = "Option::is_none")]
24 pub agent: Option<RunxAgentConfig>,
25 #[serde(skip_serializing_if = "Option::is_none")]
26 pub public: Option<RunxPublicConfig>,
27}
28
29#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct RunxAgentConfig {
32 #[serde(skip_serializing_if = "Option::is_none")]
33 pub provider: Option<String>,
34 #[serde(skip_serializing_if = "Option::is_none")]
35 pub model: Option<String>,
36 #[serde(skip_serializing_if = "Option::is_none")]
37 pub api_key_ref: Option<String>,
38}
39
40#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(deny_unknown_fields)]
42pub struct RunxPublicConfig {
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub api_token_ref: Option<String>,
45}
46
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48pub enum ConfigKey {
49 AgentProvider,
50 AgentModel,
51 AgentApiKey,
52 PublicApiToken,
53}
54
55pub mod managed_agent_provider {
59 pub const OPENAI: &str = "openai";
61 pub const ANTHROPIC: &str = "anthropic";
63}
64
65#[derive(Clone, Debug, PartialEq, Eq)]
66pub struct ManagedAgentConfig {
67 pub provider: NonEmptyString,
70 pub model: String,
71 pub api_key: SecretString,
72}
73
74#[derive(Clone, Debug, PartialEq, Eq)]
75pub enum LocalProfileSource {
76 ProfileState,
77 SkillProfile,
78 WorkspaceBindings,
79 None,
80}
81
82#[derive(Clone, Debug, PartialEq, Eq)]
83pub struct ResolvedLocalProfile {
84 pub profile_document: Option<String>,
85 pub profile_source_path: Option<PathBuf>,
86 pub source: LocalProfileSource,
87}
88
89#[derive(Debug, Error)]
90pub enum ConfigError {
91 #[error("{path} is not valid JSON: {message}")]
92 InvalidJson { path: PathBuf, message: String },
93 #[error("{path} must contain a JSON object.")]
94 NonObjectJson { path: PathBuf },
95 #[error("unsupported runx config key {key}")]
96 UnsupportedKey { key: String },
97 #[error("runx local agent key corrupted or unreadable at {path}{suffix}")]
98 LocalAgentKeyCorrupt { path: PathBuf, suffix: String },
99 #[error("Skill profile state is not valid JSON: {path}")]
100 InvalidProfileStateJson { path: PathBuf },
101 #[error("Skill profile state must be an object: {path}")]
102 NonObjectProfileState { path: PathBuf },
103 #[error(
104 "Binding manifest skill '{manifest_skill}' does not match skill '{skill_name}': {path}"
105 )]
106 ManifestSkillMismatch {
107 manifest_skill: String,
108 skill_name: String,
109 path: PathBuf,
110 },
111 #[error(
112 "Skill package '{skill_directory}' resolves to binding path {owner}/{binding_skill}, but SKILL.md declares '{skill_name}'."
113 )]
114 BindingLocatorMismatch {
115 skill_directory: PathBuf,
116 owner: String,
117 binding_skill: String,
118 skill_name: String,
119 },
120 #[error("config crypto failed: {0}")]
121 Crypto(String),
122 #[error(transparent)]
123 Io(#[from] io::Error),
124}
125
126pub fn parse_config_key(key: &str) -> Result<ConfigKey, ConfigError> {
127 match key {
128 "agent.provider" => Ok(ConfigKey::AgentProvider),
129 "agent.model" => Ok(ConfigKey::AgentModel),
130 "agent.api_key" => Ok(ConfigKey::AgentApiKey),
131 "public.api_token" => Ok(ConfigKey::PublicApiToken),
132 _ => Err(ConfigError::UnsupportedKey {
133 key: key.to_owned(),
134 }),
135 }
136}
137
138pub fn resolve_path_from_user_input(
139 user_path: &str,
140 env: &BTreeMap<String, String>,
141 cwd: &Path,
142 prefer_existing: bool,
143) -> PathBuf {
144 let path = Path::new(user_path);
145 if path.is_absolute() {
146 return path.to_path_buf();
147 }
148 if prefer_existing {
149 for base in [
150 env.get("RUNX_CWD").map(PathBuf::from),
151 env.get("INIT_CWD").map(PathBuf::from),
152 find_runx_workspace_root(cwd),
153 Some(cwd.to_path_buf()),
154 ]
155 .into_iter()
156 .flatten()
157 {
158 let candidate = base.join(path);
159 if candidate.exists() {
160 return candidate;
161 }
162 }
163 }
164 resolve_runx_workspace_base(env, cwd).join(path)
165}
166
167pub fn resolve_runx_global_home_dir(env: &BTreeMap<String, String>, cwd: &Path) -> PathBuf {
168 env.get("RUNX_HOME").map_or_else(
169 || home_dir().join(".runx"),
170 |home| resolve_path_from_user_input(home, env, cwd, false),
171 )
172}
173
174pub fn resolve_runx_home_dir(env: &BTreeMap<String, String>, cwd: &Path) -> PathBuf {
175 resolve_runx_global_home_dir(env, cwd)
176}
177
178pub fn load_runx_config_file(config_path: &Path) -> Result<RunxConfigFile, ConfigError> {
179 let contents = match fs::read_to_string(config_path) {
180 Ok(contents) => contents,
181 Err(error) if error.kind() == io::ErrorKind::NotFound => {
182 return Ok(RunxConfigFile::default());
183 }
184 Err(error) => return Err(ConfigError::Io(error)),
185 };
186 let value =
187 serde_json::from_str::<JsonValue>(&contents).map_err(|error| ConfigError::InvalidJson {
188 path: config_path.to_path_buf(),
189 message: error.to_string(),
190 })?;
191 if !matches!(value, JsonValue::Object(_)) {
192 return Err(ConfigError::NonObjectJson {
193 path: config_path.to_path_buf(),
194 });
195 }
196 serde_json::from_str(&contents).map_err(|error| ConfigError::InvalidJson {
197 path: config_path.to_path_buf(),
198 message: error.to_string(),
199 })
200}
201
202pub fn write_runx_config_file(
203 config_path: &Path,
204 config: &RunxConfigFile,
205) -> Result<(), ConfigError> {
206 if let Some(parent) = config_path.parent() {
207 fs::create_dir_all(parent)?;
208 }
209 let contents =
210 serde_json::to_string_pretty(config).map_err(|error| ConfigError::InvalidJson {
211 path: config_path.to_path_buf(),
212 message: error.to_string(),
213 })?;
214 write_private_file(config_path, format!("{contents}\n").as_bytes())
215}
216
217pub fn update_runx_config_value(
218 mut config: RunxConfigFile,
219 key: ConfigKey,
220 value: &str,
221 config_dir: &Path,
222) -> Result<RunxConfigFile, ConfigError> {
223 match key {
224 ConfigKey::AgentProvider => {
225 let mut agent = config.agent.unwrap_or_default();
226 agent.provider = Some(value.to_owned());
227 config.agent = Some(agent);
228 }
229 ConfigKey::AgentModel => {
230 let mut agent = config.agent.unwrap_or_default();
231 agent.model = Some(value.to_owned());
232 config.agent = Some(agent);
233 }
234 ConfigKey::AgentApiKey => {
235 let mut agent = config.agent.unwrap_or_default();
236 agent.api_key_ref = Some(store_local_agent_api_key(config_dir, value)?);
237 config.agent = Some(agent);
238 }
239 ConfigKey::PublicApiToken => {
240 let mut public = config.public.unwrap_or_default();
241 public.api_token_ref = Some(store_local_public_api_token(config_dir, value)?);
242 config.public = Some(public);
243 }
244 }
245 Ok(config)
246}
247
248pub fn lookup_runx_config_value(config: &RunxConfigFile, key: ConfigKey) -> Option<String> {
249 match key {
250 ConfigKey::AgentProvider => config.agent.as_ref()?.provider.clone(),
251 ConfigKey::AgentModel => config.agent.as_ref()?.model.clone(),
252 ConfigKey::AgentApiKey => config
253 .agent
254 .as_ref()?
255 .api_key_ref
256 .as_ref()
257 .map(|_| "[encrypted]".to_owned()),
258 ConfigKey::PublicApiToken => config
259 .public
260 .as_ref()?
261 .api_token_ref
262 .as_ref()
263 .map(|_| "[encrypted]".to_owned()),
264 }
265}
266
267pub fn mask_runx_config_file(config: &RunxConfigFile) -> RunxConfigFile {
268 let mut masked = config.clone();
269 if let Some(agent) = masked.agent.as_mut()
270 && agent.api_key_ref.is_some()
271 {
272 agent.api_key_ref = Some("[encrypted]".to_owned());
273 }
274 if let Some(public) = masked.public.as_mut()
275 && public.api_token_ref.is_some()
276 {
277 public.api_token_ref = Some("[encrypted]".to_owned());
278 }
279 masked
280}
281
282pub fn load_local_agent_api_key(config_dir: &Path, key_ref: &str) -> Result<String, ConfigError> {
283 load_local_config_secret_value(config_dir, key_ref)
284}
285
286pub fn load_local_public_api_token(
287 config_dir: &Path,
288 token_ref: &str,
289) -> Result<String, ConfigError> {
290 load_local_config_secret_value(config_dir, token_ref)
291}
292
293fn load_local_config_secret_value(config_dir: &Path, key_ref: &str) -> Result<String, ConfigError> {
294 let key_path = config_dir.join("keys").join(format!("{key_ref}.json"));
295 let payload = load_key_payload(&key_path)?;
296 if payload.alg != "aes-256-gcm" {
297 return Err(config_key_read_error(&key_path, None));
298 }
299 let secret = load_or_create_local_config_secret(&config_dir.join("keys"))?;
300 let key = Sha256::digest(secret.as_bytes());
301 let cipher =
302 Aes256Gcm::new_from_slice(&key).map_err(|error| ConfigError::Crypto(error.to_string()))?;
303 let nonce_bytes = decode_key_part(&key_path, &payload.iv)?;
304 let ciphertext = decode_key_part(&key_path, &payload.ciphertext)?;
305 let auth_tag = decode_key_part(&key_path, &payload.auth_tag)?;
306 let mut sealed = ciphertext;
307 sealed.extend(auth_tag);
308 let nonce = config_nonce(&key_path, &nonce_bytes)?;
309 let plaintext = cipher
310 .decrypt(&nonce, sealed.as_ref())
311 .map_err(|error| config_key_read_error(&key_path, Some(error.to_string())))?;
312 String::from_utf8(plaintext)
313 .map_err(|error| config_key_read_error(&key_path, Some(error.to_string())))
314}
315
316pub fn load_managed_agent_config(
317 env: &BTreeMap<String, String>,
318 cwd: &Path,
319) -> Result<Option<ManagedAgentConfig>, ConfigError> {
320 let config_dir = resolve_runx_home_dir(env, cwd);
321 let config = load_runx_config_file(&config_dir.join("config.json"))?;
322 let provider = env
323 .get("RUNX_AGENT_PROVIDER")
324 .or_else(|| {
325 config
326 .agent
327 .as_ref()
328 .and_then(|agent| agent.provider.as_ref())
329 })
330 .and_then(|value| normalize_managed_agent_provider(value));
331 let Some(provider) = provider else {
332 return Ok(None);
333 };
334 let model = env
335 .get("RUNX_AGENT_MODEL")
336 .or_else(|| config.agent.as_ref().and_then(|agent| agent.model.as_ref()))
337 .map(|value| value.trim().to_owned())
338 .unwrap_or_default();
339 if model.is_empty() {
340 return Ok(None);
341 }
342 let provider_env_var = managed_agent_provider_env_var(&provider);
343 let provider_key = env.get(&provider_env_var);
344 let mut api_key = env
345 .get("RUNX_AGENT_API_KEY")
346 .or(provider_key)
347 .map(|value| value.trim().to_owned())
348 .unwrap_or_default();
349 if api_key.is_empty()
350 && let Some(key_ref) = config
351 .agent
352 .as_ref()
353 .and_then(|agent| agent.api_key_ref.as_ref())
354 .filter(|value| !value.is_empty())
355 {
356 api_key = load_local_agent_api_key(&config_dir, key_ref)?
357 .trim()
358 .to_owned();
359 }
360 if api_key.is_empty() {
361 return Ok(None);
362 }
363 Ok(Some(ManagedAgentConfig {
364 provider,
365 model,
366 api_key: SecretString::new(api_key),
367 }))
368}
369
370pub fn resolve_local_skill_profile(
371 skill_path: &Path,
372 skill_name: &str,
373) -> Result<ResolvedLocalProfile, ConfigError> {
374 let metadata = fs::metadata(skill_path)?;
375 let skill_directory = if metadata.is_dir() {
376 skill_path.to_path_buf()
377 } else {
378 skill_path
379 .parent()
380 .map(Path::to_path_buf)
381 .unwrap_or_else(|| PathBuf::from("."))
382 };
383 if let Some(profile) = read_skill_profile(&skill_directory, skill_name)? {
384 return Ok(profile);
385 }
386 if let Some(profile) = read_profile_state(&skill_directory, skill_name)? {
387 return Ok(profile);
388 }
389 for binding_root in collect_binding_roots(&skill_directory) {
390 if let Some(profile) = read_workspace_profile(&skill_directory, &binding_root, skill_name)?
391 {
392 return Ok(profile);
393 }
394 }
395 Ok(ResolvedLocalProfile {
396 profile_document: None,
397 profile_source_path: None,
398 source: LocalProfileSource::None,
399 })
400}
401
402#[derive(Deserialize)]
403struct LocalConfigSecretPayload {
404 alg: String,
405 iv: String,
406 ciphertext: String,
407 auth_tag: String,
408}
409
410#[derive(Serialize)]
411struct StoredLocalConfigSecretPayload<'a> {
412 #[serde(rename = "ref")]
413 key_ref: &'a str,
414 alg: &'static str,
415 iv: String,
416 ciphertext: String,
417 auth_tag: String,
418}
419
420type ConfigNonce = Nonce<Aes256Gcm>;
421
422fn config_nonce(key_path: &Path, nonce_bytes: &[u8]) -> Result<ConfigNonce, ConfigError> {
423 ConfigNonce::try_from(nonce_bytes).map_err(|_| {
424 config_key_read_error(
425 key_path,
426 Some(format!(
427 "expected 12-byte aes-256-gcm nonce, found {} bytes",
428 nonce_bytes.len()
429 )),
430 )
431 })
432}
433
434fn random_config_nonce() -> Result<ConfigNonce, ConfigError> {
435 ConfigNonce::try_generate().map_err(|error| ConfigError::Crypto(error.to_string()))
436}
437
438fn random_config_secret_bytes() -> Result<[u8; 32], ConfigError> {
439 <[u8; 32]>::try_generate().map_err(|error| ConfigError::Crypto(error.to_string()))
440}
441
442pub fn resolve_runx_workspace_base(env: &BTreeMap<String, String>, cwd: &Path) -> PathBuf {
443 env.get("RUNX_CWD")
444 .map(PathBuf::from)
445 .or_else(|| find_runx_workspace_root(cwd))
446 .or_else(|| env.get("INIT_CWD").map(PathBuf::from))
447 .unwrap_or_else(|| cwd.to_path_buf())
448}
449
450fn find_runx_workspace_root(start: &Path) -> Option<PathBuf> {
451 let mut current = start.to_path_buf();
452 loop {
453 if current.join("pnpm-workspace.yaml").exists() {
454 return Some(current);
455 }
456 if !current.pop() {
457 return None;
458 }
459 }
460}
461
462fn home_dir() -> PathBuf {
463 std::env::var_os("HOME")
464 .map(PathBuf::from)
465 .unwrap_or_else(|| PathBuf::from("."))
466}
467
468fn store_local_agent_api_key(config_dir: &Path, api_key: &str) -> Result<String, ConfigError> {
469 store_local_config_secret_value(config_dir, api_key, "local_agent_key")
470}
471
472fn store_local_public_api_token(config_dir: &Path, api_token: &str) -> Result<String, ConfigError> {
473 store_local_config_secret_value(config_dir, api_token, "local_public_api_token")
474}
475
476fn store_local_config_secret_value(
477 config_dir: &Path,
478 value: &str,
479 ref_prefix: &str,
480) -> Result<String, ConfigError> {
481 let key_dir = config_dir.join("keys");
482 fs::create_dir_all(&key_dir)?;
483 let secret = load_or_create_local_config_secret(&key_dir)?;
484 let key = Sha256::digest(secret.as_bytes());
485 let cipher =
486 Aes256Gcm::new_from_slice(&key).map_err(|error| ConfigError::Crypto(error.to_string()))?;
487 let nonce = random_config_nonce()?;
488 let mut sealed = cipher
489 .encrypt(&nonce, value.as_bytes())
490 .map_err(|error| ConfigError::Crypto(error.to_string()))?;
491 let auth_tag = sealed.split_off(sealed.len().saturating_sub(16));
492 let key_ref = format!(
493 "{ref_prefix}_{}",
494 hex_prefix(
495 &Sha256::digest([nonce.as_slice(), sealed.as_slice()].concat()),
496 24
497 )
498 );
499 let payload = StoredLocalConfigSecretPayload {
500 key_ref: &key_ref,
501 alg: "aes-256-gcm",
502 iv: URL_SAFE_NO_PAD.encode(nonce),
503 ciphertext: URL_SAFE_NO_PAD.encode(sealed),
504 auth_tag: URL_SAFE_NO_PAD.encode(auth_tag),
505 };
506 let contents = serde_json::to_string_pretty(&payload)
507 .map_err(|error| ConfigError::Crypto(error.to_string()))?;
508 write_private_file(
509 &key_dir.join(format!("{key_ref}.json")),
510 format!("{contents}\n").as_bytes(),
511 )?;
512 Ok(key_ref)
513}
514
515fn load_or_create_local_config_secret(key_dir: &Path) -> Result<String, ConfigError> {
516 fs::create_dir_all(key_dir)?;
517 let key_path = key_dir.join("local-config-secret");
518 match fs::read_to_string(&key_path) {
519 Ok(secret) => Ok(secret),
520 Err(error) if error.kind() == io::ErrorKind::NotFound => {
521 let secret_bytes = random_config_secret_bytes()?;
522 let secret = URL_SAFE_NO_PAD.encode(secret_bytes);
523 match write_private_file_new(&key_path, secret.as_bytes()) {
524 Ok(()) => Ok(secret),
525 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
526 Ok(fs::read_to_string(&key_path)?)
527 }
528 Err(error) => Err(ConfigError::Io(error)),
529 }
530 }
531 Err(error) => Err(ConfigError::Io(error)),
532 }
533}
534
535fn load_key_payload(key_path: &Path) -> Result<LocalConfigSecretPayload, ConfigError> {
536 let contents = fs::read_to_string(key_path)
537 .map_err(|error| config_key_read_error(key_path, Some(error.to_string())))?;
538 serde_json::from_str(&contents)
539 .map_err(|error| config_key_read_error(key_path, Some(error.to_string())))
540}
541
542fn decode_key_part(key_path: &Path, value: &str) -> Result<Vec<u8>, ConfigError> {
543 URL_SAFE_NO_PAD
544 .decode(value)
545 .map_err(|error| config_key_read_error(key_path, Some(error.to_string())))
546}
547
548fn config_key_read_error(path: &Path, cause: Option<String>) -> ConfigError {
549 ConfigError::LocalAgentKeyCorrupt {
550 path: path.to_path_buf(),
551 suffix: cause.map_or_else(String::new, |message| format!(": {message}")),
552 }
553}
554
555fn normalize_managed_agent_provider(value: &str) -> Option<NonEmptyString> {
556 NonEmptyString::new(value.trim().to_lowercase())
557}
558
559fn managed_agent_provider_env_var(provider: &NonEmptyString) -> String {
564 format!("{}_API_KEY", provider.as_ref().to_uppercase())
565}
566
567fn read_skill_profile(
568 skill_directory: &Path,
569 skill_name: &str,
570) -> Result<Option<ResolvedLocalProfile>, ConfigError> {
571 let path = skill_directory.join("X.yaml");
572 let Some(document) = read_optional_file(&path)? else {
573 return Ok(None);
574 };
575 validate_manifest_skill(&path, &document, skill_name)?;
576 Ok(Some(ResolvedLocalProfile {
577 profile_document: Some(document),
578 profile_source_path: Some(path),
579 source: LocalProfileSource::SkillProfile,
580 }))
581}
582
583fn read_profile_state(
584 skill_directory: &Path,
585 skill_name: &str,
586) -> Result<Option<ResolvedLocalProfile>, ConfigError> {
587 let path = skill_directory.join(".runx").join("profile.json");
588 let Some(document) = read_optional_file(&path)? else {
589 return Ok(None);
590 };
591 let value = serde_json::from_str::<JsonValue>(&document)
592 .map_err(|_| ConfigError::InvalidProfileStateJson { path: path.clone() })?;
593 let JsonValue::Object(object) = value else {
594 return Err(ConfigError::NonObjectProfileState { path });
595 };
596 let Some(JsonValue::Object(profile)) = object.get("profile") else {
597 return Ok(None);
598 };
599 let Some(profile_document) = profile
600 .get("document")
601 .and_then(JsonValue::as_str)
602 .filter(|value| !value.is_empty())
603 else {
604 return Ok(None);
605 };
606 validate_manifest_skill(&path, profile_document, skill_name)?;
607 Ok(Some(ResolvedLocalProfile {
608 profile_document: Some(profile_document.to_owned()),
609 profile_source_path: Some(path),
610 source: LocalProfileSource::ProfileState,
611 }))
612}
613
614fn collect_binding_roots(start: &Path) -> Vec<PathBuf> {
615 let mut roots = Vec::new();
616 let mut current = start.to_path_buf();
617 loop {
618 let candidate = current.join("bindings");
619 if candidate.exists() && !roots.contains(&candidate) {
620 roots.push(candidate);
621 }
622 if !current.pop() {
623 break;
624 }
625 }
626 roots
627}
628
629fn read_workspace_profile(
630 skill_directory: &Path,
631 binding_root: &Path,
632 skill_name: &str,
633) -> Result<Option<ResolvedLocalProfile>, ConfigError> {
634 let Some((owner, binding_skill)) = resolve_binding_locator(skill_directory, binding_root)
635 else {
636 return Ok(None);
637 };
638 if binding_skill != skill_name {
639 return Err(ConfigError::BindingLocatorMismatch {
640 skill_directory: skill_directory.to_path_buf(),
641 owner,
642 binding_skill,
643 skill_name: skill_name.to_owned(),
644 });
645 }
646 let path = binding_root
647 .join(&owner)
648 .join(&binding_skill)
649 .join("X.yaml");
650 let Some(document) = read_optional_file(&path)? else {
651 return Ok(None);
652 };
653 validate_manifest_skill(&path, &document, skill_name)?;
654 Ok(Some(ResolvedLocalProfile {
655 profile_document: Some(document),
656 profile_source_path: Some(path),
657 source: LocalProfileSource::WorkspaceBindings,
658 }))
659}
660
661fn resolve_binding_locator(
662 skill_directory: &Path,
663 binding_root: &Path,
664) -> Option<(String, String)> {
665 let binding_container = binding_root.parent()?;
666 let relative = skill_directory.strip_prefix(binding_container).ok()?;
667 let segments = relative
668 .components()
669 .map(|component| component.as_os_str().to_string_lossy().to_string())
670 .collect::<Vec<_>>();
671 let skill_segments = (segments.first()? == "skills").then_some(&segments[1..])?;
672 match skill_segments {
673 [skill] => Some(("runx".to_owned(), skill.clone())),
674 [owner, skill] => Some((owner.clone(), skill.clone())),
675 _ => None,
676 }
677}
678
679fn validate_manifest_skill(
680 path: &Path,
681 manifest_text: &str,
682 skill_name: &str,
683) -> Result<(), ConfigError> {
684 let value = serde_norway::from_str::<JsonValue>(manifest_text).map_err(|error| {
685 ConfigError::InvalidJson {
686 path: path.to_path_buf(),
687 message: error.to_string(),
688 }
689 })?;
690 let manifest_skill = match &value {
691 JsonValue::Object(object) => object.get("skill").and_then(JsonValue::as_str),
692 _ => None,
693 };
694 if let Some(manifest_skill) = manifest_skill
695 && manifest_skill != skill_name
696 {
697 return Err(ConfigError::ManifestSkillMismatch {
698 manifest_skill: manifest_skill.to_owned(),
699 skill_name: skill_name.to_owned(),
700 path: path.to_path_buf(),
701 });
702 }
703 Ok(())
704}
705
706fn read_optional_file(path: &Path) -> Result<Option<String>, ConfigError> {
707 match fs::read_to_string(path) {
708 Ok(contents) => Ok(Some(contents)),
709 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
710 Err(error) => Err(ConfigError::Io(error)),
711 }
712}
713
714fn write_private_file(path: &Path, contents: &[u8]) -> Result<(), ConfigError> {
715 if let Some(parent) = path.parent() {
716 fs::create_dir_all(parent)?;
717 }
718 fs::write(path, contents)?;
719 set_private_permissions(path)?;
720 Ok(())
721}
722
723fn write_private_file_new(path: &Path, contents: &[u8]) -> io::Result<()> {
724 if let Some(parent) = path.parent() {
725 fs::create_dir_all(parent)?;
726 }
727 let mut options = fs::OpenOptions::new();
728 options.write(true).create_new(true);
729 #[cfg(unix)]
730 {
731 use std::os::unix::fs::OpenOptionsExt;
732 options.mode(0o600);
733 }
734 use std::io::Write;
735 let mut file = options.open(path)?;
736 file.write_all(contents)
737}
738
739fn set_private_permissions(path: &Path) -> Result<(), ConfigError> {
740 #[cfg(unix)]
741 {
742 use std::os::unix::fs::PermissionsExt;
743 fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
744 }
745 Ok(())
746}
747
748fn hex_prefix(bytes: &[u8], len: usize) -> String {
749 let mut value = String::new();
750 for byte in bytes {
751 value.push_str(&format!("{byte:02x}"));
752 }
753 value.chars().take(len).collect()
754}