diff --git a/crates/contract/tests/contract.rs b/crates/contract/tests/contract.rs index 06d58fd9c3..03609ac35f 100644 --- a/crates/contract/tests/contract.rs +++ b/crates/contract/tests/contract.rs @@ -33,8 +33,8 @@ use cipherbox_engine::api::{ use cipherbox_engine::content::{ContentProfile, DAG_ROOT_CODEC, assemble}; use cipherbox_engine::grants::{ GrantRecipient, GrantResumeResolver, GranteeScopePlan, InteriorRecord, InteriorResealer, - OwnerGrantKeys, ParentScopePlan, PromotedScopeRoot, ScopePointerVoucher, ScopeRootPromoter, - SharePointer, create_grant, import_contact, post_share_pointer, + MovingChild, OwnerGrantKeys, ParentScopePlan, PromotedScopeRoot, ScopePointerVoucher, + ScopeRootPromoter, SharePointer, create_grant, import_contact, post_share_pointer, }; use cipherbox_engine::mailbox::poll_verified; use cipherbox_engine::net::REGISTRY_BATCH_MAX; @@ -1387,12 +1387,16 @@ impl GrantResumeResolver for LocalNet { Ok(None) } - async fn moved_interior_node( + async fn resolve_moving_child( &self, + source: &ChildScopeRef, _root: &ResealedScopeRoot, - _node: &NodeRef, - ) -> Result, SweepResolveFailure> { - Ok(None) + node: &NodeRef, + ) -> Result { + match self.resolve_child(source, node).await? { + SweptChild::Interior(swept) => Ok(MovingChild::Pending(swept)), + SweptChild::ScopeRoot(_) => Ok(MovingChild::ScopeRoot), + } } async fn holds_a_scope_root_floor(&self, _node: &NodeRef) -> Result { diff --git a/crates/engine/src/facade.rs b/crates/engine/src/facade.rs index a9fe385aec..d4aa4137ee 100644 --- a/crates/engine/src/facade.rs +++ b/crates/engine/src/facade.rs @@ -83,7 +83,7 @@ use crate::net::cut::OwnerCutNet; use crate::net::record_publish::RecordPublishError; use crate::net::retire::{OrphanHeads, ReclaimStall, retire}; use crate::net::rotation::scope_name; -use crate::net::rotation::{GatedRoots, RotationAncestry, SweptScopeState}; +use crate::net::rotation::{GatedRoots, MovedScopeSeed, RotationAncestry, SweptScopeState}; use crate::net::{ Adopter, ChildAdopter, ChildResolveError, DescendantScopeRoot, EolRenewResult, FolderRefresh, FolderRefreshReport, GraftedLeg, HeldKey, HeldMaterial, HeldRecord, HeldRecords, @@ -2815,6 +2815,7 @@ where payload_version: POINTER_PAYLOAD_VERSION, gated: GatedRoots::default(), swept: SweptScopeState::default(), + moved_seed: MovedScopeSeed::default(), }; // The cut about to run mints a fresh seed at a fresh epoch, so the walked // material for this scope is superseded the moment it lands. Standing the @@ -5713,6 +5714,7 @@ where { payload_version: POINTER_PAYLOAD_VERSION, gated: GatedRoots::default(), swept: SweptScopeState::default(), + moved_seed: MovedScopeSeed::default(), }; // The wave is idempotent and every later write advances it, // so a pass that does not converge is left to the next one. @@ -7205,6 +7207,7 @@ where { payload_version: POINTER_PAYLOAD_VERSION, gated: GatedRoots::default(), swept: SweptScopeState::default(), + moved_seed: MovedScopeSeed::default(), } } diff --git a/crates/engine/src/grants/create.rs b/crates/engine/src/grants/create.rs index b11dd038b7..62c4b1c893 100644 --- a/crates/engine/src/grants/create.rs +++ b/crates/engine/src/grants/create.rs @@ -59,7 +59,7 @@ use crate::rotation::sweep::{body_children, canonicalize_frontier, resolve_scope use crate::rotation::{ AscentAuthority, CascadeResealResolver, CommittedSet, NodeRef, ResealError, ResealSeeds, ResealedScopeRoot, ResolveFailure, RotationPublishError, ScopeRootIdentity, ScopeRootPublisher, - SweepError, SweepPublisher, SweepResolveFailure, SweepResolver, SweptChild, WriteHistory, + SweepError, SweepPublisher, SweepResolveFailure, SweepResolver, SweptNode, WriteHistory, converge_subtree, derive_write_name, reseal_scope_root, }; use crate::seams::{Mailbox, SeamError}; @@ -487,17 +487,21 @@ pub trait GrantResumeResolver { node: &NodeRef, ) -> Result, ResolveFailure>; - /// Read `node` as a record a stalled move already published into `root`, or - /// `None` when the record at its name claims another scope. + /// Read `node` once and classify it against both scopes the move admits: + /// `source`, the scope the folder is leaving, and `root`, the scope it is + /// moving into. /// - /// Authenticity comes from `root`'s own derivation — the override seed out - /// of its section's owner blob, at the epoch the record claims — so `None` - /// never widens what a walk admits. - async fn moved_interior_node( + /// Each arm authenticates the record under the derivation of the scope it + /// claims — `source`'s gated read seed, or the override seed out of + /// `root`'s own section's owner blob — so admitting either scope for the + /// duration of the move widens nothing. A record claiming neither is a + /// fail-closed [`SweepResolveFailure::Rejected`]. + async fn resolve_moving_child( &self, + source: &ChildScopeRef, root: &ResealedScopeRoot, node: &NodeRef, - ) -> Result, SweepResolveFailure>; + ) -> Result; /// Whether this device holds a read-epoch floor at `node`'s own scope id. /// @@ -509,6 +513,19 @@ pub trait GrantResumeResolver { async fn holds_a_scope_root_floor(&self, node: &NodeRef) -> Result; } +/// Which of the two scopes a grant's interior move admits the one record at a +/// node's name claims ([`GrantResumeResolver::resolve_moving_child`]). +pub enum MovingChild { + /// Still in the scope the folder is leaving, so the move owes it the + /// re-seal. + Pending(SweptNode), + /// Already in the grantee scope, published by a stalled attempt. The walk + /// carries on through its children and publishes nothing. + Moved(ReadBody), + /// A scope root, which the move never walks through. + ScopeRoot, +} + /// The scope root a stalled grant already published over the granted folder, as /// the gate authenticated it. pub struct PromotedScopeRoot { @@ -835,7 +852,9 @@ where { return Err(CreateGrantError::TargetAlreadyNamesAScope); } - let swept = converge_subtree(resolver, publisher, &parent_ref, &folder) + // The pass runs on the scope this command already proved current, so the + // parent name is resolved once here and not again inside the pass. + let swept = converge_subtree(resolver, publisher, &parent_ref, parent_scope, &folder) .await .map_err(CreateGrantError::Converge)?; // A node the pass could not read is as unproven as one whose convergence @@ -1314,10 +1333,11 @@ pub(crate) fn commits_write_grant( /// Re-seal every interior node under the granted folder into `root`, the scope /// published over that folder. /// -/// The walk descends from `frontier` — `root`'s own body — reading each node in -/// `source`, the scope it is leaving, and falling through to `root` for a node a -/// stalled attempt already moved there. Admitting either scope id for the -/// duration of the move is what makes the leg re-drivable ([`GrantResumeResolver`]). +/// The walk descends from `frontier` — `root`'s own body — reading each node +/// once under whichever of `source`, the scope it is leaving, and `root`, the +/// scope it is moving into, the record claims. Admitting either scope id for the +/// duration of the move is what makes the leg re-drivable +/// ([`GrantResumeResolver::resolve_moving_child`]). /// /// [`InteriorBounds::stop_at`] is skipped, because a scope root is re-keyed as /// one and its own interior stays in the scope it already belongs to. A node @@ -1358,8 +1378,8 @@ where node_id: child.node_id, }); } - match resolver.resolve_child(source, child).await { - Ok(SweptChild::Interior(node)) => { + match resolver.resolve_moving_child(source, root, child).await { + Ok(MovingChild::Pending(node)) => { // Release-active (security rule 8). The read admits any // record at or below the scope's epoch, so a record that // regressed since the pass would travel into the grantee's @@ -1389,26 +1409,12 @@ where error, })?; } - Ok(SweptChild::ScopeRoot(_)) => { + Ok(MovingChild::ScopeRoot) => { return Err(CreateGrantError::InteriorNotConverged { node_id: child.node_id, }); } - // The scope the folder is leaving does not authenticate the - // record, so either this move already published it into `root` - // or nothing here opens it. - Err(SweepResolveFailure::Rejected) => { - let moved = resolver - .moved_interior_node(root, child) - .await - .map_err(|reason| CreateGrantError::InteriorResolve { - node_id: child.node_id, - reason, - })? - .ok_or(CreateGrantError::InteriorResolve { - node_id: child.node_id, - reason: SweepResolveFailure::Rejected, - })?; + Ok(MovingChild::Moved(moved)) => { next.extend(body_children(&moved)); } Err(reason) => { @@ -1427,6 +1433,7 @@ where #[cfg(test)] mod tests { use super::*; + use std::collections::BTreeMap; /// Records what the mint vouched for, and can be told to refuse — the /// pointer plane's half of the mint, without a network. @@ -1601,6 +1608,12 @@ mod tests { /// cascade re-seal resolve. A refusal that must land before the /// convergence gate leaves this at zero. resolve_calls: Rc>, + /// Scope-root resolves alone, so a test can hold one command to one + /// resolve of the parent name. + scope_resolves: Rc>, + /// Read-seam entries per node id, so a test can hold the interior move + /// to one read of each node it walks. + node_reads: Rc>>, fail_after: Option<(usize, RotationPublishError)>, /// Interior nodes **inside** the granted folder: id → published epoch. interior: Rc>>, @@ -1644,8 +1657,8 @@ mod tests { /// The direct-child-scope index that root reparented. promoted_boundaries: Vec, /// Interior nodes already re-sealed into the granted scope. The scope - /// the folder left no longer authenticates them, so `resolve_child` - /// refuses them and `moved_interior_node` answers instead. + /// the folder left no longer authenticates them, so the move's read + /// answers them out of the granted scope instead. moved: Rc>>, /// One node whose re-seal publish stalls, so a test can strand the tail /// of a subtree and then let it through. @@ -1661,6 +1674,8 @@ mod tests { publish_result, publish_calls: Rc::new(RefCell::new(0)), resolve_calls: Rc::new(RefCell::new(0)), + scope_resolves: Rc::new(RefCell::new(0)), + node_reads: Rc::new(RefCell::new(BTreeMap::new())), fail_after: None, interior: Rc::new(RefCell::new(Vec::new())), outside: Rc::new(RefCell::new(Vec::new())), @@ -1829,6 +1844,19 @@ mod tests { fn resolve_calls(&self) -> usize { *self.resolve_calls.borrow() } + + fn scope_resolves(&self) -> usize { + *self.scope_resolves.borrow() + } + + fn count_node_read(&self, node_id: [u8; 16]) { + self.count_resolve(); + *self.node_reads.borrow_mut().entry(node_id).or_default() += 1; + } + + fn node_reads(&self, node_id: [u8; 16]) -> usize { + self.node_reads.borrow().get(&node_id).copied().unwrap_or(0) + } } impl SweepResolver for FakeNet { @@ -1837,6 +1865,7 @@ mod tests { scope: &ChildScopeRef, ) -> Result { self.count_resolve(); + *self.scope_resolves.borrow_mut() += 1; if scope.scope_id != PARENT_SCOPE { return Err(SweepResolveFailure::Rejected); } @@ -1868,7 +1897,7 @@ mod tests { _scope: &ChildScopeRef, child: &NodeRef, ) -> Result { - self.count_resolve(); + self.count_node_read(child.node_id); if self.unresolvable == Some(child.node_id) { return Err(SweepResolveFailure::Unavailable); } @@ -2063,26 +2092,31 @@ mod tests { Ok(self.floored.borrow().contains(&node.node_id)) } - async fn moved_interior_node( + async fn resolve_moving_child( &self, + source: &ChildScopeRef, _root: &ResealedScopeRoot, node: &NodeRef, - ) -> Result, SweepResolveFailure> { - if !self.moved.borrow().contains(&node.node_id) { - return Ok(None); + ) -> Result { + if self.moved.borrow().contains(&node.node_id) { + self.count_node_read(node.node_id); + return Ok(MovingChild::Moved(ReadBody::Folder { + created_at: 0, + modified_at: 0, + children: child_refs( + self.nested + .borrow() + .iter() + .filter(|(parent, _, _)| *parent == node.node_id) + .map(|(_, node_id, _)| *node_id), + ), + unknown: PreservedFields::new(), + })); + } + match self.resolve_child(source, node).await? { + SweptChild::Interior(swept) => Ok(MovingChild::Pending(swept)), + SweptChild::ScopeRoot(_) => Ok(MovingChild::ScopeRoot), } - Ok(Some(ReadBody::Folder { - created_at: 0, - modified_at: 0, - children: child_refs( - self.nested - .borrow() - .iter() - .filter(|(parent, _, _)| *parent == node.node_id) - .map(|(_, node_id, _)| *node_id), - ), - unknown: PreservedFields::new(), - })) } } @@ -2891,6 +2925,44 @@ mod tests { ); } + #[test] + fn a_grant_resolves_the_parent_scope_root_once() { + // The convergence pass runs on the scope the resume probe already proved + // current, so the parent name costs one resolve for the whole command. + let net = FakeNet::new(Ok(())).with_interior(INTERIOR_NODE, PARENT_EPOCH); + let (outcome, _published, _hub) = run(9, &[], net.clone(), &[]); + outcome.expect("the grant completes"); + assert_eq!(net.scope_resolves(), 1); + } + + #[test] + fn a_resumed_walk_reads_each_already_moved_node_once() { + // A re-drive meets the first attempt's nodes in the scope it published + // them into. Classifying that one read against both scopes is what keeps + // the re-drive from paying the subtree's reads twice. + let net = FakeNet::new(Ok(())) + .with_interior(INTERIOR_NODE, PARENT_EPOCH) + .with_interior(SECOND_NODE, PARENT_EPOCH) + .stalling_reseal_at(SECOND_NODE); + stall_grant(&net); + let moved_reads = net.node_reads(INTERIOR_NODE); + let scope_resolves = net.scope_resolves(); + + let (resumed, _published, _hub) = run(8, &[], net.clone(), &[]); + resumed.expect("the re-drive finishes the owed move"); + + assert_eq!( + net.node_reads(INTERIOR_NODE) - moved_reads, + 1, + "the node the first attempt moved is read once on the re-drive", + ); + assert_eq!( + net.scope_resolves() - scope_resolves, + 1, + "the re-drive resolves the parent name once", + ); + } + #[test] fn a_promoted_root_that_commits_no_row_for_this_recipient_is_not_resumed() { // Resuming grafts no second recipient onto a scope: their blob is not in diff --git a/crates/engine/src/grants/invite_mint.rs b/crates/engine/src/grants/invite_mint.rs index ffbe39cd29..232af34e6d 100644 --- a/crates/engine/src/grants/invite_mint.rs +++ b/crates/engine/src/grants/invite_mint.rs @@ -221,7 +221,7 @@ where #[cfg(test)] mod tests { use super::super::create::{ - GrantResumeResolver, InteriorRecord, InteriorResealer, PromotedScopeRoot, + GrantResumeResolver, InteriorRecord, InteriorResealer, MovingChild, PromotedScopeRoot, }; use super::*; use crate::rotation::published_override_seed; @@ -471,12 +471,16 @@ mod tests { Ok(false) } - async fn moved_interior_node( + async fn resolve_moving_child( &self, + source: &ChildScopeRef, _root: &ResealedScopeRoot, - _node: &NodeRef, - ) -> Result, SweepResolveFailure> { - Ok(None) + node: &NodeRef, + ) -> Result { + match self.resolve_child(source, node).await? { + SweptChild::Interior(swept) => Ok(MovingChild::Pending(swept)), + SweptChild::ScopeRoot(_) => Ok(MovingChild::ScopeRoot), + } } } diff --git a/crates/engine/src/grants/mod.rs b/crates/engine/src/grants/mod.rs index 33eb067d6d..59521d4803 100644 --- a/crates/engine/src/grants/mod.rs +++ b/crates/engine/src/grants/mod.rs @@ -44,10 +44,10 @@ pub use contact_store::{ pub(crate) use create::commits_write_grant; pub use create::{ ConvergedSubtree, CreateGrantError, CreateGrantOutcome, GrantRecipient, GrantResumeResolver, - GrantSubtree, GranteeScopePlan, InteriorRecord, InteriorResealer, MintNet, OwnerGrantKeys, - ParentScopePlan, PromotedScopeRoot, PromotedSubtree, ScopePointerVoucher, ScopeRootPromoter, - converge_grant_subtree, create_grant, mint_grantee_scope, post_share_pointer, - resume_grantee_scope, + GrantSubtree, GranteeScopePlan, InteriorRecord, InteriorResealer, MintNet, MovingChild, + OwnerGrantKeys, ParentScopePlan, PromotedScopeRoot, PromotedSubtree, ScopePointerVoucher, + ScopeRootPromoter, converge_grant_subtree, create_grant, mint_grantee_scope, + post_share_pointer, resume_grantee_scope, }; pub use invite::{ CLAIM_ID_LEN, ClaimOutcome, CommittedLink, CommittedScope, ConvertedClaim, diff --git a/crates/engine/src/net/cut.rs b/crates/engine/src/net/cut.rs index 1cdb80f9f6..95dde4fb30 100644 --- a/crates/engine/src/net/cut.rs +++ b/crates/engine/src/net/cut.rs @@ -24,8 +24,8 @@ use crate::facade::{Event, NodeId}; use crate::gate::floor; use crate::net::liveness::HeldRecords; use crate::net::rotation::{ - GatedRoots, GatedWaveRoot, OwnerRotationKeys, OwnerRotationNet, PointerConsultArm, - RotationAncestry, SweptScopeState, WaveSubtree, WriteWaveNet, + GatedRoots, GatedWaveRoot, MovedScopeSeed, OwnerRotationKeys, OwnerRotationNet, + PointerConsultArm, RotationAncestry, SweptScopeState, WaveSubtree, WriteWaveNet, }; use crate::profile::SyncTimingProfile; use crate::rotation::{ @@ -161,6 +161,7 @@ where payload_version: self.payload_version, gated: GatedRoots::default(), swept: SweptScopeState::default(), + moved_seed: MovedScopeSeed::default(), } } } diff --git a/crates/engine/src/net/rotation.rs b/crates/engine/src/net/rotation.rs index 39dafcced0..024c380d76 100644 --- a/crates/engine/src/net/rotation.rs +++ b/crates/engine/src/net/rotation.rs @@ -71,9 +71,9 @@ use crate::gate::{ use crate::grants::child_index::canonicalize; use crate::grants::create::ScopePointerVoucher; use crate::grants::{ - GrantResumeResolver, InteriorRecord, InteriorResealer, PromotedScopeRoot, ScopeRootPromoter, - UNATTESTED_IDENTITY_PK, enforce_committed_ledger, mint_grant_row, recipient_self_location, - row_is_owner_attested, self_locate_signed, + GrantResumeResolver, InteriorRecord, InteriorResealer, MovingChild, PromotedScopeRoot, + ScopeRootPromoter, UNATTESTED_IDENTITY_PK, enforce_committed_ledger, mint_grant_row, + recipient_self_location, row_is_owner_attested, self_locate_signed, }; use crate::net::fanout::{FanoutRecord, fanout_get_classified, fanout_get_verify}; use crate::net::resolve::Adopter; @@ -200,6 +200,9 @@ pub struct OwnerRotationNet<'a, T, H: Http, C: CredentialStore, F, Sch, E, S> { /// The scope the sweep is walking, held from the scope-root read that /// proved it (see [`SweptScopeState`]). One sweep pass per net. pub swept: SweptScopeState, + /// The override seed of the scope a grant's interior move is publishing + /// into (see [`MovedScopeSeed`]). One move per net. + pub moved_seed: MovedScopeSeed, } /// The one scope root this pass gated and has not yet republished. @@ -232,6 +235,62 @@ impl GatedRoots { } } +/// The override seed of the scope a grant's interior move publishes into, +/// recovered from that root's own owner blob and held for the length of the +/// move. +/// +/// Every node of one move seals under the same seed, and recovering it is an +/// X25519 operation plus an owner-blob open, so without this the move pays both +/// once per node. One slot keyed on the root's own identity, as [`GatedRoots`] +/// is keyed on a name: a second root evicts the first rather than aliasing onto +/// it. Held behind an [`Rc`] so the seed has one live copy that zeroizes when +/// the slot is replaced or dropped. +#[derive(Default)] +pub struct MovedScopeSeed { + inner: RefCell>, +} + +/// One recovered override seed under the root identity it belongs to: a record +/// naming any other root re-recovers rather than reading under this seed. +struct HeldMovedSeed { + key: MovedScopeKey, + seed: Rc>, +} + +/// The root identity a recovered override seed is held under. +#[derive(PartialEq, Eq)] +struct MovedScopeKey { + scope_id: [u8; 16], + ipns_name: Vec, + read_epoch: u64, +} + +impl MovedScopeSeed { + fn recover( + &self, + enc_secret: &X25519Secret, + record: &ResealedScopeRoot, + ) -> Result>, RotationPublishError> { + let key = MovedScopeKey { + scope_id: record.scope_id, + ipns_name: record.ipns_name.clone(), + read_epoch: record.read_epoch, + }; + let mut held = self.inner.borrow_mut(); + if let Some(parked) = held.as_ref() + && parked.key == key + { + return Ok(Rc::clone(&parked.seed)); + } + let seed = Rc::new(new_override_seed(enc_secret, record)?); + *held = Some(HeldMovedSeed { + key, + seed: Rc::clone(&seed), + }); + Ok(seed) + } +} + /// What republishing a scope root needs from the record it replaces: the body /// carried forward, the envelope fields a republish preserves byte-stable /// (#27 D10), and the write seed the record's name signs under. @@ -1952,7 +2011,8 @@ where record: &ResealedScopeRoot, ) -> Result, RotationPublishError> { let name = scope_name(&record.ipns_name).map_err(publish_verdict)?; - let override_seed = new_override_seed(self.keys.enc_secret, record)?; + // The interior move this promotion heads seals under the same seed. + let override_seed = self.moved_seed.recover(self.keys.enc_secret, record)?; let source = self .swept_scope(parent) .map_err(|_| RotationPublishError::Rejected)?; @@ -2584,7 +2644,7 @@ where name: &IpnsName, sequence: u64, envelope: Envelope, - ) -> Result { + ) -> Result { if envelope.v != ENVELOPE_V { return Err(SweepResolveFailure::VersionSkew); } @@ -2604,13 +2664,49 @@ where &envelope, ) .await?; - Ok(SweptChild::Interior(SweptNode { + Ok(SweptNode { current_read_epoch: envelope.epoch, sequence, read_body, carried_unknown: envelope.unknown, carried_epoch_tag_unknown: envelope.epoch_tag_unknown, - })) + }) + } + + /// Open a node a stalled move already published into `root`. + /// + /// Authenticity comes from `root`'s own derivation: the override seed out of + /// its section's owner blob, at the epoch the record claims. + async fn moved_interior_node( + &self, + root: &ResealedScopeRoot, + node: &NodeRef, + name: &IpnsName, + sequence: u64, + envelope: &Envelope, + ) -> Result { + if envelope.v != ENVELOPE_V { + return Err(SweepResolveFailure::VersionSkew); + } + if envelope.id != node.node_id { + return Err(SweepResolveFailure::Rejected); + } + let override_seed = self + .moved_seed + .recover(self.keys.enc_secret, root) + .map_err(|_| SweepResolveFailure::Rejected)?; + self.open_interior_record( + &InteriorReadScope { + scope_id: root.scope_id, + read_epoch: root.read_epoch, + read_scope_seed: &override_seed, + history_links: &root.section.history_links, + }, + name, + sequence, + envelope, + ) + .await } /// The interior read rule itself, over whichever scope's derivation the @@ -2808,6 +2904,7 @@ where } self.interior_node(&source, child, &name, sequence, envelope) .await + .map(SweptChild::Interior) } } @@ -3027,7 +3124,7 @@ where } // Recovered from the owner blob the minted section wraps, so the seam // carries no seed ([`new_override_seed`]). - let override_seed = new_override_seed(self.keys.enc_secret, root)?; + let override_seed = self.moved_seed.recover(self.keys.enc_secret, root)?; let read_key = read_key_for(&override_seed, &node.node_id); self.publish_interior_head( &name, @@ -3140,11 +3237,13 @@ where .is_some()) } - async fn moved_interior_node( + async fn resolve_moving_child( &self, + source: &ChildScopeRef, root: &ResealedScopeRoot, node: &NodeRef, - ) -> Result, SweepResolveFailure> { + ) -> Result { + let source = self.swept_scope(source)?; let name = scope_name(&node.ipns_name).map_err(SweepResolveFailure::from)?; let Some((_, record_bytes)) = fanout_get_verify(self.transport, &name).await else { return Err(SweepResolveFailure::Unavailable); @@ -3154,34 +3253,29 @@ where .await .map_err(read_verdict)?; let envelope = decode_envelope(&block).map_err(|_| SweepResolveFailure::Rejected)?; - if envelope.v != ENVELOPE_V { - return Err(SweepResolveFailure::VersionSkew); - } - if envelope.id != node.node_id || has_grant_section(&envelope) { - return Err(SweepResolveFailure::Rejected); + if has_grant_section(&envelope) { + self.gated_child_scope_root( + &source, + node, + &name, + &record_bytes, + LocalHead { + cid: root_block_cid(&block), + block, + }, + ) + .await?; + return Ok(MovingChild::ScopeRoot); } - // The move still owes this node: the caller re-seals it out of the scope - // it is leaving. - if envelope.scope != root.scope_id { - return Ok(None); + if envelope.scope == root.scope_id { + return self + .moved_interior_node(root, node, &name, sequence, &envelope) + .await + .map(MovingChild::Moved); } - // The seed the move sealed under is the one this root's own owner blob - // yields, never a value the walk carries. - let override_seed = new_override_seed(self.keys.enc_secret, root) - .map_err(|_| SweepResolveFailure::Rejected)?; - self.open_interior_record( - &InteriorReadScope { - scope_id: root.scope_id, - read_epoch: root.read_epoch, - read_scope_seed: &override_seed, - history_links: &root.section.history_links, - }, - &name, - sequence, - &envelope, - ) - .await - .map(Some) + self.interior_node(&source, node, &name, sequence, envelope) + .await + .map(MovingChild::Pending) } } @@ -4643,6 +4737,7 @@ pub(crate) async fn enrol_owned_scope_pointers( payload_version: pass.payload_version, gated: GatedRoots::default(), swept: SweptScopeState::default(), + moved_seed: MovedScopeSeed::default(), }; let Ok(root) = net.resolve_vault_root(&vault_root).await else { return; @@ -5122,6 +5217,7 @@ mod tests { payload_version: PAYLOAD_VERSION, gated: GatedRoots::default(), swept: SweptScopeState::default(), + moved_seed: MovedScopeSeed::default(), } } } @@ -6433,6 +6529,36 @@ mod tests { } } + #[test] + fn one_override_seed_recovery_serves_a_whole_interior_move() { + // The seed is an X25519 operation plus an owner-blob open, and a move + // seals every node of the subtree under the same one. + let root = vault_root(SCOPE, Vec::new()); + let first_root = cut(&root, SCOPE, OWNER_ROOT_EPOCH + 1); + let enc_secret = owner_enc(); + let memo = MovedScopeSeed::default(); + + let first = memo + .recover(&enc_secret, &first_root) + .expect("the owner blob yields the seed"); + let again = memo + .recover(&enc_secret, &first_root) + .expect("the owner blob yields the seed"); + assert!( + Rc::ptr_eq(&first, &again), + "the same root is recovered once for the whole move", + ); + + let second_root = cut(&root, SCOPE, OWNER_ROOT_EPOCH + 2); + let evicted = memo + .recover(&enc_secret, &second_root) + .expect("the owner blob yields the seed"); + assert!( + !Rc::ptr_eq(&first, &evicted), + "another root recovers its own seed rather than aliasing onto the held one", + ); + } + /// `SCOPE`'s root staged at the fixture epoch on a plain harness, plus the /// cut `rotate_scope` would hand the publisher for it. fn staged_cut() -> ( diff --git a/crates/engine/src/rotation/sweep.rs b/crates/engine/src/rotation/sweep.rs index ffdcc9b543..dd839d0d43 100644 --- a/crates/engine/src/rotation/sweep.rs +++ b/crates/engine/src/rotation/sweep.rs @@ -513,7 +513,13 @@ where R: SweepResolver, P: SweepPublisher, { - walk_and_converge(resolver, publisher, scope, None).await + let (scope_ref, swept) = resolve_scope_current(resolver, scope) + .await + .map_err(|reason| SweepError::Scope { + scope_id: scope.scope_id, + reason, + })?; + walk_and_converge(resolver, publisher, &scope_ref, swept, None).await } /// Converge just the subtree rooted at `node` inside `scope` — grant creation's @@ -522,38 +528,39 @@ where /// /// `node` itself is measured against the scope's epoch too: the granted folder /// is an interior node until the mint publishes its new scope root over it. +/// +/// `scope` and `swept` are what [`resolve_scope_current`] proved, and the caller +/// passes them in: grant creation must resolve the parent itself, because its +/// resume probe reads the scope source only that resolve parks, and one command +/// owes the parent name one resolve. pub async fn converge_subtree( resolver: &R, publisher: &P, scope: &ChildScopeRef, + swept: SweptScope, node: &NodeRef, ) -> Result where R: SweepResolver, P: SweepPublisher, { - walk_and_converge(resolver, publisher, scope, Some(node)).await + walk_and_converge(resolver, publisher, scope, swept, Some(node)).await } -/// The one pass both entry points run: gate the scope root, walk from `from` -/// (or from the root's own body), self-heal the index, re-seal what lags. +/// The one pass both entry points run over a scope root already proved current: +/// walk from `from` (or from the root's own body), self-heal the index, re-seal +/// what lags. async fn walk_and_converge( resolver: &R, publisher: &P, - scope: &ChildScopeRef, + scope_ref: &ChildScopeRef, + swept: SweptScope, from: Option<&NodeRef>, ) -> Result where R: SweepResolver, P: SweepPublisher, { - let (scope_ref, swept) = resolve_scope_current(resolver, scope) - .await - .map_err(|reason| SweepError::Scope { - scope_id: scope.scope_id, - reason, - })?; - let boundaries: BTreeSet<[u8; 16]> = swept .direct_child_scope_index .iter() @@ -593,7 +600,7 @@ where outcome.skipped_scope_roots.push(child.node_id); continue; } - let (resolved, found) = match resolve_child_current(resolver, &scope_ref, child).await { + let (resolved, found) = match resolve_child_current(resolver, scope_ref, child).await { Ok(pair) => pair, Err(reason) if reason.isolates_the_node() => { outcome.unreachable.push((child.node_id, reason)); @@ -635,7 +642,7 @@ where index = repair_observed(&index, scope_root.clone()); } if index != swept.direct_child_scope_index { - match publisher.repair_child_scope_index(&scope_ref, &index).await { + match publisher.repair_child_scope_index(scope_ref, &index).await { Ok(()) => outcome .flagged_indexes .extend(omitted.iter().map(|root| root.scope_id)), @@ -660,7 +667,7 @@ where carried_unknown: &swept_node.carried_unknown, carried_epoch_tag_unknown: &swept_node.carried_epoch_tag_unknown, }; - match publisher.publish_node(&scope_ref, &lagging_node).await { + match publisher.publish_node(scope_ref, &lagging_node).await { Ok(()) => outcome.converged.push(node.node_id), // The one spec-mandated non-abort per-node path. The winner may be a // non-advancing ordinary write, so the node is not proven converged; @@ -1278,6 +1285,7 @@ mod tests { &net, &net, &scope_ref(0x00), + block_on(net.resolve_scope(&scope_ref(0x00))).expect("the scope resolves"), &node_ref(0x01), )) .expect("the subtree converges"); @@ -1294,6 +1302,7 @@ mod tests { &net, &net, &scope_ref(0x00), + block_on(net.resolve_scope(&scope_ref(0x00))).expect("the scope resolves"), &node_ref(0x01), )) .expect("converges");