Skip to content

Commit 3716e98

Browse files
feat(fs): glob — cover the full pattern set + followSymlinks
- leading `!` negates the whole pattern; `[^…]` accepted alongside `[!…]` (translated for globset); `\` escaping, `?`, `*`, `**`, `[a-z]`, `{a,b}` already covered by globset with literal_separator - Glob.scan gains followSymlinks (default false); when enabled the walk still rejects any entry whose real path leaves the root jail - types: GlobScanOptions.followSymlinks - e2e test asserting every pattern (match is pure, no fixtures)
1 parent e86ea07 commit 3716e98

6 files changed

Lines changed: 73 additions & 11 deletions

File tree

crates/default-providers/src/system_fs.rs

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,24 @@ use walkdir::WalkDir;
1818

1919
use crate::path;
2020

21-
/// Compiles a glob pattern with `literal_separator` so `*` does not cross `/`
22-
/// while `**` does (the conventional shell/Bun semantics).
23-
fn glob_matcher(pattern: &str) -> Result<globset::GlobMatcher, ProviderError> {
24-
GlobBuilder::new(pattern)
21+
/// Compiles a glob pattern into a matcher plus a "negated" flag, covering the
22+
/// full conventional set: `?`, `*` (not crossing `/`), `**` (crossing), `[ab]`,
23+
/// `[a-z]`, `[!abc]` **and** `[^abc]`, `{a,b}`, `\` escaping, and a leading `!`
24+
/// that negates the whole pattern.
25+
fn parse_glob(pattern: &str) -> Result<(globset::GlobMatcher, bool), ProviderError> {
26+
// A leading `!` negates; `\!…` is a literal `!` (globset unescapes it).
27+
let (negated, body) = match pattern.strip_prefix('!') {
28+
Some(rest) => (true, rest.to_string()),
29+
None => (false, pattern.to_string()),
30+
};
31+
// Accept the `[^…]` negated-class form (globset spells it `[!…]`).
32+
let body = body.replace("[^", "[!");
33+
let matcher = GlobBuilder::new(&body)
2534
.literal_separator(true)
2635
.build()
2736
.map(|g| g.compile_matcher())
28-
.map_err(|e| ProviderError::Other(format!("invalid glob pattern {pattern:?}: {e}")))
37+
.map_err(|e| ProviderError::Other(format!("invalid glob pattern {pattern:?}: {e}")))?;
38+
Ok((matcher, negated))
2939
}
3040

3141
/// A [`FileSystem`] over the real OS, jailed to `root`. Relative paths resolve
@@ -241,7 +251,8 @@ impl FileSystem for SystemFileSystem {
241251
}
242252

243253
fn glob_match(&self, pattern: &str, path: &str) -> Result<bool, ProviderError> {
244-
Ok(glob_matcher(pattern)?.is_match(path))
254+
let (matcher, negated) = parse_glob(pattern)?;
255+
Ok(negated ^ matcher.is_match(path))
245256
}
246257

247258
fn glob_scan(
@@ -251,18 +262,27 @@ impl FileSystem for SystemFileSystem {
251262
opts: GlobScanOptions,
252263
) -> BoxFuture<Result<Vec<String>, ProviderError>> {
253264
let resolved = self.jailed(&base);
265+
let root = self.root.clone();
254266
Box::pin(async move {
255267
let base_real = resolved?;
256-
let matcher = glob_matcher(&pattern)?;
268+
let (matcher, negated) = parse_glob(&pattern)?;
257269
let mut out = Vec::new();
258-
// follow_links(false) keeps the walk from leaving the jail via a
259-
// symlink; the base is already confined to root.
260-
for entry in WalkDir::new(&base_real).follow_links(false) {
270+
// Default: don't follow symlinks (can't leave the jail). When the
271+
// caller opts in, follow them but reject any entry whose real path
272+
// escapes the root.
273+
for entry in WalkDir::new(&base_real).follow_links(opts.follow_symlinks) {
261274
let entry = entry.map_err(|e| ProviderError::Other(format!("glob scan: {e}")))?;
262275
let path = entry.path();
263276
if path == base_real {
264277
continue; // skip the base itself
265278
}
279+
if opts.follow_symlinks
280+
&& path::canonicalize(path)
281+
.map(|real| !path::within_root(&real, &root))
282+
.unwrap_or(false)
283+
{
284+
continue; // a followed link left the jail
285+
}
266286
let rel = path.strip_prefix(&base_real).unwrap_or(path);
267287
let rel_str = rel.to_string_lossy().replace('\\', "/");
268288
if !opts.dot && rel_str.split('/').any(|c| c.starts_with('.')) {
@@ -271,7 +291,7 @@ impl FileSystem for SystemFileSystem {
271291
if opts.only_files && !entry.file_type().is_file() {
272292
continue;
273293
}
274-
if matcher.is_match(&rel_str) {
294+
if negated ^ matcher.is_match(&rel_str) {
275295
out.push(if opts.absolute {
276296
path.to_string_lossy().into_owned()
277297
} else {

crates/providers/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,9 @@ pub struct GlobScanOptions {
274274
pub absolute: bool,
275275
/// Yield only files, skipping directories.
276276
pub only_files: bool,
277+
/// Traverse into symlinked directories. The implementation still rejects any
278+
/// followed entry whose real path leaves the root jail.
279+
pub follow_symlinks: bool,
277280
}
278281

279282
/// Filesystem access backing `runtime:fs` (DECISIONS D25, SPEC §11).

crates/runtime-cli/tests/modules.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,41 @@ fn runtime_fs_read_write_stat_and_jail() {
297297
}
298298
}
299299

300+
#[test]
301+
fn runtime_fs_glob_covers_all_patterns() {
302+
// match() is pure (no FS), so the full pattern set runs without fixtures.
303+
let script = "import { Glob } from 'runtime:fs'; const m = (p, s) => new Glob(p).match(s);\
304+
const out = [];\
305+
out.push('q=' + m('???.ts','foo.ts') + ',' + m('???.ts','foobar.ts'));\
306+
out.push('star=' + m('*.ts','index.ts') + ',' + m('*.ts','src/index.ts'));\
307+
out.push('globstar=' + m('**/*.ts','src/index.ts'));\
308+
out.push('class=' + m('ba[rz].ts','bar.ts') + ',' + m('ba[rz].ts','bat.ts'));\
309+
out.push('range=' + m('f[a-c].ts','fb.ts') + ',' + m('f[a-c].ts','fz.ts'));\
310+
out.push('negbang=' + m('f[!o]o.ts','fao.ts') + ',' + m('f[!o]o.ts','foo.ts'));\
311+
out.push('negcaret=' + m('f[^o]o.ts','fao.ts') + ',' + m('f[^o]o.ts','foo.ts'));\
312+
out.push('brace=' + m('{a,b}.ts','a.ts') + ',' + m('{a,b}.ts','c.ts'));\
313+
out.push('not=' + m('!index.ts','a.ts') + ',' + m('!index.ts','index.ts'));\
314+
out.push('escape=' + m('\\\\!x.ts','!x.ts') + ',' + m('\\\\!x.ts','x.ts'));\
315+
console.log(out.join('\\n'));";
316+
let out = esrun().arg("-e").arg(script).output().expect("spawn esrun");
317+
assert!(out.status.success(), "stderr: {}", stderr(&out));
318+
let s = stdout(&out);
319+
for expected in [
320+
"q=true,false",
321+
"star=true,false",
322+
"globstar=true",
323+
"class=true,false",
324+
"range=true,false",
325+
"negbang=true,false",
326+
"negcaret=true,false",
327+
"brace=true,false",
328+
"not=true,false",
329+
"escape=true,false",
330+
] {
331+
assert!(s.contains(expected), "missing {expected:?} in:\n{s}");
332+
}
333+
}
334+
300335
#[test]
301336
fn unknown_runtime_builtin_module_errors() {
302337
let out = esrun()

crates/runtime/src/fs_ops.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ pub(crate) fn install(engine: &mut dyn Engine, fs: Option<Arc<dyn FileSystem>>)
141141
dot: arg_bool(&args, 2),
142142
absolute: arg_bool(&args, 3),
143143
only_files: arg_bool(&args, 4),
144+
follow_symlinks: arg_bool(&args, 5),
144145
};
145146
Box::pin(async move {
146147
let paths = require(&f)?

crates/runtime/src/runtime_modules/fs.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ class Glob {
124124
!!opts.dot,
125125
!!opts.absolute,
126126
opts.onlyFiles !== false, // default: files only, like the prior art
127+
!!opts.followSymlinks,
127128
);
128129
for (const p of JSON.parse(json)) yield p;
129130
}

types/runtime-fs.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ declare module "runtime:fs" {
4141
absolute?: boolean;
4242
/** Yield only files, skipping directories (default `true`). */
4343
onlyFiles?: boolean;
44+
/** Traverse symlinked directories; entries leaving the root jail are skipped (default `false`). */
45+
followSymlinks?: boolean;
4446
}
4547

4648
/**

0 commit comments

Comments
 (0)