From 97055316d3bce30d2d3b79bc0bf4ea60f8ce5696 Mon Sep 17 00:00:00 2001
From: Bernhard Voelker <mail@bernhard-voelker.de>
Date: Thu, 6 May 2021 23:24:43 +0200
Subject: [PATCH] find: add -files0-from option

* bootstrap.conf (gnulib_modules): Add argv-iter and same-inode.
* find/defs.h (struct options): Add files0_from and ok_prompt_stdin
members.
* find/ftsfind.c (argv-iter.h, same-inode.h, xalloc.h): Add #include
for gnulib headers.
(process_all_startpoints): Change loop over starting point arguments
to a loop using the argv_iter gnulib module.
* find/parser.c (parse_table): Add option.
(parse_files0_from): Declare and define function.
(insert_exec_ok): Set options flag ok_prompt_stdin to true for
the -ok and -okdir action.
* find/util.c (usage): Add new option.
(set_option_defaults): Initialize new struct members.
* doc/find.texi (node Starting points): Add new section describing
the regular processing of starting points, and that with the new
option.  Also mention in the description of -ok and -okdir that they
conflict with the new option.
* find/find.1: Document the new option here as well.
* tests/find/files0-from.sh: Add test.
* tests/local.mk (all_tests): Reference it.
* NEWS (New features in find): Mention the new option.
---
 NEWS                      |   7 ++
 bootstrap.conf            |   2 +
 doc/find.texi             |  89 +++++++++++++++++++
 find/defs.h               |   6 ++
 find/find.1               |  88 ++++++++++++++++++-
 find/ftsfind.c            | 180 +++++++++++++++++++++++++++++++++-----
 find/parser.c             |  16 ++++
 find/util.c               |   8 +-
 tests/find/files0-from.sh | 170 +++++++++++++++++++++++++++++++++++
 tests/local.mk            |   1 +
 10 files changed, 542 insertions(+), 25 deletions(-)
 create mode 100755 tests/find/files0-from.sh

diff --git a/NEWS b/NEWS
index 86cab905..98d36323 100644
--- a/NEWS
+++ b/NEWS
@@ -2,6 +2,13 @@ GNU findutils NEWS - User visible changes.      -*- outline -*- (allout)
 
 * Noteworthy changes in release ?.? (????-??-??) [?]
 
+** New features in find
+
+  find now supports the -files0-from option to be able to safely pass an
+  arbitrary number of starting points to the tool.  The option requires a file
+  name as argument, or "-" to read from standard input.  The entries in that
+  file have to be separated by NUL characters.
+
 ** Changes in locate / updatedb
 
   update now skips (fuse-mounted) s3fs filesystems by default,
diff --git a/bootstrap.conf b/bootstrap.conf
index 266ce932..3489da0a 100644
--- a/bootstrap.conf
+++ b/bootstrap.conf
@@ -74,6 +74,7 @@ gnulib_modules="
     alloca
     areadlinkat
     argmatch
+    argv-iter
     assert
     byteswap
     c-strcasestr
@@ -143,6 +144,7 @@ gnulib_modules="
     regex
     rpmatch
     safe-read
+    same-inode
     save-cwd
     savedir
     selinux-at
diff --git a/doc/find.texi b/doc/find.texi
index 15bfdbc8..d4b5601a 100644
--- a/doc/find.texi
+++ b/doc/find.texi
@@ -287,6 +287,7 @@ more information about the matching files.
 
 @menu
 * find Expressions::
+* Starting points::
 * Name::
 * Links::
 * Time::
@@ -357,6 +358,90 @@ for exactly @var{n}.
 @end table
 
 
