diff --git a/Cargo.lock b/Cargo.lock index 1afceee..67586bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -800,7 +800,7 @@ dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 2.0.1", ] [[package]] @@ -1458,6 +1458,17 @@ dependencies = [ "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]] name = "dlib" version = "0.5.3" @@ -1846,6 +1857,15 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "fs-err" version = "3.3.1" @@ -2436,6 +2456,88 @@ dependencies = [ "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]] name = "idea" version = "0.5.1" @@ -2451,6 +2553,27 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "indexmap" version = "2.14.0" @@ -2498,6 +2621,7 @@ dependencies = [ name = "ironstorage" version = "0.1.0" dependencies = [ + "clap", "flate2", "hex", "pgp", @@ -2505,9 +2629,11 @@ dependencies = [ "serde", "sha1", "sha2", + "shlex 1.3.0", "smallvec", "tempfile", "toml 0.9.12+spec-1.1.0", + "url", ] [[package]] @@ -2522,8 +2648,8 @@ dependencies = [ name = "ironstorage-cli" version = "0.1.0" dependencies = [ - "clap", "ironstorage", + "tempfile", ] [[package]] @@ -2789,6 +2915,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "litrs" version = "1.0.0" @@ -3973,6 +4105,15 @@ dependencies = [ "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]] name = "powerfmt" version = "0.2.0" @@ -4617,6 +4758,12 @@ dependencies = [ "keccak", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "shlex" version = "2.0.1" @@ -4888,6 +5035,12 @@ dependencies = [ "der", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" @@ -4983,6 +5136,17 @@ dependencies = [ "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]] name = "sys-locale" version = "0.3.2" @@ -5204,6 +5368,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinyvec" version = "1.12.0" @@ -5549,6 +5723,23 @@ dependencies = [ "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]] name = "utf8parse" version = "0.2.2" @@ -6447,6 +6638,12 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + [[package]] name = "wyz" version = "0.5.1" @@ -6537,6 +6734,29 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "zbus" version = "5.18.0" @@ -6624,6 +6844,27 @@ dependencies = [ "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]] name = "zeroize" version = "1.9.0" @@ -6644,6 +6885,39 @@ dependencies = [ "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]] name = "zlib-rs" version = "0.6.7" diff --git a/Cargo.toml b/Cargo.toml index 4e42f1b..50147c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,4 +19,8 @@ crossterm = "0.29" iced = "0.14" ironstorage = { path = "crates/storage" } 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" +url = { version = "2.5", default-features = false } diff --git a/README.md b/README.md index 5706cff..2bfc67f 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,9 @@ and `gpg`—are forbidden; compatibility is implemented in Rust. Candidate libraries and the pending project-license decision are tracked in [`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 ```text diff --git a/apps/cli/Cargo.toml b/apps/cli/Cargo.toml index eddcc47..8af4c86 100644 --- a/apps/cli/Cargo.toml +++ b/apps/cli/Cargo.toml @@ -11,5 +11,7 @@ name = "ironstorage" path = "src/main.rs" [dependencies] -clap.workspace = true ironstorage.workspace = true + +[dev-dependencies] +tempfile = "3" diff --git a/apps/cli/src/main.rs b/apps/cli/src/main.rs index 354da50..7f9e2a6 100644 --- a/apps/cli/src/main.rs +++ b/apps/cli/src/main.rs @@ -1,13 +1,173 @@ #![forbid(unsafe_code)] #![deny(clippy::disallowed_types)] -use clap::Parser; +use std::{ffi::OsString, io::Write, process::ExitCode}; -#[derive(Parser)] -#[command(version, about = "A pass-compatible password-store client")] -struct Arguments {} +use ironstorage::{ + command::{ + CliAction, CommandRequest, EXIT_CONFIG, EXIT_SUCCESS, EXIT_UNAVAILABLE, HelpTopic, + OtpRequest, help_text, otp_version_text, parse_from, version_text, + }, + config::Config, +}; -fn main() { - Arguments::parse(); - println!("{}", ironstorage::PRODUCT_NAME); +fn main() -> ExitCode { + match run() { + Ok(code) => ExitCode::from(code), + Err(()) => ExitCode::from(ironstorage::command::EXIT_FAILURE), + } +} + +fn run() -> Result { + let stdout = std::io::stdout(); + let stderr = std::io::stderr(); + run_with(std::env::args_os(), stdout.lock(), stderr.lock()) +} + +fn run_with(arguments: I, mut stdout: O, mut stderr: E) -> Result +where + I: IntoIterator, + T: Into + 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>; + + #[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(()) + } } diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index a3ea47e..16319f3 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -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" diff --git a/crates/storage/src/command.rs b/crates/storage/src/command.rs new file mode 100644 index 0000000..122b12b --- /dev/null +++ b/crates/storage/src/command.rs @@ -0,0 +1,1007 @@ +//! Complete pass-compatible command request contract. + +use std::{error::Error, ffi::OsString, fmt, num::NonZeroUsize, path::PathBuf}; + +use clap::{Args, CommandFactory, Parser, Subcommand, error::ErrorKind}; + +use crate::PRODUCT_NAME; + +pub const EXIT_SUCCESS: u8 = 0; +pub const EXIT_FAILURE: u8 = 1; +pub const EXIT_USAGE: u8 = 2; +pub const EXIT_UNAVAILABLE: u8 = 69; +pub const EXIT_CONFIG: u8 = 78; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CliAction { + Run(CliInvocation), + Display(String), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CliInvocation { + config: Option, + request: CommandRequest, +} + +impl CliInvocation { + pub fn config(&self) -> Option<&std::path::Path> { + self.config.as_deref() + } + + pub fn request(&self) -> &CommandRequest { + &self.request + } + + pub fn into_parts(self) -> (Option, CommandRequest) { + (self.config, self.request) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CommandRequest { + Init(InitRequest), + List(ListRequest), + Show(ShowRequest), + Find(FindRequest), + Grep(GrepRequest), + Insert(InsertRequest), + Edit(EditRequest), + Generate(GenerateRequest), + Remove(RemoveRequest), + Move(MoveRequest), + Copy(CopyRequest), + Git(GitRequest), + Otp(OtpRequest), + Help { topic: Option }, + Version, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InitRequest { + pub path: Option, + pub key_identities: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ListRequest { + pub path: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ShowRequest { + pub entry: Option, + pub presentation: Presentation, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Presentation { + Terminal, + Clipboard { line: NonZeroUsize }, + QrCode { line: NonZeroUsize }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FindRequest { + pub terms: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GrepRequest { + pub pattern: String, + pub ignore_case: bool, + pub invert_match: bool, + pub line_number: bool, + pub fixed_strings: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InsertRequest { + pub entry: String, + pub input: InsertInput, + pub force: bool, +} + +impl InsertRequest { + pub fn input_plan(&self, standard_input_is_terminal: bool) -> InputPlan { + if !standard_input_is_terminal { + return if self.input == InsertInput::Multiline { + InputPlan::StandardInputToEnd + } else { + InputPlan::StandardInputLine + }; + } + match self.input { + InsertInput::HiddenConfirmed => InputPlan::HiddenConfirmed, + InsertInput::EchoedLine => InputPlan::EchoedLine, + InsertInput::Multiline => InputPlan::StandardInputToEnd, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum InsertInput { + HiddenConfirmed, + EchoedLine, + Multiline, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum InputPlan { + HiddenConfirmed, + EchoedLine, + StandardInputLine, + StandardInputToEnd, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EditRequest { + pub entry: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GenerateRequest { + pub entry: String, + pub length: Option, + pub no_symbols: bool, + pub force: bool, + pub in_place: bool, + pub presentation: GeneratedPresentation, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GeneratedPresentation { + Terminal, + Clipboard, + QrCode, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RemoveRequest { + pub entry: String, + pub recursive: bool, + pub force: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MoveRequest { + pub source: String, + pub destination: String, + pub force: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CopyRequest { + pub source: String, + pub destination: String, + pub force: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum GitRequest { + Init, + Status, + Log { + maximum: Option, + }, + Diff { + paths: Vec, + }, + Add { + paths: Vec, + }, + Commit { + message: String, + }, + Remote(GitRemoteRequest), + Config(GitConfigRequest), + Fetch { + remote: Option, + }, + Pull { + remote: Option, + branch: Option, + }, + Push { + remote: Option, + branch: Option, + }, + Sync { + remote: Option, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum GitRemoteRequest { + List, + GetUrl { name: String }, + Add { name: String, url: String }, + SetUrl { name: String, url: String }, + Remove { name: String }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum GitConfigRequest { + Get { key: String }, + Set { key: String, value: String }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum OtpRequest { + Code(OtpCodeRequest), + Insert(OtpInsertRequest), + Append(OtpAppendRequest), + Uri(OtpUriRequest), + Validate { uri: String }, + Help, + Version, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OtpCodeRequest { + pub entry: String, + pub clipboard: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OtpInsertRequest { + pub entry: Option, + pub force: bool, + pub echo: bool, + pub source: OtpInputSource, +} + +impl OtpInsertRequest { + pub fn input_plan(&self, standard_input_is_terminal: bool) -> InputPlan { + line_input_plan(self.echo, standard_input_is_terminal) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OtpAppendRequest { + pub entry: String, + pub force: bool, + pub echo: bool, + pub source: OtpInputSource, +} + +impl OtpAppendRequest { + pub fn input_plan(&self, standard_input_is_terminal: bool) -> InputPlan { + line_input_plan(self.echo, standard_input_is_terminal) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum OtpInputSource { + Uri, + Secret { + issuer: Option, + account: Option, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OtpUriRequest { + pub entry: String, + pub presentation: OtpUriPresentation, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OtpUriPresentation { + Terminal, + Clipboard, + QrCode, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum HelpTopic { + Init, + List, + Show, + Find, + Grep, + Insert, + Edit, + Generate, + Remove, + Move, + Copy, + Git, + Otp, +} + +impl HelpTopic { + fn parse(value: &str) -> Option { + match value { + "init" => Some(Self::Init), + "ls" | "list" => Some(Self::List), + "show" => Some(Self::Show), + "find" | "search" => Some(Self::Find), + "grep" => Some(Self::Grep), + "insert" | "add" => Some(Self::Insert), + "edit" => Some(Self::Edit), + "generate" => Some(Self::Generate), + "rm" | "remove" | "delete" => Some(Self::Remove), + "mv" | "rename" => Some(Self::Move), + "cp" | "copy" => Some(Self::Copy), + "git" => Some(Self::Git), + "otp" => Some(Self::Otp), + _ => None, + } + } + + fn command_name(self) -> &'static str { + match self { + Self::Init => "init", + Self::List => "ls", + Self::Show => "show", + Self::Find => "find", + Self::Grep => "grep", + Self::Insert => "insert", + Self::Edit => "edit", + Self::Generate => "generate", + Self::Remove => "rm", + Self::Move => "mv", + Self::Copy => "cp", + Self::Git => "git", + Self::Otp => "otp", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CliParseError { + message: String, + exit_code: u8, +} + +impl CliParseError { + pub fn exit_code(&self) -> u8 { + self.exit_code + } +} + +impl fmt::Display for CliParseError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl Error for CliParseError {} + +pub fn parse_env() -> Result { + parse_from(std::env::args_os()) +} + +pub fn parse_from(arguments: I) -> Result +where + I: IntoIterator, + T: Into + Clone, +{ + let arguments = arguments.into_iter().map(Into::into).collect::>(); + let arguments = normalize_dispatch(arguments); + match CliArguments::try_parse_from(arguments) { + Ok(arguments) => convert_arguments(arguments).map(CliAction::Run), + Err(error) + if matches!( + error.kind(), + ErrorKind::DisplayHelp | ErrorKind::DisplayVersion + ) => + { + Ok(CliAction::Display(error.to_string())) + } + Err(error) => Err(CliParseError { + message: error.to_string(), + exit_code: EXIT_USAGE, + }), + } +} + +pub fn help_text(topic: Option) -> String { + let mut command = CliArguments::command(); + if let Some(topic) = topic { + let name = topic.command_name(); + if let Some(subcommand) = command.find_subcommand_mut(name) { + return subcommand.render_long_help().to_string(); + } + } + command.render_long_help().to_string() +} + +pub fn version_text() -> String { + format!("{PRODUCT_NAME} {}\n", env!("CARGO_PKG_VERSION")) +} + +/// The observable version string from the pinned pass-otp v1.2.0 source. +pub fn otp_version_text() -> &'static str { + "1.1.1\n" +} + +#[derive(Parser)] +#[command( + name = "ironstorage", + about = "A pass-compatible password-store client", + disable_help_subcommand = true, + version +)] +struct CliArguments { + #[arg(long, global = true, value_name = "PATH")] + config: Option, + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum CommandArguments { + Init(InitArguments), + #[command(name = "ls", visible_alias = "list")] + List(ListArguments), + Show(ShowArguments), + #[command(visible_alias = "search")] + Find(FindArguments), + Grep(GrepArguments), + #[command(visible_alias = "add")] + Insert(InsertArguments), + Edit(EditArguments), + Generate(GenerateArguments), + #[command(name = "rm", visible_aliases = ["remove", "delete"])] + Remove(RemoveArguments), + #[command(name = "mv", visible_alias = "rename")] + Move(MoveArguments), + #[command(name = "cp", visible_alias = "copy")] + Copy(CopyArguments), + Git(GitArguments), + Otp(OtpArguments), + Help(HelpArguments), + Version, +} + +#[derive(Args)] +struct InitArguments { + #[arg(short = 'p', long = "path")] + path: Option, + #[arg(required = true, num_args = 1.., value_name = "GPG-ID")] + key_identities: Vec, +} + +#[derive(Args)] +struct ListArguments { + path: Option, +} + +#[derive(Args)] +struct ShowArguments { + #[arg( + short = 'c', + long = "clip", + num_args = 0..=1, + default_missing_value = "1", + require_equals = true, + conflicts_with = "qrcode", + value_name = "LINE" + )] + clip: Option, + #[arg( + short = 'q', + long = "qrcode", + num_args = 0..=1, + default_missing_value = "1", + require_equals = true, + conflicts_with = "clip", + value_name = "LINE" + )] + qrcode: Option, + entry: Option, +} + +#[derive(Args)] +struct FindArguments { + #[arg(required = true, num_args = 1..)] + terms: Vec, +} + +#[derive(Args)] +struct GrepArguments { + #[arg(short = 'i', long = "ignore-case")] + ignore_case: bool, + #[arg(short = 'v', long = "invert-match")] + invert_match: bool, + #[arg(short = 'n', long = "line-number")] + line_number: bool, + #[arg(short = 'F', long = "fixed-strings")] + fixed_strings: bool, + pattern: String, +} + +#[derive(Args)] +struct InsertArguments { + #[arg(short = 'e', long = "echo", conflicts_with = "multiline")] + echo: bool, + #[arg(short = 'm', long = "multiline", conflicts_with = "echo")] + multiline: bool, + #[arg(short = 'f', long = "force")] + force: bool, + entry: String, +} + +#[derive(Args)] +struct EditArguments { + entry: String, +} + +#[derive(Args)] +struct GenerateArguments { + #[arg(short = 'n', long = "no-symbols")] + no_symbols: bool, + #[arg(short = 'c', long = "clip", conflicts_with = "qrcode")] + clip: bool, + #[arg(short = 'q', long = "qrcode", conflicts_with = "clip")] + qrcode: bool, + #[arg(short = 'i', long = "in-place", conflicts_with = "force")] + in_place: bool, + #[arg(short = 'f', long = "force", conflicts_with = "in_place")] + force: bool, + entry: String, + length: Option, +} + +#[derive(Args)] +struct RemoveArguments { + #[arg(short = 'r', long = "recursive")] + recursive: bool, + #[arg(short = 'f', long = "force")] + force: bool, + entry: String, +} + +#[derive(Args)] +struct MoveArguments { + #[arg(short = 'f', long = "force")] + force: bool, + source: String, + destination: String, +} + +#[derive(Args)] +struct CopyArguments { + #[arg(short = 'f', long = "force")] + force: bool, + source: String, + destination: String, +} + +#[derive(Args)] +struct GitArguments { + #[command(subcommand)] + command: GitCommandArguments, +} + +#[derive(Subcommand)] +enum GitCommandArguments { + Init, + Status, + Log { + #[arg(short = 'n', long = "max-count")] + maximum: Option, + }, + Diff { + paths: Vec, + }, + Add { + #[arg(required = true, num_args = 1..)] + paths: Vec, + }, + Commit { + #[arg(short = 'm', long = "message")] + message: String, + }, + Remote { + #[command(subcommand)] + command: Option, + }, + Config(GitConfigArguments), + Fetch { + remote: Option, + }, + Pull { + remote: Option, + branch: Option, + }, + Push { + remote: Option, + branch: Option, + }, + Sync { + remote: Option, + }, +} + +#[derive(Subcommand)] +enum GitRemoteArguments { + GetUrl { name: String }, + Add { name: String, url: String }, + SetUrl { name: String, url: String }, + Remove { name: String }, +} + +#[derive(Args)] +struct GitConfigArguments { + #[arg(long, conflicts_with = "values")] + get: Option, + #[arg(num_args = 2, value_names = ["KEY", "VALUE"])] + values: Vec, +} + +#[derive(Args)] +struct OtpArguments { + #[command(subcommand)] + command: OtpCommandArguments, +} + +#[derive(Subcommand)] +enum OtpCommandArguments { + #[command(visible_alias = "show")] + Code(OtpCodeArguments), + #[command(visible_alias = "add")] + Insert(OtpInsertArguments), + Append(OtpAppendArguments), + Uri(OtpUriArguments), + Validate { + uri: String, + }, + Version, +} + +#[derive(Args)] +struct OtpCodeArguments { + #[arg(short = 'c', long = "clip")] + clipboard: bool, + entry: String, +} + +#[derive(Args)] +struct OtpInsertArguments { + #[arg(short = 'f', long = "force")] + force: bool, + #[arg(short = 'e', long = "echo")] + echo: bool, + #[arg(short = 's', long = "secret")] + secret: bool, + #[arg(short = 'i', long = "issuer")] + issuer: Option, + #[arg(short = 'a', long = "account")] + account: Option, + entry: Option, +} + +#[derive(Args)] +struct OtpAppendArguments { + #[arg(short = 'f', long = "force")] + force: bool, + #[arg(short = 'e', long = "echo")] + echo: bool, + #[arg(short = 's', long = "secret")] + secret: bool, + #[arg(short = 'i', long = "issuer")] + issuer: Option, + #[arg(short = 'a', long = "account")] + account: Option, + entry: String, +} + +#[derive(Args)] +struct OtpUriArguments { + #[arg(short = 'c', long = "clip", conflicts_with = "qrcode")] + clipboard: bool, + #[arg(short = 'q', long = "qrcode", conflicts_with = "clipboard")] + qrcode: bool, + entry: String, +} + +#[derive(Args)] +struct HelpArguments { + topic: Option, +} + +fn convert_arguments(arguments: CliArguments) -> Result { + let request = match arguments.command { + None => CommandRequest::Show(ShowRequest { + entry: None, + presentation: Presentation::Terminal, + }), + Some(CommandArguments::Init(arguments)) => CommandRequest::Init(InitRequest { + path: arguments.path, + key_identities: arguments.key_identities, + }), + Some(CommandArguments::List(arguments)) => CommandRequest::List(ListRequest { + path: arguments.path, + }), + Some(CommandArguments::Show(arguments)) => CommandRequest::Show(ShowRequest { + entry: arguments.entry, + presentation: match (arguments.clip, arguments.qrcode) { + (Some(line), None) => Presentation::Clipboard { line }, + (None, Some(line)) => Presentation::QrCode { line }, + (None, None) => Presentation::Terminal, + (Some(_), Some(_)) => unreachable!("clap rejects conflicting presentation flags"), + }, + }), + Some(CommandArguments::Find(arguments)) => CommandRequest::Find(FindRequest { + terms: arguments.terms, + }), + Some(CommandArguments::Grep(arguments)) => CommandRequest::Grep(GrepRequest { + pattern: arguments.pattern, + ignore_case: arguments.ignore_case, + invert_match: arguments.invert_match, + line_number: arguments.line_number, + fixed_strings: arguments.fixed_strings, + }), + Some(CommandArguments::Insert(arguments)) => CommandRequest::Insert(InsertRequest { + entry: arguments.entry, + input: if arguments.multiline { + InsertInput::Multiline + } else if arguments.echo { + InsertInput::EchoedLine + } else { + InsertInput::HiddenConfirmed + }, + force: arguments.force, + }), + Some(CommandArguments::Edit(arguments)) => CommandRequest::Edit(EditRequest { + entry: arguments.entry, + }), + Some(CommandArguments::Generate(arguments)) => CommandRequest::Generate(GenerateRequest { + entry: arguments.entry, + length: arguments.length, + no_symbols: arguments.no_symbols, + force: arguments.force, + in_place: arguments.in_place, + presentation: if arguments.clip { + GeneratedPresentation::Clipboard + } else if arguments.qrcode { + GeneratedPresentation::QrCode + } else { + GeneratedPresentation::Terminal + }, + }), + Some(CommandArguments::Remove(arguments)) => CommandRequest::Remove(RemoveRequest { + entry: arguments.entry, + recursive: arguments.recursive, + force: arguments.force, + }), + Some(CommandArguments::Move(arguments)) => CommandRequest::Move(MoveRequest { + source: arguments.source, + destination: arguments.destination, + force: arguments.force, + }), + Some(CommandArguments::Copy(arguments)) => CommandRequest::Copy(CopyRequest { + source: arguments.source, + destination: arguments.destination, + force: arguments.force, + }), + Some(CommandArguments::Git(arguments)) => { + CommandRequest::Git(convert_git(arguments.command)?) + } + Some(CommandArguments::Otp(arguments)) => { + CommandRequest::Otp(convert_otp(arguments.command)?) + } + Some(CommandArguments::Help(arguments)) => { + let topic = arguments + .topic + .map(|topic| { + HelpTopic::parse(&topic).ok_or_else(|| usage_error("unknown help topic")) + }) + .transpose()?; + CommandRequest::Help { topic } + } + Some(CommandArguments::Version) => CommandRequest::Version, + }; + Ok(CliInvocation { + config: arguments.config, + request, + }) +} + +fn convert_git(arguments: GitCommandArguments) -> Result { + Ok(match arguments { + GitCommandArguments::Init => GitRequest::Init, + GitCommandArguments::Status => GitRequest::Status, + GitCommandArguments::Log { maximum } => GitRequest::Log { maximum }, + GitCommandArguments::Diff { paths } => GitRequest::Diff { paths }, + GitCommandArguments::Add { paths } => GitRequest::Add { paths }, + GitCommandArguments::Commit { message } => GitRequest::Commit { message }, + GitCommandArguments::Remote { command } => GitRequest::Remote(match command { + None => GitRemoteRequest::List, + Some(GitRemoteArguments::GetUrl { name }) => GitRemoteRequest::GetUrl { name }, + Some(GitRemoteArguments::Add { name, url }) => GitRemoteRequest::Add { name, url }, + Some(GitRemoteArguments::SetUrl { name, url }) => { + GitRemoteRequest::SetUrl { name, url } + } + Some(GitRemoteArguments::Remove { name }) => GitRemoteRequest::Remove { name }, + }), + GitCommandArguments::Config(arguments) => { + let request = match (arguments.get, arguments.values.as_slice()) { + (Some(key), []) => GitConfigRequest::Get { key }, + (None, [key, value]) => GitConfigRequest::Set { + key: key.clone(), + value: value.clone(), + }, + _ => return Err(usage_error("git config requires --get KEY or KEY VALUE")), + }; + GitRequest::Config(request) + } + GitCommandArguments::Fetch { remote } => GitRequest::Fetch { remote }, + GitCommandArguments::Pull { remote, branch } => GitRequest::Pull { remote, branch }, + GitCommandArguments::Push { remote, branch } => GitRequest::Push { remote, branch }, + GitCommandArguments::Sync { remote } => GitRequest::Sync { remote }, + }) +} + +fn convert_otp(arguments: OtpCommandArguments) -> Result { + Ok(match arguments { + OtpCommandArguments::Code(arguments) => OtpRequest::Code(OtpCodeRequest { + entry: arguments.entry, + clipboard: arguments.clipboard, + }), + OtpCommandArguments::Insert(arguments) => OtpRequest::Insert(OtpInsertRequest { + entry: arguments.entry, + force: arguments.force, + echo: arguments.echo, + source: otp_source(arguments.secret, arguments.issuer, arguments.account)?, + }), + OtpCommandArguments::Append(arguments) => OtpRequest::Append(OtpAppendRequest { + entry: arguments.entry, + force: arguments.force, + echo: arguments.echo, + source: otp_source(arguments.secret, arguments.issuer, arguments.account)?, + }), + OtpCommandArguments::Uri(arguments) => OtpRequest::Uri(OtpUriRequest { + entry: arguments.entry, + presentation: if arguments.clipboard { + OtpUriPresentation::Clipboard + } else if arguments.qrcode { + OtpUriPresentation::QrCode + } else { + OtpUriPresentation::Terminal + }, + }), + OtpCommandArguments::Validate { uri } => OtpRequest::Validate { uri }, + OtpCommandArguments::Version => OtpRequest::Version, + }) +} + +fn otp_source( + secret: bool, + issuer: Option, + account: Option, +) -> Result { + if secret { + if issuer.is_none() && account.is_none() { + return Err(usage_error( + "--secret requires at least one of --issuer or --account", + )); + } + Ok(OtpInputSource::Secret { issuer, account }) + } else if issuer.is_some() || account.is_some() { + Err(usage_error("--issuer and --account require --secret")) + } else { + Ok(OtpInputSource::Uri) + } +} + +fn line_input_plan(echo: bool, standard_input_is_terminal: bool) -> InputPlan { + if !standard_input_is_terminal { + InputPlan::StandardInputLine + } else if echo { + InputPlan::EchoedLine + } else { + InputPlan::HiddenConfirmed + } +} + +fn usage_error(message: &str) -> CliParseError { + CliParseError { + message: format!("error: {message}\n"), + exit_code: EXIT_USAGE, + } +} + +fn normalize_dispatch(mut arguments: Vec) -> Vec { + if arguments.is_empty() { + arguments.push(OsString::from("ironstorage")); + } + let Some(command_index) = first_command_index(&arguments) else { + return arguments; + }; + let command = arguments[command_index].to_string_lossy(); + const ROOT_COMMANDS: &[&str] = &[ + "init", "ls", "list", "show", "find", "search", "grep", "insert", "add", "edit", + "generate", "rm", "remove", "delete", "mv", "rename", "cp", "copy", "git", "otp", "help", + "version", + ]; + if !ROOT_COMMANDS.contains(&command.as_ref()) && !command.starts_with('-') { + arguments.insert(command_index, OsString::from("show")); + normalize_show_presentation(&mut arguments, command_index + 1); + return arguments; + } + if command == "show" { + normalize_show_presentation(&mut arguments, command_index + 1); + } else if command == "otp" { + normalize_otp_dispatch(&mut arguments, command_index + 1); + } + arguments +} + +fn normalize_show_presentation(arguments: &mut Vec, mut index: usize) { + while index < arguments.len() { + let argument = arguments[index].to_string_lossy(); + let long_name = match argument.as_ref() { + "-c" | "--clip" => Some("--clip"), + "-q" | "--qrcode" => Some("--qrcode"), + _ => None, + }; + if let Some(long_name) = long_name { + let numeric_value = arguments + .get(index + 1) + .and_then(|value| value.to_str()) + .filter(|value| { + !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()) + }) + .map(str::to_owned); + if let Some(value) = numeric_value { + arguments[index] = OsString::from(format!("{long_name}={value}")); + arguments.remove(index + 1); + } + } else if let Some(value) = argument + .strip_prefix("-c") + .filter(|value| !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit())) + { + arguments[index] = OsString::from(format!("--clip={value}")); + } else if let Some(value) = argument + .strip_prefix("-q") + .filter(|value| !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit())) + { + arguments[index] = OsString::from(format!("--qrcode={value}")); + } + index += 1; + } +} + +fn first_command_index(arguments: &[OsString]) -> Option { + let mut index = 1; + while index < arguments.len() { + let argument = arguments[index].to_string_lossy(); + if argument == "--config" { + index += 2; + } else if argument.starts_with("--config=") { + index += 1; + } else { + return Some(index); + } + } + None +} + +fn normalize_otp_dispatch(arguments: &mut Vec, mut index: usize) { + while index < arguments.len() { + let argument = arguments[index].to_string_lossy(); + if argument == "--config" { + index += 2; + } else if argument.starts_with("--config=") { + index += 1; + } else { + break; + } + } + let Some(argument) = arguments.get(index).map(|value| value.to_string_lossy()) else { + return; + }; + const OTP_COMMANDS: &[&str] = &[ + "code", "show", "insert", "add", "append", "uri", "validate", "help", "version", + ]; + if argument == "--version" { + arguments[index] = OsString::from("version"); + } else if !OTP_COMMANDS.contains(&argument.as_ref()) && argument != "--help" && argument != "-h" + { + arguments.insert(index, OsString::from("code")); + } +} diff --git a/crates/storage/src/config.rs b/crates/storage/src/config.rs new file mode 100644 index 0000000..6ce5fd1 --- /dev/null +++ b/crates/storage/src/config.rs @@ -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, + git_remotes: Vec, +} + +impl Config { + /// Load an explicit configuration file, or the native per-user default. + pub fn load(explicit: Option<&Path>) -> Result { + 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 { + 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 { + 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 { + 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 { + 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::(&contents).map_err(|_| ConfigError::Malformed { + path: source.clone(), + })?; + reject_insecure_fields(&value, "")?; + validate_known_fields(&value, &source)?; + let raw = value + .try_into::() + .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, +} + +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, + default_key: Option, + key_material: Option, + editor: Option, + #[serde(default)] + git: RawGit, +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum RawEditor { + CommandLine(String), + Arguments(Vec), +} + +#[derive(Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawGit { + #[serde(default)] + remotes: Vec, +} + +#[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 { + 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, + field: &'static str, +) -> Result { + 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 { + 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 { + 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) -> Result { + 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, 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) -> Result, 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 { + 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: "".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 { + env::var_os("APPDATA").map(PathBuf::from) +} + +#[cfg(target_os = "macos")] +fn native_config_directory() -> Option { + 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 { + 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 { + None +} diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 9c2f87a..6740bd1 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -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"; diff --git a/crates/storage/tests/command_contract.rs b/crates/storage/tests/command_contract.rs new file mode 100644 index 0000000..ff50760 --- /dev/null +++ b/crates/storage/tests/command_contract.rs @@ -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>; + +fn request(arguments: &[&str]) -> Result> { + 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(()) +} diff --git a/crates/storage/tests/config_contract.rs b/crates/storage/tests/config_contract.rs new file mode 100644 index 0000000..101bb3f --- /dev/null +++ b/crates/storage/tests/config_contract.rs @@ -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>; + +struct ConfigurationFixture { + temporary: TempDir, +} + +impl ConfigurationFixture { + fn new() -> Result> { + 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> { + 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(()) +} diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..4df73c2 --- /dev/null +++ b/docs/configuration.md @@ -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.