Define configuration and CLI contracts (#2)

This commit is contained in:
Hermes Agent
2026-08-09 21:22:31 +00:00
parent 41e56367bd
commit 2dff2d5254
12 changed files with 2927 additions and 12 deletions

View File

@@ -6,14 +6,19 @@ edition.workspace = true
rust-version.workspace = true
publish = false
[dependencies]
clap.workspace = true
serde.workspace = true
shlex.workspace = true
toml.workspace = true
url.workspace = true
[dev-dependencies]
flate2 = "1.1"
hex = "0.4"
pgp = { version = "0.20", default-features = false }
rand_chacha = "0.3"
serde = { version = "1", features = ["derive"] }
sha1 = "0.10"
sha2 = "0.10"
smallvec = "1.15"
tempfile = "3"
toml = "0.9"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,733 @@
//! Strict, secret-free configuration shared by every frontend.
use std::{
collections::BTreeSet,
env,
error::Error,
fmt, fs,
path::{Component, Path, PathBuf},
};
use serde::Deserialize;
use url::Url;
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>,
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 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>,
#[serde(default)]
git: RawGit,
}
#[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 git_remotes = validate_remotes(raw.git.remotes)?;
Ok(Config {
source,
vault,
default_key,
key_material,
editor,
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", "git"],
)?;
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
}

View File

@@ -5,5 +5,8 @@
//!
//! This crate is the sole owner of stored and derived password-store objects.
pub mod command;
pub mod config;
/// Product name shared by the presentation adapters.
pub const PRODUCT_NAME: &str = "IronStorage";

View File

