From e0a8738980965411f514f4a62c09f941efdea90c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 28 Jul 2026 11:43:55 +0300 Subject: [PATCH] fix(memory): guard chunk tag rewrites --- src/memory/store/content/tags.rs | 28 +++++++++++++++++++++++ src/memory/store/content/tags_tests.rs | 31 ++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/src/memory/store/content/tags.rs b/src/memory/store/content/tags.rs index 0a171dc..1f15ad9 100644 --- a/src/memory/store/content/tags.rs +++ b/src/memory/store/content/tags.rs @@ -35,6 +35,8 @@ pub fn update_chunk_tags(abs_path: &Path, tags: &[String]) -> anyhow::Result<()> let new_bytes = rewrite_tags(&old_bytes, &augmented) .map_err(|e| anyhow::anyhow!("rewrite_tags {:?}: {e}", abs_path))?; + ensure_tag_rewrite_preserves_body(&old_bytes, &new_bytes, abs_path)?; + let parent = abs_path.parent().unwrap_or_else(|| Path::new(".")); let tmp_name = format!(".tmp_tags_{}.md", crate_temp_id()); let tmp_path = parent.join(&tmp_name); @@ -57,6 +59,32 @@ pub fn update_chunk_tags(abs_path: &Path, tags: &[String]) -> anyhow::Result<()> Ok(()) } +/// Reject a front-matter rewrite unless both files parse and their bodies are +/// byte-identical. +/// +/// The body hash is persisted separately from the markdown file, so silently +/// accepting an invalid or changed body would corrupt later retrieval. +fn ensure_tag_rewrite_preserves_body( + old_bytes: &[u8], + new_bytes: &[u8], + abs_path: &Path, +) -> anyhow::Result<()> { + let body = |bytes: &[u8]| -> Option { + std::str::from_utf8(bytes) + .ok() + .and_then(split_front_matter) + .map(|(_, body)| body.to_owned()) + }; + + match (body(old_bytes), body(new_bytes)) { + (Some(old), Some(new)) if old == new => Ok(()), + _ => Err(anyhow::anyhow!( + "tag rewrite would mutate or invalidate the body for {:?}", + abs_path + )), + } +} + /// Slugify an entity kind string for an Obsidian hierarchical tag. /// /// Output: lowercase, non-alphanumeric → `-`, collapsed, trimmed. diff --git a/src/memory/store/content/tags_tests.rs b/src/memory/store/content/tags_tests.rs index cd8bec1..1cac1e5 100644 --- a/src/memory/store/content/tags_tests.rs +++ b/src/memory/store/content/tags_tests.rs @@ -88,6 +88,37 @@ fn update_chunk_tags_is_noop_for_missing_file() { assert!(update_chunk_tags(&path, &["p/X".into()]).is_ok()); } +#[test] +fn update_chunk_tags_rejects_unparseable_front_matter_without_overwriting() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("malformed.md"); + let original = b"not front matter\nBODY"; + std::fs::write(&path, original).unwrap(); + + let result = update_chunk_tags(&path, &["person/Alice".into()]); + + assert!(result.is_err()); + assert_eq!(std::fs::read(&path).unwrap(), original); +} + +#[test] +fn body_guard_accepts_front_matter_only_changes() { + let path = Path::new("chunk.md"); + let old = b"---\ntags: []\n---\nBODY"; + let new = b"---\ntags:\n - person/Alice\n---\nBODY"; + + assert!(ensure_tag_rewrite_preserves_body(old, new, path).is_ok()); +} + +#[test] +fn body_guard_rejects_body_drift() { + let path = Path::new("chunk.md"); + let old = b"---\ntags: []\n---\nBODY"; + let new = b"---\ntags:\n - person/Alice\n---\nDIFFERENT"; + + assert!(ensure_tag_rewrite_preserves_body(old, new, path).is_err()); +} + #[test] fn slugify_tag_kind_examples() { assert_eq!(slugify_tag_kind("Person"), "person");