1use std::fs;
24use std::io;
25use std::path::{Path, PathBuf};
26
27#[derive(Debug, Clone)]
28pub struct BulkEntry {
29 pub name: String,
30 pub path: PathBuf,
31 pub is_dir: bool,
32 pub is_file: bool,
33 pub is_symlink: bool,
34 pub size: u64,
35 pub mtime_secs: i64,
37}
38
39pub fn read_dir_bulk(dir: &Path) -> io::Result<Vec<BulkEntry>> {
42 #[cfg(target_os = "macos")]
43 {
44 match macos::read_dir_bulk_fast(dir) {
45 Ok(v) => return Ok(v),
46 Err(e) => {
47 let _ = e;
50 }
51 }
52 }
53 read_dir_bulk_portable(dir)
54}
55
56fn read_dir_bulk_portable(dir: &Path) -> io::Result<Vec<BulkEntry>> {
58 let entries = fs::read_dir(dir)?;
59 let mut out = Vec::new();
60 for entry in entries.flatten() {
61 let name = entry.file_name().to_string_lossy().to_string();
62 let path = entry.path();
63 let ft = match entry.file_type() {
64 Ok(f) => f,
65 Err(_) => continue,
66 };
67 let (size, mtime_secs) = if ft.is_file() {
74 match entry.metadata() {
75 Ok(m) => (
76 m.len(),
77 m.modified()
78 .ok()
79 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
80 .map(|d| d.as_secs() as i64)
81 .unwrap_or(0),
82 ),
83 Err(_) => (0, 0),
84 }
85 } else {
86 (0, 0)
87 };
88 out.push(BulkEntry {
89 name,
90 path,
91 is_dir: ft.is_dir(),
92 is_file: ft.is_file(),
93 is_symlink: ft.is_symlink(),
94 size,
95 mtime_secs,
96 });
97 }
98 Ok(out)
99}
100
101#[cfg(target_os = "macos")]
102mod macos {
103 use super::BulkEntry;
109 use std::ffi::{CStr, CString};
110 use std::io;
111 use std::mem;
112 use std::os::raw::{c_int, c_void};
113 use std::path::{Path, PathBuf};
114
115 const ATTR_BIT_MAP_COUNT: u16 = 5;
117
118 const ATTR_CMN_RETURNED_ATTRS: u32 = 0x80000000;
120 const ATTR_CMN_NAME: u32 = 0x00000001;
121 const ATTR_CMN_OBJTYPE: u32 = 0x00000008;
122 const ATTR_CMN_MODTIME: u32 = 0x00000400;
123
124 const ATTR_FILE_DATALENGTH: u32 = 0x00000200;
126
127 const FSOPT_PACK_INVAL_ATTRS: u64 = 0x00000008;
130
131 const VREG: u32 = 1;
133 const VDIR: u32 = 2;
134 const VLNK: u32 = 5;
135
136 #[repr(C)]
137 struct Attrlist {
138 bitmapcount: u16,
139 reserved: u16,
140 commonattr: u32,
141 volattr: u32,
142 dirattr: u32,
143 fileattr: u32,
144 forkattr: u32,
145 }
146
147 #[repr(C)]
148 struct AttributeSet {
149 commonattr: u32,
150 volattr: u32,
151 dirattr: u32,
152 fileattr: u32,
153 forkattr: u32,
154 }
155 unsafe extern "C" {
160 fn getattrlistbulk(
161 dirfd: c_int,
162 alist: *mut c_void,
163 attrbuf: *mut c_void,
164 bufsize: usize,
165 options: u64,
166 ) -> c_int;
167 }
168
169 pub fn read_dir_bulk_fast(dir: &Path) -> io::Result<Vec<BulkEntry>> {
170 let cpath = CString::new(dir.as_os_str().to_string_lossy().as_bytes())
171 .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
172 let fd = unsafe {
174 libc::open(
175 cpath.as_ptr(),
176 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC,
177 )
178 };
179 if fd < 0 {
180 return Err(io::Error::last_os_error());
181 }
182 struct FdGuard(c_int);
184 impl Drop for FdGuard {
185 fn drop(&mut self) {
186 unsafe {
187 libc::close(self.0);
188 }
189 }
190 }
191 let _guard = FdGuard(fd);
192
193 let mut alist: Attrlist = unsafe { mem::zeroed() };
194 alist.bitmapcount = ATTR_BIT_MAP_COUNT;
195 alist.commonattr =
198 ATTR_CMN_RETURNED_ATTRS | ATTR_CMN_NAME | ATTR_CMN_OBJTYPE | ATTR_CMN_MODTIME;
199 alist.fileattr = ATTR_FILE_DATALENGTH;
200
201 const BUFSIZE: usize = 64 * 1024;
204 let mut buf = vec![0u8; BUFSIZE];
205 let mut out = Vec::new();
206
207 loop {
208 let n = unsafe {
209 getattrlistbulk(
210 fd,
211 &mut alist as *mut _ as *mut c_void,
212 buf.as_mut_ptr() as *mut c_void,
213 BUFSIZE,
214 FSOPT_PACK_INVAL_ATTRS,
215 )
216 };
217 if n < 0 {
218 return Err(io::Error::last_os_error());
219 }
220 if n == 0 {
221 break;
222 }
223 let n = n as usize;
224 let mut cursor = 0usize;
225 for _ in 0..n {
226 let entry_start = cursor;
227 if cursor + 4 > buf.len() {
228 return Err(io::Error::new(
229 io::ErrorKind::InvalidData,
230 "getattrlistbulk returned truncated entry length",
231 ));
232 }
233 let total_len = u32::from_ne_bytes(
235 buf[cursor..cursor + 4]
236 .try_into()
237 .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "slice"))?,
238 ) as usize;
239 cursor += 4;
240
241 let returned = AttributeSet {
243 commonattr: read_u32(&buf, &mut cursor)?,
244 volattr: read_u32(&buf, &mut cursor)?,
245 dirattr: read_u32(&buf, &mut cursor)?,
246 fileattr: read_u32(&buf, &mut cursor)?,
247 forkattr: read_u32(&buf, &mut cursor)?,
248 };
249
250 let mut name = String::new();
253 if returned.commonattr & ATTR_CMN_NAME != 0 {
254 let ref_pos = cursor;
255 if cursor + 8 > buf.len() {
256 return Err(io::Error::new(
257 io::ErrorKind::InvalidData,
258 "attrreference oob",
259 ));
260 }
261 let offset = i32::from_ne_bytes(
262 buf[cursor..cursor + 4]
263 .try_into()
264 .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "slice"))?,
265 );
266 let length = u32::from_ne_bytes(
267 buf[cursor + 4..cursor + 8]
268 .try_into()
269 .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "slice"))?,
270 );
271 cursor += 8;
272 let name_start = (ref_pos as isize + offset as isize) as usize;
273 let name_end = name_start + length as usize;
274 if name_end > buf.len() || name_start >= buf.len() {
275 return Err(io::Error::new(
276 io::ErrorKind::InvalidData,
277 "name offset oob",
278 ));
279 }
280 let cstr = CStr::from_bytes_until_nul(&buf[name_start..name_end])
282 .unwrap_or(CStr::from_bytes_with_nul(b"\0").unwrap());
283 name = cstr.to_string_lossy().into_owned();
284 }
285
286 let mut objtype: u32 = 0;
288 if returned.commonattr & ATTR_CMN_OBJTYPE != 0 {
289 objtype = read_u32(&buf, &mut cursor)?;
290 }
291
292 let mut mtime_secs: i64 = 0;
294 if returned.commonattr & ATTR_CMN_MODTIME != 0 {
295 if cursor + 16 > buf.len() {
298 return Err(io::Error::new(io::ErrorKind::InvalidData, "timespec oob"));
299 }
300 mtime_secs = i64::from_ne_bytes(
301 buf[cursor..cursor + 8]
302 .try_into()
303 .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "slice"))?,
304 );
305 cursor += 16;
306 }
307
308 let mut size: u64 = 0;
317 if returned.fileattr & ATTR_FILE_DATALENGTH != 0 {
318 if cursor + 8 > buf.len() {
319 return Err(io::Error::new(io::ErrorKind::InvalidData, "datalength oob"));
320 }
321 let sz = i64::from_ne_bytes(
322 buf[cursor..cursor + 8]
323 .try_into()
324 .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "slice"))?,
325 );
326 size = sz.max(0) as u64;
327 }
329
330 cursor = entry_start + total_len;
333
334 if name.is_empty() || name == "." || name == ".." {
335 continue;
336 }
337 let is_dir = objtype == VDIR;
338 let is_file = objtype == VREG;
339 let is_symlink = objtype == VLNK;
340 let path = dir.join(&name);
341 out.push(BulkEntry {
342 name,
343 path,
344 is_dir,
345 is_file,
346 is_symlink,
347 size: if is_file { size } else { 0 },
348 mtime_secs,
349 });
350 }
351 }
352
353 Ok(out)
354 }
355
356 fn read_u32(buf: &[u8], cursor: &mut usize) -> io::Result<u32> {
357 if *cursor + 4 > buf.len() {
358 return Err(io::Error::new(io::ErrorKind::InvalidData, "u32 oob"));
359 }
360 let v = u32::from_ne_bytes(
361 buf[*cursor..*cursor + 4]
362 .try_into()
363 .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "slice"))?,
364 );
365 *cursor += 4;
366 Ok(v)
367 }
368
369 #[allow(dead_code)]
370 fn _path_buf_unused() -> PathBuf {
371 PathBuf::new()
372 }
373}
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378 use std::fs::File;
379 use std::io::Write;
380
381 struct TestDir {
382 path: PathBuf,
383 }
384 impl TestDir {
385 fn new(name: &str) -> Self {
386 let path = std::env::temp_dir().join(format!(
387 "upum_bs_{}_{}",
388 name,
389 std::time::SystemTime::now()
390 .duration_since(std::time::UNIX_EPOCH)
391 .unwrap()
392 .as_nanos()
393 ));
394 let _ = fs::remove_dir_all(&path);
395 fs::create_dir_all(&path).unwrap();
396 Self { path }
397 }
398 }
399 impl Drop for TestDir {
400 fn drop(&mut self) {
401 let _ = fs::remove_dir_all(&self.path);
402 }
403 }
404
405 fn touch_with(p: &Path, content: &[u8]) {
406 if let Some(parent) = p.parent() {
407 fs::create_dir_all(parent).unwrap();
408 }
409 let mut f = File::create(p).unwrap();
410 f.write_all(content).unwrap();
411 }
412
413 #[test]
414 fn test_read_dir_bulk_basic() {
415 let tmp = TestDir::new("basic");
416 touch_with(&tmp.path.join("a.txt"), b"hello");
417 touch_with(&tmp.path.join("b.dat"), b"0123456789");
418 fs::create_dir_all(tmp.path.join("sub")).unwrap();
419
420 let entries = read_dir_bulk(&tmp.path).unwrap();
421 assert_eq!(entries.len(), 3);
422 let by_name: std::collections::HashMap<_, _> =
423 entries.iter().map(|e| (e.name.clone(), e)).collect();
424 let a = by_name.get("a.txt").expect("a.txt missing");
425 assert!(a.is_file);
426 assert!(!a.is_dir);
427 assert_eq!(a.size, 5);
428 let b = by_name.get("b.dat").expect("b.dat missing");
429 assert_eq!(b.size, 10);
430 let sub = by_name.get("sub").expect("sub missing");
431 assert!(sub.is_dir);
432 assert!(!sub.is_file);
433 }
434
435 #[test]
436 fn test_read_dir_bulk_empty() {
437 let tmp = TestDir::new("empty");
438 let entries = read_dir_bulk(&tmp.path).unwrap();
439 assert_eq!(entries.len(), 0);
440 }
441
442 #[test]
443 fn test_read_dir_bulk_excludes_dot_entries() {
444 let tmp = TestDir::new("dotentries");
445 touch_with(&tmp.path.join("real.txt"), b"x");
446 let entries = read_dir_bulk(&tmp.path).unwrap();
447 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
448 assert!(!names.contains(&"."));
449 assert!(!names.contains(&".."));
450 assert!(names.contains(&"real.txt"));
451 }
452
453 #[test]
454 fn test_read_dir_bulk_nonexistent_dir() {
455 let tmp = TestDir::new("nonexistent");
456 let missing = tmp.path.join("does-not-exist");
457 let result = read_dir_bulk(&missing);
458 assert!(result.is_err());
459 }
460
461 #[test]
462 fn test_read_dir_bulk_many_entries() {
463 let tmp = TestDir::new("many");
465 for i in 0..250 {
466 touch_with(&tmp.path.join(format!("file_{:04}.txt", i)), b"x");
467 }
468 let entries = read_dir_bulk(&tmp.path).unwrap();
469 assert_eq!(entries.len(), 250);
470 assert!(entries.iter().all(|e| e.is_file && e.size == 1));
472 }
473
474 #[test]
475 fn test_read_dir_bulk_mtime_populated() {
476 let tmp = TestDir::new("mtime");
477 touch_with(&tmp.path.join("x.txt"), b"y");
478 let entries = read_dir_bulk(&tmp.path).unwrap();
479 let x = entries.iter().find(|e| e.name == "x.txt").unwrap();
480 let now = std::time::SystemTime::now()
482 .duration_since(std::time::UNIX_EPOCH)
483 .unwrap()
484 .as_secs() as i64;
485 assert!(x.mtime_secs > 0, "mtime should be populated");
486 assert!(
487 (now - x.mtime_secs).abs() < 60,
488 "mtime {} should be near now {}",
489 x.mtime_secs,
490 now
491 );
492 }
493
494 #[test]
495 fn test_read_dir_bulk_matches_portable() {
496 let tmp = TestDir::new("parity");
500 touch_with(&tmp.path.join("a.wav"), b"RIFF1234");
501 touch_with(&tmp.path.join("b.pdf"), b"%PDF-1.4");
502 fs::create_dir_all(tmp.path.join("dir1")).unwrap();
503 let bulk = read_dir_bulk(&tmp.path).unwrap();
504 let portable = read_dir_bulk_portable(&tmp.path).unwrap();
505 assert_eq!(bulk.len(), portable.len());
506 let bulk_names: std::collections::HashSet<_> =
507 bulk.iter().map(|e| e.name.clone()).collect();
508 let portable_names: std::collections::HashSet<_> =
509 portable.iter().map(|e| e.name.clone()).collect();
510 assert_eq!(bulk_names, portable_names);
511 for b in &bulk {
514 if b.is_file {
515 let p = portable.iter().find(|e| e.name == b.name).unwrap();
516 assert_eq!(
517 b.size, p.size,
518 "size mismatch for {}: bulk={} portable={}",
519 b.name, b.size, p.size
520 );
521 }
522 }
523 }
524}