Skip to main content

zsh/
files.rs

1//! File operation builtins - port of Modules/files.c
2//!
3//! Provides mkdir, rmdir, ln, mv, rm, chmod, chown, chgrp, sync builtins.
4
5use std::fs::{self};
6use std::io;
7use std::os::unix::fs::{MetadataExt, PermissionsExt};
8use std::path::Path;
9
10/// Options for mkdir
11#[derive(Debug, Default)]
12pub struct MkdirOptions {
13    pub parents: bool,
14    pub mode: Option<u32>,
15}
16
17/// Create a directory
18pub fn mkdir(path: &Path, options: &MkdirOptions) -> Result<(), String> {
19    let mode = options.mode.unwrap_or(0o777);
20
21    if options.parents {
22        mkdir_parents(path, mode)
23    } else {
24        mkdir_single(path, mode)
25    }
26}
27
28fn mkdir_single(path: &Path, mode: u32) -> Result<(), String> {
29    #[cfg(unix)]
30    {
31        use std::ffi::CString;
32
33        let path_str = path.to_string_lossy();
34        let path_c = CString::new(path_str.as_bytes()).map_err(|e| e.to_string())?;
35
36        let result = unsafe { libc::mkdir(path_c.as_ptr(), mode as libc::mode_t) };
37        if result < 0 {
38            Err(format!(
39                "cannot make directory '{}': {}",
40                path.display(),
41                io::Error::last_os_error()
42            ))
43        } else {
44            Ok(())
45        }
46    }
47
48    #[cfg(not(unix))]
49    {
50        fs::create_dir(path)
51            .map_err(|e| format!("cannot make directory '{}': {}", path.display(), e))
52    }
53}
54
55fn mkdir_parents(path: &Path, mode: u32) -> Result<(), String> {
56    if path.exists() {
57        if path.is_dir() {
58            return Ok(());
59        }
60        return Err(format!(
61            "'{}' exists but is not a directory",
62            path.display()
63        ));
64    }
65
66    if let Some(parent) = path.parent() {
67        if !parent.as_os_str().is_empty() {
68            mkdir_parents(parent, mode | 0o300)?;
69        }
70    }
71
72    mkdir_single(path, mode)
73}
74
75/// Remove a directory
76pub fn rmdir(path: &Path) -> Result<(), String> {
77    fs::remove_dir(path).map_err(|e| format!("cannot remove directory '{}': {}", path.display(), e))
78}
79
80/// Options for link operations
81#[derive(Debug, Default)]
82pub struct LinkOptions {
83    pub symbolic: bool,
84    pub force: bool,
85    pub interactive: bool,
86    pub no_dereference: bool,
87    pub allow_dir: bool,
88}
89
90/// Create a link (hard or symbolic)
91pub fn link(source: &Path, target: &Path, options: &LinkOptions) -> Result<(), String> {
92    let target_path = if target.is_dir() && !options.no_dereference {
93        let filename = source
94            .file_name()
95            .ok_or_else(|| "invalid source path".to_string())?;
96        target.join(filename)
97    } else {
98        target.to_path_buf()
99    };
100
101    if target_path.exists() {
102        if options.force {
103            fs::remove_file(&target_path)
104                .map_err(|e| format!("cannot remove '{}': {}", target_path.display(), e))?;
105        } else if !options.interactive {
106            return Err(format!("'{}' already exists", target_path.display()));
107        }
108    }
109
110    #[cfg(unix)]
111    {
112        if !options.allow_dir && source.is_dir() && !options.symbolic {
113            return Err(format!(
114                "'{}': hard link not allowed for directory",
115                source.display()
116            ));
117        }
118
119        if options.symbolic {
120            std::os::unix::fs::symlink(source, &target_path)
121                .map_err(|e| format!("cannot create symlink '{}': {}", target_path.display(), e))
122        } else {
123            fs::hard_link(source, &target_path)
124                .map_err(|e| format!("cannot create hard link '{}': {}", target_path.display(), e))
125        }
126    }
127
128    #[cfg(not(unix))]
129    {
130        fs::hard_link(source, &target_path)
131            .map_err(|e| format!("cannot create link '{}': {}", target_path.display(), e))
132    }
133}
134
135/// Options for move/rename
136#[derive(Debug, Default)]
137pub struct MoveOptions {
138    pub force: bool,
139    pub interactive: bool,
140}
141
142/// Move/rename a file
143pub fn mv(source: &Path, target: &Path, options: &MoveOptions) -> Result<(), String> {
144    let target_path = if target.is_dir() {
145        let filename = source
146            .file_name()
147            .ok_or_else(|| "invalid source path".to_string())?;
148        target.join(filename)
149    } else {
150        target.to_path_buf()
151    };
152
153    if target_path.exists() && !options.force && !options.interactive {
154        if target_path.is_dir() {
155            return Err(format!(
156                "'{}': cannot overwrite directory",
157                target_path.display()
158            ));
159        }
160    }
161
162    fs::rename(source, &target_path).map_err(|e| {
163        format!(
164            "cannot move '{}' to '{}': {}",
165            source.display(),
166            target_path.display(),
167            e
168        )
169    })
170}
171
172/// Options for remove
173#[derive(Debug, Default)]
174pub struct RemoveOptions {
175    pub force: bool,
176    pub recursive: bool,
177    pub interactive: bool,
178    pub dir: bool,
179}
180
181/// Remove a file or directory
182pub fn rm(path: &Path, options: &RemoveOptions) -> Result<(), String> {
183    if !path.exists() {
184        if options.force {
185            return Ok(());
186        }
187        return Err(format!(
188            "cannot remove '{}': No such file or directory",
189            path.display()
190        ));
191    }
192
193    if path.is_dir() {
194        if options.recursive {
195            rm_recursive(path, options)
196        } else if options.dir {
197            fs::remove_dir(path).map_err(|e| format!("cannot remove '{}': {}", path.display(), e))
198        } else if !options.force {
199            Err(format!(
200                "cannot remove '{}': Is a directory",
201                path.display()
202            ))
203        } else {
204            Ok(())
205        }
206    } else {
207        fs::remove_file(path).map_err(|e| format!("cannot remove '{}': {}", path.display(), e))
208    }
209}
210
211fn rm_recursive(path: &Path, options: &RemoveOptions) -> Result<(), String> {
212    if path.is_dir() {
213        for entry in fs::read_dir(path)
214            .map_err(|e| format!("cannot read directory '{}': {}", path.display(), e))?
215        {
216            let entry = entry.map_err(|e| e.to_string())?;
217            rm_recursive(&entry.path(), options)?;
218        }
219        fs::remove_dir(path).map_err(|e| format!("cannot remove '{}': {}", path.display(), e))
220    } else {
221        fs::remove_file(path).map_err(|e| format!("cannot remove '{}': {}", path.display(), e))
222    }
223}
224
225/// Change file permissions
226pub fn chmod(path: &Path, mode: u32, recursive: bool) -> Result<(), String> {
227    #[cfg(unix)]
228    {
229        use std::ffi::CString;
230
231        let path_str = path.to_string_lossy();
232        let path_c = CString::new(path_str.as_bytes()).map_err(|e| e.to_string())?;
233
234        let result = unsafe { libc::chmod(path_c.as_ptr(), mode as libc::mode_t) };
235        if result < 0 {
236            return Err(format!(
237                "cannot change mode of '{}': {}",
238                path.display(),
239                io::Error::last_os_error()
240            ));
241        }
242
243        if recursive && path.is_dir() {
244            for entry in fs::read_dir(path)
245                .map_err(|e| format!("cannot read directory '{}': {}", path.display(), e))?
246            {
247                let entry = entry.map_err(|e| e.to_string())?;
248                chmod(&entry.path(), mode, true)?;
249            }
250        }
251
252        Ok(())
253    }
254
255    #[cfg(not(unix))]
256    {
257        Err("chmod not supported on this platform".to_string())
258    }
259}
260
261/// Change file owner/group
262#[cfg(unix)]
263pub fn chown(
264    path: &Path,
265    uid: Option<u32>,
266    gid: Option<u32>,
267    recursive: bool,
268    no_dereference: bool,
269) -> Result<(), String> {
270    use std::ffi::CString;
271
272    let path_str = path.to_string_lossy();
273    let path_c = CString::new(path_str.as_bytes()).map_err(|e| e.to_string())?;
274
275    let uid = uid
276        .map(|u| u as libc::uid_t)
277        .unwrap_or(u32::MAX as libc::uid_t);
278    let gid = gid
279        .map(|g| g as libc::gid_t)
280        .unwrap_or(u32::MAX as libc::gid_t);
281
282    let result = if no_dereference {
283        unsafe { libc::lchown(path_c.as_ptr(), uid, gid) }
284    } else {
285        unsafe { libc::chown(path_c.as_ptr(), uid, gid) }
286    };
287
288    if result < 0 {
289        return Err(format!(
290            "cannot change owner of '{}': {}",
291            path.display(),
292            io::Error::last_os_error()
293        ));
294    }
295
296    if recursive && path.is_dir() {
297        for entry in fs::read_dir(path)
298            .map_err(|e| format!("cannot read directory '{}': {}", path.display(), e))?
299        {
300            let entry = entry.map_err(|e| e.to_string())?;
301            chown(&entry.path(), Some(uid), Some(gid), true, no_dereference)?;
302        }
303    }
304
305    Ok(())
306}
307
308/// Get user ID from username
309#[cfg(unix)]
310pub fn get_uid(username: &str) -> Option<u32> {
311    use std::ffi::CString;
312
313    if let Ok(uid) = username.parse::<u32>() {
314        return Some(uid);
315    }
316
317    let username_c = CString::new(username).ok()?;
318    unsafe {
319        let pwd = libc::getpwnam(username_c.as_ptr());
320        if pwd.is_null() {
321            None
322        } else {
323            Some((*pwd).pw_uid)
324        }
325    }
326}
327
328/// Get group ID from group name
329#[cfg(unix)]
330pub fn get_gid(groupname: &str) -> Option<u32> {
331    use std::ffi::CString;
332
333    if let Ok(gid) = groupname.parse::<u32>() {
334        return Some(gid);
335    }
336
337    let groupname_c = CString::new(groupname).ok()?;
338    unsafe {
339        let grp = libc::getgrnam(groupname_c.as_ptr());
340        if grp.is_null() {
341            None
342        } else {
343            Some((*grp).gr_gid)
344        }
345    }
346}
347
348/// Parse chown spec (user:group or user.group)
349#[cfg(unix)]
350pub fn parse_chown_spec(spec: &str) -> Result<(Option<u32>, Option<u32>), String> {
351    let (user_part, group_part) = if let Some(pos) = spec.find(':') {
352        let (u, g) = spec.split_at(pos);
353        (u, Some(&g[1..]))
354    } else if let Some(pos) = spec.find('.') {
355        let (u, g) = spec.split_at(pos);
356        (u, Some(&g[1..]))
357    } else {
358        (spec, None)
359    };
360
361    let uid = if user_part.is_empty() {
362        None
363    } else {
364        Some(get_uid(user_part).ok_or_else(|| format!("{}: no such user", user_part))?)
365    };
366
367    let gid = match group_part {
368        Some(g) if g.is_empty() => {
369            if let Some(uid_val) = uid {
370                unsafe {
371                    let pwd = libc::getpwuid(uid_val);
372                    if pwd.is_null() {
373                        return Err(format!("{}: no such user", user_part));
374                    }
375                    Some((*pwd).pw_gid)
376                }
377            } else {
378                None
379            }
380        }
381        Some(g) => Some(get_gid(g).ok_or_else(|| format!("{}: no such group", g))?),
382        None => None,
383    };
384
385    Ok((uid, gid))
386}
387
388/// Sync filesystem
389pub fn sync_fs() {
390    #[cfg(unix)]
391    unsafe {
392        libc::sync();
393    }
394}
395
396/// Convert octal mode to display string
397pub fn mode_to_string(mode: u32) -> String {
398    let mut result = String::with_capacity(10);
399
400    let file_type = match mode & 0o170000 {
401        0o140000 => 's',
402        0o120000 => 'l',
403        0o100000 => '-',
404        0o060000 => 'b',
405        0o040000 => 'd',
406        0o020000 => 'c',
407        0o010000 => 'p',
408        _ => '?',
409    };
410    result.push(file_type);
411
412    let perms = [
413        (mode & 0o400 != 0, 'r'),
414        (mode & 0o200 != 0, 'w'),
415        (
416            mode & 0o100 != 0,
417            if mode & 0o4000 != 0 { 's' } else { 'x' },
418        ),
419        (mode & 0o040 != 0, 'r'),
420        (mode & 0o020 != 0, 'w'),
421        (
422            mode & 0o010 != 0,
423            if mode & 0o2000 != 0 { 's' } else { 'x' },
424        ),
425        (mode & 0o004 != 0, 'r'),
426        (mode & 0o002 != 0, 'w'),
427        (
428            mode & 0o001 != 0,
429            if mode & 0o1000 != 0 { 't' } else { 'x' },
430        ),
431    ];
432
433    for (set, ch) in perms {
434        if set {
435            result.push(ch);
436        } else if ch == 's' {
437            result.push('S');
438        } else if ch == 't' {
439            result.push('T');
440        } else {
441            result.push('-');
442        }
443    }
444
445    result
446}
447
448/// Parse octal mode string
449pub fn parse_mode(s: &str) -> Option<u32> {
450    u32::from_str_radix(s, 8).ok()
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456    use std::fs::File;
457    use std::io::Write;
458    use tempfile::TempDir;
459
460    #[test]
461    fn test_mkdir_single() {
462        let dir = TempDir::new().unwrap();
463        let new_dir = dir.path().join("newdir");
464
465        let options = MkdirOptions::default();
466        mkdir(&new_dir, &options).unwrap();
467
468        assert!(new_dir.exists());
469        assert!(new_dir.is_dir());
470    }
471
472    #[test]
473    fn test_mkdir_parents() {
474        let dir = TempDir::new().unwrap();
475        let deep_dir = dir.path().join("a/b/c/d");
476
477        let options = MkdirOptions {
478            parents: true,
479            ..Default::default()
480        };
481        mkdir(&deep_dir, &options).unwrap();
482
483        assert!(deep_dir.exists());
484        assert!(deep_dir.is_dir());
485    }
486
487    #[test]
488    fn test_rmdir() {
489        let dir = TempDir::new().unwrap();
490        let new_dir = dir.path().join("to_remove");
491
492        fs::create_dir(&new_dir).unwrap();
493        assert!(new_dir.exists());
494
495        rmdir(&new_dir).unwrap();
496        assert!(!new_dir.exists());
497    }
498
499    #[test]
500    fn test_rm_file() {
501        let dir = TempDir::new().unwrap();
502        let file_path = dir.path().join("test.txt");
503
504        {
505            let mut f = File::create(&file_path).unwrap();
506            f.write_all(b"test").unwrap();
507        }
508
509        let options = RemoveOptions::default();
510        rm(&file_path, &options).unwrap();
511        assert!(!file_path.exists());
512    }
513
514    #[test]
515    fn test_rm_recursive() {
516        let dir = TempDir::new().unwrap();
517        let sub_dir = dir.path().join("subdir");
518        fs::create_dir(&sub_dir).unwrap();
519
520        let file_path = sub_dir.join("test.txt");
521        {
522            let mut f = File::create(&file_path).unwrap();
523            f.write_all(b"test").unwrap();
524        }
525
526        let options = RemoveOptions {
527            recursive: true,
528            ..Default::default()
529        };
530        rm(&sub_dir, &options).unwrap();
531        assert!(!sub_dir.exists());
532    }
533
534    #[test]
535    fn test_mv() {
536        let dir = TempDir::new().unwrap();
537        let src = dir.path().join("source.txt");
538        let dst = dir.path().join("dest.txt");
539
540        {
541            let mut f = File::create(&src).unwrap();
542            f.write_all(b"content").unwrap();
543        }
544
545        let options = MoveOptions::default();
546        mv(&src, &dst, &options).unwrap();
547
548        assert!(!src.exists());
549        assert!(dst.exists());
550    }
551
552    #[test]
553    #[cfg(unix)]
554    fn test_link_hard() {
555        let dir = TempDir::new().unwrap();
556        let src = dir.path().join("source.txt");
557        let dst = dir.path().join("link.txt");
558
559        {
560            let mut f = File::create(&src).unwrap();
561            f.write_all(b"content").unwrap();
562        }
563
564        let options = LinkOptions::default();
565        link(&src, &dst, &options).unwrap();
566
567        assert!(dst.exists());
568        assert_eq!(
569            fs::metadata(&src).unwrap().ino(),
570            fs::metadata(&dst).unwrap().ino()
571        );
572    }
573
574    #[test]
575    #[cfg(unix)]
576    fn test_link_symbolic() {
577        let dir = TempDir::new().unwrap();
578        let src = dir.path().join("source.txt");
579        let dst = dir.path().join("symlink.txt");
580
581        {
582            let mut f = File::create(&src).unwrap();
583            f.write_all(b"content").unwrap();
584        }
585
586        let options = LinkOptions {
587            symbolic: true,
588            ..Default::default()
589        };
590        link(&src, &dst, &options).unwrap();
591
592        assert!(dst.is_symlink());
593    }
594
595    #[test]
596    #[cfg(unix)]
597    fn test_chmod() {
598        let dir = TempDir::new().unwrap();
599        let file_path = dir.path().join("test.txt");
600
601        {
602            let mut f = File::create(&file_path).unwrap();
603            f.write_all(b"test").unwrap();
604        }
605
606        chmod(&file_path, 0o755, false).unwrap();
607
608        let meta = fs::metadata(&file_path).unwrap();
609        assert_eq!(meta.mode() & 0o777, 0o755);
610    }
611
612    #[test]
613    fn test_mode_to_string() {
614        assert_eq!(mode_to_string(0o100644), "-rw-r--r--");
615        assert_eq!(mode_to_string(0o100755), "-rwxr-xr-x");
616        assert_eq!(mode_to_string(0o040755), "drwxr-xr-x");
617        assert_eq!(mode_to_string(0o120777), "lrwxrwxrwx");
618    }
619
620    #[test]
621    fn test_parse_mode() {
622        assert_eq!(parse_mode("755"), Some(0o755));
623        assert_eq!(parse_mode("644"), Some(0o644));
624        assert_eq!(parse_mode("777"), Some(0o777));
625        assert_eq!(parse_mode("invalid"), None);
626    }
627
628    #[test]
629    #[cfg(unix)]
630    fn test_get_uid() {
631        assert!(get_uid("root").is_some() || get_uid("0").is_some());
632        assert_eq!(get_uid("0"), Some(0));
633    }
634
635    #[test]
636    #[cfg(unix)]
637    fn test_parse_chown_spec() {
638        let result = parse_chown_spec("0:0");
639        assert!(result.is_ok());
640        let (uid, gid) = result.unwrap();
641        assert_eq!(uid, Some(0));
642        assert_eq!(gid, Some(0));
643    }
644}