789 lines
23 KiB
Rust
789 lines
23 KiB
Rust
//! Strict, secret-free configuration shared by every frontend.
|
|
|
|
use std::{
|
|
collections::BTreeSet,
|
|
env,
|
|
error::Error,
|
|
fmt, fs,
|
|
path::{Component, Path, PathBuf},
|
|
time::Duration,
|
|
};
|
|
|
|
use serde::Deserialize;
|
|
use url::Url;
|
|
|
|
use crate::authentication::{AuthenticationTimeout, DEFAULT_AUTHENTICATION_TIMEOUT};
|
|
use crate::presentation::{ClipboardTimeout, DEFAULT_CLIPBOARD_TIMEOUT};
|
|
|
|
const APPLICATION_DIRECTORY: &str = "ironstorage";
|
|
const CONFIG_FILE: &str = "config.toml";
|
|
const MAX_CONFIG_BYTES: u64 = 1024 * 1024;
|
|
|
|
/// Validated application configuration.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct Config {
|
|
source: PathBuf,
|
|
vault: PathBuf,
|
|
default_key: KeyIdentity,
|
|
key_material: PathBuf,
|
|
editor: Option<EditorCommand>,
|
|
clipboard_timeout: ClipboardTimeout,
|
|
authentication_timeout: AuthenticationTimeout,
|
|
git_remotes: Vec<GitRemote>,
|
|
}
|
|
|
|
impl Config {
|
|
/// Load an explicit configuration file, or the native per-user default.
|
|
pub fn load(explicit: Option<&Path>) -> Result<Self, ConfigError> {
|
|
ConfigLoader::system()?.load(explicit)
|
|
}
|
|
|
|
pub fn source(&self) -> &Path {
|
|
&self.source
|
|
}
|
|
|
|
pub fn vault(&self) -> &Path {
|
|
&self.vault
|
|
}
|
|
|
|
pub fn default_key(&self) -> &KeyIdentity {
|
|
&self.default_key
|
|
}
|
|
|
|
pub fn key_material(&self) -> &Path {
|
|
&self.key_material
|
|
}
|
|
|
|
pub fn configured_editor(&self) -> Option<&EditorCommand> {
|
|
self.editor.as_ref()
|
|
}
|
|
|
|
pub fn clipboard_timeout(&self) -> ClipboardTimeout {
|
|
self.clipboard_timeout
|
|
}
|
|
|
|
/// Shared inactivity lease used by every interactive frontend.
|
|
pub fn authentication_timeout(&self) -> AuthenticationTimeout {
|
|
self.authentication_timeout
|
|
}
|
|
|
|
pub fn git_remotes(&self) -> &[GitRemote] {
|
|
&self.git_remotes
|
|
}
|
|
|
|
/// Resolve the configured editor, then `$VISUAL`, `$EDITOR`, and finally `vim`.
|
|
pub fn resolve_editor(&self) -> Result<ResolvedEditor, EditorError> {
|
|
self.resolve_editor_from(
|
|
env::var_os("VISUAL").as_deref(),
|
|
env::var_os("EDITOR").as_deref(),
|
|
)
|
|
}
|
|
|
|
/// Deterministic editor resolution used by adapters and tests.
|
|
pub fn resolve_editor_from(
|
|
&self,
|
|
visual: Option<&std::ffi::OsStr>,
|
|
editor: Option<&std::ffi::OsStr>,
|
|
) -> Result<ResolvedEditor, EditorError> {
|
|
if let Some(command) = &self.editor {
|
|
return Ok(ResolvedEditor {
|
|
command: command.clone(),
|
|
source: EditorSource::Configuration,
|
|
});
|
|
}
|
|
if let Some(command) = parse_environment_editor("VISUAL", visual)? {
|
|
return Ok(ResolvedEditor {
|
|
command,
|
|
source: EditorSource::VisualEnvironment,
|
|
});
|
|
}
|
|
if let Some(command) = parse_environment_editor("EDITOR", editor)? {
|
|
return Ok(ResolvedEditor {
|
|
command,
|
|
source: EditorSource::EditorEnvironment,
|
|
});
|
|
}
|
|
Ok(ResolvedEditor {
|
|
command: EditorCommand {
|
|
program: "vim".to_owned(),
|
|
arguments: Vec::new(),
|
|
},
|
|
source: EditorSource::Fallback,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Deterministic path context for configuration loading.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct ConfigLoader {
|
|
current_directory: PathBuf,
|
|
native_config_directory: PathBuf,
|
|
}
|
|
|
|
impl ConfigLoader {
|
|
pub fn system() -> Result<Self, ConfigError> {
|
|
let current_directory = env::current_dir().map_err(|_| ConfigError::CurrentDirectory)?;
|
|
let native_config_directory =
|
|
native_config_directory().ok_or(ConfigError::NativeConfigDirectory)?;
|
|
Ok(Self::new(current_directory, native_config_directory))
|
|
}
|
|
|
|
pub fn new(current_directory: PathBuf, native_config_directory: PathBuf) -> Self {
|
|
Self {
|
|
current_directory: normalize_absolute(current_directory),
|
|
native_config_directory: normalize_absolute(native_config_directory),
|
|
}
|
|
}
|
|
|
|
pub fn default_path(&self) -> PathBuf {
|
|
self.native_config_directory
|
|
.join(APPLICATION_DIRECTORY)
|
|
.join(CONFIG_FILE)
|
|
}
|
|
|
|
pub fn load(&self, explicit: Option<&Path>) -> Result<Config, ConfigError> {
|
|
let requested = explicit
|
|
.map(|path| resolve_path(&self.current_directory, path))
|
|
.unwrap_or_else(|| self.default_path());
|
|
let metadata = fs::metadata(&requested).map_err(|error| {
|
|
if error.kind() == std::io::ErrorKind::NotFound {
|
|
ConfigError::NotFound {
|
|
path: requested.clone(),
|
|
}
|
|
} else {
|
|
ConfigError::Read {
|
|
path: requested.clone(),
|
|
}
|
|
}
|
|
})?;
|
|
if !metadata.is_file() || metadata.len() > MAX_CONFIG_BYTES {
|
|
return Err(ConfigError::InvalidFile {
|
|
path: requested.clone(),
|
|
});
|
|
}
|
|
let source = fs::canonicalize(&requested).map_err(|_| ConfigError::Read {
|
|
path: requested.clone(),
|
|
})?;
|
|
let contents = fs::read_to_string(&source).map_err(|_| ConfigError::Read {
|
|
path: source.clone(),
|
|
})?;
|
|
let value =
|
|
toml::from_str::<toml::Value>(&contents).map_err(|_| ConfigError::Malformed {
|
|
path: source.clone(),
|
|
})?;
|
|
reject_insecure_fields(&value, "")?;
|
|
validate_known_fields(&value, &source)?;
|
|
let raw = value
|
|
.try_into::<RawConfig>()
|
|
.map_err(|_| ConfigError::Malformed {
|
|
path: source.clone(),
|
|
})?;
|
|
validate_config(source, raw)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
|
pub struct KeyIdentity(String);
|
|
|
|
impl KeyIdentity {
|
|
pub fn as_str(&self) -> &str {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for KeyIdentity {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
formatter.write_str(&self.0)
|
|
}
|
|
}
|
|
|
|
macro_rules! identifier_type {
|
|
($name:ident) => {
|
|
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
|
pub struct $name(String);
|
|
|
|
impl $name {
|
|
pub fn as_str(&self) -> &str {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for $name {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
formatter.write_str(&self.0)
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
identifier_type!(RemoteName);
|
|
identifier_type!(ServerId);
|
|
identifier_type!(ApplicationId);
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct GitRemote {
|
|
name: RemoteName,
|
|
url: Url,
|
|
server_id: ServerId,
|
|
application_id: ApplicationId,
|
|
}
|
|
|
|
impl GitRemote {
|
|
pub fn name(&self) -> &RemoteName {
|
|
&self.name
|
|
}
|
|
|
|
pub fn url(&self) -> &Url {
|
|
&self.url
|
|
}
|
|
|
|
pub fn server_id(&self) -> &ServerId {
|
|
&self.server_id
|
|
}
|
|
|
|
pub fn application_id(&self) -> &ApplicationId {
|
|
&self.application_id
|
|
}
|
|
}
|
|
|
|
/// An executable and arguments. It is never interpreted by a shell.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct EditorCommand {
|
|
program: String,
|
|
arguments: Vec<String>,
|
|
}
|
|
|
|
impl EditorCommand {
|
|
pub fn program(&self) -> &str {
|
|
&self.program
|
|
}
|
|
|
|
pub fn arguments(&self) -> &[String] {
|
|
&self.arguments
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum EditorSource {
|
|
Configuration,
|
|
VisualEnvironment,
|
|
EditorEnvironment,
|
|
Fallback,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct ResolvedEditor {
|
|
command: EditorCommand,
|
|
source: EditorSource,
|
|
}
|
|
|
|
impl ResolvedEditor {
|
|
pub fn command(&self) -> &EditorCommand {
|
|
&self.command
|
|
}
|
|
|
|
pub fn source(&self) -> EditorSource {
|
|
self.source
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub enum ConfigError {
|
|
CurrentDirectory,
|
|
NativeConfigDirectory,
|
|
NotFound { path: PathBuf },
|
|
Read { path: PathBuf },
|
|
InvalidFile { path: PathBuf },
|
|
Malformed { path: PathBuf },
|
|
UnknownField { field: String },
|
|
InsecureField { field: String },
|
|
MissingField { field: &'static str },
|
|
InvalidField { field: &'static str },
|
|
VaultIsNotDirectory { path: PathBuf },
|
|
KeyMaterialNotFound { path: PathBuf },
|
|
InvalidKeyMaterial { path: PathBuf },
|
|
DuplicateRemote { name: String },
|
|
DuplicateCredentialReference,
|
|
InvalidRemoteUrl { name: String },
|
|
}
|
|
|
|
impl fmt::Display for ConfigError {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::CurrentDirectory => formatter.write_str("cannot determine the current directory"),
|
|
Self::NativeConfigDirectory => {
|
|
formatter.write_str("cannot determine the native user configuration directory")
|
|
}
|
|
Self::NotFound { path } => {
|
|
write!(
|
|
formatter,
|
|
"configuration file not found: {}",
|
|
path.display()
|
|
)
|
|
}
|
|
Self::Read { path } => {
|
|
write!(
|
|
formatter,
|
|
"cannot read configuration file: {}",
|
|
path.display()
|
|
)
|
|
}
|
|
Self::InvalidFile { path } => write!(
|
|
formatter,
|
|
"configuration path is not a small regular file: {}",
|
|
path.display()
|
|
),
|
|
Self::Malformed { path } => write!(
|
|
formatter,
|
|
"configuration is malformed; its contents were redacted: {}",
|
|
path.display()
|
|
),
|
|
Self::UnknownField { field } => {
|
|
write!(formatter, "unknown configuration field: {field}")
|
|
}
|
|
Self::InsecureField { field } => write!(
|
|
formatter,
|
|
"secret values are forbidden in configuration field: {field}"
|
|
),
|
|
Self::MissingField { field } => {
|
|
write!(formatter, "missing required configuration field: {field}")
|
|
}
|
|
Self::InvalidField { field } => {
|
|
write!(formatter, "invalid configuration field: {field}")
|
|
}
|
|
Self::VaultIsNotDirectory { path } => {
|
|
write!(
|
|
formatter,
|
|
"vault path is not a directory: {}",
|
|
path.display()
|
|
)
|
|
}
|
|
Self::KeyMaterialNotFound { path } => write!(
|
|
formatter,
|
|
"exported key material does not exist: {}",
|
|
path.display()
|
|
),
|
|
Self::InvalidKeyMaterial { path } => write!(
|
|
formatter,
|
|
"exported key material is not a file or directory: {}",
|
|
path.display()
|
|
),
|
|
Self::DuplicateRemote { name } => {
|
|
write!(formatter, "duplicate Git remote name: {name}")
|
|
}
|
|
Self::DuplicateCredentialReference => {
|
|
formatter.write_str("duplicate Git server/application credential reference")
|
|
}
|
|
Self::InvalidRemoteUrl { name } => write!(
|
|
formatter,
|
|
"Git remote {name} must be an HTTPS URL without embedded credentials, query, or fragment"
|
|
),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Error for ConfigError {}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub enum EditorError {
|
|
NonUnicode { variable: &'static str },
|
|
InvalidCommand { source: &'static str },
|
|
}
|
|
|
|
impl fmt::Display for EditorError {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::NonUnicode { variable } => {
|
|
write!(formatter, "{variable} is not valid Unicode")
|
|
}
|
|
Self::InvalidCommand { source } => {
|
|
write!(
|
|
formatter,
|
|
"{source} does not contain a valid editor command"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Error for EditorError {}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct RawConfig {
|
|
vault: Option<PathBuf>,
|
|
default_key: Option<String>,
|
|
key_material: Option<PathBuf>,
|
|
editor: Option<RawEditor>,
|
|
clipboard_timeout_seconds: Option<u64>,
|
|
#[serde(default)]
|
|
security: RawSecurity,
|
|
#[serde(default)]
|
|
git: RawGit,
|
|
}
|
|
|
|
#[derive(Default, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct RawSecurity {
|
|
inactivity_timeout_seconds: Option<u64>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(untagged)]
|
|
enum RawEditor {
|
|
CommandLine(String),
|
|
Arguments(Vec<String>),
|
|
}
|
|
|
|
#[derive(Default, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct RawGit {
|
|
#[serde(default)]
|
|
remotes: Vec<RawGitRemote>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct RawGitRemote {
|
|
name: String,
|
|
url: String,
|
|
server_id: String,
|
|
application_id: String,
|
|
}
|
|
|
|
fn validate_config(source: PathBuf, raw: RawConfig) -> Result<Config, ConfigError> {
|
|
let base = source
|
|
.parent()
|
|
.ok_or(ConfigError::InvalidField { field: "source" })?;
|
|
let vault = resolve_required_path(base, raw.vault, "vault")?;
|
|
if vault.exists() && !vault.is_dir() {
|
|
return Err(ConfigError::VaultIsNotDirectory { path: vault });
|
|
}
|
|
|
|
let key_material = resolve_required_path(base, raw.key_material, "key_material")?;
|
|
if !key_material.exists() {
|
|
return Err(ConfigError::KeyMaterialNotFound { path: key_material });
|
|
}
|
|
let key_metadata =
|
|
fs::metadata(&key_material).map_err(|_| ConfigError::KeyMaterialNotFound {
|
|
path: key_material.clone(),
|
|
})?;
|
|
if !key_metadata.is_file() && !key_metadata.is_dir() {
|
|
return Err(ConfigError::InvalidKeyMaterial { path: key_material });
|
|
}
|
|
let key_material = fs::canonicalize(&key_material)
|
|
.map_err(|_| ConfigError::InvalidKeyMaterial { path: key_material })?;
|
|
|
|
let default_key = raw
|
|
.default_key
|
|
.ok_or(ConfigError::MissingField {
|
|
field: "default_key",
|
|
})
|
|
.and_then(validate_key_identity)?;
|
|
let editor = raw.editor.map(validate_editor).transpose()?;
|
|
let clipboard_timeout = ClipboardTimeout::new(Duration::from_secs(
|
|
raw.clipboard_timeout_seconds
|
|
.unwrap_or(DEFAULT_CLIPBOARD_TIMEOUT.as_secs()),
|
|
))
|
|
.map_err(|_| ConfigError::InvalidField {
|
|
field: "clipboard_timeout_seconds",
|
|
})?;
|
|
let authentication_timeout = AuthenticationTimeout::new(Duration::from_secs(
|
|
raw.security
|
|
.inactivity_timeout_seconds
|
|
.unwrap_or(DEFAULT_AUTHENTICATION_TIMEOUT.as_secs()),
|
|
))
|
|
.map_err(|_| ConfigError::InvalidField {
|
|
field: "security.inactivity_timeout_seconds",
|
|
})?;
|
|
let git_remotes = validate_remotes(raw.git.remotes)?;
|
|
|
|
Ok(Config {
|
|
source,
|
|
vault,
|
|
default_key,
|
|
key_material,
|
|
editor,
|
|
clipboard_timeout,
|
|
authentication_timeout,
|
|
git_remotes,
|
|
})
|
|
}
|
|
|
|
fn resolve_required_path(
|
|
base: &Path,
|
|
value: Option<PathBuf>,
|
|
field: &'static str,
|
|
) -> Result<PathBuf, ConfigError> {
|
|
let value = value.ok_or(ConfigError::MissingField { field })?;
|
|
if value.as_os_str().is_empty() {
|
|
return Err(ConfigError::InvalidField { field });
|
|
}
|
|
Ok(resolve_path(base, &value))
|
|
}
|
|
|
|
fn validate_key_identity(value: String) -> Result<KeyIdentity, ConfigError> {
|
|
let trimmed = value.trim();
|
|
if trimmed.is_empty()
|
|
|| trimmed.len() > 512
|
|
|| trimmed.chars().any(char::is_control)
|
|
|| trimmed != value
|
|
{
|
|
return Err(ConfigError::InvalidField {
|
|
field: "default_key",
|
|
});
|
|
}
|
|
Ok(KeyIdentity(value))
|
|
}
|
|
|
|
fn validate_editor(raw: RawEditor) -> Result<EditorCommand, ConfigError> {
|
|
let words = match raw {
|
|
RawEditor::CommandLine(command) => {
|
|
shlex::split(&command).ok_or(ConfigError::InvalidField { field: "editor" })?
|
|
}
|
|
RawEditor::Arguments(arguments) => arguments,
|
|
};
|
|
editor_from_words(words).map_err(|_| ConfigError::InvalidField { field: "editor" })
|
|
}
|
|
|
|
fn editor_from_words(mut words: Vec<String>) -> Result<EditorCommand, ()> {
|
|
if words.is_empty() || words.iter().any(|word| word.contains('\0')) {
|
|
return Err(());
|
|
}
|
|
let program = words.remove(0);
|
|
if program.is_empty() {
|
|
return Err(());
|
|
}
|
|
Ok(EditorCommand {
|
|
program,
|
|
arguments: words,
|
|
})
|
|
}
|
|
|
|
fn parse_environment_editor(
|
|
variable: &'static str,
|
|
value: Option<&std::ffi::OsStr>,
|
|
) -> Result<Option<EditorCommand>, EditorError> {
|
|
let Some(value) = value else {
|
|
return Ok(None);
|
|
};
|
|
if value.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
let value = value.to_str().ok_or(EditorError::NonUnicode { variable })?;
|
|
let words = shlex::split(value).ok_or(EditorError::InvalidCommand { source: variable })?;
|
|
editor_from_words(words)
|
|
.map(Some)
|
|
.map_err(|()| EditorError::InvalidCommand { source: variable })
|
|
}
|
|
|
|
fn validate_remotes(raw: Vec<RawGitRemote>) -> Result<Vec<GitRemote>, ConfigError> {
|
|
let mut names = BTreeSet::new();
|
|
let mut references = BTreeSet::new();
|
|
let mut remotes = Vec::with_capacity(raw.len());
|
|
for remote in raw {
|
|
let name = RemoteName(validate_identifier("git.remotes.name", remote.name)?);
|
|
let server_id = ServerId(validate_identifier(
|
|
"git.remotes.server_id",
|
|
remote.server_id,
|
|
)?);
|
|
let application_id = ApplicationId(validate_identifier(
|
|
"git.remotes.application_id",
|
|
remote.application_id,
|
|
)?);
|
|
if !names.insert(name.clone()) {
|
|
return Err(ConfigError::DuplicateRemote {
|
|
name: name.0.clone(),
|
|
});
|
|
}
|
|
if !references.insert((server_id.clone(), application_id.clone())) {
|
|
return Err(ConfigError::DuplicateCredentialReference);
|
|
}
|
|
let url = Url::parse(&remote.url).map_err(|_| ConfigError::InvalidRemoteUrl {
|
|
name: name.0.clone(),
|
|
})?;
|
|
if url.scheme() != "https"
|
|
|| url.host_str().is_none()
|
|
|| !url.username().is_empty()
|
|
|| url.password().is_some()
|
|
|| url.query().is_some()
|
|
|| url.fragment().is_some()
|
|
{
|
|
return Err(ConfigError::InvalidRemoteUrl {
|
|
name: name.0.clone(),
|
|
});
|
|
}
|
|
remotes.push(GitRemote {
|
|
name,
|
|
url,
|
|
server_id,
|
|
application_id,
|
|
});
|
|
}
|
|
Ok(remotes)
|
|
}
|
|
|
|
fn validate_identifier(field: &'static str, value: String) -> Result<String, ConfigError> {
|
|
if value.is_empty()
|
|
|| value.len() > 128
|
|
|| !value
|
|
.bytes()
|
|
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
|
|
{
|
|
return Err(ConfigError::InvalidField { field });
|
|
}
|
|
Ok(value)
|
|
}
|
|
|
|
fn validate_known_fields(value: &toml::Value, source: &Path) -> Result<(), ConfigError> {
|
|
let root = value.as_table().ok_or_else(|| ConfigError::UnknownField {
|
|
field: "<root>".to_owned(),
|
|
})?;
|
|
validate_table(
|
|
root,
|
|
"",
|
|
&[
|
|
"vault",
|
|
"default_key",
|
|
"key_material",
|
|
"editor",
|
|
"clipboard_timeout_seconds",
|
|
"security",
|
|
"git",
|
|
],
|
|
)?;
|
|
if let Some(security) = root.get("security") {
|
|
let security = security.as_table().ok_or_else(|| ConfigError::Malformed {
|
|
path: source.to_owned(),
|
|
})?;
|
|
validate_table(security, "security", &["inactivity_timeout_seconds"])?;
|
|
}
|
|
let Some(git) = root.get("git") else {
|
|
return Ok(());
|
|
};
|
|
let git = git.as_table().ok_or_else(|| ConfigError::Malformed {
|
|
path: source.to_owned(),
|
|
})?;
|
|
validate_table(git, "git", &["remotes"])?;
|
|
if let Some(remotes) = git.get("remotes") {
|
|
let remotes = remotes.as_array().ok_or_else(|| ConfigError::Malformed {
|
|
path: source.to_owned(),
|
|
})?;
|
|
for (index, remote) in remotes.iter().enumerate() {
|
|
let remote = remote.as_table().ok_or_else(|| ConfigError::Malformed {
|
|
path: source.to_owned(),
|
|
})?;
|
|
validate_table(
|
|
remote,
|
|
&format!("git.remotes[{index}]"),
|
|
&["name", "url", "server_id", "application_id"],
|
|
)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_table(table: &toml::Table, prefix: &str, allowed: &[&str]) -> Result<(), ConfigError> {
|
|
for key in table.keys() {
|
|
if !allowed.contains(&key.as_str()) {
|
|
let field = if prefix.is_empty() {
|
|
key.clone()
|
|
} else {
|
|
format!("{prefix}.{key}")
|
|
};
|
|
return Err(ConfigError::UnknownField { field });
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn reject_insecure_fields(value: &toml::Value, prefix: &str) -> Result<(), ConfigError> {
|
|
match value {
|
|
toml::Value::Table(table) => {
|
|
for (key, nested) in table {
|
|
let path = if prefix.is_empty() {
|
|
key.clone()
|
|
} else {
|
|
format!("{prefix}.{key}")
|
|
};
|
|
let normalized = key.to_ascii_lowercase().replace('-', "_");
|
|
if [
|
|
"password",
|
|
"passphrase",
|
|
"token",
|
|
"secret",
|
|
"credential",
|
|
"credentials",
|
|
"private_key",
|
|
]
|
|
.contains(&normalized.as_str())
|
|
{
|
|
return Err(ConfigError::InsecureField { field: path });
|
|
}
|
|
reject_insecure_fields(nested, &path)?;
|
|
}
|
|
}
|
|
toml::Value::Array(values) => {
|
|
for (index, nested) in values.iter().enumerate() {
|
|
reject_insecure_fields(nested, &format!("{prefix}[{index}]"))?;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn resolve_path(base: &Path, path: &Path) -> PathBuf {
|
|
if path.is_absolute() {
|
|
normalize_absolute(path.to_owned())
|
|
} else {
|
|
normalize_absolute(base.join(path))
|
|
}
|
|
}
|
|
|
|
fn normalize_absolute(path: PathBuf) -> PathBuf {
|
|
let mut normalized = PathBuf::new();
|
|
for component in path.components() {
|
|
match component {
|
|
Component::CurDir => {}
|
|
Component::ParentDir => {
|
|
if !normalized.pop() {
|
|
normalized.push(component.as_os_str());
|
|
}
|
|
}
|
|
_ => normalized.push(component.as_os_str()),
|
|
}
|
|
}
|
|
normalized
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
fn native_config_directory() -> Option<PathBuf> {
|
|
env::var_os("APPDATA").map(PathBuf::from)
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn native_config_directory() -> Option<PathBuf> {
|
|
env::var_os("HOME")
|
|
.map(PathBuf::from)
|
|
.map(|home| home.join("Library/Application Support"))
|
|
}
|
|
|
|
#[cfg(all(unix, not(target_os = "macos")))]
|
|
fn native_config_directory() -> Option<PathBuf> {
|
|
match env::var_os("XDG_CONFIG_HOME") {
|
|
Some(path) if !path.is_empty() && Path::new(&path).is_absolute() => {
|
|
Some(PathBuf::from(path))
|
|
}
|
|
_ => env::var_os("HOME")
|
|
.filter(|home| !home.is_empty())
|
|
.map(PathBuf::from)
|
|
.map(|home| home.join(".config")),
|
|
}
|
|
}
|
|
|
|
#[cfg(not(any(unix, target_os = "windows")))]
|
|
fn native_config_directory() -> Option<PathBuf> {
|
|
None
|
|
}
|