Enable SSH remotes in native frontends (#117)

This commit is contained in:
2026-08-25 21:39:35 +02:00
parent 76e707f645
commit a737e74aae
22 changed files with 1214 additions and 140 deletions

View File

@@ -14,7 +14,7 @@ path = "src/main.rs"
[dependencies]
crossterm.workspace = true
ironstorage.workspace = true
ironstorage = { workspace = true, features = ["ssh"] }
ratatui.workspace = true
zeroize.workspace = true

View File

@@ -4,13 +4,14 @@ use std::{collections::BTreeSet, time::Instant};
use ironstorage::{
command::{
CommandRequest, OtpCodeRequest, OtpRequest, OtpUriPresentation, OtpUriRequest,
CommandRequest, GitRequest, OtpCodeRequest, OtpRequest, OtpUriPresentation, OtpUriRequest,
Presentation, help_text, otp_version_text, version_text,
},
config::Config,
config::SshFingerprint,
crypto::KeyInfo,
document::{DocumentError, EntryDocument, EntryFieldId},
git::{GitConflict, GitProgressPhase, GitSnapshot},
git::{GitConflict, GitProgressPhase, GitSnapshot, SshHostKey},
otp::OtpCodeValidity,
presentation::{ClipboardDisposition, ClipboardError, QrMatrix},
read::{FindResults, GrepResults, TreeModel},
@@ -223,6 +224,14 @@ pub enum AsyncPayload {
conflicts: Vec<GitConflict>,
details: Option<SecretBytes>,
},
GitHostConfirmation {
request: GitRequest,
host_key: SshHostKey,
},
GitPassphraseRequired {
request: GitRequest,
fingerprint: SshFingerprint,
},
OtpCodeFinished {
entry: String,
field: Option<EntryFieldId>,
@@ -301,6 +310,15 @@ pub enum AppEffect {
},
AuthenticateWorkflow(Box<WorkflowSubmission>),
AuthenticateGit(ironstorage::command::GitRequest),
RetryGitAfterHostConfirmation {
request: GitRequest,
host_key: SshHostKey,
},
RetryGitWithPassphrase {
request: GitRequest,
fingerprint: SshFingerprint,
passphrase: SecretBytes,
},
CancelGit,
ResolveGit(Vec<ironstorage::git::GitConflictResolution>),
AuthenticateOtp(OtpUiRequest),
@@ -536,6 +554,14 @@ impl App {
self.status = format!("{label} queued for secure-storage authentication…");
}
fn begin_git_retry(&mut self) {
self.git_pending = true;
self.workflow_pending = false;
self.workflow = None;
self.transition(Transition::Dismiss);
self.status = "Retrying Git operation with confirmed SSH input…".to_owned();
}
pub fn remaining_lease(&self) -> Option<std::time::Duration> {
self.remaining_lease
}
@@ -861,6 +887,26 @@ impl App {
self.focus = PaneFocus::Main;
self.status = message;
}
Ok(AsyncPayload::GitHostConfirmation { request, host_key }) => {
self.git_pending = false;
self.workflow = Some(WorkflowForm::ssh_host(request, host_key));
self.workflow_pending = false;
self.status =
"Verify the SSH host fingerprint through a trusted channel before confirming."
.to_owned();
self.transition(Transition::OpenDialog);
}
Ok(AsyncPayload::GitPassphraseRequired {
request,
fingerprint,
}) => {
self.git_pending = false;
self.workflow = Some(WorkflowForm::ssh_passphrase(request, fingerprint));
self.workflow_pending = false;
self.status =
"Enter the encrypted SSH key passphrase; input stays masked.".to_owned();
self.transition(Transition::OpenDialog);
}
Ok(AsyncPayload::OtpCodeFinished {
entry,
field,
@@ -1337,6 +1383,22 @@ impl App {
}
WorkflowInput::Lock => Some(self.dispatch(Action::Lock)),
WorkflowInput::Submit => match self.workflow.as_ref()?.submission() {
Ok(WorkflowSubmission::SshHost { request, host_key }) => {
self.begin_git_retry();
Some(AppEffect::RetryGitAfterHostConfirmation { request, host_key })
}
Ok(WorkflowSubmission::SshPassphrase {
request,
fingerprint,
passphrase,
}) => {
self.begin_git_retry();
Some(AppEffect::RetryGitWithPassphrase {
request,
fingerprint,
passphrase,
})
}
Ok(submission) => {
self.workflow_pending = true;
self.status = "Authenticating before applying the workflow…".to_owned();
@@ -2126,7 +2188,7 @@ impl App {
fn git_phase_name(phase: GitProgressPhase) -> &'static str {
match phase {
GitProgressPhase::Validating => "validating repository",
GitProgressPhase::Authenticating => "requesting credentials",
GitProgressPhase::Authenticating => "verifying remote and credentials",
GitProgressPhase::Receiving => "receiving remote objects",
GitProgressPhase::Integrating => "integrating fetched changes",
GitProgressPhase::Sending => "sending local objects",
@@ -2744,4 +2806,40 @@ mod tests {
assert!(rows.contains("••••••••"));
assert!(!rows.contains("NEVER-RENDER"));
}
#[test]
fn typed_ssh_passphrase_request_opens_a_masked_cancellable_dialog() {
let mut app = App::new();
app.git_pending = true;
let token = app.begin_request();
let fingerprint =
SshFingerprint::parse("SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
.expect("fingerprint");
assert_eq!(
app.apply_result(AsyncResult {
token,
payload: Ok(AsyncPayload::GitPassphraseRequired {
request: GitRequest::Sync {
remote: Some("origin".to_owned()),
},
fingerprint: fingerprint.clone(),
}),
}),
ResultDisposition::Applied
);
assert_eq!(app.mode(), Mode::Dialog);
assert!(!app.git_pending());
let rows = app.workflow().expect("SSH prompt").rows().join("\n");
assert!(rows.contains(fingerprint.as_str()));
assert!(!rows.contains("secret"));
app.handle_workflow_input(
crossterm::event::KeyCode::Char('Q'),
crossterm::event::KeyModifiers::NONE,
);
let rows = app.workflow().expect("SSH prompt").rows().join("\n");
assert!(rows.contains("••••••••"));
assert!(!rows.contains('Q'));
assert!(matches!(app.dispatch(Action::Cancel), AppEffect::None));
assert_eq!(app.mode(), Mode::Browser);
}
}

View File

@@ -390,6 +390,57 @@ fn apply_app_effect(
);
}
}
AppEffect::RetryGitAfterHostConfirmation { request, host_key } => {
let (Some(config), Some(handle)) = (
app.config().cloned(),
authentication
.as_ref()
.and_then(AuthenticationCoordinator::handle),
) else {
app.authentication_failed("authentication lease expired".to_owned());
return;
};
let token = app.begin_request();
let control =
ironstorage::git::GitOperationControl::new(executor.progress_reporter(token));
*git_control = Some(control.clone());
executor.submit(token, move || {
let remote = config
.git_remote(git_requested_remote(&request))
.ok_or_else(|| "the requested Git remote is not configured".to_owned())?;
ironstorage::git::confirm_ssh_host(remote, &host_key)
.map_err(|error| error.to_string())?;
execute_git(&config, request, Some(handle), None, &control)
});
}
AppEffect::RetryGitWithPassphrase {
request,
fingerprint,
passphrase,
} => {
let (Some(config), Some(handle)) = (
app.config().cloned(),
authentication
.as_ref()
.and_then(AuthenticationCoordinator::handle),
) else {
app.authentication_failed("authentication lease expired".to_owned());
return;
};
let token = app.begin_request();
let control =
ironstorage::git::GitOperationControl::new(executor.progress_reporter(token));
*git_control = Some(control.clone());
executor.submit(token, move || {
execute_git(
&config,
request,
Some(handle),
Some((fingerprint, passphrase)),
&control,
)
});
}
AppEffect::AuthenticateOtp(request) => {
if let Some(coordinator) = authentication.as_mut() {
coordinator.request_otp(request);
@@ -557,7 +608,9 @@ fn apply_app_effect(
let control =
ironstorage::git::GitOperationControl::new(executor.progress_reporter(token));
*git_control = Some(control.clone());
executor.submit(token, move || execute_git(&config, request, None, &control));
executor.submit(token, move || {
execute_git(&config, request, None, None, &control)
});
}
}
AppEffect::RunCommand(ironstorage::command::CommandRequest::Otp(
@@ -623,15 +676,72 @@ fn git_requires_authentication(request: &ironstorage::command::GitRequest) -> bo
)
}
fn git_requested_remote(request: &ironstorage::command::GitRequest) -> Option<&str> {
match request {
ironstorage::command::GitRequest::Fetch { remote }
| ironstorage::command::GitRequest::Pull { remote, .. }
| ironstorage::command::GitRequest::Push { remote, .. }
| ironstorage::command::GitRequest::Sync { remote } => remote.as_deref(),
_ => None,
}
}
fn remote_credentials<'a>(
handle: &'a ironstorage::authentication::NativeAuthenticationHandle,
supplied: Option<&'a (
ironstorage::config::SshFingerprint,
ironstorage::repository::SecretBytes,
)>,
) -> ironstorage::git::GitRemoteCredentialOverride<
'a,
ironstorage::authentication::NativeAuthenticationHandle,
> {
supplied.map_or_else(
|| ironstorage::git::GitRemoteCredentialOverride::new(handle),
|(fingerprint, passphrase)| {
ironstorage::git::GitRemoteCredentialOverride::with_ssh_passphrase(
handle,
fingerprint,
passphrase,
)
},
)
}
fn git_interaction_or_error(
request: ironstorage::command::GitRequest,
error: ironstorage::git::GitError,
) -> Result<AsyncPayload, String> {
match error {
ironstorage::git::GitError::UnknownSshHostKey { host_key } => {
Ok(AsyncPayload::GitHostConfirmation {
request,
host_key: *host_key,
})
}
ironstorage::git::GitError::SshKeyPassphraseUnavailable { fingerprint } => {
Ok(AsyncPayload::GitPassphraseRequired {
request,
fingerprint,
})
}
error => Err(error.to_string()),
}
}
fn execute_git(
config: &ironstorage::config::Config,
request: ironstorage::command::GitRequest,
mut credentials: Option<ironstorage::authentication::NativeAuthenticationHandle>,
ssh_passphrase: Option<(
ironstorage::config::SshFingerprint,
ironstorage::repository::SecretBytes,
)>,
control: &ironstorage::git::GitOperationControl,
) -> Result<AsyncPayload, String> {
use ironstorage::{
command::{GitConfigRequest, GitRemoteRequest, GitRequest},
git::{EmbeddedFetchTransport, GitError, GitIdentity, GitRepository, ReqwestGitTransport},
git::{GitError, GitIdentity, GitRepository},
repository::{Repository, SecretBytes},
};
@@ -744,20 +854,20 @@ fn execute_git(
}
},
GitRequest::Fetch { remote } => {
let retry = GitRequest::Fetch {
remote: remote.clone(),
};
let configured = config
.git_remote(remote.as_deref())
.ok_or_else(|| "the requested HTTPS Git remote is not configured".to_owned())?;
.ok_or_else(|| "the requested Git remote is not configured".to_owned())?;
let handle = credentials
.as_ref()
.ok_or_else(|| "Git fetch requires authentication".to_owned())?;
let outcome = git
.fetch_with_transport_controlled(
configured,
handle,
&EmbeddedFetchTransport,
control,
)
.map_err(|error| error.to_string())?;
let provider = remote_credentials(handle, ssh_passphrase.as_ref());
let outcome = match git.fetch_controlled(configured, &provider, control) {
Ok(outcome) => outcome,
Err(error) => return git_interaction_or_error(retry, error),
};
format!(
"Fetched {}{}",
outcome.remote(),
@@ -769,19 +879,18 @@ fn execute_git(
)
}
GitRequest::Pull { remote, branch } => {
let retry = GitRequest::Pull {
remote: remote.clone(),
branch: branch.clone(),
};
let configured = config
.git_remote(remote.as_deref())
.ok_or_else(|| "the requested HTTPS Git remote is not configured".to_owned())?;
.ok_or_else(|| "the requested Git remote is not configured".to_owned())?;
let handle = credentials
.as_ref()
.ok_or_else(|| "Git pull requires authentication".to_owned())?;
match git.pull_with_transport_controlled(
configured,
branch.as_deref(),
handle,
&EmbeddedFetchTransport,
control,
) {
let provider = remote_credentials(handle, ssh_passphrase.as_ref());
match git.pull_controlled(configured, branch.as_deref(), &provider, control) {
Ok(outcome) => {
changed_snapshot = true;
format!("Git pull completed: {outcome:?}")
@@ -801,25 +910,26 @@ fn execute_git(
details: None,
});
}
Err(error) => return Err(error.to_string()),
Err(error) => return git_interaction_or_error(retry, error),
}
}
GitRequest::Push { remote, branch } => {
let retry = GitRequest::Push {
remote: remote.clone(),
branch: branch.clone(),
};
let configured = config
.git_remote(remote.as_deref())
.ok_or_else(|| "the requested HTTPS Git remote is not configured".to_owned())?;
.ok_or_else(|| "the requested Git remote is not configured".to_owned())?;
let handle = credentials
.as_ref()
.ok_or_else(|| "Git push requires authentication".to_owned())?;
let outcome = git
.push_with_transport_controlled(
configured,
branch.as_deref(),
handle,
&ReqwestGitTransport,
control,
)
.map_err(|error| error.to_string())?;
let provider = remote_credentials(handle, ssh_passphrase.as_ref());
let outcome =
match git.push_controlled(configured, branch.as_deref(), &provider, control) {
Ok(outcome) => outcome,
Err(error) => return git_interaction_or_error(retry, error),
};
format!(
"Pushed {} {}",
outcome.remote(),
@@ -827,19 +937,17 @@ fn execute_git(
)
}
GitRequest::Sync { remote } => {
let retry = GitRequest::Sync {
remote: remote.clone(),
};
let configured = config
.git_remote(remote.as_deref())
.ok_or_else(|| "the requested HTTPS Git remote is not configured".to_owned())?;
.ok_or_else(|| "the requested Git remote is not configured".to_owned())?;
let handle = credentials
.as_ref()
.ok_or_else(|| "Git synchronization requires authentication".to_owned())?;
let pull = match git.pull_with_transport_controlled(
configured,
None,
handle,
&EmbeddedFetchTransport,
control,
) {
let provider = remote_credentials(handle, ssh_passphrase.as_ref());
let (pull, push) = match git.sync_controlled(configured, &provider, control) {
Ok(outcome) => outcome,
Err(GitError::MergeConflicts { conflicts }) => {
let snapshot = git
@@ -856,17 +964,8 @@ fn execute_git(
details: None,
});
}
Err(error) => return Err(error.to_string()),
Err(error) => return git_interaction_or_error(retry, error),
};
let push = git
.push_with_transport_controlled(
configured,
None,
handle,
&ReqwestGitTransport,
control,
)
.map_err(|error| error.to_string())?;
changed_snapshot = true;
format!(
"Git synchronization completed: {pull:?}; pushed {}",
@@ -874,6 +973,11 @@ fn execute_git(
)
}
};
if let Some((fingerprint, passphrase)) = ssh_passphrase
&& let Some(handle) = credentials.as_ref()
{
let _ = handle.persist_verified_ssh_passphrase(&fingerprint, passphrase);
}
control
.report(ironstorage::git::GitProgressPhase::Refreshing)
.map_err(|error| error.to_string())?;
@@ -906,7 +1010,7 @@ fn execute_git_resolution(
.map_err(|error| error.to_string())?;
let configured = config
.git_remote(None)
.ok_or_else(|| "no HTTPS Git remote is configured".to_owned())?;
.ok_or_else(|| "no Git remote is configured".to_owned())?;
let repository = ironstorage::repository::Repository::open(config.vault())
.map_err(|error| error.to_string())?;
let git = GitRepository::open(&repository, GitIdentity::ironstorage())
@@ -1117,7 +1221,7 @@ fn apply_authentication_event(
ironstorage::git::GitOperationControl::new(executor.progress_reporter(token));
*git_control = Some(control.clone());
executor.submit(token, move || {
execute_git(&config, request, Some(handle), &control)
execute_git(&config, request, Some(handle), None, &control)
});
}
AuthenticationEvent::Granted(AuthenticationTarget::Otp(request)) => {
@@ -1203,6 +1307,9 @@ fn execute_workflow(
let mut presentation = None;
let mut refresh_tree = true;
let (entry, mut status) = match submission {
WorkflowSubmission::SshHost { .. } | WorkflowSubmission::SshPassphrase { .. } => {
unreachable!("SSH interactions are routed before storage workflows")
}
WorkflowSubmission::Init(request) => {
let directory = request.path.as_deref().unwrap_or_default();
let mut committer =
@@ -1597,6 +1704,37 @@ mod tests {
}
}
#[test]
fn typed_ssh_authentication_errors_only_prompt_for_missing_passphrases() {
let fingerprint = ironstorage::config::SshFingerprint::parse(
"SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
)
.expect("fingerprint");
let request = ironstorage::command::GitRequest::Fetch {
remote: Some("origin".to_owned()),
};
assert!(matches!(
git_interaction_or_error(
request.clone(),
ironstorage::git::GitError::SshKeyPassphraseUnavailable {
fingerprint: fingerprint.clone(),
},
),
Ok(AsyncPayload::GitPassphraseRequired {
request: prompted,
fingerprint: prompted_fingerprint,
}) if prompted == request && prompted_fingerprint == fingerprint
));
assert_eq!(
git_interaction_or_error(
request,
ironstorage::git::GitError::SshAuthenticationRejected,
)
.expect_err("authentication rejection is not a passphrase prompt"),
"SSH public-key authentication was rejected"
);
}
#[test]
fn terminal_ownership_loss_cancels_presentations_and_forces_relock() {
let mut app = App::new();

View File

@@ -974,7 +974,8 @@ fn git_lines(view: &crate::app::GitView) -> Vec<Line<'_>> {
];
if let Some(remote) = snapshot.remote() {
lines.push(Line::raw(format!(
"remote: {} {}",
"remote: {} {} {}",
remote.transport(),
remote.name(),
remote.url()
)));

View File

@@ -5,11 +5,13 @@ use std::{fmt, num::NonZeroUsize};
use crossterm::event::{KeyCode, KeyModifiers};
use ironstorage::{
command::{
CopyRequest, GenerateRequest, GeneratedPresentation, GrepRequest, InitRequest, InsertInput,
InsertRequest, MoveRequest, OtpAppendRequest, OtpInputSource, OtpInsertRequest,
RemoveRequest,
CopyRequest, GenerateRequest, GeneratedPresentation, GitRequest, GrepRequest, InitRequest,
InsertInput, InsertRequest, MoveRequest, OtpAppendRequest, OtpInputSource,
OtpInsertRequest, RemoveRequest,
},
config::SshFingerprint,
crypto::KeyInfo,
git::SshHostKey,
kdbx::{KdbxImportMode, KdbxImportRequest},
otp::OtpInput,
repository::SecretBytes,
@@ -167,6 +169,20 @@ pub struct OtpForm {
focus: usize,
}
#[derive(Debug)]
pub struct SshHostForm {
request: GitRequest,
host_key: SshHostKey,
confirmed: bool,
}
#[derive(Debug)]
pub struct SshPassphraseForm {
request: GitRequest,
fingerprint: SshFingerprint,
passphrase: SecretText,
}
#[derive(Debug)]
pub enum WorkflowForm {
Init(InitForm),
@@ -178,6 +194,8 @@ pub enum WorkflowForm {
Move(TransferForm),
Copy(TransferForm),
Otp(OtpForm),
SshHost(SshHostForm),
SshPassphrase(SshPassphraseForm),
}
#[derive(Debug)]
@@ -217,6 +235,15 @@ pub enum WorkflowSubmission {
OtpValidate {
uri: OtpInput,
},
SshHost {
request: GitRequest,
host_key: SshHostKey,
},
SshPassphrase {
request: GitRequest,
fingerprint: SshFingerprint,
passphrase: SecretBytes,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -228,6 +255,22 @@ pub enum WorkflowInput {
}
impl WorkflowForm {
pub fn ssh_host(request: GitRequest, host_key: SshHostKey) -> Self {
Self::SshHost(SshHostForm {
request,
host_key,
confirmed: false,
})
}
pub fn ssh_passphrase(request: GitRequest, fingerprint: SshFingerprint) -> Self {
Self::SshPassphrase(SshPassphraseForm {
request,
fingerprint,
passphrase: SecretText::default(),
})
}
pub fn otp(kind: OtpFormKind, entry: Option<String>, force: bool) -> Self {
Self::Otp(OtpForm {
kind,
@@ -409,6 +452,8 @@ impl WorkflowForm {
OtpFormKind::Append => "Append OTP URI",
OtpFormKind::Validate => "Validate OTP URI",
},
Self::SshHost(_) => "Confirm SSH host key",
Self::SshPassphrase(_) => "Unlock SSH private key",
}
}
@@ -579,6 +624,28 @@ impl WorkflowForm {
),
]
}
Self::SshHost(form) => vec![
row(
false,
"Host",
&format!("{}:{}", form.host_key.host(), form.host_key.port()),
),
row(false, "Algorithm", form.host_key.algorithm()),
row(false, "Fingerprint", form.host_key.fingerprint().as_str()),
row(true, "Trust this observed key", yes_no(form.confirmed)),
],
Self::SshPassphrase(form) => vec![
row(false, "Key fingerprint", form.fingerprint.as_str()),
row(
true,
"Passphrase",
if form.passphrase.is_empty() {
"(empty)"
} else {
"••••••••"
},
),
],
}
}
@@ -789,6 +856,25 @@ impl WorkflowForm {
}
}
}
Self::SshHost(form) => {
if !form.confirmed {
return Err("Explicitly confirm the verified SSH host fingerprint".to_owned());
}
Ok(WorkflowSubmission::SshHost {
request: form.request.clone(),
host_key: form.host_key.clone(),
})
}
Self::SshPassphrase(form) => {
if form.passphrase.is_empty() {
return Err("SSH key passphrase is required".to_owned());
}
Ok(WorkflowSubmission::SshPassphrase {
request: form.request.clone(),
fingerprint: form.fingerprint.clone(),
passphrase: SecretBytes::new(form.passphrase.bytes()),
})
}
}
}
@@ -801,6 +887,7 @@ impl WorkflowForm {
Self::Kdbx(_) => 5,
Self::Remove(_) | Self::Move(_) | Self::Copy(_) => 4,
Self::Otp(_) => 4,
Self::SshHost(_) | Self::SshPassphrase(_) => 1,
}
}
@@ -815,6 +902,7 @@ impl WorkflowForm {
Self::Remove(form) => &mut form.focus,
Self::Move(form) | Self::Copy(form) => &mut form.focus,
Self::Otp(form) => &mut form.focus,
Self::SshHost(_) | Self::SshPassphrase(_) => return,
};
*focus = if forward {
(*focus + 1) % count
@@ -863,6 +951,7 @@ impl WorkflowForm {
Self::Move(form) | Self::Copy(form) if form.focus == 3 => form.overwrite ^= true,
Self::Otp(form) if form.focus == 2 => form.force ^= true,
Self::Otp(form) if form.focus == 3 => form.confirmed ^= true,
Self::SshHost(form) => form.confirmed ^= true,
_ => self.character(' '),
}
}
@@ -919,6 +1008,7 @@ impl WorkflowForm {
Self::Otp(form) if form.focus == 1 => {
form.uri.pop();
}
Self::SshPassphrase(form) => form.passphrase.pop(),
_ => {}
}
}
@@ -954,6 +1044,7 @@ impl WorkflowForm {
}
Self::Otp(form) if form.focus == 0 => form.entry.push(character),
Self::Otp(form) if form.focus == 1 && character != '\n' => form.uri.push(character),
Self::SshPassphrase(form) if character != '\n' => form.passphrase.push(character),
_ => {}
}
}
@@ -1052,6 +1143,43 @@ mod tests {
}
}
#[test]
fn ssh_passphrase_prompt_is_masked_and_returns_typed_secret_input() {
let fingerprint = ironstorage::config::SshFingerprint::parse(
"SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
)
.expect("fingerprint");
let mut form = WorkflowForm::ssh_passphrase(
ironstorage::command::GitRequest::Sync {
remote: Some("origin".to_owned()),
},
fingerprint.clone(),
);
type_text(&mut form, "terminal secret");
let rendered = format!("{:?} {:?}", form.title(), form.rows());
assert!(rendered.contains(fingerprint.as_str()));
assert!(rendered.contains("••••••••"));
assert!(!rendered.contains("terminal secret"));
let debug = format!("{form:?}");
assert!(!debug.contains("terminal secret"));
let WorkflowSubmission::SshPassphrase {
request,
fingerprint: submitted,
passphrase,
} = form.submission().expect("submission")
else {
panic!("SSH passphrase submission")
};
assert_eq!(
request,
ironstorage::command::GitRequest::Sync {
remote: Some("origin".to_owned())
}
);
assert_eq!(submitted, fingerprint);
assert_eq!(passphrase.expose(), b"terminal secret");
}
#[test]
fn hidden_entry_is_confirmed_and_never_rendered_or_debugged() {
let mut form = WorkflowForm::insert(None);