Fix systemd service restart
This commit is contained in:
+354
-44
@@ -86,6 +86,7 @@ pub struct Application {
|
||||
vault: Option<VaultBundle>,
|
||||
draft_unsupported_reservations: BTreeSet<IpAddr>,
|
||||
historical_apply: HistoricalApplyStatus,
|
||||
verified_saved_projection: Option<SavedPeerProjection>,
|
||||
active_restart_attempt: Option<u64>,
|
||||
next_restart_attempt: u64,
|
||||
next_issue_authorization: u64,
|
||||
@@ -245,7 +246,7 @@ pub struct RestartPreview {
|
||||
unit: String,
|
||||
timeout: Duration,
|
||||
projection: SavedPeerProjection,
|
||||
expected_clients: Snapshot,
|
||||
expected_clients: Option<Snapshot>,
|
||||
has_draft: bool,
|
||||
}
|
||||
|
||||
@@ -301,6 +302,15 @@ pub enum RestartFailureKind {
|
||||
VerificationPersistenceFailed,
|
||||
}
|
||||
|
||||
/// Secret-free explanation for saved configuration that cannot be used as a
|
||||
/// restart verification target. The identity is a public WireGuard key.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct RestartDiagnostic {
|
||||
pub identity: Option<PublicKey>,
|
||||
pub section_line: Option<usize>,
|
||||
pub reason: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct RestartReport {
|
||||
pub stage: RestartStage,
|
||||
@@ -523,6 +533,7 @@ impl Application {
|
||||
vault,
|
||||
draft_unsupported_reservations: BTreeSet::new(),
|
||||
historical_apply,
|
||||
verified_saved_projection: None,
|
||||
active_restart_attempt: None,
|
||||
next_restart_attempt: 1,
|
||||
next_issue_authorization: 1,
|
||||
@@ -964,15 +975,14 @@ impl Application {
|
||||
installed: &SavedBytes,
|
||||
document: &ConfigDocument,
|
||||
) -> bool {
|
||||
if !same_saved_bytes(&self.saved, installed) {
|
||||
if !same_saved_bytes(&self.saved, installed) || self.verified_saved_projection.is_none() {
|
||||
return false;
|
||||
}
|
||||
let Ok(snapshot) = checked_snapshot_from_document(document) else {
|
||||
let Ok((projection, _)) = restart_projection_from_document(document) else {
|
||||
return false;
|
||||
};
|
||||
self.states.applied() == &RuntimeObservation::Verified
|
||||
&& self.states.saved() == &snapshot
|
||||
&& self.states.applied_snapshot() == Some(&snapshot)
|
||||
&& self.verified_saved_projection.as_ref() == Some(&projection)
|
||||
}
|
||||
|
||||
/// Recheck both the one-use token and installed saved generation as the
|
||||
@@ -1339,7 +1349,11 @@ impl Application {
|
||||
/// Drop all uncommitted edits and restore the in-memory drafts from the last
|
||||
/// successfully installed session snapshot. No files are written.
|
||||
pub fn discard_draft(&mut self) -> Result<(), AppError> {
|
||||
self.saved = self.store.read_saved()?;
|
||||
let installed = self.store.read_saved()?;
|
||||
if !same_saved_bytes(&self.saved, &installed) {
|
||||
self.verified_saved_projection = None;
|
||||
}
|
||||
self.saved = installed;
|
||||
self.document = ConfigDocument::parse(&self.saved.server_config)?;
|
||||
self.vault = self
|
||||
.saved
|
||||
@@ -1422,8 +1436,9 @@ impl Application {
|
||||
if document.has_unsafe_save_config() {
|
||||
return Err(AppError::UnsafeSaveConfig);
|
||||
}
|
||||
let projection = SavedPeerProjection::from_document(&document);
|
||||
let expected_clients = checked_snapshot_from_document(&document)?;
|
||||
let (projection, expected_clients) =
|
||||
restart_projection_from_document(&document).map_err(AppError::UnsafeSavedPeers)?;
|
||||
let expected_clients = expected_clients.filter(|snapshot| snapshot == self.states.saved());
|
||||
Ok(RestartPreview {
|
||||
saved: clone_saved(&installed),
|
||||
interface: settings.interface_name,
|
||||
@@ -1606,8 +1621,8 @@ impl Application {
|
||||
false,
|
||||
);
|
||||
}
|
||||
let expected_clients = match checked_snapshot_from_document(&document) {
|
||||
Ok(snapshot) => snapshot,
|
||||
let (projection, expected_clients) = match restart_projection_from_document(&document) {
|
||||
Ok(projection) => projection,
|
||||
Err(_) => {
|
||||
return restart_report(
|
||||
RestartStage::Preparation,
|
||||
@@ -1619,7 +1634,7 @@ impl Application {
|
||||
);
|
||||
}
|
||||
};
|
||||
let projection = SavedPeerProjection::from_document(&document);
|
||||
let expected_clients = expected_clients.filter(|snapshot| snapshot == self.states.saved());
|
||||
if settings.interface_name != preview.interface
|
||||
|| settings.systemd_unit != preview.unit
|
||||
|| settings.restart_timeout != preview.timeout
|
||||
@@ -1635,20 +1650,6 @@ impl Application {
|
||||
false,
|
||||
);
|
||||
}
|
||||
if projection.document_unverifiable
|
||||
|| !projection.unverifiable_peer_indices.is_empty()
|
||||
|| !projection.duplicate_public_keys.is_empty()
|
||||
{
|
||||
return restart_report(
|
||||
RestartStage::Preparation,
|
||||
Some(RestartFailureKind::UnsupportedSavedShape),
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
let historical_apply = match historical_apply_from_saved(&installed) {
|
||||
Ok(status) => status,
|
||||
Err(_) => {
|
||||
@@ -1947,6 +1948,7 @@ impl Application {
|
||||
self.initial_issues.clear();
|
||||
self.draft_unsupported_reservations.clear();
|
||||
self.historical_apply = HistoricalApplyStatus::Unknown;
|
||||
self.verified_saved_projection = None;
|
||||
self.active_restart_attempt = None;
|
||||
Ok(archive)
|
||||
}
|
||||
@@ -1956,7 +1958,7 @@ impl Application {
|
||||
fn mark_verified_with_attempt(
|
||||
&mut self,
|
||||
attempt: RestartAttempt,
|
||||
observed: Snapshot,
|
||||
observed_management_snapshot: Option<Snapshot>,
|
||||
verified_at_utc: String,
|
||||
install: impl FnOnce(&SavedStore, &SavedBytes, BackupComplete) -> Result<(), PersistenceError>,
|
||||
) -> Result<(), AppError> {
|
||||
@@ -1970,7 +1972,7 @@ impl Application {
|
||||
return Err(AppError::SavedGenerationChanged);
|
||||
}
|
||||
let mut next_states = self.states.clone();
|
||||
next_states.mark_applied_after_verification(observed)?;
|
||||
next_states.mark_technically_applied_after_verification(observed_management_snapshot)?;
|
||||
let mut persisted = match &installed.internal_state {
|
||||
Some(bytes) => PersistedState::from_toml(bytes)?,
|
||||
None => PersistedState::new(
|
||||
@@ -2019,6 +2021,7 @@ impl Application {
|
||||
}
|
||||
self.saved = proposed;
|
||||
self.states = next_states;
|
||||
self.verified_saved_projection = Some(SavedPeerProjection::from_document(&self.document));
|
||||
self.historical_apply = historical_apply;
|
||||
self.active_restart_attempt = None;
|
||||
Ok(())
|
||||
@@ -2131,6 +2134,7 @@ impl Application {
|
||||
self.vault.as_ref(),
|
||||
);
|
||||
self.historical_apply = HistoricalApplyStatus::Unknown;
|
||||
self.verified_saved_projection = None;
|
||||
self.active_restart_attempt = None;
|
||||
Ok(archive)
|
||||
}
|
||||
@@ -2304,20 +2308,96 @@ fn document_host_addresses(document: &ConfigDocument) -> Vec<IpAddr> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn checked_snapshot_from_document(document: &ConfigDocument) -> Result<Snapshot, AppError> {
|
||||
if !document.issues().is_empty() {
|
||||
return Err(AppError::UnsupportedSavedShape);
|
||||
fn restart_projection_from_document(
|
||||
document: &ConfigDocument,
|
||||
) -> Result<(SavedPeerProjection, Option<Snapshot>), Vec<RestartDiagnostic>> {
|
||||
use crate::wireguard::{DocumentIssue, PeerIssue};
|
||||
|
||||
let mut diagnostics = Vec::new();
|
||||
for issue in document.issues() {
|
||||
let (line, reason) = match issue {
|
||||
DocumentIssue::MalformedSectionHeader { line } => (*line, "malformed section header"),
|
||||
DocumentIssue::DirectiveOutsideSection { line } => {
|
||||
(*line, "directive appears outside a section")
|
||||
}
|
||||
DocumentIssue::MalformedDirective { line } => (*line, "malformed directive"),
|
||||
// Managed metadata is descriptive and does not affect the technical
|
||||
// peer set used for runtime verification.
|
||||
DocumentIssue::ManagedMetadata(_) => continue,
|
||||
};
|
||||
diagnostics.push(RestartDiagnostic {
|
||||
identity: None,
|
||||
section_line: Some(line),
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
for peer in document.peers() {
|
||||
let identity = peer
|
||||
.identity
|
||||
.or_else(|| peer.public_key_candidates.first().copied());
|
||||
for issue in &peer.issues {
|
||||
let reason = match issue {
|
||||
PeerIssue::MissingPublicKey => Some("peer is missing a public key"),
|
||||
PeerIssue::MalformedPublicKey { .. } => Some("peer has a malformed public key"),
|
||||
PeerIssue::DuplicatePublicKeyField { .. } => {
|
||||
Some("peer has duplicate public key fields")
|
||||
}
|
||||
PeerIssue::DuplicatePeerIdentity { .. } => {
|
||||
Some("public key is used by multiple peer sections")
|
||||
}
|
||||
PeerIssue::MalformedAllowedIp { .. } => {
|
||||
Some("peer has a malformed AllowedIPs item")
|
||||
}
|
||||
PeerIssue::UnsupportedAllowedIpShape { .. } | PeerIssue::MissingIpv4HostRoute => {
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(reason) = reason {
|
||||
diagnostics.push(RestartDiagnostic {
|
||||
identity,
|
||||
section_line: Some(peer.section_line),
|
||||
reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let projection = SavedPeerProjection::from_document(document);
|
||||
if (projection.document_unverifiable
|
||||
|| !projection.unverifiable_peer_indices.is_empty()
|
||||
|| !projection.duplicate_public_keys.is_empty())
|
||||
&& diagnostics.is_empty()
|
||||
{
|
||||
diagnostics.push(RestartDiagnostic {
|
||||
identity: None,
|
||||
section_line: None,
|
||||
reason: "saved peer set is not uniquely verifiable",
|
||||
});
|
||||
}
|
||||
if !diagnostics.is_empty() {
|
||||
return Err(diagnostics);
|
||||
}
|
||||
|
||||
let mut clients = Vec::with_capacity(document.peers().len());
|
||||
for peer in document.peers() {
|
||||
let identity = peer.identity.ok_or(AppError::UnsupportedSavedShape)?;
|
||||
clients.push(
|
||||
document
|
||||
.project_client(&identity)
|
||||
.map_err(|_| AppError::UnsupportedSavedShape)?,
|
||||
);
|
||||
let Some(identity) = peer.identity else {
|
||||
return Err(vec![RestartDiagnostic {
|
||||
identity: None,
|
||||
section_line: Some(peer.section_line),
|
||||
reason: "peer identity is unavailable",
|
||||
}]);
|
||||
};
|
||||
let Ok(client) = document.project_client(&identity) else {
|
||||
clients.clear();
|
||||
break;
|
||||
};
|
||||
clients.push(client);
|
||||
}
|
||||
Snapshot::new(clients).map_err(|_| AppError::UnsupportedSavedShape)
|
||||
let management_snapshot = (clients.len() == document.peers().len())
|
||||
.then(|| Snapshot::new(clients).ok())
|
||||
.flatten();
|
||||
Ok((projection, management_snapshot))
|
||||
}
|
||||
|
||||
fn restart_report(
|
||||
@@ -2375,10 +2455,10 @@ pub enum AppError {
|
||||
PreRestartBackupRequired,
|
||||
#[error("wg-quick SaveConfig may overwrite the saved server configuration during restart")]
|
||||
UnsafeSaveConfig,
|
||||
#[error(
|
||||
"the complete saved peer set cannot be represented for durable applied-state recording"
|
||||
)]
|
||||
#[error("saved peer cannot be represented by the client management model")]
|
||||
UnsupportedSavedShape,
|
||||
#[error("saved server configuration cannot be verified safely: {0:?}")]
|
||||
UnsafeSavedPeers(Vec<RestartDiagnostic>),
|
||||
#[error("installed saved bytes changed after this session opened")]
|
||||
SavedGenerationChanged,
|
||||
#[error(transparent)]
|
||||
@@ -2515,9 +2595,12 @@ restart_timeout_seconds = 30
|
||||
id,
|
||||
saved: clone_saved(&app.saved),
|
||||
};
|
||||
app.mark_verified_with_attempt(attempt, observed, verified_at, |store, saved, backup| {
|
||||
store.install(saved, backup)
|
||||
})
|
||||
app.mark_verified_with_attempt(
|
||||
attempt,
|
||||
Some(observed),
|
||||
verified_at,
|
||||
|store, saved, backup| store.install(saved, backup),
|
||||
)
|
||||
}
|
||||
|
||||
fn prepare_restartable(root: &Path) -> (Application, PublicKey) {
|
||||
@@ -4191,6 +4274,233 @@ restart_timeout_seconds = 30
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_verifies_metadata_free_legacy_routes_without_partial_applied_snapshot() {
|
||||
let root = tempfile::tempdir().expect("temp directory");
|
||||
let (mut app, _) = prepare_restartable(root.path());
|
||||
let deleted_key = WireGuardPrivateKey::generate()
|
||||
.expect("synthetic deleted-client key")
|
||||
.public_key();
|
||||
app.add_client(
|
||||
Client::new(
|
||||
deleted_key,
|
||||
"deleted reservation fixture",
|
||||
ClientAddresses {
|
||||
ipv4: "10.20.0.4".parse().expect("synthetic IPv4"),
|
||||
ipv6: Some("fd20::4".parse().expect("synthetic IPv6")),
|
||||
},
|
||||
)
|
||||
.expect("synthetic managed client"),
|
||||
None,
|
||||
)
|
||||
.expect("stage managed client");
|
||||
app.commit().expect("save second managed client");
|
||||
app.delete_client(deleted_key)
|
||||
.expect("delete managed peer in draft");
|
||||
app.commit()
|
||||
.expect("save deletion and reserve its addresses");
|
||||
let mut saved = clone_saved(app.saved_bytes());
|
||||
saved.server_config.extend_from_slice(
|
||||
format!(
|
||||
"\n[Peer]\nPublicKey = {}\nAllowedIPs = 192.0.2.0/24\n\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.20.0.99/32\n",
|
||||
PublicKey::from_bytes([19; 32]),
|
||||
PublicKey::from_bytes([20; 32]),
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
app.store
|
||||
.install(&saved, BackupComplete(()))
|
||||
.expect("install synthetic legacy saved generation");
|
||||
let mut app = Application::open_from_saved_store(app.store.clone())
|
||||
.expect("open metadata-free legacy peers");
|
||||
assert!(!app.states().reserved_addresses().is_empty());
|
||||
let original_config = app.saved_bytes().server_config.clone();
|
||||
let preview = app
|
||||
.preview_restart()
|
||||
.expect("valid legacy peers are eligible");
|
||||
let observation = observed_runtime(&app);
|
||||
let report = app.restart_with_adapters(
|
||||
preview,
|
||||
RestartChoice::RestartSavedState,
|
||||
|unit, _| {
|
||||
assert_eq!(unit, "wg-quick@wg0.service");
|
||||
RestartExecution {
|
||||
outcome: RestartOutcome::Succeeded,
|
||||
submitted: true,
|
||||
}
|
||||
},
|
||||
|_, _| Ok(observation),
|
||||
|store, saved, backup| store.install(saved, backup),
|
||||
);
|
||||
|
||||
assert_eq!(report.stage, RestartStage::Complete);
|
||||
assert_eq!(app.states().applied(), &RuntimeObservation::Verified);
|
||||
assert!(app.states().applied_snapshot().is_none());
|
||||
assert!(app.states().reserved_addresses().is_empty());
|
||||
assert_eq!(app.saved_bytes().server_config, original_config);
|
||||
assert!(app.saved_generation_is_verified_applied(app.saved_bytes(), app.document()));
|
||||
let mut different_generation = clone_saved(app.saved_bytes());
|
||||
different_generation.settings.push(b'\n');
|
||||
assert!(!app.saved_generation_is_verified_applied(&different_generation, app.document()));
|
||||
let historical_apply = app.historical_apply.clone();
|
||||
let reopened = Application::open_from_saved_store(app.store.clone())
|
||||
.expect("reopen historically verified saved state");
|
||||
assert_eq!(reopened.historical_apply, historical_apply);
|
||||
assert_eq!(reopened.states().applied(), &RuntimeObservation::Unknown);
|
||||
assert!(reopened.states().applied_snapshot().is_none());
|
||||
assert!(reopened.states().reserved_addresses().is_empty());
|
||||
assert!(
|
||||
!reopened
|
||||
.saved_generation_is_verified_applied(reopened.saved_bytes(), reopened.document())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsafe_saved_peer_diagnostics_block_preview_before_backup_with_safe_location() {
|
||||
let root = tempfile::tempdir().expect("temp directory");
|
||||
let (app, _) = prepare_restartable(root.path());
|
||||
let key = PublicKey::from_bytes([21; 32]);
|
||||
let mut saved = clone_saved(app.saved_bytes());
|
||||
saved.server_config.extend_from_slice(
|
||||
format!(
|
||||
"\n[Peer]\nPublicKey = {key}\nPresharedKey = synthetic-peer-secret\nAllowedIPs = malformed-route\n"
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
app.store
|
||||
.install(&saved, BackupComplete(()))
|
||||
.expect("install malformed synthetic peer");
|
||||
let app = Application::open_from_saved_store(app.store.clone())
|
||||
.expect("open malformed peer for diagnostics");
|
||||
let backup_count = app.list_backups().expect("list existing archives").len();
|
||||
|
||||
let error = match app.preview_restart() {
|
||||
Ok(_) => panic!("unsafe peer must block preview"),
|
||||
Err(error) => error,
|
||||
};
|
||||
let AppError::UnsafeSavedPeers(diagnostics) = error else {
|
||||
panic!("expected actionable unsafe peer diagnostics");
|
||||
};
|
||||
assert!(diagnostics.iter().any(|diagnostic| {
|
||||
diagnostic.identity == Some(key)
|
||||
&& diagnostic.section_line.is_some()
|
||||
&& diagnostic.reason == "peer has a malformed AllowedIPs item"
|
||||
}));
|
||||
let rendered = format!("{diagnostics:?}");
|
||||
assert!(!rendered.contains("synthetic-peer-secret"));
|
||||
assert!(!rendered.contains("malformed-route"));
|
||||
assert_eq!(
|
||||
app.list_backups().expect("backup listing").len(),
|
||||
backup_count,
|
||||
"unsafe preview must not create a restart archive"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_legacy_runtime_match_keeps_deleted_addresses_reserved() {
|
||||
let root = tempfile::tempdir().expect("temp directory");
|
||||
let (mut app, _) = prepare_restartable(root.path());
|
||||
let deleted_key = WireGuardPrivateKey::generate()
|
||||
.expect("synthetic deleted-client key")
|
||||
.public_key();
|
||||
app.add_client(
|
||||
Client::new(
|
||||
deleted_key,
|
||||
"deleted reservation fixture",
|
||||
ClientAddresses {
|
||||
ipv4: "10.20.0.4".parse().expect("synthetic IPv4"),
|
||||
ipv6: Some("fd20::4".parse().expect("synthetic IPv6")),
|
||||
},
|
||||
)
|
||||
.expect("synthetic managed client"),
|
||||
None,
|
||||
)
|
||||
.expect("stage managed client");
|
||||
app.commit().expect("save second managed client");
|
||||
app.delete_client(deleted_key)
|
||||
.expect("delete managed peer in draft");
|
||||
app.commit()
|
||||
.expect("save deletion and reserve its addresses");
|
||||
let reserved_before = app.states().reserved_addresses().clone();
|
||||
let mut saved = clone_saved(app.saved_bytes());
|
||||
let legacy_key = PublicKey::from_bytes([22; 32]);
|
||||
saved.server_config.extend_from_slice(
|
||||
format!("\n[Peer]\nPublicKey = {legacy_key}\nAllowedIPs = 10.20.0.99/32\n").as_bytes(),
|
||||
);
|
||||
app.store
|
||||
.install(&saved, BackupComplete(()))
|
||||
.expect("install synthetic legacy peer");
|
||||
let mut app =
|
||||
Application::open_from_saved_store(app.store.clone()).expect("reopen with legacy peer");
|
||||
let preview = app.preview_restart().expect("legacy peer preview succeeds");
|
||||
let mut incomplete = observed_runtime(&app);
|
||||
let InterfaceObservation::Present { peers, .. } = &mut incomplete.interface else {
|
||||
panic!("fixture interface is present");
|
||||
};
|
||||
peers.peers.remove(&legacy_key);
|
||||
|
||||
let report = app.restart_with_adapters(
|
||||
preview,
|
||||
RestartChoice::RestartSavedState,
|
||||
|_, _| RestartExecution {
|
||||
outcome: RestartOutcome::Succeeded,
|
||||
submitted: true,
|
||||
},
|
||||
|_, _| Ok(incomplete),
|
||||
|store, saved, backup| store.install(saved, backup),
|
||||
);
|
||||
|
||||
assert_eq!(report.stage, RestartStage::Verification);
|
||||
assert_eq!(report.failure, Some(RestartFailureKind::RuntimeMismatch));
|
||||
assert_eq!(app.states().applied(), &RuntimeObservation::Unknown);
|
||||
assert_eq!(app.states().reserved_addresses(), &reserved_before);
|
||||
let reopened = Application::open_from_saved_store(app.store.clone())
|
||||
.expect("failed verification leaves saved reservations durable");
|
||||
assert_eq!(reopened.states().reserved_addresses(), &reserved_before);
|
||||
assert_eq!(reopened.states().applied(), &RuntimeObservation::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambiguous_identity_and_malformed_structure_block_restart_preview() {
|
||||
for malformed in [false, true] {
|
||||
let root = tempfile::tempdir().expect("temp directory");
|
||||
let (app, managed_identity) = prepare_restartable(root.path());
|
||||
let mut saved = clone_saved(app.saved_bytes());
|
||||
if malformed {
|
||||
saved.server_config.extend_from_slice(
|
||||
b"\n[Peer\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n",
|
||||
);
|
||||
} else {
|
||||
saved.server_config.extend_from_slice(
|
||||
format!(
|
||||
"\n[Peer]\nPublicKey = {managed_identity}\nAllowedIPs = 10.20.0.8/32\n"
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
}
|
||||
app.store
|
||||
.install(&saved, BackupComplete(()))
|
||||
.expect("install unsafe synthetic saved bytes");
|
||||
let app = Application::open_from_saved_store(app.store.clone())
|
||||
.expect("open unsafe saved bytes for preview");
|
||||
let error = match app.preview_restart() {
|
||||
Ok(_) => panic!("unsafe saved bytes must block restart preview"),
|
||||
Err(error) => error,
|
||||
};
|
||||
let AppError::UnsafeSavedPeers(diagnostics) = error else {
|
||||
panic!("expected safe structured diagnostics");
|
||||
};
|
||||
let expected_reason = if malformed {
|
||||
"malformed section header"
|
||||
} else {
|
||||
"public key is used by multiple peer sections"
|
||||
};
|
||||
assert!(diagnostics.iter().any(|diagnostic| {
|
||||
diagnostic.reason == expected_reason && diagnostic.section_line.is_some()
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_timeout_reports_submission_and_may_still_complete() {
|
||||
let root = tempfile::tempdir().expect("temp directory");
|
||||
@@ -4553,7 +4863,7 @@ restart_timeout_seconds = 30
|
||||
};
|
||||
let result = app.mark_verified_with_attempt(
|
||||
attempt,
|
||||
observed,
|
||||
Some(observed),
|
||||
"2026-10-20T08:30:00Z".to_owned(),
|
||||
|store, saved, _backup| {
|
||||
store.install_with_hook(saved, |point| {
|
||||
|
||||
Reference in New Issue
Block a user