Skip to content
Merged
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
75 changes: 69 additions & 6 deletions openvtc-core/src/persona/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,17 +143,47 @@ impl ResolvedClaim {
/// minus the "hidden" case: a resolve was asked for, so an absent value is
/// an answer rather than a question that was never put.
///
/// There is no `revealed_value` counterpart here, and that is a decision
/// rather than an omission. A resolved claim has no identity of its own to
/// reveal *one* of — a face is read as a whole — so the only reveal this
/// type could offer is the blanket one the mask exists to avoid. A holder
/// who wants to check a value reads it among their attributes, one at a time.
/// See [`revealed_value`](Self::revealed_value) for lifting the mask on one
/// claim.
#[must_use]
pub fn display_value(&self) -> String {
self.value_line(false)
}

/// The same line with the mask lifted, for a holder who asked for this one
/// claim.
///
/// This used to be deliberately absent, on the argument that a resolved
/// claim has no identity of its own to reveal *one* of — a face was read as
/// a whole, so the only reveal the type could offer was the blanket one the
/// mask exists to avoid. That argument was about the **pane**, not the
/// type: it held only for as long as the face view had no cursor over its
/// claims. It has one now, so "the selected claim" is a thing a holder can
/// name, and the one-at-a-time reveal that the attributes tab has always
/// offered works here on the same terms.
///
/// Still a separate method rather than a `reveal: bool` on
/// [`display_value`](Self::display_value), for the reason
/// [`PoolAttribute::revealed_value`](crate::persona::pool::PoolAttribute::revealed_value)
/// gives: reading a masked value in the clear should be something a call
/// site had to *name*. A boolean gets passed through, and the caller that
/// ends up passing `true` is rarely the one that meant to.
#[must_use]
pub fn revealed_value(&self) -> String {
self.value_line(true)
}

fn value_line(&self, reveal: bool) -> String {
if self.stale {
return "stale — can no longer be proven".to_string();
}
let shown = |text: String| claim_types::resolve(&self.claim_type).render(&text);
let shown = |text: String| {
if reveal {
text
} else {
claim_types::resolve(&self.claim_type).render(&text)
}
};
match &self.value {
Some(Value::String(s)) => shown(s.clone()),
Some(other) => shown(other.to_string()),
Expand Down Expand Up @@ -448,6 +478,39 @@ mod tests {
assert!(claim.display_value().contains("can no longer be proven"));
}

/// A masked claim reads back whole when a caller asks for that one claim.
///
/// The pairing is the point, and it is the same one the pool makes: the
/// mask has to be liftable, or a holder cannot check what a community
/// actually sees; and lifting it has to be a different call, or it is not a
/// decision anyone made.
#[test]
fn a_masked_claim_is_only_whole_when_it_is_asked_for() {
let claim = ResolvedClaim::from_wire(&serde_json::json!({
"type": "phone.mobile",
"value": "+61400123456",
"valueType": "string",
"provenance": { "kind": "selfAsserted" },
}));
assert_eq!(claim.display_value(), "••••••••••56");
assert_eq!(claim.revealed_value(), "+61400123456");
}

/// A stale claim says it is stale under a reveal too.
///
/// The reason it cannot be shown is not that it is masked, and a reveal
/// that turned the explanation into a blank would hide the one thing the
/// holder needs to act on.
#[test]
fn a_stale_claim_still_says_it_is_stale_when_revealed() {
let claim = ResolvedClaim::from_wire(&serde_json::json!({
"type": "phone.mobile",
"value": "+61400123456",
"stale": true,
}));
assert!(claim.revealed_value().contains("can no longer be proven"));
}

/// A face masks what its type says to mask, and says that it did.
///
/// The face detail view is a screen a holder opens to check what a
Expand Down
11 changes: 11 additions & 0 deletions openvtc/src/state_handler/actions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,17 @@ pub enum PersonaAction {
ProfileOpen(usize),
/// Close that view.
ProfileClose,
/// Move the cursor within the opened face's claims.
///
/// Distinct from [`Select`](Self::Select) because the detail view is a mode
/// of the profiles tab with a cursor of its own: `Select` moves the face
/// list behind it, which still has to be where closing the detail lands.
FaceClaimSelect(usize),
/// Show the selected claim of the opened face unmasked, or stop showing it.
///
/// The face-view counterpart to [`RevealValue`](Self::RevealValue), and one
/// claim rather than a mode for the same reason.
RevealFaceClaim(usize),
ProfileNew,
ProfileEdit(usize),
ProfileDeleteArm(usize),
Expand Down
16 changes: 16 additions & 0 deletions openvtc/src/state_handler/main_page/content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -764,6 +764,22 @@ pub struct IdentityState {
pub profile_selected: usize,
/// The profile opened with Enter, resolved to what it would present.
pub open_profile: Option<openvtc_core::persona::profile::ProfileDetail>,
/// The claim under the cursor inside that opened face.
///
/// Separate from [`profile_selected`](Self::profile_selected), which keeps
/// pointing at the face in the list behind the detail — closing the detail
/// has to land back on the face that was opened, so the two cursors cannot
/// share a field.
pub face_claim_selected: usize,
/// The one claim of the opened face being shown unmasked, by index.
///
/// The same grant the attributes tab makes, on the same terms: one claim,
/// and only while it is also the selected one — the render checks both.
/// Indices rather than identifiers because a resolved claim has no id of
/// its own to key on (an inline value has no pool attribute behind it), and
/// the order is fixed for as long as a detail is open: every path that
/// replaces `open_profile` clears this too.
pub revealed_face_claim: Option<usize>,

// ── Disclosures (from the agent) ─────────────────────────────────────
/// What has actually left, newest first, across every context.
Expand Down
173 changes: 171 additions & 2 deletions openvtc/src/state_handler/persona_actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,10 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect
p.open_profile = None;
p.status_message = None;
// A reveal is granted to one row on one tab. Coming back to the
// attributes should find them masked again, not still open.
// attributes — or to a face — should find them masked again, not
// still open.
p.revealed_attribute = None;
p.revealed_face_claim = None;
// Read on arrival, once. The agent-served tabs are not polled: a
// pane nobody has opened should not be asking the agent about the
// holder's identity every few seconds.
Expand Down Expand Up @@ -235,7 +237,38 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect
})
}
PersonaAction::ProfileClose => {
state.main_page.content_panel.identity.open_profile = None;
let p = &mut state.main_page.content_panel.identity;
p.open_profile = None;
p.revealed_face_claim = None;
PersonaEffect::None
}
PersonaAction::FaceClaimSelect(index) => {
let p = &mut state.main_page.content_panel.identity;
// Same rule as the attributes tab: the selection moved, so the
// reveal it was granted for is over. Carrying it down the list is
// how "one value" becomes "all of them", one press of ↓ at a time.
p.revealed_face_claim = None;
p.face_claim_selected = *index;
PersonaEffect::None
}
PersonaAction::RevealFaceClaim(index) => {
let p = &mut state.main_page.content_panel.identity;
// Bounds-checked against the open face rather than assumed: the
// detail can be replaced by a re-read between the keypress and here.
let claims = p.open_profile.as_ref().map_or(0, |d| d.resolved.len());
if *index >= claims {
return PersonaEffect::None;
}
// A second press puts it back, so the key that showed the value is
// also the one that hides it again.
p.revealed_face_claim = match p.revealed_face_claim {
Some(i) if i == *index => None,
_ => Some(*index),
};
// No read: a face detail is resolved in full when it is opened, so
// this lifts a mask over a value already in memory. Which is
// exactly why the mask is not a security control — see
// `openvtc_core::persona::claim_types`.
PersonaEffect::None
}
PersonaAction::ProfileNew => {
Expand Down Expand Up @@ -855,6 +888,12 @@ impl PersonaOutcome {
}
} else {
p.open_profile = Some(detail);
// A fresh detail is a fresh set of rows: the cursor
// starts at the top and nothing is revealed. Carrying
// an index over would grant a reveal on whatever
// happens to sit at that position now.
p.face_claim_selected = 0;
p.revealed_face_claim = None;
}
}
Err(e) => {
Expand Down Expand Up @@ -1164,6 +1203,136 @@ mod tests {
assert!(personas(&state).revealed_attribute.is_none());
}

/// A face detail with three claims, two of which carry a mask style.
fn open_face() -> openvtc_core::persona::profile::ProfileDetail {
use openvtc_core::persona::profile::{ProfileDetail, ProfileSummary, ResolvedClaim};
ProfileDetail {
summary: ProfileSummary {
profile_id: "01P".into(),
name: "OSS Developer".into(),
..ProfileSummary::default()
},
resolved: vec![
ResolvedClaim {
claim_type: "name.legal".into(),
value: Some(serde_json::json!("Glenn Gore")),
..ResolvedClaim::default()
},
ResolvedClaim {
claim_type: "email.work".into(),
value: Some(serde_json::json!("glenn@example.com")),
..ResolvedClaim::default()
},
],
..ProfileDetail::default()
}
}

/// `s` on a face opens one claim, and pressing it again closes it — the key
/// that showed the value is the one that hides it.
#[test]
fn a_face_reveal_toggles_on_the_same_key() {
let mut state = state_with(IdentityState {
tab: PersonaTab::Profiles,
open_profile: Some(open_face()),
face_claim_selected: 1,
..IdentityState::default()
});

apply(&mut state, &PersonaAction::RevealFaceClaim(1));
assert_eq!(personas(&state).revealed_face_claim, Some(1));

apply(&mut state, &PersonaAction::RevealFaceClaim(1));
assert!(
personas(&state).revealed_face_claim.is_none(),
"toggled off"
);
}

/// A reveal aimed past the end of the face opens nothing.
///
/// The index comes from a keypress against what was on screen, and the
/// detail can be replaced between the two — so it is checked against the
/// open face rather than trusted.
#[test]
fn a_face_reveal_past_the_end_reveals_nothing() {
let mut state = state_with(IdentityState {
tab: PersonaTab::Profiles,
open_profile: Some(open_face()),
..IdentityState::default()
});

apply(&mut state, &PersonaAction::RevealFaceClaim(7));
assert!(personas(&state).revealed_face_claim.is_none());
}

/// A reveal with no face open at all opens nothing, rather than arming a
/// grant that the next face to be opened would inherit.
#[test]
fn a_face_reveal_with_nothing_open_reveals_nothing() {
let mut state = state_with(IdentityState {
tab: PersonaTab::Profiles,
..IdentityState::default()
});

apply(&mut state, &PersonaAction::RevealFaceClaim(0));
assert!(personas(&state).revealed_face_claim.is_none());
}

/// Everything that changes what is on screen puts a face's mask back too.
///
/// Same rule as the attributes tab, and the same reason: a reveal is
/// granted to one claim on one open face, and moving the cursor, closing
/// the face or leaving the tab each ends it.
#[test]
fn moving_anywhere_puts_a_face_mask_back() {
let revealed = || {
state_with(IdentityState {
tab: PersonaTab::Profiles,
open_profile: Some(open_face()),
face_claim_selected: 1,
revealed_face_claim: Some(1),
loaded: true,
..IdentityState::default()
})
};

let mut moved = revealed();
apply(&mut moved, &PersonaAction::FaceClaimSelect(0));
assert!(personas(&moved).revealed_face_claim.is_none(), "cursor");
assert_eq!(personas(&moved).face_claim_selected, 0);

let mut closed = revealed();
apply(&mut closed, &PersonaAction::ProfileClose);
assert!(personas(&closed).revealed_face_claim.is_none(), "closed");

let mut tabbed = revealed();
apply(&mut tabbed, &PersonaAction::TabNext);
assert!(personas(&tabbed).revealed_face_claim.is_none(), "tab");
}

/// Opening a face starts at the top with nothing revealed.
///
/// A carried-over index would grant a reveal on whatever now sits at that
/// position, which is a different claim in a different face.
#[test]
fn opening_a_face_starts_closed_and_at_the_top() {
let mut state = state_with(IdentityState {
tab: PersonaTab::Profiles,
face_claim_selected: 1,
revealed_face_claim: Some(1),
..IdentityState::default()
});

PersonaOutcome::ProfileRead {
edit: false,
result: Ok(open_face()),
}
.apply(&mut state);
assert_eq!(personas(&state).face_claim_selected, 0);
assert!(personas(&state).revealed_face_claim.is_none());
}

/// Everything that changes what is on screen puts the mask back.
///
/// This is what keeps the reveal from becoming a global unmask reached one
Expand Down
Loading
Loading