summaryrefslogtreecommitdiff
path: root/corebinutils/rm
diff options
context:
space:
mode:
authorMehmet Samet Duman <yongdohyun@projecttick.org>2026-04-02 18:28:39 +0300
committerMehmet Samet Duman <yongdohyun@projecttick.org>2026-04-02 18:28:39 +0300
commitf1d193fda398f32fe0b21d6448d3393661e0982b (patch)
tree94a51aa09dc877b9954d4178c4440ab779984c88 /corebinutils/rm
parent74abf524033a0d110946ac9406b77ff4e737081a (diff)
parentf17620ba69abf0400ff9043d53539db8cc2fa840 (diff)
downloadProject-Tick-f1d193fda398f32fe0b21d6448d3393661e0982b.tar.gz
Project-Tick-f1d193fda398f32fe0b21d6448d3393661e0982b.zip
Add 'corebinutils/rm/' from commit 'f17620ba69abf0400ff9043d53539db8cc2fa840'
git-subtree-dir: corebinutils/rm git-subtree-mainline: 74abf524033a0d110946ac9406b77ff4e737081a git-subtree-split: f17620ba69abf0400ff9043d53539db8cc2fa840
Diffstat (limited to 'corebinutils/rm')
-rw-r--r--corebinutils/rm/.gitignore25
-rw-r--r--corebinutils/rm/GNUmakefile40
-rw-r--r--corebinutils/rm/LICENSE32
-rw-r--r--corebinutils/rm/LICENSES/BSD-3-Clause.txt11
-rw-r--r--corebinutils/rm/README.md32
-rw-r--r--corebinutils/rm/rm.1232
-rw-r--r--corebinutils/rm/rm.c646
-rw-r--r--corebinutils/rm/tests/test.sh274
8 files changed, 1292 insertions, 0 deletions
diff --git a/corebinutils/rm/.gitignore b/corebinutils/rm/.gitignore
new file mode 100644
index 0000000000..a74d30b48c
--- /dev/null
+++ b/corebinutils/rm/.gitignore
@@ -0,0 +1,25 @@
+*.a
+*.core
+*.lo
+*.nossppico
+*.o
+*.orig
+*.pico
+*.pieo
+*.po
+*.rej
+*.so
+*.so.[0-9]*
+*.sw[nop]
+*~
+.*DS_Store
+.cache
+.clangd
+.ccls-cache
+.depend*
+compile_commands.json
+compile_commands.events.json
+tags
+build/
+out/
+.linux-obj/
diff --git a/corebinutils/rm/GNUmakefile b/corebinutils/rm/GNUmakefile
new file mode 100644
index 0000000000..a488fa4be9
--- /dev/null
+++ b/corebinutils/rm/GNUmakefile
@@ -0,0 +1,40 @@
+.DEFAULT_GOAL := all
+
+CC ?= cc
+CPPFLAGS ?=
+CPPFLAGS += -D_POSIX_C_SOURCE=200809L
+CFLAGS ?= -O2
+CFLAGS += -std=c17 -g -Wall -Wextra -Werror
+LDFLAGS ?=
+LDLIBS ?=
+
+OBJDIR := $(CURDIR)/build
+OUTDIR := $(CURDIR)/out
+TARGET := $(OUTDIR)/rm
+UNLINK_TARGET := $(OUTDIR)/unlink
+OBJS := $(OBJDIR)/rm.o
+
+.PHONY: all clean dirs status test
+
+all: $(TARGET) $(UNLINK_TARGET)
+
+dirs:
+ @mkdir -p "$(OBJDIR)" "$(OUTDIR)"
+
+$(TARGET): $(OBJS) | dirs
+ $(CC) $(LDFLAGS) -o "$@" $(OBJS) $(LDLIBS)
+
+$(UNLINK_TARGET): $(TARGET) | dirs
+ ln -sf "rm" "$@"
+
+$(OBJDIR)/rm.o: $(CURDIR)/rm.c | dirs
+ $(CC) $(CPPFLAGS) $(CFLAGS) -c "$(CURDIR)/rm.c" -o "$@"
+
+test: $(TARGET) $(UNLINK_TARGET)
+ RM_BIN="$(TARGET)" UNLINK_BIN="$(UNLINK_TARGET)" sh "$(CURDIR)/tests/test.sh"
+
+status:
+ @printf '%s\n' "$(TARGET)"
+
+clean:
+ @rm -rf "$(OBJDIR)" "$(OUTDIR)"
diff --git a/corebinutils/rm/LICENSE b/corebinutils/rm/LICENSE
new file mode 100644
index 0000000000..1019582aa2
--- /dev/null
+++ b/corebinutils/rm/LICENSE
@@ -0,0 +1,32 @@
+Copyright (c) 1990, 1993, 1994
+ The Regents of the University of California. All rights reserved.
+
+Copyright (c) 2026
+ Project Tick. All rights reserved.
+
+This code is derived from software contributed to Berkeley by
+the Institute of Electrical and Electronics Engineers, Inc.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions
+are met:
+1. Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+3. Neither the name of the University nor the names of its contributors
+ may be used to endorse or promote products derived from this software
+ without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
+OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
+OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGE.
diff --git a/corebinutils/rm/LICENSES/BSD-3-Clause.txt b/corebinutils/rm/LICENSES/BSD-3-Clause.txt
new file mode 100644
index 0000000000..ea890afbc7
--- /dev/null
+++ b/corebinutils/rm/LICENSES/BSD-3-Clause.txt
@@ -0,0 +1,11 @@
+Copyright (c) <year> <owner>.
+
+Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/corebinutils/rm/README.md b/corebinutils/rm/README.md
new file mode 100644
index 0000000000..1baa09c2c3
--- /dev/null
+++ b/corebinutils/rm/README.md
@@ -0,0 +1,32 @@
+# rm
+
+Standalone Linux-native port of FreeBSD `rm` for Project Tick BSD/Linux Distribution.
+
+## Build
+
+```sh
+gmake -f GNUmakefile
+gmake -f GNUmakefile CC=musl-gcc
+```
+
+## Test
+
+```sh
+gmake -f GNUmakefile test
+gmake -f GNUmakefile test CC=musl-gcc
+```
+
+## Port Strategy
+
+- The FreeBSD utility was rewritten into a standalone Linux-native build instead of preserving BSD build glue, `err(3)`, `fts(3)`, `lchflags(2)`, whiteout handling, or `SIGINFO` support.
+- Regular file and symlink removal maps to Linux `unlinkat(2)` with flags `0`.
+- Directory removal maps to Linux `unlinkat(2)` with `AT_REMOVEDIR`; recursive traversal uses `openat(2)`, `fdopendir(3)`, `readdir(3)`, `fstatat(2)`, and `unlinkat(2)` so traversal stays dynamic and does not depend on `PATH_MAX`.
+- `-x` is implemented by comparing `st_dev` from `fstatat(2)` against the top-level operand device. The walk does not descend into entries that live on a different mounted filesystem.
+- Interactive policy is Linux-native but manpage-aligned: `-i` prompts per operand, recursive directory descent is confirmed before traversal, and `-I` performs a single upfront confirmation when the operand set is large or includes recursive directory removal.
+
+## Supported / Unsupported Semantics
+
+- Supported: `rm` options `-d`, `-f`, `-i`, `-I`, `-P`, `-R`, `-r`, `-v`, and `-x`, plus `unlink [--] file`.
+- Supported: strict rejection of `/`, `.`, and `..`; removal of symlinks themselves rather than their targets; `--` for dash-prefixed names; and verbose output on successful removals.
+- Unsupported on Linux: `-W`. FreeBSD uses it for whiteout undelete semantics, but Linux does not expose an equivalent userland API; the port fails explicitly instead of silently degrading.
+- Unsupported by design: GNU long options and GNU-specific option parsing extensions. This port keeps the FreeBSD/POSIX command-line surface strict.
diff --git a/corebinutils/rm/rm.1 b/corebinutils/rm/rm.1
new file mode 100644
index 0000000000..8b8677e4a1
--- /dev/null
+++ b/corebinutils/rm/rm.1
@@ -0,0 +1,232 @@
+.\"-
+.\" Copyright (c) 1990, 1993, 1994
+.\" The Regents of the University of California. All rights reserved.
+.\"
+.\" This code is derived from software contributed to Berkeley by
+.\" the Institute of Electrical and Electronics Engineers, Inc.
+.\"
+.\" Redistribution and use in source and binary forms, with or without
+.\" modification, are permitted provided that the following conditions
+.\" are met:
+.\" 1. Redistributions of source code must retain the above copyright
+.\" notice, this list of conditions and the following disclaimer.
+.\" 2. Redistributions in binary form must reproduce the above copyright
+.\" notice, this list of conditions and the following disclaimer in the
+.\" documentation and/or other materials provided with the distribution.
+.\" 3. Neither the name of the University nor the names of its contributors
+.\" may be used to endorse or promote products derived from this software
+.\" without specific prior written permission.
+.\"
+.\" THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND
+.\" ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+.\" IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+.\" ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE
+.\" FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+.\" DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
+.\" OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+.\" HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+.\" LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
+.\" OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
+.\" SUCH DAMAGE.
+.\"
+.Dd November 10, 2018
+.Dt RM 1
+.Os
+.Sh NAME
+.Nm rm ,
+.Nm unlink
+.Nd remove directory entries
+.Sh SYNOPSIS
+.Nm
+.Op Fl f | i
+.Op Fl dIRrvWx
+.Ar
+.Nm unlink
+.Op Fl -
+.Ar file
+.Sh DESCRIPTION
+The
+.Nm
+utility attempts to remove the non-directory type files specified on the
+command line.
+If the permissions of the file do not permit writing, and the standard
+input device is a terminal, the user is prompted (on the standard error
+output) for confirmation.
+.Pp
+The options are as follows:
+.Bl -tag -width indent
+.It Fl d
+Attempt to remove directories as well as other types of files.
+.It Fl f
+Attempt to remove the files without prompting for confirmation,
+regardless of the file's permissions.
+If the file does not exist, do not display a diagnostic message or modify
+the exit status to reflect an error.
+The
+.Fl f
+option overrides any previous
+.Fl i
+options.
+.It Fl i
+Request confirmation before attempting to remove each file, regardless of
+the file's permissions, or whether or not the standard input device is a
+terminal.
+The
+.Fl i
+option overrides any previous
+.Fl f
+options.
+.It Fl I
+Request confirmation once if more than three files are being removed or if a
+directory is being recursively removed.
+This is a far less intrusive option than
+.Fl i
+yet provides almost the same level of protection against mistakes.
+.It Fl P
+This flag has no effect.
+It is kept only for backwards compatibility with
+.Bx 4.4 Lite2 .
+.It Fl R
+Attempt to remove the file hierarchy rooted in each
+.Ar file
+argument.
+The
+.Fl R
+option implies the
+.Fl d
+option.
+If the
+.Fl i
+option is specified, the user is prompted for confirmation before
+each directory's contents are processed (as well as before the attempt
+is made to remove the directory).
+If the user does not respond affirmatively, the file hierarchy rooted in
+that directory is skipped.
+.It Fl r
+Equivalent to
+.Fl R .
+.It Fl v
+Be verbose when deleting files, showing them as they are removed.
+.It Fl W
+Attempt to undelete the named files.
+Currently, this option can only be used to recover
+files covered by whiteouts in a union file system (see
+.Xr undelete 2 ) .
+.It Fl x
+When removing a hierarchy, do not cross mount points.
+.El
+.Pp
+The
+.Nm
+utility removes symbolic links, not the files referenced by the links.
+.Pp
+It is an error to attempt to remove the files
+.Pa / ,
+.Pa .\&
+or
+.Pa .. .
+.Pp
+When the utility is called as
+.Nm unlink ,
+only one argument,
+which must not be a directory,
+may be supplied.
+No options may be supplied in this simple mode of operation,
+which performs an
+.Xr unlink 2
+operation on the passed argument.
+However, the usual option-end delimiter,
+.Fl - ,
+may optionally precede the argument.
+.Sh EXIT STATUS
+The
+.Nm
+utility exits 0 if all of the named files or file hierarchies were removed,
+or if the
+.Fl f
+option was specified and all of the existing files or file hierarchies were
+removed.
+If an error occurs,
+.Nm
+exits with a value >0.
+.Sh NOTES
+The
+.Nm
+command uses
+.Xr getopt 3
+to parse its arguments, which allows it to accept
+the
+.Sq Li --
+option which will cause it to stop processing flag options at that
+point.
+This will allow the removal of file names that begin
+with a dash
+.Pq Sq - .
+For example:
+.Pp
+.Dl "rm -- -filename"
+.Pp
+The same behavior can be obtained by using an absolute or relative
+path reference.
+For example:
+.Pp
+.Dl "rm /home/user/-filename"
+.Dl "rm ./-filename"
+.Sh EXAMPLES
+Recursively remove all files contained within the
+.Pa foobar
+directory hierarchy:
+.Pp
+.Dl $ rm -rf foobar
+.Pp
+Any of these commands will remove the file
+.Pa -f :
+.Bd -literal -offset indent
+$ rm -- -f
+$ rm ./-f
+$ unlink -f
+.Ed
+.Sh COMPATIBILITY
+The
+.Nm
+utility differs from historical implementations in that the
+.Fl f
+option only masks attempts to remove non-existent files instead of
+masking a large variety of errors.
+The
+.Fl v
+option is non-standard and its use in scripts is not recommended.
+.Pp
+Also, historical
+.Bx
+implementations prompted on the standard output,
+not the standard error output.
+.Pp
+The
+.Fl P
+option does not have any effect as of
+.Fx 13
+and may be removed in the future.
+.Sh SEE ALSO
+.Xr chflags 1 ,
+.Xr rmdir 1 ,
+.Xr undelete 2 ,
+.Xr unlink 2 ,
+.Xr fts 3 ,
+.Xr getopt 3 ,
+.Xr symlink 7
+.Sh STANDARDS
+The
+.Nm
+command conforms to
+.St -p1003.1-2013 .
+.Pp
+The simplified
+.Nm unlink
+command conforms to
+.St -susv2 .
+.Sh HISTORY
+A
+.Nm
+command appeared in
+.At v1 .
diff --git a/corebinutils/rm/rm.c b/corebinutils/rm/rm.c
new file mode 100644
index 0000000000..d337229e3e
--- /dev/null
+++ b/corebinutils/rm/rm.c
@@ -0,0 +1,646 @@
+/*-
+ * SPDX-License-Identifier: BSD-3-Clause
+ *
+ * Copyright (c) 1990, 1993, 1994
+ * The Regents of the University of California. All rights reserved.
+ * Copyright (c) 2026
+ * Project Tick. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ * 1. Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ * 3. Neither the name of the University nor the names of its contributors
+ * may be used to endorse or promote products derived from this software
+ * without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE
+ * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
+ * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+ * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+ * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
+ * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
+ * SUCH DAMAGE.
+ */
+
+#include <dirent.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <locale.h>
+#include <stdbool.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <unistd.h>
+
+typedef struct {
+ const char *progname;
+ bool allow_directories;
+ bool force;
+ bool interactive;
+ bool interactive_once;
+ bool recursive;
+ bool verbose;
+ bool one_file_system;
+ bool stdin_is_tty;
+ int exit_status;
+} options_t;
+
+static void usage_rm(void);
+static void usage_unlink(void);
+static void set_error(options_t *options, const char *path, const char *message);
+static void set_errno_error(options_t *options, const char *path);
+static const char *program_basename(const char *path);
+static bool is_root_operand(const char *path);
+static bool is_dot_operand(const char *path);
+static bool should_skip_operand(options_t *options, const char *path);
+static bool prompt_yesno(const char *fmt, ...);
+static bool prompt_once(options_t *options, char **paths);
+static bool path_is_writable(const char *path);
+static bool prompt_for_removal(const options_t *options, const char *display,
+ const struct stat *st, bool is_directory);
+static bool prompt_for_directory_descent(const options_t *options,
+ const char *display);
+static void print_removed(const options_t *options, const char *path);
+static char *join_path(const char *base, const char *name);
+static int remove_path_at(options_t *options, int parentfd, const char *name,
+ const char *display, const struct stat *known_st, dev_t root_dev,
+ bool top_level);
+static int remove_path(options_t *options, const char *path);
+static int remove_simple_path(options_t *options, const char *path);
+static int run_unlink_mode(const char *path);
+
+static void
+usage_rm(void)
+{
+ fprintf(stderr, "%s\n", "usage: rm [-f | -i] [-dIPRrvWx] file ...");
+ exit(2);
+}
+
+static void
+usage_unlink(void)
+{
+ fprintf(stderr, "%s\n", "usage: unlink [--] file");
+ exit(2);
+}
+
+static void
+set_error(options_t *options, const char *path, const char *message)
+{
+ fprintf(stderr, "%s: %s: %s\n", options->progname, path, message);
+ options->exit_status = 1;
+}
+
+static void
+set_errno_error(options_t *options, const char *path)
+{
+ set_error(options, path, strerror(errno));
+}
+
+static const char *
+program_basename(const char *path)
+{
+ const char *slash;
+
+ slash = strrchr(path, '/');
+ return slash == NULL ? path : slash + 1;
+}
+
+static bool
+is_root_operand(const char *path)
+{
+ size_t i;
+
+ if (path[0] == '\0') {
+ return false;
+ }
+
+ for (i = 0; path[i] != '\0'; ++i) {
+ if (path[i] != '/') {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+static bool
+is_dot_operand(const char *path)
+{
+ size_t len;
+ size_t end;
+ size_t start;
+
+ len = strlen(path);
+ if (len == 0) {
+ return false;
+ }
+
+ end = len;
+ while (end > 1 && path[end - 1] == '/') {
+ --end;
+ }
+ start = end;
+ while (start > 0 && path[start - 1] != '/') {
+ --start;
+ }
+
+ if (end - start == 1 && path[start] == '.') {
+ return true;
+ }
+ if (end - start == 2 && path[start] == '.' && path[start + 1] == '.') {
+ return true;
+ }
+
+ return false;
+}
+
+static bool
+should_skip_operand(options_t *options, const char *path)
+{
+ if (is_root_operand(path)) {
+ fprintf(stderr, "%s: \"/\" may not be removed\n", options->progname);
+ options->exit_status = 1;
+ return true;
+ }
+
+ if (is_dot_operand(path)) {
+ fprintf(stderr, "%s: \".\" and \"..\" may not be removed\n",
+ options->progname);
+ options->exit_status = 1;
+ return true;
+ }
+
+ return false;
+}
+
+static bool
+prompt_yesno(const char *fmt, ...)
+{
+ char buffer[64];
+ va_list ap;
+
+ va_start(ap, fmt);
+ vfprintf(stderr, fmt, ap);
+ va_end(ap);
+ fflush(stderr);
+
+ if (fgets(buffer, sizeof(buffer), stdin) == NULL) {
+ clearerr(stdin);
+ return false;
+ }
+
+ return buffer[0] == 'y' || buffer[0] == 'Y';
+}
+
+static bool
+prompt_once(options_t *options, char **paths)
+{
+ int existing_count;
+ int directory_count;
+ int file_count;
+ int i;
+ struct stat st;
+ const char *single_directory;
+
+ existing_count = 0;
+ directory_count = 0;
+ file_count = 0;
+ single_directory = NULL;
+
+ for (i = 0; paths[i] != NULL; ++i) {
+ if (should_skip_operand(options, paths[i])) {
+ continue;
+ }
+ if (lstat(paths[i], &st) != 0) {
+ continue;
+ }
+ ++existing_count;
+ if (S_ISDIR(st.st_mode)) {
+ ++directory_count;
+ single_directory = paths[i];
+ } else {
+ ++file_count;
+ }
+ }
+
+ if (directory_count > 0 && options->recursive) {
+ if (directory_count == 1 && file_count == 0) {
+ return prompt_yesno("recursively remove %s? ", single_directory);
+ }
+ if (directory_count == 1) {
+ return prompt_yesno("recursively remove %s and %d file%s? ",
+ single_directory, file_count, file_count == 1 ? "" : "s");
+ }
+ return prompt_yesno("recursively remove %d dir%s and %d file%s? ",
+ directory_count, directory_count == 1 ? "" : "s", file_count,
+ file_count == 1 ? "" : "s");
+ }
+
+ if (existing_count > 3) {
+ return prompt_yesno("remove %d files? ", existing_count);
+ }
+
+ return true;
+}
+
+static bool
+path_is_writable(const char *path)
+{
+ return access(path, W_OK) == 0;
+}
+
+static bool
+prompt_for_removal(const options_t *options, const char *display,
+ const struct stat *st, bool is_directory)
+{
+ if (options->force) {
+ return true;
+ }
+
+ if (options->interactive) {
+ if (is_directory) {
+ return prompt_yesno("remove directory %s? ", display);
+ }
+ return prompt_yesno("remove %s? ", display);
+ }
+
+ if (!options->stdin_is_tty || S_ISLNK(st->st_mode) || path_is_writable(display)) {
+ return true;
+ }
+
+ if (is_directory) {
+ return prompt_yesno("override write protection for directory %s? ",
+ display);
+ }
+ return prompt_yesno("override write protection for %s? ", display);
+}
+
+static bool
+prompt_for_directory_descent(const options_t *options, const char *display)
+{
+ if (options->force) {
+ return true;
+ }
+
+ if (options->interactive) {
+ return prompt_yesno("descend into directory %s? ", display);
+ }
+
+ if (!options->stdin_is_tty || path_is_writable(display)) {
+ return true;
+ }
+
+ return prompt_yesno("override write protection for directory %s? ",
+ display);
+}
+
+static void
+print_removed(const options_t *options, const char *path)
+{
+ if (!options->verbose) {
+ return;
+ }
+ printf("%s\n", path);
+}
+
+static char *
+join_path(const char *base, const char *name)
+{
+ size_t base_len;
+ size_t name_len;
+ bool add_slash;
+ char *result;
+
+ base_len = strlen(base);
+ while (base_len > 1 && base[base_len - 1] == '/') {
+ --base_len;
+ }
+ name_len = strlen(name);
+ add_slash = base_len > 0 && base[base_len - 1] != '/';
+
+ result = malloc(base_len + (add_slash ? 1U : 0U) + name_len + 1U);
+ if (result == NULL) {
+ perror("rm: malloc");
+ exit(1);
+ }
+
+ memcpy(result, base, base_len);
+ if (add_slash) {
+ result[base_len] = '/';
+ memcpy(result + base_len + 1, name, name_len + 1);
+ } else {
+ memcpy(result + base_len, name, name_len + 1);
+ }
+
+ return result;
+}
+
+static int
+remove_path_at(options_t *options, int parentfd, const char *name,
+ const char *display, const struct stat *known_st, dev_t root_dev,
+ bool top_level)
+{
+ struct stat st;
+ const struct stat *stp;
+ int dirfd;
+ DIR *dir;
+ struct dirent *entry;
+ int child_status;
+
+ if (known_st != NULL) {
+ st = *known_st;
+ stp = &st;
+ } else {
+ if (fstatat(parentfd, name, &st, AT_SYMLINK_NOFOLLOW) != 0) {
+ if (options->force && errno == ENOENT) {
+ return 0;
+ }
+ set_errno_error(options, display);
+ return -1;
+ }
+ stp = &st;
+ }
+
+ if (S_ISDIR(stp->st_mode)) {
+ if (!options->recursive) {
+ if (!options->allow_directories) {
+ set_error(options, display, "is a directory");
+ return -1;
+ }
+ if (!prompt_for_removal(options, display, stp, true)) {
+ return 0;
+ }
+ if (unlinkat(parentfd, name, AT_REMOVEDIR) != 0) {
+ if (!(options->force && errno == ENOENT)) {
+ set_errno_error(options, display);
+ return -1;
+ }
+ } else {
+ print_removed(options, display);
+ }
+ return 0;
+ }
+
+ if (!top_level && options->one_file_system && stp->st_dev != root_dev) {
+ return 0;
+ }
+
+ if (!prompt_for_directory_descent(options, display)) {
+ return 0;
+ }
+
+ dirfd = openat(parentfd, name, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
+ if (dirfd >= 0) {
+ dir = fdopendir(dirfd);
+ if (dir == NULL) {
+ close(dirfd);
+ set_errno_error(options, display);
+ return -1;
+ }
+
+ errno = 0;
+ while ((entry = readdir(dir)) != NULL) {
+ char *child_display;
+ struct stat child_st;
+
+ if (strcmp(entry->d_name, ".") == 0 ||
+ strcmp(entry->d_name, "..") == 0) {
+ continue;
+ }
+
+ child_display = join_path(display, entry->d_name);
+ if (fstatat(dirfd, entry->d_name, &child_st,
+ AT_SYMLINK_NOFOLLOW) != 0) {
+ if (!(options->force && errno == ENOENT)) {
+ set_errno_error(options, child_display);
+ }
+ free(child_display);
+ continue;
+ }
+ child_status = remove_path_at(options, dirfd, entry->d_name,
+ child_display, &child_st, root_dev, false);
+ (void)child_status;
+ free(child_display);
+ }
+
+ if (errno != 0) {
+ set_errno_error(options, display);
+ }
+ closedir(dir);
+ }
+
+ if (options->interactive &&
+ !prompt_for_removal(options, display, stp, true)) {
+ return 0;
+ }
+
+ if (unlinkat(parentfd, name, AT_REMOVEDIR) != 0) {
+ if (options->force && errno == ENOENT) {
+ return 0;
+ }
+ set_errno_error(options, display);
+ return -1;
+ }
+ print_removed(options, display);
+ return 0;
+ }
+
+ if (!prompt_for_removal(options, display, stp, false)) {
+ return 0;
+ }
+
+ if (unlinkat(parentfd, name, 0) != 0) {
+ if (!(options->force && errno == ENOENT)) {
+ set_errno_error(options, display);
+ return -1;
+ }
+ return 0;
+ }
+
+ print_removed(options, display);
+ return 0;
+}
+
+static int
+remove_path(options_t *options, const char *path)
+{
+ struct stat st;
+
+ if (lstat(path, &st) != 0) {
+ if (options->force && errno == ENOENT) {
+ return 0;
+ }
+ set_errno_error(options, path);
+ return -1;
+ }
+
+ return remove_path_at(options, AT_FDCWD, path, path, &st, st.st_dev, true);
+}
+
+static int
+remove_simple_path(options_t *options, const char *path)
+{
+ struct stat st;
+
+ if (lstat(path, &st) != 0) {
+ if (options->force && errno == ENOENT) {
+ return 0;
+ }
+ set_errno_error(options, path);
+ return -1;
+ }
+
+ if (S_ISDIR(st.st_mode) && !options->allow_directories) {
+ set_error(options, path, "is a directory");
+ return -1;
+ }
+
+ if (!prompt_for_removal(options, path, &st, S_ISDIR(st.st_mode))) {
+ return 0;
+ }
+
+ if (S_ISDIR(st.st_mode)) {
+ if (unlinkat(AT_FDCWD, path, AT_REMOVEDIR) != 0) {
+ if (!(options->force && errno == ENOENT)) {
+ set_errno_error(options, path);
+ return -1;
+ }
+ } else {
+ print_removed(options, path);
+ }
+ return 0;
+ }
+
+ if (unlinkat(AT_FDCWD, path, 0) != 0) {
+ if (!(options->force && errno == ENOENT)) {
+ set_errno_error(options, path);
+ return -1;
+ }
+ return 0;
+ }
+
+ print_removed(options, path);
+ return 0;
+}
+
+static int
+run_unlink_mode(const char *path)
+{
+ struct stat st;
+
+ if (lstat(path, &st) != 0) {
+ fprintf(stderr, "unlink: %s: %s\n", path, strerror(errno));
+ return 1;
+ }
+ if (S_ISDIR(st.st_mode)) {
+ fprintf(stderr, "unlink: %s: is a directory\n", path);
+ return 1;
+ }
+ if (unlink(path) != 0) {
+ fprintf(stderr, "unlink: %s: %s\n", path, strerror(errno));
+ return 1;
+ }
+ return 0;
+}
+
+int
+main(int argc, char *argv[])
+{
+ options_t options;
+ int ch;
+ int i;
+
+ memset(&options, 0, sizeof(options));
+ options.progname = program_basename(argv[0]);
+
+ (void)setlocale(LC_ALL, "");
+
+ if (strcmp(options.progname, "unlink") == 0) {
+ if (argc == 2) {
+ return run_unlink_mode(argv[1]);
+ }
+ if (argc == 3 && strcmp(argv[1], "--") == 0) {
+ return run_unlink_mode(argv[2]);
+ }
+ usage_unlink();
+ }
+
+ while ((ch = getopt(argc, argv, "dfiIPRrvWx")) != -1) {
+ switch (ch) {
+ case 'd':
+ options.allow_directories = true;
+ break;
+ case 'f':
+ options.force = true;
+ options.interactive = false;
+ break;
+ case 'i':
+ options.interactive = true;
+ options.force = false;
+ break;
+ case 'I':
+ options.interactive_once = true;
+ break;
+ case 'P':
+ break;
+ case 'R':
+ case 'r':
+ options.recursive = true;
+ options.allow_directories = true;
+ break;
+ case 'v':
+ options.verbose = true;
+ break;
+ case 'W':
+ fprintf(stderr, "%s: -W is unsupported on Linux: whiteout undelete is unavailable\n",
+ options.progname);
+ return 1;
+ case 'x':
+ options.one_file_system = true;
+ break;
+ default:
+ usage_rm();
+ }
+ }
+
+ argc -= optind;
+ argv += optind;
+
+ if (argc < 1) {
+ if (options.force) {
+ return 0;
+ }
+ usage_rm();
+ }
+
+ options.stdin_is_tty = isatty(STDIN_FILENO) == 1;
+
+ if (options.interactive_once && !prompt_once(&options, argv)) {
+ return 1;
+ }
+
+ for (i = 0; argv[i] != NULL; ++i) {
+ if (should_skip_operand(&options, argv[i])) {
+ continue;
+ }
+ if (options.recursive) {
+ (void)remove_path(&options, argv[i]);
+ } else {
+ (void)remove_simple_path(&options, argv[i]);
+ }
+ }
+
+ return options.exit_status;
+}
diff --git a/corebinutils/rm/tests/test.sh b/corebinutils/rm/tests/test.sh
new file mode 100644
index 0000000000..e96ff55687
--- /dev/null
+++ b/corebinutils/rm/tests/test.sh
@@ -0,0 +1,274 @@
+#!/bin/sh
+set -eu
+
+ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
+RM_BIN=${RM_BIN:-"$ROOT/out/rm"}
+UNLINK_BIN=${UNLINK_BIN:-"$ROOT/out/unlink"}
+TMPDIR=${TMPDIR:-/tmp}
+WORKDIR=$(mktemp -d "$TMPDIR/rm-test.XXXXXX")
+STDOUT_FILE="$WORKDIR/stdout"
+STDERR_FILE="$WORKDIR/stderr"
+LAST_STATUS=0
+LAST_STDOUT=
+LAST_STDERR=
+trap 'chmod -R u+rwx "$WORKDIR" 2>/dev/null || true; rm -rf "$WORKDIR"' EXIT INT TERM
+
+export LC_ALL=C
+
+RM_USAGE='usage: rm [-f | -i] [-dIPRrvWx] file ...'
+UNLINK_USAGE='usage: unlink [--] file'
+
+fail() {
+ printf '%s\n' "FAIL: $1" >&2
+ exit 1
+}
+
+assert_eq() {
+ name=$1
+ expected=$2
+ actual=$3
+ if [ "$expected" != "$actual" ]; then
+ printf '%s\n' "FAIL: $name" >&2
+ printf '%s\n' "--- expected ---" >&2
+ printf '%s' "$expected" >&2
+ printf '\n%s\n' "--- actual ---" >&2
+ printf '%s' "$actual" >&2
+ printf '\n' >&2
+ exit 1
+ fi
+}
+
+assert_contains() {
+ name=$1
+ text=$2
+ pattern=$3
+ case $text in
+ *"$pattern"*) ;;
+ *) fail "$name" ;;
+ esac
+}
+
+assert_empty() {
+ name=$1
+ text=$2
+ if [ -n "$text" ]; then
+ printf '%s\n' "FAIL: $name" >&2
+ printf '%s\n' "--- expected empty ---" >&2
+ printf '%s\n' "--- actual ---" >&2
+ printf '%s' "$text" >&2
+ printf '\n' >&2
+ exit 1
+ fi
+}
+
+assert_status() {
+ name=$1
+ expected=$2
+ actual=$3
+ if [ "$expected" -ne "$actual" ]; then
+ printf '%s\n' "FAIL: $name" >&2
+ printf '%s\n' "expected status: $expected" >&2
+ printf '%s\n' "actual status: $actual" >&2
+ exit 1
+ fi
+}
+
+sort_lines() {
+ printf '%s\n' "$1" | sort
+}
+
+run_capture() {
+ if "$@" >"$STDOUT_FILE" 2>"$STDERR_FILE"; then
+ LAST_STATUS=0
+ else
+ LAST_STATUS=$?
+ fi
+ LAST_STDOUT=$(cat "$STDOUT_FILE")
+ LAST_STDERR=$(cat "$STDERR_FILE")
+}
+
+run_with_input() {
+ input=$1
+ shift
+ if printf '%b' "$input" | "$@" >"$STDOUT_FILE" 2>"$STDERR_FILE"; then
+ LAST_STATUS=0
+ else
+ LAST_STATUS=$?
+ fi
+ LAST_STDOUT=$(cat "$STDOUT_FILE")
+ LAST_STDERR=$(cat "$STDERR_FILE")
+}
+
+[ -x "$RM_BIN" ] || fail "missing binary: $RM_BIN"
+[ -x "$UNLINK_BIN" ] || fail "missing binary: $UNLINK_BIN"
+
+run_capture "$RM_BIN"
+assert_status "rm usage status" 2 "$LAST_STATUS"
+assert_empty "rm usage stdout" "$LAST_STDOUT"
+assert_eq "rm usage stderr" "$RM_USAGE" "$LAST_STDERR"
+
+run_capture "$UNLINK_BIN"
+assert_status "unlink usage status" 2 "$LAST_STATUS"
+assert_empty "unlink usage stdout" "$LAST_STDOUT"
+assert_eq "unlink usage stderr" "$UNLINK_USAGE" "$LAST_STDERR"
+
+run_capture "$RM_BIN" -z
+assert_status "invalid option status" 2 "$LAST_STATUS"
+assert_empty "invalid option stdout" "$LAST_STDOUT"
+assert_contains "invalid option usage" "$LAST_STDERR" "$RM_USAGE"
+
+run_capture "$RM_BIN" -W target
+assert_status "unsupported W status" 1 "$LAST_STATUS"
+assert_empty "unsupported W stdout" "$LAST_STDOUT"
+assert_contains "unsupported W stderr" "$LAST_STDERR" "-W is unsupported on Linux"
+
+run_capture "$RM_BIN" -f
+assert_status "force without operands status" 0 "$LAST_STATUS"
+assert_empty "force without operands stdout" "$LAST_STDOUT"
+assert_empty "force without operands stderr" "$LAST_STDERR"
+
+printf 'payload\n' >"$WORKDIR/basic"
+run_capture "$RM_BIN" "$WORKDIR/basic"
+assert_status "basic remove status" 0 "$LAST_STATUS"
+assert_empty "basic remove stdout" "$LAST_STDOUT"
+assert_empty "basic remove stderr" "$LAST_STDERR"
+[ ! -e "$WORKDIR/basic" ] || fail "basic file still exists"
+
+run_capture "$RM_BIN" "$WORKDIR/missing"
+assert_status "missing remove status" 1 "$LAST_STATUS"
+assert_empty "missing remove stdout" "$LAST_STDOUT"
+assert_contains "missing remove stderr" "$LAST_STDERR" "$WORKDIR/missing"
+
+run_capture "$RM_BIN" -f "$WORKDIR/missing"
+assert_status "force missing status" 0 "$LAST_STATUS"
+assert_empty "force missing stdout" "$LAST_STDOUT"
+assert_empty "force missing stderr" "$LAST_STDERR"
+
+mkdir "$WORKDIR/dir"
+run_capture "$RM_BIN" "$WORKDIR/dir"
+assert_status "directory without d status" 1 "$LAST_STATUS"
+assert_empty "directory without d stdout" "$LAST_STDOUT"
+assert_contains "directory without d stderr" "$LAST_STDERR" "is a directory"
+[ -d "$WORKDIR/dir" ] || fail "directory without d removed unexpectedly"
+
+run_capture "$RM_BIN" -d "$WORKDIR/dir"
+assert_status "directory with d status" 0 "$LAST_STATUS"
+assert_empty "directory with d stdout" "$LAST_STDOUT"
+assert_empty "directory with d stderr" "$LAST_STDERR"
+[ ! -e "$WORKDIR/dir" ] || fail "directory with d still exists"
+
+mkdir -p "$WORKDIR/tree/sub"
+printf 'a\n' >"$WORKDIR/tree/file"
+printf 'b\n' >"$WORKDIR/tree/sub/child"
+run_capture "$RM_BIN" -rv "$WORKDIR/tree"
+assert_status "recursive verbose status" 0 "$LAST_STATUS"
+assert_eq "recursive verbose stdout" "$(sort_lines "$WORKDIR/tree/sub/child
+$WORKDIR/tree/sub
+$WORKDIR/tree/file
+$WORKDIR/tree")" "$(sort_lines "$LAST_STDOUT")"
+assert_empty "recursive verbose stderr" "$LAST_STDERR"
+[ ! -e "$WORKDIR/tree" ] || fail "recursive tree still exists"
+
+printf '1\n' >"$WORKDIR/one"
+printf '2\n' >"$WORKDIR/two"
+printf '3\n' >"$WORKDIR/three"
+printf '4\n' >"$WORKDIR/four"
+run_with_input 'n\n' "$RM_BIN" -I "$WORKDIR/one" "$WORKDIR/two" "$WORKDIR/three" "$WORKDIR/four"
+assert_status "interactive once no status" 1 "$LAST_STATUS"
+assert_empty "interactive once no stdout" "$LAST_STDOUT"
+assert_contains "interactive once no prompt" "$LAST_STDERR" "remove 4 files?"
+[ -e "$WORKDIR/one" ] || fail "interactive once no removed file"
+
+run_with_input 'y\n' "$RM_BIN" -I "$WORKDIR/one" "$WORKDIR/two" "$WORKDIR/three" "$WORKDIR/four"
+assert_status "interactive once yes status" 0 "$LAST_STATUS"
+assert_empty "interactive once yes stdout" "$LAST_STDOUT"
+assert_contains "interactive once yes prompt" "$LAST_STDERR" "remove 4 files?"
+[ ! -e "$WORKDIR/one" ] || fail "interactive once yes kept file"
+
+printf 'keep\n' >"$WORKDIR/ifile"
+run_with_input 'n\n' "$RM_BIN" -i "$WORKDIR/ifile"
+assert_status "interactive no status" 0 "$LAST_STATUS"
+assert_empty "interactive no stdout" "$LAST_STDOUT"
+assert_contains "interactive no prompt" "$LAST_STDERR" "remove $WORKDIR/ifile?"
+[ -e "$WORKDIR/ifile" ] || fail "interactive no removed file"
+
+run_with_input 'y\n' "$RM_BIN" -i "$WORKDIR/ifile"
+assert_status "interactive yes status" 0 "$LAST_STATUS"
+assert_empty "interactive yes stdout" "$LAST_STDOUT"
+assert_contains "interactive yes prompt" "$LAST_STDERR" "remove $WORKDIR/ifile?"
+[ ! -e "$WORKDIR/ifile" ] || fail "interactive yes kept file"
+
+mkdir -p "$WORKDIR/idir/sub"
+printf 'child\n' >"$WORKDIR/idir/sub/file"
+run_with_input 'n\n' "$RM_BIN" -ri "$WORKDIR/idir"
+assert_status "interactive recursive no status" 0 "$LAST_STATUS"
+assert_empty "interactive recursive no stdout" "$LAST_STDOUT"
+assert_contains "interactive recursive no prompt" "$LAST_STDERR" "descend into directory $WORKDIR/idir?"
+[ -d "$WORKDIR/idir" ] || fail "interactive recursive no removed directory"
+
+run_with_input 'y\ny\ny\ny\ny\n' "$RM_BIN" -ri "$WORKDIR/idir"
+assert_status "interactive recursive yes status" 0 "$LAST_STATUS"
+assert_empty "interactive recursive yes stdout" "$LAST_STDOUT"
+assert_contains "interactive recursive yes prompt 1" "$LAST_STDERR" "descend into directory $WORKDIR/idir?"
+assert_contains "interactive recursive yes prompt 2" "$LAST_STDERR" "descend into directory $WORKDIR/idir/sub?"
+assert_contains "interactive recursive yes prompt 3" "$LAST_STDERR" "remove $WORKDIR/idir/sub/file?"
+assert_contains "interactive recursive yes prompt 4" "$LAST_STDERR" "remove directory $WORKDIR/idir/sub?"
+[ -n "$LAST_STDERR" ] || fail "interactive recursive yes missing prompts"
+[ ! -e "$WORKDIR/idir" ] || fail "interactive recursive yes kept directory"
+
+run_capture "$RM_BIN" /
+assert_status "slash operand status" 1 "$LAST_STATUS"
+assert_empty "slash operand stdout" "$LAST_STDOUT"
+assert_contains "slash operand stderr" "$LAST_STDERR" "\"/\" may not be removed"
+
+mkdir -p "$WORKDIR/dots"
+(
+ cd "$WORKDIR/dots"
+ run_capture "$RM_BIN" .
+ assert_status "dot operand status" 1 "$LAST_STATUS"
+ assert_empty "dot operand stdout" "$LAST_STDOUT"
+ assert_contains "dot operand stderr" "$LAST_STDERR" "\".\" and \"..\" may not be removed"
+
+ run_capture "$RM_BIN" sub/..
+ assert_status "dotdot operand status" 1 "$LAST_STATUS"
+ assert_empty "dotdot operand stdout" "$LAST_STDOUT"
+ assert_contains "dotdot operand stderr" "$LAST_STDERR" "\".\" and \"..\" may not be removed"
+)
+
+printf 'dash\n' >"$WORKDIR/-dash"
+run_capture "$RM_BIN" -- "$WORKDIR/-dash"
+assert_status "dash operand status" 0 "$LAST_STATUS"
+assert_empty "dash operand stdout" "$LAST_STDOUT"
+assert_empty "dash operand stderr" "$LAST_STDERR"
+[ ! -e "$WORKDIR/-dash" ] || fail "dash operand still exists"
+
+printf 'noop\n' >"$WORKDIR/noop"
+run_capture "$RM_BIN" -P "$WORKDIR/noop"
+assert_status "P noop status" 0 "$LAST_STATUS"
+assert_empty "P noop stdout" "$LAST_STDOUT"
+assert_empty "P noop stderr" "$LAST_STDERR"
+[ ! -e "$WORKDIR/noop" ] || fail "P noop failed"
+
+mkdir -p "$WORKDIR/xdev/dir"
+printf 'x\n' >"$WORKDIR/xdev/dir/file"
+run_capture "$RM_BIN" -rx "$WORKDIR/xdev"
+assert_status "x option status" 0 "$LAST_STATUS"
+assert_empty "x option stdout" "$LAST_STDOUT"
+assert_empty "x option stderr" "$LAST_STDERR"
+[ ! -e "$WORKDIR/xdev" ] || fail "x option failed"
+
+printf 'unlink\n' >"$WORKDIR/unlink-file"
+run_capture "$UNLINK_BIN" -- "$WORKDIR/unlink-file"
+assert_status "unlink success status" 0 "$LAST_STATUS"
+assert_empty "unlink success stdout" "$LAST_STDOUT"
+assert_empty "unlink success stderr" "$LAST_STDERR"
+[ ! -e "$WORKDIR/unlink-file" ] || fail "unlink file still exists"
+
+mkdir "$WORKDIR/unlink-dir"
+run_capture "$UNLINK_BIN" "$WORKDIR/unlink-dir"
+assert_status "unlink dir status" 1 "$LAST_STATUS"
+assert_empty "unlink dir stdout" "$LAST_STDOUT"
+assert_contains "unlink dir stderr" "$LAST_STDERR" "is a directory"
+[ -d "$WORKDIR/unlink-dir" ] || fail "unlink dir removed unexpectedly"
+
+printf '%s\n' "PASS"