Skip to main content

ontocore_core/
scanner.rs

1use crate::error::{OntoCoreError, Result};
2use crate::limits::{MAX_FILE_BYTES, MAX_SCAN_FILES, MAX_SCAN_WALK_ENTRIES};
3use crate::model::OntologyFormat;
4use crate::path_jail::{canonical_workspace_root, is_path_within};
5use ignore::WalkBuilder;
6use sha2::{Digest, Sha256};
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::time::UNIX_EPOCH;
10
11const ONTOLOGY_EXTENSIONS: &[&str] =
12    &["ttl", "rdf", "owl", "jsonld", "json-ld", "nt", "nq", "trig", "obo"];
13
14#[derive(Debug, Clone)]
15pub struct OntologyFile {
16    pub path: PathBuf,
17    pub format: OntologyFormat,
18    pub content_hash: String,
19    pub modified_time: u64,
20    pub size_bytes: u64,
21}
22
23pub struct WorkspaceScanner {
24    root: PathBuf,
25    canonical_root: PathBuf,
26}
27
28impl WorkspaceScanner {
29    pub fn new(root: impl Into<PathBuf>) -> Self {
30        let root = root.into();
31        let canonical_root = canonical_workspace_root(&root).unwrap_or_else(|_| root.clone());
32        Self { root, canonical_root }
33    }
34
35    pub fn canonical_root(&self) -> &Path {
36        &self.canonical_root
37    }
38
39    pub fn scan(&self) -> Result<Vec<OntologyFile>> {
40        if !self.root.exists() {
41            return Err(OntoCoreError::Scanner(format!(
42                "workspace path does not exist: {}",
43                self.root.display()
44            )));
45        }
46
47        let mut files = Vec::new();
48        let mut walked = 0usize;
49        let walker = WalkBuilder::new(&self.root)
50            .hidden(false)
51            .git_ignore(true)
52            .git_global(true)
53            .git_exclude(true)
54            .follow_links(false)
55            .build();
56
57        for entry in walker {
58            walked += 1;
59            if walked > MAX_SCAN_WALK_ENTRIES {
60                return Err(OntoCoreError::Scanner(format!(
61                    "workspace walk exceeds maximum of {MAX_SCAN_WALK_ENTRIES} entries"
62                )));
63            }
64            if files.len() >= MAX_SCAN_FILES {
65                return Err(OntoCoreError::Scanner(format!(
66                    "workspace exceeds maximum of {MAX_SCAN_FILES} ontology files"
67                )));
68            }
69
70            let entry = entry.map_err(|e| OntoCoreError::Scanner(e.to_string()))?;
71            let path = entry.path();
72            if !path.is_file() {
73                continue;
74            }
75            if path.symlink_metadata().map(|m| m.file_type().is_symlink()).unwrap_or(false) {
76                continue;
77            }
78            if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
79                if ONTOLOGY_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()) {
80                    let canonical =
81                        path.canonicalize().map_err(|e| OntoCoreError::Scanner(e.to_string()))?;
82                    if !is_path_within(&self.canonical_root, &canonical) {
83                        continue;
84                    }
85                    files.push(self.describe_file(&canonical)?);
86                }
87            }
88        }
89
90        files.sort_by(|a, b| a.path.cmp(&b.path));
91        Ok(files)
92    }
93
94    /// Describe a single ontology file on disk (hash, format, mtime).
95    pub fn describe_path(&self, path: &Path) -> Result<OntologyFile> {
96        let canonical = path.canonicalize().map_err(|e| OntoCoreError::Scanner(e.to_string()))?;
97        if !is_path_within(&self.canonical_root, &canonical) {
98            return Err(OntoCoreError::Scanner(format!(
99                "path outside workspace: {}",
100                path.display()
101            )));
102        }
103        self.describe_file(&canonical)
104    }
105
106    fn describe_file(&self, path: &Path) -> Result<OntologyFile> {
107        let metadata = fs::metadata(path)?;
108        let size_bytes = metadata.len();
109        if size_bytes > MAX_FILE_BYTES {
110            return Err(OntoCoreError::Scanner(format!(
111                "file exceeds size limit ({} bytes > {MAX_FILE_BYTES}): {}",
112                size_bytes,
113                path.display()
114            )));
115        }
116
117        let content = crate::io::read_file_capped(path, MAX_FILE_BYTES)?;
118        let mut hasher = Sha256::new();
119        hasher.update(&content);
120        let content_hash = hex::encode(hasher.finalize());
121
122        let modified_time = metadata
123            .modified()
124            .ok()
125            .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
126            .map(|d| d.as_secs())
127            .unwrap_or(0);
128
129        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or_default();
130
131        Ok(OntologyFile {
132            path: path.to_path_buf(),
133            format: OntologyFormat::from_extension(ext),
134            content_hash,
135            modified_time,
136            size_bytes,
137        })
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use std::io::Write;
145
146    #[test]
147    fn scan_finds_ontology_files() {
148        let dir = tempfile::tempdir().unwrap();
149        let ttl = dir.path().join("example.ttl");
150        let mut f = fs::File::create(&ttl).unwrap();
151        writeln!(f, "@prefix ex: <http://example.org/> .").unwrap();
152
153        let txt = dir.path().join("readme.txt");
154        fs::write(txt, "not ontology").unwrap();
155
156        let scanner = WorkspaceScanner::new(dir.path());
157        let files = scanner.scan().unwrap();
158        assert_eq!(files.len(), 1);
159        assert_eq!(files[0].format, OntologyFormat::Turtle);
160        assert!(!files[0].content_hash.is_empty());
161    }
162
163    #[test]
164    fn skips_symlinked_ontology_files() {
165        let dir = tempfile::tempdir().unwrap();
166        let outside = tempfile::tempdir().unwrap();
167        let secret = outside.path().join("secret.ttl");
168        fs::write(&secret, "@prefix ex: <http://ex/> .").unwrap();
169
170        #[cfg(unix)]
171        {
172            use std::os::unix::fs::symlink;
173            let link = dir.path().join("linked.ttl");
174            symlink(&secret, &link).unwrap();
175            let scanner = WorkspaceScanner::new(dir.path());
176            let files = scanner.scan().unwrap();
177            assert_eq!(files.len(), 0);
178        }
179    }
180
181    #[test]
182    fn skips_files_in_sibling_prefix_directory() {
183        let dir = tempfile::tempdir().unwrap();
184        let root = dir.path().join("ws");
185        fs::create_dir_all(&root).unwrap();
186        let sibling = dir.path().join("ws_extra");
187        fs::create_dir_all(&sibling).unwrap();
188        fs::write(sibling.join("secret.ttl"), "@prefix ex: <http://ex/> .").unwrap();
189
190        let scanner = WorkspaceScanner::new(&root);
191        let files = scanner.scan().unwrap();
192        assert_eq!(files.len(), 0);
193    }
194}