spreadsheet_mcp/
security.rs1use crate::errors::InvalidParamsError;
2use anyhow::{Result, anyhow};
3use std::path::{Path, PathBuf};
4
5pub 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
60pub 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
89pub 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}