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

278
Cargo.lock generated
View File

@@ -800,7 +800,7 @@ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver", "jobserver",
"libc", "libc",
"shlex", "shlex 2.0.1",
] ]
[[package]] [[package]]
@@ -1458,6 +1458,17 @@ dependencies = [
"objc2 0.6.4", "objc2 0.6.4",
] ]
[[package]]
name = "displaydoc"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]] [[package]]
name = "dlib" name = "dlib"
version = "0.5.3" version = "0.5.3"
@@ -1846,6 +1857,15 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]] [[package]]
name = "fs-err" name = "fs-err"
version = "3.3.1" version = "3.3.1"
@@ -2436,6 +2456,88 @@ dependencies = [
"winit", "winit",
] ]
[[package]]
name = "icu_collections"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
dependencies = [
"displaydoc",
"potential_utf",
"utf8_iter",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
name = "icu_locale_core"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
dependencies = [
"displaydoc",
"litemap",
"tinystr",
"writeable",
"zerovec",
]
[[package]]
name = "icu_normalizer"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
dependencies = [
"icu_collections",
"icu_normalizer_data",
"icu_properties",
"icu_provider",
"smallvec",
"zerovec",
]
[[package]]
name = "icu_normalizer_data"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
[[package]]
name = "icu_properties"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
dependencies = [
"icu_collections",
"icu_locale_core",
"icu_properties_data",
"icu_provider",
"zerotrie",
"zerovec",
]
[[package]]
name = "icu_properties_data"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
[[package]]
name = "icu_provider"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
dependencies = [
"displaydoc",
"icu_locale_core",
"writeable",
"yoke",
"zerofrom",
"zerotrie",
"zerovec",
]
[[package]] [[package]]
name = "idea" name = "idea"
version = "0.5.1" version = "0.5.1"
@@ -2451,6 +2553,27 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "idna"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [
"idna_adapter",
"smallvec",
"utf8_iter",
]
[[package]]
name = "idna_adapter"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
dependencies = [
"icu_normalizer",
"icu_properties",
]
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.14.0" version = "2.14.0"
@@ -2498,6 +2621,7 @@ dependencies = [
name = "ironstorage" name = "ironstorage"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"clap",
"flate2", "flate2",
"hex", "hex",
"pgp", "pgp",
@@ -2505,9 +2629,11 @@ dependencies = [
"serde", "serde",
"sha1", "sha1",
"sha2", "sha2",
"shlex 1.3.0",
"smallvec", "smallvec",
"tempfile", "tempfile",
"toml 0.9.12+spec-1.1.0", "toml 0.9.12+spec-1.1.0",
"url",
] ]
[[package]] [[package]]
@@ -2522,8 +2648,8 @@ dependencies = [
name = "ironstorage-cli" name = "ironstorage-cli"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"clap",
"ironstorage", "ironstorage",
"tempfile",
] ]
[[package]] [[package]]
@@ -2789,6 +2915,12 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]] [[package]]
name = "litrs" name = "litrs"
version = "1.0.0" version = "1.0.0"
@@ -3973,6 +4105,15 @@ dependencies = [
"portable-atomic", "portable-atomic",
] ]
[[package]]
name = "potential_utf"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
dependencies = [
"zerovec",
]
[[package]] [[package]]
name = "powerfmt" name = "powerfmt"
version = "0.2.0" version = "0.2.0"
@@ -4617,6 +4758,12 @@ dependencies = [
"keccak", "keccak",
] ]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "2.0.1" version = "2.0.1"
@@ -4888,6 +5035,12 @@ dependencies = [
"der", "der",
] ]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]] [[package]]
name = "static_assertions" name = "static_assertions"
version = "1.1.0" version = "1.1.0"
@@ -4983,6 +5136,17 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "sys-locale" name = "sys-locale"
version = "0.3.2" version = "0.3.2"
@@ -5204,6 +5368,16 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "tinystr"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
dependencies = [
"displaydoc",
"zerovec",
]
[[package]] [[package]]
name = "tinyvec" name = "tinyvec"
version = "1.12.0" version = "1.12.0"
@@ -5549,6 +5723,23 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "url"
version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
]
[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]] [[package]]
name = "utf8parse" name = "utf8parse"
version = "0.2.2" version = "0.2.2"
@@ -6447,6 +6638,12 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "writeable"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]] [[package]]
name = "wyz" name = "wyz"
version = "0.5.1" version = "0.5.1"
@@ -6537,6 +6734,29 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5"
[[package]]
name = "yoke"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
dependencies = [
"stable_deref_trait",
"yoke-derive",
"zerofrom",
]
[[package]]
name = "yoke-derive"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure",
]
[[package]] [[package]]
name = "zbus" name = "zbus"
version = "5.18.0" version = "5.18.0"
@@ -6624,6 +6844,27 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "zerofrom"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure",
]
[[package]] [[package]]
name = "zeroize" name = "zeroize"
version = "1.9.0" version = "1.9.0"
@@ -6644,6 +6885,39 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "zerotrie"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
dependencies = [
"displaydoc",
"yoke",
"zerofrom",
]
[[package]]
name = "zerovec"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
dependencies = [
"yoke",
"zerofrom",
"zerovec-derive",
]
[[package]]
name = "zerovec-derive"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "zlib-rs" name = "zlib-rs"
version = "0.6.7" version = "0.6.7"

