use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::cli::{ConfigArgs, ConfigCommand, ConfigInspectArgs, ConfigValidateArgs};
const PROJECT_CONFIG: &str = "harn.config.toml";
const MANIFEST: &str = "harn.toml";
const MAX_PARENT_DIRS: usize = 16;
pub(crate) async fn run(args: ConfigArgs) -> Result<(), String> {
match args.command {
ConfigCommand::Inspect(args) => run_inspect(args).await,
ConfigCommand::Validate(args) => run_validate(args),
ConfigCommand::Schema(args) => {
let text = serde_json::to_string_pretty(&harn_vm::config::schema_json())
.map_err(|error| error.to_string())?;
if let Some(path) = args.output {
harn_vm::atomic_io::atomic_write(&path, text.as_bytes())
.map_err(|error| format!("failed to write {}: {error}", path.display()))?;
} else {
println!("{text}");
}
Ok(())
}
}
}
async fn run_inspect(args: ConfigInspectArgs) -> Result<(), String> {
let layers = build_layers(&args).await?;
let resolved = harn_vm::config::merge_layers(layers).map_err(|error| error.to_string())?;
let output = if args.explain {
serde_json::json!({
"config": resolved.redacted_config,
"layers": resolved.layers,
"explain": resolved.explain,
})
} else {
resolved.redacted_config
};
println!(
"{}",
serde_json::to_string_pretty(&output).map_err(|error| error.to_string())?
);
Ok(())
}
fn run_validate(args: ConfigValidateArgs) -> Result<(), String> {
let files = if args.files.is_empty() {
discovered_validation_files()
} else {
args.files
};
if files.is_empty() {
println!("No Harn config files found.");
return Ok(());
}
for path in files {
let source = path.display().to_string();
let content = fs::read_to_string(&path)
.map_err(|error| format!("failed to read {}: {error}", path.display()))?;
if path.file_name().is_some_and(|name| name == MANIFEST) {
if let Some(value) = harn_vm::config::parse_manifest_config_table(&content, &source)
.map_err(|error| error.to_string())?
{
if args.managed {
harn_vm::config::validate_policy_paths(&value)
.map_err(|error| error.to_string())?;
}
}
} else {
let value = harn_vm::config::parse_config_toml(&content, &source)
.map_err(|error| error.to_string())?;
if args.managed {
harn_vm::config::validate_policy_paths(&value)
.map_err(|error| error.to_string())?;
}
}
let kind = if args.managed {
"managed policy"
} else {
"config"
};
println!("ok: {kind} {}", path.display());
}
Ok(())
}
async fn build_layers(
args: &ConfigInspectArgs,
) -> Result<Vec<harn_vm::config::ConfigLayer>, String> {
let mut layers = vec![harn_vm::config::built_in_defaults_layer()];
if !args.no_discovery {
if let Some(layer) = legacy_provider_layer()? {
layers.push(layer);
}
for path in install_config_paths() {
push_file_layer(
&mut layers,
harn_vm::config::ConfigLayerKind::RuntimeInstallDefaults,
"runtime install defaults",
&path,
)?;
}
if let Some(url) = remote_defaults_url(args, true) {
layers.push(fetch_remote_defaults(&url).await?);
}
for path in user_config_paths() {
push_file_layer(
&mut layers,
harn_vm::config::ConfigLayerKind::UserConfig,
"user config",
&path,
)?;
}
if let Some(path) =
find_nearest_named(&env::current_dir().unwrap_or_default(), PROJECT_CONFIG)
{
push_file_layer(
&mut layers,
harn_vm::config::ConfigLayerKind::ProjectConfig,
"project config",
&path,
)?;
}
if let Some(path) = find_nearest_named(&env::current_dir().unwrap_or_default(), MANIFEST) {
push_manifest_config_layer(&mut layers, &path)?;
}
}
if args.no_discovery {
if let Some(url) = remote_defaults_url(args, false) {
layers.push(fetch_remote_defaults(&url).await?);
}
}
for path in &args.config_files {
push_explicit_file_layer(
&mut layers,
harn_vm::config::ConfigLayerKind::ProjectConfig,
"explicit config",
path,
)?;
}
if !args.no_discovery {
for path in managed_config_paths() {
push_file_layer(
&mut layers,
harn_vm::config::ConfigLayerKind::ManagedPolicy,
"managed policy",
&path,
)?;
}
}
for path in &args.managed_files {
push_explicit_file_layer(
&mut layers,
harn_vm::config::ConfigLayerKind::ManagedPolicy,
"explicit managed policy",
path,
)?;
}
if let Some(layer) =
harn_vm::config::environment_layer(env::vars()).map_err(|error| error.to_string())?
{
layers.push(layer);
}
Ok(layers)
}
fn legacy_provider_layer() -> Result<Option<harn_vm::config::ConfigLayer>, String> {
let path = if let Ok(path) = env::var("HARN_PROVIDERS_CONFIG") {
PathBuf::from(path)
} else {
let Some(path) = harn_vm::config::user_config_path_for_os(
env::consts::OS,
env::var("HOME").ok().as_deref(),
env::var("XDG_CONFIG_HOME").ok().as_deref(),
env::var("APPDATA").ok().as_deref(),
) else {
return Ok(None);
};
path.with_file_name("providers.toml")
};
if !path.is_file() {
return Ok(None);
}
let source = path.display().to_string();
let content = fs::read_to_string(&path)
.map_err(|error| format!("failed to read {}: {error}", path.display()))?;
let providers =
toml::from_str::<harn_vm::llm_config::ProvidersConfig>(&content).map_err(|error| {
format!(
"failed to parse legacy providers config {source}: {}",
sanitized_error_message(error)
)
})?;
Ok(Some(harn_vm::config::layer_from_providers_config(
harn_vm::config::ConfigLayerKind::UserConfig,
"legacy providers config",
source,
&providers,
)))
}
fn sanitized_error_message(error: impl ToString) -> String {
error
.to_string()
.lines()
.next()
.unwrap_or("parse error")
.to_string()
}
fn push_file_layer(
layers: &mut Vec<harn_vm::config::ConfigLayer>,
kind: harn_vm::config::ConfigLayerKind,
name: &str,
path: &Path,
) -> Result<(), String> {
push_file_layer_with_required(layers, kind, name, path, false)
}
fn push_explicit_file_layer(
layers: &mut Vec<harn_vm::config::ConfigLayer>,
kind: harn_vm::config::ConfigLayerKind,
name: &str,
path: &Path,
) -> Result<(), String> {
push_file_layer_with_required(layers, kind, name, path, true)
}
fn push_file_layer_with_required(
layers: &mut Vec<harn_vm::config::ConfigLayer>,
kind: harn_vm::config::ConfigLayerKind,
name: &str,
path: &Path,
required: bool,
) -> Result<(), String> {
if !path.is_file() {
if required {
return Err(format!("config file {} does not exist", path.display()));
}
return Ok(());
}
let source = path.display().to_string();
let content = fs::read_to_string(path)
.map_err(|error| format!("failed to read {}: {error}", path.display()))?;
let value =
harn_vm::config::parse_config_toml(&content, &source).map_err(|error| error.to_string())?;
layers.push(harn_vm::config::ConfigLayer::new(kind, name, source, value));
Ok(())
}
fn push_manifest_config_layer(
layers: &mut Vec<harn_vm::config::ConfigLayer>,
path: &Path,
) -> Result<(), String> {
if !path.is_file() {
return Ok(());
}
let source = path.display().to_string();
let content = fs::read_to_string(path)
.map_err(|error| format!("failed to read {}: {error}", path.display()))?;
let Some(value) = harn_vm::config::parse_manifest_config_table(&content, &source)
.map_err(|error| error.to_string())?
else {
return Ok(());
};
layers.push(harn_vm::config::ConfigLayer::new(
harn_vm::config::ConfigLayerKind::RepoConfig,
"repo manifest config",
source,
value,
));
Ok(())
}
fn install_config_paths() -> Vec<PathBuf> {
if let Some(paths) = split_env_paths("HARN_CONFIG_INSTALL_DEFAULTS") {
return paths;
}
vec![harn_vm::config::install_config_path_for_os(
env::consts::OS,
env::var("PROGRAMDATA").ok().as_deref(),
)]
}
fn user_config_paths() -> Vec<PathBuf> {
if let Some(paths) = split_env_paths("HARN_CONFIG_USER") {
return paths;
}
harn_vm::config::user_config_path_for_os(
env::consts::OS,
env::var("HOME").ok().as_deref(),
env::var("XDG_CONFIG_HOME").ok().as_deref(),
env::var("APPDATA").ok().as_deref(),
)
.into_iter()
.collect()
}
fn managed_config_paths() -> Vec<PathBuf> {
split_env_paths("HARN_CONFIG_MANAGED").unwrap_or_default()
}
fn split_env_paths(key: &str) -> Option<Vec<PathBuf>> {
let value = env::var_os(key)?;
let paths: Vec<PathBuf> = env::split_paths(&value).collect();
if paths.is_empty() {
None
} else {
Some(paths)
}
}
fn remote_defaults_url(args: &ConfigInspectArgs, include_env: bool) -> Option<String> {
args.remote_defaults_url
.clone()
.or_else(|| {
include_env
.then(|| env::var("HARN_CONFIG_REMOTE_DEFAULTS_URL").ok())
.flatten()
})
.filter(|value| !value.trim().is_empty())
}
async fn fetch_remote_defaults(url: &str) -> Result<harn_vm::config::ConfigLayer, String> {
ensure_remote_trusted(url)?;
let display_url = redacted_url(url);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(15))
.build()
.map_err(|error| format!("failed to create config HTTP client: {error}"))?;
let response = client
.get(url)
.send()
.await
.map_err(|error| format!("failed to fetch remote defaults {display_url}: {error}"))?
.error_for_status()
.map_err(|error| format!("failed to fetch remote defaults {display_url}: {error}"))?;
let text = response
.text()
.await
.map_err(|error| format!("failed to read remote defaults {display_url}: {error}"))?;
let value = if text.trim_start().starts_with('{') {
harn_vm::config::parse_config_json(&text, url).map_err(|error| error.to_string())?
} else {
harn_vm::config::parse_config_toml(&text, url).map_err(|error| error.to_string())?
};
Ok(harn_vm::config::ConfigLayer::new(
harn_vm::config::ConfigLayerKind::RemoteDefaults,
"remote defaults",
url,
value,
))
}
fn ensure_remote_trusted(raw_url: &str) -> Result<(), String> {
let trusted = matches!(
env::var("HARN_CONFIG_TRUST_REMOTE").ok().as_deref(),
Some("1" | "true" | "TRUE" | "yes" | "YES")
);
remote_url_trust_error(raw_url, trusted).map_err(|reason| match reason {
RemoteTrustError::Untrusted => {
"remote defaults require HARN_CONFIG_TRUST_REMOTE=1 and an https or localhost URL"
.to_string()
}
RemoteTrustError::Invalid(error) => {
format!("invalid remote defaults URL: {error}")
}
RemoteTrustError::UnsupportedScheme => format!(
"remote defaults URL {} is not trusted; use https or http localhost",
redacted_url(raw_url)
),
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum RemoteTrustError {
Untrusted,
Invalid(String),
UnsupportedScheme,
}
fn remote_url_trust_error(raw_url: &str, trusted: bool) -> Result<(), RemoteTrustError> {
if !trusted {
return Err(RemoteTrustError::Untrusted);
}
let url =
url::Url::parse(raw_url).map_err(|error| RemoteTrustError::Invalid(error.to_string()))?;
let host = url.host_str().unwrap_or_default();
let local = matches!(host, "localhost" | "127.0.0.1" | "::1");
if url.scheme() == "https" || (local && url.scheme() == "http") {
Ok(())
} else {
Err(RemoteTrustError::UnsupportedScheme)
}
}
fn discovered_validation_files() -> Vec<PathBuf> {
let mut files = Vec::new();
for path in install_config_paths()
.into_iter()
.chain(user_config_paths())
.chain(managed_config_paths())
{
if path.is_file() {
files.push(path);
}
}
let cwd = env::current_dir().unwrap_or_default();
if let Some(path) = find_nearest_named(&cwd, PROJECT_CONFIG) {
files.push(path);
}
if let Some(path) = find_nearest_named(&cwd, MANIFEST) {
files.push(path);
}
files.sort();
files.dedup();
files
}
fn find_nearest_named(start: &Path, name: &str) -> Option<PathBuf> {
let base = if start.is_absolute() {
start.to_path_buf()
} else {
env::current_dir().ok()?.join(start)
};
let mut cursor = if base.is_dir() {
Some(base)
} else {
base.parent().map(Path::to_path_buf)
};
let mut steps = 0usize;
while let Some(dir) = cursor {
if steps >= MAX_PARENT_DIRS {
break;
}
steps += 1;
let candidate = dir.join(name);
if candidate.is_file() {
return Some(candidate);
}
if dir.join(".git").exists() {
break;
}
cursor = dir.parent().map(Path::to_path_buf);
}
None
}
fn redacted_url(value: &str) -> String {
if url::Url::parse(value).is_err() {
return "[redacted]".to_string();
}
harn_vm::redact::current_policy().redact_url(value)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_untrusted_remote_defaults() {
let error = ensure_remote_trusted("http://example.com/.well-known/harn").unwrap_err();
assert!(error.contains("HARN_CONFIG_TRUST_REMOTE") || error.contains("not trusted"));
}
#[test]
fn remote_defaults_trust_allows_only_https_or_http_localhost() {
assert!(remote_url_trust_error("https://example.com/.well-known/harn", true).is_ok());
assert!(remote_url_trust_error("http://localhost:8787/.well-known/harn", true).is_ok());
assert_eq!(
remote_url_trust_error("ftp://localhost/.well-known/harn", true),
Err(RemoteTrustError::UnsupportedScheme)
);
assert_eq!(
remote_url_trust_error("http://example.com/.well-known/harn", true),
Err(RemoteTrustError::UnsupportedScheme)
);
}
#[test]
fn explicit_config_files_must_exist() {
let mut layers = Vec::new();
let missing = PathBuf::from("__missing_harn_config.toml");
let error = push_explicit_file_layer(
&mut layers,
harn_vm::config::ConfigLayerKind::ProjectConfig,
"explicit config",
&missing,
)
.unwrap_err();
assert!(error.contains("does not exist"));
}
#[test]
fn managed_validation_rejects_invalid_policy_paths() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("policy.toml");
fs::write(&path, "[policy]\nlocked_fields = [\"limits..network\"]\n").unwrap();
let error = run_validate(ConfigValidateArgs {
files: vec![path],
managed: true,
})
.unwrap_err();
assert!(error.contains("invalid config field path"));
}
#[test]
fn managed_validation_rejects_invalid_manifest_policy_paths() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("harn.toml");
fs::write(
&path,
"[package]\nname = \"demo\"\n\n[config.policy]\ndenied_fields = [\"endpoints..mcp\"]\n",
)
.unwrap();
let error = run_validate(ConfigValidateArgs {
files: vec![path],
managed: true,
})
.unwrap_err();
assert!(error.contains("invalid config field path"));
}
}