1
0
forked from GRIN/grim

goblin: forward-compatible parsing for proof-address and held-identity registries

Add serde field defaults to every field of ProofAddrRegistry, HeldIdentities,
and HeldEntry so a file written by a different build parses instead of failing:
an unknown extra field is ignored and a dropped non-essential field falls back
to its default. The counter and version defaults are supplied by helper
functions so next defaults to 1 (never 0, which is the app's default address)
and ver to 1. This is parse-side only; the on-disk write path is unchanged.
Adds forward-compat tests for both registries.
This commit is contained in:
2ro
2026-07-06 07:51:38 -04:00
parent 7e0c39964f
commit 7054efa846
2 changed files with 89 additions and 1 deletions
+40
View File
@@ -58,16 +58,19 @@ pub const MAX_IDENTITIES: usize = 8;
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct HeldEntry {
/// Public key, lowercase hex — the stable id of this identity.
#[serde(default)]
pub pubkey: String,
/// Path to the identity's `identity.json`, RELATIVE to the nostr dir:
/// `"identity.json"` for the legacy identity #1, else
/// `"identities/<hex>/identity.json"`.
#[serde(default)]
pub path: String,
/// A short human label: the identity's claimed name (local part of its NIP-05)
/// when it has one, else empty. NOT rendered — the UI derives its display from
/// the name or a truncated npub — kept only as a convenience field in the
/// index. Plaintext by design (this index carries no secret). Never a
/// placeholder word.
#[serde(default)]
pub label: String,
}
@@ -91,16 +94,27 @@ impl HeldEntry {
/// active. Persisted as `identities.json`.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct HeldIdentities {
/// Format version. Defaults to 1 when absent so a file that dropped the field
/// still parses.
#[serde(default = "default_held_ver")]
pub ver: u8,
/// Active identity, lowercase hex. Drives the single live subscription and
/// all display; the only pointer a switch moves.
#[serde(default)]
pub active: String,
/// Display order, lowercase hex.
#[serde(default)]
pub order: Vec<String>,
/// Entry metadata (no secrets).
#[serde(default)]
pub identities: Vec<HeldEntry>,
}
/// The held-index format version default (1).
fn default_held_ver() -> u8 {
1
}
impl HeldIdentities {
/// Index file path inside the nostr dir.
pub fn index_path(nostr_dir: &PathBuf) -> PathBuf {
@@ -552,6 +566,32 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn held_index_parse_is_forward_compatible() {
// A blob in the CURRENT format plus an unknown extra field still parses
// (unknown fields ignored), so a newer build's index is not rejected.
let idx: HeldIdentities = serde_json::from_str(
r#"{"ver":1,"active":"ab","order":["ab"],"identities":[{"pubkey":"ab","path":"identity.json","label":"","future_field":true}],"future_top":42}"#,
)
.unwrap();
assert_eq!(idx.ver, 1);
assert_eq!(idx.active, "ab");
assert_eq!(idx.len(), 1);
// A blob MISSING non-essential fields parses with the correct defaults:
// ver -> 1, order/identities -> empty, active -> empty.
let idx: HeldIdentities = serde_json::from_str(r#"{"active":"cd"}"#).unwrap();
assert_eq!(idx.ver, 1, "ver must default to 1");
assert_eq!(idx.active, "cd");
assert!(idx.order.is_empty());
assert!(idx.identities.is_empty());
// A HeldEntry missing its label still parses (label defaults empty).
let idx: HeldIdentities = serde_json::from_str(
r#"{"ver":1,"active":"ab","order":["ab"],"identities":[{"pubkey":"ab","path":"identity.json"}]}"#,
)
.unwrap();
assert_eq!(idx.identities[0].label, "");
}
#[test]
fn catchup_since_prefers_identity_then_wallet_then_now() {
let lookback = 3 * 86_400;
+49 -1
View File
@@ -48,13 +48,34 @@ pub const MAX_PROOF_ADDRESS_INDEX: u32 = 1023;
/// app's default address, so per-sale allocation starts at 1.
#[derive(Serialize, Deserialize, Clone, Debug)]
struct ProofAddrRegistry {
/// Format version. Defaults to 1 when absent so an older/newer file that
/// dropped the field still parses.
#[serde(default = "default_ver")]
ver: u8,
/// Next index to hand out. MUST default to 1 (never 0: index 0 is the app's
/// default address), so a file missing this field parses to the fresh-wallet
/// counter rather than re-handing-out index 0.
#[serde(default = "default_next")]
next: u32,
}
/// The registry format version default (1).
fn default_ver() -> u8 {
1
}
/// The allocation counter default. MUST be 1: index 0 is the app's default
/// address, so per-sale minting starts at 1 and never 0.
fn default_next() -> u32 {
1
}
impl Default for ProofAddrRegistry {
fn default() -> Self {
Self { ver: 1, next: 1 }
Self {
ver: default_ver(),
next: default_next(),
}
}
}
@@ -180,6 +201,33 @@ mod tests {
let _ = std::fs::remove_file(&path);
}
#[test]
fn registry_parse_is_forward_compatible() {
// A blob in the CURRENT format plus an unknown extra field still parses
// (unknown fields ignored, so it is NOT treated as corrupt).
let reg: ProofAddrRegistry =
serde_json::from_str(r#"{"ver":1,"next":7,"future_field":true}"#).unwrap();
assert_eq!(reg.next, 7);
assert_eq!(reg.ver, 1);
// A blob MISSING the non-essential field parses with the correct default:
// next defaults to 1 (never 0), ver to 1.
let reg: ProofAddrRegistry = serde_json::from_str(r#"{"ver":1}"#).unwrap();
assert_eq!(reg.next, 1, "next must default to 1, never 0");
let reg: ProofAddrRegistry = serde_json::from_str(r#"{}"#).unwrap();
assert_eq!(reg.ver, 1);
assert_eq!(reg.next, 1);
}
#[test]
fn present_registry_with_unknown_field_still_allocates() {
// Interaction with the corrupt-refuse rule: an unknown extra field is
// forward-compat, not corruption, so allocation proceeds normally.
let path = tmpfile("forward");
std::fs::write(&path, r#"{"ver":1,"next":9,"future_field":true}"#).unwrap();
assert_eq!(allocate(&path).unwrap(), 9);
let _ = std::fs::remove_file(&path);
}
#[test]
fn concurrent_allocation_hands_out_unique_indices() {
use std::collections::HashSet;