parent
e3f580c161
commit
23a55e5258
@ -0,0 +1,111 @@ |
||||
# Modules & `require` |
||||
|
||||
`a` provides a sandboxed `require` for splitting a script into multiple files. |
||||
It follows stock Lua's conventions — dot-separated module names, one execution |
||||
per module, cached results — so editors and the Lua language server understand |
||||
the imports without special setup. |
||||
|
||||
```lua |
||||
local helper = require "lib.helper" -- lib/helper.lua |
||||
local db = require "db" -- db.lua or db/init.lua |
||||
local mod, path = require "lib.helper" -- second value: the resolved file path |
||||
``` |
||||
|
||||
## Module names |
||||
|
||||
A name is one or more dot-separated segments; each segment may contain |
||||
`A–Z a–z 0–9 _ -`. Dots map to directory separators, so `require "db.migrations"` |
||||
looks for `db/migrations.lua`. Paths (`/`, `..`), absolute names, and empty |
||||
segments are rejected — a module can only ever be loaded from inside the |
||||
search roots, which keeps `require` compatible with the sandbox: it is the |
||||
only file access scripts have. |
||||
|
||||
## Search roots |
||||
|
||||
Names are resolved against a fixed list of directories, in order: |
||||
|
||||
1. **The main script's directory.** Symlinks to the script are resolved first, |
||||
so a script symlinked into `~/.local/bin` still loads modules (and its |
||||
`.env`, below) from next to the real file. In the REPL, the current working |
||||
directory is used instead. |
||||
2. **Each entry of `$A_INCLUDE_PATHS`**, colon-separated like `$PATH`. |
||||
|
||||
In every root, two candidates are tried (the Lua language server's default |
||||
templates): |
||||
|
||||
``` |
||||
<root>/<name with dots as slashes>.lua |
||||
<root>/<name with dots as slashes>/init.lua |
||||
``` |
||||
|
||||
The first hit wins. If a later root (or the `init.lua` form) also matches, a |
||||
warning is logged about the shadowed file. When nothing matches, the error |
||||
lists every path that was tried, in the stock Lua format. |
||||
|
||||
## `.env` |
||||
|
||||
Before resolving anything, `a` loads a `.env` file from the main script's |
||||
real directory (the CWD in the REPL), if one exists. Variables already set in |
||||
the real environment take precedence. This is the intended place to set |
||||
`A_INCLUDE_PATHS` for a project: |
||||
|
||||
``` |
||||
# myproject/.env |
||||
A_INCLUDE_PATHS=/home/me/lua-libs:/opt/shared-lua |
||||
``` |
||||
|
||||
Since the script's symlink is resolved first, `myproject/main.lua` symlinked |
||||
as `~/.local/bin/mytool` still picks up `myproject/.env`. |
||||
|
||||
## Semantics |
||||
|
||||
- **A module file is a plain chunk.** Its `local`s are private to the file; it |
||||
shares globals with the rest of the program; whatever it returns is the |
||||
module's value (conventionally a table). Like stock Lua, the chunk receives |
||||
the module name and the resolved file path as `...`. |
||||
- **Executed once.** The return value is cached by the file's canonical path, |
||||
so two names or symlinks reaching the same file share one instance, and |
||||
`require` of an already-loaded module is just a table lookup. A module that |
||||
returns nothing is cached as `true`. |
||||
- **Return values.** `require` returns the module value and, for file modules, |
||||
the resolved path as a second value. |
||||
- **Built-ins.** `require "http"`, `require "utils"`, etc. return the same |
||||
table as the corresponding global, so code written for stock Lua's habits |
||||
works unchanged. |
||||
- **Cycles.** A require cycle (`a` requires `b` requires `a`) raises a |
||||
`circular require` error instead of recursing forever. |
||||
- **Errors.** If a module raises while loading, the error propagates to the |
||||
requiring code and nothing is cached — a later `require` retries the load. |
||||
- Module files may use the async stdlib (`http`, `os.sleep`, …) at top level, |
||||
and may start with a shebang and/or UTF-8 BOM, same as the main script. |
||||
|
||||
## Editor / LSP setup |
||||
|
||||
The resolution rules match the |
||||
[Lua language server](https://luals.github.io/) defaults, so a minimal |
||||
`.luarc.json` next to your project gives go-to-definition and type inference |
||||
across `require` boundaries: |
||||
|
||||
```json |
||||
{ |
||||
"runtime.version": "Lua 5.4", |
||||
"runtime.path": ["?.lua", "?/init.lua"], |
||||
"workspace.library": ["/home/me/lua-libs", "/opt/shared-lua"] |
||||
} |
||||
``` |
||||
|
||||
List the same directories in `workspace.library` as in `A_INCLUDE_PATHS`; |
||||
modules under the script's own directory are found via the workspace root. |
||||
|
||||
## Example layout |
||||
|
||||
``` |
||||
myproject/ |
||||
├── main.lua -- #!/usr/bin/env a; symlinked to ~/.local/bin/mytool |
||||
├── .env -- A_INCLUDE_PATHS=... |
||||
├── .luarc.json |
||||
└── lib/ |
||||
├── helper.lua -- require "lib.helper" |
||||
└── db/ |
||||
└── init.lua -- require "lib.db" |
||||
``` |
||||
@ -0,0 +1,262 @@ |
||||
//! Tests for `require`: resolution, caching, isolation and failure modes.
|
||||
//! Each test builds its own module tree under a unique temp directory and
|
||||
//! installs `require` with that directory as a search root, the same way
|
||||
//! `main.rs` does with the script's directory.
|
||||
|
||||
use std::path::PathBuf; |
||||
|
||||
use mlua::Lua; |
||||
|
||||
use super::lua; |
||||
|
||||
/// A per-test module tree; removed on drop.
|
||||
struct TempRoot(PathBuf); |
||||
|
||||
impl TempRoot { |
||||
fn new(tag: &str) -> Self { |
||||
let dir = std::env::temp_dir().join(format!("a-require-{tag}-{}", std::process::id())); |
||||
let _ = std::fs::remove_dir_all(&dir); |
||||
std::fs::create_dir_all(&dir).unwrap(); |
||||
TempRoot(dir) |
||||
} |
||||
|
||||
fn write(&self, rel: &str, contents: &str) { |
||||
let path = self.0.join(rel); |
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap(); |
||||
std::fs::write(&path, contents).unwrap(); |
||||
} |
||||
} |
||||
|
||||
impl Drop for TempRoot { |
||||
fn drop(&mut self) { |
||||
let _ = std::fs::remove_dir_all(&self.0); |
||||
} |
||||
} |
||||
|
||||
fn lua_with_roots(roots: &[&TempRoot]) -> Lua { |
||||
let lua = lua(); |
||||
let roots: Vec<PathBuf> = roots.iter().map(|r| r.0.clone()).collect(); |
||||
crate::stdlib::require::install(&lua, roots).unwrap(); |
||||
lua |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_require_returns_value_and_path() { |
||||
let root = TempRoot::new("value"); |
||||
root.write("mod.lua", "return { answer = 42 }"); |
||||
let lua = lua_with_roots(&[&root]); |
||||
|
||||
let (answer, path): (i64, String) = lua |
||||
.load(r#"local m, p = require "mod"; return m.answer, p"#) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert_eq!(answer, 42); |
||||
assert!(path.ends_with("mod.lua"), "{path}"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_require_caches_and_runs_once() { |
||||
let root = TempRoot::new("cache"); |
||||
root.write("counted.lua", "COUNT = (COUNT or 0) + 1\nreturn { n = COUNT }"); |
||||
let lua = lua_with_roots(&[&root]); |
||||
|
||||
let (same, count): (bool, i64) = lua |
||||
.load( |
||||
r#" |
||||
local a = require "counted" |
||||
local b = require "counted" |
||||
return rawequal(a, b), COUNT |
||||
"#, |
||||
) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert!(same, "both requires must return the same table"); |
||||
assert_eq!(count, 1, "module body must run exactly once"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_require_dot_names_and_init_fallback() { |
||||
let root = TempRoot::new("dots"); |
||||
root.write("db/migrations.lua", r#"return "migrations""#); |
||||
root.write("pkg/init.lua", r#"return "pkg-init""#); |
||||
let lua = lua_with_roots(&[&root]); |
||||
|
||||
let (a, b): (String, String) = lua |
||||
.load(r#"return require "db.migrations", require "pkg""#) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert_eq!(a, "migrations"); |
||||
assert_eq!(b, "pkg-init"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_require_module_locals_stay_local() { |
||||
let root = TempRoot::new("scope"); |
||||
root.write("scoped.lua", "local secret = 'hidden'\nreturn true"); |
||||
let lua = lua_with_roots(&[&root]); |
||||
|
||||
let leaked: bool = lua |
||||
.load(r#"require "scoped"; return secret ~= nil"#) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert!(!leaked, "module locals must not leak into globals"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_require_passes_name_and_path_as_varargs() { |
||||
let root = TempRoot::new("varargs"); |
||||
root.write("who.lua", "local name, path = ...\nreturn { name = name, path = path }"); |
||||
let lua = lua_with_roots(&[&root]); |
||||
|
||||
let (name, path): (String, String) = lua |
||||
.load(r#"local m = require "who"; return m.name, m.path"#) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert_eq!(name, "who"); |
||||
assert!(path.ends_with("who.lua"), "{path}"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_require_rejects_malformed_names() { |
||||
let root = TempRoot::new("names"); |
||||
let lua = lua_with_roots(&[&root]); |
||||
|
||||
for name in ["", ".", "a..b", "../x", "a/b", "/etc/passwd", "a.b."] { |
||||
let err = lua |
||||
.load(format!("require {name:?}")) |
||||
.exec_async() |
||||
.await |
||||
.unwrap_err(); |
||||
assert!( |
||||
err.to_string().contains("invalid module name"), |
||||
"{name}: {err}" |
||||
); |
||||
} |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_require_not_found_lists_candidates() { |
||||
let root = TempRoot::new("notfound"); |
||||
let lua = lua_with_roots(&[&root]); |
||||
|
||||
let err = lua.load(r#"require "nope""#).exec_async().await.unwrap_err(); |
||||
let msg = err.to_string(); |
||||
assert!(msg.contains("module 'nope' not found"), "{msg}"); |
||||
assert!(msg.contains("nope.lua"), "{msg}"); |
||||
assert!(msg.contains("init.lua"), "{msg}"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_require_circular_errors() { |
||||
let root = TempRoot::new("circular"); |
||||
root.write("aaa.lua", "return require 'bbb'"); |
||||
root.write("bbb.lua", "return require 'aaa'"); |
||||
let lua = lua_with_roots(&[&root]); |
||||
|
||||
let err = lua.load(r#"require "aaa""#).exec_async().await.unwrap_err(); |
||||
assert!(err.to_string().contains("circular require"), "{err}"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_require_nil_result_stored_as_true() { |
||||
let root = TempRoot::new("nilret"); |
||||
root.write("sideeffect.lua", "MARK = 1"); |
||||
let lua = lua_with_roots(&[&root]); |
||||
|
||||
let (ret, again, mark): (bool, bool, i64) = lua |
||||
.load(r#"return require "sideeffect", require "sideeffect", MARK"#) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert!(ret, "a module returning nothing must yield true"); |
||||
assert!(again, "...also on the cached path"); |
||||
assert_eq!(mark, 1); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_require_builtins_return_the_globals() { |
||||
let root = TempRoot::new("builtins"); |
||||
let lua = lua_with_roots(&[&root]); |
||||
|
||||
let all_same: bool = lua |
||||
.load( |
||||
r#" |
||||
for _, name in ipairs({"utils", "log", "sqlite", "http", "task", "math", "table", "string", "os"}) do |
||||
if not rawequal(require(name), _G[name]) then return false end |
||||
end |
||||
return true |
||||
"#, |
||||
) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert!(all_same, "require of a built-in must return its global table"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_require_first_root_wins() { |
||||
let first = TempRoot::new("order1"); |
||||
let second = TempRoot::new("order2"); |
||||
first.write("dup.lua", "return 'first'"); |
||||
second.write("dup.lua", "return 'second'"); |
||||
let lua = lua_with_roots(&[&first, &second]); |
||||
|
||||
let got: String = lua.load(r#"return require "dup""#).eval_async().await.unwrap(); |
||||
assert_eq!(got, "first"); |
||||
} |
||||
|
||||
#[cfg(unix)] |
||||
#[tokio::test] |
||||
async fn test_require_symlink_resolves_to_same_module() { |
||||
let root = TempRoot::new("symlink"); |
||||
root.write("orig.lua", "COUNT = (COUNT or 0) + 1\nreturn {}"); |
||||
std::os::unix::fs::symlink(root.0.join("orig.lua"), root.0.join("alias.lua")).unwrap(); |
||||
let lua = lua_with_roots(&[&root]); |
||||
|
||||
let (same, count): (bool, i64) = lua |
||||
.load(r#"return rawequal(require "orig", require "alias"), COUNT"#) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert!(same, "symlinked names must resolve to one module instance"); |
||||
assert_eq!(count, 1); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_require_module_may_use_async_stdlib() { |
||||
let root = TempRoot::new("async"); |
||||
root.write("sleepy.lua", "os.sleep(0.01)\nreturn 'awake'"); |
||||
let lua = lua_with_roots(&[&root]); |
||||
|
||||
let got: String = lua.load(r#"return require "sleepy""#).eval_async().await.unwrap(); |
||||
assert_eq!(got, "awake"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_require_module_error_propagates_and_is_not_cached() { |
||||
let root = TempRoot::new("boom"); |
||||
root.write("boom.lua", "error('kaboom')"); |
||||
let lua = lua_with_roots(&[&root]); |
||||
|
||||
for _ in 0..2 { |
||||
// The second attempt must re-run the module (and fail the same way),
|
||||
// not report a bogus circular require or return a cached value.
|
||||
let err = lua.load(r#"require "boom""#).exec_async().await.unwrap_err(); |
||||
assert!(err.to_string().contains("kaboom"), "{err}"); |
||||
} |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_require_module_with_shebang_and_bom() { |
||||
let root = TempRoot::new("shebang"); |
||||
root.write("sh.lua", "\u{FEFF}#!/usr/bin/env a\nreturn 7"); |
||||
let lua = lua_with_roots(&[&root]); |
||||
|
||||
let got: i64 = lua.load(r#"return require "sh""#).eval_async().await.unwrap(); |
||||
assert_eq!(got, 7); |
||||
} |
||||
@ -0,0 +1,221 @@ |
||||
//! A sandboxed `require` for loading Lua modules from files.
|
||||
//!
|
||||
//! Module names use stock Lua dot notation (`require "db.migrations"`) and are
|
||||
//! resolved against a fixed list of root directories — the main script's real
|
||||
//! directory (the CWD in the REPL) followed by the entries of
|
||||
//! `$A_INCLUDE_PATHS` — through the standard `?.lua` / `?/init.lua` templates.
|
||||
//! Because a name may only contain identifier-like segments, a module can
|
||||
//! never name a file outside the roots; `require` is the sandbox's only file
|
||||
//! access.
|
||||
//!
|
||||
//! Each file is executed once and its return value cached by canonical path,
|
||||
//! so repeated requires — even under different names or through symlinks —
|
||||
//! return the same value.
|
||||
|
||||
use std::cell::RefCell; |
||||
use std::collections::{HashMap, HashSet}; |
||||
use std::path::PathBuf; |
||||
use std::rc::Rc; |
||||
|
||||
use mlua::prelude::{LuaResult, LuaTable, LuaValue}; |
||||
use mlua::Lua; |
||||
|
||||
/// Globals pre-seeded into the module cache, so `require "http"` returns the
|
||||
/// same table as the global: scripts read more portably and LSPs get an
|
||||
/// anchor for the stdlib. Covers the project stdlib plus the stock libraries
|
||||
/// the sandbox loads.
|
||||
const BUILTINS: &[&str] = &[ |
||||
"utils", |
||||
"log", |
||||
"sqlite", |
||||
"http", |
||||
"task", |
||||
"coroutine", |
||||
"math", |
||||
"os", |
||||
"string", |
||||
"table", |
||||
"utf8", |
||||
]; |
||||
|
||||
/// Strip a leading UTF-8 BOM and a shebang line from Lua source, as stock Lua
|
||||
/// does. The shebang's newline is kept so error messages still report correct
|
||||
/// line numbers. Used for the main script and for every required file.
|
||||
pub(crate) fn prepare_chunk(source: &[u8]) -> &[u8] { |
||||
let source = source.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(source); |
||||
if source.first() == Some(&b'#') { |
||||
let nl = source.iter().position(|&b| b == b'\n').unwrap_or(source.len()); |
||||
&source[nl..] |
||||
} else { |
||||
source |
||||
} |
||||
} |
||||
|
||||
/// Install the global `require` function, searching `roots` in order.
|
||||
pub(crate) fn install(lua: &Lua, roots: Vec<PathBuf>) -> LuaResult<()> { |
||||
// Cache of loaded modules, held Lua-side so the values stay GC-anchored.
|
||||
// Built-ins are keyed by name, file modules by canonical path; the key
|
||||
// spaces cannot collide because canonical paths are absolute.
|
||||
let loaded = lua.create_table()?; |
||||
for name in BUILTINS { |
||||
let value: LuaValue = lua.globals().get(*name)?; |
||||
if !value.is_nil() { |
||||
loaded.set(*name, value)?; |
||||
} |
||||
} |
||||
|
||||
let roots = Rc::new(roots); |
||||
// Module name -> canonical path it resolved to; repeated requires of the
|
||||
// same name skip the filesystem probing.
|
||||
let resolved: Rc<RefCell<HashMap<String, String>>> = Rc::default(); |
||||
// Canonical paths of modules currently executing, to catch require cycles.
|
||||
let in_flight: Rc<RefCell<HashSet<String>>> = Rc::default(); |
||||
|
||||
let require = lua.create_async_function(move |lua, name: String| { |
||||
let loaded = loaded.clone(); |
||||
let roots = roots.clone(); |
||||
let resolved = resolved.clone(); |
||||
let in_flight = in_flight.clone(); |
||||
async move { require_impl(lua, name, loaded, roots, resolved, in_flight).await } |
||||
})?; |
||||
lua.globals().set("require", require) |
||||
} |
||||
|
||||
async fn require_impl( |
||||
lua: Lua, |
||||
name: String, |
||||
loaded: LuaTable, |
||||
roots: Rc<Vec<PathBuf>>, |
||||
resolved: Rc<RefCell<HashMap<String, String>>>, |
||||
in_flight: Rc<RefCell<HashSet<String>>>, |
||||
) -> LuaResult<(LuaValue, Option<String>)> { |
||||
// Built-in module?
|
||||
let builtin: LuaValue = loaded.get(name.as_str())?; |
||||
if !builtin.is_nil() { |
||||
return Ok((builtin, None)); |
||||
} |
||||
|
||||
// A name that already resolved to a loaded file?
|
||||
if let Some(key) = resolved.borrow().get(&name).cloned() { |
||||
let value: LuaValue = loaded.get(key.as_str())?; |
||||
if !value.is_nil() { |
||||
return Ok((value, Some(key))); |
||||
} |
||||
} |
||||
|
||||
let Some(rel) = module_relpath(&name) else { |
||||
return Err(mlua::Error::runtime(format!( |
||||
"invalid module name '{name}' (expected dot-separated segments of [A-Za-z0-9_-], like 'dir.mod')" |
||||
))); |
||||
}; |
||||
|
||||
// Probe every root so a hit shadowed by an earlier one can be warned about.
|
||||
let mut hits: Vec<PathBuf> = Vec::new(); |
||||
let mut misses: Vec<PathBuf> = Vec::new(); |
||||
for root in roots.iter() { |
||||
let base = root.join(&rel); |
||||
for candidate in [base.with_extension("lua"), base.join("init.lua")] { |
||||
match tokio::fs::metadata(&candidate).await { |
||||
Ok(meta) if meta.is_file() => hits.push(candidate), |
||||
_ => misses.push(candidate), |
||||
} |
||||
} |
||||
} |
||||
|
||||
let Some(found) = hits.first() else { |
||||
let mut msg = format!("module '{name}' not found:"); |
||||
for miss in &misses { |
||||
msg.push_str(&format!("\n\tno file '{}'", miss.display())); |
||||
} |
||||
return Err(mlua::Error::runtime(msg)); |
||||
}; |
||||
for shadowed in &hits[1..] { |
||||
log::warn!( |
||||
"require '{name}': using '{}', ignoring shadowed '{}'", |
||||
found.display(), |
||||
shadowed.display() |
||||
); |
||||
} |
||||
|
||||
let real = tokio::fs::canonicalize(found).await.map_err(|e| { |
||||
mlua::Error::runtime(format!( |
||||
"require '{name}': cannot resolve '{}': {e}", |
||||
found.display() |
||||
)) |
||||
})?; |
||||
let key = real.display().to_string(); |
||||
|
||||
// The same file may already be loaded under another name or via a symlink.
|
||||
let value: LuaValue = loaded.get(key.as_str())?; |
||||
if !value.is_nil() { |
||||
resolved.borrow_mut().insert(name, key.clone()); |
||||
return Ok((value, Some(key))); |
||||
} |
||||
|
||||
if !in_flight.borrow_mut().insert(key.clone()) { |
||||
return Err(mlua::Error::runtime(format!( |
||||
"circular require of module '{name}' ('{key}')" |
||||
))); |
||||
} |
||||
// Clears the in-flight marker even when the load errors or is cancelled,
|
||||
// so a failed module can be required again.
|
||||
let _guard = InFlightGuard { |
||||
set: in_flight.clone(), |
||||
key: key.clone(), |
||||
}; |
||||
|
||||
let source = tokio::fs::read(&real) |
||||
.await |
||||
.map_err(|e| mlua::Error::runtime(format!("require '{name}': cannot read '{key}': {e}")))?; |
||||
|
||||
let value: LuaValue = lua |
||||
.load(prepare_chunk(&source)) |
||||
// "@" marks the chunk name as a file path in error messages / tracebacks
|
||||
.set_name(format!("@{key}")) |
||||
// like stock Lua, the chunk receives the module name and file path as `...`
|
||||
.call_async((name.as_str(), key.as_str())) |
||||
.await?; |
||||
|
||||
// A module that returns nothing is cached as `true`, as stock require does.
|
||||
let value = if value.is_nil() { |
||||
LuaValue::Boolean(true) |
||||
} else { |
||||
value |
||||
}; |
||||
loaded.set(key.as_str(), value.clone())?; |
||||
resolved.borrow_mut().insert(name, key.clone()); |
||||
Ok((value, Some(key))) |
||||
} |
||||
|
||||
/// Convert a dot-separated module name into a relative path, or `None` when
|
||||
/// the name is malformed. Restricting segments to identifier-like characters
|
||||
/// is what keeps `require` inside its roots: `..`, `/` and absolute paths are
|
||||
/// unrepresentable.
|
||||
fn module_relpath(name: &str) -> Option<PathBuf> { |
||||
if name.is_empty() { |
||||
return None; |
||||
} |
||||
let mut rel = PathBuf::new(); |
||||
for segment in name.split('.') { |
||||
let ok = !segment.is_empty() |
||||
&& segment |
||||
.bytes() |
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-'); |
||||
if !ok { |
||||
return None; |
||||
} |
||||
rel.push(segment); |
||||
} |
||||
Some(rel) |
||||
} |
||||
|
||||
struct InFlightGuard { |
||||
set: Rc<RefCell<HashSet<String>>>, |
||||
key: String, |
||||
} |
||||
|
||||
impl Drop for InFlightGuard { |
||||
fn drop(&mut self) { |
||||
self.set.borrow_mut().remove(&self.key); |
||||
} |
||||
} |
||||
Loading…
Reference in new issue