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 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}