Skip to main content

subcog/storage/prompt/
filesystem.rs

1//! Filesystem-based prompt storage fallback.
2//!
3//! Stores prompts as JSON files in a directory structure.
4
5use super::PromptStorage;
6use crate::current_timestamp;
7use crate::models::PromptTemplate;
8use crate::{Error, Result};
9use std::fs;
10use std::path::{Path, PathBuf};
11
12/// Filesystem-based prompt storage.
13///
14/// Stores each prompt as a JSON file: `{base_path}/{prompt_name}.json`
15pub struct FilesystemPromptStorage {
16    /// Base directory for prompt files.
17    base_path: PathBuf,
18}
19
20impl FilesystemPromptStorage {
21    /// Creates a new filesystem prompt storage.
22    ///
23    /// # Arguments
24    ///
25    /// * `base_path` - Directory to store prompt files
26    ///
27    /// # Errors
28    ///
29    /// Returns an error if the directory cannot be created.
30    pub fn new(base_path: impl Into<PathBuf>) -> Result<Self> {
31        let path = base_path.into();
32
33        // Ensure directory exists
34        fs::create_dir_all(&path).map_err(|e| Error::OperationFailed {
35            operation: "create_prompt_dir".to_string(),
36            cause: e.to_string(),
37        })?;
38
39        Ok(Self { base_path: path })
40    }
41
42    /// Returns the default user-scope path.
43    ///
44    /// Returns `~/.config/subcog/prompts/`.
45    #[must_use]
46    pub fn default_user_path() -> Option<PathBuf> {
47        directories::BaseDirs::new()
48            .map(|d| d.home_dir().join(".config").join("subcog").join("prompts"))
49    }
50
51    /// Returns the default org-scope path.
52    ///
53    /// Returns `~/.config/subcog/orgs/{org}/prompts/`.
54    #[must_use]
55    pub fn default_org_path(org: &str) -> Option<PathBuf> {
56        directories::BaseDirs::new().map(|d| {
57            d.home_dir()
58                .join(".config")
59                .join("subcog")
60                .join("orgs")
61                .join(org)
62                .join("prompts")
63        })
64    }
65
66    /// Returns the base path.
67    #[must_use]
68    pub fn base_path(&self) -> &Path {
69        &self.base_path
70    }
71
72    /// Validates a prompt name to prevent path traversal attacks.
73    ///
74    /// # Errors
75    ///
76    /// Returns an error if the name contains dangerous characters.
77    fn validate_prompt_name(name: &str) -> Result<()> {
78        if name.is_empty() {
79            return Err(Error::InvalidInput(
80                "Prompt name cannot be empty".to_string(),
81            ));
82        }
83
84        // Reject path separators
85        if name.contains('/') || name.contains('\\') {
86            return Err(Error::InvalidInput(
87                "Prompt name cannot contain path separators".to_string(),
88            ));
89        }
90
91        // Reject parent directory references
92        if name.contains("..") {
93            return Err(Error::InvalidInput(
94                "Prompt name cannot contain parent directory references".to_string(),
95            ));
96        }
97
98        // Reject null bytes
99        if name.contains('\0') {
100            return Err(Error::InvalidInput(
101                "Prompt name cannot contain null bytes".to_string(),
102            ));
103        }
104
105        // Reject hidden files (starting with .)
106        if name.starts_with('.') {
107            return Err(Error::InvalidInput(
108                "Prompt name cannot start with a dot".to_string(),
109            ));
110        }
111
112        Ok(())
113    }
114
115    /// Gets the file path for a prompt.
116    ///
117    /// # Errors
118    ///
119    /// Returns an error if the name fails validation.
120    fn prompt_path(&self, name: &str) -> Result<PathBuf> {
121        Self::validate_prompt_name(name)?;
122        Ok(self.base_path.join(format!("{name}.json")))
123    }
124
125    /// Reads a prompt from a file.
126    fn read_prompt_file(&self, path: &Path) -> Result<PromptTemplate> {
127        let content = fs::read_to_string(path).map_err(|e| Error::OperationFailed {
128            operation: "read_prompt_file".to_string(),
129            cause: e.to_string(),
130        })?;
131
132        serde_json::from_str(&content).map_err(|e| Error::OperationFailed {
133            operation: "parse_prompt_json".to_string(),
134            cause: e.to_string(),
135        })
136    }
137
138    /// Writes a prompt to a file.
139    fn write_prompt_file(&self, path: &Path, template: &PromptTemplate) -> Result<()> {
140        let content =
141            serde_json::to_string_pretty(template).map_err(|e| Error::OperationFailed {
142                operation: "serialize_prompt".to_string(),
143                cause: e.to_string(),
144            })?;
145
146        fs::write(path, content).map_err(|e| Error::OperationFailed {
147            operation: "write_prompt_file".to_string(),
148            cause: e.to_string(),
149        })
150    }
151}
152
153/// Simple glob pattern matching.
154fn matches_glob(pattern: &str, text: &str) -> bool {
155    if !pattern.contains('*') {
156        return pattern == text;
157    }
158
159    let parts: Vec<&str> = pattern.split('*').collect();
160
161    if parts.is_empty() {
162        return true;
163    }
164
165    // Check prefix
166    if !parts[0].is_empty() && !text.starts_with(parts[0]) {
167        return false;
168    }
169
170    // Check suffix
171    let last = parts.last().unwrap_or(&"");
172    if !last.is_empty() && !text.ends_with(last) {
173        return false;
174    }
175
176    // Check all parts exist in order
177    let mut remaining = text;
178    for part in &parts {
179        if part.is_empty() {
180            continue;
181        }
182        if let Some(pos) = remaining.find(part) {
183            remaining = &remaining[pos + part.len()..];
184        } else {
185            return false;
186        }
187    }
188
189    true
190}
191
192impl PromptStorage for FilesystemPromptStorage {
193    fn save(&self, template: &PromptTemplate) -> Result<String> {
194        let path = self.prompt_path(&template.name)?;
195
196        // Create mutable copy with updated timestamp
197        let mut template = template.clone();
198        let now = current_timestamp();
199        if template.created_at == 0 {
200            template.created_at = now;
201        }
202        template.updated_at = now;
203
204        self.write_prompt_file(&path, &template)?;
205
206        Ok(format!("prompt_fs_{}", template.name))
207    }
208
209    fn get(&self, name: &str) -> Result<Option<PromptTemplate>> {
210        let path = self.prompt_path(name)?;
211
212        if !path.exists() {
213            return Ok(None);
214        }
215
216        let template = self.read_prompt_file(&path)?;
217        Ok(Some(template))
218    }
219
220    fn list(
221        &self,
222        tags: Option<&[String]>,
223        name_pattern: Option<&str>,
224    ) -> Result<Vec<PromptTemplate>> {
225        let entries = fs::read_dir(&self.base_path).map_err(|e| Error::OperationFailed {
226            operation: "list_prompt_dir".to_string(),
227            cause: e.to_string(),
228        })?;
229
230        let mut results = Vec::new();
231
232        for entry in entries.flatten() {
233            let path = entry.path();
234
235            // Only process .json files
236            if path.extension().and_then(|e| e.to_str()) != Some("json") {
237                continue;
238            }
239
240            // Try to read the prompt
241            let template = match self.read_prompt_file(&path) {
242                Ok(t) => t,
243                Err(_) => continue,
244            };
245
246            // Check tag filter (AND logic)
247            let has_all_tags = tags.is_none_or(|required_tags| {
248                required_tags.iter().all(|rt| template.tags.contains(rt))
249            });
250            if !has_all_tags {
251                continue;
252            }
253
254            // Check name pattern
255            let matches_pattern =
256                name_pattern.is_none_or(|pattern| matches_glob(pattern, &template.name));
257            if !matches_pattern {
258                continue;
259            }
260
261            results.push(template);
262        }
263
264        // Sort by usage count (descending) then name
265        results.sort_by(|a, b| {
266            b.usage_count
267                .cmp(&a.usage_count)
268                .then_with(|| a.name.cmp(&b.name))
269        });
270
271        Ok(results)
272    }
273
274    fn delete(&self, name: &str) -> Result<bool> {
275        let path = self.prompt_path(name)?;
276
277        if !path.exists() {
278            return Ok(false);
279        }
280
281        fs::remove_file(&path).map_err(|e| Error::OperationFailed {
282            operation: "delete_prompt_file".to_string(),
283            cause: e.to_string(),
284        })?;
285
286        Ok(true)
287    }
288
289    fn increment_usage(&self, name: &str) -> Result<u64> {
290        let path = self.prompt_path(name)?;
291
292        if !path.exists() {
293            return Err(Error::OperationFailed {
294                operation: "increment_usage".to_string(),
295                cause: format!("Prompt not found: {name}"),
296            });
297        }
298
299        let mut template = self.read_prompt_file(&path)?;
300        template.usage_count = template.usage_count.saturating_add(1);
301        template.updated_at = current_timestamp();
302
303        self.write_prompt_file(&path, &template)?;
304
305        Ok(template.usage_count)
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use tempfile::TempDir;
313
314    #[test]
315    fn test_filesystem_prompt_storage_creation() {
316        let dir = TempDir::new().unwrap();
317        let storage = FilesystemPromptStorage::new(dir.path()).unwrap();
318        assert_eq!(storage.base_path(), dir.path());
319    }
320
321    #[test]
322    fn test_save_and_get_prompt() {
323        let dir = TempDir::new().unwrap();
324        let storage = FilesystemPromptStorage::new(dir.path()).unwrap();
325
326        let template =
327            PromptTemplate::new("test-prompt", "Hello {{name}}!").with_description("A test prompt");
328
329        let id = storage.save(&template).unwrap();
330        assert!(id.contains("test-prompt"));
331
332        let retrieved = storage.get("test-prompt").unwrap();
333        assert!(retrieved.is_some());
334        let retrieved = retrieved.unwrap();
335        assert_eq!(retrieved.name, "test-prompt");
336        assert_eq!(retrieved.content, "Hello {{name}}!");
337    }
338
339    #[test]
340    fn test_list_prompts() {
341        let dir = TempDir::new().unwrap();
342        let storage = FilesystemPromptStorage::new(dir.path()).unwrap();
343
344        storage
345            .save(&PromptTemplate::new("alpha", "A").with_tags(vec!["tag1".to_string()]))
346            .unwrap();
347        storage
348            .save(
349                &PromptTemplate::new("beta", "B")
350                    .with_tags(vec!["tag1".to_string(), "tag2".to_string()]),
351            )
352            .unwrap();
353        storage.save(&PromptTemplate::new("gamma", "C")).unwrap();
354
355        // List all
356        let all = storage.list(None, None).unwrap();
357        assert_eq!(all.len(), 3);
358
359        // Filter by tag
360        let with_tag1 = storage.list(Some(&["tag1".to_string()]), None).unwrap();
361        assert_eq!(with_tag1.len(), 2);
362
363        // Filter by name pattern
364        let alpha_pattern = storage.list(None, Some("a*")).unwrap();
365        assert_eq!(alpha_pattern.len(), 1);
366        assert_eq!(alpha_pattern[0].name, "alpha");
367    }
368
369    #[test]
370    fn test_delete_prompt() {
371        let dir = TempDir::new().unwrap();
372        let storage = FilesystemPromptStorage::new(dir.path()).unwrap();
373
374        storage
375            .save(&PromptTemplate::new("to-delete", "Content"))
376            .unwrap();
377
378        assert!(storage.get("to-delete").unwrap().is_some());
379        assert!(storage.delete("to-delete").unwrap());
380        assert!(storage.get("to-delete").unwrap().is_none());
381        assert!(!storage.delete("to-delete").unwrap()); // Already deleted
382    }
383
384    #[test]
385    fn test_increment_usage() {
386        let dir = TempDir::new().unwrap();
387        let storage = FilesystemPromptStorage::new(dir.path()).unwrap();
388
389        storage
390            .save(&PromptTemplate::new("used-prompt", "Content"))
391            .unwrap();
392
393        let count1 = storage.increment_usage("used-prompt").unwrap();
394        assert_eq!(count1, 1);
395
396        let count2 = storage.increment_usage("used-prompt").unwrap();
397        assert_eq!(count2, 2);
398
399        let prompt = storage.get("used-prompt").unwrap().unwrap();
400        assert_eq!(prompt.usage_count, 2);
401    }
402
403    #[test]
404    fn test_default_user_path() {
405        let path = FilesystemPromptStorage::default_user_path();
406        // Should return Some on most systems
407        if let Some(p) = path {
408            assert!(p.to_string_lossy().contains("subcog"));
409            assert!(p.to_string_lossy().ends_with("prompts"));
410        }
411    }
412
413    #[test]
414    fn test_default_org_path() {
415        let path = FilesystemPromptStorage::default_org_path("test-org");
416        // Should return Some on most systems
417        if let Some(p) = path {
418            assert!(p.to_string_lossy().contains("subcog"));
419            assert!(p.to_string_lossy().contains("orgs"));
420            assert!(p.to_string_lossy().contains("test-org"));
421            assert!(p.to_string_lossy().ends_with("prompts"));
422        }
423    }
424
425    #[test]
426    fn test_matches_glob() {
427        assert!(matches_glob("test", "test"));
428        assert!(!matches_glob("test", "other"));
429
430        assert!(matches_glob("test-*", "test-prompt"));
431        assert!(!matches_glob("test-*", "other-prompt"));
432
433        assert!(matches_glob("*-prompt", "test-prompt"));
434        assert!(matches_glob("*test*", "my-test-prompt"));
435    }
436
437    // Path traversal prevention tests
438    #[test]
439    fn test_path_traversal_parent_directory() {
440        let dir = TempDir::new().unwrap();
441        let storage = FilesystemPromptStorage::new(dir.path()).unwrap();
442
443        // Attempt to use ".." without slashes (caught by parent dir check)
444        let result = storage.get("foo..bar");
445        assert!(result.is_err());
446        let err = result.unwrap_err();
447        assert!(err.to_string().contains("parent directory"));
448
449        // Attempt with slashes is caught by path separator check
450        let result2 = storage.get("../../../etc/passwd");
451        assert!(result2.is_err());
452    }
453
454    #[test]
455    fn test_path_traversal_forward_slash() {
456        let dir = TempDir::new().unwrap();
457        let storage = FilesystemPromptStorage::new(dir.path()).unwrap();
458
459        // Attempt to use forward slash
460        let result = storage.get("foo/bar");
461        assert!(result.is_err());
462        let err = result.unwrap_err();
463        assert!(err.to_string().contains("path separators"));
464    }
465
466    #[test]
467    fn test_path_traversal_backslash() {
468        let dir = TempDir::new().unwrap();
469        let storage = FilesystemPromptStorage::new(dir.path()).unwrap();
470
471        // Attempt to use backslash
472        let result = storage.get("foo\\bar");
473        assert!(result.is_err());
474        let err = result.unwrap_err();
475        assert!(err.to_string().contains("path separators"));
476    }
477
478    #[test]
479    fn test_path_traversal_null_byte() {
480        let dir = TempDir::new().unwrap();
481        let storage = FilesystemPromptStorage::new(dir.path()).unwrap();
482
483        // Attempt to use null byte
484        let result = storage.get("foo\0bar");
485        assert!(result.is_err());
486        let err = result.unwrap_err();
487        assert!(err.to_string().contains("null bytes"));
488    }
489
490    #[test]
491    fn test_path_traversal_hidden_file() {
492        let dir = TempDir::new().unwrap();
493        let storage = FilesystemPromptStorage::new(dir.path()).unwrap();
494
495        // Attempt to create hidden file
496        let result = storage.get(".hidden");
497        assert!(result.is_err());
498        let err = result.unwrap_err();
499        assert!(err.to_string().contains("start with a dot"));
500    }
501
502    #[test]
503    fn test_path_traversal_empty_name() {
504        let dir = TempDir::new().unwrap();
505        let storage = FilesystemPromptStorage::new(dir.path()).unwrap();
506
507        // Attempt to use empty name
508        let result = storage.get("");
509        assert!(result.is_err());
510        let err = result.unwrap_err();
511        assert!(err.to_string().contains("cannot be empty"));
512    }
513
514    #[test]
515    fn test_path_traversal_save_blocked() {
516        let dir = TempDir::new().unwrap();
517        let storage = FilesystemPromptStorage::new(dir.path()).unwrap();
518
519        // Attempt to save with path traversal
520        let template = PromptTemplate::new("../evil", "Malicious content");
521        let result = storage.save(&template);
522        assert!(result.is_err());
523    }
524
525    #[test]
526    fn test_path_traversal_delete_blocked() {
527        let dir = TempDir::new().unwrap();
528        let storage = FilesystemPromptStorage::new(dir.path()).unwrap();
529
530        // Attempt to delete with path traversal
531        let result = storage.delete("../../../etc/passwd");
532        assert!(result.is_err());
533    }
534
535    #[test]
536    fn test_valid_prompt_name_works() {
537        let dir = TempDir::new().unwrap();
538        let storage = FilesystemPromptStorage::new(dir.path()).unwrap();
539
540        // Valid names should work
541        let template = PromptTemplate::new("valid-prompt-name", "Content");
542        let result = storage.save(&template);
543        assert!(result.is_ok());
544
545        let retrieved = storage.get("valid-prompt-name").unwrap();
546        assert!(retrieved.is_some());
547    }
548}