Skip to main content

git_paw/mcp/query/
docs.rs

1//! Documentation reads for the MCP server.
2//!
3//! Serves the repository's own documentation — the README and the
4//! documentation tree — driven by the bring-your-own `[governance].readme`
5//! and `[governance].docs` configuration. Locations are configured, never
6//! hardcoded: an unset path degrades to a null/empty result rather than a
7//! transport error (the degradation contract, design D4).
8//!
9//! [`read_doc`] is confined to the configured documentation directory: the
10//! requested path is resolved under that directory, canonicalised, and
11//! verified to still lie within it, so `..`/absolute escapes are refused
12//! before any file outside the directory is read.
13
14use std::path::Path;
15
16use rmcp::schemars;
17use serde::Serialize;
18
19use crate::config::GovernanceConfig;
20use crate::error::PawError;
21
22use super::resolve_under_root;
23
24/// Reads the configured README.
25///
26/// - `[governance].readme` unset → `Ok(None)` (graceful degradation).
27/// - configured but the file is absent → `Ok(None)` (the README is optional).
28/// - configured + readable → `Ok(Some(content))`.
29/// - configured + present-but-unreadable (e.g. a permission error) → `Err`,
30///   so the tool layer can surface the misconfiguration to the client.
31pub fn read_readme(repo_root: &Path, gov: &GovernanceConfig) -> Result<Option<String>, PawError> {
32    let Some(rel) = gov.readme.as_deref() else {
33        return Ok(None);
34    };
35    let path = resolve_under_root(repo_root, rel);
36    match std::fs::read_to_string(&path) {
37        Ok(content) => Ok(Some(content)),
38        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
39        Err(e) => Err(PawError::McpError(format!(
40            "configured readme path {} could not be read: {e}",
41            path.display()
42        ))),
43    }
44}
45
46/// One documentation entry returned by [`list_docs`].
47#[derive(Debug, Clone, Serialize, schemars::JsonSchema, PartialEq, Eq)]
48pub struct DocEntry {
49    /// Path relative to the configured documentation directory (so it feeds
50    /// directly back into [`read_doc`]). Uses forward slashes.
51    pub path: String,
52}
53
54/// Recursively collects `*.md` files under `dir`, pushing their paths relative
55/// to `base` (forward-slash normalised) into `out`.
56fn collect_md(dir: &Path, base: &Path, out: &mut Vec<DocEntry>) {
57    let Ok(entries) = std::fs::read_dir(dir) else {
58        return;
59    };
60    for entry in entries.flatten() {
61        let path = entry.path();
62        if path.is_dir() {
63            collect_md(&path, base, out);
64            continue;
65        }
66        let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
67            continue;
68        };
69        if !name.to_ascii_lowercase().ends_with(".md") {
70            continue;
71        }
72        let Ok(rel) = path.strip_prefix(base) else {
73            continue;
74        };
75        let rel = rel
76            .components()
77            .map(|c| c.as_os_str().to_string_lossy())
78            .collect::<Vec<_>>()
79            .join("/");
80        out.push(DocEntry { path: rel });
81    }
82}
83
84/// Lists Markdown documents under the configured documentation directory.
85///
86/// Returns an empty list when `[governance].docs` is unset or the directory
87/// is absent (graceful degradation). Each entry's path is relative to the
88/// configured documentation directory and sorted lexicographically.
89#[must_use]
90pub fn list_docs(repo_root: &Path, gov: &GovernanceConfig) -> Vec<DocEntry> {
91    let Some(dir) = gov.docs.as_ref() else {
92        return Vec::new();
93    };
94    let dir = resolve_under_root(repo_root, dir);
95    let mut out = Vec::new();
96    collect_md(&dir, &dir, &mut out);
97    out.sort_by(|a, b| a.path.cmp(&b.path));
98    out
99}
100
101/// Reads one document confined to the configured documentation directory.
102///
103/// The requested `rel_path` is resolved under the configured documentation
104/// directory, then canonicalised and checked to still lie within that
105/// directory. Any path that escapes the directory (`..`, an absolute path, a
106/// symlink target outside the tree) is refused with `Ok(None)` — no file
107/// outside the directory is ever read.
108///
109/// - `[governance].docs` unset → `Ok(None)`.
110/// - requested document absent → `Ok(None)`.
111/// - traversal/escape → `Ok(None)` (refused; the tool layer attaches a
112///   message).
113/// - confined + readable → `Ok(Some(content))`.
114/// - confined + present-but-unreadable → `Err`.
115pub fn read_doc(
116    repo_root: &Path,
117    gov: &GovernanceConfig,
118    rel_path: &str,
119) -> Result<Option<String>, PawError> {
120    let Some(dir) = gov.docs.as_ref() else {
121        return Ok(None);
122    };
123    let dir = resolve_under_root(repo_root, dir);
124    // The directory must canonicalise (exist) for confinement to be
125    // meaningful; an absent docs dir degrades to None.
126    let Ok(canonical_dir) = dir.canonicalize() else {
127        return Ok(None);
128    };
129
130    let requested = dir.join(rel_path);
131    // Canonicalise the requested path; a non-existent file (or a broken
132    // traversal target) yields None rather than an error.
133    let Ok(canonical) = requested.canonicalize() else {
134        return Ok(None);
135    };
136    // Confinement check: the canonical target must stay within the canonical
137    // documentation directory. This rejects `..`, absolute paths, and symlink
138    // escapes alike.
139    if !canonical.starts_with(&canonical_dir) {
140        return Ok(None);
141    }
142
143    match std::fs::read_to_string(&canonical) {
144        Ok(content) => Ok(Some(content)),
145        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
146        Err(e) => Err(PawError::McpError(format!(
147            "configured doc {} could not be read: {e}",
148            canonical.display()
149        ))),
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use std::path::PathBuf;
157
158    #[test]
159    fn read_readme_returns_content_when_configured_and_present() {
160        let tmp = tempfile::tempdir().unwrap();
161        std::fs::write(tmp.path().join("README.md"), "# Hello\nbody").unwrap();
162        let gov = GovernanceConfig {
163            readme: Some(PathBuf::from("README.md")),
164            ..Default::default()
165        };
166        let content = read_readme(tmp.path(), &gov).unwrap();
167        assert_eq!(content.as_deref(), Some("# Hello\nbody"));
168    }
169
170    #[test]
171    fn read_readme_none_when_unconfigured() {
172        let tmp = tempfile::tempdir().unwrap();
173        assert!(
174            read_readme(tmp.path(), &GovernanceConfig::default())
175                .unwrap()
176                .is_none()
177        );
178    }
179
180    #[test]
181    fn read_readme_none_when_configured_but_absent() {
182        let tmp = tempfile::tempdir().unwrap();
183        let gov = GovernanceConfig {
184            readme: Some(PathBuf::from("README.md")),
185            ..Default::default()
186        };
187        // Configured but the file does not exist → graceful null, not an error.
188        assert!(read_readme(tmp.path(), &gov).unwrap().is_none());
189    }
190
191    #[test]
192    fn list_docs_empty_when_unconfigured() {
193        let tmp = tempfile::tempdir().unwrap();
194        assert!(list_docs(tmp.path(), &GovernanceConfig::default()).is_empty());
195    }
196
197    #[test]
198    fn list_docs_empty_when_dir_absent() {
199        let tmp = tempfile::tempdir().unwrap();
200        let gov = GovernanceConfig {
201            docs: Some(PathBuf::from("docs/src")),
202            ..Default::default()
203        };
204        assert!(list_docs(tmp.path(), &gov).is_empty());
205    }
206
207    #[test]
208    fn list_docs_enumerates_nested_markdown_relative_to_dir() {
209        let tmp = tempfile::tempdir().unwrap();
210        let docs = tmp.path().join("docs/src");
211        std::fs::create_dir_all(docs.join("user-guide")).unwrap();
212        std::fs::write(docs.join("intro.md"), "# Intro").unwrap();
213        std::fs::write(docs.join("user-guide/mcp.md"), "# MCP").unwrap();
214        std::fs::write(docs.join("not-a-doc.txt"), "ignored").unwrap();
215        let gov = GovernanceConfig {
216            docs: Some(PathBuf::from("docs/src")),
217            ..Default::default()
218        };
219        let list = list_docs(tmp.path(), &gov);
220        let paths: Vec<&str> = list.iter().map(|d| d.path.as_str()).collect();
221        assert_eq!(paths, vec!["intro.md", "user-guide/mcp.md"]);
222    }
223
224    #[test]
225    fn read_doc_happy_path() {
226        let tmp = tempfile::tempdir().unwrap();
227        let docs = tmp.path().join("docs/src");
228        std::fs::create_dir_all(docs.join("user-guide")).unwrap();
229        std::fs::write(docs.join("user-guide/mcp.md"), "# MCP guide").unwrap();
230        let gov = GovernanceConfig {
231            docs: Some(PathBuf::from("docs/src")),
232            ..Default::default()
233        };
234        let content = read_doc(tmp.path(), &gov, "user-guide/mcp.md").unwrap();
235        assert_eq!(content.as_deref(), Some("# MCP guide"));
236    }
237
238    #[test]
239    fn read_doc_none_when_unconfigured() {
240        let tmp = tempfile::tempdir().unwrap();
241        assert!(
242            read_doc(tmp.path(), &GovernanceConfig::default(), "x.md")
243                .unwrap()
244                .is_none()
245        );
246    }
247
248    #[test]
249    fn read_doc_rejects_dotdot_traversal() {
250        let tmp = tempfile::tempdir().unwrap();
251        let docs = tmp.path().join("docs/src");
252        std::fs::create_dir_all(&docs).unwrap();
253        std::fs::write(docs.join("ok.md"), "# ok").unwrap();
254        // A secret file outside the docs dir.
255        std::fs::write(tmp.path().join("secret.txt"), "TOPSECRET").unwrap();
256        let gov = GovernanceConfig {
257            docs: Some(PathBuf::from("docs/src")),
258            ..Default::default()
259        };
260        // Even though ../../secret.txt exists, confinement refuses it.
261        let escaped = read_doc(tmp.path(), &gov, "../../secret.txt").unwrap();
262        assert!(escaped.is_none(), "traversal must be refused");
263    }
264
265    #[test]
266    fn read_doc_rejects_absolute_path() {
267        let tmp = tempfile::tempdir().unwrap();
268        let docs = tmp.path().join("docs/src");
269        std::fs::create_dir_all(&docs).unwrap();
270        let secret = tmp.path().join("secret.txt");
271        std::fs::write(&secret, "TOPSECRET").unwrap();
272        let gov = GovernanceConfig {
273            docs: Some(PathBuf::from("docs/src")),
274            ..Default::default()
275        };
276        let abs = secret.to_string_lossy().into_owned();
277        let escaped = read_doc(tmp.path(), &gov, &abs).unwrap();
278        assert!(escaped.is_none(), "absolute escape must be refused");
279    }
280}