@@ -0,0 +1,328 @@
#![forbid(unsafe_code)]
use std::{error::Error, num::NonZeroUsize, path::Path};
use ironstorage::command::{
CliAction, CommandRequest, EXIT_USAGE, GeneratedPresentation, GitConfigRequest,
GitRemoteRequest, GitRequest, HelpTopic, InputPlan, InsertInput, OtpInputSource, OtpRequest,
OtpUriPresentation, Presentation, help_text, otp_version_text, parse_from, version_text,
};
type TestResult = Result<(), Box<dyn Error>>;
fn request(arguments: &[&str]) -> Result<CommandRequest, Box<dyn Error>> {
let mut complete = vec!["ironstorage"];
complete.extend_from_slice(arguments);
match parse_from(complete)? {
CliAction::Run(invocation) => Ok(invocation.into_parts().1),
CliAction::Display(_) => Err("expected a command request".into()),
}
}
#[test]
fn implicit_show_and_list_aliases_are_canonical_requests() -> TestResult {
assert!(matches!(
request(&[])?,
CommandRequest::Show(ref show)
if show.entry.is_none() && show.presentation == Presentation::Terminal
));
assert!(matches!(
request(&["email/personal"] )?,
CommandRequest::Show(ref show)
if show.entry.as_deref() == Some("email/personal")
));
assert_eq!(request(&["ls", "team"])?, request(&["list", "team"])?);
assert_eq!(
request(&["find", "service"])?,
request(&["search", "service"])?
);
Ok(())
}
#[test]
fn mutation_aliases_map_to_the_same_storage_request() -> TestResult {
assert_eq!(
request(&["insert", "-f", "new/entry"])?,
request(&["add", "--force", "new/entry"])?
);
assert_eq!(
request(&["rm", "-rf", "team/"])?,
request(&["remove", "--recursive", "--force", "team/"])?
);
assert_eq!(request(&["rm", "entry"])?, request(&["delete", "entry"])?);
assert_eq!(
request(&["mv", "old", "new"])?,
request(&["rename", "old", "new"])?
);
assert_eq!(
request(&["cp", "old", "new"])?,
request(&["copy", "old", "new"])?
);
Ok(())
}
#[test]
fn show_presentation_lines_are_typed_and_conflicts_fail() -> TestResult {
assert!(matches!(
request(&["show", "--clip=2", "entry"] )?,
CommandRequest::Show(ref show)
if show.presentation == Presentation::Clipboard {
line: NonZeroUsize::new(2).expect("nonzero")
}
));
assert!(matches!(
request(&["show", "-q3", "entry"] )?,
CommandRequest::Show(ref show)
if show.presentation == Presentation::QrCode {
line: NonZeroUsize::new(3).expect("nonzero")
}
));
for invalid in [
vec!["show", "--clip", "--qrcode", "entry"],
vec!["show", "--clip=0", "entry"],
vec!["show", "--clip=not-a-number", "entry"],
] {
let error = parse_from(std::iter::once("ironstorage").chain(invalid))
.expect_err("invalid presentation");
assert_eq!(error.exit_code(), EXIT_USAGE);
}
Ok(())
}
#[test]
fn insert_modes_conflicts_and_noninteractive_input_are_explicit() -> TestResult {
let hidden = request(&["insert", "entry"])?;
let CommandRequest::Insert(hidden) = hidden else {
return Err("expected insert".into());
};
assert_eq!(hidden.input, InsertInput::HiddenConfirmed);
assert_eq!(hidden.input_plan(true), InputPlan::HiddenConfirmed);
assert_eq!(hidden.input_plan(false), InputPlan::StandardInputLine);
let multiline = request(&["insert", "--multiline", "entry"])?;
let CommandRequest::Insert(multiline) = multiline else {
return Err("expected multiline insert".into());
};
assert_eq!(multiline.input, InsertInput::Multiline);
assert_eq!(multiline.input_plan(false), InputPlan::StandardInputToEnd);
let echo = request(&["insert", "--echo", "entry"])?;
let CommandRequest::Insert(echo) = echo else {
return Err("expected echo insert".into());
};
assert_eq!(echo.input_plan(true), InputPlan::EchoedLine);
assert_eq!(
parse_from(["ironstorage", "insert", "--echo", "--multiline", "entry"])
.expect_err("conflicting insert modes")
.exit_code(),
EXIT_USAGE
);
Ok(())
}
#[test]
fn generate_options_defaults_and_conflicts_are_validated() -> TestResult {
let generated = request(&["generate", "--no-symbols", "--clip", "entry", "32"])?;
assert!(matches!(
generated,
CommandRequest::Generate(ref request)
if request.no_symbols
&& request.length == NonZeroUsize::new(32)
&& request.presentation == GeneratedPresentation::Clipboard
));
for invalid in [
["generate", "--clip", "--qrcode", "entry"].as_slice(),
["generate", "--force", "--in-place", "entry"].as_slice(),
["generate", "entry", "0"].as_slice(),
] {
let error = parse_from(std::iter::once("ironstorage").chain(invalid.iter().copied()))
.expect_err("invalid generate request");
assert_eq!(error.exit_code(), EXIT_USAGE);
}
Ok(())
}
#[test]
fn grep_support_is_explicit_and_unknown_gnu_options_fail() -> TestResult {
assert!(matches!(
request(&["grep", "-ivnF", "fixture"] )?,
CommandRequest::Grep(ref grep)
if grep.ignore_case && grep.invert_match && grep.line_number && grep.fixed_strings
));
let error = parse_from(["ironstorage", "grep", "--binary-files=text", "fixture"])
.expect_err("unsupported grep option");
assert_eq!(error.exit_code(), EXIT_USAGE);
Ok(())
}
#[test]
fn git_workflows_are_typed_and_arbitrary_passthrough_is_rejected() -> TestResult {
assert_eq!(
request(&["git", "init"])?,
CommandRequest::Git(GitRequest::Init)
);
assert_eq!(
request(&["git", "remote"])?,
CommandRequest::Git(GitRequest::Remote(GitRemoteRequest::List))
);
assert_eq!(
request(&[
"git",
"remote",
"add",
"origin",
"https://example.test/store.git",
])?,
CommandRequest::Git(GitRequest::Remote(GitRemoteRequest::Add {
name: "origin".to_owned(),
url: "https://example.test/store.git".to_owned(),
}))
);
assert_eq!(
request(&["git", "config", "--get", "remote.origin.url"])?,
CommandRequest::Git(GitRequest::Config(GitConfigRequest::Get {
key: "remote.origin.url".to_owned()
}))
);
assert_eq!(
request(&[
"git",
"config",
"remote.origin.url",
"https://example.test/store.git",
])?,
CommandRequest::Git(GitRequest::Config(GitConfigRequest::Set {
key: "remote.origin.url".to_owned(),
value: "https://example.test/store.git".to_owned(),
}))
);
for command in ["rebase", "cherry-pick", "credential"] {
assert_eq!(
parse_from(["ironstorage", "git", command])
.expect_err("unsupported Git passthrough")
.exit_code(),
EXIT_USAGE
);
}
Ok(())
}
#[test]
fn otp_default_dispatch_aliases_and_input_contract_are_complete() -> TestResult {
let default = request(&["otp", "otp/totp"])?;
assert_eq!(default, request(&["otp", "code", "otp/totp"])?);
assert_eq!(default, request(&["otp", "show", "otp/totp"])?);
assert_eq!(
request(&["otp", "insert", "otp/new"])?,
request(&["otp", "add", "otp/new"])?
);
let inserted = request(&[
"otp",
"insert",
"--secret",
"--issuer",
"Issuer",
"--account",
"account",
])?;
let CommandRequest::Otp(OtpRequest::Insert(inserted)) = inserted else {
return Err("expected OTP insert".into());
};
assert_eq!(
inserted.source,
OtpInputSource::Secret {
issuer: Some("Issuer".to_owned()),
account: Some("account".to_owned())
}
);
assert_eq!(inserted.input_plan(true), InputPlan::HiddenConfirmed);
assert_eq!(inserted.input_plan(false), InputPlan::StandardInputLine);
assert_eq!(
parse_from(["ironstorage", "otp", "insert", "--secret"])
.expect_err("secret without identity")
.exit_code(),
EXIT_USAGE
);
assert_eq!(
parse_from(["ironstorage", "otp", "insert", "--issuer", "Issuer"])
.expect_err("issuer without secret")
.exit_code(),
EXIT_USAGE
);
Ok(())
}
#[test]
fn otp_uri_presentation_and_conflicts_are_typed() -> TestResult {
assert!(matches!(
request(&["otp", "uri", "--qrcode", "otp/totp"] )?,
CommandRequest::Otp(OtpRequest::Uri(ref uri))
if uri.presentation == OtpUriPresentation::QrCode
));
assert_eq!(
parse_from([
"ironstorage",
"otp",
"uri",
"--clip",
"--qrcode",
"otp/totp",
])
.expect_err("conflicting OTP URI presentation")
.exit_code(),
EXIT_USAGE
);
Ok(())
}
#[test]
fn configuration_option_and_meta_commands_have_stable_contracts() -> TestResult {
let action = parse_from([
"ironstorage",
"--config",
"relative/config.toml",
"show",
"entry",
])?;
let CliAction::Run(invocation) = action else {
return Err("expected invocation".into());
};
assert_eq!(invocation.config(), Some(Path::new("relative/config.toml")));
assert_eq!(
request(&["help", "search"])?,
CommandRequest::Help {
topic: Some(HelpTopic::Find)
}
);
assert_eq!(request(&["version"])?, CommandRequest::Version);
assert!(!help_text(None).is_empty());
assert!(help_text(Some(HelpTopic::Otp)).contains("Usage"));
assert!(version_text().starts_with("IronStorage "));
assert_eq!(otp_version_text(), "1.1.1\n");
for option in ["--help", "--version"] {
assert!(matches!(
parse_from(["ironstorage", option])?,
CliAction::Display(ref text) if !text.is_empty()
));
}
assert!(matches!(
parse_from(["ironstorage", "otp", "--help"] )?,
CliAction::Display(ref text) if text.contains("Usage")
));
assert_eq!(
request(&["otp", "--version"])?,
CommandRequest::Otp(OtpRequest::Version)
);
assert_eq!(
parse_from(["ironstorage", "help", "not-a-command"])
.expect_err("unknown help topic")
.exit_code(),
EXIT_USAGE
);
Ok(())
}

