Onboard iPhone password-store clone
This commit is contained in:
@@ -5,6 +5,7 @@ use std::{
|
||||
env,
|
||||
error::Error,
|
||||
fmt, fs,
|
||||
fs::OpenOptions,
|
||||
io::Write,
|
||||
path::{Component, Path, PathBuf},
|
||||
time::Duration,
|
||||
@@ -167,6 +168,73 @@ impl Config {
|
||||
validate_config(self.source.clone(), document, raw)?.persist()
|
||||
}
|
||||
|
||||
pub(crate) fn create_mobile_clone(
|
||||
source: PathBuf,
|
||||
vault: &Path,
|
||||
key_material: &Path,
|
||||
default_key: &str,
|
||||
remote: &GitRemote,
|
||||
) -> Result<Self, ConfigError> {
|
||||
let base = source
|
||||
.parent()
|
||||
.ok_or(ConfigError::InvalidField { field: "source" })?;
|
||||
let vault = vault
|
||||
.strip_prefix(base)
|
||||
.map_err(|_| ConfigError::InvalidField { field: "vault" })?;
|
||||
let key_material =
|
||||
key_material
|
||||
.strip_prefix(base)
|
||||
.map_err(|_| ConfigError::InvalidField {
|
||||
field: "key_material",
|
||||
})?;
|
||||
let mut root = toml::Table::new();
|
||||
root.insert(
|
||||
"vault".to_owned(),
|
||||
toml::Value::String(path_text(vault, "vault")?),
|
||||
);
|
||||
root.insert(
|
||||
"default_key".to_owned(),
|
||||
toml::Value::String(default_key.to_owned()),
|
||||
);
|
||||
root.insert(
|
||||
"key_material".to_owned(),
|
||||
toml::Value::String(path_text(key_material, "key_material")?),
|
||||
);
|
||||
let mut configured = toml::Table::new();
|
||||
configured.insert(
|
||||
"name".to_owned(),
|
||||
toml::Value::String(remote.name().as_str().to_owned()),
|
||||
);
|
||||
configured.insert(
|
||||
"url".to_owned(),
|
||||
toml::Value::String(remote.url().to_string()),
|
||||
);
|
||||
configured.insert(
|
||||
"server_id".to_owned(),
|
||||
toml::Value::String(remote.server_id().as_str().to_owned()),
|
||||
);
|
||||
configured.insert(
|
||||
"application_id".to_owned(),
|
||||
toml::Value::String(remote.application_id().as_str().to_owned()),
|
||||
);
|
||||
let mut git = toml::Table::new();
|
||||
git.insert(
|
||||
"remotes".to_owned(),
|
||||
toml::Value::Array(vec![toml::Value::Table(configured)]),
|
||||
);
|
||||
root.insert("git".to_owned(), toml::Value::Table(git));
|
||||
let document = toml::Value::Table(root);
|
||||
let raw = document
|
||||
.clone()
|
||||
.try_into::<RawConfig>()
|
||||
.map_err(|_| ConfigError::Malformed {
|
||||
path: source.clone(),
|
||||
})?;
|
||||
let config = validate_config(source, document, raw)?;
|
||||
config.persist_new()?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Select a configured remote by name, or the configured default (first
|
||||
/// remote) when no name was requested.
|
||||
pub fn git_remote(&self, requested: Option<&str>) -> Option<&GitRemote> {
|
||||
@@ -313,6 +381,72 @@ impl Config {
|
||||
path: self.source.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn persist_new(&self) -> Result<(), ConfigError> {
|
||||
let parent = self.source.parent().ok_or_else(|| ConfigError::Write {
|
||||
path: self.source.clone(),
|
||||
})?;
|
||||
let name = self.source.file_name().ok_or_else(|| ConfigError::Write {
|
||||
path: self.source.clone(),
|
||||
})?;
|
||||
if self.source.exists() {
|
||||
return Err(ConfigError::AlreadyConfigured {
|
||||
path: self.source.clone(),
|
||||
});
|
||||
}
|
||||
fs::create_dir_all(parent).map_err(|_| ConfigError::Write {
|
||||
path: self.source.clone(),
|
||||
})?;
|
||||
set_private_directory(parent).map_err(|_| ConfigError::Write {
|
||||
path: self.source.clone(),
|
||||
})?;
|
||||
let contents = toml::to_string_pretty(&self.document).map_err(|_| ConfigError::Write {
|
||||
path: self.source.clone(),
|
||||
})?;
|
||||
let temporary = (0..128_u8)
|
||||
.find_map(|attempt| {
|
||||
let path = parent.join(format!(
|
||||
".ironstorage-config-{}-{attempt}",
|
||||
rand::random::<u64>()
|
||||
));
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt as _;
|
||||
options.mode(0o600);
|
||||
}
|
||||
match options.open(&path) {
|
||||
Ok(file) => Some(Ok((path, file))),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => None,
|
||||
Err(_) => Some(Err(ConfigError::Write {
|
||||
path: self.source.clone(),
|
||||
})),
|
||||
}
|
||||
})
|
||||
.transpose()?
|
||||
.ok_or_else(|| ConfigError::Write {
|
||||
path: self.source.clone(),
|
||||
})?;
|
||||
let (temporary_path, mut temporary_file) = temporary;
|
||||
let installed = temporary_file
|
||||
.write_all(contents.as_bytes())
|
||||
.and_then(|()| temporary_file.sync_all())
|
||||
.and_then(|()| fs::hard_link(&temporary_path, parent.join(name)));
|
||||
drop(temporary_file);
|
||||
let _ = fs::remove_file(&temporary_path);
|
||||
match installed {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
Err(ConfigError::AlreadyConfigured {
|
||||
path: self.source.clone(),
|
||||
})
|
||||
}
|
||||
Err(_) => Err(ConfigError::Write {
|
||||
path: self.source.clone(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic path context for configuration loading.
|
||||
@@ -432,6 +566,21 @@ pub struct GitRemote {
|
||||
}
|
||||
|
||||
impl GitRemote {
|
||||
pub fn https(
|
||||
name: impl Into<String>,
|
||||
url: impl Into<String>,
|
||||
server_id: impl Into<String>,
|
||||
application_id: impl Into<String>,
|
||||
) -> Result<Self, ConfigError> {
|
||||
let mut remotes = validate_remotes(vec![RawGitRemote {
|
||||
name: name.into(),
|
||||
url: url.into(),
|
||||
server_id: server_id.into(),
|
||||
application_id: application_id.into(),
|
||||
}])?;
|
||||
Ok(remotes.remove(0))
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &RemoteName {
|
||||
&self.name
|
||||
}
|
||||
@@ -505,6 +654,7 @@ pub enum ConfigError {
|
||||
VaultUnavailable { path: PathBuf },
|
||||
VaultIsNotDirectory { path: PathBuf },
|
||||
Write { path: PathBuf },
|
||||
AlreadyConfigured { path: PathBuf },
|
||||
KeyMaterialNotFound { path: PathBuf },
|
||||
InvalidKeyMaterial { path: PathBuf },
|
||||
DuplicateRemote { name: String },
|
||||
@@ -575,6 +725,11 @@ impl fmt::Display for ConfigError {
|
||||
path.display()
|
||||
)
|
||||
}
|
||||
Self::AlreadyConfigured { path } => write!(
|
||||
formatter,
|
||||
"configuration already exists and was not replaced: {}",
|
||||
path.display()
|
||||
),
|
||||
Self::KeyMaterialNotFound { path } => write!(
|
||||
formatter,
|
||||
"exported key material does not exist: {}",
|
||||
@@ -760,6 +915,23 @@ fn resolve_required_path(
|
||||
Ok(resolve_path(base, &value))
|
||||
}
|
||||
|
||||
fn path_text(path: &Path, field: &'static str) -> Result<String, ConfigError> {
|
||||
path.to_str()
|
||||
.map(str::to_owned)
|
||||
.ok_or(ConfigError::InvalidField { field })
|
||||
}
|
||||
|
||||
fn set_private_directory(path: &Path) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = path;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_key_identity(value: String) -> Result<KeyIdentity, ConfigError> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty()
|
||||
@@ -1014,7 +1186,14 @@ fn native_config_directory() -> Option<PathBuf> {
|
||||
.map(|home| home.join("Library/Application Support"))
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[cfg(target_os = "ios")]
|
||||
fn native_config_directory() -> Option<PathBuf> {
|
||||
env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.map(|home| home.join("Library/Application Support"))
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(any(target_os = "ios", target_os = "macos"))))]
|
||||
fn native_config_directory() -> Option<PathBuf> {
|
||||
match env::var_os("XDG_CONFIG_HOME") {
|
||||
Some(path) if !path.is_empty() && Path::new(&path).is_absolute() => {
|
||||
@@ -1031,3 +1210,53 @@ fn native_config_directory() -> Option<PathBuf> {
|
||||
fn native_config_directory() -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
|
||||
use super::{Config, ConfigError, GitRemote};
|
||||
|
||||
#[test]
|
||||
fn mobile_clone_configuration_is_secret_free_and_never_replaced() {
|
||||
let temporary = tempfile::tempdir().expect("temporary directory");
|
||||
let original = temporary.path().join("original");
|
||||
let relocated = temporary.path().join("relocated");
|
||||
let vault = original.join("vault");
|
||||
let keys = original.join("keys");
|
||||
let source = original.join("config.toml");
|
||||
fs::create_dir_all(&vault).expect("vault");
|
||||
fs::create_dir(&keys).expect("keys");
|
||||
let remote = GitRemote::https(
|
||||
"origin",
|
||||
"https://example.test/team/passwords.git",
|
||||
"server-example",
|
||||
"repository-example",
|
||||
)
|
||||
.expect("remote");
|
||||
Config::create_mobile_clone(source.clone(), &vault, &keys, "ALICE", &remote)
|
||||
.expect("create config");
|
||||
let contents = fs::read_to_string(&source).expect("read config");
|
||||
assert!(!contents.contains("token ="));
|
||||
assert!(!contents.contains("password ="));
|
||||
assert!(!contents.contains(&original.to_string_lossy().into_owned()));
|
||||
assert!(contents.contains("vault = \"vault\""));
|
||||
assert!(contents.contains("key_material = \"keys\""));
|
||||
assert!(contents.contains("https://example.test/team/passwords.git"));
|
||||
assert_eq!(
|
||||
Config::create_mobile_clone(source.clone(), &vault, &keys, "BOB", &remote)
|
||||
.expect_err("must not replace existing config"),
|
||||
ConfigError::AlreadyConfigured {
|
||||
path: source.clone()
|
||||
}
|
||||
);
|
||||
fs::rename(&original, &relocated).expect("relocate app container");
|
||||
assert_eq!(
|
||||
Config::load(Some(&relocated.join("config.toml")))
|
||||
.expect("reload config")
|
||||
.default_key()
|
||||
.as_str(),
|
||||
"ALICE"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user