Add typed SSH remote endpoints
This commit is contained in:
@@ -7,8 +7,11 @@ derived from a password-store repository. This includes filesystem layout,
|
|||||||
`.gpg-id` handling, GPG-compatible encryption and key handling, Git,
|
`.gpg-id` handling, GPG-compatible encryption and key handling, Git,
|
||||||
synchronization, conflict handling, entry parsing, password generation, OTP,
|
synchronization, conflict handling, entry parsing, password generation, OTP,
|
||||||
QR payloads, server/application identities, HTTPS Git credentials, and
|
QR payloads, server/application identities, HTTPS Git credentials, and
|
||||||
secure-secret-storage orchestration. Git remotes are HTTPS-only; reject other
|
secure-secret-storage orchestration. Git remotes are typed HTTPS or optional
|
||||||
schemes before entering the Git transport.
|
SSH endpoints; reject local, helper, executable, and unknown transports before
|
||||||
|
entering Git transport code. Builds without the `ssh` feature must still parse
|
||||||
|
SSH endpoints and return a typed unsupported-transport error before connection
|
||||||
|
or repository mutation.
|
||||||
|
|
||||||
The CLI, Ratatui, Iced, Swift, SwiftUI, AutoFill, and watchOS code may collect
|
The CLI, Ratatui, Iced, Swift, SwiftUI, AutoFill, and watchOS code may collect
|
||||||
input, invoke the Rust API, and present Rust-provided state. They must not
|
input, invoke the Rust API, and present Rust-provided state. They must not
|
||||||
|
|||||||
1076
Cargo.lock
generated
1076
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -44,6 +44,7 @@ keyring-core = "1.0"
|
|||||||
keepass = "0.13.20"
|
keepass = "0.13.20"
|
||||||
muda = "0.19"
|
muda = "0.19"
|
||||||
pgp = { version = "0.20", default-features = false }
|
pgp = { version = "0.20", default-features = false }
|
||||||
|
percent-encoding = "2.3"
|
||||||
qrcode = { version = "0.14", default-features = false }
|
qrcode = { version = "0.14", default-features = false }
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
regex = "1.13"
|
regex = "1.13"
|
||||||
@@ -51,6 +52,7 @@ reqwest = { version = "0.13", default-features = false, features = ["blocking",
|
|||||||
rfd = { version = "0.17", default-features = false }
|
rfd = { version = "0.17", default-features = false }
|
||||||
rqrr = { version = "0.10", default-features = false }
|
rqrr = { version = "0.10", default-features = false }
|
||||||
rpassword = "7.5"
|
rpassword = "7.5"
|
||||||
|
russh = { version = "0.63.1", default-features = false, features = ["ring"] }
|
||||||
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color", "unstable-rendered-line-info"] }
|
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color", "unstable-rendered-line-info"] }
|
||||||
security-framework = "3.7"
|
security-framework = "3.7"
|
||||||
secret-service = { version = "5.1", default-features = false, features = ["rt-tokio-crypto-rust"] }
|
secret-service = { version = "5.1", default-features = false, features = ["rt-tokio-crypto-rust"] }
|
||||||
@@ -61,6 +63,7 @@ sha2 = "0.10"
|
|||||||
shlex = "1.3"
|
shlex = "1.3"
|
||||||
simple-file-manifest = "0.11"
|
simple-file-manifest = "0.11"
|
||||||
toml = "0.9"
|
toml = "0.9"
|
||||||
|
tokio = { version = "1.53.1", default-features = false, features = ["io-util", "net", "rt-multi-thread", "sync", "time"] }
|
||||||
uniffi = "0.32"
|
uniffi = "0.32"
|
||||||
url = { version = "2.5", default-features = false }
|
url = { version = "2.5", default-features = false }
|
||||||
windows-native-keyring-store = { version = "1.1", default-features = false }
|
windows-native-keyring-store = { version = "1.1", default-features = false }
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ and the first-party `pass` command behavior. `pass-otp` compatibility adds
|
|||||||
|
|
||||||
The compatibility target includes init, list/show, find/grep, insert/edit,
|
The compatibility target includes init, list/show, find/grep, insert/edit,
|
||||||
generate, remove, move/copy, and Git-backed commit and synchronization flows.
|
generate, remove, move/copy, and Git-backed commit and synchronization flows.
|
||||||
Remote synchronization is HTTPS-only and uses server/application credentials
|
Remote endpoints are typed in the storage crate as credential-free HTTPS or
|
||||||
kept in the operating system's secure store.
|
feature-gated SSH. HTTPS synchronization uses server/application credentials
|
||||||
|
kept in the operating system's secure store; SSH transport dependencies and
|
||||||
|
runtime are compiled only with the optional `ssh` Cargo feature.
|
||||||
|
|
||||||
The central `ironstorage` Rust crate owns repository access, Git,
|
The central `ironstorage` Rust crate owns repository access, Git,
|
||||||
GPG-compatible encryption and key handling, entries, OTP, synchronization,
|
GPG-compatible encryption and key handling, entries, OTP, synchronization,
|
||||||
@@ -24,7 +26,7 @@ and `gpg`—are forbidden; compatibility is implemented in Rust.
|
|||||||
IronStorage is MIT licensed. Dependency licenses and the completed packaging
|
IronStorage is MIT licensed. Dependency licenses and the completed packaging
|
||||||
review are tracked in [`DEPENDENCIES.md`](DEPENDENCIES.md).
|
review are tracked in [`DEPENDENCIES.md`](DEPENDENCIES.md).
|
||||||
|
|
||||||
The shared TOML schema, path rules, editor precedence, and HTTPS remote format
|
The shared TOML schema, path rules, editor precedence, and Git remote formats
|
||||||
are documented in [`docs/configuration.md`](docs/configuration.md).
|
are documented in [`docs/configuration.md`](docs/configuration.md).
|
||||||
Embedded Git, HTTPS synchronization, merge behavior, and commit signing are
|
Embedded Git, HTTPS synchronization, merge behavior, and commit signing are
|
||||||
documented in [`docs/git-synchronization.md`](docs/git-synchronization.md).
|
documented in [`docs/git-synchronization.md`](docs/git-synchronization.md).
|
||||||
|
|||||||
@@ -1939,15 +1939,16 @@ mod tests {
|
|||||||
assert!(stderr.is_empty());
|
assert!(stderr.is_empty());
|
||||||
|
|
||||||
let remote = &config.git_remotes()[0];
|
let remote = &config.git_remotes()[0];
|
||||||
|
let (server_id, application_id) = remote.https_credentials().expect("HTTPS remote");
|
||||||
secrets.create(
|
secrets.create(
|
||||||
&SecretReference::https_git_credential(
|
&SecretReference::https_git_credential(
|
||||||
remote.server_id().as_str(),
|
server_id.as_str(),
|
||||||
remote.application_id().as_str(),
|
application_id.as_str(),
|
||||||
"fixture-account",
|
"fixture-account",
|
||||||
)?,
|
)?,
|
||||||
SecretBytes::new(b"fixture-token".to_vec()),
|
SecretBytes::new(b"fixture-token".to_vec()),
|
||||||
)?;
|
)?;
|
||||||
let credential = secrets.credential(remote.server_id(), remote.application_id())?;
|
let credential = secrets.credential(server_id, application_id)?;
|
||||||
assert_eq!(credential.username(), "fixture-account");
|
assert_eq!(credential.username(), "fixture-account");
|
||||||
assert_eq!(credential.password(), b"fixture-token");
|
assert_eq!(credential.password(), b"fixture-token");
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -7601,7 +7601,7 @@ mod tests {
|
|||||||
assert_eq!(error.git_error(), Some(&GitError::ForbiddenRemoteUrl));
|
assert_eq!(error.git_error(), Some(&GitError::ForbiddenRemoteUrl));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
error.to_string(),
|
error.to_string(),
|
||||||
"Git remotes must use credential-free HTTPS URLs"
|
"the Git remote URL is invalid or forbidden"
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(DesktopGitRequest::Pull.requires_authentication());
|
assert!(DesktopGitRequest::Pull.requires_authentication());
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ full = [
|
|||||||
"dep:keepass",
|
"dep:keepass",
|
||||||
"dep:keyring-core",
|
"dep:keyring-core",
|
||||||
"dep:pgp",
|
"dep:pgp",
|
||||||
|
"dep:percent-encoding",
|
||||||
"dep:qrcode",
|
"dep:qrcode",
|
||||||
"dep:rand",
|
"dep:rand",
|
||||||
"dep:regex",
|
"dep:regex",
|
||||||
@@ -38,6 +39,7 @@ full = [
|
|||||||
"dep:windows-native-keyring-store",
|
"dep:windows-native-keyring-store",
|
||||||
"dep:zbus-secret-service-keyring-store",
|
"dep:zbus-secret-service-keyring-store",
|
||||||
]
|
]
|
||||||
|
ssh = ["dep:russh", "dep:tokio"]
|
||||||
watch = []
|
watch = []
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -54,16 +56,19 @@ image = { workspace = true, optional = true }
|
|||||||
keyring-core = { workspace = true, optional = true }
|
keyring-core = { workspace = true, optional = true }
|
||||||
keepass = { workspace = true, optional = true }
|
keepass = { workspace = true, optional = true }
|
||||||
pgp = { workspace = true, optional = true }
|
pgp = { workspace = true, optional = true }
|
||||||
|
percent-encoding = { workspace = true, optional = true }
|
||||||
qrcode = { workspace = true, optional = true }
|
qrcode = { workspace = true, optional = true }
|
||||||
rand = { workspace = true, optional = true }
|
rand = { workspace = true, optional = true }
|
||||||
regex = { workspace = true, optional = true }
|
regex = { workspace = true, optional = true }
|
||||||
reqwest = { workspace = true, optional = true }
|
reqwest = { workspace = true, optional = true }
|
||||||
rqrr = { workspace = true, optional = true }
|
rqrr = { workspace = true, optional = true }
|
||||||
|
russh = { workspace = true, optional = true }
|
||||||
serde = { workspace = true, optional = true }
|
serde = { workspace = true, optional = true }
|
||||||
sha1.workspace = true
|
sha1.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
shlex = { workspace = true, optional = true }
|
shlex = { workspace = true, optional = true }
|
||||||
toml = { workspace = true, optional = true }
|
toml = { workspace = true, optional = true }
|
||||||
|
tokio = { workspace = true, optional = true }
|
||||||
url = { workspace = true, optional = true }
|
url = { workspace = true, optional = true }
|
||||||
zeroize.workspace = true
|
zeroize.workspace = true
|
||||||
|
|
||||||
|
|||||||
@@ -420,26 +420,9 @@ impl Config {
|
|||||||
.ok_or(ConfigError::InvalidField { field: "git" })?;
|
.ok_or(ConfigError::InvalidField { field: "git" })?;
|
||||||
match remote {
|
match remote {
|
||||||
Some(remote) => {
|
Some(remote) => {
|
||||||
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()),
|
|
||||||
);
|
|
||||||
git.insert(
|
git.insert(
|
||||||
"remotes".to_owned(),
|
"remotes".to_owned(),
|
||||||
toml::Value::Array(vec![toml::Value::Table(configured)]),
|
toml::Value::Array(vec![toml::Value::Table(git_remote_document(remote))]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
@@ -511,27 +494,10 @@ impl Config {
|
|||||||
toml::Value::String(path_text(key_material, "key_material")?),
|
toml::Value::String(path_text(key_material, "key_material")?),
|
||||||
);
|
);
|
||||||
if let Some(remote) = remote {
|
if let Some(remote) = remote {
|
||||||
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();
|
let mut git = toml::Table::new();
|
||||||
git.insert(
|
git.insert(
|
||||||
"remotes".to_owned(),
|
"remotes".to_owned(),
|
||||||
toml::Value::Array(vec![toml::Value::Table(configured)]),
|
toml::Value::Array(vec![toml::Value::Table(git_remote_document(remote))]),
|
||||||
);
|
);
|
||||||
root.insert("git".to_owned(), toml::Value::Table(git));
|
root.insert("git".to_owned(), toml::Value::Table(git));
|
||||||
}
|
}
|
||||||
@@ -768,6 +734,29 @@ impl Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn git_remote_document(remote: &GitRemote) -> toml::Table {
|
||||||
|
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_owned()),
|
||||||
|
);
|
||||||
|
if let Some((server_id, application_id)) = remote.https_credentials() {
|
||||||
|
configured.insert(
|
||||||
|
"server_id".to_owned(),
|
||||||
|
toml::Value::String(server_id.as_str().to_owned()),
|
||||||
|
);
|
||||||
|
configured.insert(
|
||||||
|
"application_id".to_owned(),
|
||||||
|
toml::Value::String(application_id.as_str().to_owned()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
configured
|
||||||
|
}
|
||||||
|
|
||||||
/// Deterministic path context for configuration loading.
|
/// Deterministic path context for configuration loading.
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct ConfigLoader {
|
pub struct ConfigLoader {
|
||||||
@@ -876,12 +865,264 @@ identifier_type!(RemoteName);
|
|||||||
identifier_type!(ServerId);
|
identifier_type!(ServerId);
|
||||||
identifier_type!(ApplicationId);
|
identifier_type!(ApplicationId);
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum RemoteTransport {
|
||||||
|
Https,
|
||||||
|
Ssh,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for RemoteTransport {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter.write_str(match self {
|
||||||
|
Self::Https => "HTTPS",
|
||||||
|
Self::Ssh => "SSH",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum RemoteEndpoint {
|
||||||
|
Https(Url),
|
||||||
|
Ssh(SshEndpoint),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RemoteEndpoint {
|
||||||
|
pub fn parse(value: &str) -> Result<Self, InvalidRemoteEndpoint> {
|
||||||
|
if value.is_empty() || value.chars().any(char::is_control) {
|
||||||
|
return Err(InvalidRemoteEndpoint);
|
||||||
|
}
|
||||||
|
if value.contains("://") {
|
||||||
|
let parsed = Url::parse(value).map_err(|_| InvalidRemoteEndpoint)?;
|
||||||
|
return match parsed.scheme() {
|
||||||
|
"https"
|
||||||
|
if parsed.host_str().is_some()
|
||||||
|
&& parsed.username().is_empty()
|
||||||
|
&& parsed.password().is_none()
|
||||||
|
&& parsed.query().is_none()
|
||||||
|
&& parsed.fragment().is_none() =>
|
||||||
|
{
|
||||||
|
Ok(Self::Https(parsed))
|
||||||
|
}
|
||||||
|
"ssh" => parse_ssh_url(parsed).map(Self::Ssh),
|
||||||
|
_ => Err(InvalidRemoteEndpoint),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
parse_scp_remote(value).map(Self::Ssh)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn transport(&self) -> RemoteTransport {
|
||||||
|
match self {
|
||||||
|
Self::Https(_) => RemoteTransport::Https,
|
||||||
|
Self::Ssh(_) => RemoteTransport::Ssh,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn as_https(&self) -> Option<&Url> {
|
||||||
|
match self {
|
||||||
|
Self::Https(url) => Some(url),
|
||||||
|
Self::Ssh(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn as_ssh(&self) -> Option<&SshEndpoint> {
|
||||||
|
match self {
|
||||||
|
Self::Https(_) => None,
|
||||||
|
Self::Ssh(endpoint) => Some(endpoint),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct SshEndpoint {
|
||||||
|
user: Option<String>,
|
||||||
|
host: String,
|
||||||
|
port: u16,
|
||||||
|
path: SshRepositoryPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SshEndpoint {
|
||||||
|
pub fn user(&self) -> Option<&str> {
|
||||||
|
self.user.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn host(&self) -> &str {
|
||||||
|
&self.host
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn port(&self) -> u16 {
|
||||||
|
self.port
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn path(&self) -> &SshRepositoryPath {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum SshRepositoryPath {
|
||||||
|
Absolute(String),
|
||||||
|
Relative(String),
|
||||||
|
Tilde(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SshRepositoryPath {
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::Absolute(path) | Self::Relative(path) | Self::Tilde(path) => path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct InvalidRemoteEndpoint;
|
||||||
|
|
||||||
|
impl fmt::Display for InvalidRemoteEndpoint {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter.write_str("invalid or forbidden Git remote endpoint")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for InvalidRemoteEndpoint {}
|
||||||
|
|
||||||
|
fn parse_ssh_url(url: Url) -> Result<SshEndpoint, InvalidRemoteEndpoint> {
|
||||||
|
if url.password().is_some() || url.query().is_some() || url.fragment().is_some() {
|
||||||
|
return Err(InvalidRemoteEndpoint);
|
||||||
|
}
|
||||||
|
let user = parse_ssh_user(url.username())?;
|
||||||
|
let host = url.host_str().ok_or(InvalidRemoteEndpoint)?.to_owned();
|
||||||
|
let port = url.port().unwrap_or(22);
|
||||||
|
if port == 0 {
|
||||||
|
return Err(InvalidRemoteEndpoint);
|
||||||
|
}
|
||||||
|
let path = percent_encoding::percent_decode_str(url.path())
|
||||||
|
.decode_utf8()
|
||||||
|
.map_err(|_| InvalidRemoteEndpoint)?;
|
||||||
|
let path = if let Some(tilde) = path.strip_prefix("/~") {
|
||||||
|
parse_ssh_path(format!("~{tilde}"), true)?
|
||||||
|
} else {
|
||||||
|
parse_ssh_path(path.into_owned(), false)?
|
||||||
|
};
|
||||||
|
Ok(SshEndpoint {
|
||||||
|
user,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_scp_remote(value: &str) -> Result<SshEndpoint, InvalidRemoteEndpoint> {
|
||||||
|
if value.contains(['?', '#']) || value.to_ascii_lowercase().starts_with("ext::") {
|
||||||
|
return Err(InvalidRemoteEndpoint);
|
||||||
|
}
|
||||||
|
let separator = if let Some(bracket) = value.find('[') {
|
||||||
|
let close = value[bracket + 1..]
|
||||||
|
.find(']')
|
||||||
|
.map(|index| bracket + index + 1)
|
||||||
|
.ok_or(InvalidRemoteEndpoint)?;
|
||||||
|
if value.as_bytes().get(close + 1) != Some(&b':') {
|
||||||
|
return Err(InvalidRemoteEndpoint);
|
||||||
|
}
|
||||||
|
close + 1
|
||||||
|
} else {
|
||||||
|
value.find(':').ok_or(InvalidRemoteEndpoint)?
|
||||||
|
};
|
||||||
|
let (authority, path) = value.split_at(separator);
|
||||||
|
let path = path.strip_prefix(':').ok_or(InvalidRemoteEndpoint)?;
|
||||||
|
if path.contains(':') {
|
||||||
|
return Err(InvalidRemoteEndpoint);
|
||||||
|
}
|
||||||
|
let (user, host) = match authority.split_once('@') {
|
||||||
|
Some((user, host)) if !host.contains('@') => (parse_ssh_user(user)?, host),
|
||||||
|
Some(_) => return Err(InvalidRemoteEndpoint),
|
||||||
|
None => (None, authority),
|
||||||
|
};
|
||||||
|
if host.is_empty()
|
||||||
|
|| host.starts_with('-')
|
||||||
|
|| (host.len() == 1
|
||||||
|
&& host.as_bytes()[0].is_ascii_alphabetic()
|
||||||
|
&& path.starts_with(['/', '\\']))
|
||||||
|
{
|
||||||
|
return Err(InvalidRemoteEndpoint);
|
||||||
|
}
|
||||||
|
let bracketed = host.starts_with('[') && host.ends_with(']');
|
||||||
|
if host.contains(['[', ']']) && !bracketed || host.contains(':') && !bracketed {
|
||||||
|
return Err(InvalidRemoteEndpoint);
|
||||||
|
}
|
||||||
|
let host = host
|
||||||
|
.strip_prefix('[')
|
||||||
|
.and_then(|host| host.strip_suffix(']'))
|
||||||
|
.unwrap_or(host);
|
||||||
|
let host = url::Host::parse(host)
|
||||||
|
.map_err(|_| InvalidRemoteEndpoint)?
|
||||||
|
.to_string();
|
||||||
|
Ok(SshEndpoint {
|
||||||
|
user,
|
||||||
|
host,
|
||||||
|
port: 22,
|
||||||
|
path: parse_ssh_path(path.to_owned(), true)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_ssh_user(value: &str) -> Result<Option<String>, InvalidRemoteEndpoint> {
|
||||||
|
if value.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if value.starts_with('-')
|
||||||
|
|| !value
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
|
||||||
|
{
|
||||||
|
return Err(InvalidRemoteEndpoint);
|
||||||
|
}
|
||||||
|
Ok(Some(value.to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_ssh_path(
|
||||||
|
path: String,
|
||||||
|
allow_relative: bool,
|
||||||
|
) -> Result<SshRepositoryPath, InvalidRemoteEndpoint> {
|
||||||
|
if path.is_empty()
|
||||||
|
|| path == "/"
|
||||||
|
|| path.contains(['?', '#'])
|
||||||
|
|| path.chars().any(char::is_control)
|
||||||
|
{
|
||||||
|
return Err(InvalidRemoteEndpoint);
|
||||||
|
}
|
||||||
|
if path.starts_with('/') {
|
||||||
|
return Ok(SshRepositoryPath::Absolute(path));
|
||||||
|
}
|
||||||
|
if let Some(tilde_path) = path.strip_prefix('~') {
|
||||||
|
let user = tilde_path.split('/').next().unwrap_or_default();
|
||||||
|
if !user.is_empty()
|
||||||
|
&& !user
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
|
||||||
|
{
|
||||||
|
return Err(InvalidRemoteEndpoint);
|
||||||
|
}
|
||||||
|
return Ok(SshRepositoryPath::Tilde(path));
|
||||||
|
}
|
||||||
|
if !allow_relative || path.starts_with('-') {
|
||||||
|
return Err(InvalidRemoteEndpoint);
|
||||||
|
}
|
||||||
|
Ok(SshRepositoryPath::Relative(path))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct GitRemote {
|
pub struct GitRemote {
|
||||||
name: RemoteName,
|
name: RemoteName,
|
||||||
url: Url,
|
url: String,
|
||||||
server_id: ServerId,
|
endpoint: RemoteEndpoint,
|
||||||
application_id: ApplicationId,
|
credentials: GitRemoteCredentials,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
enum GitRemoteCredentials {
|
||||||
|
Https {
|
||||||
|
server_id: ServerId,
|
||||||
|
application_id: ApplicationId,
|
||||||
|
},
|
||||||
|
Ssh,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GitRemote {
|
impl GitRemote {
|
||||||
@@ -894,8 +1135,18 @@ impl GitRemote {
|
|||||||
let mut remotes = validate_remotes(vec![RawGitRemote {
|
let mut remotes = validate_remotes(vec![RawGitRemote {
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
url: url.into(),
|
url: url.into(),
|
||||||
server_id: server_id.into(),
|
server_id: Some(server_id.into()),
|
||||||
application_id: application_id.into(),
|
application_id: Some(application_id.into()),
|
||||||
|
}])?;
|
||||||
|
Ok(remotes.remove(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ssh(name: impl Into<String>, url: impl Into<String>) -> Result<Self, ConfigError> {
|
||||||
|
let mut remotes = validate_remotes(vec![RawGitRemote {
|
||||||
|
name: name.into(),
|
||||||
|
url: url.into(),
|
||||||
|
server_id: None,
|
||||||
|
application_id: None,
|
||||||
}])?;
|
}])?;
|
||||||
Ok(remotes.remove(0))
|
Ok(remotes.remove(0))
|
||||||
}
|
}
|
||||||
@@ -904,16 +1155,22 @@ impl GitRemote {
|
|||||||
&self.name
|
&self.name
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn url(&self) -> &Url {
|
pub fn url(&self) -> &str {
|
||||||
&self.url
|
&self.url
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn server_id(&self) -> &ServerId {
|
pub const fn endpoint(&self) -> &RemoteEndpoint {
|
||||||
&self.server_id
|
&self.endpoint
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn application_id(&self) -> &ApplicationId {
|
pub const fn https_credentials(&self) -> Option<(&ServerId, &ApplicationId)> {
|
||||||
&self.application_id
|
match &self.credentials {
|
||||||
|
GitRemoteCredentials::Https {
|
||||||
|
server_id,
|
||||||
|
application_id,
|
||||||
|
} => Some((server_id, application_id)),
|
||||||
|
GitRemoteCredentials::Ssh => None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1067,7 +1324,7 @@ impl fmt::Display for ConfigError {
|
|||||||
}
|
}
|
||||||
Self::InvalidRemoteUrl { name } => write!(
|
Self::InvalidRemoteUrl { name } => write!(
|
||||||
formatter,
|
formatter,
|
||||||
"Git remote {name} must be an HTTPS URL without embedded credentials, query, or fragment"
|
"Git remote {name} must be an allowed HTTPS or SSH endpoint without embedded credentials, query, or fragment"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1153,8 +1410,8 @@ struct RawGit {
|
|||||||
struct RawGitRemote {
|
struct RawGitRemote {
|
||||||
name: String,
|
name: String,
|
||||||
url: String,
|
url: String,
|
||||||
server_id: String,
|
server_id: Option<String>,
|
||||||
application_id: String,
|
application_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_config(
|
fn validate_config(
|
||||||
@@ -1413,41 +1670,52 @@ fn validate_remotes(raw: Vec<RawGitRemote>) -> Result<Vec<GitRemote>, ConfigErro
|
|||||||
let mut remotes = Vec::with_capacity(raw.len());
|
let mut remotes = Vec::with_capacity(raw.len());
|
||||||
for remote in raw {
|
for remote in raw {
|
||||||
let name = RemoteName(validate_identifier("git.remotes.name", remote.name)?);
|
let name = RemoteName(validate_identifier("git.remotes.name", remote.name)?);
|
||||||
let server_id = ServerId(validate_identifier(
|
|
||||||
"git.remotes.server_id",
|
|
||||||
remote.server_id,
|
|
||||||
)?);
|
|
||||||
let application_id = ApplicationId(validate_identifier(
|
|
||||||
"git.remotes.application_id",
|
|
||||||
remote.application_id,
|
|
||||||
)?);
|
|
||||||
if !names.insert(name.clone()) {
|
if !names.insert(name.clone()) {
|
||||||
return Err(ConfigError::DuplicateRemote {
|
return Err(ConfigError::DuplicateRemote {
|
||||||
name: name.0.clone(),
|
name: name.0.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if !references.insert((server_id.clone(), application_id.clone())) {
|
let endpoint =
|
||||||
return Err(ConfigError::DuplicateCredentialReference);
|
RemoteEndpoint::parse(&remote.url).map_err(|_| ConfigError::InvalidRemoteUrl {
|
||||||
}
|
|
||||||
let url = Url::parse(&remote.url).map_err(|_| ConfigError::InvalidRemoteUrl {
|
|
||||||
name: name.0.clone(),
|
|
||||||
})?;
|
|
||||||
if url.scheme() != "https"
|
|
||||||
|| url.host_str().is_none()
|
|
||||||
|| !url.username().is_empty()
|
|
||||||
|| url.password().is_some()
|
|
||||||
|| url.query().is_some()
|
|
||||||
|| url.fragment().is_some()
|
|
||||||
{
|
|
||||||
return Err(ConfigError::InvalidRemoteUrl {
|
|
||||||
name: name.0.clone(),
|
name: name.0.clone(),
|
||||||
});
|
})?;
|
||||||
}
|
let credentials = match (&endpoint, remote.server_id, remote.application_id) {
|
||||||
|
(RemoteEndpoint::Https(_), Some(server_id), Some(application_id)) => {
|
||||||
|
let server_id = ServerId(validate_identifier("git.remotes.server_id", server_id)?);
|
||||||
|
let application_id = ApplicationId(validate_identifier(
|
||||||
|
"git.remotes.application_id",
|
||||||
|
application_id,
|
||||||
|
)?);
|
||||||
|
if !references.insert((server_id.clone(), application_id.clone())) {
|
||||||
|
return Err(ConfigError::DuplicateCredentialReference);
|
||||||
|
}
|
||||||
|
GitRemoteCredentials::Https {
|
||||||
|
server_id,
|
||||||
|
application_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(RemoteEndpoint::Https(_), None, _) => {
|
||||||
|
return Err(ConfigError::MissingField {
|
||||||
|
field: "git.remotes.server_id",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
(RemoteEndpoint::Https(_), _, None) => {
|
||||||
|
return Err(ConfigError::MissingField {
|
||||||
|
field: "git.remotes.application_id",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
(RemoteEndpoint::Ssh(_), None, None) => GitRemoteCredentials::Ssh,
|
||||||
|
(RemoteEndpoint::Ssh(_), _, _) => {
|
||||||
|
return Err(ConfigError::InvalidField {
|
||||||
|
field: "git.remotes.https_credentials",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
remotes.push(GitRemote {
|
remotes.push(GitRemote {
|
||||||
name,
|
name,
|
||||||
url,
|
url: remote.url,
|
||||||
server_id,
|
endpoint,
|
||||||
application_id,
|
credentials,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(remotes)
|
Ok(remotes)
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ use sha1::{Digest as _, Sha1};
|
|||||||
use zeroize::Zeroize as _;
|
use zeroize::Zeroize as _;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{ApplicationId, GitRemote, ServerId},
|
config::{ApplicationId, GitRemote, RemoteEndpoint, RemoteTransport, ServerId},
|
||||||
crypto::{KeyHandle, KeyStore, SecretProvider},
|
crypto::{KeyHandle, KeyStore, SecretProvider},
|
||||||
mutation::{TreeCommit, TreeCommitError, TreeCommitter},
|
mutation::{TreeCommit, TreeCommitError, TreeCommitter},
|
||||||
recipient::{PolicyCommit, PolicyCommitError, PolicyCommitter},
|
recipient::{PolicyCommit, PolicyCommitError, PolicyCommitter},
|
||||||
@@ -384,6 +384,9 @@ pub enum GitError {
|
|||||||
DirtyWorktree,
|
DirtyWorktree,
|
||||||
InvalidRemoteName,
|
InvalidRemoteName,
|
||||||
ForbiddenRemoteUrl,
|
ForbiddenRemoteUrl,
|
||||||
|
UnsupportedRemoteTransport {
|
||||||
|
transport: RemoteTransport,
|
||||||
|
},
|
||||||
RemoteNotFound {
|
RemoteNotFound {
|
||||||
name: String,
|
name: String,
|
||||||
},
|
},
|
||||||
@@ -429,7 +432,13 @@ impl fmt::Display for GitError {
|
|||||||
Self::DirtyWorktree => formatter.write_str("the worktree has uncommitted changes"),
|
Self::DirtyWorktree => formatter.write_str("the worktree has uncommitted changes"),
|
||||||
Self::InvalidRemoteName => formatter.write_str("the Git remote name is invalid"),
|
Self::InvalidRemoteName => formatter.write_str("the Git remote name is invalid"),
|
||||||
Self::ForbiddenRemoteUrl => {
|
Self::ForbiddenRemoteUrl => {
|
||||||
formatter.write_str("Git remotes must use credential-free HTTPS URLs")
|
formatter.write_str("the Git remote URL is invalid or forbidden")
|
||||||
|
}
|
||||||
|
Self::UnsupportedRemoteTransport { transport } => {
|
||||||
|
write!(
|
||||||
|
formatter,
|
||||||
|
"this build does not support {transport} Git remotes"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Self::RemoteNotFound { name } => write!(formatter, "Git remote not found: {name}"),
|
Self::RemoteNotFound { name } => write!(formatter, "Git remote not found: {name}"),
|
||||||
Self::CredentialsUnavailable => {
|
Self::CredentialsUnavailable => {
|
||||||
@@ -861,7 +870,7 @@ impl GitRepository {
|
|||||||
control: &GitOperationControl,
|
control: &GitOperationControl,
|
||||||
) -> Result<Vec<String>, GitError> {
|
) -> Result<Vec<String>, GitError> {
|
||||||
control.report(GitProgressPhase::Validating)?;
|
control.report(GitProgressPhase::Validating)?;
|
||||||
validate_https_remote(configured.url().as_str())?;
|
require_https_remote(configured)?;
|
||||||
ensure_clone_parent(parent)?;
|
ensure_clone_parent(parent)?;
|
||||||
let temporary = private_temporary_directory(parent, "probe")?;
|
let temporary = private_temporary_directory(parent, "probe")?;
|
||||||
let result = (|| {
|
let result = (|| {
|
||||||
@@ -874,7 +883,7 @@ impl GitRepository {
|
|||||||
)
|
)
|
||||||
.map_err(invalid)?;
|
.map_err(invalid)?;
|
||||||
let mut repository = Self::open(&store, identity)?;
|
let mut repository = Self::open(&store, identity)?;
|
||||||
repository.add_remote(configured.name().as_str(), configured.url().as_str())?;
|
repository.add_remote(configured.name().as_str(), configured.url())?;
|
||||||
repository.fetch_with_transport_controlled(
|
repository.fetch_with_transport_controlled(
|
||||||
configured,
|
configured,
|
||||||
credentials,
|
credentials,
|
||||||
@@ -951,7 +960,7 @@ impl GitRepository {
|
|||||||
control: &GitOperationControl,
|
control: &GitOperationControl,
|
||||||
) -> Result<Self, GitError> {
|
) -> Result<Self, GitError> {
|
||||||
control.report(GitProgressPhase::Validating)?;
|
control.report(GitProgressPhase::Validating)?;
|
||||||
validate_https_remote(configured.url().as_str())?;
|
require_https_remote(configured)?;
|
||||||
if let Some(branch) = branch {
|
if let Some(branch) = branch {
|
||||||
validate_remote_name(branch)?;
|
validate_remote_name(branch)?;
|
||||||
}
|
}
|
||||||
@@ -984,7 +993,7 @@ impl GitRepository {
|
|||||||
)
|
)
|
||||||
.map_err(invalid)?;
|
.map_err(invalid)?;
|
||||||
let mut repository = Self::open(&store, identity.clone())?;
|
let mut repository = Self::open(&store, identity.clone())?;
|
||||||
repository.add_remote(configured.name().as_str(), configured.url().as_str())?;
|
repository.add_remote(configured.name().as_str(), configured.url())?;
|
||||||
repository.pull_with_transport_controlled(
|
repository.pull_with_transport_controlled(
|
||||||
configured,
|
configured,
|
||||||
branch,
|
branch,
|
||||||
@@ -1167,13 +1176,15 @@ impl GitRepository {
|
|||||||
name: name.to_owned(),
|
name: name.to_owned(),
|
||||||
})?;
|
})?;
|
||||||
let text = url.to_bstring().to_str_lossy().into_owned();
|
let text = url.to_bstring().to_str_lossy().into_owned();
|
||||||
validate_https_remote(&text)?;
|
RemoteEndpoint::parse(&text).map_err(|_| GitError::ForbiddenRemoteUrl)?;
|
||||||
Ok(text)
|
Ok(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_remote(&mut self, name: &str, url: &str) -> Result<(), GitError> {
|
pub fn add_remote(&mut self, name: &str, url: &str) -> Result<(), GitError> {
|
||||||
validate_remote_name(name)?;
|
validate_remote_name(name)?;
|
||||||
validate_https_remote(url)?;
|
ensure_remote_transport_available(
|
||||||
|
&RemoteEndpoint::parse(url).map_err(|_| GitError::ForbiddenRemoteUrl)?,
|
||||||
|
)?;
|
||||||
if self.remotes().iter().any(|existing| existing == name) {
|
if self.remotes().iter().any(|existing| existing == name) {
|
||||||
return Err(GitError::InvalidRepository(format!(
|
return Err(GitError::InvalidRepository(format!(
|
||||||
"remote {name} already exists"
|
"remote {name} already exists"
|
||||||
@@ -1197,7 +1208,9 @@ impl GitRepository {
|
|||||||
|
|
||||||
pub fn set_remote_url(&mut self, name: &str, url: &str) -> Result<(), GitError> {
|
pub fn set_remote_url(&mut self, name: &str, url: &str) -> Result<(), GitError> {
|
||||||
validate_remote_name(name)?;
|
validate_remote_name(name)?;
|
||||||
validate_https_remote(url)?;
|
ensure_remote_transport_available(
|
||||||
|
&RemoteEndpoint::parse(url).map_err(|_| GitError::ForbiddenRemoteUrl)?,
|
||||||
|
)?;
|
||||||
if !self.remotes().iter().any(|existing| existing == name) {
|
if !self.remotes().iter().any(|existing| existing == name) {
|
||||||
return Err(GitError::RemoteNotFound {
|
return Err(GitError::RemoteNotFound {
|
||||||
name: name.to_owned(),
|
name: name.to_owned(),
|
||||||
@@ -1248,7 +1261,9 @@ impl GitRepository {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
validate_remote_name(&parts[1..parts.len() - 1].join("."))?;
|
validate_remote_name(&parts[1..parts.len() - 1].join("."))?;
|
||||||
validate_https_remote(value)?;
|
ensure_remote_transport_available(
|
||||||
|
&RemoteEndpoint::parse(value).map_err(|_| GitError::ForbiddenRemoteUrl)?,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
if value.contains(['\n', '\r', '\0']) {
|
if value.contains(['\n', '\r', '\0']) {
|
||||||
return Err(GitError::InvalidRepository(
|
return Err(GitError::InvalidRepository(
|
||||||
@@ -1293,12 +1308,12 @@ impl GitRepository {
|
|||||||
control.checkpoint(GitProgressPhase::Validating)?;
|
control.checkpoint(GitProgressPhase::Validating)?;
|
||||||
let name = configured.name().as_str();
|
let name = configured.name().as_str();
|
||||||
let actual_url = self.remote_url(name)?;
|
let actual_url = self.remote_url(name)?;
|
||||||
if actual_url != configured.url().as_str() {
|
if !same_remote_endpoint(&actual_url, configured.url())? {
|
||||||
return Err(GitError::ForbiddenRemoteUrl);
|
return Err(GitError::ForbiddenRemoteUrl);
|
||||||
}
|
}
|
||||||
|
let (_, server_id, application_id) = require_https_remote(configured)?;
|
||||||
control.checkpoint(GitProgressPhase::Authenticating)?;
|
control.checkpoint(GitProgressPhase::Authenticating)?;
|
||||||
let credential =
|
let credential = credentials.credential(server_id, application_id)?;
|
||||||
credentials.credential(configured.server_id(), configured.application_id())?;
|
|
||||||
let received_pack = transport.fetch_controlled(self, configured, &credential, control)?;
|
let received_pack = transport.fetch_controlled(self, configured, &credential, control)?;
|
||||||
Ok(FetchOutcome {
|
Ok(FetchOutcome {
|
||||||
remote: name.to_owned(),
|
remote: name.to_owned(),
|
||||||
@@ -1333,7 +1348,7 @@ impl GitRepository {
|
|||||||
.map_err(|_| GitError::CredentialsUnavailable)?
|
.map_err(|_| GitError::CredentialsUnavailable)?
|
||||||
.to_owned();
|
.to_owned();
|
||||||
let username = credential.username().to_owned();
|
let username = credential.username().to_owned();
|
||||||
let expected_origin = configured.url().clone();
|
let expected_origin = require_https_remote(configured)?.0.clone();
|
||||||
let remote = self
|
let remote = self
|
||||||
.repository
|
.repository
|
||||||
.find_fetch_remote(Some(name.into()))
|
.find_fetch_remote(Some(name.into()))
|
||||||
@@ -1576,10 +1591,10 @@ impl GitRepository {
|
|||||||
}
|
}
|
||||||
let name = configured.name().as_str();
|
let name = configured.name().as_str();
|
||||||
let actual_url = self.remote_url(name)?;
|
let actual_url = self.remote_url(name)?;
|
||||||
if actual_url != configured.url().as_str() {
|
if !same_remote_endpoint(&actual_url, configured.url())? {
|
||||||
return Err(GitError::ForbiddenRemoteUrl);
|
return Err(GitError::ForbiddenRemoteUrl);
|
||||||
}
|
}
|
||||||
let url = validate_https_remote(&actual_url)?;
|
let (url, server_id, application_id) = require_https_remote(configured)?;
|
||||||
let branch = branch.map_or_else(
|
let branch = branch.map_or_else(
|
||||||
|| self.current_branch(),
|
|| self.current_branch(),
|
||||||
|branch| {
|
|branch| {
|
||||||
@@ -1594,10 +1609,9 @@ impl GitRepository {
|
|||||||
.map_err(|_| GitError::UnbornHead)?
|
.map_err(|_| GitError::UnbornHead)?
|
||||||
.detach();
|
.detach();
|
||||||
control.checkpoint(GitProgressPhase::Authenticating)?;
|
control.checkpoint(GitProgressPhase::Authenticating)?;
|
||||||
let credential =
|
let credential = credentials.credential(server_id, application_id)?;
|
||||||
credentials.credential(configured.server_id(), configured.application_id())?;
|
|
||||||
let advertisement =
|
let advertisement =
|
||||||
transport.advertise_receive_pack_controlled(&url, &credential, control)?;
|
transport.advertise_receive_pack_controlled(url, &credential, control)?;
|
||||||
let advertised = parse_receive_pack_advertisement(&advertisement)?;
|
let advertised = parse_receive_pack_advertisement(&advertisement)?;
|
||||||
let old = advertised.refs.get(&reference).copied();
|
let old = advertised.refs.get(&reference).copied();
|
||||||
if let Some(old) = old {
|
if let Some(old) = old {
|
||||||
@@ -1635,7 +1649,7 @@ impl GitRepository {
|
|||||||
let mut request = encode_pkt_line(command.as_bytes())?;
|
let mut request = encode_pkt_line(command.as_bytes())?;
|
||||||
request.extend_from_slice(b"0000");
|
request.extend_from_slice(b"0000");
|
||||||
request.extend_from_slice(&pack);
|
request.extend_from_slice(&pack);
|
||||||
let response = transport.receive_pack_controlled(&url, &credential, request, control)?;
|
let response = transport.receive_pack_controlled(url, &credential, request, control)?;
|
||||||
parse_receive_pack_result(&response, &reference)?;
|
parse_receive_pack_result(&response, &reference)?;
|
||||||
self.update_remote_tracking(name, &branch, new)?;
|
self.update_remote_tracking(name, &branch, new)?;
|
||||||
Ok(PushOutcome {
|
Ok(PushOutcome {
|
||||||
@@ -1706,7 +1720,7 @@ impl GitRepository {
|
|||||||
let name = configured.name().as_str();
|
let name = configured.name().as_str();
|
||||||
let actual_url = if self.remotes().iter().any(|remote| remote == name) {
|
let actual_url = if self.remotes().iter().any(|remote| remote == name) {
|
||||||
let actual = self.remote_url(name)?;
|
let actual = self.remote_url(name)?;
|
||||||
if actual != configured.url().as_str() {
|
if !same_remote_endpoint(&actual, configured.url())? {
|
||||||
return Err(GitError::ForbiddenRemoteUrl);
|
return Err(GitError::ForbiddenRemoteUrl);
|
||||||
}
|
}
|
||||||
actual
|
actual
|
||||||
@@ -1756,7 +1770,7 @@ impl GitRepository {
|
|||||||
let status = self.status()?;
|
let status = self.status()?;
|
||||||
let name = configured.name().as_str();
|
let name = configured.name().as_str();
|
||||||
let actual_url = self.remote_url(name)?;
|
let actual_url = self.remote_url(name)?;
|
||||||
if actual_url != configured.url().as_str() {
|
if !same_remote_endpoint(&actual_url, configured.url())? {
|
||||||
return Err(GitError::ForbiddenRemoteUrl);
|
return Err(GitError::ForbiddenRemoteUrl);
|
||||||
}
|
}
|
||||||
let local_id = self
|
let local_id = self
|
||||||
@@ -3375,18 +3389,34 @@ fn private_temporary_directory(parent: &Path, purpose: &str) -> Result<PathBuf,
|
|||||||
.ok_or_else(|| io("create private Git directory", parent))
|
.ok_or_else(|| io("create private Git directory", parent))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_https_remote(value: &str) -> Result<url::Url, GitError> {
|
fn require_https_remote(
|
||||||
let parsed = url::Url::parse(value).map_err(|_| GitError::ForbiddenRemoteUrl)?;
|
remote: &GitRemote,
|
||||||
if parsed.scheme() != "https"
|
) -> Result<(&url::Url, &ServerId, &ApplicationId), GitError> {
|
||||||
|| parsed.host_str().is_none()
|
let url = remote
|
||||||
|| !parsed.username().is_empty()
|
.endpoint()
|
||||||
|| parsed.password().is_some()
|
.as_https()
|
||||||
|| parsed.query().is_some()
|
.ok_or(GitError::UnsupportedRemoteTransport {
|
||||||
|| parsed.fragment().is_some()
|
transport: RemoteTransport::Ssh,
|
||||||
{
|
})?;
|
||||||
return Err(GitError::ForbiddenRemoteUrl);
|
let (server_id, application_id) = remote
|
||||||
|
.https_credentials()
|
||||||
|
.ok_or(GitError::ForbiddenRemoteUrl)?;
|
||||||
|
Ok((url, server_id, application_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn same_remote_endpoint(actual: &str, configured: &str) -> Result<bool, GitError> {
|
||||||
|
let actual = RemoteEndpoint::parse(actual).map_err(|_| GitError::ForbiddenRemoteUrl)?;
|
||||||
|
let configured = RemoteEndpoint::parse(configured).map_err(|_| GitError::ForbiddenRemoteUrl)?;
|
||||||
|
Ok(actual == configured)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_remote_transport_available(endpoint: &RemoteEndpoint) -> Result<(), GitError> {
|
||||||
|
if endpoint.transport() == RemoteTransport::Ssh && !cfg!(feature = "ssh") {
|
||||||
|
return Err(GitError::UnsupportedRemoteTransport {
|
||||||
|
transport: RemoteTransport::Ssh,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Ok(parsed)
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn same_https_origin(expected: &url::Url, requested: &url::Url) -> bool {
|
fn same_https_origin(expected: &url::Url, requested: &url::Url) -> bool {
|
||||||
@@ -3485,7 +3515,8 @@ fn validate_local_config_security(config: &gix_config::File) -> Result<(), GitEr
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
if let Ok(url) = config.raw_value_by("remote", Some(name.into()), "url") {
|
if let Ok(url) = config.raw_value_by("remote", Some(name.into()), "url") {
|
||||||
validate_https_remote(url.to_str().map_err(|_| GitError::ForbiddenRemoteUrl)?)?;
|
RemoteEndpoint::parse(url.to_str().map_err(|_| GitError::ForbiddenRemoteUrl)?)
|
||||||
|
.map_err(|_| GitError::ForbiddenRemoteUrl)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -593,15 +593,7 @@ impl MobileAuthentication {
|
|||||||
drop(status);
|
drop(status);
|
||||||
let repository_title = remote.map_or_else(
|
let repository_title = remote.map_or_else(
|
||||||
|| "Local Password Store".to_owned(),
|
|| "Local Password Store".to_owned(),
|
||||||
|remote| {
|
remote_repository_title,
|
||||||
remote
|
|
||||||
.url()
|
|
||||||
.path_segments()
|
|
||||||
.and_then(Iterator::last)
|
|
||||||
.unwrap_or("Password Store")
|
|
||||||
.trim_end_matches(".git")
|
|
||||||
.to_owned()
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
Ok(MobilePreferences {
|
Ok(MobilePreferences {
|
||||||
sync_configured: remote.is_some(),
|
sync_configured: remote.is_some(),
|
||||||
@@ -611,12 +603,23 @@ impl MobileAuthentication {
|
|||||||
|remote| remote.url().to_string(),
|
|remote| remote.url().to_string(),
|
||||||
),
|
),
|
||||||
server_title: remote
|
server_title: remote
|
||||||
.and_then(|remote| remote.url().host_str())
|
.and_then(|remote| {
|
||||||
|
remote
|
||||||
|
.endpoint()
|
||||||
|
.as_https()
|
||||||
|
.and_then(url::Url::host_str)
|
||||||
|
.or_else(|| remote.endpoint().as_ssh().map(|ssh| ssh.host()))
|
||||||
|
})
|
||||||
.unwrap_or("Git Sync Not Configured")
|
.unwrap_or("Git Sync Not Configured")
|
||||||
.to_owned(),
|
.to_owned(),
|
||||||
server_identity: remote.map_or_else(
|
server_identity: remote.map_or_else(
|
||||||
|| "Add an HTTPS remote when you want to sync".to_owned(),
|
|| "Add an HTTPS remote when you want to sync".to_owned(),
|
||||||
|remote| remote.server_id().as_str().to_owned(),
|
|remote| {
|
||||||
|
remote.https_credentials().map_or_else(
|
||||||
|
|| "SSH identity and host trust".to_owned(),
|
||||||
|
|(server_id, _)| server_id.as_str().to_owned(),
|
||||||
|
)
|
||||||
|
},
|
||||||
),
|
),
|
||||||
application_account: remote.map(application_account).transpose()?.flatten(),
|
application_account: remote.map(application_account).transpose()?.flatten(),
|
||||||
default_key_title: key
|
default_key_title: key
|
||||||
@@ -673,7 +676,10 @@ impl MobileAuthentication {
|
|||||||
.ok_or_else(|| config_detail("No HTTPS Git remote is configured."))?;
|
.ok_or_else(|| config_detail("No HTTPS Git remote is configured."))?;
|
||||||
let store = preference_secret_store()?;
|
let store = preference_secret_store()?;
|
||||||
store.unlock().map_err(preference_secret_error)?;
|
store.unlock().map_err(preference_secret_error)?;
|
||||||
let result = store.delete_https_git_credential(remote.server_id(), remote.application_id());
|
let (server_id, application_id) = remote.https_credentials().ok_or_else(|| {
|
||||||
|
config_detail("The configured Git remote does not use HTTPS credentials.")
|
||||||
|
})?;
|
||||||
|
let result = store.delete_https_git_credential(server_id, application_id);
|
||||||
let lock_result = store.lock();
|
let lock_result = store.lock();
|
||||||
if !matches!(result, Ok(()) | Err(SecretStoreError::Missing)) {
|
if !matches!(result, Ok(()) | Err(SecretStoreError::Missing)) {
|
||||||
return Err(preference_secret_error(result.expect_err("checked error")));
|
return Err(preference_secret_error(result.expect_err("checked error")));
|
||||||
@@ -1558,9 +1564,12 @@ fn preference_secret_store() -> Result<NativeSecretStore, MobileAuthenticationEr
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn application_account(remote: &GitRemote) -> Result<Option<String>, MobileAuthenticationError> {
|
fn application_account(remote: &GitRemote) -> Result<Option<String>, MobileAuthenticationError> {
|
||||||
|
let Some((server_id, application_id)) = remote.https_credentials() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
let store = preference_secret_store()?;
|
let store = preference_secret_store()?;
|
||||||
store.unlock().map_err(preference_secret_error)?;
|
store.unlock().map_err(preference_secret_error)?;
|
||||||
let result = store.https_git_credential_account(remote.server_id(), remote.application_id());
|
let result = store.https_git_credential_account(server_id, application_id);
|
||||||
let lock_result = store.lock();
|
let lock_result = store.lock();
|
||||||
let account = match result {
|
let account = match result {
|
||||||
Ok(account) => Some(account),
|
Ok(account) => Some(account),
|
||||||
@@ -1572,6 +1581,26 @@ fn application_account(remote: &GitRemote) -> Result<Option<String>, MobileAuthe
|
|||||||
.map(|()| account)
|
.map(|()| account)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn remote_repository_title(remote: &GitRemote) -> String {
|
||||||
|
let path = remote.endpoint().as_https().map_or_else(
|
||||||
|
|| {
|
||||||
|
remote
|
||||||
|
.endpoint()
|
||||||
|
.as_ssh()
|
||||||
|
.map(|ssh| ssh.path().as_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
},
|
||||||
|
|url| url.path(),
|
||||||
|
);
|
||||||
|
path.trim_end_matches('/')
|
||||||
|
.rsplit('/')
|
||||||
|
.next()
|
||||||
|
.filter(|name| !name.is_empty())
|
||||||
|
.unwrap_or("Password Store")
|
||||||
|
.trim_end_matches(".git")
|
||||||
|
.to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
fn preference_secret_error(error: SecretStoreError) -> MobileAuthenticationError {
|
fn preference_secret_error(error: SecretStoreError) -> MobileAuthenticationError {
|
||||||
MobileAuthenticationError::new(
|
MobileAuthenticationError::new(
|
||||||
MobileAuthenticationErrorKind::SecureStorage,
|
MobileAuthenticationErrorKind::SecureStorage,
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ impl MobileOnboardingOperation {
|
|||||||
let actual = git
|
let actual = git
|
||||||
.remote_url(request.remote.name().as_str())
|
.remote_url(request.remote.name().as_str())
|
||||||
.map_err(MobileOnboardingError::from_git)?;
|
.map_err(MobileOnboardingError::from_git)?;
|
||||||
if actual != request.remote.url().as_str()
|
if actual != request.remote.url()
|
||||||
|| git.current_branch().ok().as_deref() != Some(branch)
|
|| git.current_branch().ok().as_deref() != Some(branch)
|
||||||
{
|
{
|
||||||
return Err(MobileOnboardingError::different_existing_clone());
|
return Err(MobileOnboardingError::different_existing_clone());
|
||||||
@@ -219,11 +219,8 @@ impl MobileOnboardingOperation {
|
|||||||
.map_err(|_| MobileOnboardingError::inaccessible_repository())?;
|
.map_err(|_| MobileOnboardingError::inaccessible_repository())?;
|
||||||
let mut git = GitRepository::open(&repository, config.git_identity().clone())
|
let mut git = GitRepository::open(&repository, config.git_identity().clone())
|
||||||
.map_err(MobileOnboardingError::from_git)?;
|
.map_err(MobileOnboardingError::from_git)?;
|
||||||
git.add_remote(
|
git.add_remote(request.remote.name().as_str(), request.remote.url())
|
||||||
request.remote.name().as_str(),
|
.map_err(MobileOnboardingError::from_git)?;
|
||||||
request.remote.url().as_str(),
|
|
||||||
)
|
|
||||||
.map_err(MobileOnboardingError::from_git)?;
|
|
||||||
if let Err(error) = config.update_mobile_remote(Some(&request.remote)) {
|
if let Err(error) = config.update_mobile_remote(Some(&request.remote)) {
|
||||||
let _ = git.remove_remote(request.remote.name().as_str());
|
let _ = git.remove_remote(request.remote.name().as_str());
|
||||||
return Err(MobileOnboardingError::from_config(error));
|
return Err(MobileOnboardingError::from_config(error));
|
||||||
@@ -481,7 +478,10 @@ impl GitCredentialProvider for MobileOnboardingRequest {
|
|||||||
server: &crate::config::ServerId,
|
server: &crate::config::ServerId,
|
||||||
application: &crate::config::ApplicationId,
|
application: &crate::config::ApplicationId,
|
||||||
) -> Result<GitCredential, GitError> {
|
) -> Result<GitCredential, GitError> {
|
||||||
if server != self.remote.server_id() || application != self.remote.application_id() {
|
let Some((expected_server, expected_application)) = self.remote.https_credentials() else {
|
||||||
|
return Err(GitError::CredentialsUnavailable);
|
||||||
|
};
|
||||||
|
if server != expected_server || application != expected_application {
|
||||||
return Err(GitError::CredentialsUnavailable);
|
return Err(GitError::CredentialsUnavailable);
|
||||||
}
|
}
|
||||||
GitCredential::new(self.account.clone(), self.token.expose().to_vec())
|
GitCredential::new(self.account.clone(), self.token.expose().to_vec())
|
||||||
@@ -985,8 +985,12 @@ fn default_key(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn store_application_token(request: &MobileOnboardingRequest) -> Result<(), MobileOnboardingError> {
|
fn store_application_token(request: &MobileOnboardingRequest) -> Result<(), MobileOnboardingError> {
|
||||||
|
let (server_id, application_id) = request
|
||||||
|
.remote
|
||||||
|
.https_credentials()
|
||||||
|
.ok_or_else(MobileOnboardingError::token_configuration)?;
|
||||||
let credential = request
|
let credential = request
|
||||||
.credential(request.remote.server_id(), request.remote.application_id())
|
.credential(server_id, application_id)
|
||||||
.map_err(MobileOnboardingError::from_git)?;
|
.map_err(MobileOnboardingError::from_git)?;
|
||||||
store_git_credential(&request.remote, &credential)
|
store_git_credential(&request.remote, &credential)
|
||||||
}
|
}
|
||||||
@@ -995,6 +999,9 @@ fn store_git_credential(
|
|||||||
remote: &GitRemote,
|
remote: &GitRemote,
|
||||||
credential: &GitCredential,
|
credential: &GitCredential,
|
||||||
) -> Result<(), MobileOnboardingError> {
|
) -> Result<(), MobileOnboardingError> {
|
||||||
|
let (server_id, application_id) = remote
|
||||||
|
.https_credentials()
|
||||||
|
.ok_or_else(MobileOnboardingError::token_configuration)?;
|
||||||
let store = NativeSecretStore::system(
|
let store = NativeSecretStore::system(
|
||||||
SecretCachePolicy::Disabled,
|
SecretCachePolicy::Disabled,
|
||||||
SecretProtectionPolicy::device_unlocked(),
|
SecretProtectionPolicy::device_unlocked(),
|
||||||
@@ -1003,8 +1010,8 @@ fn store_git_credential(
|
|||||||
store.unlock().map_err(MobileOnboardingError::from_secret)?;
|
store.unlock().map_err(MobileOnboardingError::from_secret)?;
|
||||||
store
|
store
|
||||||
.store_https_git_credential(
|
.store_https_git_credential(
|
||||||
remote.server_id(),
|
server_id,
|
||||||
remote.application_id(),
|
application_id,
|
||||||
credential.username(),
|
credential.username(),
|
||||||
SecretBytes::new(credential.password().to_vec()),
|
SecretBytes::new(credential.password().to_vec()),
|
||||||
)
|
)
|
||||||
@@ -1069,7 +1076,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.expect("valid request");
|
.expect("valid request");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
request.remote().url().as_str(),
|
request.remote().url(),
|
||||||
"https://example.test/gitea/team/passwords.git"
|
"https://example.test/gitea/team/passwords.git"
|
||||||
);
|
);
|
||||||
assert!(!format!("{request:?}").contains("DO-NOT-RENDER"));
|
assert!(!format!("{request:?}").contains("DO-NOT-RENDER"));
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ use ironstorage::{
|
|||||||
authentication::{
|
authentication::{
|
||||||
AuthenticationTimeout, DEFAULT_AUTHENTICATION_TIMEOUT, MAX_AUTHENTICATION_TIMEOUT,
|
AuthenticationTimeout, DEFAULT_AUTHENTICATION_TIMEOUT, MAX_AUTHENTICATION_TIMEOUT,
|
||||||
},
|
},
|
||||||
config::{ConfigError, ConfigLoader, EditorSource, MobileAppearance},
|
config::{
|
||||||
|
ConfigError, ConfigLoader, EditorSource, MobileAppearance, RemoteEndpoint, RemoteTransport,
|
||||||
|
SshRepositoryPath,
|
||||||
|
},
|
||||||
desktop::DesktopStorage,
|
desktop::DesktopStorage,
|
||||||
git::GitIdentity,
|
git::GitIdentity,
|
||||||
mobile::MobileTab,
|
mobile::MobileTab,
|
||||||
@@ -84,12 +87,10 @@ fn explicit_relative_configuration_resolves_deterministically() -> TestResult {
|
|||||||
assert_eq!(config.git_remotes().len(), 1);
|
assert_eq!(config.git_remotes().len(), 1);
|
||||||
let remote = &config.git_remotes()[0];
|
let remote = &config.git_remotes()[0];
|
||||||
assert_eq!(remote.name().as_str(), "origin");
|
assert_eq!(remote.name().as_str(), "origin");
|
||||||
assert_eq!(
|
assert_eq!(remote.url(), "https://git.example.test/alice/store.git");
|
||||||
remote.url().as_str(),
|
let (server_id, application_id) = remote.https_credentials().expect("HTTPS credentials");
|
||||||
"https://git.example.test/alice/store.git"
|
assert_eq!(server_id.as_str(), "personal-git");
|
||||||
);
|
assert_eq!(application_id.as_str(), "ironstorage-cli");
|
||||||
assert_eq!(remote.server_id().as_str(), "personal-git");
|
|
||||||
assert_eq!(remote.application_id().as_str(), "ironstorage-cli");
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
config.clipboard_timeout().duration(),
|
config.clipboard_timeout().duration(),
|
||||||
DEFAULT_CLIPBOARD_TIMEOUT
|
DEFAULT_CLIPBOARD_TIMEOUT
|
||||||
@@ -564,10 +565,9 @@ fn missing_and_invalid_required_fields_are_typed() -> TestResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn git_configuration_rejects_non_https_and_embedded_credentials() -> TestResult {
|
fn git_configuration_rejects_forbidden_transports_and_embedded_credentials() -> TestResult {
|
||||||
let fixture = ConfigurationFixture::new()?;
|
let fixture = ConfigurationFixture::new()?;
|
||||||
for url in [
|
for url in [
|
||||||
"ssh://git@example.test/store.git",
|
|
||||||
"git://example.test/store.git",
|
"git://example.test/store.git",
|
||||||
"file:///tmp/store.git",
|
"file:///tmp/store.git",
|
||||||
"../store.git",
|
"../store.git",
|
||||||
@@ -602,6 +602,159 @@ application_id = "application"
|
|||||||
Ok(())
|
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_without_https_credentials() -> 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"
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
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());
|
||||||
|
|
||||||
|
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"));
|
||||||
|
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]
|
#[test]
|
||||||
fn duplicate_remote_names_and_credential_references_are_rejected() -> TestResult {
|
fn duplicate_remote_names_and_credential_references_are_rejected() -> TestResult {
|
||||||
let fixture = ConfigurationFixture::new()?;
|
let fixture = ConfigurationFixture::new()?;
|
||||||
|
|||||||
@@ -137,13 +137,11 @@ fn nested_repository_selection_is_innermost() -> TestResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn remotes_and_config_are_local_https_only() -> TestResult {
|
fn remotes_and_config_reject_local_helper_and_credential_urls() -> TestResult {
|
||||||
let temporary = tempfile::tempdir()?;
|
let temporary = tempfile::tempdir()?;
|
||||||
let store = Repository::open(temporary.path())?;
|
let store = Repository::open(temporary.path())?;
|
||||||
let mut git = GitRepository::init(&store, identity())?;
|
let mut git = GitRepository::init(&store, identity())?;
|
||||||
for forbidden in [
|
for forbidden in [
|
||||||
"ssh://example.test/store.git",
|
|
||||||
"git@example.test:store.git",
|
|
||||||
"git://example.test/store.git",
|
"git://example.test/store.git",
|
||||||
"file:///tmp/store.git",
|
"file:///tmp/store.git",
|
||||||
"../store.git",
|
"../store.git",
|
||||||
@@ -172,6 +170,52 @@ fn remotes_and_config_are_local_https_only() -> TestResult {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "ssh"))]
|
||||||
|
#[test]
|
||||||
|
fn ssh_remotes_are_typed_but_unavailable_before_transport_or_mutation() -> TestResult {
|
||||||
|
let temporary = tempfile::tempdir()?;
|
||||||
|
let store = Repository::open(temporary.path())?;
|
||||||
|
let mut git = GitRepository::init(&store, identity())?;
|
||||||
|
let remote = GitRemote::ssh("origin", "git@example.test:team/store.git")?;
|
||||||
|
let unsupported = GitError::UnsupportedRemoteTransport {
|
||||||
|
transport: ironstorage::config::RemoteTransport::Ssh,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
git.add_remote(remote.name().as_str(), remote.url()),
|
||||||
|
Err(unsupported.clone())
|
||||||
|
);
|
||||||
|
assert!(git.remotes().is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
git.config_set("remote.origin.url", remote.url()),
|
||||||
|
Err(unsupported.clone())
|
||||||
|
);
|
||||||
|
assert!(git.config_get("remote.origin.url")?.is_none());
|
||||||
|
assert_eq!(
|
||||||
|
GitRepository::discover_remote_branches_with_transport(
|
||||||
|
temporary.path(),
|
||||||
|
identity(),
|
||||||
|
&remote,
|
||||||
|
&Credentials,
|
||||||
|
&CloningFetch,
|
||||||
|
&GitOperationControl::default(),
|
||||||
|
),
|
||||||
|
Err(unsupported)
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ssh")]
|
||||||
|
#[test]
|
||||||
|
fn ssh_feature_allows_repository_remote_configuration() -> TestResult {
|
||||||
|
let temporary = tempfile::tempdir()?;
|
||||||
|
let store = Repository::open(temporary.path())?;
|
||||||
|
let mut git = GitRepository::init(&store, identity())?;
|
||||||
|
git.add_remote("origin", "git@example.test:team/store.git")?;
|
||||||
|
assert_eq!(git.remote_url("origin")?, "git@example.test:team/store.git");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
struct Credentials;
|
struct Credentials;
|
||||||
|
|
||||||
impl GitCredentialProvider for Credentials {
|
impl GitCredentialProvider for Credentials {
|
||||||
@@ -347,7 +391,7 @@ fn injected_smart_http_push_sends_a_complete_pack_and_credentials() -> TestResul
|
|||||||
let remote: &GitRemote = &config.git_remotes()[0];
|
let remote: &GitRemote = &config.git_remotes()[0];
|
||||||
let store = Repository::open(config.vault())?;
|
let store = Repository::open(config.vault())?;
|
||||||
let mut git = GitRepository::init(&store, identity())?;
|
let mut git = GitRepository::init(&store, identity())?;
|
||||||
git.add_remote("origin", remote.url().as_str())?;
|
git.add_remote("origin", remote.url())?;
|
||||||
fs::write(config.vault().join("secret.gpg"), b"ciphertext")?;
|
fs::write(config.vault().join("secret.gpg"), b"ciphertext")?;
|
||||||
git.stage(&["secret.gpg".into()])?;
|
git.stage(&["secret.gpg".into()])?;
|
||||||
let head = git.commit("Add secret to store.")?;
|
let head = git.commit("Add secret to store.")?;
|
||||||
@@ -381,7 +425,7 @@ fn controlled_sync_pulls_then_pushes_with_one_progress_and_cancellation_contract
|
|||||||
let remote = &config.git_remotes()[0];
|
let remote = &config.git_remotes()[0];
|
||||||
let store = Repository::open(config.vault())?;
|
let store = Repository::open(config.vault())?;
|
||||||
let mut git = GitRepository::init(&store, identity())?;
|
let mut git = GitRepository::init(&store, identity())?;
|
||||||
git.add_remote("origin", remote.url().as_str())?;
|
git.add_remote("origin", remote.url())?;
|
||||||
let head = git.log(Some(1))?[0].id().to_owned();
|
let head = git.log(Some(1))?[0].id().to_owned();
|
||||||
set_remote_tracking(config.vault(), &head)?;
|
set_remote_tracking(config.vault(), &head)?;
|
||||||
let phases = Arc::new(Mutex::new(Vec::new()));
|
let phases = Arc::new(Mutex::new(Vec::new()));
|
||||||
@@ -409,7 +453,7 @@ fn push_propagates_authentication_and_rejects_non_fast_forward_before_upload() -
|
|||||||
let remote = &config.git_remotes()[0];
|
let remote = &config.git_remotes()[0];
|
||||||
let store = Repository::open(config.vault())?;
|
let store = Repository::open(config.vault())?;
|
||||||
let mut git = GitRepository::init(&store, identity())?;
|
let mut git = GitRepository::init(&store, identity())?;
|
||||||
git.add_remote("origin", remote.url().as_str())?;
|
git.add_remote("origin", remote.url())?;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
git.push_with_transport(remote, Some("main"), &Credentials, &AuthenticationFailure),
|
git.push_with_transport(remote, Some("main"), &Credentials, &AuthenticationFailure),
|
||||||
Err(GitError::AuthenticationFailed)
|
Err(GitError::AuthenticationFailed)
|
||||||
@@ -433,7 +477,7 @@ fn fetched_branches_fast_forward_and_report_typed_conflicts() -> TestResult {
|
|||||||
let remote = &config.git_remotes()[0];
|
let remote = &config.git_remotes()[0];
|
||||||
let store = Repository::open(config.vault())?;
|
let store = Repository::open(config.vault())?;
|
||||||
let mut git = GitRepository::init(&store, identity())?;
|
let mut git = GitRepository::init(&store, identity())?;
|
||||||
git.add_remote("origin", remote.url().as_str())?;
|
git.add_remote("origin", remote.url())?;
|
||||||
|
|
||||||
fs::write(config.vault().join("secret.gpg"), b"base")?;
|
fs::write(config.vault().join("secret.gpg"), b"base")?;
|
||||||
git.stage(&["secret.gpg".into()])?;
|
git.stage(&["secret.gpg".into()])?;
|
||||||
@@ -511,7 +555,7 @@ fn cancelled_operations_stop_before_credentials_or_transport() -> TestResult {
|
|||||||
let remote = &config.git_remotes()[0];
|
let remote = &config.git_remotes()[0];
|
||||||
let store = Repository::open(config.vault())?;
|
let store = Repository::open(config.vault())?;
|
||||||
let mut git = GitRepository::init(&store, identity())?;
|
let mut git = GitRepository::init(&store, identity())?;
|
||||||
git.add_remote("origin", remote.url().as_str())?;
|
git.add_remote("origin", remote.url())?;
|
||||||
let control = GitOperationControl::default();
|
let control = GitOperationControl::default();
|
||||||
control.cancel();
|
control.cancel();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -339,20 +339,21 @@ fn git_account_status_and_removal_never_expose_the_token() -> TestResult {
|
|||||||
"personal-git",
|
"personal-git",
|
||||||
"ironstorage-mobile",
|
"ironstorage-mobile",
|
||||||
)?;
|
)?;
|
||||||
|
let (server_id, application_id) = remote.https_credentials().expect("HTTPS credentials");
|
||||||
store.unlock()?;
|
store.unlock()?;
|
||||||
store.store_https_git_credential(
|
store.store_https_git_credential(
|
||||||
remote.server_id(),
|
server_id,
|
||||||
remote.application_id(),
|
application_id,
|
||||||
"alice",
|
"alice",
|
||||||
SecretBytes::new(b"private-token".to_vec()),
|
SecretBytes::new(b"private-token".to_vec()),
|
||||||
)?;
|
)?;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
store.https_git_credential_account(remote.server_id(), remote.application_id())?,
|
store.https_git_credential_account(server_id, application_id)?,
|
||||||
"alice"
|
"alice"
|
||||||
);
|
);
|
||||||
store.delete_https_git_credential(remote.server_id(), remote.application_id())?;
|
store.delete_https_git_credential(server_id, application_id)?;
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
store.https_git_credential_account(remote.server_id(), remote.application_id()),
|
store.https_git_credential_account(server_id, application_id),
|
||||||
Err(SecretStoreError::Missing)
|
Err(SecretStoreError::Missing)
|
||||||
));
|
));
|
||||||
assert!(!format!("{store:?}").contains("private-token"));
|
assert!(!format!("{store:?}").contains("private-token"));
|
||||||
@@ -396,28 +397,29 @@ fn one_unlocked_provider_supplies_openpgp_and_https_git_secrets() -> TestResult
|
|||||||
let config = ConfigLoader::new(temporary.path().to_owned(), temporary.path().join("native"))
|
let config = ConfigLoader::new(temporary.path().to_owned(), temporary.path().join("native"))
|
||||||
.load(Some(&temporary.path().join("config.toml")))?;
|
.load(Some(&temporary.path().join("config.toml")))?;
|
||||||
let remote = &config.git_remotes()[0];
|
let remote = &config.git_remotes()[0];
|
||||||
|
let (server_id, application_id) = remote.https_credentials().expect("HTTPS credentials");
|
||||||
backend.fail_next(SecretStoreError::Cancelled);
|
backend.fail_next(SecretStoreError::Cancelled);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
store.credential(remote.server_id(), remote.application_id()),
|
store.credential(server_id, application_id),
|
||||||
Err(GitError::CredentialCancelled)
|
Err(GitError::CredentialCancelled)
|
||||||
));
|
));
|
||||||
backend.fail_next(SecretStoreError::Denied);
|
backend.fail_next(SecretStoreError::Denied);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
store.credential(remote.server_id(), remote.application_id()),
|
store.credential(server_id, application_id),
|
||||||
Err(GitError::CredentialAccessDenied)
|
Err(GitError::CredentialAccessDenied)
|
||||||
));
|
));
|
||||||
let git = SecretReference::https_git_credential(
|
let git = SecretReference::https_git_credential(
|
||||||
remote.server_id().as_str(),
|
server_id.as_str(),
|
||||||
remote.application_id().as_str(),
|
application_id.as_str(),
|
||||||
"alice",
|
"alice",
|
||||||
)?;
|
)?;
|
||||||
store.create(&git, SecretBytes::new(b"https-token".to_vec()))?;
|
store.create(&git, SecretBytes::new(b"https-token".to_vec()))?;
|
||||||
let credential = store.credential(remote.server_id(), remote.application_id())?;
|
let credential = store.credential(server_id, application_id)?;
|
||||||
assert_eq!(credential.username(), "alice");
|
assert_eq!(credential.username(), "alice");
|
||||||
assert_eq!(credential.password(), b"https-token");
|
assert_eq!(credential.password(), b"https-token");
|
||||||
store.store_https_git_credential(
|
store.store_https_git_credential(
|
||||||
remote.server_id(),
|
server_id,
|
||||||
remote.application_id(),
|
application_id,
|
||||||
"bob",
|
"bob",
|
||||||
SecretBytes::new(b"replacement-token".to_vec()),
|
SecretBytes::new(b"replacement-token".to_vec()),
|
||||||
)?;
|
)?;
|
||||||
@@ -425,7 +427,7 @@ fn one_unlocked_provider_supplies_openpgp_and_https_git_secrets() -> TestResult
|
|||||||
store.retrieve(&git),
|
store.retrieve(&git),
|
||||||
Err(SecretStoreError::Missing)
|
Err(SecretStoreError::Missing)
|
||||||
));
|
));
|
||||||
let credential = store.credential(remote.server_id(), remote.application_id())?;
|
let credential = store.credential(server_id, application_id)?;
|
||||||
assert_eq!(credential.username(), "bob");
|
assert_eq!(credential.username(), "bob");
|
||||||
assert_eq!(credential.password(), b"replacement-token");
|
assert_eq!(credential.password(), b"replacement-token");
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -62,13 +62,38 @@ configured editor.
|
|||||||
IronStorage. Both must be present together and cannot contain line breaks or
|
IronStorage. Both must be present together and cannot contain line breaks or
|
||||||
angle brackets. The built-in IronStorage identity is used when both are absent.
|
angle brackets. The built-in IronStorage identity is used when both are absent.
|
||||||
|
|
||||||
Git remotes are HTTPS-only. URLs containing user information, passwords,
|
HTTPS remote URLs containing user information, passwords, queries, or fragments
|
||||||
queries, or fragments are rejected. `server_id` and `application_id` are opaque
|
are rejected. `server_id` and `application_id` are opaque
|
||||||
references used to retrieve credentials from the operating-system secret
|
HTTPS-only references used to retrieve credentials from the operating-system
|
||||||
store; duplicate names and duplicate reference pairs are errors.
|
secret store; duplicate names and duplicate reference pairs are errors.
|
||||||
The HTTPS account name is stored inside the protected credential record, not in
|
The HTTPS account name is stored inside the protected credential record, not in
|
||||||
TOML. OpenPGP passphrases are addressed by the resolved primary fingerprint.
|
TOML. OpenPGP passphrases are addressed by the resolved primary fingerprint.
|
||||||
|
|
||||||
|
SSH remotes use either `ssh://[user@]host[:port]/path` or scp-like
|
||||||
|
`[user@]host:path` syntax and omit the HTTPS credential fields:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[git.remotes]]
|
||||||
|
name = "origin"
|
||||||
|
url = "git@git.example.test:alice/password-store.git"
|
||||||
|
```
|
||||||
|
|
||||||
|
The typed endpoint model is always available so an SSH remote remains readable
|
||||||
|
in configuration even when the binary was built without SSH. Such a build
|
||||||
|
returns a typed unsupported-transport error before connection or repository
|
||||||
|
mutation. The optional storage `ssh` feature contains `russh` 0.63.1 and Tokio;
|
||||||
|
`russh` default features are disabled and the Ring backend plus RSA key support
|
||||||
|
are selected explicitly.
|
||||||
|
|
||||||
|
For SSH, URI paths are absolute, scp-like paths without a leading slash are
|
||||||
|
relative to the remote account, and `~`/`~user` paths retain tilde-expansion
|
||||||
|
semantics. Bracketed IPv6 and explicit URI ports are accepted. Host names are
|
||||||
|
IDNA-normalized, repository paths may contain Unicode, and usernames are
|
||||||
|
restricted to ASCII letters, digits, `.`, `_`, and `-`. Empty paths, control
|
||||||
|
bytes, credentials, queries, fragments, ambiguous unbracketed IPv6 or colon
|
||||||
|
paths, leading-option paths, local paths, URL rewrites, separate push URLs,
|
||||||
|
helper transports, and unknown schemes fail closed.
|
||||||
|
|
||||||
`clipboard_timeout_seconds` controls the native clipboard presentation lease.
|
`clipboard_timeout_seconds` controls the native clipboard presentation lease.
|
||||||
It defaults to 45 seconds for upstream `pass` compatibility and must be between
|
It defaults to 45 seconds for upstream `pass` compatibility and must be between
|
||||||
1 and 300 seconds. The CLI remains alive for the lease so Linux can serve its
|
1 and 300 seconds. The CLI remains alive for the lease so Linux can serve its
|
||||||
|
|||||||
@@ -20,13 +20,22 @@ committer. Only their affected paths are staged, unrelated index state is
|
|||||||
preserved, no-op mutations create no commit, and commit failures restore the
|
preserved, no-op mutations create no commit, and commit failures restore the
|
||||||
index so the storage transaction can roll back its files.
|
index so the storage transaction can roll back its files.
|
||||||
|
|
||||||
|
## Remote endpoint contract
|
||||||
|
|
||||||
|
Storage parses credential-free HTTPS URLs plus feature-gated `ssh://` and
|
||||||
|
scp-like SSH URLs into one typed endpoint contract. Local paths, `git://`,
|
||||||
|
`file://`, helper transports, URL rewrites, separate push URLs, embedded
|
||||||
|
credentials, and unknown schemes are rejected before transport. A build
|
||||||
|
without the `ssh` feature reports SSH as unsupported before connection or
|
||||||
|
repository mutation instead of treating its configuration as malformed.
|
||||||
|
|
||||||
## HTTPS transport
|
## HTTPS transport
|
||||||
|
|
||||||
Remote URLs must be absolute, credential-free HTTPS URLs. SSH, scp syntax,
|
HTTPS credentials are requested with the configured server ID and application
|
||||||
`git://`, `file://`, local paths, helper transports, URL rewrites, separate push
|
ID and remain outside Git configuration. The SSH session, host-verification,
|
||||||
URLs, and unknown schemes are rejected before transport. Credentials are
|
authentication, and pack-protocol implementations are separate milestone work;
|
||||||
requested with the configured server ID and application ID and remain outside
|
until those layers are present, network operations on SSH endpoints return the
|
||||||
Git configuration.
|
typed unsupported-transport result.
|
||||||
|
|
||||||
Fetch uses the embedded Rust smart-HTTP client with an explicit credential
|
Fetch uses the embedded Rust smart-HTTP client with an explicit credential
|
||||||
callback, so Git's credential cascade is never entered. Push implements the
|
callback, so Git's credential cascade is never entered. Push implements the
|
||||||
|
|||||||
Reference in New Issue
Block a user