View File

@@ -19,4 +19,8 @@ crossterm = "0.29"
iced = "0.14" iced = "0.14"
ironstorage = { path = "crates/storage" } ironstorage = { path = "crates/storage" }
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color"] } ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color"] }
serde = { version = "1", features = ["derive"] }
shlex = "1.3"
toml = "0.9"
uniffi = "0.32" uniffi = "0.32"
url = { version = "2.5", default-features = false }

View File

@@ -24,6 +24,9 @@ and `gpg`—are forbidden; compatibility is implemented in Rust.
Candidate libraries and the pending project-license decision are tracked in Candidate libraries and the pending project-license decision are tracked in
[`DEPENDENCIES.md`](DEPENDENCIES.md). [`DEPENDENCIES.md`](DEPENDENCIES.md).
The shared TOML schema, path rules, editor precedence, and HTTPS remote format
are documented in [`docs/configuration.md`](docs/configuration.md).
## Project layout ## Project layout
```text ```text

View File

@@ -11,5 +11,7 @@ name = "ironstorage"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
clap.workspace = true
ironstorage.workspace = true ironstorage.workspace = true
[dev-dependencies]
tempfile = "3"

View File

@@ -1,13 +1,173 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#![deny(clippy::disallowed_types)] #![deny(clippy::disallowed_types)]
use clap::Parser; use std::{ffi::OsString, io::Write, process::ExitCode};
#[derive(Parser)] use ironstorage::{
#[command(version, about = "A pass-compatible password-store client")] command::{
struct Arguments {} CliAction, CommandRequest, EXIT_CONFIG, EXIT_SUCCESS, EXIT_UNAVAILABLE, HelpTopic,
OtpRequest, help_text, otp_version_text, parse_from, version_text,
},
config::Config,
};
fn main() { fn main() -> ExitCode {
Arguments::parse(); match run() {
println!("{}", ironstorage::PRODUCT_NAME); Ok(code) => ExitCode::from(code),
Err(()) => ExitCode::from(ironstorage::command::EXIT_FAILURE),
}
}
fn run() -> Result<u8, ()> {
let stdout = std::io::stdout();
let stderr = std::io::stderr();
run_with(std::env::args_os(), stdout.lock(), stderr.lock())
}
fn run_with<I, T, O, E>(arguments: I, mut stdout: O, mut stderr: E) -> Result<u8, ()>
where
I: IntoIterator<Item = T>,
T: Into<OsString> + Clone,
O: Write,
E: Write,
{
let action = match parse_from(arguments) {
Ok(action) => action,
Err(error) => {
stderr
.write_all(error.to_string().as_bytes())
.map_err(|_| ())?;
return Ok(error.exit_code());
}
};
match action {
CliAction::Display(text) => {
stdout.write_all(text.as_bytes()).map_err(|_| ())?;
Ok(EXIT_SUCCESS)
}
CliAction::Run(invocation) => match invocation.request() {
CommandRequest::Help { topic } => {
stdout
.write_all(help_text(*topic).as_bytes())
.map_err(|_| ())?;
Ok(EXIT_SUCCESS)
}
CommandRequest::Version => {
stdout
.write_all(version_text().as_bytes())
.map_err(|_| ())?;
Ok(EXIT_SUCCESS)
}
CommandRequest::Otp(OtpRequest::Help) => {
stdout
.write_all(help_text(Some(HelpTopic::Otp)).as_bytes())
.map_err(|_| ())?;
Ok(EXIT_SUCCESS)
}
CommandRequest::Otp(OtpRequest::Version) => {
stdout
.write_all(otp_version_text().as_bytes())
.map_err(|_| ())?;
Ok(EXIT_SUCCESS)
}
_ => match Config::load(invocation.config()) {
Ok(_) => {
stderr
.write_all(
b"the command contract is valid, but this storage operation is not available yet\n",
)
.map_err(|_| ())?;
Ok(EXIT_UNAVAILABLE)
}
Err(error) => {
writeln!(stderr, "{error}").map_err(|_| ())?;
Ok(EXIT_CONFIG)
}
},
},
}
}
#[cfg(test)]
mod tests {
use std::{error::Error, ffi::OsString, fs};
use ironstorage::command::{EXIT_CONFIG, EXIT_SUCCESS, EXIT_UNAVAILABLE, EXIT_USAGE};
use super::run_with;
type TestResult = Result<(), Box<dyn Error>>;
#[test]
fn help_and_usage_errors_have_stable_streams_and_exit_codes() -> TestResult {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let code = run_with(["ironstorage", "--help"], &mut stdout, &mut stderr)
.expect("writing to memory cannot fail");
assert_eq!(code, EXIT_SUCCESS);
assert!(String::from_utf8(stdout)?.contains("Usage"));
assert!(stderr.is_empty());
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let code = run_with(
["ironstorage", "insert", "--echo", "--multiline", "entry"],
&mut stdout,
&mut stderr,
)
.expect("writing to memory cannot fail");
assert_eq!(code, EXIT_USAGE);
assert!(stdout.is_empty());
assert!(String::from_utf8(stderr)?.contains("cannot be used with"));
Ok(())
}
#[test]
fn configuration_and_unavailable_operations_have_stable_exit_codes() -> TestResult {
let temporary = tempfile::tempdir()?;
let missing = temporary.path().join("missing.toml");
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let code = run_with(
[
OsString::from("ironstorage"),
OsString::from("--config"),
missing.into_os_string(),
OsString::from("show"),
OsString::from("entry"),
],
&mut stdout,
&mut stderr,
)
.expect("writing to memory cannot fail");
assert_eq!(code, EXIT_CONFIG);
assert!(stdout.is_empty());
assert!(String::from_utf8(stderr)?.contains("configuration file not found"));
let keys = temporary.path().join("keys");
fs::create_dir(&keys)?;
let config = temporary.path().join("config.toml");
fs::write(
&config,
"vault = 'vault'\ndefault_key = 'alice'\nkey_material = 'keys'\n",
)?;
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let code = run_with(
[
OsString::from("ironstorage"),
OsString::from("--config"),
config.into_os_string(),
OsString::from("show"),
OsString::from("entry"),
],
&mut stdout,
&mut stderr,
)
.expect("writing to memory cannot fail");
assert_eq!(code, EXIT_UNAVAILABLE);
assert!(stdout.is_empty());
assert!(String::from_utf8(stderr)?.contains("not available yet"));
Ok(())
}
} }

