app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit c4bd463cef9a40f2f284250ccc4a72099f4c493a
parent 7c7a999058f779e63889d927d651e4e1a9cea5c7
Author: triesap <tyson@radroots.org>
Date:   Wed,  3 Jun 2026 18:18:53 -0700

guards: harden workflow copy checks

Diffstat:
Mcrates/desktop/src/source_guards.rs | 160++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/i18n/src/lib.rs | 184+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
2 files changed, 302 insertions(+), 42 deletions(-)

diff --git a/crates/desktop/src/source_guards.rs b/crates/desktop/src/source_guards.rs @@ -407,6 +407,7 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::PersonalOrdersDetailEmptyBody", "AppTextKey::PersonalOrdersDetailFarmLabel", "AppTextKey::PersonalOrdersDetailFulfillmentLabel", + "AppTextKey::PersonalOrdersDetailTotalLabel", "AppTextKey::PersonalOrdersDetailNoteLabel", "AppTextKey::PersonalOrdersDetailItemsTitle", "AppTextKey::PersonalOrdersActionCancel", @@ -476,12 +477,41 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::OrdersDetailCustomerLabel", "AppTextKey::OrdersDetailWindowLabel", "AppTextKey::OrdersDetailPickupLabel", + "AppTextKey::OrdersDetailTotalLabel", "AppTextKey::TradeWorkflowAxisAgreement", "AppTextKey::TradeWorkflowAxisRevision", "AppTextKey::TradeWorkflowAxisFulfillment", "AppTextKey::TradeWorkflowAxisInventory", "AppTextKey::TradeWorkflowAxisPayment", "AppTextKey::TradeWorkflowAxisSource", + "AppTextKey::TradeWorkflowAgreementOrdered", + "AppTextKey::TradeWorkflowAgreementConfirmed", + "AppTextKey::TradeWorkflowAgreementDeclined", + "AppTextKey::TradeWorkflowAgreementCancelled", + "AppTextKey::TradeWorkflowAgreementCompleted", + "AppTextKey::TradeWorkflowAgreementNeedsReview", + "AppTextKey::TradeWorkflowRevisionNone", + "AppTextKey::TradeWorkflowRevisionChangeProposed", + "AppTextKey::TradeWorkflowRevisionUpdated", + "AppTextKey::TradeWorkflowRevisionKeptAsPlaced", + "AppTextKey::TradeWorkflowFulfillmentConfirmed", + "AppTextKey::TradeWorkflowFulfillmentPreparing", + "AppTextKey::TradeWorkflowFulfillmentReadyForPickup", + "AppTextKey::TradeWorkflowFulfillmentOutForDelivery", + "AppTextKey::TradeWorkflowFulfillmentDelivered", + "AppTextKey::TradeWorkflowFulfillmentCancelled", + "AppTextKey::TradeWorkflowInventoryAvailable", + "AppTextKey::TradeWorkflowInventoryReserved", + "AppTextKey::TradeWorkflowInventorySoldOut", + "AppTextKey::TradeWorkflowInventoryNeedsReview", + "AppTextKey::TradeWorkflowPaymentNotRecorded", + "AppTextKey::TradeWorkflowPaymentRecorded", + "AppTextKey::TradeWorkflowPaymentNeedsReview", + "AppTextKey::TradeWorkflowProvenanceApp", + "AppTextKey::TradeWorkflowProvenanceCli", + "AppTextKey::TradeWorkflowProvenanceRelay", + "AppTextKey::TradeWorkflowProvenanceLocalEvents", + "AppTextKey::TradeWorkflowProvenanceUnknown", "AppTextKey::OrdersRecoverySectionTitle", "AppTextKey::OrdersRecoveryMissedPickupTitle", "AppTextKey::OrdersRecoveryMissedPickupBody", @@ -747,15 +777,67 @@ const REMOVED_WINDOW_HELPER_FAMILIES: &[&str] = &[ "fn home_farm_setup_blocker(", ]; -const FORBIDDEN_PAYMENT_ACTION_COPY_PATTERNS: &[&str] = &[ +const FORBIDDEN_HARDCODED_WORKFLOW_UI_LITERALS: &[&str] = &[ + "Agreement", + "Change", + "Fulfillment", + "Stock", + "Payment", + "Source", + "Ordered", + "Confirmed", + "Declined", + "Cancelled", + "Completed", + "Needs review", + "No change", + "Change proposed", + "Updated", + "Kept as placed", + "Preparing", + "Ready for pickup", + "Out for delivery", + "Delivered", + "Available", + "Reserved", + "Sold out", + "Not recorded", + "Recorded", + "App", + "CLI", + "Relay", + "Local events", + "Unknown", +]; + +const FORBIDDEN_PAYMENT_DEFERRAL_COPY_PATTERNS: &[&str] = &[ "payments are deferred", "payment is deferred", "payment deferred", + "payments deferred", + "deferred payment", + "deferred payments", "checkout unavailable", - "pay now", - "refund outside the app", + "figure it out", "payment handling outside the app", + "refund outside the app", "handle any refund outside the app", + "settle outside the app", +]; + +const FORBIDDEN_PAYMENT_ACTION_COPY_TERMS: &[&str] = &[ + "checkout", + "pay", + "refund", + "settlement", + "wallet", + "invoice", + "bank", + "card", + "processor", + "provider", + "payment-provider", + "payment provider", ]; #[test] @@ -828,15 +910,41 @@ fn desktop_window_source_does_not_use_about_placeholder_copy() { } #[test] +fn desktop_sources_do_not_hardcode_workflow_ui_copy() { + for (path, source) in launcher_source_files() { + let literals = extract_string_literals(&source); + for literal in literals { + for forbidden_literal in FORBIDDEN_HARDCODED_WORKFLOW_UI_LITERALS { + assert_ne!( + literal, + *forbidden_literal, + "{} hardcodes workflow UI copy `{forbidden_literal}`", + path.display() + ); + } + } + } +} + +#[test] fn desktop_sources_do_not_expose_reserved_payment_action_copy() { for (path, source) in launcher_source_files() { - let normalized_source = source.to_lowercase(); - for pattern in FORBIDDEN_PAYMENT_ACTION_COPY_PATTERNS { - assert!( - !normalized_source.contains(pattern), - "{} contains reserved payment action copy `{pattern}`", - path.display() - ); + for literal in extract_string_literals(&source) { + let normalized_literal = literal.to_lowercase(); + for pattern in FORBIDDEN_PAYMENT_DEFERRAL_COPY_PATTERNS { + assert!( + !normalized_literal.contains(pattern), + "{} contains forbidden payment-deferral copy `{pattern}`", + path.display() + ); + } + for term in FORBIDDEN_PAYMENT_ACTION_COPY_TERMS { + assert!( + !contains_reserved_payment_action_term(&normalized_literal, term), + "{} contains reserved payment action copy `{term}` in `{literal}`", + path.display() + ); + } } } } @@ -863,6 +971,38 @@ fn extract_string_literals(source: &str) -> BTreeSet<&str> { literals } +fn contains_reserved_payment_action_term(value: &str, term: &str) -> bool { + if term.contains(' ') || term.contains('-') { + return value.contains(term); + } + + value.match_indices(term).any(|(start, _)| { + let end = start + term.len(); + is_reserved_payment_term_boundary_before(value, start) + && is_reserved_payment_term_boundary_after(value, end) + }) +} + +fn is_reserved_payment_term_boundary_before(value: &str, index: usize) -> bool { + if index == 0 { + return true; + } + + is_reserved_payment_term_boundary_byte(value.as_bytes()[index - 1]) +} + +fn is_reserved_payment_term_boundary_after(value: &str, index: usize) -> bool { + if index == value.len() { + return true; + } + + is_reserved_payment_term_boundary_byte(value.as_bytes()[index]) +} + +fn is_reserved_payment_term_boundary_byte(byte: u8) -> bool { + !byte.is_ascii_alphanumeric() && byte != b'_' && byte != b'-' +} + fn launcher_source_files() -> Vec<(PathBuf, String)> { let mut paths = Vec::new(); collect_rust_source_files( diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs @@ -76,6 +76,47 @@ mod tests { AppTextKey, app_text, default_locale, resolve_locale_from_host, supported_locales, }; + const RESERVED_PAYMENT_ACTION_TERMS: &[&str] = &[ + "checkout", + "pay", + "refund", + "settlement", + "wallet", + "invoice", + "bank", + "card", + "processor", + "provider", + "payment-provider", + "payment provider", + ]; + + const FORBIDDEN_PAYMENT_DEFERRAL_COPY_PATTERNS: &[&str] = &[ + "payments are deferred", + "payment is deferred", + "payment deferred", + "payments deferred", + "deferred payment", + "deferred payments", + "checkout unavailable", + "figure it out", + "payment handling outside the app", + "refund outside the app", + "handle any refund outside the app", + "settle outside the app", + ]; + + const FORBIDDEN_TRADE_WORKFLOW_LEAKAGE_PATTERNS: &[&str] = &[ + "state machine", + "reducer", + "event kind", + "nostr", + "protocol", + "checkout", + "payment provider", + "payment-provider", + ]; + #[test] fn generated_catalog_matches_typed_key_registry() { let catalog_keys = super::DEFAULT_CATALOG_KEY_IDS @@ -382,45 +423,79 @@ mod tests { #[test] fn english_payment_action_copy_remains_unspoken_for_reserved_workflow() { - let action_keys = [ - AppTextKey::PersonalCartReviewOrderAction, - AppTextKey::PersonalOrderReviewBackAction, - AppTextKey::PersonalOrderReviewPlaceOrderAction, - AppTextKey::OrdersRecoveryActionOpenFollowUp, - AppTextKey::OrdersRecoveryActionStartReview, - AppTextKey::OrdersRecoveryActionMarkOpen, - AppTextKey::OrdersRecoveryActionResolve, - ]; - let forbidden_action_terms = [ - "checkout", - "pay", - "refund", - "settlement", - "wallet", - "invoice", - "bank", - "card", - "processor", - "provider", - "payment-provider", - "payment provider", - ]; + let action_keys = AppTextKey::ALL + .iter() + .copied() + .filter(|key| is_visible_action_text_key(*key)) + .collect::<Vec<_>>(); + + assert!(action_keys.contains(&AppTextKey::PersonalCartReviewOrderAction)); + assert!(action_keys.contains(&AppTextKey::PersonalOrderReviewPlaceOrderAction)); + assert!(action_keys.contains(&AppTextKey::OrdersRecoveryActionResolve)); for key in action_keys { let copy = app_text(key).to_lowercase(); - for term in forbidden_action_terms { - assert!(!copy.contains(term)); + for term in RESERVED_PAYMENT_ACTION_TERMS { + assert!( + !contains_reserved_payment_action_term(&copy, term), + "{} contains reserved payment action term `{term}`", + key.id() + ); } } + } - for copy in [ - app_text(AppTextKey::PersonalOrderReviewLocalOnlyBody), - app_text(AppTextKey::PersonalOrderCoordinationFailedNotice), - ] { + #[test] + fn english_visible_copy_does_not_explain_payment_deferral() { + for key in AppTextKey::ALL { + let normalized_copy = app_text(*key).to_lowercase(); + for pattern in FORBIDDEN_PAYMENT_DEFERRAL_COPY_PATTERNS { + assert!( + !normalized_copy.contains(pattern), + "{} contains forbidden payment-deferral copy `{pattern}`", + key.id() + ); + } + } + } + + #[test] + fn english_buyer_visible_copy_does_not_use_checkout_wording() { + for key in AppTextKey::ALL + .iter() + .copied() + .filter(|key| is_buyer_visible_text_key(*key)) + { + let normalized_copy = app_text(key).to_lowercase(); + assert!( + !contains_reserved_payment_action_term(&normalized_copy, "checkout"), + "{} contains buyer-visible checkout wording", + key.id() + ); + } + } + + #[test] + fn english_trade_workflow_copy_stays_compact_and_product_facing() { + for key in AppTextKey::ALL + .iter() + .copied() + .filter(|key| is_trade_workflow_text_key(*key)) + { + let copy = app_text(key); let normalized_copy = copy.to_lowercase(); - assert!(!normalized_copy.contains("payments are deferred")); - assert!(!normalized_copy.contains("payment deferred")); - assert!(!normalized_copy.contains("checkout unavailable")); + assert!( + copy.split_whitespace().count() <= 4, + "{} is too long for a compact workflow badge", + key.id() + ); + for pattern in FORBIDDEN_TRADE_WORKFLOW_LEAKAGE_PATTERNS { + assert!( + !normalized_copy.contains(pattern), + "{} contains workflow implementation copy `{pattern}`", + key.id() + ); + } } } @@ -1135,4 +1210,49 @@ mod tests { assert_eq!(resolve_locale_from_host(""), "en"); assert_eq!(resolve_locale_from_host("C.UTF-8"), "en"); } + + fn is_visible_action_text_key(key: AppTextKey) -> bool { + let id = key.id(); + id.contains(".action") || id.contains("_action") + } + + fn is_buyer_visible_text_key(key: AppTextKey) -> bool { + key.id().starts_with("messages.personal.") + } + + fn is_trade_workflow_text_key(key: AppTextKey) -> bool { + key.id().starts_with("messages.trade.workflow.") + } + + fn contains_reserved_payment_action_term(value: &str, term: &str) -> bool { + if term.contains(' ') || term.contains('-') { + return value.contains(term); + } + + value.match_indices(term).any(|(start, _)| { + let end = start + term.len(); + is_reserved_payment_term_boundary_before(value, start) + && is_reserved_payment_term_boundary_after(value, end) + }) + } + + fn is_reserved_payment_term_boundary_before(value: &str, index: usize) -> bool { + if index == 0 { + return true; + } + + is_reserved_payment_term_boundary_byte(value.as_bytes()[index - 1]) + } + + fn is_reserved_payment_term_boundary_after(value: &str, index: usize) -> bool { + if index == value.len() { + return true; + } + + is_reserved_payment_term_boundary_byte(value.as_bytes()[index]) + } + + fn is_reserved_payment_term_boundary_byte(byte: u8) -> bool { + !byte.is_ascii_alphanumeric() && byte != b'_' && byte != b'-' + } }