commit: b2abf6ed6294703cf25d3eddcc5334608ce6fea1
parent 5a85e6116186685645fc90f7f11b2e843c54464c
Author: Haelwenn (lanodan) Monnier <contact@hacktivis.me>
Date: Sat, 23 Mar 2024 03:51:44 +0100
cmd/rm: new
Diffstat:
A | cmd/rm.1 | 48 | ++++++++++++++++++++++++++++++++++++++++++++++++ |
A | cmd/rm.c | 234 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
A | test-cmd/rm.t | 112 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
3 files changed, 394 insertions(+), 0 deletions(-)
diff --git a/cmd/rm.1 b/cmd/rm.1
@@ -0,0 +1,48 @@
+.\" utils-std: Collection of commonly available Unix tools
+.\" Copyright 2017 Haelwenn (lanodan) Monnier <contact+utils@hacktivis.me>
+.\" SPDX-License-Identifier: MPL-2.0
+.Dd 2024-03-20
+.Dt RM 1
+.Os
+.Sh NAME
+.Nm rm
+.Nd remove files and directories
+.Sh SYNOPSIS
+.Nm
+.Op Fl firR
+.Op Ar files ...
+.Sh DESCRIPTION
+The
+.Nm
+utility removes each given
+.Ar file ,
+by default directories aren't removed.
+.Pp
+As required by POSIX,
+.Nm
+explicitely checks if a file is writable, when not and
+.Fl f
+wasn't passed, the user is prompted before removal.
+Care should be taken when using other programs as
+.Xr unlink 2
+only fails when the containing directory isn't writable, rather than the file itself.
+.Sh OPTIONS
+.Bl -tag -width Ds
+.It Fl f
+Force: Never prompt before recursing into directories and removing files. Overrides
+.Fl i .
+.It Fl i
+Interactive: Prompt before removing any file
+.It Fl r , Fl R
+Recurse into directories, also allowing to remove them.
+.El
+.Sh EXIT STATUS
+.Ex -std
+.Sh SEE ALSO
+.Xr unlink 2
+.Sh STANDARDS
+.Nm
+should be compliant with
+.St -p1003.1-2008
+.Sh AUTHORS
+.An Haelwenn (lanodan) Monnier Aq Mt contact@hacktivis.me
diff --git a/cmd/rm.c b/cmd/rm.c
@@ -0,0 +1,234 @@
+// utils-std: Collection of commonly available Unix tools
+// SPDX-FileCopyrightText: 2017 Haelwenn (lanodan) Monnier <contact+utils@hacktivis.me>
+// SPDX-License-Identifier: MPL-2.0
+
+#define _POSIX_C_SOURCE 200809L
+#include <ctype.h> // isprint
+#include <dirent.h> // fdopendir, readdir, closedir
+#include <errno.h> // errno
+#include <fcntl.h> // AT_FDCWD
+#include <limits.h> // PATH_MAX
+#include <stdarg.h> // va_list
+#include <stdbool.h>
+#include <stdio.h> // fprintf, getline
+#include <stdlib.h> // free
+#include <string.h> // strerror
+#include <sys/stat.h> // chmod, fstatat, S_ISDIR
+#include <unistd.h> // unlink, isatty
+
+bool force = false, recurse = false, verbose = false, opt_i = false;
+char *argv0 = "rm";
+
+// Consent therefore defaults to no
+static bool
+consentf(const char *restrict fmt, ...)
+{
+ bool result = false;
+ char *line = NULL;
+ size_t len = 0;
+
+ va_list ap;
+
+ va_start(ap, fmt);
+ int ret = vfprintf(stderr, fmt, ap);
+ va_end(ap);
+
+ if(!ret)
+ {
+ fprintf(stderr, "%s: Failed to print user prompt: %s\n", argv0, strerror(errno));
+ goto end;
+ }
+
+ errno = 0;
+ ssize_t nread = getline(&line, &len, stdin);
+ if(nread < 0)
+ {
+ fprintf(stderr, "\n%s: Failed getting user entry via getline: %s\n", argv0, strerror(errno));
+ goto end;
+ }
+
+ if(nread == 0)
+ {
+ fprintf(stderr, "%s: Got empty response, considering it false\n", argv0);
+ goto end;
+ }
+
+ // Doesn't echoes if not a TTY
+ if(!isatty(0)) write(2, line, nread);
+ // isatty changes errno if not a TTY *sigh*
+ errno = 0;
+
+ // Only consider the first character for now
+ switch(line[0])
+ {
+ case 'y':
+ case 'Y':
+ result = true;
+ goto end;
+ case 'n':
+ case 'N':
+ goto end;
+ case '\n':
+ case '\r':
+ fprintf(stderr, "%s: Got empty response, considering it false\n", argv0);
+ goto end;
+ default:
+ fprintf(stderr, "%s: User entry (%c) isn't [yn], considering it false\n", argv0, line[0]);
+ goto end;
+ }
+
+end:
+ if(len != 0) free(line);
+ return result;
+}
+
+static int
+do_unlinkat(int fd, char *name, char *acc_path)
+{
+ struct stat stats;
+ int err = 0;
+
+ if(fstatat(fd, name, &stats, AT_SYMLINK_NOFOLLOW) != 0)
+ {
+ fprintf(stderr, "rm: Failed getting status for '%s': %s\n", acc_path, strerror(errno));
+ return 1;
+ }
+
+ bool is_dir = S_ISDIR(stats.st_mode);
+
+ if(is_dir)
+ {
+ if(!recurse)
+ {
+ fprintf(stderr, "rm: Is a directory, pass -r to remove: %s\n", acc_path);
+ return 1;
+ }
+
+ if(!force && opt_i)
+ if(!consentf("rm: Recurse into '%s' ? [y/N] ", acc_path)) return 0;
+
+ int dir = openat(fd, name, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
+ if(dir == -1)
+ {
+ fprintf(stderr, "rm: Couldn't open '%s' as directory: %s\n", acc_path, strerror(errno));
+ return 1;
+ }
+
+ DIR *dirp = fdopendir(dir);
+ if(dirp == NULL)
+ {
+ fprintf(
+ stderr, "rm: Couldn't get DIR entry for opened '%s': %s\n", acc_path, strerror(errno));
+ return 1;
+ }
+
+ while(true)
+ {
+ struct dirent *dp = readdir(dirp);
+ if(dp == NULL)
+ {
+ if(errno == 0) break;
+
+ fprintf(stderr, "rm: Failed reading directory '%s': %s\n", acc_path, strerror(errno));
+ closedir(dirp);
+ return 1;
+ }
+
+ if(strcmp(dp->d_name, ".") == 0) continue;
+ if(strcmp(dp->d_name, "..") == 0) continue;
+
+ char new_path[PATH_MAX] = "";
+ if(snprintf(new_path, PATH_MAX, "%s/%s", acc_path, dp->d_name) < 0)
+ {
+ fprintf(stderr,
+ "rm: Couldn't concatenate '%s' into parent '%s', skipping to next entry: %s",
+ name,
+ acc_path,
+ strerror(errno));
+ err = 1;
+ continue;
+ }
+
+ // No depth counter for now, unlikely to be a problem
+ int ret = do_unlinkat(dir, dp->d_name, new_path);
+ if(ret != 0) err = 1;
+ }
+
+ // fdopendir allocates memory for DIR, needs closedir
+ if(closedir(dirp) != 0)
+ {
+ fprintf(stderr,
+ "rm: Deallocating directory entry for '%s' failed: %s\n",
+ acc_path,
+ strerror(errno));
+ return 1;
+ }
+ }
+
+ if(!force)
+ {
+ // FIXME: Terminal always considered to be present for tests reason
+ if(faccessat(fd, name, W_OK, 0) != 0 || opt_i)
+ if(!consentf("rm: Remove '%s' ? [y/N] ", acc_path)) return 0;
+ }
+
+ if(unlinkat(fd, name, is_dir ? AT_REMOVEDIR : 0) != 0)
+ {
+ fprintf(stderr, "rm: Couldn't remove '%s': %s\n", acc_path, strerror(errno));
+ return 1;
+ }
+
+ return err;
+}
+void
+usage()
+{
+ fprintf(stderr, "Usage: rm [-firRv] [files ...]\n");
+}
+
+int
+main(int argc, char *argv[])
+{
+ int c = -1;
+ while((c = getopt(argc, argv, ":firRv")) != -1)
+ {
+ switch(c)
+ {
+ case 'f':
+ force = true;
+ break;
+ case 'i':
+ opt_i = true;
+ break;
+ case 'r':
+ recurse = true;
+ break;
+ case 'v':
+ verbose = true;
+ break;
+ case ':':
+ fprintf(stderr, "rm: Error: Missing operand for option: '-%c'\n", optopt);
+ usage();
+ return 1;
+ case '?':
+ fprintf(stderr, "rm: Error: Unrecognised option: '-%c'\n", optopt);
+ usage();
+ return 1;
+ default:
+ abort();
+ }
+ }
+
+ argc -= optind;
+ argv += optind;
+
+ int err = 0;
+
+ for(int i = 0; i < argc; i++)
+ {
+ int ret = do_unlinkat(AT_FDCWD, argv[i], argv[0]);
+ if(ret != 0) err = 1;
+ }
+
+ return err;
+}
diff --git a/test-cmd/rm.t b/test-cmd/rm.t
@@ -0,0 +1,112 @@
+#!/usr/bin/env cram
+# SPDX-FileCopyrightText: 2017 Haelwenn (lanodan) Monnier <contact+utils@hacktivis.me>
+# SPDX-License-Identifier: MPL-2.0
+
+ $ export PATH="$TESTDIR/../cmd:$PATH"
+
+ $ test "$(command -v rm)" = "$TESTDIR/../cmd/rm"
+
+ $ touch file
+ $ rm file
+ $ test ! -e file
+
+POSIX rm(1p) step 1a, no -f option
+ $ test ! -f enoent
+ $ touch exists
+ $ test -f exists
+ $ rm enoent exists
+ rm: Failed getting status for 'enoent': No such file or directory
+ [1]
+ $ test ! -e exists
+
+POSIX rm(1p) step 1a, -f option
+Note: Still printing an error message, even if POSIX doesn't requires it
+ $ touch exists_f
+ $ test -f exists_f
+ $ test ! -f enoent
+ $ rm -f enoent exists_f
+ rm: Failed getting status for 'enoent': No such file or directory
+ [1]
+ $ test ! -e exists_f
+
+POSIX rm(1p) step 2a:
+ $ mkdir no_rR.d
+ $ test -d no_rR.d
+ $ touch no_rR.f
+ $ test -f no_rR.f
+ $ rm no_rR.d no_rR.f
+ rm: Is a directory, pass -r to remove: no_rR.d
+ [1]
+ $ test -d no_rR.d
+ $ test ! -e no_rR.f
+
+POSIX rm(1p) step 2b, empty directory, no -i
+ $ mkdir 2b-empty-noi
+ $ test -d 2b-empty-noi
+ $ rm -r 2b-empty-noi
+ $ test ! -e 2b-empty-noi
+
+POSIX rm(1p) step 2b, empty directory, -i
+Note: Ignoring if standard input is a terminal
+ $ mkdir 2b-empty-no
+ $ test -d 2b-empty-no
+ $ printf '%s\n' | rm -ri 2b-empty-no
+ rm: Recurse into '2b-empty-no' ? [y/N]
+ rm: Got empty response, considering it false
+ $ test -e 2b-empty-no
+ $ printf '%s\n' y n | rm -ri 2b-empty-no
+ rm: Recurse into '2b-empty-no' ? [y/N] y
+ rm: Remove '2b-empty-no' ? [y/N] n
+ $ test -e 2b-empty-no
+ $ printf '%s\n' y y | rm -ri 2b-empty-no
+ rm: Recurse into '2b-empty-no' ? [y/N] y
+ rm: Remove '2b-empty-no' ? [y/N] y
+ $ test ! -e 2b-empty-no
+
+POSIX rm(1p) step 2c, don't follow symlinks
+ $ mkdir 2c-origin
+ $ mkdir 2c-symlinks
+ $ ln -s 2c-origin 2c-symlinks/dir
+ $ touch 2c-origin/file
+ $ ln -s 2c-origin/file 2c-symlinks/file
+ $ test -L 2c-symlinks/file
+ $ test -L 2c-symlinks/dir
+ $ printf '%s\n' y y | rm -r 2c-symlinks
+ rm: Remove '2c-symlinks/file' ? [y/N] y
+ rm: Remove '2c-symlinks/dir' ? [y/N] y
+ $ test ! -e 2c-symlinks
+ $ test -d 2c-origin
+ $ test -f 2c-origin/file
+
+POSIX rm(1p) step 3, no write
+Extra check from rm(1), unrelated to the EPERM that unlink gets on a non-writable directory
+ $ touch no_write
+ $ chmod -- -w no_write
+ $ echo | rm no_write
+ rm: Remove 'no_write' ? [y/N]
+ rm: Got empty response, considering it false
+ $ test -f no_write
+ $ echo n | rm no_write
+ rm: Remove 'no_write' ? [y/N] n
+ $ test -f no_write
+ $ echo y | rm no_write
+ rm: Remove 'no_write' ? [y/N] y
+ $ test ! -f no_write
+
+ $ touch file_i
+ $ echo | rm -i file_i
+ rm: Remove 'file_i' ? [y/N]
+ rm: Got empty response, considering it false
+ $ test -e file_i
+ $ rm file_i
+
+ $ touch file_in
+ $ echo n | rm -i file_in
+ rm: Remove 'file_in' ? [y/N] n
+ $ test -e file_in
+ $ rm file_in
+
+ $ touch file_iy
+ $ echo y | rm -i file_iy
+ rm: Remove 'file_iy' ? [y/N] y
+ $ test ! -e file_iy