388 lines
14 KiB
Rust
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());
|
|
}
|
|
}
|