+@node Starting points
+@section Starting points
+
+GNU @code{find} searches the directory tree rooted at each given starting-point
+by evaluating the given expression from left to right, according to the
+rules of operator precedence, until the outcome is known (the left hand side
+is false for @samp{and} operations, true for @samp{or}), at which point
+@code{find} moves on to the next file name.
+
+If no starting-point is specified, the current directory @samp{.} is assumed.
+
+A double dash @samp{--} could theoretically be used to signal that any remaining
+arguments are not options, but this does not really work due to the way
+@code{find} determines the end of the list of starting point arguments:
+it does that by reading until an expression argument comes (which also starts
+with a @samp{-}).
+Now, if a starting point argument would begin with a @samp{-}, then @code{find}
+would treat it as expression argument instead.
+Thus, to ensure that all start points are taken as such, and especially to
+prevent that wildcard patterns expanded by the calling shell are not mistakenly
+treated as expression arguments, it is generally safer to prefix wildcards
+or dubious path names with either @samp{./}, or to use absolute path names
+starting with @samp{/}.
+
+Alternatively, it is generally safe though non-portable to use the GNU option
+@samp{-files0-from} to pass arbitrary starting points to @code{find}.
+
+@deffn Option -files0-from file
+@deffnx Option -files0-from file
+
+Read the starting points from @file{file} instead of getting them on the
+command line.
+In contrast to the known limitations of passing starting points via arguments
+on the command line, namely the limitation of the amount of file names,
+and the inherent ambiguity of file names clashing with option names,
+using this option allows to safely pass an arbitrary number of starting points
+to @code{find}.
+
+Using this option and passing starting points on the command line is mutually
+exclusive, and is therefore not allowed at the same time.
+
+The @file{file} argument is mandatory.
+One can use @samp{-files0-from -} to read the list of starting points from the
+standard input stream, and e.g. from a pipe.
+In this case, the actions @samp{-ok} and @samp{-okdir} are not allowed,
+because they would obviously interfere with reading from standard input
+in order to get a user confirmation.
+
+The starting points in @file{file} have to be separated by ASCII NUL characters.
+Two consecutive NUL characters, i.e., a starting point with a Zero-length
+file name is not allowed and will lead to an error diagnostic followed by
+a non-Zero exit code later.
+The given @file{file} has to contain at least one starting point,
+i.e., an empty input file will be diagnosed as well.
+
+The processing of the starting points is otherwise as usual, e.g. @code{find}
+will recurse into subdirectories unless otherwise prevented.
+To process only the starting points, one can additionally pass @samp{-maxdepth 0}.
+
+Further notes:
+if a file is listed more than once in the input file, it is unspecified
+whether it is visited more than once.
+If the @file{file} is mutated during the operation of @code{find}, the result
+is unspecified as well.
+Finally, the seek position within the named @samp{file} at the time @code{find}
+exits, be it with @samp{-quit} or in any other way, is also unspecified.
+By "unspecified" here is meant that it may or may not work or do any specific
+thing, and that the behavior may change from platform to platform, or from
+findutils release to release.
+
+Example:
+Given that another program @code{proggy} pre-filters and creates a huge
+NUL-separated list of files, process those as starting points, and find
+all regular, empty files among them:
+@example
+$ proggy | find -files0-from - -maxdepth 0 -type f -empty
+@end example
+The use of @samp{-files0-from -} means to read the names of the starting points
+from standard input, i.e., from the pipe; and @samp{-maxdepth 0} ensures that
+only explicitly those entries are examined without recursing into directories
+(in the case one of the starting points is one).
+@end deffn
+
+
 @node Name
 @section Name
 
@@ -2822,6 +2907,8 @@ If the user does not agree to run the command, just return false.
 Otherwise, run it, with  standard input redirected from
 @file{/dev/null}.
 
+This action may not be specified together with the @samp{-files0-from} option.
+
 The response to the prompt is matched against a pair of regular
 expressions to determine if it is a yes or no response.  These regular
 expressions are obtained from the system (@code{nl_langinfo} items
@@ -2844,6 +2931,8 @@ is expanded to a relative path starting with the name of one of the
 starting directories, rather than just the basename of the matched
 file.  If the command is run, its standard input is redirected from
 @file{/dev/null}.
+
+This action may not be specified together with the @samp{-files0-from} option.
 @end deffn
 
 When processing multiple files with a single command, to query the
diff --git a/find/defs.h b/find/defs.h
index 72048ffa..27b00a0e 100644
--- a/find/defs.h
+++ b/find/defs.h
@@ -636,6 +636,12 @@ struct options
   /* How should we quote filenames in error messages and so forth?
    */
   enum quoting_style err_quoting_style;
+
+  /* Read starting points from FILE (instead of argv).  */
+  const char *files0_from;
+
+  /* True if actions like -ok, -okdir need a user confirmation via stdin.  */
+  bool ok_prompt_stdin;
 };
 
 
