This commit is contained in:
2026-09-04 08:16:05 +07:00
parent 212f35d6bb
commit 17de4f6376
43 changed files with 376 additions and 721 deletions
+31 -155
View File
@@ -19,8 +19,6 @@ use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update};
use crate::git_store::GitStore;
/// Keyring entry for the user credential.
/// It is an `nsec1...` key or a `bunker://...` URI.
/// The URI embeds a `?master=<nsec>` NIP-46 session key.
pub const USER_KEYRING: &str = "Signed Safe Storage";
/// Timeout for NIP-46 signer responses.
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
@@ -37,17 +35,13 @@ pub const BOOTSTRAP_RELAYS: [&str; 4] = [
pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"];
/// How long an identical fetch or sync request is suppressed after it started.
/// A second panel for the same repository does not duplicate a live sync.
/// The global and per-author list stores at login share this dedup.
/// After the window, re-fetching is allowed again so data stays fresh.
const FETCH_DEDUP_WINDOW: Duration = Duration::from_secs(5 * 60);
#[derive(Debug, Clone)]
pub enum BackendEvent {
/// User has no signer configured.
SignerRequired,
/// The stored identity is NIP-49 encrypted, an `ncryptsec1...` key.
/// A passphrase is required to decrypt it before the session can resume.
/// The stored identity is NIP-49 encrypted key.
PassphraseRequired,
/// The signer changed on login, logout or account switch.
SignerChanged,
@@ -56,12 +50,8 @@ pub enum BackendEvent {
/// A new event was received from a relay and stored in the database.
NostrUpdate(Update),
/// A negentropy sync completed.
/// The database was updated directly, so stores should re-query.
/// No [`BackendEvent::NostrUpdate`] is fired for synced events.
Synced,
/// A negentropy sync is in flight.
/// Stores may re-query to render incrementally.
/// UI can show `current` and `total` progress.
SyncProgress {
/// Total events to process.
total: u64,
@@ -84,9 +74,8 @@ impl BackendEvent {
}
/// The global backend entity.
///
/// Owns the nostr client, the signer and the notification pump.
/// Stores subscribe to [`BackendEvent`].
/// They re-query the local database when relevant updates arrive.
pub struct Backend {
client: Client,
signer: UniversalSigner,
@@ -94,18 +83,10 @@ pub struct Backend {
connected: bool,
sync_progress: Option<(u64, u64)>,
/// True when the stored credential is NIP-49 encrypted.
/// A passphrase is still needed to resume the session.
passphrase_required: bool,
/// Fingerprints of recently started fetches and syncs, a relay plus filter set.
/// Duplicate requests within [`FETCH_DEDUP_WINDOW`] collapse into one.
/// Entries are pruned lazily on the next request.
recent_fetches: HashMap<u64, Instant>,
/// Repositories with a push in flight, mirror or checkout based.
/// Concurrent pushes of the same refs make the losing push fail server-side.
/// The rejection is a compare-and-swap error from the server.
/// Two panels of the same repository can race.
/// The banner push can also race the header's Republish.
/// Pushes are single-flight per repository.
pushing_repos: Arc<Mutex<HashSet<RepoAddr>>>,
tasks: Vec<Task<Result<(), Error>>>,
}
@@ -115,6 +96,7 @@ struct GlobalBackend(Entity<Backend>);
impl Global for GlobalBackend {}
/// Removes its repository from the in-flight push set when dropped.
///
/// A push task cancelled by its panel closing cannot leave the repository locked.
struct PushGuard {
repos: Arc<Mutex<HashSet<RepoAddr>>>,
@@ -182,7 +164,7 @@ impl Backend {
}
/// Bootstrap the client.
/// Connect to the default relays, with the indexers as discovery-only.
///
/// Restore the saved session, if any.
fn bootstrap(&mut self, cx: &mut Context<Self>) {
let client = self.client.clone();
@@ -221,7 +203,9 @@ impl Backend {
}
/// Restore the saved session from the keyring.
///
/// Emits [`BackendEvent::SignerRequired`] when no credential is stored.
///
/// Emits [`BackendEvent::PassphraseRequired`] for a NIP-49 encrypted identity.
pub fn restore_session(&mut self, cx: &mut Context<Self>) {
if cfg!(target_arch = "wasm32") {
@@ -283,9 +267,6 @@ impl Backend {
}
/// Decrypt the NIP-49 keyring credential with the given passphrase.
/// Resume the session on success.
/// The scrypt decryption runs off the UI thread.
/// The task yields the public key or the failure reason, e.g. a wrong passphrase.
pub fn restore_with_passphrase(
&mut self,
password: &str,
@@ -321,11 +302,6 @@ impl Backend {
}
/// Create a new identity.
/// Generate keys and encrypt the secret key with the passphrase, NIP-49.
/// Persist it in the keyring.
/// Then publish the NIP-65 relay list, metadata and grasp list.
/// The encryption runs off the UI thread.
/// The task yields the new public key.
pub fn create_identity(
&mut self,
name: &str,
@@ -412,19 +388,8 @@ impl Backend {
})
}
/// Create a repository.
/// Initialize a local clone with a `main` branch and a `README.md`.
/// Publish the NIP-34 announcement and the repository state to the grasp relays.
/// Push the initial commit to each grasp server.
/// Also create a working copy at `<folder>/<name>`, like the header's Clone action.
/// Its `origin` points at the first grasp server.
/// The new project exists in the chosen folder right away.
/// The events must reach the grasp relays before the push.
/// GRASP servers hold the signed state event in purgatory.
/// They accept the push only while the authorization is pending.
/// The pushed repository must not exist yet.
/// The authorization expires after 30 minutes, like gitworkshop and ngit.
/// The git work runs on background threads.
///
/// The task yields the announcement and the path of the working copy.
pub fn create_repository(
&mut self,
@@ -453,16 +418,15 @@ impl Backend {
return Task::ready(Err(anyhow!("Sign in to create a repository")));
};
// The repository identifier is derived from the name, like ngit and gitworkshop.
// Spaces become hyphens.
// Other non-alphanumeric characters become hyphens, except `/`.
// Case is preserved.
// The repository identifier is derived from the name.
let repo_id = identifier_from_name(&name);
if repo_id.is_empty() || repo_id.len() > 100 {
return Task::ready(Err(anyhow!(
"Repository name must produce an identifier of 1-100 characters"
)));
}
if !repo_id.chars().any(|c| c.is_ascii_alphanumeric()) {
return Task::ready(Err(anyhow!(
"Repository name must contain at least one alphanumeric character"
@@ -472,9 +436,7 @@ impl Backend {
let addr = repo_addr(public_key, repo_id.clone());
let cache = GitStore::global(cx).cache().clone();
let path = cache.repo_path(&addr);
let owner = public_key
.to_bech32()
.unwrap_or_else(|_| public_key.to_hex());
let owner = public_key.to_bech32().unwrap();
let servers = grasp_servers.clone();
cx.spawn(async move |this, cx| {
@@ -544,8 +506,7 @@ impl Backend {
this.add_relays(urls, cx);
})?;
// The state event is the push authorization.
// It must be accepted before the push below.
// The state event is the push authorization. It must be accepted before the push below.
let announcement = GitRepositoryAnnouncement {
id: repo_id.clone(),
name: Some(name.clone()),
@@ -601,8 +562,7 @@ impl Backend {
});
if let Err(e) = push.await {
// The events are already published.
// Retract them so the repository is not left announced without content.
// The events are already published. Retract them so the repository is not left announced without content.
this.update(cx, |this, cx| {
this.retract_events(&[event.clone(), state_event.clone()], cx);
})
@@ -622,11 +582,6 @@ impl Backend {
}
/// Publish an existing local repository to NIP-34.
/// Read its current branches, tags and HEAD.
/// Publish the announcement and the repository state to the grasp relays.
/// Then push every branch and tag to each grasp server.
/// Also point `origin` at the first grasp server.
/// The state event must be accepted before the push, like [`Self::create_repository`].
pub fn publish_local_repo(
&mut self,
path: PathBuf,
@@ -685,8 +640,7 @@ impl Backend {
this.add_relays(urls, cx);
})?;
// The state event is the push authorization.
// It must be accepted before the push below.
// The state event is the push authorization. It must be accepted before the push below.
let announcement = GitRepositoryAnnouncement {
id: repo_id.clone(),
name: Some(name.clone()),
@@ -730,8 +684,8 @@ impl Backend {
}
};
// Push every branch and tag to each grasp server.
// The push fails only when no server accepted it.
// Push every branch and tag to each grasp server. The push fails only when no server accepted it.
//
// An empty repository has nothing to push.
if !refs.is_empty() {
let push = cx.background_spawn({
@@ -769,9 +723,6 @@ impl Backend {
}
/// Re-push the repository's current refs to the grasp servers in its `relays` tag.
/// Publish a fresh state event, the push authorization.
/// Then push every branch and tag, like the init flow.
/// The repository must have a local clone in the cache.
pub fn push_repository(
&mut self,
announcement: Announcement,
@@ -784,10 +735,8 @@ impl Backend {
/// Push the refs of a local checkout to the grasp servers in its `relays` tag.
/// The checkout is the working copy of the user's own repository.
///
/// Publish a fresh state event, then push every branch and tag of the checkout.
/// That mirrors the init flow.
/// `announced_head` keeps the state event's `HEAD` on the announced default branch.
/// That matters when the checkout is on a different branch.
pub fn push_checkout(
&mut self,
announcement: Announcement,
@@ -799,12 +748,6 @@ impl Backend {
}
/// Shared body of the mirror-based and checkout-based pushes.
/// Publish the repository state, the push authorization.
/// Then push every branch and tag of `path` to each announced grasp server.
/// Pushes are single-flight per repository.
/// Concurrent pushes of the same refs fail server-side.
/// The rejection is a compare-and-swap error from the server.
/// Two panels of the same repository can produce the race.
fn push_repo_from(
&mut self,
announcement: Announcement,
@@ -828,16 +771,13 @@ impl Backend {
addr: addr.clone(),
}
};
let owner = announcement
.owner
.to_bech32()
.unwrap_or_else(|_| announcement.owner.to_hex());
let owner = announcement.owner.to_bech32().unwrap();
let repo_id = announcement.id.clone();
let relays = announcement.relays.clone();
cx.spawn(async move |this, cx| {
// Held for the whole task.
// Dropped on completion, on error and on cancellation alike.
// Held for the whole task. Dropped on completion, on error and on cancellation alike.
let _guard = guard;
let mut state = {
@@ -850,12 +790,14 @@ impl Backend {
// The state event announces the pushed refs.
// Keep the announced default branch in `HEAD` when it is among the pushed refs.
//
// Otherwise `HEAD` stays the checkout's current branch.
let heads: Vec<&str> = state
.refs
.iter()
.filter_map(|(name, _)| name.strip_prefix("refs/heads/"))
.collect();
if let Some(head) = announced_head
&& heads.iter().any(|branch| *branch == head)
{
@@ -865,6 +807,7 @@ impl Backend {
// Grasp servers authorize a push by the state they have seen.
let refs = state.refs.clone();
let head = state.head.clone();
this.update(cx, |this, cx| {
let builder = build_state(&repo_id, &refs, head.as_deref());
this.send(builder, cx)
@@ -890,8 +833,7 @@ impl Backend {
}
/// Delete the repository from nostr.
/// Publish NIP-09 deletions for its announcement, state and activity events.
/// Those are issues, pull requests, patches, statuses and comments.
///
/// Only the repository owner may delete it.
pub fn delete_repository(
&mut self,
@@ -934,7 +876,6 @@ impl Backend {
}
/// Login with an `nsec1...` key or a `bunker://...` URI.
/// Dispatch on the credential's prefix.
pub fn login(&mut self, credential: &str, cx: &mut Context<Self>) {
let credential = credential.trim();
@@ -950,7 +891,6 @@ impl Backend {
}
/// Create a fresh identity and login with it.
/// The generated key is persisted in the keyring like any other `nsec` credential.
pub fn login_with_new_identity(&mut self, cx: &mut Context<Self>) {
let nsec = Keys::generate()
.secret_key()
@@ -960,7 +900,6 @@ impl Backend {
}
/// Login with an `nsec1...` secret key.
/// The credential is verified by the signer flow and persisted in the keyring.
pub fn login_with_nsec(&mut self, nsec: &str, cx: &mut Context<Self>) {
let keys = match SecretKey::parse(nsec) {
Ok(secret) => Keys::new(secret),
@@ -985,10 +924,6 @@ impl Backend {
}
/// Login with a `bunker://...` URI, NIP-46.
/// A fresh session key is embedded into the stored URI as `?master=<nsec>`.
/// No separate keyring entry is needed.
/// The auth URL, if any, is opened in the default browser.
/// The credential is persisted in the keyring after the signer proves reachable.
pub fn login_with_bunker(&mut self, uri: &str, cx: &mut Context<Self>) {
let uri_string = uri.trim().to_owned();
@@ -1052,8 +987,7 @@ impl Backend {
}));
}
/// Fetch the user's grasp list of kind `10317`.
/// Add the listed grasp servers as relays.
/// Fetch the user's grasp list and add the listed grasp servers as relays.
fn bootstrap_user(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
let client = self.client.clone();
@@ -1106,7 +1040,6 @@ impl Backend {
}
/// True when the stored credential is NIP-49 encrypted.
/// A passphrase is still needed to resume the session.
pub fn passphrase_required(&self) -> bool {
self.passphrase_required
}
@@ -1122,14 +1055,11 @@ impl Backend {
}
/// Progress of the in-flight negentropy sync, if any.
/// Reported as `total` and `current`.
pub fn sync_progress(&self) -> Option<(u64, u64)> {
self.sync_progress
}
/// Update the signer.
/// Any type implementing the async signer traits works.
/// Examples are `Keys`, `NostrConnect` and a browser extension proxy.
pub fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
where
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
@@ -1191,7 +1121,6 @@ impl Backend {
}
/// Add discovery-only relays, e.g. NIP-65 indexers, and connect to them.
/// No subscriptions or writes are routed through them.
pub fn add_discovery_relays(&mut self, urls: Vec<String>, cx: &mut Context<Self>) {
let client = self.client.clone();
@@ -1215,8 +1144,6 @@ impl Backend {
}
/// Start a persistent subscription.
/// Matching events are stored in the database automatically.
/// They surface as [`BackendEvent::NostrUpdate`].
pub fn subscribe(&mut self, filter: Filter, cx: &mut Context<Self>) {
let client = self.client.clone();
@@ -1231,6 +1158,7 @@ impl Backend {
}
/// Whether an identical fetch started within [`FETCH_DEDUP_WINDOW`] is still recent.
///
/// Records the fingerprint when returning `false`, pruning expired entries first.
fn fetch_recently_started(&mut self, fingerprint: u64) -> bool {
self.recent_fetches
@@ -1243,12 +1171,6 @@ impl Backend {
}
/// Connect to a repository's announced relays, its NIP-34 `relays` tag.
/// Fetch the repository's events from them.
/// Run a one-shot auto-closing subscription for `filters`.
/// Then a negentropy sync covers issues, patches and PRs stored only on those relays.
/// An identical request within [`FETCH_DEDUP_WINDOW`] is skipped.
/// The relays stay in the pool, so later publishes for this repository reach them too.
/// Failures are logged, not surfaced.
pub fn connect_repo_relays(
&mut self,
relays: Vec<RelayUrl>,
@@ -1278,9 +1200,6 @@ impl Backend {
}
/// One-shot subscription on the bootstrap relays only.
/// Auto-closes after EOSE or a short timeout.
/// Matching events are stored in the database.
/// They surface as [`BackendEvent::NostrUpdate`] while the subscription is open.
pub fn subscribe_bootstrap(&mut self, filters: Vec<Filter>, cx: &mut Context<Self>) {
let client = self.client.clone();
@@ -1296,12 +1215,6 @@ impl Backend {
}
/// Negentropy-sync the given filter against the bootstrap relays.
/// Reconciles the local database with the relays in both directions.
/// Emits [`BackendEvent::SyncProgress`] while running.
/// Throttled to whole-percent changes.
/// Emits [`BackendEvent::Synced`] on completion.
/// An identical sync started within [`FETCH_DEDUP_WINDOW`] is skipped.
/// Observers still see the original sync's progress and completion events.
pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context<Self>) {
let fingerprint = fetch_fingerprint(&BOOTSTRAP_RELAYS, std::slice::from_ref(&filter));
if self.fetch_recently_started(fingerprint) {
@@ -1377,9 +1290,6 @@ impl Backend {
}
/// Sign, broadcast and locally store an event.
/// Emits [`BackendEvent::Published`] on success so stores can refresh.
/// The task yields the outcome of this specific action for inline progress or errors.
/// The caller owns the task, dropping it cancels the publish.
pub fn send(
&mut self,
builder: EventBuilder,
@@ -1430,9 +1340,6 @@ impl Backend {
}
/// Broadcast and locally store an already-signed event.
/// Like [`Self::send`] without the signing step.
/// Callers that signed early use this.
/// They may need the event id before pushing a commit to the grasp servers.
pub fn publish_event(
&mut self,
event: Event,
@@ -1479,8 +1386,6 @@ impl Backend {
}
/// Publish a NIP-34 repository announcement, kind 30617, with the current signer.
/// The returned task yields the published event.
/// Callers can show inline progress or errors.
pub fn publish_announcement(
&mut self,
announcement: GitRepositoryAnnouncement,
@@ -1490,8 +1395,6 @@ impl Backend {
}
/// Sign, broadcast and store an event without awaiting the result.
/// Failures surface through [`BackendEvent::Error`].
/// The backend owns the spawned task, so dropping it cancels the task.
fn send_fire_and_forget(&mut self, builder: EventBuilder, cx: &mut Context<Self>) {
let task = self.send(builder, cx);
@@ -1507,9 +1410,6 @@ impl Backend {
}
/// Publish NIP-09 deletions for `events`, best-effort.
/// A publish that fails midway retracts the events already broadcast to relays.
/// Failures are logged, not surfaced.
/// The caller's error already told the user what happened.
fn retract_events(&mut self, events: &[Event], cx: &mut Context<Self>) {
if events.is_empty() {
return;
@@ -1534,6 +1434,7 @@ impl Backend {
}
/// Fingerprint of a relay and filter set, for fetch dedup.
///
/// Relays and filters are sorted first, so the fingerprint is order-independent.
fn fetch_fingerprint(relays: &[&str], filters: &[Filter]) -> u64 {
let mut relays: Vec<&str> = relays.to_vec();
@@ -1548,9 +1449,6 @@ fn fetch_fingerprint(relays: &[&str], filters: &[Filter]) -> u64 {
}
/// Add the given relays, connect and fetch the filters.
/// Run a one-shot subscription, auto-closing after EOSE, then a negentropy sync per filter.
/// The second pass catches events that race the subscription or flaky EOSE behavior.
/// Relays without NEG-XX support fail the sync step, the subscription already covered them.
async fn connect_repo_relays_only(
client: &Client,
relays: Vec<RelayUrl>,
@@ -1564,8 +1462,8 @@ async fn connect_repo_relays_only(
for url in &relays {
added |= client.add_relay(url).await?;
}
// Connect only when the pool grew.
// Connected relays no-op, but the call still iterates every relay in the pool.
if added {
client.connect().await;
}
@@ -1581,8 +1479,6 @@ async fn connect_repo_relays_only(
client.subscribe(target).close_on(opts).await?;
// Sync the filters concurrently.
// Each reconciles against every relay either way.
// Without NEG-XX a relay would serialize its initial timeout behind every other filter.
let sync_opts = SyncOptions::default().initial_timeout(Duration::from_secs(5));
let syncs = filters.into_iter().map(|filter| {
let client = &client;
@@ -1599,15 +1495,13 @@ async fn connect_repo_relays_only(
}
}
});
futures::future::join_all(syncs).await;
Ok(())
}
/// Subscribe only on the bootstrap relays.
/// Auto-closes after EOSE or a short timeout.
/// Use for one-shot data fetches, repo events and profiles.
/// Not for persistent gossip-routed subscriptions.
pub(crate) async fn subscribe_bootstrap_only(
client: &Client,
filters: Vec<Filter>,
@@ -1648,16 +1542,14 @@ fn with_master_key(uri: &str, keys: &Keys) -> String {
}
/// Base URL of a grasp server, `https://<host>`.
///
/// `ws://` grasp servers use `http://<host>`, like ngit.
/// The repository then lives at `{base}/{npub}/{repo-id}.git`.
pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option<String> {
// `domain()` drops the port.
// Parse the full URL to keep it, local dev grasp servers often run on a custom port.
let parsed = Url::parse(relay.as_str()).ok()?;
let host = parsed.host_str()?;
let port = parsed.port().map(|p| format!(":{p}")).unwrap_or_default();
// `ws://` grasp servers, e.g. local dev relays, speak plain HTTP.
// Everything else is HTTPS, matching ngit.
let scheme = if relay.scheme().is_secure() {
"https"
} else {
@@ -1667,24 +1559,19 @@ pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option<String> {
}
/// GRASP clone URL of a repository on a grasp server.
/// Matches the format ngit announces, `https://<host>/<npub>/<repo-id>.git`.
fn grasp_clone_url(relay: &RelayUrl, owner: &str, repo_id: &str) -> Option<Url> {
let base = grasp_base_url(relay)?;
Url::parse(&format!("{base}/{owner}/{repo_id}.git")).ok()
}
/// GRASP-06 contributor namespace URL of a pull request tip.
/// The pattern is `{base}/prs/<author-npub>/<repo-id>.git`.
/// The npub sits in the URL, the server stores it under the hex form.
/// Anyone may push there, no announcement or maintainer rights are involved.
pub(crate) fn grasp06_prs_url(base_url: &str, npub: &str, repo_id: &str) -> String {
format!("{base_url}/prs/{npub}/{repo_id}.git")
}
/// Assemble the `clone` URLs of a pull request.
///
/// The author's GRASP-06 `/prs/` URLs come first.
/// They are author-controlled and most likely to accept the tip push.
/// The base announcement's clone URLs follow, deduplicated while preserving order.
pub(crate) fn pr_clone_urls(prs_urls: Vec<Url>, base_clone_urls: Vec<Url>) -> Vec<Url> {
let mut seen = std::collections::HashSet::new();
let mut urls = Vec::new();
@@ -1697,7 +1584,6 @@ pub(crate) fn pr_clone_urls(prs_urls: Vec<Url>, base_clone_urls: Vec<Url>) -> Ve
}
/// The `g` tag servers of one kind-10317 grasp list event, in tag order.
/// Unparseable URLs are dropped, the UI only writes well-formed servers.
fn grasp_list_servers(event: &Event) -> Vec<RelayUrl> {
event
.tags
@@ -1709,8 +1595,6 @@ fn grasp_list_servers(event: &Event) -> Vec<RelayUrl> {
}
/// Grasp servers of the newest kind-10317 grasp list among `events`.
/// The latest event wins, like other latest-wins resolutions in the app.
/// Empty when there is no list, so the caller falls back to the settings defaults.
fn latest_grasp_list_servers(events: Vec<Event>) -> Vec<RelayUrl> {
events
.into_iter()
@@ -1720,9 +1604,6 @@ fn latest_grasp_list_servers(events: Vec<Event>) -> Vec<RelayUrl> {
}
/// Resolve the user's published grasp servers.
/// Read the `g` tags of their latest kind-10317 grasp list in the local database.
/// Returns an empty list when the user has no published list.
/// The caller can then fall back to the settings defaults.
pub(crate) async fn user_grasp_list_servers(
client: Client,
user: PublicKey,
@@ -1737,10 +1618,6 @@ pub(crate) async fn user_grasp_list_servers(
}
/// Push the repository at `path` to every grasp server.
/// Rejecting servers are logged, the push only fails when no server accepted it.
/// `push` performs the single-server push.
/// [`signed_git::push_main`] serves the create flow.
/// [`signed_git::push_all`] serves the init flow.
async fn push_to_grasp_servers(
path: PathBuf,
owner: String,
@@ -1777,7 +1654,6 @@ async fn push_to_grasp_servers(
}
/// Split a stored bunker credential into the plain URI and the session key.
/// Credentials without an embedded key, legacy, get a fresh one.
fn extract_master_key(credential: &str) -> (&str, Keys) {
match credential.split_once("master=") {
Some((base, nsec)) => {