Files

388 lines
14 KiB
Rust

//! 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<i64>,
pub correlation_id: Option<String>,
}
#[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<OperationContext>,
pub description: Description,
pub diagnostics: Value,
#[serde(default)]
pub diagnostic_sections: Vec<String>,
pub preview: Value,
pub review_digest: String,
pub signed_event: Option<nostr_sdk::Event>,
#[serde(default)]
pub destination: Option<outbox::Destination>,
#[serde(default)]
pub attachments: Vec<outbox::Attachment>,
#[serde(default)]
pub attempts: Vec<outbox::SendAttempt>,
}
/// 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<regex::Regex> = 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::<Vec<_>>()
.join("\n")
}
fn reviewed_description(mut d: Description) -> Result<Description> {
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::<usize>() <= 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<Value> {
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<std::path::PathBuf> {
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<Vec<u8>> {
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<Draft> {
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<OperationContext>,
) -> Result<Draft> {
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<String> {
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<Draft> {
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());
}
}