Files
IronStorage/crates/storage/tests/config_contract.rs

551 lines
17 KiB
Rust

#![forbid(unsafe_code)]
use std::{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,
mobile::MobileTab,
};
use tempfile::TempDir;
type TestResult = Result<(), Box<dyn Error>>;
struct ConfigurationFixture {
temporary: TempDir,
}
impl ConfigurationFixture {
fn new() -> Result<Self, Box<dyn Error>> {
let temporary = tempfile::tempdir()?;
fs::create_dir_all(temporary.path().join("cwd/config/keys"))?;
fs::create_dir_all(temporary.path().join("native"))?;
Ok(Self { temporary })
}
fn loader(&self) -> ConfigLoader {
ConfigLoader::new(
self.temporary.path().join("cwd"),
self.temporary.path().join("native"),
)
}
fn write_explicit(&self, contents: &str) -> Result<(), Box<dyn Error>> {
fs::write(self.explicit_path(), contents)?;
Ok(())
}
fn explicit_path(&self) -> std::path::PathBuf {
self.temporary.path().join("cwd/config/config.toml")
}
fn valid_contents(&self) -> &'static str {
r#"
vault = "../vault"
default_key = "0123456789ABCDEF0123456789ABCDEF01234567"
key_material = "keys"
editor = ["code", "--wait"]
[[git.remotes]]
name = "origin"
url = "https://git.example.test/alice/store.git"
server_id = "personal-git"
application_id = "ironstorage-cli"
"#
}
}
#[test]
fn explicit_relative_configuration_resolves_deterministically() -> TestResult {
let fixture = ConfigurationFixture::new()?;
fixture.write_explicit(fixture.valid_contents())?;
let config = fixture
.loader()
.load(Some(Path::new("config/config.toml")))?;
assert_eq!(config.source(), 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 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\"")
);
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 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(())
}