View File

@@ -0,0 +1,343 @@
#![forbid(unsafe_code)]
use std::{error::Error, ffi::OsStr, fs, path::Path};
use ironstorage::config::{ConfigError, ConfigLoader, EditorSource};
use tempfile::TempDir;
type TestResult = Result<(), Box<dyn Error>>;
struct ConfigurationFixture {
temporary: TempDir,
}
impl ConfigurationFixture {
fn new() -> Result<Self, Box<dyn Error>> {
let temporary = tempfile::tempdir()?;
fs::create_dir_all(temporary.path().join("cwd/config/keys"))?;
fs::create_dir_all(temporary.path().join("native"))?;
Ok(Self { temporary })
}
fn loader(&self) -> ConfigLoader {
ConfigLoader::new(
self.temporary.path().join("cwd"),
self.temporary.path().join("native"),
)
}
fn write_explicit(&self, contents: &str) -> Result<(), Box<dyn Error>> {
fs::write(self.explicit_path(), contents)?;
Ok(())
}
fn explicit_path(&self) -> std::path::PathBuf {
self.temporary.path().join("cwd/config/config.toml")
}
fn valid_contents(&self) -> &'static str {
r#"
vault = "../vault"
default_key = "0123456789ABCDEF0123456789ABCDEF01234567"
key_material = "keys"
editor = ["code", "--wait"]
[[git.remotes]]
name = "origin"
url = "https://git.example.test/alice/store.git"
server_id = "personal-git"
application_id = "ironstorage-cli"
"#
}
}
#[test]
fn explicit_relative_configuration_resolves_deterministically() -> TestResult {
let fixture = ConfigurationFixture::new()?;
fixture.write_explicit(fixture.valid_contents())?;
let config = fixture
.loader()
.load(Some(Path::new("config/config.toml")))?;
assert_eq!(config.source(), fixture.explicit_path());
assert_eq!(config.vault(), fixture.temporary.path().join("cwd/vault"));
assert_eq!(
config.key_material(),
fs::canonicalize(fixture.temporary.path().join("cwd/config/keys"))?
);
assert_eq!(
config.default_key().as_str(),
"0123456789ABCDEF0123456789ABCDEF01234567"
);
assert_eq!(config.git_remotes().len(), 1);
let remote = &config.git_remotes()[0];
assert_eq!(remote.name().as_str(), "origin");
assert_eq!(
remote.url().as_str(),
"https://git.example.test/alice/store.git"
);
assert_eq!(remote.server_id().as_str(), "personal-git");
assert_eq!(remote.application_id().as_str(), "ironstorage-cli");
Ok(())
}
#[test]
fn native_default_path_is_used_without_an_explicit_path() -> TestResult {
let fixture = ConfigurationFixture::new()?;
let default = fixture.loader().default_path();
fs::create_dir_all(default.parent().expect("default parent"))?;
fs::create_dir_all(default.parent().expect("default parent").join("keys"))?;
fs::write(&default, fixture.valid_contents())?;
let config = fixture.loader().load(None)?;
assert_eq!(config.source(), default);
Ok(())
}
#[test]
fn editor_precedence_and_argument_splitting_are_storage_owned() -> TestResult {
let fixture = ConfigurationFixture::new()?;
fixture.write_explicit(fixture.valid_contents())?;
let config = fixture
.loader()
.load(Some(Path::new("config/config.toml")))?;
let editor = config.resolve_editor_from(
Some(OsStr::new("visual --ignored")),
Some(OsStr::new("editor --ignored")),
)?;
assert_eq!(editor.source(), EditorSource::Configuration);
assert_eq!(editor.command().program(), "code");
assert_eq!(editor.command().arguments(), ["--wait"]);
fixture.write_explicit(
r#"
vault = "vault"
default_key = "alice@example.test"
key_material = "keys"
"#,
)?;
let config = fixture
.loader()
.load(Some(Path::new("config/config.toml")))?;
let visual = config.resolve_editor_from(
Some(OsStr::new("code --wait 'two words'")),
Some(OsStr::new("nano")),
)?;
assert_eq!(visual.source(), EditorSource::VisualEnvironment);
assert_eq!(visual.command().program(), "code");
assert_eq!(visual.command().arguments(), ["--wait", "two words"]);
let editor = config.resolve_editor_from(None, Some(OsStr::new("nano -w")))?;
assert_eq!(editor.source(), EditorSource::EditorEnvironment);
assert_eq!(editor.command().program(), "nano");
assert_eq!(editor.command().arguments(), ["-w"]);
let fallback = config.resolve_editor_from(None, None)?;
assert_eq!(fallback.source(), EditorSource::Fallback);
assert_eq!(fallback.command().program(), "vim");
assert!(fallback.command().arguments().is_empty());
Ok(())
}
#[test]
fn malformed_unknown_and_insecure_configuration_are_redacted() -> TestResult {
let fixture = ConfigurationFixture::new()?;
fixture.write_explicit("vault = [\"unterminated\"\npassword = \"do-not-repeat\"")?;
let error = fixture
.loader()
.load(Some(Path::new("config/config.toml")))
.expect_err("malformed TOML");
assert!(matches!(error, ConfigError::Malformed { .. }));
assert!(!error.to_string().contains("do-not-repeat"));
fixture.write_explicit(
r#"
vault = "vault"
default_key = "alice"
key_material = "keys"
colour = "blue"
"#,
)?;
let error = fixture
.loader()
.load(Some(Path::new("config/config.toml")))
.expect_err("unknown field");
assert_eq!(
error,
ConfigError::UnknownField {
field: "colour".to_owned()
}
);
fixture.write_explicit(
r#"
vault = "vault"
default_key = "alice"
key_material = "keys"
token = "do-not-repeat"
"#,
)?;
let error = fixture
.loader()
.load(Some(Path::new("config/config.toml")))
.expect_err("secret field");
assert_eq!(
error,
ConfigError::InsecureField {
field: "token".to_owned()
}
);
assert!(!error.to_string().contains("do-not-repeat"));
Ok(())
}
#[test]
fn missing_and_invalid_required_fields_are_typed() -> TestResult {
let fixture = ConfigurationFixture::new()?;
fixture.write_explicit("default_key = \"alice\"\nkey_material = \"keys\"\n")?;
assert_eq!(
fixture
.loader()
.load(Some(Path::new("config/config.toml")))
.expect_err("missing vault"),
ConfigError::MissingField { field: "vault" }
);
fixture.write_explicit("vault = \"vault\"\ndefault_key = \" \"\nkey_material = \"keys\"\n")?;
assert_eq!(
fixture
.loader()
.load(Some(Path::new("config/config.toml")))
.expect_err("empty identity"),
ConfigError::InvalidField {
field: "default_key"
}
);
fixture.write_explicit(
"vault = \"vault\"\ndefault_key = \"alice\"\nkey_material = \"missing\"\n",
)?;
assert!(matches!(
fixture
.loader()
.load(Some(Path::new("config/config.toml")))
.expect_err("missing keys"),
ConfigError::KeyMaterialNotFound { .. }
));
fs::write(
fixture.temporary.path().join("cwd/config/not-a-vault"),
b"file",
)?;
fixture.write_explicit(
"vault = \"not-a-vault\"\ndefault_key = \"alice\"\nkey_material = \"keys\"\n",
)?;
assert!(matches!(
fixture
.loader()
.load(Some(Path::new("config/config.toml")))
.expect_err("vault file"),
ConfigError::VaultIsNotDirectory { .. }
));
Ok(())
}
#[test]
fn git_configuration_rejects_non_https_and_embedded_credentials() -> TestResult {
let fixture = ConfigurationFixture::new()?;
for url in [
"ssh://git@example.test/store.git",
"git://example.test/store.git",
"file:///tmp/store.git",
"../store.git",
"https://user:password@example.test/store.git",
"https://example.test/store.git?token=do-not-store",
"https://example.test/store.git#fragment",
] {
fixture.write_explicit(&format!(
r#"
vault = "vault"
default_key = "alice"
key_material = "keys"
[[git.remotes]]
name = "origin"
url = "{url}"
server_id = "server"
application_id = "application"
"#
))?;
assert_eq!(
fixture
.loader()
.load(Some(Path::new("config/config.toml")))
.expect_err("forbidden remote"),
ConfigError::InvalidRemoteUrl {
name: "origin".to_owned()
},
"URL should be rejected without entering transport: {url}"
);
}
Ok(())
}
#[test]
fn duplicate_remote_names_and_credential_references_are_rejected() -> TestResult {
let fixture = ConfigurationFixture::new()?;
fixture.write_explicit(
r#"
vault = "vault"
default_key = "alice"
key_material = "keys"
[[git.remotes]]
name = "origin"
url = "https://one.example.test/store.git"
server_id = "one"
application_id = "app"
[[git.remotes]]
name = "origin"
url = "https://two.example.test/store.git"
server_id = "two"
application_id = "app"
"#,
)?;
assert_eq!(
fixture
.loader()
.load(Some(Path::new("config/config.toml")))
.expect_err("duplicate name"),
ConfigError::DuplicateRemote {
name: "origin".to_owned()
}
);
fixture.write_explicit(
r#"
vault = "vault"
default_key = "alice"
key_material = "keys"
[[git.remotes]]
name = "one"
url = "https://one.example.test/store.git"
server_id = "server"
application_id = "app"
[[git.remotes]]
name = "two"
url = "https://two.example.test/store.git"
server_id = "server"
application_id = "app"
"#,
)?;
assert_eq!(
fixture
.loader()
.load(Some(Path::new("config/config.toml")))
.expect_err("duplicate credential reference"),
ConfigError::DuplicateCredentialReference
);
Ok(())
}