use mkit_core::layout::RepoLayout;
use std::fmt::Write as _;
use std::fs;
use std::io;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use thiserror::Error;
pub const CONFIG_FILE: &str = ".mkit/config";
pub const USER_CONFIG_SUBPATH: &str = "mkit/config";
pub const DEFAULT_SIGNING_KEY: &str = ".mkit/keys/default.key";
pub const DEFAULT_BRANCH: &str = "main";
pub const DEFAULT_SIGNER: &str = "legacy";
pub const DEFAULT_KEY_BACKEND: &str = "software";
pub const DEFAULT_KEY_REF: &str = "software:default";
pub const DEFAULT_SECP256K1_KEY_REF: &str = "software:default-secp256k1";
pub const DEFAULT_P256_KEY_REF: &str = "software:default-p256";
pub const REPO_FORBIDDEN_KEYS: &[&str] = &[
"user.identity",
"trusted_remote_endpoint",
"signer",
"pull.require_signed",
"key.backend",
"key.default_ref",
"key.ed25519_ref",
"key.secp256k1_ref",
"key.p256_ref",
"signing_key",
"ssh.strict_host_key_checking",
"ssh.user_known_hosts_file",
"ssh.identity_file",
"attest.signer",
"attest.default_algorithm",
"attest.external_signer_path",
"attest.external_signer_args",
"attest.external_signer_timeout_secs",
"attest.secp256k1_key_path",
"attest.p256_key_path",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigScope {
Repo,
User,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Config {
pub user_identity: String,
pub user_name: String,
pub user_email: String,
pub trusted_remote_endpoint: String,
pub signing_key: String,
pub default_branch: String,
pub remote_endpoint: String,
pub remote_bucket: String,
pub remote_type: String,
pub ssh_strict_host_key_checking: String,
pub ssh_user_known_hosts_file: String,
pub ssh_identity_file: String,
pub transport_auth: String,
pub signer: String,
pub pull_require_signed: String,
pub key: KeyConfig,
pub attest: AttestConfig,
pub remotes: std::collections::BTreeMap<String, RemoteEntry>,
pub branch_upstreams: std::collections::BTreeMap<String, Upstream>,
pub durability_objects: String,
pub core: std::collections::BTreeMap<String, String>,
}
pub const CORE_ALLOWED_KEYS: &[&str] = &[
"autocrlf",
"bare",
"filemode",
"ignorecase",
"quotepath",
"symlinks",
];
pub const CORE_DENIED_KEYS: &[&str] = &["editor", "fsmonitor", "hookspath", "pager", "sshcommand"];
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RemoteEntry {
pub url: String,
pub remote_type: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Upstream {
pub remote: String,
pub branch: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct KeyConfig {
pub backend: String,
pub default_ref: String,
pub ed25519_ref: String,
pub secp256k1_ref: String,
pub p256_ref: String,
}
impl KeyConfig {
#[must_use]
pub fn backend_or_fallback(&self) -> &str {
if self.backend.is_empty() {
DEFAULT_KEY_BACKEND
} else {
self.backend.as_str()
}
}
#[must_use]
pub fn default_ref_or_fallback(&self) -> &str {
if self.default_ref.is_empty() {
DEFAULT_KEY_REF
} else {
self.default_ref.as_str()
}
}
#[must_use]
pub fn ed25519_ref_or_fallback(&self) -> &str {
if self.ed25519_ref.is_empty() {
self.default_ref_or_fallback()
} else {
self.ed25519_ref.as_str()
}
}
#[must_use]
pub fn secp256k1_ref_or_fallback(&self) -> &str {
if self.secp256k1_ref.is_empty() {
if self.default_ref.is_empty() {
DEFAULT_SECP256K1_KEY_REF
} else {
self.default_ref.as_str()
}
} else {
self.secp256k1_ref.as_str()
}
}
#[must_use]
pub fn p256_ref_or_fallback(&self) -> &str {
if self.p256_ref.is_empty() {
if self.default_ref.is_empty() {
DEFAULT_P256_KEY_REF
} else {
self.default_ref.as_str()
}
} else {
self.p256_ref.as_str()
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LayeredConfig {
pub merged: Config,
pub user: Config,
pub repo: Config,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AttestConfig {
pub default_algorithm: String,
pub signer: String,
pub external_signer_path: String,
pub external_signer_args: Vec<String>,
pub external_signer_timeout_secs: Option<u64>,
pub secp256k1_key_path: String,
pub p256_key_path: String,
}
impl AttestConfig {
#[must_use]
pub fn default_algorithm_or_fallback(&self) -> &str {
if self.default_algorithm.is_empty() {
"ed25519"
} else {
self.default_algorithm.as_str()
}
}
#[must_use]
pub fn signer_or_fallback(&self) -> &str {
if self.signer.is_empty() {
"repo-key"
} else {
self.signer.as_str()
}
}
#[must_use]
pub fn secp256k1_key_path_or_default(&self) -> &str {
if self.secp256k1_key_path.is_empty() {
".mkit/keys/secp256k1.key"
} else {
self.secp256k1_key_path.as_str()
}
}
#[must_use]
pub fn p256_key_path_or_default(&self) -> &str {
if self.p256_key_path.is_empty() {
".mkit/keys/p256.key"
} else {
self.p256_key_path.as_str()
}
}
}
impl Config {
#[must_use]
pub fn with_defaults() -> Self {
Self {
signing_key: DEFAULT_SIGNING_KEY.to_owned(),
default_branch: DEFAULT_BRANCH.to_owned(),
signer: DEFAULT_SIGNER.to_owned(),
key: KeyConfig {
backend: DEFAULT_KEY_BACKEND.to_owned(),
default_ref: String::new(),
ed25519_ref: String::new(),
secp256k1_ref: String::new(),
p256_ref: String::new(),
},
..Self::default()
}
}
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("I/O: {0}")]
Io(#[from] io::Error),
#[error("invalid config value — control characters are not permitted")]
InvalidValue,
#[error("unknown config key: {0}")]
UnknownKey(String),
#[error("invalid user.identity: {0}")]
InvalidUserIdentity(&'static str),
#[error(
"key path must not contain `..`; relative paths must stay under `.mkit/keys/` and absolute paths must stay under `$HOME`: {0}"
)]
InvalidKeyPath(String),
}
impl Config {
#[must_use]
pub fn object_sync_policy(&self) -> mkit_core::store::SyncPolicy {
match self.durability_objects.trim() {
"per-object" | "per_object" => mkit_core::store::SyncPolicy::PerObject,
_ => mkit_core::store::SyncPolicy::Batch,
}
}
#[must_use]
pub fn pull_require_signed_or_default(&self) -> bool {
!matches!(
self.pull_require_signed
.trim()
.to_ascii_lowercase()
.as_str(),
"false" | "0" | "no" | "off"
)
}
#[must_use]
pub fn transport_auth_envelope(&self) -> bool {
self.transport_auth.trim().eq_ignore_ascii_case("envelope")
}
}
pub fn validate_key_path(value: &str) -> Result<(), ConfigError> {
if value.is_empty() {
return Ok(());
}
let p = Path::new(value);
for comp in p.components() {
if matches!(comp, std::path::Component::ParentDir) {
return Err(ConfigError::InvalidKeyPath(value.to_owned()));
}
}
Ok(())
}
pub fn resolve_key_path(layout: &RepoLayout, value: &str) -> Result<PathBuf, ConfigError> {
validate_key_path(value)?;
let path = Path::new(value);
if path.is_absolute() {
let Some(home) = home_dir_for_euid() else {
return Err(ConfigError::InvalidKeyPath(value.to_owned()));
};
return if path.starts_with(&home) {
Ok(path.to_path_buf())
} else {
Err(ConfigError::InvalidKeyPath(value.to_owned()))
};
}
let Ok(under_mkit) = path.strip_prefix(mkit_core::MKIT_DIR) else {
return Err(ConfigError::InvalidKeyPath(value.to_owned()));
};
let joined = layout.common_dir().join(under_mkit);
let repo_keys = layout.keys_dir();
if !joined.starts_with(&repo_keys) {
return Err(ConfigError::InvalidKeyPath(value.to_owned()));
}
Ok(joined)
}
#[cfg(unix)]
#[must_use]
pub fn home_dir_for_euid() -> Option<PathBuf> {
use std::ffi::CStr;
use std::os::unix::ffi::OsStringExt;
#[allow(unsafe_code)]
let pw_dir_owned = unsafe {
let mut buf = [0i8; 4096];
let mut pwd: libc::passwd = std::mem::zeroed();
let mut result: *mut libc::passwd = std::ptr::null_mut();
let rc = libc::getpwuid_r(
libc::geteuid(),
std::ptr::addr_of_mut!(pwd),
buf.as_mut_ptr().cast::<libc::c_char>(),
buf.len(),
std::ptr::addr_of_mut!(result),
);
if rc != 0 || result.is_null() || pwd.pw_dir.is_null() {
None
} else {
Some(CStr::from_ptr(pwd.pw_dir).to_bytes().to_vec())
}
};
let bytes = pw_dir_owned?;
if bytes.is_empty() {
return None;
}
Some(PathBuf::from(std::ffi::OsString::from_vec(bytes)))
}
#[cfg(not(unix))]
#[must_use]
pub fn home_dir_for_euid() -> Option<PathBuf> {
std::env::var_os("USERPROFILE").map(PathBuf::from)
}
#[must_use]
pub fn parse_pipe_list(s: &str) -> Vec<String> {
if s.is_empty() {
return Vec::new();
}
s.split('|').map(str::to_owned).collect()
}
pub fn validate_value(v: &str) -> Result<(), ConfigError> {
for b in v.bytes() {
if b < 0x20 || b == 0x7f {
return Err(ConfigError::InvalidValue);
}
}
Ok(())
}
#[must_use]
pub fn user_config_path() -> PathBuf {
xdg_config_home().join(USER_CONFIG_SUBPATH)
}
pub fn read_or_default(layout: &RepoLayout) -> Result<Config, ConfigError> {
let mut cfg = Config::with_defaults();
apply_file(&mut cfg, &user_config_path(), ConfigScope::User)?;
apply_file(&mut cfg, &layout.config_file(), ConfigScope::Repo)?;
apply_cli_overrides(&mut cfg);
Ok(cfg)
}
pub fn read_layered(layout: &RepoLayout) -> Result<LayeredConfig, ConfigError> {
let mut merged = Config::with_defaults();
let user_path = user_config_path();
let repo_path = layout.config_file();
apply_file_inner(&mut merged, &user_path, ConfigScope::User, true)?;
apply_file_inner(&mut merged, &repo_path, ConfigScope::Repo, true)?;
apply_cli_overrides(&mut merged);
let mut user = Config::default();
apply_file_inner(&mut user, &user_path, ConfigScope::User, false)?;
let mut repo = Config::default();
apply_file_inner(&mut repo, &repo_path, ConfigScope::Repo, false)?;
Ok(LayeredConfig { merged, user, repo })
}
static CLI_OVERRIDES: std::sync::OnceLock<std::sync::Mutex<Vec<(String, String)>>> =
std::sync::OnceLock::new();
pub fn set_cli_overrides(overrides: Vec<(String, String)>) {
let slot = CLI_OVERRIDES.get_or_init(|| std::sync::Mutex::new(Vec::new()));
if let Ok(mut guard) = slot.lock() {
*guard = overrides;
}
}
fn apply_cli_overrides(cfg: &mut Config) {
let Some(slot) = CLI_OVERRIDES.get() else {
return;
};
let Ok(overrides) = slot.lock() else {
return;
};
for (raw_key, val) in overrides.iter() {
let key = normalize_config_key(raw_key.trim());
if REPO_FORBIDDEN_KEYS.contains(&key.as_str()) {
let mut stderr = io::stderr().lock();
let _ = writeln!(
stderr,
"warning: ignoring `-c {key}=…` (security-sensitive keys cannot be set via -c; \
set it in your user config — see docs/THREAT-MODEL.md)"
);
continue;
}
if validate_value(val.trim()).is_err() {
let mut stderr = io::stderr().lock();
let _ = writeln!(
stderr,
"warning: ignoring `-c {key}=…` (value contains control characters)"
);
continue;
}
apply_kv(cfg, &key, val.trim());
}
}
pub(crate) fn apply_file(
cfg: &mut Config,
path: &Path,
scope: ConfigScope,
) -> Result<(), ConfigError> {
apply_file_inner(cfg, path, scope, true)
}
fn apply_file_inner(
cfg: &mut Config,
path: &Path,
scope: ConfigScope,
warn_on_forbidden: bool,
) -> Result<(), ConfigError> {
let text = match fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e.into()),
};
for raw_line in text.lines() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((k, v)) = line.split_once('=') else {
continue;
};
let key = normalize_config_key(k.trim());
let key = key.as_str();
let val = v.trim();
if scope == ConfigScope::Repo && REPO_FORBIDDEN_KEYS.contains(&key) {
if warn_on_forbidden {
warn_forbidden_repo_key(path, key);
}
continue;
}
apply_kv(cfg, key, val);
}
Ok(())
}
fn warn_forbidden_repo_key(path: &Path, key: &str) {
let mut stderr = io::stderr().lock();
let _ = writeln!(
stderr,
"warning: ignoring `{key}` from per-repo config at {} \
(security-sensitive keys are user-scoped only — see {} \
and docs/THREAT-MODEL.md)",
path.display(),
user_config_path().display()
);
}
fn apply_kv(cfg: &mut Config, key: &str, val: &str) {
if let Some(suffix) = core_allowed_suffix(key) {
cfg.core.insert(suffix, val.to_string());
return;
}
match key {
"user.identity" => val.clone_into(&mut cfg.user_identity),
"user.name" => val.clone_into(&mut cfg.user_name),
"user.email" => val.clone_into(&mut cfg.user_email),
"trusted_remote_endpoint" => val.clone_into(&mut cfg.trusted_remote_endpoint),
"signer" => val.clone_into(&mut cfg.signer),
"pull.require_signed" => val.clone_into(&mut cfg.pull_require_signed),
"key.backend" => val.clone_into(&mut cfg.key.backend),
"key.default_ref" => val.clone_into(&mut cfg.key.default_ref),
"key.ed25519_ref" => val.clone_into(&mut cfg.key.ed25519_ref),
"key.secp256k1_ref" => val.clone_into(&mut cfg.key.secp256k1_ref),
"key.p256_ref" => val.clone_into(&mut cfg.key.p256_ref),
"signing_key" => val.clone_into(&mut cfg.signing_key),
"default_branch" => val.clone_into(&mut cfg.default_branch),
"durability.objects" => val.clone_into(&mut cfg.durability_objects),
"remote_endpoint" => val.clone_into(&mut cfg.remote_endpoint),
"remote_bucket" => val.clone_into(&mut cfg.remote_bucket),
"remote_type" => val.clone_into(&mut cfg.remote_type),
"ssh.strict_host_key_checking" => val.clone_into(&mut cfg.ssh_strict_host_key_checking),
"ssh.user_known_hosts_file" => val.clone_into(&mut cfg.ssh_user_known_hosts_file),
"ssh.identity_file" => val.clone_into(&mut cfg.ssh_identity_file),
"transport_auth" => val.clone_into(&mut cfg.transport_auth),
"attest.default_algorithm" => val.clone_into(&mut cfg.attest.default_algorithm),
"attest.signer" => val.clone_into(&mut cfg.attest.signer),
"attest.external_signer_path" => val.clone_into(&mut cfg.attest.external_signer_path),
"attest.external_signer_args" => {
cfg.attest.external_signer_args = parse_pipe_list(val);
}
"attest.external_signer_timeout_secs" => {
cfg.attest.external_signer_timeout_secs = val.trim().parse::<u64>().ok();
}
"attest.secp256k1_key_path" => val.clone_into(&mut cfg.attest.secp256k1_key_path),
"attest.p256_key_path" => val.clone_into(&mut cfg.attest.p256_key_path),
_ if apply_section_kv(cfg, key, val) => {}
"author_mid" | "project_id" | "network" => {}
_ if key.ends_with("_url") => {}
_ => {} }
}
#[must_use]
pub fn is_core_section(key: &str) -> bool {
key.split_once('.')
.is_some_and(|(section, _)| section.eq_ignore_ascii_case("core"))
}
#[must_use]
pub fn core_allowed_suffix(key: &str) -> Option<String> {
let (section, name) = key.split_once('.')?;
if !section.eq_ignore_ascii_case("core") {
return None;
}
let suffix = name.to_ascii_lowercase();
CORE_ALLOWED_KEYS
.contains(&suffix.as_str())
.then_some(suffix)
}
#[must_use]
pub fn normalize_config_key(key: &str) -> String {
match key.split_once('.') {
Some((section, rest)) => match rest.rsplit_once('.') {
Some((subsection, variable)) => format!(
"{}.{subsection}.{}",
section.to_ascii_lowercase(),
variable.to_ascii_lowercase()
),
None => format!(
"{}.{}",
section.to_ascii_lowercase(),
rest.to_ascii_lowercase()
),
},
None => key.to_ascii_lowercase(),
}
}
fn apply_section_kv(cfg: &mut Config, key: &str, val: &str) -> bool {
let mut parts = key.splitn(3, '.');
let (Some(section), Some(name), Some(field)) = (parts.next(), parts.next(), parts.next())
else {
return false;
};
let valid_name = !name.is_empty() && mkit_core::refs::validate_ref_name(name);
match (section, field) {
("remote", "url") => {
if valid_name {
val.clone_into(&mut cfg.remotes.entry(name.to_owned()).or_default().url);
}
true
}
("remote", "type") => {
if valid_name {
val.clone_into(&mut cfg.remotes.entry(name.to_owned()).or_default().remote_type);
}
true
}
("branch", "remote") => {
if valid_name {
val.clone_into(
&mut cfg
.branch_upstreams
.entry(name.to_owned())
.or_default()
.remote,
);
}
true
}
("branch", "merge") => {
if valid_name {
val.clone_into(
&mut cfg
.branch_upstreams
.entry(name.to_owned())
.or_default()
.branch,
);
}
true
}
_ => false,
}
}
pub fn write(layout: &RepoLayout, cfg: &Config) -> Result<(), ConfigError> {
let path = layout.config_file();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut out = String::new();
for (k, v) in [
("user.name", cfg.user_name.as_str()),
("user.email", cfg.user_email.as_str()),
("default_branch", cfg.default_branch.as_str()),
("durability.objects", cfg.durability_objects.as_str()),
("remote_endpoint", cfg.remote_endpoint.as_str()),
("remote_bucket", cfg.remote_bucket.as_str()),
("remote_type", cfg.remote_type.as_str()),
("transport_auth", cfg.transport_auth.as_str()),
] {
if !v.is_empty() {
out.push_str(k);
out.push_str(" = ");
out.push_str(v);
out.push('\n');
}
}
for (name, entry) in &cfg.remotes {
if !entry.url.is_empty() {
let _ = writeln!(out, "remote.{name}.url = {}", entry.url);
}
if !entry.remote_type.is_empty() {
let _ = writeln!(out, "remote.{name}.type = {}", entry.remote_type);
}
}
for (branch, up) in &cfg.branch_upstreams {
if !up.remote.is_empty() {
let _ = writeln!(out, "branch.{branch}.remote = {}", up.remote);
}
if !up.branch.is_empty() {
let _ = writeln!(out, "branch.{branch}.merge = {}", up.branch);
}
}
for (k, v) in &cfg.core {
let _ = writeln!(out, "core.{k} = {v}");
}
let dir = path.parent().unwrap_or_else(|| Path::new("."));
let mut tmp = tempfile::Builder::new()
.prefix(".config.")
.tempfile_in(dir)?;
tmp.write_all(out.as_bytes())?;
tmp.flush()?;
tmp.persist(&path).map_err(|e| ConfigError::Io(e.error))?;
Ok(())
}
pub const DEFAULT_REMOTE_NAME: &str = "default";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedRemote {
pub name: String,
pub endpoint: String,
pub repo_chosen: bool,
}
#[must_use]
pub fn resolve_remote(cfg: &LayeredConfig, name: &str) -> Option<ResolvedRemote> {
let name = if name.is_empty() {
DEFAULT_REMOTE_NAME
} else {
name
};
if name == DEFAULT_REMOTE_NAME && !cfg.merged.remote_endpoint.trim().is_empty() {
let endpoint = cfg.merged.remote_endpoint.trim().to_owned();
let repo_chosen = cfg.repo.remote_endpoint.trim() == endpoint;
return Some(ResolvedRemote {
name: DEFAULT_REMOTE_NAME.to_owned(),
endpoint,
repo_chosen,
});
}
let entry = cfg.merged.remotes.get(name)?;
let endpoint = entry.url.trim();
if endpoint.is_empty() {
return None;
}
let repo_chosen = cfg
.repo
.remotes
.get(name)
.is_some_and(|e| e.url.trim() == endpoint);
Some(ResolvedRemote {
name: name.to_owned(),
endpoint: endpoint.to_owned(),
repo_chosen,
})
}
#[must_use]
pub fn configured_remote_names(cfg: &LayeredConfig) -> Vec<String> {
let mut names: std::collections::BTreeSet<String> =
cfg.merged.remotes.keys().cloned().collect();
if !cfg.merged.remote_endpoint.trim().is_empty() {
names.insert(DEFAULT_REMOTE_NAME.to_owned());
}
names.into_iter().collect()
}
#[must_use]
pub fn resolve_upstream(cfg: &LayeredConfig, branch: &str) -> Option<Upstream> {
if let Some(up) = cfg.merged.branch_upstreams.get(branch)
&& !up.remote.is_empty()
&& !up.branch.is_empty()
{
return Some(up.clone());
}
if !cfg.merged.remote_endpoint.trim().is_empty() {
return Some(Upstream {
remote: DEFAULT_REMOTE_NAME.to_owned(),
branch: branch.to_owned(),
});
}
None
}
fn real_getenv(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|value| !value.is_empty())
}
pub fn enforce_trusted_remote_endpoint(cfg: &LayeredConfig) -> Result<(), String> {
let endpoint = cfg.merged.remote_endpoint.trim();
let repo_chosen = cfg.repo.remote_endpoint.trim() == endpoint;
match trusted_remote_error_for(
endpoint,
repo_chosen,
cfg.user.trusted_remote_endpoint.trim(),
&real_getenv,
) {
Some(msg) => Err(msg),
None => Ok(()),
}
}
pub fn endpoint_credential_trust(
cfg: &LayeredConfig,
endpoint: &str,
repo_chosen: bool,
) -> Result<(), String> {
match trusted_remote_error_for(
endpoint.trim(),
repo_chosen,
cfg.user.trusted_remote_endpoint.trim(),
&real_getenv,
) {
Some(msg) => Err(msg),
None => Ok(()),
}
}
fn trusted_remote_error_for<F>(
endpoint: &str,
repo_chosen: bool,
user_trusted: &str,
getenv: &F,
) -> Option<String>
where
F: Fn(&str) -> Option<String>,
{
if endpoint.is_empty() || !repo_chosen {
return None;
}
if user_trusted == endpoint {
return None;
}
if endpoint.starts_with("mkit+http://") || endpoint.starts_with("mkit+https://") {
if getenv(mkit_transport_http::TOKEN_ENV).is_some() {
return Some(format!(
"refusing repo-configured remote `{endpoint}` with ambient {} bearer token; trust it explicitly with `mkit config trusted_remote_endpoint {endpoint}` (writes {})",
mkit_transport_http::TOKEN_ENV,
user_config_path().display()
));
}
return None;
}
if endpoint.starts_with("mkit+s3://")
&& (getenv(mkit_transport_s3::ENV_ACCESS_KEY).is_some()
|| getenv(mkit_transport_s3::ENV_SECRET_KEY).is_some())
{
return Some(format!(
"refusing repo-configured remote `{endpoint}` with ambient S3/R2 credentials; trust it explicitly with `mkit config trusted_remote_endpoint {endpoint}` (writes {})",
user_config_path().display()
));
}
None
}
pub fn write_user_kv(key: &str, value: &str) -> Result<(), ConfigError> {
let key = normalize_config_key(key);
let key = key.as_str();
let path = user_config_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let existing = fs::read_to_string(&path).unwrap_or_default();
let mut out = String::new();
let mut replaced = false;
for raw_line in existing.lines() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
out.push_str(raw_line);
out.push('\n');
continue;
}
if let Some((k, _)) = line.split_once('=')
&& normalize_config_key(k.trim()) == key
{
out.push_str(key);
out.push_str(" = ");
out.push_str(value);
out.push('\n');
replaced = true;
continue;
}
out.push_str(raw_line);
out.push('\n');
}
if !replaced {
out.push_str(key);
out.push_str(" = ");
out.push_str(value);
out.push('\n');
}
write_atomic_user_config(&path, out.as_bytes())?;
Ok(())
}
pub fn remove_user_kv(key: &str) -> Result<bool, ConfigError> {
let key = normalize_config_key(key);
let key = key.as_str();
let path = user_config_path();
let existing = match fs::read_to_string(&path) {
Ok(s) => s,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(false),
Err(e) => return Err(ConfigError::Io(e)),
};
let mut out = String::new();
let mut removed = false;
for raw_line in existing.lines() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
out.push_str(raw_line);
out.push('\n');
continue;
}
if let Some((k, _)) = line.split_once('=')
&& normalize_config_key(k.trim()) == key
{
removed = true;
continue;
}
out.push_str(raw_line);
out.push('\n');
}
if removed {
write_atomic_user_config(&path, out.as_bytes())?;
}
Ok(removed)
}
fn write_atomic_user_config(path: &Path, bytes: &[u8]) -> Result<(), ConfigError> {
use tempfile::NamedTempFile;
let parent = path.parent().ok_or(ConfigError::Io(io::Error::new(
io::ErrorKind::InvalidInput,
"user config path has no parent",
)))?;
let mut tmp = NamedTempFile::new_in(parent)?;
tmp.as_file_mut().write_all(bytes)?;
tmp.as_file_mut().sync_all()?;
tmp.persist(path).map_err(|e| ConfigError::Io(e.error))?;
Ok(())
}
pub fn expand_user_identity(value: &str) -> Result<String, ConfigError> {
if value.is_empty() {
return Err(ConfigError::InvalidUserIdentity("empty value"));
}
if let Some(hex) = value.strip_prefix("ed25519:") {
if hex.len() != 64 {
return Err(ConfigError::InvalidUserIdentity(
"ed25519:<hex> must have 64 hex chars",
));
}
let bytes =
hex_decode(hex).ok_or(ConfigError::InvalidUserIdentity("ed25519 hex is not valid"))?;
return Ok(encode_identity_hex(0x01, &bytes));
}
if let Some(dec) = value.strip_prefix("mid:") {
let mid: u64 = dec
.parse()
.map_err(|_| ConfigError::InvalidUserIdentity("mid must be a decimal u64"))?;
return Ok(encode_identity_hex(0x03, &mid.to_le_bytes()));
}
if !value.len().is_multiple_of(2) || value.len() < 6 {
return Err(ConfigError::InvalidUserIdentity(
"raw hex is too short or has odd length",
));
}
let bytes = hex_decode(value).ok_or(ConfigError::InvalidUserIdentity(
"raw value is not valid hex",
))?;
let declared = u16::from(bytes[1]) | (u16::from(bytes[2]) << 8);
if bytes.len() != usize::from(declared) + 3 {
return Err(ConfigError::InvalidUserIdentity(
"declared length does not match payload length",
));
}
Ok(value.to_owned())
}
fn encode_identity_hex(kind: u8, bytes: &[u8]) -> String {
let len = u16::try_from(bytes.len()).unwrap_or(u16::MAX);
let mut buf = Vec::with_capacity(3 + bytes.len());
buf.push(kind);
buf.extend_from_slice(&len.to_le_bytes());
buf.extend_from_slice(bytes);
hex_encode(&buf)
}
fn hex_encode(bytes: &[u8]) -> String {
static H: &[u8; 16] = b"0123456789abcdef";
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push(H[(b >> 4) as usize] as char);
s.push(H[(b & 0x0F) as usize] as char);
}
s
}
fn hex_decode(s: &str) -> Option<Vec<u8>> {
if !s.len().is_multiple_of(2) {
return None;
}
let mut out = Vec::with_capacity(s.len() / 2);
let b = s.as_bytes();
for i in (0..b.len()).step_by(2) {
let hi = nibble(b[i])?;
let lo = nibble(b[i + 1])?;
out.push((hi << 4) | lo);
}
Some(out)
}
fn nibble(c: u8) -> Option<u8> {
Some(match c {
b'0'..=b'9' => c - b'0',
b'a'..=b'f' => 10 + c - b'a',
b'A'..=b'F' => 10 + c - b'A',
_ => return None,
})
}
fn xdg(var: &str, fallback_under_home: &str) -> PathBuf {
if let Some(v) = std::env::var_os(var)
&& !v.is_empty()
{
return PathBuf::from(v);
}
if let Some(home) = std::env::var_os("HOME") {
return PathBuf::from(home).join(fallback_under_home);
}
PathBuf::from(".")
}
#[must_use]
pub fn xdg_config_home() -> PathBuf {
xdg("XDG_CONFIG_HOME", ".config")
}
#[cfg(test)]
mod tests {
use super::*;
use mkit_core::layout::RepoLayout;
use tempfile::TempDir;
#[test]
fn normalize_config_key_casing() {
assert_eq!(normalize_config_key("User.Name"), "user.name");
assert_eq!(normalize_config_key("Core.AutoCRLF"), "core.autocrlf");
assert_eq!(normalize_config_key("user.identity"), "user.identity");
assert_eq!(
normalize_config_key("remote.Origin.url"),
"remote.Origin.url"
);
assert_eq!(
normalize_config_key("Remote.Origin.URL"),
"remote.Origin.url"
);
assert_eq!(
normalize_config_key("branch.Release.remote"),
"branch.Release.remote"
);
assert_eq!(normalize_config_key("Remote.A.B.URL"), "remote.A.B.url");
assert_eq!(
normalize_config_key("HTTP.https://Ex.com/.SSLVerify"),
"http.https://Ex.com/.sslverify"
);
assert_eq!(normalize_config_key("Foo"), "foo");
}
#[test]
fn config_file_preserves_subsection_case() {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".mkit")).unwrap();
std::fs::write(
dir.path().join(".mkit/config"),
"remote.Origin.url = mkit+file:///tmp/x\nremote.Origin.type = file\n",
)
.unwrap();
let cfg = read_or_default(&RepoLayout::single(dir.path())).unwrap();
assert!(
cfg.remotes.contains_key("Origin"),
"subsection case lost on reload: {:?}",
cfg.remotes.keys().collect::<Vec<_>>()
);
assert!(!cfg.remotes.contains_key("origin"));
}
#[test]
fn durability_objects_key_selects_sync_policy() {
let mut cfg = Config::with_defaults();
assert_eq!(
cfg.object_sync_policy(),
mkit_core::store::SyncPolicy::Batch
);
apply_kv(&mut cfg, "durability.objects", "per-object");
assert_eq!(
cfg.object_sync_policy(),
mkit_core::store::SyncPolicy::PerObject
);
let dir = tempfile::tempdir().unwrap();
write(&RepoLayout::single(dir.path()), &cfg).unwrap();
let text = std::fs::read_to_string(dir.path().join(CONFIG_FILE)).unwrap();
assert!(text.contains("durability.objects = per-object"));
apply_kv(&mut cfg, "durability.objects", "bogus");
assert_eq!(
cfg.object_sync_policy(),
mkit_core::store::SyncPolicy::Batch
);
}
fn layer(repo_text: Option<&str>, user_text: Option<&str>) -> Config {
let td = TempDir::new().unwrap();
let mut cfg = Config::with_defaults();
if let Some(text) = user_text {
let upath = td.path().join("user_config");
fs::write(&upath, text).unwrap();
apply_file(&mut cfg, &upath, ConfigScope::User).unwrap();
}
if let Some(text) = repo_text {
let rpath = td.path().join("repo_config");
fs::write(&rpath, text).unwrap();
apply_file(&mut cfg, &rpath, ConfigScope::Repo).unwrap();
}
cfg
}
fn layered(repo_text: Option<&str>, user_text: Option<&str>) -> LayeredConfig {
let td = TempDir::new().unwrap();
let user_path = td.path().join("user_config");
let repo_path = td.path().join("repo_config");
if let Some(text) = user_text {
fs::write(&user_path, text).unwrap();
}
if let Some(text) = repo_text {
fs::write(&repo_path, text).unwrap();
}
let mut merged = Config::with_defaults();
apply_file_inner(&mut merged, &user_path, ConfigScope::User, false).unwrap();
apply_file_inner(&mut merged, &repo_path, ConfigScope::Repo, false).unwrap();
let mut user = Config::default();
let mut repo = Config::default();
apply_file_inner(&mut user, &user_path, ConfigScope::User, false).unwrap();
apply_file_inner(&mut repo, &repo_path, ConfigScope::Repo, false).unwrap();
LayeredConfig { merged, user, repo }
}
#[test]
fn read_default_when_missing() {
let td = TempDir::new().unwrap();
let cfg = Config::with_defaults();
assert_eq!(cfg.signing_key, DEFAULT_SIGNING_KEY);
assert_eq!(cfg.default_branch, DEFAULT_BRANCH);
assert!(cfg.remote_endpoint.is_empty());
let _ = read_or_default(&RepoLayout::single(td.path())).unwrap();
}
#[test]
fn roundtrip_repo_safe_keys() {
let cfg = layer(
Some("remote_endpoint = /tmp/mirror\nremote_type = file\n"),
None,
);
assert_eq!(cfg.remote_endpoint, "/tmp/mirror");
assert_eq!(cfg.remote_type, "file");
}
#[test]
fn write_does_not_emit_forbidden_repo_keys() {
let td = TempDir::new().unwrap();
fs::create_dir_all(td.path().join(".mkit")).unwrap();
let mut cfg = Config::with_defaults();
cfg.user_identity = "01200011".into();
cfg.signing_key = "/should/not/be/written".into();
cfg.signer = "keystore".into();
cfg.key.backend = "software".into();
cfg.key.default_ref = "software:attacker".into();
cfg.ssh_strict_host_key_checking = "no".into();
cfg.attest.external_signer_path = "/usr/local/bin/evil".into();
write(&RepoLayout::single(td.path()), &cfg).unwrap();
let on_disk = fs::read_to_string(td.path().join(CONFIG_FILE)).unwrap();
assert!(!on_disk.contains("user.identity"));
assert!(!on_disk.contains("signing_key"));
assert!(!on_disk.contains("signer"));
assert!(!on_disk.contains("key.default_ref"));
assert!(!on_disk.contains("ssh.strict_host_key_checking"));
assert!(!on_disk.contains("external_signer_path"));
}
#[test]
fn repo_signing_key_is_rejected_with_warning() {
let cfg = layer(
Some("signing_key = ../../../etc/passwd\nremote_type = file\n"),
None,
);
assert_eq!(cfg.signing_key, DEFAULT_SIGNING_KEY);
assert_eq!(cfg.remote_type, "file");
}
#[test]
fn repo_user_identity_is_rejected() {
let cfg = layer(Some("user.identity = 012000aaaaaaaa\n"), None);
assert!(cfg.user_identity.is_empty());
}
#[test]
fn repo_trusted_remote_endpoint_is_rejected() {
let cfg = layer(
Some("trusted_remote_endpoint = mkit+https://attacker.invalid/repo\n"),
None,
);
assert!(cfg.trusted_remote_endpoint.is_empty());
}
#[test]
fn repo_external_signer_is_rejected() {
let cfg = layer(
Some(
"attest.external_signer_path = /usr/bin/curl\n\
attest.external_signer_args = -X|POST|attacker.example.com\n\
attest.signer = external\n",
),
None,
);
assert!(cfg.attest.external_signer_path.is_empty());
assert!(cfg.attest.external_signer_args.is_empty());
assert_eq!(cfg.attest.signer, "");
}
#[test]
fn repo_attest_signer_selector_cannot_weaponise_user_external_signer() {
let cfg = layer(
Some("attest.signer = external\n"),
Some(
"attest.external_signer_path = /home/user/bin/yubikey-sign\n\
attest.external_signer_args = sign\n",
),
);
assert_eq!(
cfg.attest.external_signer_path,
"/home/user/bin/yubikey-sign"
);
assert_eq!(cfg.attest.signer, "");
assert_eq!(cfg.attest.signer_or_fallback(), "repo-key");
}
#[test]
fn repo_attest_default_algorithm_is_rejected() {
let cfg = layer(Some("attest.default_algorithm = secp256k1\n"), None);
assert_eq!(cfg.attest.default_algorithm, "");
assert_eq!(cfg.attest.default_algorithm_or_fallback(), "ed25519");
}
#[test]
fn repo_keystore_selectors_are_rejected() {
let cfg = layer(
Some(
"signer = keystore\n\
key.backend = yubikey\n\
key.default_ref = yubikey:main\n\
key.ed25519_ref = software:repo-ed\n\
key.secp256k1_ref = software:repo-k1\n\
key.p256_ref = software:repo-p256\n",
),
None,
);
assert_eq!(cfg.signer, DEFAULT_SIGNER);
assert_eq!(cfg.key.backend, DEFAULT_KEY_BACKEND);
assert_eq!(cfg.key.default_ref_or_fallback(), DEFAULT_KEY_REF);
assert_eq!(cfg.key.ed25519_ref_or_fallback(), DEFAULT_KEY_REF);
assert_eq!(
cfg.key.secp256k1_ref_or_fallback(),
DEFAULT_SECP256K1_KEY_REF
);
assert_eq!(cfg.key.p256_ref_or_fallback(), DEFAULT_P256_KEY_REF);
}
#[test]
fn user_keystore_selectors_are_honored() {
let cfg = layer(
None,
Some(
"signer = keystore\n\
key.backend = software\n\
key.default_ref = software:user-default\n\
key.ed25519_ref = software:user-ed\n\
key.secp256k1_ref = software:user-k1\n\
key.p256_ref = software:user-p256\n",
),
);
assert_eq!(cfg.signer, "keystore");
assert_eq!(cfg.key.backend, "software");
assert_eq!(cfg.key.default_ref, "software:user-default");
assert_eq!(cfg.key.ed25519_ref_or_fallback(), "software:user-ed");
assert_eq!(cfg.key.secp256k1_ref_or_fallback(), "software:user-k1");
assert_eq!(cfg.key.p256_ref_or_fallback(), "software:user-p256");
}
#[test]
fn user_default_key_ref_is_generic_fallback() {
let cfg = layer(None, Some("key.default_ref = software:release\n"));
assert_eq!(cfg.key.default_ref_or_fallback(), "software:release");
assert_eq!(cfg.key.ed25519_ref_or_fallback(), "software:release");
assert_eq!(cfg.key.secp256k1_ref_or_fallback(), "software:release");
assert_eq!(cfg.key.p256_ref_or_fallback(), "software:release");
}
#[test]
fn algorithm_key_refs_override_default_key_ref() {
let cfg = layer(
None,
Some(
"key.default_ref = software:release\n\
key.ed25519_ref = software:ed\n\
key.secp256k1_ref = software:k1\n\
key.p256_ref = software:p256\n",
),
);
assert_eq!(cfg.key.default_ref_or_fallback(), "software:release");
assert_eq!(cfg.key.ed25519_ref_or_fallback(), "software:ed");
assert_eq!(cfg.key.secp256k1_ref_or_fallback(), "software:k1");
assert_eq!(cfg.key.p256_ref_or_fallback(), "software:p256");
}
#[test]
fn repo_ssh_host_key_checking_is_rejected() {
let cfg = layer(
Some(
"ssh.strict_host_key_checking = no\n\
ssh.user_known_hosts_file = /dev/null\n",
),
None,
);
assert!(cfg.ssh_strict_host_key_checking.is_empty());
assert!(cfg.ssh_user_known_hosts_file.is_empty());
}
#[test]
fn repo_ssh_identity_file_is_rejected() {
let cfg = layer(
Some("ssh.identity_file = /home/victim/.ssh/id_ed25519\n"),
None,
);
assert!(cfg.ssh_identity_file.is_empty());
}
#[test]
fn repo_pull_require_signed_is_rejected() {
let cfg = layer(Some("pull.require_signed = false\n"), None);
assert!(cfg.pull_require_signed.is_empty());
assert!(cfg.pull_require_signed_or_default());
}
#[test]
fn user_pull_require_signed_false_disables_verification() {
let cfg = layer(None, Some("pull.require_signed = false\n"));
assert_eq!(cfg.pull_require_signed, "false");
assert!(!cfg.pull_require_signed_or_default());
}
#[test]
fn pull_require_signed_defaults_to_true_and_rejects_typos() {
assert!(Config::default().pull_require_signed_or_default());
let cfg = layer(None, Some("pull.require_signed = nope\n"));
assert!(cfg.pull_require_signed_or_default());
for falsy in ["false", "0", "no", "off", "FALSE", "Off"] {
let cfg = layer(None, Some(&format!("pull.require_signed = {falsy}\n")));
assert!(
!cfg.pull_require_signed_or_default(),
"{falsy} should disable verification"
);
}
}
#[test]
fn repo_attest_secp256k1_key_path_is_rejected() {
let cfg = layer(
Some("attest.secp256k1_key_path = /home/victim/.wallet/seed\n"),
None,
);
assert!(cfg.attest.secp256k1_key_path.is_empty());
assert_eq!(
cfg.attest.secp256k1_key_path_or_default(),
".mkit/keys/secp256k1.key"
);
}
#[test]
fn repo_attest_p256_key_path_is_rejected() {
let cfg = layer(
Some("attest.p256_key_path = /home/victim/.ssh/id_ecdsa\n"),
None,
);
assert!(cfg.attest.p256_key_path.is_empty());
assert_eq!(cfg.attest.p256_key_path_or_default(), ".mkit/keys/p256.key");
}
#[test]
fn every_forbidden_key_is_actually_dropped_from_repo_scope() {
const SENTINEL: &str = "EXFIL_SENTINEL";
for key in REPO_FORBIDDEN_KEYS {
let line = format!("{key} = {SENTINEL}\n");
let cfg = layer(Some(&line), None);
let observed = match *key {
"user.identity" => cfg.user_identity.as_str(),
"trusted_remote_endpoint" => cfg.trusted_remote_endpoint.as_str(),
"signer" => cfg.signer.as_str(),
"pull.require_signed" => cfg.pull_require_signed.as_str(),
"key.backend" => cfg.key.backend.as_str(),
"key.default_ref" => cfg.key.default_ref.as_str(),
"key.ed25519_ref" => cfg.key.ed25519_ref.as_str(),
"key.secp256k1_ref" => cfg.key.secp256k1_ref.as_str(),
"key.p256_ref" => cfg.key.p256_ref.as_str(),
"signing_key" => cfg.signing_key.as_str(),
"ssh.strict_host_key_checking" => cfg.ssh_strict_host_key_checking.as_str(),
"ssh.user_known_hosts_file" => cfg.ssh_user_known_hosts_file.as_str(),
"ssh.identity_file" => cfg.ssh_identity_file.as_str(),
"attest.signer" => cfg.attest.signer.as_str(),
"attest.default_algorithm" => cfg.attest.default_algorithm.as_str(),
"attest.external_signer_path" => cfg.attest.external_signer_path.as_str(),
"attest.external_signer_args" => {
if cfg.attest.external_signer_args.is_empty() {
""
} else {
"<non-empty>"
}
}
"attest.external_signer_timeout_secs" => {
if cfg.attest.external_signer_timeout_secs.is_none() {
""
} else {
"<set>"
}
}
"attest.secp256k1_key_path" => cfg.attest.secp256k1_key_path.as_str(),
"attest.p256_key_path" => cfg.attest.p256_key_path.as_str(),
other => panic!(
"REPO_FORBIDDEN_KEYS contains `{other}` but the meta-test \
in config.rs has no matching field accessor. Add an arm \
to `every_forbidden_key_is_actually_dropped_from_repo_scope` \
so the per-key drop is verified.",
),
};
assert!(
observed != SENTINEL,
"forbidden key `{key}` was NOT dropped from repo scope — \
observed `{observed}` (matches attacker SENTINEL)",
);
}
}
#[test]
fn user_signing_key_is_honored() {
let cfg = layer(None, Some("signing_key = /home/user/.mkit/global.key\n"));
assert_eq!(cfg.signing_key, "/home/user/.mkit/global.key");
}
fn gate_for_flat<F>(cfg: &LayeredConfig, getenv: &F) -> Option<String>
where
F: Fn(&str) -> Option<String>,
{
let endpoint = cfg.merged.remote_endpoint.trim();
let repo_chosen = cfg.repo.remote_endpoint.trim() == endpoint;
trusted_remote_error_for(
endpoint,
repo_chosen,
cfg.user.trusted_remote_endpoint.trim(),
getenv,
)
}
#[test]
fn repo_http_remote_with_token_requires_user_trust() {
let cfg = layered(
Some("remote_endpoint = mkit+https://example.invalid/repo\n"),
None,
);
let msg = gate_for_flat(&cfg, &|name| {
(name == mkit_transport_http::TOKEN_ENV).then(|| "token".to_string())
})
.expect("repo-scoped HTTP remote with token must be rejected");
assert!(msg.contains("trusted_remote_endpoint"));
}
#[test]
fn trusted_http_remote_is_allowed() {
let cfg = layered(
Some("remote_endpoint = mkit+https://example.invalid/repo\n"),
Some("trusted_remote_endpoint = mkit+https://example.invalid/repo\n"),
);
let msg = gate_for_flat(&cfg, &|name| {
(name == mkit_transport_http::TOKEN_ENV).then(|| "token".to_string())
});
assert!(msg.is_none());
}
#[test]
fn repo_s3_remote_with_env_creds_requires_user_trust() {
let cfg = layered(
Some("remote_endpoint = mkit+s3://r2.example.com/bucket/proj\n"),
None,
);
let msg = gate_for_flat(&cfg, &|name| match name {
mkit_transport_s3::ENV_ACCESS_KEY => Some("AKIA...".to_string()),
_ => None,
})
.expect("repo-scoped S3 remote with env creds must be rejected");
assert!(msg.contains("trusted_remote_endpoint"));
}
#[test]
fn user_chosen_http_remote_with_token_is_allowed() {
let token =
|name: &str| (name == mkit_transport_http::TOKEN_ENV).then(|| "tok".to_string());
let ep = "mkit+https://example.invalid/repo";
assert!(trusted_remote_error_for(ep, false, "", &token).is_none());
assert!(trusted_remote_error_for(ep, true, "", &token).is_some());
}
#[test]
fn repo_http_remote_without_token_is_allowed() {
let none = |_: &str| None;
let ep = "mkit+https://example.invalid/repo";
assert!(trusted_remote_error_for(ep, true, "", &none).is_none());
}
#[test]
fn ssh_and_file_endpoints_bypass_credential_gate() {
let all = |_: &str| Some("present".to_string());
assert!(trusted_remote_error_for("mkit+ssh://host/path", true, "", &all).is_none());
assert!(trusted_remote_error_for("mkit+file:///srv/mirror", true, "", &all).is_none());
}
#[test]
fn endpoint_credential_trust_honours_provenance_and_user_trust() {
let cfg = layered(
None,
Some("trusted_remote_endpoint = mkit+https://trusted.invalid/r\n"),
);
let _ = endpoint_credential_trust(&cfg, "mkit+https://untrusted.invalid/r", true);
assert!(endpoint_credential_trust(&cfg, "mkit+https://trusted.invalid/r", true).is_ok());
}
#[test]
fn repo_safe_keys_override_user() {
let cfg = layer(
Some("default_branch = release\n"),
Some("default_branch = trunk\n"),
);
assert_eq!(cfg.default_branch, "release");
}
#[test]
fn validate_key_path_rejects_parent_dir() {
assert!(validate_key_path("../etc/passwd").is_err());
assert!(validate_key_path(".mkit/keys/../../etc/passwd").is_err());
assert!(validate_key_path("foo/../bar").is_err());
}
#[test]
fn validate_key_path_accepts_relative_and_absolute() {
assert!(validate_key_path("").is_ok());
assert!(validate_key_path(".mkit/keys/default.key").is_ok());
assert!(validate_key_path("/home/user/.mkit/global.key").is_ok());
}
#[test]
fn resolve_key_path_resolves_against_common_dir_in_linked_worktree() {
let layout = RepoLayout::linked("/trees/wt1", "/main/.mkit/worktrees/wt1", "/main/.mkit");
let out = resolve_key_path(&layout, ".mkit/keys/default.key").unwrap();
assert_eq!(out, std::path::Path::new("/main/.mkit/keys/default.key"));
}
#[test]
fn resolve_key_path_rejects_relative_path_outside_repo_keys() {
let td = TempDir::new().unwrap();
assert!(
resolve_key_path(&RepoLayout::single(td.path()), ".mkit/custom/global.key").is_err()
);
}
#[test]
fn resolve_key_path_accepts_relative_path_under_repo_keys() {
let td = TempDir::new().unwrap();
let out = resolve_key_path(
&RepoLayout::single(td.path()),
".mkit/keys/custom/global.key",
)
.unwrap();
assert_eq!(out, td.path().join(".mkit/keys/custom/global.key"));
}
#[cfg(unix)]
#[test]
fn home_dir_for_euid_is_independent_of_home_env() {
let from_passwd = home_dir_for_euid().expect("getpwuid_r should succeed");
assert!(from_passwd.is_absolute());
let td = TempDir::new().unwrap();
let inside = from_passwd.join(".mkit/test-inside.key");
assert!(resolve_key_path(&RepoLayout::single(td.path()), inside.to_str().unwrap()).is_ok());
assert!(
resolve_key_path(
&RepoLayout::single(td.path()),
"/__definitely_not_a_home_dir__/x.key"
)
.is_err()
);
}
#[test]
fn expand_user_identity_ed25519() {
let hex = "11".repeat(32);
let out = expand_user_identity(&format!("ed25519:{hex}")).unwrap();
assert_eq!(out.len(), 70);
assert!(out.starts_with("012000"));
}
#[test]
fn expand_user_identity_mid() {
let out = expand_user_identity("mid:42").unwrap();
assert_eq!(out, "0308002a00000000000000");
}
#[test]
fn expand_rejects_bogus() {
assert!(expand_user_identity("").is_err());
assert!(expand_user_identity("ed25519:short").is_err());
assert!(expand_user_identity("mid:notanumber").is_err());
assert!(expand_user_identity("zzzzzz").is_err());
}
#[test]
fn validate_value_rejects_control_chars() {
assert!(validate_value("hello world").is_ok());
assert!(validate_value("bad\x01char").is_err());
assert!(validate_value("\x7fdel").is_err());
}
#[test]
fn attest_config_defaults_are_empty() {
let cfg = Config::with_defaults();
assert_eq!(cfg.signer, DEFAULT_SIGNER);
assert_eq!(cfg.key.backend_or_fallback(), DEFAULT_KEY_BACKEND);
assert_eq!(cfg.key.default_ref_or_fallback(), DEFAULT_KEY_REF);
assert!(cfg.key.default_ref.is_empty());
assert!(cfg.key.ed25519_ref.is_empty());
assert!(cfg.key.secp256k1_ref.is_empty());
assert!(cfg.key.p256_ref.is_empty());
assert_eq!(cfg.key.ed25519_ref_or_fallback(), DEFAULT_KEY_REF);
assert_eq!(
cfg.key.secp256k1_ref_or_fallback(),
DEFAULT_SECP256K1_KEY_REF
);
assert_eq!(cfg.key.p256_ref_or_fallback(), DEFAULT_P256_KEY_REF);
assert_eq!(cfg.attest.default_algorithm, "");
assert_eq!(cfg.attest.signer, "");
assert_eq!(cfg.attest.default_algorithm_or_fallback(), "ed25519");
assert_eq!(cfg.attest.signer_or_fallback(), "repo-key");
assert_eq!(
cfg.attest.secp256k1_key_path_or_default(),
".mkit/keys/secp256k1.key"
);
assert_eq!(cfg.attest.p256_key_path_or_default(), ".mkit/keys/p256.key");
}
#[test]
fn legacy_keys_are_ignored_in_repo() {
let cfg = layer(Some("project_id = xyz\nauthor_mid = 5\n"), None);
assert_eq!(cfg.signing_key, DEFAULT_SIGNING_KEY);
}
#[test]
fn user_kv_replace_or_append_logic_via_roundtrip() {
let td = TempDir::new().unwrap();
let path = td.path().join("user_config");
fs::write(&path, "default_branch = trunk\nsigning_key = /a\n").unwrap();
let mut text = fs::read_to_string(&path).unwrap();
text = text.replace("/a", "/b");
fs::write(&path, text).unwrap();
let mut cfg = Config::with_defaults();
apply_file(&mut cfg, &path, ConfigScope::User).unwrap();
assert_eq!(cfg.signing_key, "/b");
assert_eq!(cfg.default_branch, "trunk");
}
#[test]
fn named_remote_keys_parse_repo_safe() {
let cfg = layer(
Some(
"remote.origin.url = mkit+file:///srv/m\n\
remote.origin.type = file\n\
branch.main.remote = origin\n\
branch.main.merge = main\n",
),
None,
);
let origin = cfg.remotes.get("origin").expect("origin present");
assert_eq!(origin.url, "mkit+file:///srv/m");
assert_eq!(origin.remote_type, "file");
let up = cfg.branch_upstreams.get("main").expect("upstream present");
assert_eq!(up.remote, "origin");
assert_eq!(up.branch, "main");
}
#[test]
fn named_remote_roundtrips_through_write() {
let td = TempDir::new().unwrap();
let mut cfg = Config::with_defaults();
cfg.remotes.insert(
"origin".into(),
RemoteEntry {
url: "mkit+https://h/r".into(),
remote_type: "http".into(),
},
);
cfg.branch_upstreams.insert(
"main".into(),
Upstream {
remote: "origin".into(),
branch: "main".into(),
},
);
write(&RepoLayout::single(td.path()), &cfg).unwrap();
let reloaded = read_or_default(&RepoLayout::single(td.path())).unwrap();
assert_eq!(
reloaded.remotes.get("origin").unwrap().url,
"mkit+https://h/r"
);
assert_eq!(
reloaded.branch_upstreams.get("main").unwrap().remote,
"origin"
);
}
#[test]
fn resolve_remote_default_and_named_provenance() {
let lc = layered(
Some("remote.origin.url = mkit+https://h/r\nremote.origin.type = http\n"),
None,
);
let r = resolve_remote(&lc, "origin").expect("origin resolves");
assert_eq!(r.endpoint, "mkit+https://h/r");
assert!(r.repo_chosen);
let lc = layered(Some("remote_endpoint = mkit+https://h/d\n"), None);
let r = resolve_remote(&lc, "default").expect("default resolves");
assert!(r.repo_chosen);
let lc = layered(None, Some("remote_endpoint = mkit+https://h/u\n"));
let r = resolve_remote(&lc, "").expect("empty -> default");
assert!(!r.repo_chosen);
let lc = layered(None, None);
assert!(resolve_remote(&lc, "nope").is_none());
}
#[test]
fn resolve_upstream_explicit_and_fallback() {
let lc = layered(
Some("branch.main.remote = origin\nbranch.main.merge = trunk\n"),
None,
);
let up = resolve_upstream(&lc, "main").unwrap();
assert_eq!(up.remote, "origin");
assert_eq!(up.branch, "trunk");
let lc = layered(Some("remote_endpoint = mkit+file:///srv\n"), None);
let up = resolve_upstream(&lc, "feature").unwrap();
assert_eq!(up.remote, DEFAULT_REMOTE_NAME);
assert_eq!(up.branch, "feature");
let lc = layered(None, None);
assert!(resolve_upstream(&lc, "main").is_none());
}
}