diff --git a/Cargo.lock b/Cargo.lock index f44b6fcd4..a215cf943 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1016,6 +1016,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "diffy" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05264ab2aab4fb952fc4b0f3f6eff1ddfb4563064053a4ea174d91537584a769" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "digest" version = "0.10.7" @@ -1484,6 +1493,9 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -3913,6 +3925,7 @@ version = "0.1.0" dependencies = [ "base64", "clap", + "diffy", "hex", "infer", "nostr", diff --git a/crates/sprout-cli/Cargo.toml b/crates/sprout-cli/Cargo.toml index a0a488071..3fb8b0170 100644 --- a/crates/sprout-cli/Cargo.toml +++ b/crates/sprout-cli/Cargo.toml @@ -45,9 +45,12 @@ sprout-core = { workspace = true } # Base64 encoding — NIP-98 event serialization for Authorization header base64 = "0.22" -# SHA-256 — NIP-98 payload hash tag and Blossom file hash +# SHA-256 — NIP-98 payload hash tag, Blossom file hash, mem patch base-hash sha2 = "0.11" +# Unified-diff parser and strict applier — `mem patch` +diffy = "0.5" + # Hex encoding — SHA-256 hash output for Blossom uploads hex = { workspace = true } diff --git a/crates/sprout-cli/src/commands/mem.rs b/crates/sprout-cli/src/commands/mem.rs index 7fcd46902..6136a1f64 100644 --- a/crates/sprout-cli/src/commands/mem.rs +++ b/crates/sprout-cli/src/commands/mem.rs @@ -3,7 +3,9 @@ //! Subcommands: //! - `sprout mem ls` — list non-tombstoned memories //! - `sprout mem get ` — print the value to stdout +//! - `sprout mem hash ` — print sha256(value) hex //! - `sprout mem set ` — write a value (use `-` for stdin) +//! - `sprout mem patch ` — apply a unified diff to the current value //! - `sprout mem rm ` — publish a tombstone //! //! The caller's `SPROUT_PRIVATE_KEY` is the agent's nsec. The agent's owner @@ -14,6 +16,8 @@ use std::io::Read; use std::time::SystemTime; +use sha2::{Digest, Sha256}; + use nostr::PublicKey; use sprout_core::engram::{ self, conversation_key, d_tag, normalize_slug, select_head, validate_and_decrypt, Body, Listing, @@ -261,11 +265,18 @@ pub async fn cmd_get( /// `sprout mem set ` — write a value or core profile. /// /// Pass `-` to read the value from stdin. +/// +/// Guardrail: when reading from stdin, an empty read is **rejected** unless +/// `--allow-empty` is passed. This catches the common failure mode where an +/// upstream pipeline step errors out, closes its stdout, and `mem set` would +/// otherwise commit an empty value — silently destroying the slug. +/// A literal `""` positional argument is still accepted (explicit intent). pub async fn cmd_set( client: &SproutClient, raw_slug: &str, raw_value: &str, owner_flag: Option<&str>, + allow_empty: bool, ) -> Result<(), CliError> { let slug = normalize_slug(raw_slug).map_err(|e| CliError::Usage(format!("invalid slug: {e}")))?; @@ -285,6 +296,14 @@ pub async fn cmd_set( engram::NIP44_PLAINTEXT_MAX ))); } + if buf.is_empty() && !allow_empty { + return Err(CliError::Usage( + "refusing to write empty value from stdin (an upstream pipeline step likely \ + failed). Pass --allow-empty to confirm, or use `sprout mem rm ` to \ + tombstone." + .into(), + )); + } buf } else { raw_value.to_string() @@ -311,6 +330,329 @@ pub async fn cmd_set( Ok(()) } +/// Compute the canonical hex-encoded SHA-256 of a UTF-8 string. Matches +/// `printf '%s' "$value" | sha256sum`, so operators can verify base-hash +/// from the shell. +fn sha256_hex(s: &str) -> String { + let mut h = Sha256::new(); + h.update(s.as_bytes()); + hex::encode(h.finalize()) +} + +/// Verify that each hunk's preimage lines (Context + Delete) match the +/// current value byte-for-byte starting at the line number the hunk declares. +/// +/// Diffy's `apply` is strict on context content but will *slide* a hunk +/// forward or backward through the file to find a position where the +/// preimage matches. For memory-edit safety we want the stronger property: +/// the patch must apply at exactly the line number it was generated against. +/// Drift in line numbers usually means lines were inserted or deleted before +/// the hunk — at which point regenerating the patch is the correct response, +/// not silently landing the change at a different position. +/// +/// Returns `Ok(())` on a clean match, `Err(message)` otherwise. +/// +/// Line-number convention: unified-diff `@@ -N,M @@` uses 1-based line +/// numbers. A pure-insertion hunk against an empty file is encoded as +/// `@@ -0,0 +1,M @@` (`start == 0`, `len == 0`), which we treat as +/// "apply at index 0 of an empty preimage." +fn verify_hunks_at_declared_position( + current: &str, + patch: &diffy::Patch<'_, str>, +) -> Result<(), String> { + // `split_inclusive('\n')` preserves the trailing newline on each line, + // matching diffy's own line representation. A value with no trailing + // newline produces a last segment with no `\n`, which also matches how + // diffy stores the "no newline at EOF" case (parser strips the `\n`). + let current_lines: Vec<&str> = current.split_inclusive('\n').collect(); + + for (i, hunk) in patch.hunks().iter().enumerate() { + let preimage: Vec<&str> = hunk + .lines() + .iter() + .filter_map(|l| match l { + diffy::Line::Context(s) | diffy::Line::Delete(s) => Some(*s), + diffy::Line::Insert(_) => None, + }) + .collect(); + + // Pure insertion at start of empty file: `@@ -0,0 +1,M @@`. + // + // Known limitation: a pure-insertion hunk into a non-empty value + // (`@@ -N,0 +N,M @@` with `N > 0`) is currently rejected. With no + // preimage lines there's nothing to position-check against, and the + // safe-default for a strict mode is "refuse" rather than "land at an + // unverified position." `diff -u` includes context lines by default, + // so users hit this only if they hand-author a no-context insertion. + // Failure mode is rejection, not corruption — see PR #627 review. + if preimage.is_empty() { + if hunk.old_range().start() == 0 { + continue; + } + return Err(format!( + "hunk #{} has empty preimage at line {}; \ + pure no-context insertions into non-empty values are not \ + supported (regenerate the patch with `diff -u` to include \ + surrounding context)", + i + 1, + hunk.old_range().start() + )); + } + + // Convert 1-based line number to 0-based index. + let declared_start = hunk + .old_range() + .start() + .checked_sub(1) + .ok_or_else(|| format!("hunk #{} has invalid line number 0", i + 1))?; + + let end = declared_start + .checked_add(preimage.len()) + .ok_or_else(|| format!("hunk #{} line range overflows", i + 1))?; + if end > current_lines.len() { + return Err(format!( + "hunk #{} expects {} preimage line(s) starting at line {}, \ + but the value only has {} line(s)", + i + 1, + preimage.len(), + declared_start + 1, + current_lines.len() + )); + } + + for (offset, expected) in preimage.iter().enumerate() { + let actual = current_lines[declared_start + offset]; + if *expected != actual { + return Err(format!( + "hunk #{} preimage mismatch at line {}: \ + patch expects {:?} but value has {:?}", + i + 1, + declared_start + offset + 1, + expected, + actual + )); + } + } + } + Ok(()) +} + +/// Extract the current slug value as a `String` (or return `NotFound`). +/// Used by `mem hash` and `mem patch` — they both need "the value or fail". +/// Returns `(head_event, value)` so the caller can preserve monotonic ordering. +async fn fetch_value( + client: &SproutClient, + owner: &PublicKey, + slug: &str, +) -> Result<(nostr::Event, String), CliError> { + let (head, body) = fetch_head(client, owner, slug).await?; + match (head, body) { + (None, _) => Err(CliError::NotFound(format!("not found: {slug}"))), + (_, None) => Err(CliError::NotFound(format!("not found: {slug}"))), + (_, Some(Body::Memory { value: None, .. })) => { + Err(CliError::NotFound(format!("tombstoned: {slug}"))) + } + (Some(head), Some(Body::Memory { value: Some(v), .. })) => Ok((head, v)), + (Some(head), Some(Body::Core { profile })) => Ok((head, profile)), + } +} + +/// `sprout mem hash ` — print sha256(value) in hex to stdout. +/// +/// The output is a 64-character hex digest followed by a newline (line- +/// oriented for shell use). Use this to capture a base-hash before editing, +/// then pass it to `sprout mem patch --base-hash ` to make the edit +/// safe against concurrent writes. +pub async fn cmd_hash( + client: &SproutClient, + raw_slug: &str, + owner_flag: Option<&str>, +) -> Result<(), CliError> { + let slug = + normalize_slug(raw_slug).map_err(|e| CliError::Usage(format!("invalid slug: {e}")))?; + let owner = resolve_owner(client, owner_flag)?; + let (_head, value) = fetch_value(client, &owner, &slug).await?; + println!("{}", sha256_hex(&value)); + Ok(()) +} + +/// `sprout mem patch ` — apply a unified diff to the current value. +/// +/// Reads a unified diff from stdin (or `--patch-file `), fetches the +/// current head, applies the diff with **strict context matching** (no +/// content fuzz; diffy will refuse a hunk whose context lines don't match +/// the file verbatim), and writes the result. +/// +/// Safety properties: +/// - `--base-hash ` is **required** unless `--no-base-hash` is passed. +/// This makes concurrent edits safe: if the slug has changed since the +/// patch was generated, the write is refused. +/// - The result is rejected if it would be empty, unless `--allow-empty`. +/// - `--dry-run` prints the post-application diff and exits without writing. +/// - On a successful write, the new sha256 is printed to stderr so callers +/// can chain edits. +#[allow(clippy::too_many_arguments)] +pub async fn cmd_patch( + client: &SproutClient, + raw_slug: &str, + patch_path: Option<&str>, + base_hash: Option<&str>, + no_base_hash: bool, + dry_run: bool, + allow_empty: bool, + owner_flag: Option<&str>, +) -> Result<(), CliError> { + let slug = + normalize_slug(raw_slug).map_err(|e| CliError::Usage(format!("invalid slug: {e}")))?; + + // Require an explicit base-hash decision: this is the whole point of the + // command vs. raw stdin pipelines. + match (base_hash, no_base_hash) { + (Some(_), true) => { + return Err(CliError::Usage( + "--base-hash and --no-base-hash are mutually exclusive".into(), + )); + } + (None, false) => { + return Err(CliError::Usage( + "missing --base-hash (run `sprout mem hash ` to get it). \ + Pass --no-base-hash to skip this check at your own risk." + .into(), + )); + } + _ => {} + } + if let Some(h) = base_hash { + if h.len() != 64 || !h.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(CliError::Usage( + "--base-hash must be a 64-character hex sha256 digest".into(), + )); + } + } + + // Read the diff (stdin or file). We bound it the same as `set` since the + // resulting value can't exceed the NIP-44 cap anyway — and we don't want + // a 4 GB malformed patch to OOM us. + let limit = engram::NIP44_PLAINTEXT_MAX + 1; + let diff_text = match patch_path { + Some(path) => std::fs::read_to_string(path) + .map_err(|e| CliError::Usage(format!("failed to read --patch-file {path}: {e}")))?, + None => { + let mut buf = String::new(); + std::io::stdin() + .take(limit as u64) + .read_to_string(&mut buf) + .map_err(|e| CliError::Other(format!("stdin read failed: {e}")))?; + if buf.is_empty() { + return Err(CliError::Usage( + "refusing to apply empty patch from stdin (an upstream pipeline step likely \ + failed)" + .into(), + )); + } + buf + } + }; + + let owner = resolve_owner(client, owner_flag)?; + let (head, current) = fetch_value(client, &owner, &slug).await?; + + // Base-hash gate: concurrent-edit safety. + if let Some(expected) = base_hash { + let actual = sha256_hex(¤t); + if actual != expected.to_ascii_lowercase() { + return Err(CliError::Conflict(format!( + "slug `{slug}` has changed since patch was generated \ + (expected sha256 {expected}, got {actual}). Re-fetch and regenerate the patch." + ))); + } + } + + // Reject multi-file patches. A memory slug is a single virtual file; a + // patch with multiple `--- ` headers is ambiguous and almost certainly an + // operator mistake (e.g. piping a multi-file `git diff` output here). + let file_header_count = diff_text.lines().filter(|l| l.starts_with("--- ")).count(); + if file_header_count > 1 { + return Err(CliError::Usage(format!( + "multi-file patch not supported (found {file_header_count} `--- ` headers); \ + a memory slug is a single virtual file" + ))); + } + + let patch = diffy::Patch::from_str(&diff_text) + .map_err(|e| CliError::Usage(format!("malformed unified diff: {e}")))?; + + // Strict positional check — diffy's `apply` allows the hunk to slide + // forward/backward in the file if it finds the preimage elsewhere. For + // memory edits we want the stronger guarantee: the hunk must apply at + // exactly the line number it claims. If the file has drifted enough that + // the hunk's declared position no longer matches, we'd rather refuse and + // make the operator regenerate the patch than risk landing the change + // somewhere unintended. + verify_hunks_at_declared_position(¤t, &patch).map_err(|msg| { + CliError::Usage(format!( + "patch did not apply cleanly to slug `{slug}`: {msg}. \ + Context must match the current value verbatim at the declared \ + line numbers — no fuzz, no offset." + )) + })?; + + let new_value = diffy::apply(¤t, &patch).map_err(|e| { + CliError::Usage(format!( + "patch did not apply cleanly to slug `{slug}`: {e}. \ + Context must match the current value verbatim — no fuzz, no offset." + )) + })?; + + if new_value.len() > engram::NIP44_PLAINTEXT_MAX { + return Err(CliError::Usage(format!( + "patched value would exceed {}-byte NIP-44 plaintext limit \ + (got {} bytes)", + engram::NIP44_PLAINTEXT_MAX, + new_value.len() + ))); + } + if new_value.is_empty() && !allow_empty { + return Err(CliError::Usage( + "refusing to write empty value (patch result is empty). \ + Pass --allow-empty to confirm, or use `sprout mem rm ` to tombstone." + .into(), + )); + } + + // Echo the *input* patch verbatim (not a regenerated form) plus the + // resulting sha256, so the operator can review exactly what was applied + // and chain follow-up edits with the new hash. + let new_hash = sha256_hex(&new_value); + eprintln!("{}", diff_text.trim_end_matches('\n')); + eprintln!(); + if dry_run { + eprintln!("(dry run — slug `{slug}` not modified; would write sha256 {new_hash})"); + return Ok(()); + } + + let body = if slug == engram::CORE_SLUG { + Body::Core { + profile: new_value.clone(), + } + } else { + Body::Memory { + slug: slug.clone(), + value: Some(new_value.clone()), + } + }; + let prior_created_at = Some(head.created_at.as_u64()); + let created_at = engram::monotonic_created_at(now_secs(), prior_created_at); + + let agent = client.keys(); + let event = engram::build_event(agent, &owner, &body, created_at) + .map_err(|e| CliError::Other(format!("build event failed: {e}")))?; + let id = event.id.to_hex(); + submit_engram(client, event).await?; + eprintln!("wrote {slug} (event {id}, created_at {created_at}, sha256 {new_hash})"); + Ok(()) +} + /// `sprout mem rm ` — publish a tombstone (`value: null`). /// /// `rm core` writes a tombstone-shaped body, but a core tombstone has no @@ -356,9 +698,243 @@ pub async fn dispatch(cmd: crate::MemCmd, client: &SproutClient) -> Result<(), C match cmd { MemCmd::Ls { owner, json } => cmd_ls(client, owner.as_deref(), json).await, MemCmd::Get { slug, owner } => cmd_get(client, &slug, owner.as_deref()).await, - MemCmd::Set { slug, value, owner } => { - cmd_set(client, &slug, &value, owner.as_deref()).await + MemCmd::Hash { slug, owner } => cmd_hash(client, &slug, owner.as_deref()).await, + MemCmd::Set { + slug, + value, + owner, + allow_empty, + } => cmd_set(client, &slug, &value, owner.as_deref(), allow_empty).await, + MemCmd::Patch { + slug, + patch_file, + base_hash, + no_base_hash, + dry_run, + allow_empty, + owner, + } => { + cmd_patch( + client, + &slug, + patch_file.as_deref(), + base_hash.as_deref(), + no_base_hash, + dry_run, + allow_empty, + owner.as_deref(), + ) + .await } MemCmd::Rm { slug, owner } => cmd_rm(client, &slug, owner.as_deref()).await, } } + +#[cfg(test)] +mod tests { + use super::*; + + // sha256_hex must match `printf '%s' "$value" | sha256sum` so operators can + // verify base-hash from the shell. Hard-coded vectors from the NIST and + // common quick-check inputs. + + #[test] + fn sha256_hex_empty() { + assert_eq!( + sha256_hex(""), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + } + + #[test] + fn sha256_hex_abc() { + assert_eq!( + sha256_hex("abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + + #[test] + fn sha256_hex_handles_newline_terminated_value() { + // `mem get` writes the raw value with no trailing newline added; a + // value that itself ends in '\n' must hash the newline as part of + // the content. Confirms we hash the bytes verbatim. + assert_eq!( + sha256_hex("abc\n"), + "edeaaff3f1774ad2888673770c6d64097e391bc362d7d6fb34982ddf0efd18cb" + ); + } + + // Strict-context behavior: diffy::apply must refuse a hunk whose context + // lines don't match the current value. This pins the property `mem patch` + // depends on; if a future diffy upgrade loosens it, this test catches it. + + #[test] + fn diffy_apply_refuses_mismatched_context() { + let current = "alpha\nbeta\ngamma\n"; + // Patch references "BETA" (wrong case) as context — must fail. + let bad_patch = "\ +--- a/x ++++ b/x +@@ -1,3 +1,3 @@ + alpha +-BETA ++delta + gamma +"; + let patch = diffy::Patch::from_str(bad_patch).unwrap(); + assert!(diffy::apply(current, &patch).is_err()); + } + + #[test] + fn diffy_apply_succeeds_on_exact_context() { + let current = "alpha\nbeta\ngamma\n"; + let good_patch = "\ +--- a/x ++++ b/x +@@ -1,3 +1,3 @@ + alpha +-beta ++delta + gamma +"; + let patch = diffy::Patch::from_str(good_patch).unwrap(); + let out = diffy::apply(current, &patch).unwrap(); + assert_eq!(out, "alpha\ndelta\ngamma\n"); + } + + // Round-trip: a patch generated from (current → new) must reproduce `new` + // when applied to `current`. Guards against silent encoding drift between + // `create_patch` and `apply` (e.g. line-ending handling). + #[test] + fn diffy_roundtrip_preserves_content() { + let current = "one\ntwo\nthree\nfour\nfive\n"; + let new = "one\nTWO\nthree\nFOUR\nfive\n"; + let p = diffy::create_patch(current, new); + let applied = diffy::apply(current, &p).unwrap(); + assert_eq!(applied, new); + } + + // Max's offset-search case: a hunk declaring `@@ -1,3 @@ alpha/-beta/+delta/gamma` + // against `zero\nalpha\nbeta\ngamma\n` must be rejected. Diffy's `apply` + // would happily slide the hunk forward and land it at line 2; the + // positional check refuses. + #[test] + fn strict_position_rejects_offset_slide() { + let current = "zero\nalpha\nbeta\ngamma\n"; + let patch_text = "\ +--- a/x ++++ b/x +@@ -1,3 +1,3 @@ + alpha +-beta ++delta + gamma +"; + let patch = diffy::Patch::from_str(patch_text).unwrap(); + // Sanity: diffy's apply *would* slide and produce a result. + let diffy_result = diffy::apply(current, &patch).unwrap(); + assert_eq!(diffy_result, "zero\nalpha\ndelta\ngamma\n"); + // Our positional check rejects. + let err = verify_hunks_at_declared_position(current, &patch).unwrap_err(); + assert!(err.contains("preimage mismatch"), "got: {err}"); + } + + #[test] + fn strict_position_accepts_exact_match() { + let current = "alpha\nbeta\ngamma\n"; + let patch_text = "\ +--- a/x ++++ b/x +@@ -1,3 +1,3 @@ + alpha +-beta ++delta + gamma +"; + let patch = diffy::Patch::from_str(patch_text).unwrap(); + verify_hunks_at_declared_position(current, &patch).unwrap(); + } + + // Pure insertion against an empty value: `@@ -0,0 +1,N @@`. + #[test] + fn strict_position_accepts_pure_insertion_into_empty() { + let current = ""; + let patch_text = "\ +--- a/x ++++ b/x +@@ -0,0 +1,2 @@ ++first ++second +"; + let patch = diffy::Patch::from_str(patch_text).unwrap(); + verify_hunks_at_declared_position(current, &patch).unwrap(); + } + + // Multi-hunk patches: each hunk's `@@ -N @@` references line numbers in + // the *original* file, not in the file as modified by previous hunks. So + // validating each hunk's preimage against the unmodified `current_lines` + // at the declared position is correct — no cumulative-delta tracking + // needed. This test pins that property. + #[test] + fn strict_position_accepts_multi_hunk_against_original() { + let current = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\n"; + let patch_text = "\ +--- a/x ++++ b/x +@@ -1,3 +1,3 @@ + a +-b ++B + c +@@ -10,3 +10,3 @@ + j +-k ++K + l +"; + let patch = diffy::Patch::from_str(patch_text).unwrap(); + verify_hunks_at_declared_position(current, &patch).unwrap(); + // And the actual apply produces the expected result. + assert_eq!( + diffy::apply(current, &patch).unwrap(), + "a\nB\nc\nd\ne\nf\ng\nh\ni\nj\nK\nl\n" + ); + } + + // Value with no trailing newline must still round-trip. `split_inclusive` + // produces a final segment without `\n`; diffy strips `\n` from the last + // hunk line when the patch carries `\\ No newline at end of file`. Both + // representations must match here. + #[test] + fn strict_position_handles_no_trailing_newline() { + let current = "alpha\nbeta\ngamma"; // no trailing \n + let patch_text = "\ +--- a/x ++++ b/x +@@ -1,3 +1,3 @@ + alpha +-beta ++delta + gamma +\\ No newline at end of file +"; + let patch = diffy::Patch::from_str(patch_text).unwrap(); + verify_hunks_at_declared_position(current, &patch).unwrap(); + } + + // Lock the multi-file detection. The check is a simple count of lines + // starting with `--- ` because diffy's `parse()` will only consume the + // first patch and silently treat everything after the last hunk as + // junk until the next `@@` (which it then rejects). Counting `--- ` + // before parsing catches the ambiguous case up-front. + #[test] + fn multi_file_header_count() { + let single = "--- a/x\n+++ b/x\n@@ -1 +1 @@\n-a\n+b\n"; + assert_eq!(single.lines().filter(|l| l.starts_with("--- ")).count(), 1); + + let multi = "--- a/x\n+++ b/x\n@@ -1 +1 @@\n-a\n+b\n\ + --- a/y\n+++ b/y\n@@ -1 +1 @@\n-c\n+d\n"; + assert_eq!(multi.lines().filter(|l| l.starts_with("--- ")).count(), 2); + } +} diff --git a/crates/sprout-cli/src/lib.rs b/crates/sprout-cli/src/lib.rs index effa431ec..bc6a52669 100644 --- a/crates/sprout-cli/src/lib.rs +++ b/crates/sprout-cli/src/lib.rs @@ -831,12 +831,53 @@ pub enum MemCmd { #[arg(long)] owner: Option, }, + /// Print sha256(value) in hex (use as `--base-hash` for `mem patch`). + Hash { + slug: String, + #[arg(long)] + owner: Option, + }, /// Set a slug's value. Pass `-` to read the value from stdin. Set { slug: String, value: String, #[arg(long)] owner: Option, + /// Allow committing an empty value. Without this, a zero-byte stdin + /// read is rejected to prevent silent data loss from upstream + /// pipeline failures. + #[arg(long, default_value_t = false)] + allow_empty: bool, + }, + /// Apply a unified diff to a slug's current value (safer than set). + /// + /// Reads the diff from stdin or `--patch-file`. Refuses to apply if the + /// slug has changed since `--base-hash` was captured, and refuses + /// hunks whose context doesn't match the current value verbatim. + Patch { + slug: String, + /// Read the patch from a file instead of stdin. + #[arg(long)] + patch_file: Option, + /// sha256 hex digest (lowercase) of the value the patch was generated + /// against. Hashes the exact UTF-8 bytes returned by `sprout mem get`, + /// not normalized lines. Run `sprout mem hash ` to capture this + /// before editing. + #[arg(long)] + base_hash: Option, + /// Skip the base-hash check. Unsafe if concurrent edits are possible — + /// the patch will be applied against whatever the current value is, + /// even if another agent rewrote it after the patch was generated. + #[arg(long, default_value_t = false)] + no_base_hash: bool, + /// Echo the input patch + resulting sha256 and exit without writing. + #[arg(long, default_value_t = false)] + dry_run: bool, + /// Allow committing an empty result. + #[arg(long, default_value_t = false)] + allow_empty: bool, + #[arg(long)] + owner: Option, }, /// Publish a tombstone for a slug (cannot be used on `core`). Rm {