Some checks failed
Dependency security audit / rustsec (push) Has been cancelled
971 lines
31 KiB
Rust
971 lines
31 KiB
Rust
#![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::{
|
|
AuthenticationTimeout, DEFAULT_AUTHENTICATION_TIMEOUT, MAX_AUTHENTICATION_TIMEOUT,
|
|
},
|
|
config::{
|
|
ConfigError, ConfigLoader, EditorSource, MobileAppearance, RemoteEndpoint, RemoteTransport,
|
|
SshRepositoryPath,
|
|
},
|
|
desktop::DesktopStorage,
|
|
git::GitIdentity,
|
|
mobile::MobileTab,
|
|
repository::EntryPath,
|
|
};
|
|
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(), "https://git.example.test/alice/store.git");
|
|
let (server_id, application_id) = remote.https_credentials().expect("HTTPS credentials");
|
|
assert_eq!(server_id.as_str(), "personal-git");
|
|
assert_eq!(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(())
|
|
}
|
|
|
|
#[cfg(not(any(target_os = "ios", target_os = "watchos")))]
|
|
#[test]
|
|
fn desktop_configuration_uses_repository_remotes_without_duplicate_settings() -> TestResult {
|
|
let fixture = ConfigurationFixture::new()?;
|
|
let vault = fixture.temporary.path().join("cwd/vault");
|
|
fs::create_dir_all(vault.join(".git"))?;
|
|
fs::write(
|
|
vault.join(".git/config"),
|
|
"[remote \"origin\"]\n\turl = git@git.example:team/store.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n",
|
|
)?;
|
|
fixture.write_explicit(
|
|
r#"
|
|
vault = "../vault"
|
|
default_key = "0123456789ABCDEF0123456789ABCDEF01234567"
|
|
key_material = "keys"
|
|
"#,
|
|
)?;
|
|
|
|
let config = fixture
|
|
.loader()
|
|
.load(Some(Path::new("config/config.toml")))?;
|
|
assert_eq!(config.git_remotes().len(), 1);
|
|
assert_eq!(config.git_remotes()[0].name().as_str(), "origin");
|
|
assert_eq!(
|
|
config.git_remotes()[0].url(),
|
|
"git@git.example:team/store.git"
|
|
);
|
|
assert!(config.git_remotes()[0].ssh_authentication().is_none());
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(not(any(target_os = "ios", target_os = "watchos")))]
|
|
#[test]
|
|
fn repository_https_remote_reuses_credentials_only_for_the_same_url() -> TestResult {
|
|
let fixture = ConfigurationFixture::new()?;
|
|
let vault = fixture.temporary.path().join("cwd/vault");
|
|
fs::create_dir_all(vault.join(".git"))?;
|
|
fs::write(
|
|
vault.join(".git/config"),
|
|
r#"[remote "origin"]
|
|
url = https://git.example.test/team/store.git
|
|
[remote "mirror"]
|
|
url = https://mirror.example.test/team/store.git
|
|
"#,
|
|
)?;
|
|
fixture.write_explicit(
|
|
r#"
|
|
vault = "../vault"
|
|
default_key = "0123456789ABCDEF0123456789ABCDEF01234567"
|
|
key_material = "keys"
|
|
|
|
[[git.remotes]]
|
|
name = "legacy-name"
|
|
url = "https://git.example.test/team/store.git"
|
|
server_id = "existing-server"
|
|
application_id = "existing-application"
|
|
"#,
|
|
)?;
|
|
|
|
let config = fixture
|
|
.loader()
|
|
.load(Some(Path::new("config/config.toml")))?;
|
|
let origin = config.git_remote(Some("origin")).expect("origin remote");
|
|
let (server, application) = origin.https_credentials().expect("HTTPS credentials");
|
|
assert_eq!(server.as_str(), "existing-server");
|
|
assert_eq!(application.as_str(), "existing-application");
|
|
|
|
let mirror = config.git_remote(Some("mirror")).expect("mirror remote");
|
|
let (server, application) = mirror.https_credentials().expect("HTTPS credentials");
|
|
assert_ne!(server.as_str(), "existing-server");
|
|
assert_ne!(application.as_str(), "existing-application");
|
|
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 mobile_timeout_and_appearance_share_the_secret_free_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_appearance(), MobileAppearance::System);
|
|
|
|
config.update_mobile_appearance(MobileAppearance::Dark)?;
|
|
config.update_authentication_timeout(AuthenticationTimeout::new(Duration::from_secs(300))?)?;
|
|
let reloaded = fixture.loader().load(Some(&fixture.explicit_path()))?;
|
|
assert_eq!(reloaded.mobile_appearance(), MobileAppearance::Dark);
|
|
assert_eq!(
|
|
reloaded.authentication_timeout().duration(),
|
|
Duration::from_secs(300)
|
|
);
|
|
let contents = fs::read_to_string(fixture.explicit_path())?;
|
|
assert!(contents.contains("mobile_appearance = \"dark\""));
|
|
assert!(contents.contains("inactivity_timeout_seconds = 300"));
|
|
assert!(!contents.to_ascii_lowercase().contains("token ="));
|
|
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_forbidden_transports_and_embedded_credentials() -> TestResult {
|
|
let fixture = ConfigurationFixture::new()?;
|
|
for url in [
|
|
"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 ssh_remote_endpoints_parse_to_one_typed_contract() -> TestResult {
|
|
let absolute = RemoteEndpoint::parse("ssh://git@example.test:2222/repos/store.git")?;
|
|
assert_eq!(absolute.transport(), RemoteTransport::Ssh);
|
|
let ssh = absolute.as_ssh().expect("SSH endpoint");
|
|
assert_eq!(ssh.user(), Some("git"));
|
|
assert_eq!(ssh.host(), "example.test");
|
|
assert_eq!(ssh.port(), 2222);
|
|
assert_eq!(
|
|
ssh.path(),
|
|
&SshRepositoryPath::Absolute("/repos/store.git".to_owned())
|
|
);
|
|
|
|
assert_eq!(
|
|
RemoteEndpoint::parse("ssh://git@example.test/repos/store.git")?,
|
|
RemoteEndpoint::parse("git@example.test:/repos/store.git")?
|
|
);
|
|
assert_eq!(
|
|
RemoteEndpoint::parse("ssh://git@example.test/~alice/store.git")?,
|
|
RemoteEndpoint::parse("git@example.test:~alice/store.git")?
|
|
);
|
|
|
|
let relative = RemoteEndpoint::parse("git@example.test:team/store.git")?;
|
|
assert_eq!(
|
|
relative.as_ssh().expect("SSH endpoint").path(),
|
|
&SshRepositoryPath::Relative("team/store.git".to_owned())
|
|
);
|
|
let ipv6 = RemoteEndpoint::parse("ssh://git@[2001:db8::1]:2200/store.git")?;
|
|
assert_eq!(ipv6.as_ssh().expect("IPv6 endpoint").port(), 2200);
|
|
let ipv4 = RemoteEndpoint::parse("git@192.0.2.10:team/store.git")?;
|
|
assert_eq!(ipv4.as_ssh().expect("IPv4 endpoint").host(), "192.0.2.10");
|
|
|
|
let unicode = RemoteEndpoint::parse("git@bücher.example:team/密码.git")?;
|
|
let unicode = unicode.as_ssh().expect("Unicode endpoint");
|
|
assert_eq!(unicode.host(), "xn--bcher-kva.example");
|
|
assert_eq!(unicode.path().as_str(), "team/密码.git");
|
|
|
|
let inert = "team/repo';touch${IFS}pwned.git";
|
|
assert_eq!(
|
|
RemoteEndpoint::parse(&format!("git@example.test:{inert}"))?
|
|
.as_ssh()
|
|
.expect("literal path")
|
|
.path()
|
|
.as_str(),
|
|
inert
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn remote_credentials_are_explicitly_transport_specific() -> TestResult {
|
|
let fixture = ConfigurationFixture::new()?;
|
|
fixture.write_explicit(
|
|
r#"
|
|
vault = "vault"
|
|
default_key = "alice"
|
|
key_material = "keys"
|
|
|
|
[[git.remotes]]
|
|
name = "origin"
|
|
url = "git@example.test:team/store.git"
|
|
server_id = "server"
|
|
application_id = "application"
|
|
"#,
|
|
)?;
|
|
assert_eq!(
|
|
fixture
|
|
.loader()
|
|
.load(Some(&fixture.explicit_path()))
|
|
.expect_err("HTTPS credentials cannot configure SSH"),
|
|
ConfigError::InvalidField {
|
|
field: "git.remotes.https_credentials"
|
|
}
|
|
);
|
|
|
|
fixture.write_explicit(
|
|
r#"
|
|
vault = "vault"
|
|
default_key = "alice"
|
|
key_material = "keys"
|
|
|
|
[[git.remotes]]
|
|
name = "origin"
|
|
url = "https://example.test/team/store.git"
|
|
"#,
|
|
)?;
|
|
assert_eq!(
|
|
fixture
|
|
.loader()
|
|
.load(Some(&fixture.explicit_path()))
|
|
.expect_err("HTTPS credentials are required"),
|
|
ConfigError::MissingField {
|
|
field: "git.remotes.server_id"
|
|
}
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn ssh_remote_configuration_round_trips_with_secret_free_authentication() -> TestResult {
|
|
let fixture = ConfigurationFixture::new()?;
|
|
fixture.write_explicit(
|
|
r#"
|
|
vault = "vault"
|
|
default_key = "alice"
|
|
key_material = "keys"
|
|
|
|
[[git.remotes]]
|
|
name = "origin"
|
|
url = "git@example.test:team/store.git"
|
|
ssh_identity_file = "keys/id_ed25519"
|
|
ssh_known_hosts_file = "known_hosts"
|
|
"#,
|
|
)?;
|
|
let config = fixture.loader().load(Some(&fixture.explicit_path()))?;
|
|
let remote = &config.git_remotes()[0];
|
|
assert_eq!(remote.url(), "git@example.test:team/store.git");
|
|
assert!(remote.https_credentials().is_none());
|
|
let authentication = remote.ssh_authentication().expect("SSH authentication");
|
|
assert_eq!(
|
|
authentication.identity().key_file(),
|
|
Some(
|
|
fs::canonicalize(fixture.temporary.path())?
|
|
.join("cwd/config/keys/id_ed25519")
|
|
.as_path()
|
|
)
|
|
);
|
|
assert_eq!(
|
|
authentication.known_hosts_file(),
|
|
fs::canonicalize(fixture.temporary.path())?.join("cwd/config/known_hosts")
|
|
);
|
|
|
|
config.update_git_identity(&GitIdentity::new("Alice", "alice@example.test")?)?;
|
|
let reloaded = fixture.loader().load(Some(&fixture.explicit_path()))?;
|
|
assert_eq!(reloaded.git_remotes(), config.git_remotes());
|
|
let persisted = fs::read_to_string(fixture.explicit_path())?;
|
|
assert!(!persisted.contains("server_id"));
|
|
assert!(!persisted.contains("application_id"));
|
|
assert!(!persisted.contains("passphrase"));
|
|
assert!(persisted.contains("ssh_identity_file"));
|
|
assert!(persisted.contains("ssh_known_hosts_file"));
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn ssh_authentication_requires_exactly_one_identity_source() -> TestResult {
|
|
let fixture = ConfigurationFixture::new()?;
|
|
fixture.write_explicit(
|
|
r#"
|
|
vault = "vault"
|
|
default_key = "alice"
|
|
key_material = "keys"
|
|
|
|
[[git.remotes]]
|
|
name = "origin"
|
|
url = "ssh://git@example.test/team/store.git"
|
|
"#,
|
|
)?;
|
|
assert_eq!(
|
|
fixture
|
|
.loader()
|
|
.load(Some(&fixture.explicit_path()))
|
|
.expect_err("SSH identity is required"),
|
|
ConfigError::InvalidField {
|
|
field: "git.remotes.ssh_authentication"
|
|
}
|
|
);
|
|
|
|
fixture.write_explicit(
|
|
r#"
|
|
vault = "vault"
|
|
default_key = "alice"
|
|
key_material = "keys"
|
|
|
|
[[git.remotes]]
|
|
name = "origin"
|
|
url = "ssh://git@example.test/team/store.git"
|
|
ssh_agent_fingerprint = "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
|
ssh_agent_socket = "agent.sock"
|
|
ssh_known_hosts_file = "known_hosts"
|
|
"#,
|
|
)?;
|
|
let config = fixture.loader().load(Some(&fixture.explicit_path()))?;
|
|
let authentication = config.git_remotes()[0]
|
|
.ssh_authentication()
|
|
.expect("SSH authentication");
|
|
assert_eq!(
|
|
authentication
|
|
.identity()
|
|
.agent_fingerprint()
|
|
.expect("agent fingerprint")
|
|
.as_str(),
|
|
"SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
|
);
|
|
assert_eq!(
|
|
authentication.identity().agent_socket(),
|
|
Some(
|
|
fs::canonicalize(fixture.temporary.path())?
|
|
.join("cwd/config/agent.sock")
|
|
.as_path()
|
|
)
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn ssh_remote_parser_rejects_ambiguous_local_and_executable_inputs() {
|
|
for remote in [
|
|
"",
|
|
"ssh://example.test",
|
|
"ssh://example.test/",
|
|
"ssh://user:secret@example.test/store.git",
|
|
"ssh://example.test/store.git?option=value",
|
|
"ssh://example.test/store.git#fragment",
|
|
"ssh://example.test/%0Acommand",
|
|
"git://example.test/store.git",
|
|
"file:///tmp/store.git",
|
|
"ext::helper command",
|
|
"../store.git",
|
|
"/tmp/store.git",
|
|
"C:/store.git",
|
|
"git@2001:db8::1:store.git",
|
|
"git@example.test:-upload-pack=evil",
|
|
"git@example.test:repo\ncommand",
|
|
] {
|
|
assert!(
|
|
RemoteEndpoint::parse(remote).is_err(),
|
|
"forbidden remote should fail closed: {remote:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[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(())
|
|
}
|