use crate::args::Cli;
use crate::error::{ErrorCode, ErrorContext, RosWireError, RosWireResult};
use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Nonce};
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use directories::BaseDirs;
use rand::rngs::OsRng;
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::fs;
use std::io::{self, Read};
use std::path::{Path, PathBuf};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
fn default_config_version() -> u32 {
1
}
fn default_logging_enabled() -> bool {
true
}
fn default_logging_retention_days() -> u16 {
30
}
fn default_logging_level() -> String {
"info".to_owned()
}
const DEFAULT_MASTER_KEY_ENV: &str = "ROSWIRE_MASTER_KEY";
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct ConfigFile {
#[serde(default = "default_config_version")]
pub version: u32,
#[serde(default)]
pub default_profile: Option<String>,
#[serde(default)]
pub profiles: BTreeMap<String, ProfileConfig>,
#[serde(default)]
pub logging: LoggingConfig,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct LoggingConfig {
#[serde(default = "default_logging_enabled")]
pub enabled: bool,
#[serde(default = "default_logging_retention_days")]
pub retention_days: u16,
#[serde(default = "default_logging_level")]
pub level: String,
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
enabled: default_logging_enabled(),
retention_days: default_logging_retention_days(),
level: default_logging_level(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct ProfileConfig {
pub host: Option<String>,
pub user: Option<String>,
pub protocol: Option<String>,
pub routeros_version: Option<String>,
pub transfer: Option<String>,
pub port: Option<u16>,
pub ssh_port: Option<u16>,
pub ssh_user: Option<String>,
pub ssh_key: Option<String>,
pub ssh_host_key: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allow_from: Vec<String>,
#[serde(default)]
pub allow_plain_secrets: bool,
#[serde(default)]
pub secrets: BTreeMap<String, SecretSpec>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum SecretSpec {
Plain {
value: String,
},
Encrypted {
key_id: Option<String>,
value: String,
},
Keychain {
service: String,
account: String,
},
Env {
var: String,
},
SameAs {
target: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigPaths {
pub home: PathBuf,
pub config: PathBuf,
pub logs: PathBuf,
}
impl ConfigPaths {
pub fn from_home(home: PathBuf) -> Self {
Self {
config: home.join("config.toml"),
logs: home.join("logs"),
home,
}
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ValueSource {
Cli,
Env,
Profile,
Default,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ResolvedField {
pub value: String,
pub source: ValueSource,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ConfigInspectPaths {
pub home: String,
pub config: String,
pub logs: String,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ConfigInspect {
pub schema_version: String,
pub active_profile: String,
pub paths: ConfigInspectPaths,
pub logging: LoggingConfig,
pub resolved: BTreeMap<String, ResolvedField>,
pub secrets: BTreeMap<String, SecretInspectField>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct SecretInspectField {
pub status: String,
#[serde(rename = "type")]
pub secret_type: String,
pub source: ValueSource,
pub redacted: bool,
}
pub fn default_roswire_home() -> PathBuf {
BaseDirs::new()
.map(|dirs| dirs.home_dir().join(".roswire"))
.unwrap_or_else(|| PathBuf::from(".roswire"))
}
pub fn resolve_home_path(env_home: Option<&str>) -> PathBuf {
env_home
.map(PathBuf::from)
.filter(|path| !path.as_os_str().is_empty())
.unwrap_or_else(default_roswire_home)
}
pub fn parse_config_toml(contents: &str) -> RosWireResult<ConfigFile> {
toml::from_str(contents)
.map_err(|err| Box::new(RosWireError::config(format!("invalid config.toml: {err}"))))
}
pub fn load_config_file(path: &Path) -> RosWireResult<ConfigFile> {
let contents = fs::read_to_string(path).map_err(|err| {
Box::new(RosWireError::config(format!(
"failed to read config file: {err}"
)))
})?;
parse_config_toml(&contents)
}
pub fn validate_remote_host(host: &str) -> RosWireResult<()> {
if is_mac_address(host) {
return Err(Box::new(
RosWireError::config(format!(
"RouterOS host must be an IP address or DNS name, not a MAC address: {host}"
))
.with_hint(
"set profile host or --host to a routable IP address or DNS name; MAC-based Layer 2 discovery is not supported by this CLI",
)
.with_context(ErrorContext {
host: host.to_owned(),
..ErrorContext::default()
}),
));
}
Ok(())
}
pub fn is_mac_address(value: &str) -> bool {
let value = value.trim();
is_separated_mac_address(value, ':') || is_separated_mac_address(value, '-')
}
fn is_separated_mac_address(value: &str, separator: char) -> bool {
let parts = value.split(separator).collect::<Vec<_>>();
parts.len() == 6
&& parts
.iter()
.all(|part| part.len() == 2 && part.chars().all(|ch| ch.is_ascii_hexdigit()))
}
pub fn select_active_profile(
cli_profile: Option<&str>,
config: &ConfigFile,
) -> RosWireResult<String> {
let selected = cli_profile
.map(str::to_owned)
.or_else(|| config.default_profile.clone())
.or_else(|| {
if config.profiles.len() == 1 {
config.profiles.keys().next().cloned()
} else {
None
}
})
.ok_or_else(|| {
Box::new(RosWireError::config(
"no profile selected; set --profile or default_profile",
))
})?;
if config.profiles.contains_key(&selected) {
Ok(selected)
} else {
Err(Box::new(RosWireError::profile_not_found(selected)))
}
}
pub fn ensure_secure_directory_permissions(path: &Path) -> RosWireResult<()> {
#[cfg(unix)]
{
let metadata = fs::metadata(path).map_err(|err| {
Box::new(RosWireError::config(format!(
"failed to inspect directory permissions: {err}",
)))
})?;
let mode = metadata.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
return Err(Box::new(RosWireError::config_insecure_permissions(
format!("directory permissions are too wide: {:o}", mode,),
)));
}
}
Ok(())
}
pub fn ensure_secure_file_permissions(path: &Path) -> RosWireResult<()> {
#[cfg(unix)]
{
let metadata = fs::metadata(path).map_err(|err| {
Box::new(RosWireError::config(format!(
"failed to inspect file permissions: {err}",
)))
})?;
let mode = metadata.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
return Err(Box::new(RosWireError::config_insecure_permissions(
format!("config file permissions are too wide: {:o}", mode,),
)));
}
}
Ok(())
}
pub fn resolve_profile_secrets(
profile: &ProfileConfig,
) -> RosWireResult<BTreeMap<String, SecretInspectField>> {
let mut resolved = BTreeMap::new();
let mut visiting = Vec::new();
for name in profile.secrets.keys() {
let field = resolve_secret_recursive(name, profile, &mut resolved, &mut visiting)?;
resolved.insert(name.clone(), field);
}
Ok(resolved)
}
fn resolve_secret_recursive(
name: &str,
profile: &ProfileConfig,
resolved: &mut BTreeMap<String, SecretInspectField>,
visiting: &mut Vec<String>,
) -> RosWireResult<SecretInspectField> {
if let Some(field) = resolved.get(name) {
return Ok(field.clone());
}
if visiting.iter().any(|item| item == name) {
return Err(Box::new(RosWireError::config(
"secret same-as cycle detected",
)));
}
let spec = profile.secrets.get(name).ok_or_else(|| {
Box::new(RosWireError::config(format!(
"secret target missing: {name}",
)))
})?;
visiting.push(name.to_owned());
let field = match spec {
SecretSpec::Plain { .. } => {
if !profile.allow_plain_secrets {
return Err(Box::new(RosWireError::config(
"plain secrets require allow_plain_secrets = true",
)));
}
SecretInspectField {
status: "available".to_owned(),
secret_type: "plain".to_owned(),
source: ValueSource::Profile,
redacted: true,
}
}
SecretSpec::Encrypted { .. } => SecretInspectField {
status: "available".to_owned(),
secret_type: "encrypted".to_owned(),
source: ValueSource::Profile,
redacted: true,
},
SecretSpec::Keychain { .. } => SecretInspectField {
status: "available".to_owned(),
secret_type: "keychain".to_owned(),
source: ValueSource::Profile,
redacted: true,
},
SecretSpec::Env { .. } => SecretInspectField {
status: "available".to_owned(),
secret_type: "env".to_owned(),
source: ValueSource::Env,
redacted: true,
},
SecretSpec::SameAs { target } => {
resolve_secret_recursive(target, profile, resolved, visiting)?
}
};
visiting.pop();
resolved.insert(name.to_owned(), field.clone());
Ok(field)
}
pub fn resolve_profile_secret_value(
profile: &ProfileConfig,
name: &str,
env: &BTreeMap<String, String>,
) -> RosWireResult<Option<String>> {
resolve_profile_secret_value_recursive(profile, name, env, &mut Vec::new())
}
fn resolve_profile_secret_value_recursive(
profile: &ProfileConfig,
name: &str,
env: &BTreeMap<String, String>,
visiting: &mut Vec<String>,
) -> RosWireResult<Option<String>> {
let Some(spec) = profile.secrets.get(name) else {
return Ok(None);
};
if visiting.iter().any(|item| item == name) {
return Err(Box::new(RosWireError::config(
"secret same-as cycle detected",
)));
}
visiting.push(name.to_owned());
let value = match spec {
SecretSpec::Plain { value } => {
if !profile.allow_plain_secrets {
return Err(Box::new(RosWireError::config(
"plain secrets require allow_plain_secrets = true",
)));
}
Some(value.clone())
}
SecretSpec::Encrypted { key_id, value } => {
let master_key = encrypted_master_key(env, key_id.as_deref())?;
Some(decrypt_secret_value(value, &master_key)?)
}
SecretSpec::Keychain { service, account } => Some(read_keychain_secret(service, account)?),
SecretSpec::Env { var } => Some(read_env_secret(env, var)?),
SecretSpec::SameAs { target } => {
resolve_profile_secret_value_recursive(profile, target, env, visiting)?
}
};
visiting.pop();
Ok(value)
}
fn read_env_secret(env: &BTreeMap<String, String>, var: &str) -> RosWireResult<String> {
env.get(var)
.filter(|value| !value.is_empty())
.cloned()
.ok_or_else(|| {
Box::new(RosWireError::secret_not_found(format!(
"environment secret is not set: {var}",
)))
})
}
fn read_keychain_secret(service: &str, account: &str) -> RosWireResult<String> {
let entry = keyring::Entry::new(service, account).map_err(map_keychain_backend_error)?;
entry.get_password().map_err(map_keychain_read_error)
}
fn write_keychain_secret(service: &str, account: &str, value: &str) -> RosWireResult<()> {
let entry = keyring::Entry::new(service, account).map_err(map_keychain_backend_error)?;
entry
.set_password(value)
.map_err(map_keychain_backend_error)
}
fn map_keychain_read_error(error: keyring::Error) -> Box<RosWireError> {
match error {
keyring::Error::NoEntry => Box::new(RosWireError::secret_not_found(
"keychain secret was not found",
)),
other => map_keychain_backend_error(other),
}
}
fn map_keychain_backend_error(error: keyring::Error) -> Box<RosWireError> {
Box::new(RosWireError::secret_backend_unavailable(format!(
"keychain backend unavailable: {error}",
)))
}
fn encrypted_master_key(
env: &BTreeMap<String, String>,
key_id: Option<&str>,
) -> RosWireResult<String> {
let key_env = key_id.unwrap_or(DEFAULT_MASTER_KEY_ENV);
env.get(key_env)
.filter(|value| !value.is_empty())
.cloned()
.ok_or_else(|| {
Box::new(RosWireError::secret_backend_unavailable(format!(
"encrypted secret master key env var is unavailable: {key_env}",
)))
})
}
fn encrypt_secret_value(value: &str, master_key: &str) -> RosWireResult<String> {
let key = derive_encryption_key(master_key);
let cipher = Aes256Gcm::new_from_slice(&key).map_err(|error| {
Box::new(RosWireError::internal(format!(
"failed to initialize secret cipher: {error}",
)))
})?;
let mut nonce = [0_u8; 12];
OsRng.fill_bytes(&mut nonce);
let ciphertext = cipher
.encrypt(Nonce::from_slice(&nonce), value.as_bytes())
.map_err(|_| {
Box::new(RosWireError::secret_decrypt_failed(
"failed to encrypt secret",
))
})?;
Ok(format!(
"v1:{}:{}",
BASE64_STANDARD.encode(nonce),
BASE64_STANDARD.encode(ciphertext)
))
}
fn decrypt_secret_value(value: &str, master_key: &str) -> RosWireResult<String> {
let parts = value.split(':').collect::<Vec<_>>();
if parts.len() != 3 || parts[0] != "v1" {
return Err(Box::new(RosWireError::secret_decrypt_failed(
"encrypted secret payload has an unsupported format",
)));
}
let nonce = BASE64_STANDARD.decode(parts[1]).map_err(|_| {
Box::new(RosWireError::secret_decrypt_failed(
"encrypted secret nonce is invalid",
))
})?;
let ciphertext = BASE64_STANDARD.decode(parts[2]).map_err(|_| {
Box::new(RosWireError::secret_decrypt_failed(
"encrypted secret ciphertext is invalid",
))
})?;
if nonce.len() != 12 {
return Err(Box::new(RosWireError::secret_decrypt_failed(
"encrypted secret nonce has invalid length",
)));
}
let key = derive_encryption_key(master_key);
let cipher = Aes256Gcm::new_from_slice(&key).map_err(|error| {
Box::new(RosWireError::internal(format!(
"failed to initialize secret cipher: {error}",
)))
})?;
let plaintext = cipher
.decrypt(Nonce::from_slice(&nonce), ciphertext.as_ref())
.map_err(|_| RosWireError::secret_decrypt_failed("failed to decrypt secret"))?;
String::from_utf8(plaintext).map_err(|_| {
Box::new(RosWireError::secret_decrypt_failed(
"decrypted secret is not valid UTF-8",
))
})
}
fn derive_encryption_key(master_key: &str) -> [u8; 32] {
Sha256::digest(master_key.as_bytes()).into()
}
pub fn inspect_config(
cli: &Cli,
_env: &BTreeMap<String, String>,
config: &ConfigFile,
paths: &ConfigPaths,
) -> RosWireResult<ConfigInspect> {
let active_profile = select_active_profile(cli.profile.as_deref(), config)?;
let profile = config
.profiles
.get(&active_profile)
.ok_or_else(|| Box::new(RosWireError::profile_not_found(active_profile.clone())))?;
let secrets = resolve_profile_secrets(profile)?;
let mut resolved = BTreeMap::new();
insert_resolved_field(
&mut resolved,
"host",
cli.host.as_deref(),
profile.host.as_deref(),
None,
);
insert_resolved_field(
&mut resolved,
"user",
cli.user.as_deref(),
profile.user.as_deref(),
None,
);
insert_resolved_field(
&mut resolved,
"protocol",
cli.protocol.map(|value| value.as_str()),
profile.protocol.as_deref(),
Some("auto"),
);
insert_resolved_field(
&mut resolved,
"routeros_version",
cli.routeros_version.map(|value| value.as_str()),
profile.routeros_version.as_deref(),
Some("auto"),
);
insert_resolved_field(
&mut resolved,
"transfer",
cli.transfer.map(|value| value.as_str()),
profile.transfer.as_deref(),
Some("ssh"),
);
let port_cli = cli.port.map(|value| value.to_string());
let port_profile = profile.port.map(|value| value.to_string());
insert_resolved_field(
&mut resolved,
"port",
port_cli.as_deref(),
port_profile.as_deref(),
None,
);
let ssh_port_cli = cli.ssh_port.map(|value| value.to_string());
let ssh_port_profile = profile.ssh_port.map(|value| value.to_string());
insert_resolved_field(
&mut resolved,
"ssh_port",
ssh_port_cli.as_deref(),
ssh_port_profile.as_deref(),
Some("22"),
);
insert_resolved_field(
&mut resolved,
"ssh_user",
cli.ssh_user.as_deref(),
profile.ssh_user.as_deref(),
None,
);
let ssh_key_cli = cli.ssh_key.as_deref().map(redact_local_path_for_inspect);
let ssh_key_profile = profile
.ssh_key
.as_deref()
.map(redact_local_path_for_inspect);
insert_resolved_field(
&mut resolved,
"ssh_key",
ssh_key_cli.as_deref(),
ssh_key_profile.as_deref(),
None,
);
insert_resolved_field(
&mut resolved,
"ssh_host_key",
cli.ssh_host_key.as_deref(),
profile.ssh_host_key.as_deref(),
None,
);
let allow_from_cli = (!cli.allow_from.is_empty()).then(|| cli.allow_from.join(","));
let allow_from_profile = (!profile.allow_from.is_empty()).then(|| profile.allow_from.join(","));
insert_resolved_field(
&mut resolved,
"allow_from",
allow_from_cli.as_deref(),
allow_from_profile.as_deref(),
None,
);
Ok(ConfigInspect {
schema_version: "roswire.config.inspect.v1".to_owned(),
active_profile,
paths: ConfigInspectPaths {
home: paths.home.display().to_string(),
config: paths.config.display().to_string(),
logs: paths.logs.display().to_string(),
},
logging: config.logging.clone(),
resolved,
secrets,
warnings: Vec::new(),
})
}
fn insert_resolved_field(
resolved: &mut BTreeMap<String, ResolvedField>,
name: &str,
cli_value: Option<&str>,
profile_value: Option<&str>,
default_value: Option<&str>,
) {
let candidate = cli_value
.map(|value| (value.to_owned(), ValueSource::Cli))
.or_else(|| profile_value.map(|value| (value.to_owned(), ValueSource::Profile)))
.or_else(|| default_value.map(|value| (value.to_owned(), ValueSource::Default)));
if let Some((value, source)) = candidate {
resolved.insert(name.to_owned(), ResolvedField { value, source });
}
}
pub fn has_error_code(error: &RosWireError, expected: ErrorCode) -> bool {
error.error_code == expected
}
#[derive(Debug, Serialize)]
struct ConfigInitPayload {
schema_version: &'static str,
operation: &'static str,
created_home: bool,
created_config: bool,
paths: ConfigInspectPaths,
}
#[derive(Debug, Serialize)]
struct ConfigProfilesPayload {
schema_version: &'static str,
default_profile: Option<String>,
profiles: Vec<String>,
}
#[derive(Debug, Serialize)]
struct ConfigDevicePayload {
schema_version: &'static str,
operation: &'static str,
profile: String,
updated_fields: Vec<String>,
default_profile: Option<String>,
}
#[derive(Debug, Serialize)]
struct ConfigSecretPayload {
schema_version: &'static str,
operation: &'static str,
profile: String,
secret_name: String,
#[serde(rename = "type")]
secret_type: String,
redacted: bool,
allow_plain_secrets: bool,
}
pub fn handle(tokens: &[String], cli: &Cli) -> Option<RosWireResult<String>> {
if tokens.is_empty() {
return None;
}
match tokens[0].as_str() {
"config" => Some(handle_config_tokens(tokens, cli)),
"secret" => Some(handle_secret_alias(tokens, cli)),
_ => None,
}
}
fn handle_config_tokens(tokens: &[String], cli: &Cli) -> RosWireResult<String> {
if tokens.len() < 2 {
return Err(Box::new(RosWireError::usage(
"config command requires a subcommand",
)));
}
match tokens[1].as_str() {
"init" => handle_config_init(),
"inspect" => handle_config_inspect(cli),
"profiles" => handle_config_profiles(),
"device" => handle_config_device(tokens),
"secret" => handle_config_secret(tokens, cli),
_ => Err(Box::new(RosWireError::usage(format!(
"unsupported config subcommand: {}",
tokens[1],
)))),
}
}
fn handle_secret_alias(tokens: &[String], cli: &Cli) -> RosWireResult<String> {
if tokens.get(1).map(String::as_str) != Some("set") {
return Err(Box::new(RosWireError::usage(
"secret command supports: secret set <profile> <name> type=<...>",
)));
}
if tokens.len() < 4 {
return Err(Box::new(RosWireError::usage(
"secret set requires: secret set <profile> <name> type=<...>",
)));
}
handle_secret_set_impl(&tokens[2], &tokens[3], &tokens[4..], cli.stdin)
}
fn handle_config_init() -> RosWireResult<String> {
let env = read_env_map();
let paths = runtime_paths_from_env(&env);
let (created_home, created_config) = ensure_home_layout(&paths)?;
render_json(&ConfigInitPayload {
schema_version: "roswire.config.init.v1",
operation: "config.init",
created_home,
created_config,
paths: ConfigInspectPaths {
home: paths.home.display().to_string(),
config: paths.config.display().to_string(),
logs: paths.logs.display().to_string(),
},
})
}
fn handle_config_inspect(cli: &Cli) -> RosWireResult<String> {
let env = read_env_map();
let paths = runtime_paths_from_env(&env);
if !paths.config.exists() {
return Err(Box::new(RosWireError::config(
"config.toml not found; run `roswire config init --json` first",
)));
}
ensure_secure_directory_permissions(&paths.home)?;
ensure_secure_file_permissions(&paths.config)?;
let config = load_config_file(&paths.config)?;
let inspect = inspect_config(cli, &env, &config, &paths)?;
render_json(&inspect)
}
fn handle_config_profiles() -> RosWireResult<String> {
let env = read_env_map();
let paths = runtime_paths_from_env(&env);
if !paths.config.exists() {
return Err(Box::new(RosWireError::config(
"config.toml not found; run `roswire config init --json` first",
)));
}
let config = load_config_file(&paths.config)?;
let profiles = config.profiles.keys().cloned().collect::<Vec<_>>();
render_json(&ConfigProfilesPayload {
schema_version: "roswire.config.profiles.v1",
default_profile: config.default_profile,
profiles,
})
}
fn handle_config_device(tokens: &[String]) -> RosWireResult<String> {
if tokens.len() < 4 {
return Err(Box::new(RosWireError::usage(
"config device requires: config device <add|set> <profile> [key=value ...]",
)));
}
let operation = tokens[2].as_str();
let profile_name = tokens[3].clone();
let key_values = parse_key_value_tokens(&tokens[4..])?;
if operation != "add" && operation != "set" {
return Err(Box::new(RosWireError::usage(
"config device supports add|set",
)));
}
let env = read_env_map();
let paths = runtime_paths_from_env(&env);
let _ = ensure_home_layout(&paths)?;
let mut config = load_or_default_config(&paths.config)?;
let profile_exists = config.profiles.contains_key(&profile_name);
if operation == "add" && profile_exists {
return Err(Box::new(RosWireError::config(format!(
"profile already exists: {profile_name}",
))));
}
let profile = config
.profiles
.entry(profile_name.clone())
.or_insert_with(ProfileConfig::default);
let mut updated_fields = Vec::new();
for (key, value) in key_values {
match key.as_str() {
"host" => {
validate_remote_host(&value)?;
profile.host = Some(value);
updated_fields.push("host".to_owned());
}
"user" => {
profile.user = Some(value);
updated_fields.push("user".to_owned());
}
"protocol" => {
profile.protocol = Some(normalize_protocol(&value)?);
updated_fields.push("protocol".to_owned());
}
"routeros_version" | "routeros-version" => {
profile.routeros_version = Some(normalize_routeros_version(&value)?);
updated_fields.push("routeros_version".to_owned());
}
"transfer" => {
profile.transfer = Some(normalize_transfer(&value)?);
updated_fields.push("transfer".to_owned());
}
"port" => {
profile.port = Some(parse_port(&value)?);
updated_fields.push("port".to_owned());
}
"ssh_port" | "ssh-port" => {
profile.ssh_port = Some(parse_port(&value)?);
updated_fields.push("ssh_port".to_owned());
}
"ssh_user" | "ssh-user" => {
profile.ssh_user = Some(value);
updated_fields.push("ssh_user".to_owned());
}
"ssh_key" | "ssh-key" => {
profile.ssh_key = Some(value);
updated_fields.push("ssh_key".to_owned());
}
"ssh_host_key" | "ssh-host-key" => {
profile.ssh_host_key = Some(value);
updated_fields.push("ssh_host_key".to_owned());
}
"allow_from" | "allow-from" => {
profile.allow_from = parse_allow_from_list(&value)?;
updated_fields.push("allow_from".to_owned());
}
_ => {
return Err(Box::new(RosWireError::usage(format!(
"unsupported device field: {key}",
))));
}
}
}
if operation == "add" && (profile.host.is_none() || profile.user.is_none()) {
return Err(Box::new(RosWireError::usage(
"config device add requires host=<...> and user=<...>",
)));
}
if config.default_profile.is_none() {
config.default_profile = Some(profile_name.clone());
}
save_config_file(&paths.config, &config)?;
render_json(&ConfigDevicePayload {
schema_version: "roswire.config.device.v1",
operation: if operation == "add" {
"config.device.add"
} else {
"config.device.set"
},
profile: profile_name,
updated_fields,
default_profile: config.default_profile,
})
}
fn handle_config_secret(tokens: &[String], cli: &Cli) -> RosWireResult<String> {
if tokens.get(2).map(String::as_str) != Some("set") {
return Err(Box::new(RosWireError::usage(
"config secret supports: config secret set <profile> <name> type=<...>",
)));
}
if tokens.len() < 5 {
return Err(Box::new(RosWireError::usage(
"config secret set requires: config secret set <profile> <name> type=<...>",
)));
}
handle_secret_set_impl(&tokens[3], &tokens[4], &tokens[5..], cli.stdin)
}
fn handle_secret_set_impl(
profile_name: &str,
secret_name: &str,
key_value_tokens: &[String],
read_stdin: bool,
) -> RosWireResult<String> {
let mut key_values = parse_key_value_tokens(key_value_tokens)?;
let secret_type = key_values
.remove("type")
.ok_or_else(|| Box::new(RosWireError::usage("secret set requires type=<...>")))?;
let input_value = match secret_type.as_str() {
"plain" | "encrypted" | "keychain" => take_secret_input(&mut key_values, read_stdin, true)?,
_ => take_secret_input(&mut key_values, false, false)?,
};
let env = read_env_map();
let paths = runtime_paths_from_env(&env);
let _ = ensure_home_layout(&paths)?;
let mut config = load_or_default_config(&paths.config)?;
if !config.profiles.contains_key(profile_name) {
return Err(Box::new(RosWireError::profile_not_found(profile_name)));
}
let (secret_spec, normalized_type, toggled_plain) = match secret_type.as_str() {
"plain" => {
let value = input_value.ok_or_else(|| {
Box::new(RosWireError::usage(
"plain secret requires value=<...>, env=<VAR>, or --stdin",
))
})?;
(SecretSpec::Plain { value }, "plain".to_owned(), true)
}
"encrypted" => {
let value = input_value.ok_or_else(|| {
Box::new(RosWireError::usage(
"encrypted secret requires value=<...>, env=<VAR>, or --stdin",
))
})?;
let key_id = key_values.remove("key_id");
let master_key = encrypted_master_key(&env, key_id.as_deref())?;
let value = encrypt_secret_value(&value, &master_key)?;
(
SecretSpec::Encrypted { key_id, value },
"encrypted".to_owned(),
false,
)
}
"keychain" => {
let service = key_values.remove("service").ok_or_else(|| {
Box::new(RosWireError::usage(
"keychain secret requires service=<...>",
))
})?;
let account = key_values.remove("account").ok_or_else(|| {
Box::new(RosWireError::usage(
"keychain secret requires account=<...>",
))
})?;
if let Some(value) = input_value.as_deref() {
write_keychain_secret(&service, &account, value)?;
}
(
SecretSpec::Keychain { service, account },
"keychain".to_owned(),
false,
)
}
"env" => {
let var = key_values
.remove("env")
.ok_or_else(|| Box::new(RosWireError::usage("env secret requires env=<VAR>")))?;
if input_value.is_some() || read_stdin {
return Err(Box::new(RosWireError::usage(
"env secret stores an environment variable name; do not pass value=<...> or --stdin",
)));
}
(SecretSpec::Env { var }, "env".to_owned(), false)
}
"same-as" => {
let target = key_values.remove("target").ok_or_else(|| {
Box::new(RosWireError::usage("same-as secret requires target=<...>"))
})?;
if input_value.is_some() || read_stdin {
return Err(Box::new(RosWireError::usage(
"same-as secret does not accept value=<...>, env=<VAR>, or --stdin",
)));
}
(SecretSpec::SameAs { target }, "same-as".to_owned(), false)
}
_ => {
return Err(Box::new(RosWireError::usage(format!(
"unsupported secret type: {secret_type}",
))));
}
};
if let Some(extra) = key_values.keys().next() {
return Err(Box::new(RosWireError::usage(format!(
"unexpected secret option: {extra}",
))));
}
let allow_plain_secrets = {
let profile = config
.profiles
.get_mut(profile_name)
.ok_or_else(|| Box::new(RosWireError::profile_not_found(profile_name)))?;
if toggled_plain {
profile.allow_plain_secrets = true;
}
profile.secrets.insert(secret_name.to_owned(), secret_spec);
profile.allow_plain_secrets
};
save_config_file(&paths.config, &config)?;
render_json(&ConfigSecretPayload {
schema_version: "roswire.config.secret.v1",
operation: "config.secret.set",
profile: profile_name.to_owned(),
secret_name: secret_name.to_owned(),
secret_type: normalized_type,
redacted: true,
allow_plain_secrets,
})
}
fn runtime_paths_from_env(env: &BTreeMap<String, String>) -> ConfigPaths {
ConfigPaths::from_home(resolve_home_path(
env.get("ROSWIRE_HOME").map(String::as_str),
))
}
fn read_env_map() -> BTreeMap<String, String> {
std::env::vars().collect()
}
fn parse_key_value_tokens(tokens: &[String]) -> RosWireResult<BTreeMap<String, String>> {
let mut key_values = BTreeMap::new();
for token in tokens {
let (key, value) = token.split_once('=').ok_or_else(|| {
Box::new(RosWireError::usage(format!(
"expected key=value token, got: {token}",
)))
})?;
if key.is_empty() {
return Err(Box::new(RosWireError::usage(
"key=value token cannot have empty key",
)));
}
key_values.insert(key.to_owned(), value.to_owned());
}
Ok(key_values)
}
fn parse_allow_from_list(value: &str) -> RosWireResult<Vec<String>> {
let cidrs = value
.split(',')
.map(str::trim)
.filter(|item| !item.is_empty())
.map(str::to_owned)
.collect::<Vec<_>>();
if cidrs.is_empty() {
return Err(Box::new(RosWireError::usage(
"allow_from requires at least one CIDR",
)));
}
Ok(cidrs)
}
fn take_secret_input(
key_values: &mut BTreeMap<String, String>,
read_stdin: bool,
allow_env_source: bool,
) -> RosWireResult<Option<String>> {
let value = key_values.remove("value");
let env_var = if allow_env_source {
key_values.remove("env")
} else {
None
};
let source_count =
usize::from(value.is_some()) + usize::from(env_var.is_some()) + read_stdin as usize;
if source_count > 1 {
return Err(Box::new(RosWireError::usage(
"secret value source must be exactly one of value=<...>, env=<VAR>, or --stdin",
)));
}
if let Some(value) = value {
return Ok(Some(value));
}
if let Some(var) = env_var {
return std::env::var(&var).map(Some).map_err(|_| {
Box::new(RosWireError::secret_not_found(format!(
"secret source environment variable is not set: {var}",
)))
});
}
if read_stdin {
return read_secret_from_stdin().map(Some);
}
Ok(None)
}
fn read_secret_from_stdin() -> RosWireResult<String> {
let mut value = String::new();
io::stdin().read_to_string(&mut value).map_err(|error| {
Box::new(RosWireError::config(format!(
"failed to read secret from stdin: {error}",
)))
})?;
Ok(trim_secret_stdin(value))
}
fn trim_secret_stdin(mut value: String) -> String {
while value.ends_with('\n') || value.ends_with('\r') {
value.pop();
}
value
}
fn normalize_protocol(value: &str) -> RosWireResult<String> {
match value {
"auto" | "api" | "api-ssl" | "rest" => Ok(value.to_owned()),
_ => Err(Box::new(RosWireError::usage(format!(
"invalid protocol value: {value}",
)))),
}
}
fn normalize_routeros_version(value: &str) -> RosWireResult<String> {
match value {
"auto" | "v6" | "v7" => Ok(value.to_owned()),
_ => Err(Box::new(RosWireError::usage(format!(
"invalid routeros_version value: {value}",
)))),
}
}
fn normalize_transfer(value: &str) -> RosWireResult<String> {
match value {
"ssh" => Ok(value.to_owned()),
_ => Err(Box::new(RosWireError::usage(format!(
"invalid transfer value: {value}",
)))),
}
}
fn parse_port(value: &str) -> RosWireResult<u16> {
value.parse::<u16>().map_err(|error| {
Box::new(RosWireError::usage(format!(
"invalid port value `{value}`: {error}",
)))
})
}
fn redact_local_path_for_inspect(path: &str) -> String {
let path_ref = Path::new(path);
if path_ref.is_absolute() {
let file_name = path_ref
.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty())
.unwrap_or("ssh-key");
format!("***REDACTED***/{file_name}")
} else {
path.to_owned()
}
}
fn ensure_home_layout(paths: &ConfigPaths) -> RosWireResult<(bool, bool)> {
let created_home = !paths.home.exists();
let created_config = !paths.config.exists();
fs::create_dir_all(&paths.home).map_err(|error| {
Box::new(RosWireError::config(format!(
"failed to create roswire home: {error}",
)))
})?;
fs::create_dir_all(&paths.logs).map_err(|error| {
Box::new(RosWireError::config(format!(
"failed to create roswire logs directory: {error}",
)))
})?;
if created_config {
save_config_file(&paths.config, &ConfigFile::default())?;
}
#[cfg(unix)]
{
fs::set_permissions(&paths.home, fs::Permissions::from_mode(0o700)).map_err(|error| {
Box::new(RosWireError::config(format!(
"failed to set home permissions: {error}",
)))
})?;
if paths.config.exists() {
fs::set_permissions(&paths.config, fs::Permissions::from_mode(0o600)).map_err(
|error| {
Box::new(RosWireError::config(format!(
"failed to set config permissions: {error}",
)))
},
)?;
}
}
Ok((created_home, created_config))
}
fn load_or_default_config(path: &Path) -> RosWireResult<ConfigFile> {
if path.exists() {
load_config_file(path)
} else {
Ok(ConfigFile::default())
}
}
fn save_config_file(path: &Path, config: &ConfigFile) -> RosWireResult<()> {
let serialized = toml::to_string_pretty(config).map_err(|error| {
Box::new(RosWireError::internal(format!(
"failed to serialize config.toml: {error}",
)))
})?;
fs::write(path, serialized).map_err(|error| {
Box::new(RosWireError::config(format!(
"failed to write config.toml: {error}",
)))
})?;
#[cfg(unix)]
{
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|error| {
Box::new(RosWireError::config(format!(
"failed to set config permissions: {error}",
)))
})?;
}
Ok(())
}
fn render_json<T: Serialize>(value: &T) -> RosWireResult<String> {
serde_json::to_string_pretty(value).map_err(|error| {
Box::new(RosWireError::internal(format!(
"failed to serialize config payload: {error}",
)))
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::args::ProtocolMode;
use clap::Parser;
#[test]
fn roswire_home_respects_env_override() {
let path = resolve_home_path(Some("/tmp/roswire-home"));
assert_eq!(path, PathBuf::from("/tmp/roswire-home"));
}
#[test]
fn logging_config_defaults_and_partial_overrides_are_stable() {
let default_config = parse_config_toml("version = 1").expect("config should parse");
assert!(default_config.logging.enabled);
assert_eq!(default_config.logging.retention_days, 30);
assert_eq!(default_config.logging.level, "info");
let custom = parse_config_toml(
r#"
version = 1
[logging]
enabled = false
retention_days = 7
"#,
)
.expect("config should parse");
assert!(!custom.logging.enabled);
assert_eq!(custom.logging.retention_days, 7);
assert_eq!(custom.logging.level, "info");
}
#[test]
fn select_profile_uses_cli_then_default() {
let config = ConfigFile {
version: 1,
default_profile: Some("home".to_owned()),
profiles: BTreeMap::from([
("home".to_owned(), ProfileConfig::default()),
("office".to_owned(), ProfileConfig::default()),
]),
logging: LoggingConfig::default(),
};
let selected =
select_active_profile(Some("office"), &config).expect("cli profile should win");
assert_eq!(selected, "office");
let selected = select_active_profile(None, &config).expect("default profile should apply");
assert_eq!(selected, "home");
}
#[test]
fn mac_host_detection_rejects_common_layer2_formats_only() {
assert!(is_mac_address("48:8F:5A:A3:0E:A7"));
assert!(is_mac_address("48-8f-5a-a3-0e-a7"));
assert!(!is_mac_address("192.0.2.1"));
assert!(!is_mac_address("router.example.test"));
assert!(!is_mac_address("2001:db8::1"));
validate_remote_host("router.example.test").expect("DNS host should be accepted");
let error =
validate_remote_host("48:8F:5A:A3:0E:A7").expect_err("MAC host should be rejected");
assert!(has_error_code(&error, ErrorCode::ConfigError));
assert_eq!(error.context.host, "48:8F:5A:A3:0E:A7");
assert!(error.message.contains("MAC address"));
assert!(error
.hint
.as_deref()
.is_some_and(|hint| hint.contains("Layer 2 discovery is not supported")));
}
#[test]
fn profile_not_found_returns_structured_error() {
let config = ConfigFile {
version: 1,
default_profile: Some("home".to_owned()),
profiles: BTreeMap::from([("home".to_owned(), ProfileConfig::default())]),
logging: LoggingConfig::default(),
};
let error = select_active_profile(Some("missing"), &config)
.expect_err("missing profile should fail");
assert!(has_error_code(&error, ErrorCode::ProfileNotFound));
}
#[test]
fn inspect_config_resolves_precedence() {
let cli = Cli::try_parse_from([
"roswire",
"--host",
"3.3.3.3",
"--protocol",
"rest",
"ip",
"address",
"print",
])
.expect("cli should parse");
let config = ConfigFile {
version: 1,
default_profile: Some("home".to_owned()),
profiles: BTreeMap::from([(
"home".to_owned(),
ProfileConfig {
host: Some("1.1.1.1".to_owned()),
user: Some("profile-user".to_owned()),
protocol: Some("api".to_owned()),
routeros_version: Some("v7".to_owned()),
transfer: Some("ssh".to_owned()),
port: Some(8728),
ssh_host_key: Some("SHA256:profile".to_owned()),
allow_from: vec!["203.0.113.10/32".to_owned()],
..ProfileConfig::default()
},
)]),
logging: LoggingConfig::default(),
};
let env = BTreeMap::from([
("ROS_HOST".to_owned(), "2.2.2.2".to_owned()),
("ROS_USER".to_owned(), "env-user".to_owned()),
]);
let paths = ConfigPaths::from_home(PathBuf::from("/tmp/roswire"));
let inspect = inspect_config(&cli, &env, &config, &paths).expect("inspect should work");
assert_eq!(inspect.active_profile, "home");
assert_eq!(inspect.schema_version, "roswire.config.inspect.v1");
assert_eq!(
inspect.resolved.get("host"),
Some(&ResolvedField {
value: "3.3.3.3".to_owned(),
source: ValueSource::Cli,
})
);
assert_eq!(
inspect.resolved.get("user"),
Some(&ResolvedField {
value: "profile-user".to_owned(),
source: ValueSource::Profile,
})
);
assert_eq!(
inspect.resolved.get("protocol"),
Some(&ResolvedField {
value: ProtocolMode::Rest.as_str().to_owned(),
source: ValueSource::Cli,
})
);
assert_eq!(
inspect.resolved.get("routeros_version"),
Some(&ResolvedField {
value: "v7".to_owned(),
source: ValueSource::Profile,
})
);
assert_eq!(
inspect.resolved.get("ssh_host_key"),
Some(&ResolvedField {
value: "SHA256:profile".to_owned(),
source: ValueSource::Profile,
})
);
assert_eq!(
inspect.resolved.get("allow_from"),
Some(&ResolvedField {
value: "203.0.113.10/32".to_owned(),
source: ValueSource::Profile,
})
);
assert_eq!(inspect.logging, LoggingConfig::default());
}
#[test]
fn plain_secret_requires_explicit_allow_flag() {
let profile = ProfileConfig {
allow_plain_secrets: false,
secrets: BTreeMap::from([(
"password".to_owned(),
SecretSpec::Plain {
value: "super-secret".to_owned(),
},
)]),
..ProfileConfig::default()
};
let error = resolve_profile_secrets(&profile).expect_err("plain secret should be blocked");
assert!(has_error_code(&error, ErrorCode::ConfigError));
}
#[test]
fn same_as_secret_detects_cycle() {
let profile = ProfileConfig {
allow_plain_secrets: true,
secrets: BTreeMap::from([
(
"password".to_owned(),
SecretSpec::SameAs {
target: "ssh_password".to_owned(),
},
),
(
"ssh_password".to_owned(),
SecretSpec::SameAs {
target: "password".to_owned(),
},
),
]),
..ProfileConfig::default()
};
let error = resolve_profile_secrets(&profile).expect_err("cycle should fail");
assert!(has_error_code(&error, ErrorCode::ConfigError));
}
#[test]
fn same_as_secret_resolves_without_exposing_plain_value() {
let profile = ProfileConfig {
allow_plain_secrets: true,
secrets: BTreeMap::from([
(
"password".to_owned(),
SecretSpec::Plain {
value: "super-secret".to_owned(),
},
),
(
"ssh_password".to_owned(),
SecretSpec::SameAs {
target: "password".to_owned(),
},
),
]),
..ProfileConfig::default()
};
let resolved = resolve_profile_secrets(&profile).expect("secrets should resolve");
assert_eq!(
resolved
.get("password")
.map(|field| field.secret_type.as_str()),
Some("plain")
);
assert_eq!(
resolved
.get("ssh_password")
.map(|field| field.secret_type.as_str()),
Some("plain")
);
assert_eq!(
resolved.get("password").map(|field| field.redacted),
Some(true)
);
}
#[test]
fn env_secret_resolves_without_storing_value() {
let secret = generated_secret();
let profile = ProfileConfig {
secrets: BTreeMap::from([
(
"password".to_owned(),
SecretSpec::Env {
var: "ROSWIRE_TEST_PASSWORD".to_owned(),
},
),
(
"ssh_password".to_owned(),
SecretSpec::SameAs {
target: "password".to_owned(),
},
),
]),
..ProfileConfig::default()
};
let env = BTreeMap::from([("ROSWIRE_TEST_PASSWORD".to_owned(), secret.clone())]);
let password = resolve_profile_secret_value(&profile, "password", &env)
.expect("env secret should resolve");
let ssh_password = resolve_profile_secret_value(&profile, "ssh_password", &env)
.expect("same-as env secret should resolve");
assert_eq!(password.as_deref(), Some(secret.as_str()));
assert_eq!(ssh_password.as_deref(), Some(secret.as_str()));
}
#[test]
fn missing_env_secret_uses_structured_error() {
let profile = ProfileConfig {
secrets: BTreeMap::from([(
"password".to_owned(),
SecretSpec::Env {
var: "ROSWIRE_TEST_PASSWORD".to_owned(),
},
)]),
..ProfileConfig::default()
};
let error = resolve_profile_secret_value(&profile, "password", &BTreeMap::new())
.expect_err("missing env secret should fail");
assert!(has_error_code(&error, ErrorCode::SecretNotFound));
}
#[test]
fn encrypted_secret_round_trips_with_env_master_key() {
let secret = generated_secret();
let master_key = generated_master_key();
let encrypted = encrypt_secret_value(&secret, &master_key).expect("secret should encrypt");
let profile = ProfileConfig {
secrets: BTreeMap::from([(
"password".to_owned(),
SecretSpec::Encrypted {
key_id: Some("ROSWIRE_TEST_MASTER_KEY".to_owned()),
value: encrypted.clone(),
},
)]),
..ProfileConfig::default()
};
let env = BTreeMap::from([("ROSWIRE_TEST_MASTER_KEY".to_owned(), master_key)]);
let resolved = resolve_profile_secret_value(&profile, "password", &env)
.expect("encrypted secret should resolve");
assert_ne!(encrypted, secret);
assert_eq!(resolved.as_deref(), Some(secret.as_str()));
}
#[test]
fn encrypted_secret_rejects_wrong_master_key() {
let secret = generated_secret();
let encrypted =
encrypt_secret_value(&secret, &generated_master_key()).expect("secret should encrypt");
let profile = ProfileConfig {
secrets: BTreeMap::from([(
"password".to_owned(),
SecretSpec::Encrypted {
key_id: Some("ROSWIRE_TEST_MASTER_KEY".to_owned()),
value: encrypted,
},
)]),
..ProfileConfig::default()
};
let env = BTreeMap::from([(
"ROSWIRE_TEST_MASTER_KEY".to_owned(),
generated_other_master_key(),
)]);
let error = resolve_profile_secret_value(&profile, "password", &env)
.expect_err("wrong master key should fail");
assert!(has_error_code(&error, ErrorCode::SecretDecryptFailed));
}
#[test]
fn stdin_secret_trimming_removes_line_endings_only() {
assert_eq!(trim_secret_stdin("abc\n".to_owned()), "abc");
assert_eq!(trim_secret_stdin("abc\r\n".to_owned()), "abc");
assert_eq!(trim_secret_stdin(" abc ".to_owned()), " abc ");
}
#[test]
fn inspect_output_never_contains_secret_values() {
let cli = Cli::try_parse_from(["roswire", "interface", "print"]).expect("cli should parse");
let config = ConfigFile {
version: 1,
default_profile: Some("home".to_owned()),
profiles: BTreeMap::from([(
"home".to_owned(),
ProfileConfig {
allow_plain_secrets: true,
secrets: BTreeMap::from([(
"password".to_owned(),
SecretSpec::Plain {
value: "super-secret".to_owned(),
},
)]),
..ProfileConfig::default()
},
)]),
logging: LoggingConfig::default(),
};
let secret = generated_secret();
let mut config = config;
if let Some(profile) = config.profiles.get_mut("home") {
profile.secrets.insert(
"env_password".to_owned(),
SecretSpec::Env {
var: "ROSWIRE_TEST_PASSWORD".to_owned(),
},
);
}
let inspect = inspect_config(
&cli,
&BTreeMap::from([("ROSWIRE_TEST_PASSWORD".to_owned(), secret.clone())]),
&config,
&ConfigPaths::from_home(PathBuf::from("/tmp/roswire")),
)
.expect("inspect should succeed");
let payload = serde_json::to_string(&inspect).expect("inspect payload should serialize");
assert!(!payload.contains("super-secret"));
assert!(!payload.contains(&secret));
assert_eq!(
inspect.secrets.get("password").map(|field| field.redacted),
Some(true)
);
}
#[derive(Debug)]
struct FakeKeychainBackendError;
impl std::fmt::Display for FakeKeychainBackendError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("fake keychain unavailable")
}
}
impl std::error::Error for FakeKeychainBackendError {}
#[test]
fn keychain_backend_errors_are_structured() {
let unavailable = map_keychain_backend_error(keyring::Error::NoStorageAccess(Box::new(
FakeKeychainBackendError,
)));
assert!(has_error_code(
&unavailable,
ErrorCode::SecretBackendUnavailable
));
assert!(unavailable.message.contains("keychain backend unavailable"));
let missing = map_keychain_read_error(keyring::Error::NoEntry);
assert!(has_error_code(&missing, ErrorCode::SecretNotFound));
}
#[cfg(unix)]
#[test]
fn insecure_file_permissions_are_rejected() {
let temp = tempfile::tempdir().expect("temp dir should be created");
let file = temp.path().join("config.toml");
fs::write(&file, "version = 1").expect("config file should be written");
let mut permissions = fs::metadata(&file)
.expect("metadata should exist")
.permissions();
permissions.set_mode(0o644);
fs::set_permissions(&file, permissions).expect("permissions should be applied");
let error =
ensure_secure_file_permissions(&file).expect_err("insecure permissions should fail");
assert!(has_error_code(&error, ErrorCode::ConfigInsecurePermissions));
}
fn generated_secret() -> String {
[
'r', 'o', 's', 'w', 'i', 'r', 'e', '-', 's', 'e', 'c', 'r', 'e', 't',
]
.into_iter()
.collect()
}
fn generated_master_key() -> String {
[
'r', 'o', 's', 'w', 'i', 'r', 'e', '-', 'm', 'a', 's', 't', 'e', 'r', '-', 'k', 'e',
'y',
]
.into_iter()
.collect()
}
fn generated_other_master_key() -> String {
[
'o', 't', 'h', 'e', 'r', '-', 'm', 'a', 's', 't', 'e', 'r', '-', 'k', 'e', 'y',
]
.into_iter()
.collect()
}
}