mod_filesystem/
lib.rs

1use anyhow::anyhow;
2#[cfg(unix)]
3use chrono::{DateTime, Utc};
4use config::{get_or_create_module, get_or_create_sub_module};
5use mlua::prelude::LuaUserData;
6use mlua::{Lua, UserDataFields};
7#[cfg(unix)]
8use mod_time::Time;
9use std::fs::Metadata;
10#[cfg(unix)]
11use std::os::unix::fs::MetadataExt;
12use tokio::time::{Duration, Instant};
13
14mod file;
15
16const GLOB_CACHE_CAPACITY: usize = 32;
17const DEFAULT_CACHE_TTL: f32 = 60.;
18
19struct MetadataWrapper(Metadata);
20
21macro_rules! add_metadata_field {
22    ($fields:expr, $name:expr, $method:ident) => {
23        $fields.add_field_method_get($name, |_, this| Ok(this.0.$method()));
24    };
25}
26
27#[cfg(unix)]
28macro_rules! system_time_to_lua_time {
29    ($epoch_secs:expr, $nsecs:expr) => {{
30        let dt = DateTime::<Utc>::from_timestamp($epoch_secs, $nsecs as u32)
31            .ok_or_else(|| mlua::Error::external("invalid timestamp"))?;
32        Ok(Time::from(dt))
33    }};
34}
35
36impl LuaUserData for MetadataWrapper {
37    fn add_fields<F: UserDataFields<Self>>(fields: &mut F) {
38        add_metadata_field!(fields, "is_file", is_file);
39        add_metadata_field!(fields, "is_dir", is_dir);
40        add_metadata_field!(fields, "is_symlink", is_symlink);
41        add_metadata_field!(fields, "len", len); // can be used on non-unix platform
42        fields.add_field_method_get("readonly", |_, this| Ok(this.0.permissions().readonly()));
43
44        #[cfg(unix)]
45        {
46            add_metadata_field!(fields, "dev", dev);
47            add_metadata_field!(fields, "ino", ino);
48            add_metadata_field!(fields, "mode", mode);
49            add_metadata_field!(fields, "nlink", nlink);
50            add_metadata_field!(fields, "uid", uid);
51            add_metadata_field!(fields, "gid", gid);
52            add_metadata_field!(fields, "rdev", rdev);
53            add_metadata_field!(fields, "size", size);
54            fields.add_field_method_get("atime", |_lua, this| {
55                let secs = this.0.atime();
56                let nsec = this.0.atime_nsec();
57                system_time_to_lua_time!(secs, nsec)
58            });
59            fields.add_field_method_get("mtime", |_lua, this| {
60                let secs = this.0.mtime();
61                let nsec = this.0.mtime_nsec();
62                system_time_to_lua_time!(secs, nsec)
63            });
64            fields.add_field_method_get("ctime", |_lua, this| {
65                let secs = this.0.ctime();
66                let nsec = this.0.ctime_nsec();
67                system_time_to_lua_time!(secs, nsec)
68            });
69            add_metadata_field!(fields, "blksize", blksize);
70            add_metadata_field!(fields, "blocks", blocks);
71        }
72    }
73}
74
75#[derive(PartialEq, Eq, Hash, Clone, Debug)]
76struct GlobKey {
77    pattern: String,
78    path: Option<String>,
79}
80
81lruttl::declare_cache! {
82/// Caches glob results by glob pattern
83static CACHE: LruCacheWithTtl<GlobKey, Result<Vec<String>, String>>::new("mod_filesystem_glob_cache", GLOB_CACHE_CAPACITY);
84}
85
86pub fn register(lua: &Lua) -> anyhow::Result<()> {
87    let kumo_mod = get_or_create_module(lua, "kumo")?;
88    kumo_mod.set("read_dir", lua.create_async_function(read_dir)?)?;
89    kumo_mod.set("glob", lua.create_async_function(cached_glob)?)?;
90    kumo_mod.set("uncached_glob", lua.create_async_function(uncached_glob)?)?;
91
92    let fs_mod = get_or_create_sub_module(lua, "fs")?;
93    fs_mod.set("open", lua.create_async_function(file::AsyncFile::open)?)?;
94    fs_mod.set("read_dir", lua.create_async_function(read_dir)?)?;
95    fs_mod.set("glob", lua.create_async_function(cached_glob)?)?;
96    fs_mod.set("uncached_glob", lua.create_async_function(uncached_glob)?)?;
97    fs_mod.set(
98        "metadata_for_path",
99        lua.create_async_function(metadata_for_path)?,
100    )?;
101    fs_mod.set(
102        "symlink_metadata_for_path",
103        lua.create_async_function(symlink_metadata_for_path)?,
104    )?;
105    Ok(())
106}
107
108async fn read_dir(_: Lua, path: String) -> mlua::Result<Vec<String>> {
109    let mut dir = tokio::fs::read_dir(path)
110        .await
111        .map_err(mlua::Error::external)?;
112    let mut entries = vec![];
113    while let Some(entry) = dir.next_entry().await.map_err(mlua::Error::external)? {
114        if let Some(utf8) = entry.path().to_str() {
115            entries.push(utf8.to_string());
116        } else {
117            return Err(mlua::Error::external(anyhow!(
118                "path entry {} is not representable as utf8",
119                entry.path().display()
120            )));
121        }
122    }
123    Ok(entries)
124}
125
126async fn cached_glob(
127    _: Lua,
128    (pattern, path, ttl): (String, Option<String>, Option<f32>),
129) -> mlua::Result<Vec<String>> {
130    let key = GlobKey {
131        pattern: pattern.to_string(),
132        path: path.clone(),
133    };
134    if let Some(cached) = CACHE.get(&key) {
135        return cached.map_err(mlua::Error::external);
136    }
137
138    let result = glob(pattern.clone(), path.clone())
139        .await
140        .map_err(|err| format!("glob({pattern}, {path:?}): {err:#}"));
141
142    let ttl = Duration::from_secs_f32(ttl.unwrap_or(DEFAULT_CACHE_TTL));
143
144    CACHE
145        .insert(key, result.clone(), Instant::now() + ttl)
146        .await
147        .map_err(mlua::Error::external)
148}
149
150async fn uncached_glob(
151    _: Lua,
152    (pattern, path): (String, Option<String>),
153) -> mlua::Result<Vec<String>> {
154    glob(pattern, path).await
155}
156
157async fn metadata_for_path(_lua: Lua, path: String) -> mlua::Result<MetadataWrapper> {
158    let metadata = tokio::fs::metadata(&path)
159        .await
160        .map_err(mlua::Error::external)?;
161    Ok(MetadataWrapper(metadata))
162}
163
164async fn symlink_metadata_for_path(_lua: Lua, path: String) -> mlua::Result<MetadataWrapper> {
165    let metadata = tokio::fs::symlink_metadata(&path)
166        .await
167        .map_err(mlua::Error::external)?;
168    Ok(MetadataWrapper(metadata))
169}
170
171async fn glob(pattern: String, path: Option<String>) -> mlua::Result<Vec<String>> {
172    let entries = tokio::task::spawn_blocking(move || {
173        let mut entries = vec![];
174        let glob = filenamegen::Glob::new(&pattern)?;
175        for path in glob.walk(path.as_deref().unwrap_or(".")) {
176            if let Some(utf8) = path.to_str() {
177                entries.push(utf8.to_string());
178            } else {
179                return Err(anyhow!(
180                    "path entry {} is not representable as utf8",
181                    path.display()
182                ));
183            }
184        }
185        Ok(entries)
186    })
187    .await
188    .map_err(mlua::Error::external)?
189    .map_err(mlua::Error::external)?;
190    Ok(entries)
191}
192
193#[cfg(test)]
194mod test {
195    use super::*;
196    use std::io::Write;
197
198    #[tokio::test]
199    async fn test_metadata_file() -> anyhow::Result<()> {
200        let mut tmp = tempfile::NamedTempFile::new()?;
201        tmp.write_all(b"hello world")?;
202        let path = tmp.path().to_str().unwrap().to_string();
203
204        let lua = Lua::new();
205        register(&lua)?;
206
207        lua.globals().set("path", path)?;
208        lua.load(
209            r#"
210local kumo = require 'kumo'
211local meta = kumo.fs.metadata_for_path(path)
212assert(meta.is_file)
213assert(not meta.is_dir)
214assert(not meta.is_symlink)
215assert(meta.len == 11)
216
217if meta.ctime ~= nil then
218    assert(meta.ctime.unix_timestamp > 0)
219end
220
221if meta.size ~= nil then
222    assert(meta.size == 11)
223end
224
225if meta.ino ~= nil then
226    assert(meta.ino > 0)
227end
228
229if meta.dev ~= nil then
230    assert(meta.dev > 0)
231end
232
233if meta.mode ~= nil then
234    assert(meta.mode > 0)
235end
236"#,
237        )
238        .exec_async()
239        .await?;
240
241        Ok(())
242    }
243
244    #[cfg(unix)]
245    #[tokio::test]
246    async fn test_metadata_symlink() -> anyhow::Result<()> {
247        let tmp_dir = tempfile::tempdir()?;
248        let target = tmp_dir.path().join("target.txt");
249        let link = tmp_dir.path().join("link.txt");
250
251        std::fs::write(&target, b"hello world")?;
252        std::os::unix::fs::symlink(&target, &link)?;
253
254        let lua = Lua::new();
255        register(&lua)?;
256
257        let link_path = link.to_str().unwrap().to_string();
258        lua.globals().set("path", link_path)?;
259        lua.load(
260            r#"
261local kumo = require 'kumo'
262local meta = kumo.fs.metadata_for_path(path)
263assert(meta.is_file)
264assert(not meta.is_dir)
265assert(not meta.is_symlink) -- this is false cause metadata_for_path follows symlinks
266assert(meta.size == 11)
267assert(meta.len == 11)
268
269local symlink_meta = kumo.fs.symlink_metadata_for_path(path)
270assert(not symlink_meta.is_file)
271assert(not symlink_meta.is_dir)
272assert(symlink_meta.is_symlink)
273"#,
274        )
275        .exec_async()
276        .await?;
277
278        Ok(())
279    }
280
281    #[tokio::test]
282    async fn test_metadata_directory() -> anyhow::Result<()> {
283        let tmp_dir = tempfile::tempdir()?;
284        let path = tmp_dir.path().to_str().unwrap().to_string();
285
286        let lua = Lua::new();
287        register(&lua)?;
288
289        lua.globals().set("path", path)?;
290        lua.load(
291            r#"
292local kumo = require 'kumo'
293local meta = kumo.fs.metadata_for_path(path)
294assert(not meta.is_file)
295assert(meta.is_dir)
296assert(not meta.is_symlink)
297assert(meta.len > 0)
298
299if meta.size ~= nil then
300    assert(meta.size > 0)
301end
302
303if meta.ino ~= nil then
304    assert(meta.ino > 0)
305end
306
307if meta.dev ~= nil then
308    assert(meta.dev > 0)
309end
310
311if meta.mode ~= nil then
312    assert(meta.mode > 0)
313end
314"#,
315        )
316        .exec_async()
317        .await?;
318        Ok(())
319    }
320
321    #[tokio::test]
322    async fn test_metadata_not_found() -> anyhow::Result<()> {
323        let lua = Lua::new();
324        register(&lua)?;
325        lua.globals().set("path", "this/path/does/not/exist")?;
326        lua.load(
327            r#"
328local kumo = require 'kumo'
329local ok, meta = pcall(kumo.fs.metadata_for_path, path)
330assert(not ok)
331assert(string.match(tostring(meta), "No such file or directory"))
332"#,
333        )
334        .exec_async()
335        .await?;
336        Ok(())
337    }
338}