Skip to main content

agentmux_srv/backend/
schema.rs

1#![allow(dead_code)]
2// Copyright 2025, Command Line Inc.
3// Copyright 2025-2026, AgentMux Corp.
4// SPDX-License-Identifier: Apache-2.0
5
6//! Schema path resolution with `.json` extension fallback.
7//! Port of Go's `pkg/schema/schema.go`.
8//!
9//! Provides file path resolution for JSON schema files, trying the
10//! exact path first, then falling back to appending `.json`.
11
12
13use std::path::{Path, PathBuf};
14
15/// The content type for JSON schema responses.
16pub const SCHEMA_CONTENT_TYPE: &str = "application/schema+json";
17
18/// Resolve a schema file path, trying the exact path first, then with `.json` extension.
19///
20/// Returns `Some(path)` if a file exists at the exact path or with `.json` appended.
21/// Returns `None` if neither exists.
22pub fn resolve_schema_path(base_dir: &Path, name: &str) -> Option<PathBuf> {
23    // Prevent directory traversal
24    if name.contains("..") {
25        return None;
26    }
27
28    let exact = base_dir.join(name);
29    if exact.is_file() {
30        return Some(exact);
31    }
32
33    // Try with .json extension
34    let with_ext = base_dir.join(format!("{}.json", name));
35    if with_ext.is_file() {
36        return Some(with_ext);
37    }
38
39    None
40}
41
42/// Get the schema directory path from the app path.
43pub fn get_schema_dir(app_path: &Path) -> PathBuf {
44    app_path.join("schema")
45}
46
47/// Check if a schema directory exists and is valid.
48pub fn schema_dir_exists(app_path: &Path) -> bool {
49    let schema_dir = get_schema_dir(app_path);
50    schema_dir.is_dir()
51}
52
53/// Normalize a request path for schema lookup.
54/// Strips leading slashes and ensures no directory traversal.
55pub fn normalize_schema_request(path: &str) -> Option<String> {
56    let stripped = path.trim_start_matches('/');
57    if stripped.contains("..") {
58        return None;
59    }
60    if stripped.is_empty() {
61        return None;
62    }
63    Some(stripped.to_string())
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69    use std::fs;
70
71    fn setup_test_dir() -> PathBuf {
72        let dir = std::env::temp_dir().join(format!(
73            "schema_test_{}_{:?}",
74            std::process::id(),
75            std::thread::current().id()
76        ));
77        let schema_dir = dir.join("schema");
78        let _ = fs::remove_dir_all(&dir);
79        fs::create_dir_all(&schema_dir).unwrap();
80        schema_dir
81    }
82
83    fn cleanup(dir: &Path) {
84        let _ = fs::remove_dir_all(dir.parent().unwrap_or(dir));
85    }
86
87    #[test]
88    fn test_resolve_exact_path() {
89        let dir = setup_test_dir();
90        fs::write(dir.join("test.json"), "{}").unwrap();
91
92        let result = resolve_schema_path(&dir, "test.json");
93        assert!(result.is_some());
94        assert!(result.unwrap().ends_with("test.json"));
95
96        cleanup(&dir);
97    }
98
99    #[test]
100    fn test_resolve_with_json_fallback() {
101        let dir = setup_test_dir();
102        fs::write(dir.join("test.json"), "{}").unwrap();
103
104        let result = resolve_schema_path(&dir, "test");
105        assert!(result.is_some());
106        assert!(result.unwrap().ends_with("test.json"));
107
108        cleanup(&dir);
109    }
110
111    #[test]
112    fn test_resolve_not_found() {
113        let dir = setup_test_dir();
114
115        let result = resolve_schema_path(&dir, "missing");
116        assert!(result.is_none());
117
118        cleanup(&dir);
119    }
120
121    #[test]
122    fn test_resolve_traversal_rejected() {
123        let dir = setup_test_dir();
124
125        let result = resolve_schema_path(&dir, "../etc/passwd");
126        assert!(result.is_none());
127
128        cleanup(&dir);
129    }
130
131    #[test]
132    fn test_normalize_schema_request() {
133        assert_eq!(normalize_schema_request("/foo/bar"), Some("foo/bar".into()));
134        assert_eq!(normalize_schema_request("foo"), Some("foo".into()));
135        assert_eq!(normalize_schema_request("/"), None);
136        assert_eq!(normalize_schema_request(""), None);
137        assert_eq!(normalize_schema_request("/../bad"), None);
138    }
139
140    #[test]
141    fn test_get_schema_dir() {
142        let app_path = Path::new("/opt/agentmux");
143        assert_eq!(get_schema_dir(app_path), PathBuf::from("/opt/agentmux/schema"));
144    }
145
146    #[test]
147    fn test_schema_dir_exists() {
148        let dir = setup_test_dir();
149        let app_path = dir.parent().unwrap();
150        assert!(schema_dir_exists(app_path));
151        cleanup(&dir);
152    }
153
154    #[test]
155    fn test_schema_dir_not_exists() {
156        let app_path = Path::new("/nonexistent/path");
157        assert!(!schema_dir_exists(app_path));
158    }
159
160    #[test]
161    fn test_schema_content_type() {
162        assert_eq!(SCHEMA_CONTENT_TYPE, "application/schema+json");
163    }
164}