turbovault_core/utils.rs
1//! Shared utilities for operations across turbovault crates.
2//!
3//! Provides DRY helpers for:
4//! - Serialization with consistent error handling
5//! - Result/report builders
6//! - Path validation
7//! - Transaction tracking
8
9use crate::{Error, Result};
10use std::path::{Path, PathBuf};
11
12/// Render `path` as a `/`-separated string, the spelling Obsidian vault paths
13/// (and this server's MCP surface) always use.
14///
15/// Only touches [`std::path::MAIN_SEPARATOR`], and only when it is not
16/// already `/`. On Unix that separator IS `/`, so this is a no-op, which
17/// matters because a backslash is a legal filename character there; an
18/// unconditional `.replace('\\', "/")` would corrupt a component that
19/// legitimately contains one. On Windows it rewrites the `\` the platform
20/// APIs return so a path looks identical regardless of which OS produced it.
21///
22/// A hand-rolled helper rather than the `path-slash` crate: the conversion
23/// is exactly this one conditional replace, small enough that a dependency
24/// (plus its `Path`/`PathBuf` extension-trait surface we would not use) buys
25/// nothing a doc comment doesn't already cover.
26pub fn path_to_slash(path: &Path) -> String {
27 let rendered = path.to_string_lossy();
28 if std::path::MAIN_SEPARATOR == '/' {
29 rendered.into_owned()
30 } else {
31 rendered.replace(std::path::MAIN_SEPARATOR, "/")
32 }
33}
34
35/// Whether `path` is a *note*: what the note cache, the link graph and the
36/// search index model. Markdown, by extension, in any case: `Note.MD` is as
37/// much a note as `Note.md`.
38///
39/// Every consumer has to use this one test. The freshness sweep compares what
40/// the cache holds against what a scan finds, and the parser decides whether
41/// to read a file's links, so two tests that disagree about a path leave it
42/// cached but unparsed, or reported changed on every pass.
43///
44/// ```
45/// use std::path::Path;
46/// use turbovault_core::is_note;
47///
48/// assert!(is_note(Path::new("wiki/Note.MD")));
49/// assert!(!is_note(Path::new("board.canvas")));
50/// ```
51pub fn is_note(path: &Path) -> bool {
52 path.extension()
53 .and_then(|ext| ext.to_str())
54 .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
55}
56
57/// The content hash TurboVault hands out for a note: lowercase hex SHA-256 of
58/// its Unicode NFC form. It is the `expected_hash` a Direct-backed write
59/// checks, the hash `read_note` reports, the before/after hash on an audit
60/// entry, and `FileMetadata::checksum`, so any two of them can be compared.
61///
62/// Normalizing first means the same text typed on two platforms (macOS input
63/// often produces decomposed accents) hashes the same. Text that is already
64/// NFC, which is nearly all of it, is hashed without being copied.
65pub fn compute_hash(content: &str) -> String {
66 use sha2::{Digest, Sha256};
67 use unicode_normalization::{IsNormalized, UnicodeNormalization, is_nfc_quick};
68
69 let digest = if is_nfc_quick(content.chars()) == IsNormalized::Yes {
70 Sha256::digest(content.as_bytes())
71 } else {
72 let normalized: String = content.nfc().collect();
73 Sha256::digest(normalized.as_bytes())
74 };
75 bytes_to_lower_hex(digest)
76}
77
78/// [`compute_hash`] for bytes that may not be text: the same hash when they
79/// are UTF-8, and a plain SHA-256 of the bytes when they are not (an
80/// attachment).
81pub fn compute_hash_bytes(bytes: &[u8]) -> String {
82 use sha2::{Digest, Sha256};
83
84 match std::str::from_utf8(bytes) {
85 Ok(text) => compute_hash(text),
86 Err(_) => bytes_to_lower_hex(Sha256::digest(bytes)),
87 }
88}
89
90/// Encode bytes as lowercase hexadecimal.
91pub fn bytes_to_lower_hex(bytes: impl AsRef<[u8]>) -> String {
92 const HEX: &[u8; 16] = b"0123456789abcdef";
93
94 let bytes = bytes.as_ref();
95 let mut out = String::with_capacity(bytes.len() * 2);
96
97 for &byte in bytes {
98 out.push(HEX[(byte >> 4) as usize] as char);
99 out.push(HEX[(byte & 0x0f) as usize] as char);
100 }
101
102 out
103}
104
105/// Generic JSON serialization with consistent error handling
106/// Works with any type that implements Serialize (including slices)
107pub fn to_json_string<T: serde::Serialize + ?Sized>(data: &T, context: &str) -> Result<String> {
108 serde_json::to_string_pretty(data)
109 .map_err(|e| Error::other(format!("Failed to serialize {} as JSON: {}", context, e)))
110}
111
112/// A path inside a vault, checked against where it lands on disk.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct ResolvedPath {
115 /// The path as addressed: the vault root joined with the request, with
116 /// `.` and `..` resolved. This is the path to open and to key caches by.
117 pub path: PathBuf,
118 /// Where that path really is, relative to the vault root's real location,
119 /// once every symlink along it has been followed. Empty for the root.
120 pub real_relative: PathBuf,
121}
122
123/// Path validation helpers
124pub struct PathValidator;
125
126impl PathValidator {
127 /// Resolve `path` against `vault_root`, refusing it unless it stays inside
128 /// the vault both as written and as the filesystem will resolve it.
129 ///
130 /// Checking the text alone is not enough, because the text is not what
131 /// gets opened. A symlink inside the vault can point anywhere, and a note
132 /// that does not exist yet cannot be canonicalized the ordinary way, which
133 /// is exactly when the check used to fall back to the text and let a new
134 /// note be written through a link to anywhere on the machine. The real
135 /// location is found by following every link along the path as far as it
136 /// exists, including a dangling one at the end, which writing through
137 /// would create.
138 ///
139 /// The resolved path is still the one as addressed, not the real one, so a
140 /// vault registered through a symlinked directory keeps its own paths.
141 pub fn resolve_in_vault(vault_root: &Path, path: &Path) -> Result<ResolvedPath> {
142 Self::resolve_in_vault_at(vault_root, &Self::real_root(vault_root)?, path)
143 }
144
145 /// Where `vault_root` really is, every symlink to it followed: what
146 /// [`Self::resolve_in_vault_at`] takes as `real_root`.
147 pub fn real_root(vault_root: &Path) -> Result<PathBuf> {
148 Ok(soft_canonicalize::soft_canonicalize(lexically_normalize(
149 vault_root,
150 ))?)
151 }
152
153 /// [`Self::resolve_in_vault`] for a caller that has already resolved where
154 /// the vault root really is (`real_root`, every symlink to it followed),
155 /// which costs a walk of the root's own path on each call.
156 pub fn resolve_in_vault_at(
157 vault_root: &Path,
158 real_root: &Path,
159 path: &Path,
160 ) -> Result<ResolvedPath> {
161 let root = lexically_normalize(vault_root);
162 let addressed = lexically_normalize(&root.join(path));
163 if !addressed.starts_with(&root) {
164 return Err(Error::path_traversal(addressed));
165 }
166
167 // A path the filesystem cannot resolve (a symlink loop, a directory
168 // that cannot be read) cannot be shown to stay inside, so it does not.
169 let real = soft_canonicalize::soft_canonicalize(&addressed)
170 .map_err(|_| Error::path_traversal(&addressed))?;
171 let Ok(real_relative) = real.strip_prefix(real_root) else {
172 return Err(Error::path_traversal(addressed));
173 };
174
175 Ok(ResolvedPath {
176 real_relative: real_relative.to_path_buf(),
177 path: addressed,
178 })
179 }
180
181 /// Ensure a path is within a vault root (prevents directory traversal).
182 ///
183 /// See [`Self::resolve_in_vault`], which this is the path half of.
184 pub fn validate_path_in_vault(vault_root: &Path, path: &Path) -> Result<PathBuf> {
185 Ok(Self::resolve_in_vault(vault_root, path)?.path)
186 }
187}
188
189/// Resolve `.` and `..` in a path by its text alone, without touching the
190/// filesystem. `..` at the root stays at the root.
191fn lexically_normalize(path: &Path) -> PathBuf {
192 use std::path::Component;
193 let mut out = PathBuf::new();
194 for component in path.components() {
195 match component {
196 Component::CurDir => {}
197 Component::ParentDir => {
198 if !matches!(
199 out.components().next_back(),
200 None | Some(Component::RootDir | Component::Prefix(_))
201 ) {
202 out.pop();
203 }
204 }
205 other => out.push(other),
206 }
207 }
208 out
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use serde::{Deserialize, Serialize};
215
216 #[derive(Serialize, Deserialize)]
217 struct TestData {
218 name: String,
219 value: i32,
220 }
221
222 #[test]
223 fn test_to_json_string() {
224 let data = TestData {
225 name: "test".to_string(),
226 value: 42,
227 };
228 let json = to_json_string(&data, "test_data").unwrap();
229 assert!(json.contains("test"));
230 assert!(json.contains("42"));
231 }
232
233 #[test]
234 fn test_bytes_to_lower_hex() {
235 assert_eq!(bytes_to_lower_hex([0x00, 0x0f, 0xa5, 0xff]), "000fa5ff");
236 }
237
238 #[test]
239 fn path_to_slash_leaves_an_already_slashed_path_untouched() {
240 assert_eq!(path_to_slash(Path::new("a/b")), "a/b");
241 }
242
243 #[cfg(not(windows))]
244 #[test]
245 fn path_to_slash_preserves_a_literal_backslash_in_a_unix_filename() {
246 // A backslash is a legal filename character on Unix. MAIN_SEPARATOR
247 // there is '/', so the conditional replace must be a no-op and leave
248 // this real filename intact rather than corrupt it.
249 assert_eq!(path_to_slash(Path::new(r"weird\name.md")), r"weird\name.md");
250 }
251
252 #[cfg(windows)]
253 #[test]
254 fn path_to_slash_converts_backslashes_on_windows() {
255 assert_eq!(path_to_slash(Path::new(r"islands\a.md")), "islands/a.md");
256 assert_eq!(
257 path_to_slash(Path::new(r"C:\vault\guides\authentication.md")),
258 "C:/vault/guides/authentication.md"
259 );
260 }
261
262 #[cfg(windows)]
263 #[test]
264 fn path_to_slash_leaves_an_already_slashed_windows_path_untouched() {
265 assert_eq!(path_to_slash(Path::new("a/b")), "a/b");
266 }
267
268 #[test]
269 fn test_path_validator_valid() {
270 let vault_root = PathBuf::from("/vault");
271 let path = Path::new("notes/file.md");
272 let result = PathValidator::validate_path_in_vault(&vault_root, path);
273 assert!(result.is_ok());
274 }
275
276 #[test]
277 fn test_path_validator_traversal() {
278 let vault_root = PathBuf::from("/vault");
279 let path = Path::new("../../../etc/passwd");
280 let result = PathValidator::validate_path_in_vault(&vault_root, path);
281 assert!(result.is_err());
282 }
283}