logo

utils-std

Collection of commonly available Unix tools
commit: b2abf6ed6294703cf25d3eddcc5334608ce6fea1
parent 5a85e6116186685645fc90f7f11b2e843c54464c
Author: Haelwenn (lanodan) Monnier <contact@hacktivis.me>
Date:   Sat, 23 Mar 2024 03:51:44 +0100

cmd/rm: new

Diffstat:

Acmd/rm.148++++++++++++++++++++++++++++++++++++++++++++++++
Acmd/rm.c234+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest-cmd/rm.t112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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