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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user