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); 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! {
82static 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}