use std::{future::Future, io};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use serde::{Deserialize, Serialize};
use ulid::Ulid;
use crate::{Client, RequestError, RequestProgress, protocol::Method};
const SSH_SIGNATURE_BEGIN: &str = "-----BEGIN SSH SIGNATURE-----\n";
const SSH_SIGNATURE_END: &str = "-----END SSH SIGNATURE-----";
pub struct GitSignRequest<'a> {
pub invocation_id: &'a str,
pub invocation_token: &'a [u8; 32],
pub secret: &'a str,
pub message: &'a [u8],
pub repository: Option<&'a GitSignRepository>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitSignRepository {
pub remote: Option<String>,
pub worktree: Option<String>,
pub head: Option<GitSignHead>,
pub changed_path_count: Option<usize>,
pub changed_paths: Option<Vec<GitSignChangedPath>>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum GitSignHead {
Branch {
name: String,
upstream: Option<String>,
},
Detached,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitSignChangedPath {
pub status: GitSignChangeStatus,
pub path: String,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum GitSignChangeStatus {
Added,
Deleted,
Modified,
TypeChanged,
}
impl Client {
pub async fn request_git_signature<P>(
&self,
request: GitSignRequest<'_>,
cancellation: impl Future<Output = ()>,
mut progress: P,
) -> Result<String, RequestError>
where
P: FnMut(RequestProgress),
{
progress(RequestProgress::Preparing);
request
.invocation_id
.parse::<Ulid>()
.map_err(RequestError::other)?;
if request.secret.is_empty() {
return Err(RequestError::other("signing secret name is empty"));
}
let request_id = Ulid::generate();
let payload = GitSignRequestPayload {
method: Method::GitSign,
invocation_id: request.invocation_id,
invocation_token: BASE64_STANDARD.encode(request.invocation_token),
secret: request.secret,
message: BASE64_STANDARD.encode(request.message),
repository: request.repository.map(GitSignRepositoryPayload::from),
};
self.approval_exchange(
request_id,
&payload,
cancellation,
progress,
|response: ApprovedSignature| {
response
.signature
.filter(|signature| valid_ssh_signature_envelope(signature))
.ok_or_else(|| {
io::Error::other(
"approved response doesn't contain a valid SSH signature envelope",
)
})
},
)
.await
}
}
fn valid_ssh_signature_envelope(signature: &str) -> bool {
signature.starts_with(SSH_SIGNATURE_BEGIN) && signature.trim_end().ends_with(SSH_SIGNATURE_END)
}
#[derive(Serialize)]
struct GitSignRequestPayload<'a> {
method: Method,
invocation_id: &'a str,
invocation_token: String,
secret: &'a str,
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
repository: Option<GitSignRepositoryPayload<'a>>,
}
#[derive(Serialize)]
struct GitSignRepositoryPayload<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
remote: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
worktree: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
head: Option<GitSignHeadPayload<'a>>,
#[serde(skip_serializing_if = "Option::is_none")]
changed_path_count: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
changed_paths: Option<Vec<GitSignChangedPathPayload<'a>>>,
}
impl<'a> From<&'a GitSignRepository> for GitSignRepositoryPayload<'a> {
fn from(repository: &'a GitSignRepository) -> Self {
Self {
remote: repository.remote.as_deref(),
worktree: repository.worktree.as_deref(),
head: repository.head.as_ref().map(GitSignHeadPayload::from),
changed_path_count: repository.changed_path_count,
changed_paths: repository
.changed_paths
.as_ref()
.map(|paths| paths.iter().map(GitSignChangedPathPayload::from).collect()),
}
}
}
#[derive(Serialize)]
#[serde(tag = "type", rename_all = "SCREAMING_SNAKE_CASE")]
enum GitSignHeadPayload<'a> {
Branch {
name: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
upstream: Option<&'a str>,
},
Detached,
}
impl<'a> From<&'a GitSignHead> for GitSignHeadPayload<'a> {
fn from(head: &'a GitSignHead) -> Self {
match head {
GitSignHead::Branch { name, upstream } => Self::Branch {
name,
upstream: upstream.as_deref(),
},
GitSignHead::Detached => Self::Detached,
}
}
}
#[derive(Serialize)]
struct GitSignChangedPathPayload<'a> {
status: GitSignChangeStatusPayload,
path: &'a str,
}
impl<'a> From<&'a GitSignChangedPath> for GitSignChangedPathPayload<'a> {
fn from(path: &'a GitSignChangedPath) -> Self {
Self {
status: path.status.into(),
path: &path.path,
}
}
}
#[derive(Clone, Copy, Serialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
enum GitSignChangeStatusPayload {
Added,
Deleted,
Modified,
TypeChanged,
}
impl From<GitSignChangeStatus> for GitSignChangeStatusPayload {
fn from(status: GitSignChangeStatus) -> Self {
match status {
GitSignChangeStatus::Added => Self::Added,
GitSignChangeStatus::Deleted => Self::Deleted,
GitSignChangeStatus::Modified => Self::Modified,
GitSignChangeStatus::TypeChanged => Self::TypeChanged,
}
}
}
#[derive(Deserialize)]
struct ApprovedSignature {
signature: Option<String>,
}