Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/memory/store/content/tags.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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<String> {
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.
Expand Down
31 changes: 31 additions & 0 deletions src/memory/store/content/tags_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down