View File

@@ -6,14 +6,19 @@ edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
publish = false publish = false
[dependencies]
clap.workspace = true
serde.workspace = true
shlex.workspace = true
toml.workspace = true
url.workspace = true
[dev-dependencies] [dev-dependencies]
flate2 = "1.1" flate2 = "1.1"
hex = "0.4" hex = "0.4"
pgp = { version = "0.20", default-features = false } pgp = { version = "0.20", default-features = false }
rand_chacha = "0.3" rand_chacha = "0.3"
serde = { version = "1", features = ["derive"] }
sha1 = "0.10" sha1 = "0.10"
sha2 = "0.10" sha2 = "0.10"
smallvec = "1.15" smallvec = "1.15"
tempfile = "3" 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. //! 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. /// Product name shared by the presentation adapters.
pub const PRODUCT_NAME: &str = "IronStorage"; 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(())
}

53
docs/configuration.md Normal file
View File

@@ -0,0 +1,53 @@
# Configuration
IronStorage uses one small TOML file. Pass an explicit file with
`--config PATH`; otherwise the CLI loads `ironstorage/config.toml` below the
native per-user configuration directory:
- Linux and other Unix systems: `$XDG_CONFIG_HOME`, when it is absolute, or
`$HOME/.config`;
- macOS: `$HOME/Library/Application Support`;
- Windows: `%APPDATA%`.
An explicit relative configuration path is resolved from the process current
directory. Relative `vault` and `key_material` paths inside the file are
resolved from the directory containing the resolved configuration file.
Absolute paths remain absolute. `.` and `..` components are normalized
lexically; `~` is not expanded. The vault may be absent before `init`, but an
existing vault must be a directory. Exported key material must already exist
as a regular file or directory.
```toml
vault = "../vault"
default_key = "0123456789ABCDEF0123456789ABCDEF01234567"
key_material = "keys"
# An array avoids shell interpretation. A quoted command string is also
# accepted and split without launching a shell.
editor = ["code", "--wait"]
[[git.remotes]]
name = "origin"
url = "https://git.example.test/alice/password-store.git"
server_id = "personal-git"
application_id = "ironstorage-cli"
```
`default_key` is the required identity that the embedded OpenPGP layer resolves
against the exported key material. It may be a full fingerprint, key ID, or
exact user ID; ambiguity and key capability checks happen in the key resolver.
Editor selection is deterministic: TOML, `$VISUAL`, `$EDITOR`, then `vim`.
Commands are split into an executable and argument vector and are never passed
through a shell. Only the CLI `edit` adapter may eventually launch this
configured editor.
Git remotes are HTTPS-only. URLs containing user information, passwords,
queries, or fragments are rejected. `server_id` and `application_id` are opaque
references used to retrieve credentials from the operating-system secret
store; duplicate names and duplicate reference pairs are errors.
Passwords, passphrases, tokens, credentials, private keys, and other secret
values are forbidden in TOML. Unknown fields are rejected. Parse errors never
echo the source line or value, so an accidentally supplied secret is not
repeated in diagnostics.