Onboard iPhone password-store clone

This commit is contained in:
hugo committed 2026-08-11 16:33:08 +02:00
1 parent db898152bf
commit daf122aff3
11 files changed
+2770 -38

No files matched your search

+231 -34
View File
@@ -788,18 +788,91 @@ impl FetchOutcome {
}
impl GitRepository {
pub fn discover_remote_branches(
parent: &Path,
identity: GitIdentity,
configured: &GitRemote,
credentials: &impl GitCredentialProvider,
control: &GitOperationControl,
) -> Result<Vec<String>, GitError> {
Self::discover_remote_branches_with_transport(
parent,
identity,
configured,
credentials,
&EmbeddedFetchTransport,
control,
)
}
pub fn discover_remote_branches_with_transport(
parent: &Path,
identity: GitIdentity,
configured: &GitRemote,
credentials: &impl GitCredentialProvider,
transport: &impl GitFetchTransport,
control: &GitOperationControl,
) -> Result<Vec<String>, GitError> {
control.report(GitProgressPhase::Validating)?;
validate_https_remote(configured.url().as_str())?;
ensure_clone_parent(parent)?;
let temporary = private_temporary_directory(parent, "probe")?;
let result = (|| {
let store = Repository::open(&temporary).map_err(invalid)?;
gix::ThreadSafeRepository::init_opts(
store.root_path(),
gix::create::Kind::WithWorktree,
gix::create::Options::default(),
isolated_options(),
)
.map_err(invalid)?;
let mut repository = Self::open(&store, identity)?;
repository.add_remote(configured.name().as_str(), configured.url().as_str())?;
repository.fetch_with_transport_controlled(
configured,
credentials,
transport,
control,
)?;
repository.remote_branch_names(configured.name().as_str())
})();
let _ = fs::remove_dir_all(&temporary);
result
}
pub fn clone_into(
destination: &Path,
identity: GitIdentity,
configured: &GitRemote,
credentials: &impl GitCredentialProvider,
) -> Result<Self, GitError> {
Self::clone_into_with_transport(
Self::clone_into_with_transport_controlled(
destination,
identity,
configured,
None,
credentials,
&EmbeddedFetchTransport,
&GitOperationControl::default(),
)
}
pub fn clone_into_controlled(
destination: &Path,
identity: GitIdentity,
configured: &GitRemote,
branch: &str,
credentials: &impl GitCredentialProvider,
control: &GitOperationControl,
) -> Result<Self, GitError> {
Self::clone_into_with_transport_controlled(
destination,
identity,
configured,
Some(branch),
credentials,
&EmbeddedFetchTransport,
control,
)
}
@@ -810,17 +883,35 @@ impl GitRepository {
credentials: &impl GitCredentialProvider,
transport: &impl GitFetchTransport,
) -> Result<Self, GitError> {
Self::clone_into_with_transport_controlled(
destination,
identity,
configured,
None,
credentials,
transport,
&GitOperationControl::default(),
)
}
pub fn clone_into_with_transport_controlled(
destination: &Path,
identity: GitIdentity,
configured: &GitRemote,
branch: Option<&str>,
credentials: &impl GitCredentialProvider,
transport: &impl GitFetchTransport,
control: &GitOperationControl,
) -> Result<Self, GitError> {
control.report(GitProgressPhase::Validating)?;
validate_https_remote(configured.url().as_str())?;
if let Some(branch) = branch {
validate_remote_name(branch)?;
}
let parent = destination.parent().ok_or_else(|| GitError::InvalidPath {
path: destination.to_owned(),
})?;
let metadata =
fs::symlink_metadata(parent).map_err(|_| io("inspect clone parent", parent))?;
if !metadata.is_dir() || metadata.file_type().is_symlink() {
return Err(GitError::UnsafeWorktreeObject {
path: parent.to_owned(),
});
}
ensure_clone_parent(parent)?;
if let Ok(metadata) = fs::symlink_metadata(destination) {
if !metadata.is_dir() || metadata.file_type().is_symlink() {
return Err(GitError::UnsafeWorktreeObject {
@@ -835,18 +926,7 @@ impl GitRepository {
return Err(GitError::DirtyWorktree);
}
}
let temporary = (0..128_u8)
.find_map(|attempt| {
let name = format!(".ironstorage-clone-{}-{attempt}", rand::random::<u64>());
let candidate = parent.join(name);
match fs::create_dir(&candidate) {
Ok(()) => Some(Ok(candidate)),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => None,
Err(_) => Some(Err(io("create clone directory", parent))),
}
})
.transpose()?
.ok_or_else(|| io("create clone directory", parent))?;
let temporary = private_temporary_directory(parent, "clone")?;
let cloned = (|| {
let store = Repository::open(&temporary).map_err(invalid)?;
gix::ThreadSafeRepository::init_opts(
@@ -858,17 +938,23 @@ impl GitRepository {
.map_err(invalid)?;
let mut repository = Self::open(&store, identity.clone())?;
repository.add_remote(configured.name().as_str(), configured.url().as_str())?;
repository.pull_with_transport(configured, None, credentials, transport)?;
Ok(repository)
repository.pull_with_transport_controlled(
configured,
branch,
credentials,
transport,
control,
)?;
control.report(GitProgressPhase::Refreshing)?;
Ok(())
})();
if let Err(error) = cloned {
let _ = fs::remove_dir_all(&temporary);
return Err(error);
}
drop(cloned);
if destination.exists() {
fs::remove_dir(destination)
.map_err(|_| io("prepare clone destination", destination))?;
if destination.exists() && fs::remove_dir(destination).is_err() {
let _ = fs::remove_dir_all(&temporary);
return Err(io("prepare clone destination", destination));
}
if fs::rename(&temporary, destination).is_err() {
let _ = fs::remove_dir_all(&temporary);
@@ -880,6 +966,42 @@ impl GitRepository {
Self::open_at(destination.to_owned(), identity)
}
fn remote_branch_names(&self, remote: &str) -> Result<Vec<String>, GitError> {
validate_remote_name(remote)?;
let prefix = format!("refs/remotes/{remote}/");
let mut branches = self
.repository
.references()
.map_err(invalid)?
.remote_branches()
.map_err(invalid)?
.filter_map(|reference| {
let reference = match reference {
Ok(reference) => reference,
Err(error) => return Some(Err(invalid(error))),
};
let name = match reference.name().as_bstr().to_str() {
Ok(name) => name,
Err(error) => return Some(Err(invalid(error))),
};
name.strip_prefix(&prefix)
.filter(|name| *name != "HEAD")
.map(|name| {
validate_remote_name(name)?;
Ok(name.to_owned())
})
})
.collect::<Result<Vec<_>, GitError>>()?;
branches.sort();
branches.dedup();
if branches.is_empty() {
return Err(GitError::InvalidRepository(
"the remote has no branches".to_owned(),
));
}
Ok(branches)
}
pub fn init(store: &Repository, identity: GitIdentity) -> Result<Self, GitError> {
let root = store.root_path().to_owned();
if root.join(".git").exists() {
@@ -957,12 +1079,13 @@ impl GitRepository {
}
fn open_at(root: PathBuf, identity: GitIdentity) -> Result<Self, GitError> {
let repository =
let mut repository =
gix::open_opts(&root, isolated_options()).map_err(|_| GitError::NotRepository)?;
if repository.is_bare() {
return Err(GitError::BareRepository);
}
validate_local_config_security(&load_local_config(&repository)?)?;
apply_in_memory_identity(&mut repository, &identity)?;
Ok(Self {
root,
repository,
@@ -1204,18 +1327,18 @@ impl GitRepository {
return GitError::Cancelled;
}
let text = error.to_string();
let lower = text.to_ascii_lowercase();
if text.contains("401")
|| text.contains("403")
|| text.to_ascii_lowercase().contains("authentication")
|| lower.contains("authentication")
|| (lower.contains("credential") && lower.contains("not accepted"))
{
GitError::AuthenticationFailed
} else if text.to_ascii_lowercase().contains("certificate")
|| text.to_ascii_lowercase().contains("tls")
{
} else if lower.contains("certificate") || lower.contains("tls") {
GitError::TlsFailed
} else if text.to_ascii_lowercase().contains("network")
|| text.to_ascii_lowercase().contains("connect")
|| text.to_ascii_lowercase().contains("dns")
} else if lower.contains("network")
|| lower.contains("connect")
|| lower.contains("dns")
{
GitError::NetworkUnavailable
} else {
@@ -1878,6 +2001,7 @@ impl GitRepository {
validate_local_config_security(&config)?;
write_local_config(&self.repository, &config)?;
self.repository = gix::open_opts(&self.root, isolated_options()).map_err(invalid)?;
apply_in_memory_identity(&mut self.repository, &self.identity)?;
Ok(())
}
@@ -3032,6 +3156,43 @@ fn validate_remote_name(name: &str) -> Result<(), GitError> {
Ok(())
}
fn ensure_clone_parent(parent: &Path) -> Result<(), GitError> {
let metadata = fs::symlink_metadata(parent).map_err(|_| io("inspect clone parent", parent))?;
if !metadata.is_dir() || metadata.file_type().is_symlink() {
return Err(GitError::UnsafeWorktreeObject {
path: parent.to_owned(),
});
}
Ok(())
}
fn private_temporary_directory(parent: &Path, purpose: &str) -> Result<PathBuf, GitError> {
(0..128_u8)
.find_map(|attempt| {
let name = format!(".ironstorage-{purpose}-{}-{attempt}", rand::random::<u64>());
let candidate = parent.join(name);
match fs::create_dir(&candidate) {
Ok(()) => {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
if fs::set_permissions(&candidate, fs::Permissions::from_mode(0o700))
.is_err()
{
let _ = fs::remove_dir(&candidate);
return Some(Err(io("secure private Git directory", parent)));
}
}
Some(Ok(candidate))
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => None,
Err(_) => Some(Err(io("create private Git directory", parent))),
}
})
.transpose()?
.ok_or_else(|| io("create private Git directory", parent))
}
fn validate_https_remote(value: &str) -> Result<url::Url, GitError> {
let parsed = url::Url::parse(value).map_err(|_| GitError::ForbiddenRemoteUrl)?;
if parsed.scheme() != "https"
@@ -3215,9 +3376,45 @@ fn invalid(error: impl fmt::Display) -> GitError {
GitError::InvalidRepository(error.to_string())
}
fn apply_in_memory_identity(
repository: &mut gix::Repository,
identity: &GitIdentity,
) -> Result<(), GitError> {
let mut config = repository.config_snapshot_mut();
config
.set_raw_value("user.name", identity.name())
.map_err(invalid)?;
config
.set_raw_value("user.email", identity.email())
.map_err(invalid)?;
drop(config);
if repository.committer().is_none() {
return Err(GitError::InvalidIdentity);
}
Ok(())
}
fn io(operation: &'static str, path: &Path) -> GitError {
GitError::Io {
operation,
path: path.to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::{GitIdentity, GitRepository};
use crate::repository::Repository;
#[test]
fn embedded_identity_survives_local_config_updates() {
let temporary = tempfile::tempdir().expect("temporary repository");
let store = Repository::open(temporary.path()).expect("store");
let mut repository =
GitRepository::init(&store, GitIdentity::ironstorage()).expect("initialize Git");
repository
.add_remote("origin", "https://example.test/password-store.git")
.expect("add remote");
assert!(repository.repository.committer().is_some());
}
}