diff --git a/find/find.1 b/find/find.1
index da0ce9b8..004e9b23 100644
--- a/find/find.1
+++ b/find/find.1
@@ -79,6 +79,11 @@ prevent that wildcard patterns expanded by the calling shell are not mistakenly
 treated as expression arguments, it is generally safer to prefix wildcards or
 dubious path names with either `./' or to use absolute path names starting
 with '/'.
+Alternatively, it is generally safe though non-portable to use the GNU option
+.B \-files0\-from
+to pass arbitrary starting points to
+.BR find .
+
 .IP \-P
 Never follow symbolic links.  This is the default behaviour.  When
 .B find
@@ -506,6 +511,59 @@ Process each directory's contents before the directory itself.  The
 \-delete action also implies
 .BR \-depth .
 
+.IP "\-files0\-from \fIfile\fR"
+Read the starting points from \fIfile\fR instead of getting them on the
+command line.
+In contrast to the known limitations of passing starting points via arguments
+on the command line, namely the limitation of the amount of file names,
+and the inherent ambiguity of file names clashing with option names,
+using this option allows to safely pass an arbitrary number of starting points
+to \fBfind\fR.
+
+Using this option and passing starting points on the command line is mutually
+exclusive, and is therefore not allowed at the same time.
+
+The \fIfile\fR argument is mandatory.
+One can use
+.B \-files0\-from\ \-
+to read the list of starting points from the \fIstandard input\fR stream,
+and e.g. from a pipe.
+In this case, the actions
+.B \-ok
+and
+.B \-okdir
+are not allowed, because they would obviously interfere with reading from
+\fIstandard input\fR in order to get a user confirmation.
+
+The starting points in \fIfile\fR have to be separated by ASCII NUL characters.
+Two consecutive NUL characters, i.e., a starting point with a Zero-length
+file name is not allowed and will lead to an error diagnostic followed by
+a non-Zero exit code later.
+The given \fIfile\fR has to contain at least one starting point,
+i.e., an empty input file will be diagnosed as well.
+
+The processing of the starting points is otherwise as usual, e.g.
+.B find
+will recurse into subdirectories unless otherwise prevented.
+To process only the starting points, one can additionally pass
+.BR \-maxdepth\ 0 .
+
+Further notes:
+if a file is listed more than once in the input file, it is unspecified
+whether it is visited more than once.
+If the \fIfile\fR is mutated during the operation of
+.BR find ,
+the result is unspecified as well.
+Finally, the seek position within the named \fIfile\fR at the time
+.B find
+exits, be it with
+.B \-quit
+or in any other way, is also unspecified.
+By "unspecified" here is meant that it may or may not work or do any specific
+thing, and that the behavior may change from platform to platform, or from
+.B findutils
+release to release.
+
 .IP "\-help, \-\-help"
 Print a summary of the command-line usage of
 .B find
@@ -1324,6 +1382,9 @@ but ask the user first.  If the user agrees, run the command.  Otherwise
 just return false.  If the command is run, its standard input is redirected
 from
 .IR /dev/null .
+This action may not be specified together with the
+.B \-files0\-from
+option.
 
 .IP
 The response to the prompt is matched against a pair of regular
@@ -1353,6 +1414,10 @@ but ask the user first in the same way as for
 If the user does not agree, just return false.
 If the command is run, its standard input is redirected from
 .IR /dev/null .
+This action may not be specified together with the
+.B \-files0\-from
+option.
+
 
 .IP \-print
 True; print the full file name on the standard output, followed by a
@@ -2159,7 +2224,27 @@ traverses the hierarchy printing the matching filenames, and the time the
 process executed by
 .B xargs
 works with that file.
-
+.
+.SS Processing arbitrary starting points
+.IP \[bu]
+Given that another program \fIproggy\fR pre-filters and creates a huge
+NUL-separated list of files, process those as starting points, and find
+all regular, empty files among them:
+.nf
+\&
+.in +4m
+.B $ proggy | find \-files0\-from \- \-maxdepth 0 \-type f \-empty
+.in
+\&
+.fi
+The use of
+.B `\-files0\-from\ \-`
+means to read the names of the starting points from \fIstandard input\fR,
+i.e., from the pipe; and
+.B \-maxdepth\ 0
+ensures that only explicitly those entries are examined without recursing
+into directories (in the case one of the starting points is one).
+.
 .SS
 Executing a command for each file
 .IP \[bu]
@@ -2524,6 +2609,7 @@ exit status was unaffected by the failure of
 .TS
 l l l .
 Feature	Added in	Also occurs in
+\-files0\-from	4.9.0
 \-newerXY	4.3.3	BSD
 \-D	4.3.1
 \-O	4.3.1
diff --git a/find/ftsfind.c b/find/ftsfind.c
index 109f23a2..75da0707 100644
--- a/find/ftsfind.c
+++ b/find/ftsfind.c
@@ -38,6 +38,7 @@
 #include <unistd.h>
 
 /* gnulib headers. */
+#include "argv-iter.h"
 #include "cloexec.h"
 #include "closeout.h"
 #include "error.h"
@@ -45,8 +46,10 @@
 #include "intprops.h"
 #include "progname.h"
 #include "quotearg.h"
+#include "same-inode.h"
 #include "save-cwd.h"
 #include "xgetcwd.h"
+#include "xalloc.h"
 
 /* find headers. */
 #include "defs.h"
@@ -563,30 +566,163 @@ find (char *arg)
 static bool
 process_all_startpoints (int argc, char *argv[])
 {
-  int i;
-  bool empty = true;
-
-  /* figure out how many start points there are */
-  for (i = 0; i < argc && !looks_like_expression (argv[i], true); i++)
-    {
-      empty = false;
-      state.starting_path_length = strlen (argv[i]); /* TODO: is this redundant? */
-      if (!find (argv[i]))
-	return false;
+  /* Did the user pass starting points on the command line?  */
+  bool argv_starting_points = 0 < argc && !looks_like_expression (argv[0], true);
+
+  FILE *stream = NULL;
+  char const* files0_filename_quoted = NULL;
+
+  struct argv_iterator *ai;
+  if (options.files0_from)
+    {
+      /* Option -files0-from must not be combined with passing starting points
+       * on the command line.  */
+      if (argv_starting_points)
+        {
+          error (0, 0, _("extra operand %s"), safely_quote_err_filename (0, argv[0]));
+          die (EXIT_FAILURE, 0, "%s",
+                   _("file operands cannot be combined with -files0-from"));
+        }
+
+      if (0 == strcmp (options.files0_from, "-"))
+        {
+          /* Option -files0-from with argument "-" (=stdin) must not be combined
+           * with the -ok, -okdir actions: getting the user confirmation would
+           * mess with stdin.  */
+          if (options.ok_prompt_stdin)
+            {
+              die (EXIT_FAILURE, 0, "%s\n",
+                   _("option -files0-from reading from standard input"
+                   " cannot be combined with -ok, -okdir"));
+            }
+          files0_filename_quoted = safely_quote_err_filename (0, _("(standard input)"));
+          stream = stdin;
+        }
+      else
+        {
+          files0_filename_quoted = safely_quote_err_filename (0, options.files0_from);
+          stream = fopen (options.files0_from, "r");
+          if (stream == NULL)
+            die (EXIT_FAILURE, errno, _("cannot open %s for reading"),
+                 files0_filename_quoted);
+
+          const int fd = fileno (stream);
+          assert (fd >= 0);
+          if (options.ok_prompt_stdin)
+            {
+              /* Check if the given file is associated to the same stream as
+               * standard input - which is not allowed with -ok, -okdir.  This
+               * is the case with special device names symlinks for stdin like
+               *   $ find -files0-from /dev/stdin -ok
+               * or when the given FILE is also associated to stdin:
+               *   $ find -files0-from FILE -ok < FILE
+               */
+              struct stat sb1, sb2;
+              if (fstat (fd, &sb1) == 0 && fstat (STDIN_FILENO, &sb2) == 0
+                    && SAME_INODE (sb1, sb2))
+                {
+                  die (EXIT_FAILURE, 0, "%s: %s\n",
+                           _("option -files0-from: standard input must not refer"
+                             " to the same file when combined with -ok, -okdir"),
+                           files0_filename_quoted);
+                }
+            }
+          set_cloexec_flag (fd, true);
+       }
+      ai = argv_iter_init_stream (stream);
     }
-
-  if (empty)
+  else
     {
-      /*
-       * We use a temporary variable here because some actions modify
-       * the path temporarily.  Hence if we use a string constant,
-       * we get a coredump.  The best example of this is if we say
-       * "find -printf %H" (note, not "find . -printf %H").
-       */
-      char defaultpath[2] = ".";
-      return find (defaultpath);
-    }
-  return true;
+      if (!argv_starting_points)
+        {
+          /* If no starting points are given on the comman line, then
+           * fall back to processing the current directory, i.e., ".".
+           * We use a temporary variable here because some actions modify
+           * the path temporarily.  Hence if we use a string constant,
+           * we get a coredump.  The best example of this is if we say
+           * "find -printf %H" (note, not "find . -printf %H").
+           */
+          char defaultpath[2] = ".";
+          return find (defaultpath);
+        }
+
+      /* Process the starting point(s) from the command line.  */
+      ai = argv_iter_init_argv (argv);
+    }
+
+  if (!ai)
+    xalloc_die ();
+
+  bool ok = true;
+  while (true)
+    {
+      enum argv_iter_err ai_err;
+      char *file_name = argv_iter (ai, &ai_err);
+      if (!file_name)
+        {
+          switch (ai_err)
+            {
+            case AI_ERR_EOF:
+              goto argv_iter_done;
+            case AI_ERR_READ:  /* may only happen with -files0-from  */
+              error (0, errno, _("%s: read error"), files0_filename_quoted);
+              state.exit_status = 1;
+              ok = false;
+              goto argv_iter_done;
+            case AI_ERR_MEM:
+              xalloc_die ();
+            default:
+              assert (!"unexpected error code from argv_iter");
+            }
+        }
+      /* Report and skip any empty file names before invoking fts.
+         This works around a glitch in fts, which fails immediately
+         (without looking at the other file names) when given an empty
+         file name.  */
+      if (!file_name[0])
+        {
+          /* Diagnose a zero-length file name.  When it's one
+             among many, knowing the record number may help.  */
+          if (options.files0_from == NULL)
+            error (0, ENOENT, "%s", safely_quote_err_filename (0, file_name));
+          else
+            {
+              /* Using the standard 'filename:line-number:' prefix here is
+                 not totally appropriate, since NUL is the separator, not NL,
+                 but it might be better than nothing.  */
+              unsigned long int file_number = argv_iter_n_args (ai);
+              error (0, 0, "%s:%lu: %s", files0_filename_quoted, file_number,
+                     _("invalid zero-length file name"));
+            }
+          state.exit_status = 1;
+          ok = false;
+          continue;
+        }
+
+      /* Terminate loop when processing the start points from command line,
+         and reaching the first expression.  */
+      if (!options.files0_from && looks_like_expression (file_name, true))
+        break;
+
+      state.starting_path_length = strlen (file_name); /* TODO: is this redundant? */
+      if (!find (file_name))
+        {
+          ok = false;
+          goto argv_iter_done;
+        }
+    }
+ argv_iter_done:
+
+  if (ok && options.files0_from && argv_iter_n_args (ai) <= 0)
+    die (EXIT_FAILURE, 0, _("file with starting points is empty: %s"),
+         files0_filename_quoted);
+
+  argv_iter_free (ai);
+
+  if (ok && options.files0_from && (ferror (stream) || fclose (stream) != 0))
+    die (EXIT_FAILURE, 0, _("error reading %s"), files0_filename_quoted);
+
+  return ok;
 }
 
 
diff --git a/find/parser.c b/find/parser.c
index 1ebf2548..f5d100a2 100644
--- a/find/parser.c
+++ b/find/parser.c
@@ -90,6 +90,7 @@ static bool parse_empty         (const struct parser_table*, char *argv[], int *
 static bool parse_exec          (const struct parser_table*, char *argv[], int *arg_ptr);
 static bool parse_execdir       (const struct parser_table*, char *argv[], int *arg_ptr);
 static bool parse_false         (const struct parser_table*, char *argv[], int *arg_ptr);
+static bool parse_files0_from   (const struct parser_table*, char *argv[], int *arg_ptr);
 static bool parse_fls           (const struct parser_table*, char *argv[], int *arg_ptr);
 static bool parse_fprintf       (const struct parser_table*, char *argv[], int *arg_ptr);
 static bool parse_follow        (const struct parser_table*, char *argv[], int *arg_ptr);
@@ -241,6 +242,7 @@ static struct parser_table const parse_table[] =
   {ARG_ACTION,      "exec",    parse_exec, pred_exec}, /* POSIX */
   {ARG_TEST,        "executable",            parse_accesscheck, pred_executable}, /* GNU, 4.3.0+ */
   PARSE_ACTION     ("execdir",               execdir), /* *BSD, GNU */
+  PARSE_OPTION     ("files0-from",           files0_from),   /* GNU */
   PARSE_ACTION     ("fls",                   fls),	     /* GNU */
   PARSE_POSOPT     ("follow",                follow),  /* GNU, Unix */
   PARSE_ACTION     ("fprint",                fprint),	     /* GNU */
@@ -963,6 +965,18 @@ parse_false (const struct parser_table* entry, char **argv, int *arg_ptr)
   return insert_false ();
 }
 
+static bool
+parse_files0_from (const struct parser_table* entry, char **argv, int *arg_ptr)
+{
+  const char *filename;
+  if (collect_arg (argv, arg_ptr, &filename))
+    {
+      options.files0_from = filename;
+      return true;
+    }
+  return false;
+}
+
 static bool
 insert_fls (const struct parser_table* entry, const char *filename)
 {
@@ -2903,6 +2917,8 @@ insert_exec_ok (const char *action,
   else
     {
       allow_plus = false;
+      /* The -ok* family need user confirmations via stdin.  */
+      options.ok_prompt_stdin = true;
       /* If find reads stdin (i.e. for -ok and similar), close stdin
        * in the child to prevent some script from consuming the output
        * intended for find.
diff --git a/find/util.c b/find/util.c
index 959272a2..73a0cef2 100644
--- a/find/util.c
+++ b/find/util.c
@@ -180,8 +180,9 @@ operators (decreasing precedence; -and is implicit where no others are given):\n
   HTL (_("\
 positional options (always true): -daystart -follow -regextype\n\n\
 normal options (always true, specified before other expressions):\n\
-      -depth --help -maxdepth LEVELS -mindepth LEVELS -mount -noleaf\n\
-      --version -xdev -ignore_readdir_race -noignore_readdir_race\n"));
+      -depth --help -files0-from FILE -maxdepth LEVELS -mindepth LEVELS\n\
+      -mount -noleaf --version -xdev -ignore_readdir_race\n\
+      -noignore_readdir_race\n"));
   HTL (_("\
 tests (N can be +N or -N or N): -amin N -anewer FILE -atime N -cmin N\n\
       -cnewer FILE -ctime N -empty -false -fstype TYPE -gid N -group NAME\n\
@@ -1073,6 +1074,9 @@ set_option_defaults (struct options *p)
   set_follow_state (SYMLINK_NEVER_DEREF); /* The default is equivalent to -P. */
 
   p->err_quoting_style = locale_quoting_style;
+
+  p->files0_from = NULL;
+  p->ok_prompt_stdin = false;
 }
 
 
diff --git a/tests/find/files0-from.sh b/tests/find/files0-from.sh
new file mode 100755
index 00000000..b930a999
--- /dev/null
+++ b/tests/find/files0-from.sh
@@ -0,0 +1,170 @@
+#!/bin/sh
+# Exercise -files0-from option.
+
+# Copyright (C) 2021 Free Software Foundation, Inc.
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+. "${srcdir=.}/tests/init.sh"; fu_path_prepend_
+print_ver_ find
+
+# Option -files0-from requires a file name argument.
+returns_ 1 find -files0-from > out 2> err \
+  && grep 'missing argument.*files0' err \
+  || { grep . out err; fail=1; }
+
+# Option -files0-from must not be combined with passing starting points on
+# the command line.
+returns_ 1 find OFFENDING -files0-from FILE > out 2> err \
+  && grep 'extra operand .*OFFENDING' err \
+  && grep 'file operands cannot be combined with -files0-from' err \
+  || { grep . out err; fail=1; }
+
+# Process "." as default when starting points neither passed via -files0-from
+# nor on the command line.
+printf "%s\n" '.' > exp || framework_failure_
+find -maxdepth 0 > out 2> err || fail=1
+compare exp out || fail=1
+compare /dev/null err || fail=1
+
+# Option -files0-from with argument "-" (=stdin) must not be combined with
+# the -ok action: getting the user confirmation would mess with stdin.
+returns_ 1 find -files0-from - -ok echo '{}' ';' < /dev/null > out 2> err \
+  && grep 'files0.* standard input .*cannot be combined with .*ok' err \
+  || { grep . out err; fail=1; }
+
+# Option -files0-from with argument "-" (=stdin) must not be combined with
+# the -okdir action: getting the user confirmation would mess with stdin.
+returns_ 1 find -files0-from - -okdir echo '{}' ';' < /dev/null > out 2> err \
+  && grep 'files0.* standard input .*cannot be combined with .*ok' err \
+  || { grep . out err; fail=1; }
+
+# File argument of -files0-from option must not refer to the same file as stdin.
+printf '.' > in || framework_failure_
+returns_ 1 find -files0-from in -ok echo '{}' ';' < in > out 2> err \
+    && grep 'files0.* standard input .*same file .*with -ok, -okdir' err \
+  || { grep . out err; fail=1; }
+
+# Likewise via a symlink.
+if ln -s in inlink; then
+  returns_ 1 find -files0-from inlink -ok echo '{}' ';' < in > out 2> err \
+    && grep 'files0.* standard input .*same file .*with -ok, -okdir' err \
+    || { grep . out err; fail=1; }
+  # ... and vice versa.
+  returns_ 1 find -files0-from in -ok echo '{}' ';' < inlink > out 2> err \
+    && grep 'files0.* standard input .*same file .*with -ok, -okdir' err \
+    || { grep . out err; fail=1; }
+fi
+
+# Likewise when the system provides the name '/dev/stdin'.
+if ls /dev/stdin >/dev/null 2>&1; then
+  returns_ 1 find -files0-from /dev/stdin -ok echo '{}' ';' < in > out 2> err \
+    && grep 'files0.* standard input .*same file .*with -ok, -okdir' err \
+    || { grep . out err; fail=1; }
+fi
+
+# Exercise a non-existing file.
+returns_ 1 find -files0-from ENOENT > out 2> err \
+  && grep 'cannot open .ENOENT. for reading: No such' err \
+  || { grep . out err; fail=1; }
+
+# Exercise a file which cannot be opened.
+# The shadow(5) file seems to be a good choice for reasonable coverage.
+f='/etc/shadow'
+if test -e $f && test '!' -r $f; then
+  returns_ 1 find -files0-from $f > out 2> err \
+  && grep 'cannot open .* for reading: Permission denied' err \
+  || { grep . out err; fail=1; }
+fi
+
+# Exercise a directory argument.
+returns_ 1 find -files0-from / > out 2> err \
+  && grep 'read error' err \
+  || { grep . out err; fail=1; }
+
+# Exercise an empty input file.
+returns_ 1 find -files0-from /dev/null > out 2> err || fail=1
+compare /dev/null out || fail=1
+grep 'file with starting points is empty:' err || fail=1
+
+# Likewise via stdin.
+returns_ 1 find -files0-from - < /dev/null > out 2> err || fail=1
+compare /dev/null out || fail=1
+grep 'file with starting points is empty:.*standard input' err || fail=1
+
+# Likewise via a pipe on stdin.
+cat /dev/null | returns_ 1 find -files0-from - > out 2> err || fail=1
+compare /dev/null out || fail=1
+grep 'file with starting points is empty:.*standard input' err || fail=1
+
+# Now a regular case: 2 files: expect the same output.
+touch a b || framework_failure_
+printf '%s\0' a b > in || framework_failure_
+tr '\0' '\n' < in > exp || framework_failure_
+find -files0-from in -print > out 2> err || fail=1
+compare exp out || fail=1
+compare /dev/null err || fail=1
+
+# Demonstrate that -files0-from accepts file names which would otherwise be
+# rejected because they are recognized as test or action.
+touch ./-print ./-mtime ./-size || framework_failure_
+printf '%s\0' _print _mtime _size | tr '_' '-' > in || framework_failure_
+tr '\0' '\n' < in > exp || framework_failure_
+find -files0-from in -printf '%p\n' > out 2> err || fail=1
+compare exp out || fail=1
+compare /dev/null err || fail=1
+
+# Zero-length file name on position 2, once per stdin ...
+printf '%s\n' a b > exp || framework_failure_
+printf '%s\0' a '' b \
+  | tee file \
+  | returns_ 1 find -files0-from - -print > out 2> err || fail=1
+compare exp out || fail=1
+grep '(standard input).:2: invalid zero-length file name' err || fail=1
+# ... and once per file.
+returns_ 1 find -files0-from file -print > out 2> err || fail=1
+compare exp out || fail=1
+grep 'file.:2: invalid zero-length file name' err || fail=1
+
+# Non-existing file name.
+printf '%s\0' a ENOENT b \
+  | returns_ 1 find -files0-from - -print > out 2> err || fail=1
+compare exp out || fail=1
+grep 'ENOENT' err || fail=1
+
+# Demonstrate (the usual!) recursion ...
+mkdir d1 d1/d2 d1/d2/d3 && touch d1/d2/d3/file || framework_failure_
+printf '%s\n' d1 d1/d2 d1/d2/d3 d1/d2/d3/file > exp || framework_failure_
+printf 'd1' \
+  | find -files0-from - > out 2> err || fail=1
+compare exp out || fail=1
+compare /dev/null err || fail=1
+# ... and how to avoid recursion with -maxdepth 0.
+find d1 -print0 \
+  | find -files0-from - -maxdepth 0 > out 2> err || fail=1
+compare exp out || fail=1
+compare /dev/null err || fail=1
+
+# Large input with files.
+# Create file 'exp' with many times its own name as content, NUL-separated;
+# and let find(1) check that it worked (4x in bytes).
+yes exp | head -n 100000 | tr '\n' '\0' > exp \
+  && find exp -size 400000c | grep . || framework_failure_
+# Run the test.
+find -files0-from exp > out 2> err || fail=1
+# ... and verify.
+test $( wc -l < out ) = 100000 || fail=1
+find out -size 400000c | grep . || fail=1
+
+Exit $fail
diff --git a/tests/local.mk b/tests/local.mk
index 95bc3df5..5b5a0e64 100644
--- a/tests/local.mk
+++ b/tests/local.mk
@@ -114,6 +114,7 @@ all_tests = \
   tests/find/printf_inode.sh \
   tests/find/execdir-fd-leak.sh \
   tests/find/exec-plus-last-file.sh \
+  tests/find/files0-from.sh \
   tests/find/refuse-noop.sh \
   tests/find/debug-missing-arg.sh \
   tests/find/used.sh \
-- 
2.31.1

