commit 521c79432c66e79fad640dad9e44f22ba876e00d
parent dc16f1e8b149012eaaabfec1e758e1e2d849d092
Author: triesap <tyson@radroots.org>
Date: Sat, 23 May 2026 17:40:38 +0000
cli: harden app listing export identity
- require canonical record owner pubkeys before app listing export
- prevent app body pubkeys or exportable hints from making unresolved records publishable
- prefer canonical record owner metadata when exporting app-authored listing drafts
- cover body-only and owner-over-body app listing record cases
Diffstat:
2 files changed, 160 insertions(+), 17 deletions(-)
diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs
@@ -72,6 +72,7 @@ const LISTING_DRAFTS_DIR: &str = "listings/drafts";
const LISTING_SELLER_ACTOR_SOURCE_FARM_CONFIG: &str = "farm_config";
const LISTING_SELLER_ACTOR_SOURCE_RESOLVED_ACCOUNT: &str = "resolved_account";
const LISTING_SELLER_ACTOR_SOURCE_REBIND: &str = "listing_rebind";
+const CANONICAL_OWNER_PUBKEY_REQUIRED_REASON: &str = "canonical hex pubkey required before export";
const APP_RECORD_LIST_LIMIT: u32 = 500;
static D_TAG_COUNTER: AtomicU64 = AtomicU64::new(0);
@@ -1343,10 +1344,7 @@ fn app_record_current_key(record: &LocalEventRecord) -> String {
}
let (listing_id, _, _) = app_listing_display_parts(record);
if let (Some(owner_pubkey), Some(listing_id)) = (
- record
- .owner_pubkey
- .as_deref()
- .and_then(canonical_hex_pubkey),
+ app_record_canonical_owner_pubkey(record),
listing_id.filter(|value| is_d_tag_base64url(value)),
) {
return format!("listing_owner:{owner_pubkey}:{listing_id}");
@@ -1385,6 +1383,13 @@ fn canonical_hex_pubkey(value: &str) -> Option<String> {
}
}
+fn app_record_canonical_owner_pubkey(record: &LocalEventRecord) -> Option<String> {
+ record
+ .owner_pubkey
+ .as_deref()
+ .and_then(canonical_hex_pubkey)
+}
+
fn app_listing_display_parts(
record: &LocalEventRecord,
) -> (Option<String>, Option<String>, Option<String>) {
@@ -1411,6 +1416,11 @@ fn app_listing_display_parts(
}
fn app_record_exportability_reason(record: &LocalEventRecord) -> Option<String> {
+ if local_record_kind(record).as_deref() == Some(DRAFT_KIND)
+ && app_record_canonical_owner_pubkey(record).is_none()
+ {
+ return Some(CANONICAL_OWNER_PUBKEY_REQUIRED_REASON.to_owned());
+ }
let exportability = record
.local_work_json
.as_ref()
@@ -1428,7 +1438,7 @@ fn app_record_exportability_reason(record: &LocalEventRecord) -> Option<String>
.unwrap_or_default();
Some(match (state, reason) {
("identity_unresolved", "canonical_hex_pubkey_required") => {
- "canonical hex pubkey required before export".to_owned()
+ CANONICAL_OWNER_PUBKEY_REQUIRED_REASON.to_owned()
}
("identity_unresolved", _) => "app record identity is unresolved".to_owned(),
(_, "") => format!("app record exportability state `{state}` is not exportable"),
@@ -1462,22 +1472,23 @@ fn app_listing_draft_from_record(
if let Some(reason) = app_record_exportability_reason(record) {
return Err(reason);
}
+ let owner_pubkey = app_record_canonical_owner_pubkey(record)
+ .ok_or_else(|| CANONICAL_OWNER_PUBKEY_REQUIRED_REASON.to_owned())?;
let document = payload
.get("document")
.cloned()
.ok_or_else(|| "record local_work_json.document is missing".to_owned())?;
let mut draft = serde_json::from_value::<ListingDraftDocument>(document)
.map_err(|error| format!("record listing document is invalid: {error}"))?;
- if draft.seller_actor.account_id.trim().is_empty()
- && let Some(account_id) = record.owner_account_id.as_ref()
- {
- draft.seller_actor.account_id = account_id.clone();
- }
- if draft.seller_actor.pubkey.trim().is_empty()
- && let Some(pubkey) = record.owner_pubkey.as_ref()
+ if let Some(account_id) = record
+ .owner_account_id
+ .as_deref()
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
{
- draft.seller_actor.pubkey = pubkey.clone();
+ draft.seller_actor.account_id = account_id.to_owned();
}
+ draft.seller_actor.pubkey = owner_pubkey;
if draft.listing.farm_d_tag.trim().is_empty()
&& let Some(farm_id) = record.farm_id.as_ref()
{
diff --git a/tests/target_cli.rs b/tests/target_cli.rs
@@ -356,8 +356,34 @@ fn seed_app_listing_record_variant_with_listing_addr(
exportability: Option<serde_json::Value>,
include_listing_addr: bool,
) -> String {
+ seed_app_listing_record_identity_variant(
+ sandbox,
+ account_id,
+ seller_pubkey,
+ seller_pubkey,
+ farm_d_tag,
+ listing_d_tag,
+ record_suffix,
+ title,
+ exportability,
+ include_listing_addr,
+ )
+}
+
+fn seed_app_listing_record_identity_variant(
+ sandbox: &RadrootsCliSandbox,
+ account_id: &str,
+ document_seller_pubkey: Option<&str>,
+ owner_pubkey: Option<&str>,
+ farm_d_tag: &str,
+ listing_d_tag: &str,
+ record_suffix: &str,
+ title: &str,
+ exportability: Option<serde_json::Value>,
+ include_listing_addr: bool,
+) -> String {
let record_id = format!("app:local_work:listing:{listing_d_tag}:{record_suffix}");
- let seller_pubkey_json = seller_pubkey
+ let seller_pubkey_json = document_seller_pubkey
.map(|value| json!(value))
.unwrap_or_else(|| json!(null));
let mut payload = json!({
@@ -416,12 +442,12 @@ fn seed_app_listing_record_variant_with_listing_addr(
created_at_ms: 1_779_000_002_000,
inserted_at_ms: 1_779_000_002_000,
owner_account_id: Some(account_id.to_owned()),
- owner_pubkey: seller_pubkey.map(str::to_owned),
+ owner_pubkey: owner_pubkey.map(str::to_owned),
farm_id: Some(farm_d_tag.to_owned()),
listing_addr: include_listing_addr
- .then_some(seller_pubkey)
+ .then_some(owner_pubkey)
.flatten()
- .map(|seller_pubkey| format!("30402:{seller_pubkey}:{listing_d_tag}")),
+ .map(|owner_pubkey| format!("30402:{owner_pubkey}:{listing_d_tag}")),
local_work_json: Some(payload),
event_id: None,
event_kind: None,
@@ -4002,6 +4028,112 @@ fn listing_app_records_mark_unresolved_pubkey_records_non_exportable() {
}
#[test]
+fn listing_app_records_ignore_body_pubkey_without_owner_metadata() {
+ let sandbox = RadrootsCliSandbox::new();
+ let account_id = "acct_body_only";
+ let body_pubkey = identity_public(91).public_key_hex;
+ let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAw";
+ let listing_d_tag = "AAAAAAAAAAAAAAAAAAAAAQ";
+ let record_id = seed_app_listing_record_identity_variant(
+ &sandbox,
+ account_id,
+ Some(body_pubkey.as_str()),
+ None,
+ farm_d_tag,
+ listing_d_tag,
+ "body-only",
+ "Body Only App Eggs",
+ Some(json!({ "state": "exportable" })),
+ false,
+ );
+
+ let list = sandbox.json_success(&["--format", "json", "listing", "app", "list"]);
+ assert_eq!(list["result"]["count"], 1);
+ let listing_row = &list["result"]["records"][0];
+ assert_eq!(listing_row["record_id"], record_id);
+ assert_eq!(listing_row["title"], "Body Only App Eggs");
+ assert_eq!(listing_row["exportable"], false);
+ assert_eq!(
+ listing_row["reason"],
+ "canonical hex pubkey required before export"
+ );
+ assert!(
+ listing_row
+ .get("actions")
+ .and_then(Value::as_array)
+ .is_none_or(Vec::is_empty)
+ );
+
+ let export_path = sandbox.root().join("body-only-app-eggs.toml");
+ let export_path_arg = export_path.to_string_lossy();
+ let (output, export) = sandbox.json_output(&[
+ "--format",
+ "json",
+ "listing",
+ "app",
+ "export",
+ record_id.as_str(),
+ "--output",
+ export_path_arg.as_ref(),
+ ]);
+ assert!(!output.status.success());
+ assert_eq!(export["result"], Value::Null);
+ assert_eq!(export["errors"][0]["detail"]["state"], "unsupported");
+ assert_eq!(
+ export["errors"][0]["message"],
+ "canonical hex pubkey required before export"
+ );
+ assert!(!export_path.exists());
+}
+
+#[test]
+fn listing_app_records_export_uses_record_owner_over_body_pubkey() {
+ let sandbox = RadrootsCliSandbox::new();
+ let account = sandbox.json_success(&["--format", "json", "account", "create"]);
+ let account_id = account["result"]["account"]["id"]
+ .as_str()
+ .expect("account id");
+ let signer = sandbox.json_success(&["--format", "json", "signer", "status", "get"]);
+ let owner_pubkey = signer["result"]["local"]["public_identity"]["public_key_hex"]
+ .as_str()
+ .expect("seller pubkey");
+ let body_pubkey = identity_public(92).public_key_hex;
+ let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAw";
+ let listing_d_tag = "AAAAAAAAAAAAAAAAAAAAAQ";
+ let record_id = seed_app_listing_record_identity_variant(
+ &sandbox,
+ account_id,
+ Some(body_pubkey.as_str()),
+ Some(owner_pubkey),
+ farm_d_tag,
+ listing_d_tag,
+ "owner-wins",
+ "Owner Wins App Eggs",
+ None,
+ true,
+ );
+
+ let export_path = sandbox.root().join("owner-wins-app-eggs.toml");
+ let export_path_arg = export_path.to_string_lossy();
+ let export = sandbox.json_success(&[
+ "--format",
+ "json",
+ "listing",
+ "app",
+ "export",
+ record_id.as_str(),
+ "--output",
+ export_path_arg.as_ref(),
+ ]);
+ assert_eq!(export["operation_id"], "listing.app.export");
+ assert_eq!(export["result"]["state"], "exported");
+ assert_eq!(export["result"]["seller_pubkey"], owner_pubkey);
+ let exported_contents = fs::read_to_string(&export_path).expect("exported listing draft");
+ assert!(exported_contents.contains(format!("pubkey = \"{owner_pubkey}\"").as_str()));
+ assert!(!exported_contents.contains(body_pubkey.as_str()));
+}
+
+#[test]
fn farm_publish_writes_acknowledged_signed_outbox_records() {
let sandbox = RadrootsCliSandbox::new();
sandbox.json_success(&["--format", "json", "account", "create"]);