//! Private beta feedback drafts. No capture, signing or network publication is implicit. //! These primitives deliberately do not accept arbitrary issue repositories or raw logs. use anyhow::{ensure, Context, Result}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::path::Path; use tokio::sync::Mutex; #[path = "feedback/outbox.rs"] pub mod outbox; static LOCK: Mutex<()> = Mutex::const_new(()); const MAX_TEXT: usize = 24 * 1024; const MAX_FILE: usize = 256 * 1024; const MAX_DRAFTS: usize = 20; const OWNER: &str = "npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg"; #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Description { pub subject: String, pub description: String, pub reproduction: String, pub expected: String, pub actual: String, } #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct OperationContext { pub method: String, pub error_code: Option, pub correlation_id: Option, } #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Draft { pub id: String, pub created_at: i64, pub author_pubkey: String, #[serde(default)] pub private_operation: Option, pub description: Description, pub diagnostics: Value, #[serde(default)] pub diagnostic_sections: Vec, pub preview: Value, pub review_digest: String, pub signed_event: Option, #[serde(default)] pub destination: Option, #[serde(default)] pub attachments: Vec, #[serde(default)] pub attempts: Vec, } /// Drop whole suspect lines rather than attempting to surgically preserve secrets. /// This is defence in depth, not a promise that arbitrary prose is anonymous. /// The caller must display these exact resulting bytes for explicit owner review. pub fn redact(input: &str) -> String { static SENSITIVE: std::sync::OnceLock = std::sync::OnceLock::new(); let sensitive = SENSITIVE.get_or_init(|| regex::Regex::new(r"(?i)(password|passwd|secret|token|cookie|authorization|bearer|address|(?:invoice|payment|balance|transaction)\s*[:=]|sats|txid|preimage|macaroon|mnemonic|seed phrase|private.?key|nsec1|cashu[ab]|lnbc|lntb|lnurl|https?://|wss?://|\.onion|\.local\b|[a-z0-9-]+\.[a-z]{2,}\b|[0-9a-f]{8}-[0-9a-f-]{20,}|(?:^|\s)/[^\s]+|(?:[0-9]{1,3}\.){3}[0-9]{1,3}|[0-9a-f]{0,4}:[0-9a-f:]{2,}|/home/|/Users/|@|did:key|npub1|[0-9a-f]{64}|bc1[ac-hj-np-z02-9]{10,})").expect("constant redaction expression")); input .lines() .map(|line| { if sensitive.is_match(line) { "[private details removed]" } else { line } }) .collect::>() .join("\n") } fn reviewed_description(mut d: Description) -> Result { ensure!( !d.subject.trim().is_empty() && d.subject.len() <= 160, "Feedback needs a subject of at most 160 bytes" ); let fields = [ &mut d.subject, &mut d.description, &mut d.reproduction, &mut d.expected, &mut d.actual, ]; ensure!( fields.iter().map(|s| s.len()).sum::() <= MAX_TEXT, "Feedback text exceeds 24 KiB" ); for field in fields { ensure!( !field .chars() .any(|c| c.is_control() && c != '\n' && c != '\t'), "Feedback contains control characters" ); *field = redact(field); } Ok(d) } #[path = "feedback/diagnostics.rs"] mod diagnostics; fn preview(d: &Description, facts: &Value, now: i64) -> Result { let owner = nostr_sdk::PublicKey::parse(OWNER)?.to_hex(); let content = format!("## Description\n{}\n\n## Reproduction\n{}\n\n## Expected\n{}\n\n## Actual\n{}\n\n## Diagnostics\n```json\n{}\n```", d.description, d.reproduction, d.expected, d.actual, serde_json::to_string_pretty(facts)?); Ok(json!({"kind":1621, "created_at":now, "content":content, "tags":[["a",format!("30617:{owner}:archy")],["p",owner],["subject",d.subject],["t","feedback from beta"]]})) } fn path(root: &Path, id: &str) -> Result { let parsed = uuid::Uuid::parse_str(id).context("Invalid feedback draft id")?; ensure!(parsed.to_string() == id, "Noncanonical feedback draft id"); Ok(root.join("feedback").join(format!("{id}.json"))) } async fn save(root: &Path, draft: &Draft) -> Result<()> { use tokio::io::AsyncWriteExt; let destination = path(root, &draft.id)?; let dir = root.join("feedback"); tokio::fs::create_dir_all(&dir).await?; let metadata = tokio::fs::symlink_metadata(&dir).await?; ensure!( metadata.is_dir() && !metadata.file_type().is_symlink(), "Private feedback directory must not be a symlink" ); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; tokio::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).await?; } let bytes = serde_json::to_vec(draft)?; ensure!( bytes.len() <= MAX_FILE, "Feedback draft exceeds storage limit" ); let tmp = dir.join(format!("{}.tmp", uuid::Uuid::new_v4())); let mut options = tokio::fs::OpenOptions::new(); options.create_new(true).write(true); #[cfg(unix)] options.mode(0o600); let result = async { let mut file = options.open(&tmp).await?; file.write_all(&bytes).await?; file.sync_all().await?; tokio::fs::rename(&tmp, destination).await?; tokio::fs::File::open(&dir).await?.sync_all().await?; Ok::<(), anyhow::Error>(()) } .await; if result.is_err() { let _ = tokio::fs::remove_file(tmp).await; } result } async fn read_private(path: &Path, maximum: usize) -> Result> { use tokio::io::AsyncReadExt; let mut options = tokio::fs::OpenOptions::new(); options.read(true); #[cfg(unix)] options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); let file = options .open(path) .await .context("Private feedback file unavailable")?; let metadata = file.metadata().await?; ensure!( metadata.is_file() && metadata.len() <= maximum as u64, "Private feedback file exceeds its bound or is not regular" ); let mut bytes = Vec::new(); file.take(maximum as u64 + 1) .read_to_end(&mut bytes) .await?; ensure!( bytes.len() <= maximum, "Private feedback file grew beyond its bound" ); Ok(bytes) } pub async fn load(root: &Path, id: &str) -> Result { let directory = tokio::fs::symlink_metadata(root.join("feedback")).await?; ensure!( directory.is_dir() && !directory.file_type().is_symlink(), "Invalid private feedback directory" ); let draft: Draft = serde_json::from_slice(&read_private(&path(root, id)?, MAX_FILE).await?)?; ensure!(draft.id == id, "Feedback draft binding mismatch"); ensure!( draft.review_digest == digest(&draft)?, "Feedback preview changed" ); outbox::validate_retained(&draft)?; Ok(draft) } pub async fn collect( root: &Path, description: Description, author_pubkey: &str, app_snapshot: Value, operation: Option, ) -> Result { let author_pubkey = nostr_sdk::PublicKey::from_hex(author_pubkey)?.to_hex(); let _lock = LOCK.lock().await; let description = reviewed_description(description)?; if let Some(operation) = &operation { ensure!( operation.method.len() <= 80 && operation .method .bytes() .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b".-".contains(&b)), "Invalid failed method" ); ensure!( operation.correlation_id.as_ref().is_none_or( |id| id.len() <= 80 && id.bytes().all(|b| b.is_ascii_hexdigit() || b == b'-') ), "Invalid private correlation reference" ); } let now = chrono::Utc::now().timestamp(); let directory = root.join("feedback"); let mut count = 0; match tokio::fs::read_dir(&directory).await { Ok(mut entries) => { while let Some(entry) = entries.next_entry().await? { if entry.path().extension().and_then(|s| s.to_str()) != Some("json") { continue; } count += 1; ensure!( count < MAX_DRAFTS, "Private feedback storage is full; export older reports first" ); let modified = entry.metadata().await?.modified()?; let age = std::time::SystemTime::now() .duration_since(modified) .unwrap_or_default(); ensure!( age.as_secs() >= 60, "Wait one minute before collecting another feedback report" ); } } Err(e) if e.kind() == std::io::ErrorKind::NotFound => (), Err(e) => return Err(e.into()), } let mut facts = diagnostics::collect(root).await; facts["apps"] = app_snapshot; facts["failed_operation"] = operation.as_ref().map(|op|json!({"method":op.method,"error_code":op.error_code,"correlation_reference_retained_privately":op.correlation_id.is_some()})).unwrap_or_else(||json!({"status":"unavailable","reason":"no triggering operation supplied"})); let mut event = preview(&description, &facts, now)?; event["pubkey"] = json!(author_pubkey); let mut draft = Draft { id: uuid::Uuid::new_v4().to_string(), created_at: now, author_pubkey, private_operation: operation, description, diagnostic_sections: facts .as_object() .map(|v| v.keys().cloned().collect()) .unwrap_or_default(), diagnostics: facts, review_digest: blake3::hash(&serde_json::to_vec(&event)?) .to_hex() .to_string(), preview: event, signed_event: None, destination: None, attachments: Vec::new(), attempts: Vec::new(), }; draft.review_digest = digest(&draft)?; save(root, &draft).await?; Ok(draft) } fn digest(draft: &Draft) -> Result { Ok(blake3::hash(&serde_json::to_vec( &json!({"event":draft.preview,"destination":draft.destination}), )?) .to_hex() .to_string()) } /// Persist the exact reviewed signed event BEFORE any network send. A retry can /// only retain the same event id, never silently sign a second public issue. pub async fn retain_reviewed( root: &Path, id: &str, digest: &str, acknowledge_public: bool, event: nostr_sdk::Event, ) -> Result { let _lock = LOCK.lock().await; ensure!( acknowledge_public, "Review the exact public report before submitting" ); let mut draft = load(root, id).await?; ensure!( draft.review_digest == digest, "Report changed; review the current preview" ); ensure!( draft.destination.is_some() && draft.attachments.iter().all(|a| a.uploaded), "Choose a verified repository and complete reviewed uploads before signing" ); event.verify().context("Invalid feedback signature")?; validate_event_fields(&draft.preview, &serde_json::to_value(&event)?)?; if let Some(original) = &draft.signed_event { ensure!( original.id == event.id, "This report already has a signed event; retry the original" ); return Ok(draft); } draft.signed_event = Some(event); save(root, &draft).await?; Ok(draft) } fn validate_event_fields(preview: &Value, event: &Value) -> Result<()> { for field in ["kind", "created_at", "content", "tags", "pubkey"] { ensure!( event[field] == preview[field], "Signed event differs from reviewed feedback" ); } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn redaction_drops_whole_secret_and_network_lines() { for line in [ "token=abc", "node at 100.1.2.3", "https://example.com/path", "user@example.com", "nsec1private", "cashuAabc", "at /home/alice/key", "fd00::1234", "bc1qsecretaddress123456", ] { assert_eq!(redact(line), "[private details removed]"); } assert_eq!(redact("App failed\nstatus 500"), "App failed\nstatus 500"); } #[test] fn every_reviewed_field_is_bound_including_public_author() { let original = json!({"kind":1621,"created_at":123,"content":"reviewed", "tags":[["t","feedback from beta"]], "pubkey":"selected-persona"}); assert!(validate_event_fields(&original, &original).is_ok()); for field in ["kind", "created_at", "content", "tags", "pubkey"] { let mut changed = original.clone(); changed[field] = json!("changed"); assert!(validate_event_fields(&original, &changed).is_err()); } } #[test] fn exact_issue_target_and_space_label_are_preserved() { let d = Description { subject: "App problem".into(), description: "failed".into(), reproduction: "open".into(), expected: "ready".into(), actual: "stopped".into(), }; let e = preview(&d, &json!({"status":"unavailable"}), 123).unwrap(); assert_eq!(e["kind"], 1621); assert_eq!(e["tags"][3], json!(["t", "feedback from beta"])); assert!(e["tags"][0][1].as_str().unwrap().ends_with(":archy")); assert!(path(Path::new("/tmp"), "../../secret").is_err()); } }