Skip to main content

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}