Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .alfonso/release-notes/next/consent-ended-scope-cards.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- macOS permission requests whose filing session has ended are left for Cingulate to settle, instead of AFT retrying their withdrawal at every start.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"targeted_mechanism": "none",
"kind": "harness",
"slice_class": "non_ranking",
"fixtures": [
"harness-goldens"
]
}
35 changes: 31 additions & 4 deletions crates/aft/src/subc/consent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,10 @@ pub(super) enum ConsentError {
Unavailable(String),
/// Cingulate answered with a named refusal.
Refused { code: String, message: String },
/// The daemon refused the route because the asking scope has ended
/// (`scope_not_live`, `scope_ended`). Cingulate settles the asks of an
/// ended scope itself, so nothing sent under that scope can succeed.
ScopeGone(String),
}

pub(super) type ConsentFuture = Pin<Box<dyn Future<Output = Result<Value, ConsentError>> + Send>>;
Expand Down Expand Up @@ -574,6 +578,14 @@ impl SubcConsentTransport {
message: body.message,
})
}
Err(error)
if error.route_open_refusal().is_some_and(|body| {
body.code == subc_protocol::error_codes::SCOPE_NOT_LIVE
|| body.code == subc_protocol::error_codes::SCOPE_ENDED
}) =>
{
return Err(ConsentError::ScopeGone(error.to_string()));
}
Err(error) => {
drop(gate);
self.forget(&consumer).await;
Expand Down Expand Up @@ -2934,7 +2946,7 @@ async fn hold(
/// the held call ends with that same code.
async fn failed(call: &HeldCall, prep: &Prepared, error: ConsentError) -> Hold {
match error {
ConsentError::Unavailable(detail) => {
ConsentError::Unavailable(detail) | ConsentError::ScopeGone(detail) => {
Hold::Answer(unavailable_answer(call, &prep.grant, &detail))
}
ConsentError::Refused { code, message } if code == "access_unavailable" => {
Expand Down Expand Up @@ -3541,11 +3553,12 @@ pub(super) async fn withdraw_macos_permission_cards(
let mut withdrawn = 0;
for (folder, program, scope, root, session, id) in rows {
let had_id = id.is_some();
let mut left_to_cingulate = false;
let result: Result<(), ConsentError> = async {
if let Some(id) = id {
let scope = serde_json::from_str(&scope)
.map_err(|e| ConsentError::Unavailable(format!("invalid card scope: {e}")))?;
tokio::time::timeout(
let sent = tokio::time::timeout(
UNARY_TIMEOUT,
transport.call(ConsentCall {
scope,
Expand All @@ -3559,7 +3572,21 @@ pub(super) async fn withdraw_macos_permission_cards(
.await
.map_err(|_| {
ConsentError::Unavailable("macOS permission card withdrawal timed out".into())
})??;
})?;
match sent {
Ok(_) => {}
// The session that filed the card has ended, so the card
// can never be withdrawn under its scope; Cingulate
// settles the asks of an ended scope itself. Drop the
// record instead of retrying it at every start.
Err(ConsentError::ScopeGone(detail)) => {
left_to_cingulate = true;
log::info!(
"macOS permission card for {folder} left to Cingulate: its scope ended ({detail})"
);
}
Err(error) => return Err(error),
}
}
db.lock()
.map_err(|e| ConsentError::Unavailable(e.to_string()))?
Expand All @@ -3572,7 +3599,7 @@ pub(super) async fn withdraw_macos_permission_cards(
}
.await;
match result {
Ok(()) => withdrawn += usize::from(had_id),
Ok(()) => withdrawn += usize::from(had_id && !left_to_cingulate),
Err(error) => {
log::warn!("macOS permission card withdrawal failed for {folder}: {error:?}")
}
Expand Down
76 changes: 76 additions & 0 deletions crates/aft/src/subc/consent_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6192,6 +6192,8 @@ async fn macos_permission_cards_deduplicate_and_withdraw_on_readable_probe() {
struct StartupMacosWithdrawal {
fake: Arc<FakeCingulate>,
failed_id: String,
/// A card whose filing scope has ended: the daemon refuses its route.
gone_id: Option<String>,
calls: StdMutex<Vec<Value>>,
}

Expand All @@ -6206,6 +6208,16 @@ impl ConsentTransport for StartupMacosWithdrawal {
"injected withdrawal failure".into(),
))
})
} else if self
.gone_id
.as_ref()
.is_some_and(|id| call.params["elicitation_id"] == *id)
{
Box::pin(async {
Err(ConsentError::ScopeGone(
"route.open failed: scope_not_live".into(),
))
})
} else {
SharedFake(Arc::clone(&self.fake)).call(call)
}
Expand Down Expand Up @@ -6242,6 +6254,7 @@ async fn macos_permission_startup_withdraws_ids_deletes_unpublished_and_keeps_fa
let transport = Arc::new(StartupMacosWithdrawal {
fake: Arc::clone(&f.fake),
failed_id: failed_id.clone(),
gone_id: None,
calls: StdMutex::default(),
});
install_test_transport(Some(transport.clone()));
Expand Down Expand Up @@ -6275,6 +6288,69 @@ async fn macos_permission_startup_withdraws_ids_deletes_unpublished_and_keeps_fa
);
}

/// A card filed by a session that has since ended can't be withdrawn under its
/// scope; Cingulate settles it, so startup drops the record instead of
/// retrying it at every start.
#[cfg(target_os = "macos")]
#[tokio::test]
async fn macos_permission_startup_drops_cards_whose_scope_ended() {
let _lock = CONSENT_TEST_LOCK.lock().await;
let f = fixture(FakeCingulate::up());
let db = f.ctx.db().unwrap();
let gone = f.dir.path().join("gone");
let failed = f.dir.path().join("failed");
let observations = [gone.clone(), failed.clone()]
.into_iter()
.map(|folder| crate::bounded_io::PermissionObservation {
folder,
readable: false,
})
.collect();
macos_permissions(Arc::clone(&db), f.head.clone(), observations)
.await
.unwrap();
let id_of = |folder: &std::path::Path| -> String {
db.lock()
.unwrap()
.query_row(
"SELECT elicitation_id FROM macos_permission_cards WHERE folder=?1",
[folder.to_string_lossy().as_ref()],
|row| row.get(0),
)
.unwrap()
};
let transport = Arc::new(StartupMacosWithdrawal {
fake: Arc::clone(&f.fake),
failed_id: id_of(&failed),
gone_id: Some(id_of(&gone)),
calls: StdMutex::default(),
});
install_test_transport(Some(transport.clone()));
assert_eq!(
withdraw_macos_permission_cards(Arc::clone(&db))
.await
.unwrap(),
0,
"a card left to Cingulate is not counted as withdrawn"
);
assert_eq!(transport.calls.lock().unwrap().len(), 2);
let remaining = db
.lock()
.unwrap()
.prepare("SELECT folder FROM macos_permission_cards")
.unwrap()
.query_map([], |row| row.get::<_, String>(0))
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert_eq!(
remaining,
vec![failed.to_string_lossy().into_owned()],
"the ended scope's card is dropped; an unreachable Cingulate keeps its row for the next start"
);
install_test_transport(None);
}

#[cfg(unix)]
#[tokio::test]
async fn macos_allowed_read_eperm_returns_named_denial_and_updates_one_card() {
Expand Down
Loading