#![forbid(unsafe_code)] use std::{collections::BTreeSet, error::Error, ffi::OsStr, fs, path::Path, time::Duration}; use ironstorage::presentation::DEFAULT_CLIPBOARD_TIMEOUT; use ironstorage::{ authentication::{DEFAULT_AUTHENTICATION_TIMEOUT, MAX_AUTHENTICATION_TIMEOUT}, config::{ConfigError, ConfigLoader, EditorSource}, desktop::DesktopStorage, git::GitIdentity, mobile::MobileTab, repository::EntryPath, }; 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(), fs::canonicalize(fixture.explicit_path())?); assert_eq!( config.vault(), fs::canonicalize(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"); assert_eq!( config.clipboard_timeout().duration(), DEFAULT_CLIPBOARD_TIMEOUT ); assert_eq!( config.authentication_timeout().duration(), DEFAULT_AUTHENTICATION_TIMEOUT ); Ok(()) } #[test] fn git_commit_identity_defaults_validates_and_persists() -> TestResult { let fixture = ConfigurationFixture::new()?; fixture.write_explicit(fixture.valid_contents())?; let config = fixture.loader().load(Some(&fixture.explicit_path()))?; assert_eq!(config.git_identity(), &GitIdentity::ironstorage()); let identity = GitIdentity::new("Alice Example", "alice@example.test")?; config.update_git_identity(&identity)?; let reloaded = fixture.loader().load(Some(&fixture.explicit_path()))?; assert_eq!(reloaded.git_identity(), &identity); assert_eq!(reloaded.git_remotes(), config.git_remotes()); let contents = fs::read_to_string(fixture.explicit_path())?; assert!(contents.contains("user_name = \"Alice Example\"")); assert!(contents.contains("user_email = \"alice@example.test\"")); fixture.write_explicit(&fixture.valid_contents().replace( "[[git.remotes]]", "[git]\nuser_name = \"Alice Example\"\n\n[[git.remotes]]", ))?; assert_eq!( fixture .loader() .load(Some(&fixture.explicit_path())) .expect_err("partial Git identity must be rejected"), ConfigError::InvalidField { field: "git.user_identity" } ); Ok(()) } #[test] fn watch_totp_selection_persists_only_in_application_configuration() -> TestResult { let fixture = ConfigurationFixture::new()?; fixture.write_explicit(fixture.valid_contents())?; let config = fixture .loader() .load(Some(Path::new("config/config.toml")))?; let stale = fixture .loader() .load(Some(Path::new("config/config.toml")))?; let selected = BTreeSet::from([ EntryPath::parse("otp/personal")?, EntryPath::parse("otp/work")?, ]); config.update_watch_shared_totp_entries(&selected)?; stale.update_mobile_tab(MobileTab::Totp)?; let reloaded = fixture .loader() .load(Some(Path::new("config/config.toml")))?; assert_eq!(reloaded.watch_shared_totp_entries(), &selected); assert_eq!(reloaded.mobile_tab(), MobileTab::Totp); assert!(!fixture.temporary.path().join("cwd/vault").exists()); Ok(()) } #[test] fn authentication_timeout_defaults_overrides_and_rejects_invalid_values() -> TestResult { let fixture = ConfigurationFixture::new()?; fixture.write_explicit(&fixture.valid_contents().replace( "editor = [\"code\", \"--wait\"]", "editor = [\"code\", \"--wait\"]\n[security]\ninactivity_timeout_seconds = 300", ))?; let config = fixture .loader() .load(Some(Path::new("config/config.toml")))?; assert_eq!( config.authentication_timeout().duration(), Duration::from_secs(300) ); for timeout in [0, MAX_AUTHENTICATION_TIMEOUT.as_secs() + 1] { fixture.write_explicit(&fixture.valid_contents().replace( "editor = [\"code\", \"--wait\"]", &format!( "editor = [\"code\", \"--wait\"]\n[security]\ninactivity_timeout_seconds = {timeout}" ), ))?; assert_eq!( fixture .loader() .load(Some(Path::new("config/config.toml"))) .expect_err("invalid authentication timeout"), ConfigError::InvalidField { field: "security.inactivity_timeout_seconds" } ); } Ok(()) } #[test] fn clipboard_timeout_defaults_overrides_and_rejects_unsafe_values() -> TestResult { let fixture = ConfigurationFixture::new()?; fixture.write_explicit(&fixture.valid_contents().replace( "editor = [\"code\", \"--wait\"]", "editor = [\"code\", \"--wait\"]\nclipboard_timeout_seconds = 30", ))?; let config = fixture .loader() .load(Some(Path::new("config/config.toml")))?; assert_eq!( config.clipboard_timeout().duration(), Duration::from_secs(30) ); for timeout in [0, 301] { fixture.write_explicit(&fixture.valid_contents().replace( "editor = [\"code\", \"--wait\"]", &format!("editor = [\"code\", \"--wait\"]\nclipboard_timeout_seconds = {timeout}"), ))?; assert_eq!( fixture .loader() .load(Some(Path::new("config/config.toml"))) .expect_err("unsafe clipboard timeout"), ConfigError::InvalidField { field: "clipboard_timeout_seconds" } ); } 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(), fs::canonicalize(default)?); Ok(()) } #[test] fn mobile_tab_defaults_and_persists_through_storage_configuration() -> TestResult { let fixture = ConfigurationFixture::new()?; fs::create_dir_all(fixture.temporary.path().join("cwd/vault"))?; fixture.write_explicit(fixture.valid_contents())?; let config = fixture.loader().load(Some(&fixture.explicit_path()))?; assert_eq!(config.mobile_tab(), MobileTab::Home); config.update_mobile_tab(MobileTab::Totp)?; let reloaded = fixture.loader().load(Some(&fixture.explicit_path()))?; assert_eq!(reloaded.mobile_tab(), MobileTab::Totp); assert!( fs::read_to_string(fixture.explicit_path())?.contains("selected_mobile_tab = \"totp\"") ); reloaded.update_mobile_tab(MobileTab::Search)?; let reloaded = fixture.loader().load(Some(&fixture.explicit_path()))?; assert_eq!(reloaded.mobile_tab(), MobileTab::Search); fixture.write_explicit(&format!( "{}\n[ui]\nselected_mobile_tab = \"unknown\"\n", fixture.valid_contents() ))?; assert_eq!( fixture .loader() .load(Some(&fixture.explicit_path())) .expect_err("unknown mobile tab"), ConfigError::InvalidField { field: "ui.selected_mobile_tab" } ); Ok(()) } #[test] fn biometric_preference_is_secret_free_and_defaults_to_disabled() -> TestResult { let fixture = ConfigurationFixture::new()?; fs::create_dir_all(fixture.temporary.path().join("cwd/vault"))?; fixture.write_explicit(fixture.valid_contents())?; let config = fixture.loader().load(Some(&fixture.explicit_path()))?; assert!(!config.biometric_unlock_enabled()); config.update_biometric_unlock(true)?; let contents = fs::read_to_string(fixture.explicit_path())?; assert!(contents.contains("biometric_unlock_enabled = true")); assert!(!contents.to_ascii_lowercase().contains("passphrase")); let reloaded = fixture.loader().load(Some(&fixture.explicit_path()))?; assert!(reloaded.biometric_unlock_enabled()); reloaded.update_biometric_unlock(false)?; assert!( !fixture .loader() .load(Some(&fixture.explicit_path()))? .biometric_unlock_enabled() ); Ok(()) } #[test] fn desktop_vault_switch_preserves_and_reloads_the_shared_configuration() -> TestResult { let fixture = ConfigurationFixture::new()?; fs::create_dir_all(fixture.temporary.path().join("cwd/vault"))?; let selected = fixture.temporary.path().join("selected-vault"); fs::create_dir(&selected)?; let keys = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/compatibility/keys"); let contents = fixture .valid_contents() .replace( "0123456789ABCDEF0123456789ABCDEF01234567", "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30", ) .replace( "key_material = \"keys\"", &format!("key_material = {keys:?}"), ) .replace( "editor = [\"code\", \"--wait\"]", "editor = [\"code\", \"--wait\"]\n[security]\ninactivity_timeout_seconds = 300", ); fixture.write_explicit(&contents)?; let storage = DesktopStorage::load(Some(&fixture.explicit_path()))?; let switched = storage.switch_vault(&selected)?; assert_eq!(switched.vault(), fs::canonicalize(&selected)?); let mut settings = switched.settings(); settings.set_default_key("B37027B56FC406BD3F6A622B2AC03492B992D06F".to_owned()); settings.set_editor(Some(vec!["nano".to_owned(), "-w".to_owned()])); settings.set_authentication_timeout(Duration::from_secs(600)); let updated = switched.update_settings(settings)?; let reloaded = fixture.loader().load(Some(&fixture.explicit_path()))?; assert_eq!(reloaded.vault(), fs::canonicalize(&selected)?); assert_eq!( reloaded.default_key().as_str(), "B37027B56FC406BD3F6A622B2AC03492B992D06F" ); assert_eq!( reloaded.authentication_timeout().duration(), Duration::from_secs(600) ); let editor = reloaded.configured_editor().expect("configured editor"); assert_eq!(editor.program(), "nano"); assert_eq!(editor.arguments(), ["-w"]); assert_eq!(reloaded.git_remotes().len(), 1); let before_rejection = fs::read(fixture.explicit_path())?; let mut invalid = updated.settings(); invalid.set_default_key("missing-key".to_owned()); assert!(updated.update_settings(invalid).is_err()); assert_eq!(fs::read(fixture.explicit_path())?, before_rejection); Ok(()) } #[test] fn rejected_vault_switches_leave_configuration_and_session_unchanged() -> TestResult { let fixture = ConfigurationFixture::new()?; fs::create_dir_all(fixture.temporary.path().join("cwd/vault"))?; fixture.write_explicit(fixture.valid_contents())?; let before = fs::read(fixture.explicit_path())?; let storage = DesktopStorage::load(Some(&fixture.explicit_path()))?; let current = storage.vault().to_owned(); for invalid in [ fixture.temporary.path().join("missing"), fixture.temporary.path().join("ordinary-file"), ] { if invalid.file_name() == Some(OsStr::new("ordinary-file")) { fs::write(&invalid, b"not a folder")?; } assert!(storage.switch_vault(&invalid).is_err()); assert_eq!(storage.vault(), current); assert_eq!(fs::read(fixture.explicit_path())?, before); } #[cfg(unix)] { use std::os::unix::fs::symlink; let target = fixture.temporary.path().join("symlink-target"); let link = fixture.temporary.path().join("symlink-vault"); fs::create_dir(&target)?; symlink(&target, &link)?; assert!(storage.switch_vault(&link).is_err()); assert_eq!(storage.vault(), current); assert_eq!(fs::read(fixture.explicit_path())?, before); } 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(()) }