Skip to main content

spreadsheet_mcp/
security.rs

1use crate::errors::InvalidParamsError;
2use anyhow::{Result, anyhow};
3use std::path::{Path, PathBuf};
4
5/// Canonicalize `candidate` and ensure it remains within `workspace_root`.
6///
7/// This is symlink-aware: we canonicalize both the workspace root and the candidate path.
8///
9/// If `candidate` does not exist, we canonicalize its parent directory and then re-join
10/// the final path segment, which is sufficient for boundary enforcement prior to a write.
11pub fn canonicalize_and_enforce_within_workspace(
12    workspace_root: &Path,
13    candidate: &Path,
14    tool: &'static str,
15    field: &'static str,
16) -> Result<PathBuf> {
17    let workspace_root = workspace_root
18        .canonicalize()
19        .map_err(|e| anyhow!("failed to canonicalize workspace_root: {e}"))?;
20
21    let canonical_candidate = if candidate.exists() {
22        candidate.canonicalize().map_err(|e| {
23            InvalidParamsError::new(tool, format!("{field} could not be canonicalized: {e}"))
24                .with_path(field)
25        })?
26    } else {
27        let parent = candidate.parent().ok_or_else(|| {
28            InvalidParamsError::new(tool, format!("{field} must have a parent directory"))
29                .with_path(field)
30        })?;
31        let file_name = candidate.file_name().ok_or_else(|| {
32            InvalidParamsError::new(tool, format!("{field} must include a file name"))
33                .with_path(field)
34        })?;
35
36        let canonical_parent = parent.canonicalize().map_err(|e| {
37            InvalidParamsError::new(
38                tool,
39                format!("{field} parent directory could not be canonicalized: {e}"),
40            )
41            .with_path(field)
42        })?;
43
44        canonical_parent.join(file_name)
45    };
46
47    if !canonical_candidate.starts_with(&workspace_root) {
48        return Err(InvalidParamsError::new(tool, format!(
49            "{field} must be within workspace_root after canonicalization (got '{}', workspace_root='{}')",
50            canonical_candidate.display(),
51            workspace_root.display(),
52        ))
53        .with_path(field)
54        .into());
55    }
56
57    Ok(canonical_candidate)
58}
59
60/// Escape a value for LibreOffice Basic string literal context.
61///
62/// - Rejects control characters (including newlines) to avoid ambiguous parsing.
63/// - Escapes `"` by doubling it (`""`), per Basic string literal rules.
64/// - Returns the fully-quoted Basic string literal (including surrounding `"`).
65pub fn basic_string_literal(field: &'static str, value: &str) -> Result<String> {
66    if value.chars().any(|c| c.is_control()) {
67        return Err(InvalidParamsError::new(
68            "recalc",
69            format!("{field} must not contain control characters"),
70        )
71        .with_path(field)
72        .into());
73    }
74
75    let mut out = String::with_capacity(value.len() + 2);
76    out.push('"');
77    for ch in value.chars() {
78        if ch == '"' {
79            out.push('"');
80            out.push('"');
81        } else {
82            out.push(ch);
83        }
84    }
85    out.push('"');
86    Ok(out)
87}
88
89/// Sanitize a filename component for safe `Path::join` usage.
90///
91/// This is intentionally conservative; it prevents path separators and traversal.
92pub fn sanitize_filename_component(input: &str) -> String {
93    let mut out = String::with_capacity(input.len());
94    for ch in input.chars() {
95        if ch.is_control() || ch == '/' || ch == '\\' {
96            out.push('_');
97        } else {
98            out.push(ch);
99        }
100    }
101
102    if out == "." || out == ".." {
103        return "_".to_string();
104    }
105
106    out
107}