1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt;
3use std::fs::File;
4use std::future::Future;
5use std::io::Read;
6use std::pin::Pin;
7use std::sync::Arc;
8
9use camino::{Utf8Path, Utf8PathBuf};
10use glob::{MatchOptions, Pattern};
11
12pub(crate) const MAX_FILE_BYTES: usize = 4 * 1024 * 1024;
13pub(crate) const MAX_OPERATION_FILE_BYTES: usize = 32 * 1024 * 1024;
14pub(crate) const MAX_OPERATION_PATHS: usize = 10_000;
15
16pub type PluginFileFuture<'a, T> =
18 Pin<Box<dyn Future<Output = Result<T, PluginFileError>> + Send + 'a>>;
19
20pub trait PluginFileClient: fmt::Debug + Send + Sync + 'static {
22 fn list_files(&self, pattern: &str) -> PluginFileFuture<'_, Vec<String>>;
23
24 fn read_text(&self, path: &str) -> PluginFileFuture<'_, String>;
25}
26
27#[derive(Clone, Copy, Debug, Default)]
29pub struct DenyPluginFileClient;
30
31impl PluginFileClient for DenyPluginFileClient {
32 fn list_files(&self, _pattern: &str) -> PluginFileFuture<'_, Vec<String>> {
33 Box::pin(async { Err(PluginFileError::NotConfigured) })
34 }
35
36 fn read_text(&self, _path: &str) -> PluginFileFuture<'_, String> {
37 Box::pin(async { Err(PluginFileError::NotConfigured) })
38 }
39}
40
41#[derive(Clone, Debug)]
46pub struct ScopedPluginFileClient {
47 root: Utf8PathBuf,
48 patterns: Arc<BTreeMap<String, AllowedPattern>>,
49}
50
51#[derive(Clone, Debug)]
52struct AllowedPattern {
53 compiled: Pattern,
54 segments: Vec<AllowedPatternSegment>,
55}
56
57#[derive(Clone, Debug)]
58enum AllowedPatternSegment {
59 Recursive,
60 Pattern(Pattern),
61}
62
63impl AllowedPattern {
64 fn new(pattern: &str) -> Result<Self, PluginFileError> {
65 let compiled = Pattern::new(pattern).map_err(|source| PluginFileError::InvalidPattern {
66 pattern: pattern.to_owned(),
67 reason: source.to_string(),
68 })?;
69 let segments = pattern
70 .split('/')
71 .map(|segment| {
72 if segment == "**" {
73 Ok(AllowedPatternSegment::Recursive)
74 } else {
75 Pattern::new(segment)
76 .map(AllowedPatternSegment::Pattern)
77 .map_err(|source| PluginFileError::InvalidPattern {
78 pattern: pattern.to_owned(),
79 reason: source.to_string(),
80 })
81 }
82 })
83 .collect::<Result<Vec<_>, _>>()?;
84 Ok(Self { compiled, segments })
85 }
86
87 fn matches(&self, path: &str) -> bool {
88 self.compiled.matches_with(path, match_options())
89 }
90
91 fn can_match_below(&self, directory: &Utf8Path) -> bool {
92 let mut states = BTreeSet::new();
93 self.add_recursive_closure(0, &mut states);
94 for component in directory
95 .as_str()
96 .split('/')
97 .filter(|part| !part.is_empty())
98 {
99 let mut next = BTreeSet::new();
100 for state in states {
101 match self.segments.get(state) {
102 Some(AllowedPatternSegment::Recursive) if !component.starts_with('.') => {
103 self.add_recursive_closure(state, &mut next);
104 }
105 Some(AllowedPatternSegment::Pattern(pattern))
106 if pattern.matches_with(component, match_options()) =>
107 {
108 self.add_recursive_closure(state + 1, &mut next);
109 }
110 _ => {}
111 }
112 }
113 states = next;
114 if states.is_empty() {
115 return false;
116 }
117 }
118 states.into_iter().any(|state| state < self.segments.len())
119 }
120
121 fn add_recursive_closure(&self, mut state: usize, states: &mut BTreeSet<usize>) {
122 states.insert(state);
123 while matches!(
124 self.segments.get(state),
125 Some(AllowedPatternSegment::Recursive)
126 ) {
127 state += 1;
128 states.insert(state);
129 }
130 }
131}
132
133impl ScopedPluginFileClient {
134 pub fn new(
135 root: impl Into<Utf8PathBuf>,
136 read_patterns: impl IntoIterator<Item = String>,
137 ) -> Result<Self, PluginFileError> {
138 let configured_root = root.into();
139 let canonical_root = std::fs::canonicalize(&configured_root).map_err(|source| {
140 PluginFileError::ResolveRoot {
141 root: configured_root.clone(),
142 source,
143 }
144 })?;
145 let root = Utf8PathBuf::from_path_buf(canonical_root).map_err(|path| {
146 PluginFileError::NonUtf8Root {
147 root: path.display().to_string(),
148 }
149 })?;
150 if !root.is_dir() {
151 return Err(PluginFileError::RootNotDirectory { root });
152 }
153
154 let mut patterns = BTreeMap::new();
155 for pattern in read_patterns {
156 validate_protocol_path(&pattern, PathKind::Pattern)?;
157 let compiled = AllowedPattern::new(&pattern)?;
158 patterns.insert(pattern, compiled);
159 }
160
161 Ok(Self {
162 root,
163 patterns: Arc::new(patterns),
164 })
165 }
166
167 fn list_files_sync(&self, requested_pattern: &str) -> Result<Vec<String>, PluginFileError> {
168 validate_protocol_path(requested_pattern, PathKind::Pattern)?;
169 let pattern = self.patterns.get(requested_pattern).ok_or_else(|| {
170 PluginFileError::PatternNotAllowed {
171 pattern: requested_pattern.to_owned(),
172 }
173 })?;
174 let mut matches = BTreeSet::new();
175 self.walk_directory(
176 &self.root,
177 Utf8Path::new(""),
178 pattern,
179 &mut BTreeSet::new(),
180 &mut matches,
181 )?;
182 Ok(matches.into_iter().collect())
183 }
184
185 fn walk_directory(
186 &self,
187 directory: &Utf8Path,
188 logical_directory: &Utf8Path,
189 pattern: &AllowedPattern,
190 ancestors: &mut BTreeSet<Utf8PathBuf>,
191 matches: &mut BTreeSet<String>,
192 ) -> Result<(), PluginFileError> {
193 let canonical = self.resolve_inside_root(directory, logical_directory.as_str())?;
194 if !ancestors.insert(canonical.clone()) {
195 return Err(PluginFileError::DirectoryCycle {
196 path: logical_directory.to_owned(),
197 });
198 }
199 let entries =
200 std::fs::read_dir(&canonical).map_err(|source| PluginFileError::ReadDirectory {
201 path: canonical.clone(),
202 source,
203 })?;
204 for entry in entries {
205 let entry = entry.map_err(|source| PluginFileError::ReadDirectory {
206 path: canonical.clone(),
207 source,
208 })?;
209 let path = Utf8PathBuf::from_path_buf(entry.path()).map_err(|path| {
210 PluginFileError::NonUtf8Path {
211 path: path.display().to_string(),
212 }
213 })?;
214 let name =
215 entry
216 .file_name()
217 .into_string()
218 .map_err(|name| PluginFileError::NonUtf8Path {
219 path: name.to_string_lossy().into_owned(),
220 })?;
221 let logical_path = logical_directory.join(name);
222 let file_type = entry
223 .file_type()
224 .map_err(|source| PluginFileError::InspectPath {
225 path: path.clone(),
226 source,
227 })?;
228
229 if file_type.is_dir() {
230 if pattern.can_match_below(&logical_path) {
231 self.walk_directory(&path, &logical_path, pattern, ancestors, matches)?;
232 }
233 continue;
234 }
235
236 if file_type.is_symlink() {
237 self.collect_symlink(&path, &logical_path, pattern, ancestors, matches)?;
238 continue;
239 }
240
241 if file_type.is_file() && pattern.matches(logical_path.as_str()) {
242 insert_match(matches, logical_path.as_str())?;
243 }
244 }
245 ancestors.remove(&canonical);
246 Ok(())
247 }
248
249 fn collect_symlink(
250 &self,
251 path: &Utf8Path,
252 logical_path: &Utf8Path,
253 pattern: &AllowedPattern,
254 ancestors: &mut BTreeSet<Utf8PathBuf>,
255 matches: &mut BTreeSet<String>,
256 ) -> Result<(), PluginFileError> {
257 let matches_file = pattern.matches(logical_path.as_str());
258 let matches_below = pattern.can_match_below(logical_path);
259 if !matches_file && !matches_below {
260 return Ok(());
261 }
262 let canonical = self.resolve_inside_root(path, logical_path.as_str())?;
263 if canonical.is_dir() && matches_below {
264 self.walk_directory(&canonical, logical_path, pattern, ancestors, matches)?;
265 } else if canonical.is_file() && matches_file {
266 insert_match(matches, logical_path.as_str())?;
267 }
268 Ok(())
269 }
270
271 fn read_text_sync(&self, requested_path: &str) -> Result<String, PluginFileError> {
272 validate_protocol_path(requested_path, PathKind::File)?;
273 if !self
274 .patterns
275 .values()
276 .any(|pattern| pattern.matches(requested_path))
277 {
278 return Err(PluginFileError::PathNotAllowed {
279 path: requested_path.to_owned(),
280 });
281 }
282
283 let path = self.root.join(requested_path);
284 let canonical = self.resolve_inside_root(&path, requested_path)?;
285 let metadata = canonical
286 .metadata()
287 .map_err(|source| PluginFileError::InspectPath {
288 path: canonical.clone(),
289 source,
290 })?;
291 if !metadata.is_file() {
292 return Err(PluginFileError::NotAFile {
293 path: requested_path.to_owned(),
294 });
295 }
296 let actual = usize::try_from(metadata.len()).unwrap_or(usize::MAX);
297 if actual > MAX_FILE_BYTES {
298 return Err(PluginFileError::FileTooLarge {
299 path: requested_path.to_owned(),
300 actual,
301 maximum: MAX_FILE_BYTES,
302 });
303 }
304
305 let file = File::open(&canonical).map_err(|source| PluginFileError::ReadFile {
306 path: requested_path.to_owned(),
307 source,
308 })?;
309 let maximum =
310 u64::try_from(MAX_FILE_BYTES).map_err(|source| PluginFileError::InternalLimit {
311 reason: source.to_string(),
312 })?;
313 let mut bytes = Vec::with_capacity(actual);
314 file.take(maximum.saturating_add(1))
315 .read_to_end(&mut bytes)
316 .map_err(|source| PluginFileError::ReadFile {
317 path: requested_path.to_owned(),
318 source,
319 })?;
320 if bytes.len() > MAX_FILE_BYTES {
321 return Err(PluginFileError::FileTooLarge {
322 path: requested_path.to_owned(),
323 actual: bytes.len(),
324 maximum: MAX_FILE_BYTES,
325 });
326 }
327 String::from_utf8(bytes).map_err(|source| PluginFileError::InvalidUtf8 {
328 path: requested_path.to_owned(),
329 source,
330 })
331 }
332
333 fn resolve_inside_root(
334 &self,
335 path: &Utf8Path,
336 relative: &str,
337 ) -> Result<Utf8PathBuf, PluginFileError> {
338 let canonical =
339 std::fs::canonicalize(path).map_err(|source| PluginFileError::ResolvePath {
340 path: relative.to_owned(),
341 source,
342 })?;
343 let canonical =
344 Utf8PathBuf::from_path_buf(canonical).map_err(|path| PluginFileError::NonUtf8Path {
345 path: path.display().to_string(),
346 })?;
347 if !canonical.starts_with(&self.root) {
348 return Err(PluginFileError::OutsideProjectRoot {
349 path: relative.to_owned(),
350 });
351 }
352 Ok(canonical)
353 }
354}
355
356impl PluginFileClient for ScopedPluginFileClient {
357 fn list_files(&self, pattern: &str) -> PluginFileFuture<'_, Vec<String>> {
358 let pattern = pattern.to_owned();
359 Box::pin(async move { self.list_files_sync(&pattern) })
360 }
361
362 fn read_text(&self, path: &str) -> PluginFileFuture<'_, String> {
363 let path = path.to_owned();
364 Box::pin(async move { self.read_text_sync(&path) })
365 }
366}
367
368fn insert_match(matches: &mut BTreeSet<String>, path: &str) -> Result<(), PluginFileError> {
369 matches.insert(path.to_owned());
370 if matches.len() > MAX_OPERATION_PATHS {
371 return Err(PluginFileError::TooManyPaths {
372 actual: matches.len(),
373 maximum: MAX_OPERATION_PATHS,
374 });
375 }
376 Ok(())
377}
378
379#[derive(Clone, Copy)]
380enum PathKind {
381 File,
382 Pattern,
383}
384
385pub(crate) fn validate_file_path(value: &str) -> Result<(), PluginFileError> {
386 validate_protocol_path(value, PathKind::File)
387}
388
389pub(crate) fn validate_pattern_path(value: &str) -> Result<(), PluginFileError> {
390 validate_protocol_path(value, PathKind::Pattern)
391}
392
393pub(crate) fn matches_pattern(pattern: &str, path: &str) -> Result<bool, PluginFileError> {
394 let pattern = Pattern::new(pattern).map_err(|source| PluginFileError::InvalidPattern {
395 pattern: pattern.to_owned(),
396 reason: source.to_string(),
397 })?;
398 Ok(pattern.matches_with(path, match_options()))
399}
400
401fn validate_protocol_path(value: &str, kind: PathKind) -> Result<(), PluginFileError> {
402 let invalid = value.is_empty()
403 || value.contains('\\')
404 || value.starts_with('/')
405 || value.ends_with('/')
406 || value.split('/').any(|segment| {
407 segment.is_empty()
408 || segment == "."
409 || segment == ".."
410 || is_windows_drive_segment(segment)
411 });
412 if invalid {
413 return match kind {
414 PathKind::File => Err(PluginFileError::InvalidPath {
415 path: value.to_owned(),
416 }),
417 PathKind::Pattern => Err(PluginFileError::InvalidPattern {
418 pattern: value.to_owned(),
419 reason: "patterns must be normalized project-relative UTF-8 paths".to_owned(),
420 }),
421 };
422 }
423 Ok(())
424}
425
426fn is_windows_drive_segment(segment: &str) -> bool {
427 let bytes = segment.as_bytes();
428 bytes.len() == 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
429}
430
431const fn match_options() -> MatchOptions {
432 MatchOptions {
433 case_sensitive: true,
434 require_literal_separator: true,
435 require_literal_leading_dot: true,
436 }
437}
438
439#[derive(Debug, thiserror::Error)]
440pub enum PluginFileError {
441 #[error("plugin file access is not configured")]
442 NotConfigured,
443 #[error("failed to resolve plugin project root `{root}`: {source}")]
444 ResolveRoot {
445 root: Utf8PathBuf,
446 #[source]
447 source: std::io::Error,
448 },
449 #[error("plugin project root is not UTF-8: `{root}`")]
450 NonUtf8Root { root: String },
451 #[error("plugin project root is not a directory: `{root}`")]
452 RootNotDirectory { root: Utf8PathBuf },
453 #[error("invalid plugin read pattern `{pattern}`: {reason}")]
454 InvalidPattern { pattern: String, reason: String },
455 #[error("plugin read pattern is not allowed: `{pattern}`")]
456 PatternNotAllowed { pattern: String },
457 #[error("invalid plugin file path: `{path}`")]
458 InvalidPath { path: String },
459 #[error("plugin file path is not allowed: `{path}`")]
460 PathNotAllowed { path: String },
461 #[error("plugin file path is outside the project root: `{path}`")]
462 OutsideProjectRoot { path: String },
463 #[error("plugin file path is not UTF-8: `{path}`")]
464 NonUtf8Path { path: String },
465 #[error("failed to read plugin directory `{path}`: {source}")]
466 ReadDirectory {
467 path: Utf8PathBuf,
468 #[source]
469 source: std::io::Error,
470 },
471 #[error("failed to inspect plugin file path `{path}`: {source}")]
472 InspectPath {
473 path: Utf8PathBuf,
474 #[source]
475 source: std::io::Error,
476 },
477 #[error("failed to resolve plugin file path `{path}`: {source}")]
478 ResolvePath {
479 path: String,
480 #[source]
481 source: std::io::Error,
482 },
483 #[error("plugin file traversal encountered a directory cycle at `{path}`")]
484 DirectoryCycle { path: Utf8PathBuf },
485 #[error("plugin file path is not a regular file: `{path}`")]
486 NotAFile { path: String },
487 #[error("plugin file `{path}` contains {actual} bytes; maximum is {maximum}")]
488 FileTooLarge {
489 path: String,
490 actual: usize,
491 maximum: usize,
492 },
493 #[error("failed to read plugin file `{path}`: {source}")]
494 ReadFile {
495 path: String,
496 #[source]
497 source: std::io::Error,
498 },
499 #[error("plugin file `{path}` is not valid UTF-8: {source}")]
500 InvalidUtf8 {
501 path: String,
502 #[source]
503 source: std::string::FromUtf8Error,
504 },
505 #[error("plugin file listing returned {actual} paths; maximum is {maximum}")]
506 TooManyPaths { actual: usize, maximum: usize },
507 #[error("plugin file capability `{method}` requires a string argument")]
508 InvalidArgument { method: &'static str },
509 #[error("plugin file capability host is unavailable")]
510 HostUnavailable,
511 #[error("plugin file capability budget state is unavailable")]
512 BudgetStateUnavailable,
513 #[error("plugin file capability returned path `{path}` that does not match `{pattern}`")]
514 ReturnedPathDoesNotMatch { path: String, pattern: String },
515 #[error("plugin file reads returned {actual} bytes in this operation; maximum is {maximum}")]
516 OperationBytesExceeded { actual: usize, maximum: usize },
517 #[error("invalid internal plugin file limit: {reason}")]
518 InternalLimit { reason: String },
519}
520
521#[cfg(test)]
522mod tests {
523 use std::fs;
524 use std::time::{SystemTime, UNIX_EPOCH};
525
526 use super::*;
527
528 fn fixture_root(test: &str) -> Utf8PathBuf {
529 let nonce = SystemTime::now()
530 .duration_since(UNIX_EPOCH)
531 .unwrap()
532 .as_nanos();
533 let root = std::env::temp_dir().join(format!(
534 "semifold-plugin-file-{}-{test}-{nonce}",
535 std::process::id()
536 ));
537 fs::create_dir_all(&root).unwrap();
538 Utf8PathBuf::from_path_buf(root).unwrap()
539 }
540
541 #[test]
542 fn lists_declared_patterns_in_sorted_order_and_reads_matching_text() {
543 let root = fixture_root("list-and-read");
544 fs::create_dir_all(root.join("packages/zeta")).unwrap();
545 fs::create_dir_all(root.join("packages/alpha")).unwrap();
546 fs::write(root.join("packages/zeta/package.json"), "zeta").unwrap();
547 fs::write(root.join("packages/alpha/package.json"), "alpha").unwrap();
548 fs::write(root.join("packages/alpha/ignored.toml"), "ignored").unwrap();
549 let client =
550 ScopedPluginFileClient::new(root.clone(), ["packages/**/package.json".to_owned()])
551 .unwrap();
552
553 assert_eq!(
554 client.list_files_sync("packages/**/package.json").unwrap(),
555 vec![
556 "packages/alpha/package.json".to_owned(),
557 "packages/zeta/package.json".to_owned()
558 ]
559 );
560 assert_eq!(
561 client
562 .read_text_sync("packages/alpha/package.json")
563 .unwrap(),
564 "alpha"
565 );
566 fs::remove_dir_all(root).unwrap();
567 }
568
569 #[test]
570 fn rejects_undeclared_patterns_and_non_matching_paths() {
571 let root = fixture_root("authorization");
572 fs::write(root.join("package.json"), "{}").unwrap();
573 fs::write(root.join("secret.txt"), "secret").unwrap();
574 let client =
575 ScopedPluginFileClient::new(root.clone(), ["package.json".to_owned()]).unwrap();
576
577 assert!(matches!(
578 client.list_files_sync("*.json"),
579 Err(PluginFileError::PatternNotAllowed { .. })
580 ));
581 assert!(matches!(
582 client.read_text_sync("secret.txt"),
583 Err(PluginFileError::PathNotAllowed { .. })
584 ));
585 assert!(matches!(
586 client.read_text_sync("../secret.txt"),
587 Err(PluginFileError::InvalidPath { .. })
588 ));
589 fs::remove_dir_all(root).unwrap();
590 }
591
592 #[test]
593 fn rejects_invalid_utf8_file_content() {
594 let root = fixture_root("invalid-utf8");
595 fs::write(root.join("invalid.txt"), [0xff, 0xfe]).unwrap();
596 let client = ScopedPluginFileClient::new(root.clone(), ["*.txt".to_owned()]).unwrap();
597
598 assert!(matches!(
599 client.read_text_sync("invalid.txt"),
600 Err(PluginFileError::InvalidUtf8 { .. })
601 ));
602 fs::remove_dir_all(root).unwrap();
603 }
604
605 #[test]
606 fn rejects_files_larger_than_the_per_file_budget() {
607 let root = fixture_root("file-budget");
608 fs::write(root.join("large.txt"), vec![b'x'; MAX_FILE_BYTES + 1]).unwrap();
609 let client = ScopedPluginFileClient::new(root.clone(), ["*.txt".to_owned()]).unwrap();
610
611 assert!(matches!(
612 client.read_text_sync("large.txt"),
613 Err(PluginFileError::FileTooLarge {
614 actual,
615 maximum: MAX_FILE_BYTES,
616 ..
617 }) if actual == MAX_FILE_BYTES + 1
618 ));
619 fs::remove_dir_all(root).unwrap();
620 }
621
622 #[cfg(unix)]
623 #[test]
624 fn rejects_symlinks_that_escape_the_project_root() {
625 use std::os::unix::fs::symlink;
626
627 let root = fixture_root("symlink-root");
628 let outside = fixture_root("symlink-outside");
629 fs::write(outside.join("secret.txt"), "secret").unwrap();
630 symlink(outside.join("secret.txt"), root.join("secret.txt")).unwrap();
631 let client = ScopedPluginFileClient::new(root.clone(), ["*.txt".to_owned()]).unwrap();
632
633 assert!(matches!(
634 client.list_files_sync("*.txt"),
635 Err(PluginFileError::OutsideProjectRoot { .. })
636 ));
637 assert!(matches!(
638 client.read_text_sync("secret.txt"),
639 Err(PluginFileError::OutsideProjectRoot { .. })
640 ));
641 fs::remove_dir_all(root).unwrap();
642 fs::remove_dir_all(outside).unwrap();
643 }
644
645 #[cfg(unix)]
646 #[test]
647 fn traverses_in_root_directory_symlinks_and_rejects_external_targets() {
648 use std::os::unix::fs::symlink;
649
650 let root = fixture_root("directory-symlinks");
651 let outside = fixture_root("external-directory");
652 fs::create_dir_all(root.join("actual")).unwrap();
653 fs::write(root.join("actual/package.json"), "{}").unwrap();
654 fs::write(outside.join("package.json"), "{}").unwrap();
655 symlink(root.join("actual"), root.join("linked")).unwrap();
656 symlink(&outside, root.join("external")).unwrap();
657 let client = ScopedPluginFileClient::new(
658 root.clone(),
659 [
660 "linked/**/*.json".to_owned(),
661 "external/**/*.json".to_owned(),
662 ],
663 )
664 .unwrap();
665
666 assert_eq!(
667 client.list_files_sync("linked/**/*.json").unwrap(),
668 vec!["linked/package.json".to_owned()]
669 );
670 assert!(matches!(
671 client.list_files_sync("external/**/*.json"),
672 Err(PluginFileError::OutsideProjectRoot { .. })
673 ));
674 fs::remove_dir_all(root).unwrap();
675 fs::remove_dir_all(outside).unwrap();
676 }
677
678 #[cfg(unix)]
679 #[test]
680 fn rejects_directory_symlink_cycles() {
681 use std::os::unix::fs::symlink;
682
683 let root = fixture_root("symlink-cycle");
684 fs::create_dir_all(root.join("data")).unwrap();
685 symlink(&root, root.join("data/back")).unwrap();
686 let client =
687 ScopedPluginFileClient::new(root.clone(), ["data/**/*.json".to_owned()]).unwrap();
688
689 assert!(matches!(
690 client.list_files_sync("data/**/*.json"),
691 Err(PluginFileError::DirectoryCycle { .. })
692 ));
693 fs::remove_dir_all(root).unwrap();
694 }
695}