From 961e48a903f610c559a58ffdc3a8285983993365 Mon Sep 17 00:00:00 2001 From: Paul Eggert Date: Thu, 20 Aug 2026 10:51:27 -0700 Subject: [PATCH] tar: support --one-top-dir=/abs/olute It appears that people were using the --one-top-dir=DIR option with an absolute file name, even though this behavior was undocumented and erratic and recent tar versions rejected it. Document and implement behavior that I hope people expect. * src/extract.c (MAKEDIR_FULL_ESCAPE): New constant. (make_directories): Let full directory names escape if MAKEDIR_FULL_ESCAPE is passed. (create_dir): Let DIR escape if it is the one-top-level dir or an ancestor. * src/misc.c (fdbase_opendir): Replace old CHILD_OFLAGS arg with two new args CHILD and OFLAG, so that the functionalities can be distinguished. All callers changed. (fdbase_escape): New function. (fdbase): Use it, for simplicity. * tests/onetop06.at: New test file. * tests/Makefile.am (TESTSUITE_AT): Add it. * tests/testsuite.at: Include it. --- NEWS | 7 ++++- doc/tar.texi | 77 +++++++++++++++++++++++++++++++--------------- src/common.h | 1 + src/extract.c | 37 +++++++++++++++++----- src/misc.c | 47 ++++++++++++++++++++-------- tests/Makefile.am | 1 + tests/onetop06.at | 39 +++++++++++++++++++++++ tests/testsuite.at | 1 + 8 files changed, 164 insertions(+), 46 deletions(-) create mode 100644 tests/onetop06.at diff --git a/NEWS b/NEWS index a7193430..abdeb348 100644 --- a/NEWS +++ b/NEWS @@ -1,4 +1,4 @@ -GNU tar NEWS - User visible changes. 2026-07-22 +GNU tar NEWS - User visible changes. 2026-08-20 Please send GNU tar bug reports to version 1.35.90 (git) @@ -38,6 +38,11 @@ option. ** The --one-top-level option now requires either -x or -d mode. Previously the behavior was unspecified in other operation modes. +** If DIR is absolute, the --one-top-level=DIR option now extracts all + files relative to DIR, without stripping leading DIR from file names. + Any -C options are ignored. Formerly, the behavior was + undocumented and differed from one tar version to the next. + * Bug fixes ** When extracting, tar no longer follows symbolic links to targets diff --git a/doc/tar.texi b/doc/tar.texi index 4441cc7b..cdf745e0 100644 --- a/doc/tar.texi +++ b/doc/tar.texi @@ -3287,23 +3287,13 @@ directory. @opsummary{one-top-level} @item --one-top-level[=@var{dir}] -Tells @command{tar} to use a directory beneath the extraction directory -(or the one passed to @option{-C}) to prevent @command{tar} -from accessing files outside that directory. -If @var{dir} is absent, the name of the directory -is the base name of the archive minus any recognized archive suffix. -If @var{dir} is an absolute file name, that is the only such directory; -otherwise, the working directory and the directories specified by -any @option{-C} options each have the named subdirectory. -Any member names that do not begin -with that directory name (after -transformations from @option{--transform} and -@option{--strip-components}) will be prefixed with it. Recognized -file name suffixes are @samp{.tar}, and any compression suffixes -recognizable by @xref{--auto-compress}. - -This option can be used only when extracting, comparing, or reading -from the archive. +Extract files relative to @var{dir}, +ignoring any @var{dir} prefixes in the archive. +An absent @var{dir} defaults to +the base name of the archive minus any recognized archive suffix. +If @var{dir} is relative, it is relative to the working directory; +if absolute, any @option{--directory} (@option{-C}) options are irrelevant. +@xref{directory}. @opsummary{overwrite} @item --overwrite @@ -9440,23 +9430,59 @@ mentioned by name on the standard error. @node directory @subsection Changing the Working Directory -@cindex Changing directory mid-stream -@cindex Directory, changing mid-stream +@cindex Changing working directory +@cindex Directory, changing @cindex Working directory, specifying To change the working directory in the middle of a list of file names, either on the command line or in a file specified using @option{--files-from} (@option{-T}), use @option{--directory} (@option{-C}). This will change the working directory to the specified directory after that point in the list. +You can also use the @code{--one-top-level} option to alter +the working directory location, though this option applies to all file names. @table @option @opindex directory @item --directory=@var{directory} @itemx -C @var{directory} -Changes the working directory in the middle of a command line. +Change the working directory for later files in the command line. + +@item --one-top-level +@item --one-top-level[=@var{dir}] +Change the effective working directory to @var{dir} for all files. +If @var{dir} does not already exist, create it and any ancestors as +necessary before extracting any files into it. + +If @var{dir} is relative, extract all files under that directory; +if @option{-C} is also given, +@var{dir} is relative to the @option{-C} directory. +Prefix any member names that do not begin with @var{dir} +(after transformations from @option{--transform} and +@option{--strip-components}) with @var{dir}. +An absent @var{dir} defaults to the base name of the archive +minus any recognized archive suffix. +Recognized file name suffixes are @samp{.tar}, +and any compression suffixes recognizable by @xref{--auto-compress}. + +If @var{dir} is absolute, extract all files under that directory; +any @option{-C} options are irrelevant, +and @var{dir} itself is exempt from the usual safety rules +prohibiting extraction outside the working directory. +The two main differences between @samp{--one-top-level=/abs/olute} +and @samp{-C /abs/olute} are that the former affects all files +whereas the latter affects only later files, +and in the former @file{/abs/olute} and its ancestors are created +if they do not already exist +whereas in the latter @file{/abs/olute} must already exist. + +The @option{--one-top-level} option can be used only when extracting, +comparing, or reading from the archive. +It is incompatible with the @option{--absolute-names} (@option{-P}) option. @end table -All filename arguments appearing after this option will be taken +All filename arguments appearing after a +@option{--directory=@var{directory}} (@option{-C @var{directory}}) option +are taken relative to @var{directory}. For example, @smallexample @@ -9511,7 +9537,7 @@ the original working directory of @command{tar}, due to a previous @option{--directory} option. There is an important exception, though: the @option{-C} option does -not affect filename arguments to another options that follow it, in +not affect filename arguments to other options that follow it, in particular @option{-f}. This means that the example above can be written as @@ -9538,6 +9564,7 @@ The command-line options not affected by @option{-C} are: @item @option{-T}, @option{--files-from} @item @option{-X}, @option{--exclude-from} @item @option{--mtime} +@item @option{--one-top-level=@var{dir}} when @var{dir} is absolute @item @option{--owner-map} @item @option{-f}, @option{--file} @item @option{-F}, @option{--info-script}, @option{--new-volume-script} @@ -13171,8 +13198,10 @@ by an untrusted user, that user therefore can write into any file under the working directory. When extracting from an untrusted archive, it is therefore good practice to create an empty directory and run @command{tar} in that directory. You can use the -@option{--directory} (@option{-C}) option to specify the working -directory (@pxref{directory}). +@option{--directory} (@option{-C}) and @option{--one-top-level} +options to specify the working directory; the latter option starts by +creating the working directory for you if it does not already exist. +@xref{directory}. When extracting from an archive, @command{tar} by default rejects attempts to modify files outside the working directory. diff --git a/src/common.h b/src/common.h index 7476888d..38f9958e 100644 --- a/src/common.h +++ b/src/common.h @@ -787,6 +787,7 @@ void chdir_do (idx_t dir, bool create); struct chdir_id { int err; dev_t st_dev; ino_t st_ino; } chdir_id (void); struct fdbase fdbase (char const *); struct fdbase fdbase1 (char const *); +struct fdbase fdbase_escape (char const *, bool); int open_searchdir (char const *); int fdbase_close (int); void fdbase_clear (void); diff --git a/src/extract.c b/src/extract.c index 2c9cfca2..2bb0e80f 100644 --- a/src/extract.c +++ b/src/extract.c @@ -771,11 +771,10 @@ fixup_delayed_set_stat (char const *src, char const *dst) } /* Ensure that a directory exists by creating it and ancestors as needed. - If MAKEDIR == MAKEDIR_FULL the directory is FILE_NAME; - otherwise it is FILE_NAME's parent directory. - Follow symlinks. Do not overwrite existing files. - Allow races with other processes that are also trying to create - the requested directory. + Allow races with other processes that are doing likewise. + Do not overwrite existing files. + Follow symlinks, but do not escape the destination directory + unless MAKEDIR == MAKEDIR_FULL_ESCAPE. Possibly temporarily modify FILE_NAME if it contains slashes, but restore it before returning. Return: @@ -783,7 +782,23 @@ fixup_delayed_set_stat (char const *src, char const *dst) 0 if the requested directory likely exists (and if MAKEDIR == MAKEDIR_PARENT_CHECK, check that it does exist), -1 (issuing a diagnostic) otherwise. */ -enum makedir { MAKEDIR_PARENT_CHECK = -1, MAKEDIR_PARENT, MAKEDIR_FULL }; +enum makedir + { + /* Make the file name's parent directory, + and also check that it was made. */ + MAKEDIR_PARENT_CHECK = -1, + + /* Make the file name's parent directory, + but for efficiency do not check that it was actually made. */ + MAKEDIR_PARENT, + + /* Treat the entire file name as the name of the directory to be made. */ + MAKEDIR_FULL, + + /* Likewise, but ignore the usual fence around the destination + directory. */ + MAKEDIR_FULL_ESCAPE + }; static int make_directories (char *file_name, enum makedir makedir) { @@ -841,7 +856,8 @@ make_directories (char *file_name, enum makedir makedir) *cursor = '\0'; /* truncate the name there */ desired_mode = MODE_RWX & ~ newdir_umask; mode = desired_mode | (we_are_root ? 0 : MODE_WXUSR); - struct fdbase f = fdbase (file_name); + struct fdbase f = fdbase_escape (file_name, + makedir == MAKEDIR_FULL_ESCAPE); if (f.fd != BADFD && mkdirat (f.fd, f.base, mode) == 0) { @@ -918,7 +934,12 @@ make_directories (char *file_name, enum makedir makedir) bool create_dir (char *dir) { - return 0 <= make_directories (dir, MAKEDIR_FULL); + /* If --one-top-level=TOPDIR is used and TOPDIR is absolute, + DIR must be TOPDIR or an ancestor, so let DIR escape. */ + return 0 <= make_directories (dir, + ((one_top_level_dir + && IS_ABSOLUTE_FILE_NAME (one_top_level_dir)) + ? MAKEDIR_FULL_ESCAPE : MAKEDIR_FULL)); } /* Return true if FILE_NAME (with status *STP, if STP) is not a diff --git a/src/misc.c b/src/misc.c index 5e9e709c..8507f35f 100644 --- a/src/misc.c +++ b/src/misc.c @@ -32,7 +32,7 @@ static void namebuf_add_dir (namebuf_t, char const *); static char *namebuf_finish (namebuf_t); static const char *tar_getcdpath (idx_t); -static struct fdbase fdbase_opendir (char const *, bool, int); +static struct fdbase fdbase_opendir (char const *, bool, bool, int); char const * quote_n_colon (int n, char const *arg) @@ -1109,7 +1109,7 @@ chdir_do (idx_t i, bool create) chdir_do ((i - 1) & ~+one_top_level, false); int oflags = open_searchdir_how.flags & ~O_NOFOLLOW; - fd = fdbase_opendir (curr->name, false, oflags).fd; + fd = fdbase_opendir (curr->name, false, true, oflags).fd; if (fd < 0) { if (errno == ENOENT) @@ -1119,7 +1119,7 @@ chdir_do (idx_t i, bool create) if (!create_dir (curr->name)) fatal_exit (); /* Directory likely exists now; retry. */ - fd = fdbase_opendir (curr->name, false, oflags).fd; + fd = fdbase_opendir (curr->name, false, true, oflags).fd; } else if (i & one_top_level) { @@ -1282,12 +1282,13 @@ open_subdir (int fd, char const *subdir, int oflags) only the alternate cache; otherwise, use and update only the main cache; this means a call with ALTERNATE cannot invalidate a call without. If FILE_NAME is relative, it is relative to chdir_fd. - If CHILD_OFLAGS, open FILE_NAME itself, posssibly letting it escape from - chdir_fd; otherwise, open FILE_NAME's parent but do not let it escape. + If CHILD, open FILE_NAME; otherwise open FILE_NAME's parent directory. + If OFLAGS, open the directory with those flags, possibly letting it + escape from chdir_fd; otherwise, do not let it escape. Return AT_FDCWD if FILE_NAME is relative to the working directory. Return BADFD (setting errno) on failure. */ static struct fdbase -fdbase_opendir (char const *file_name, bool alternate, int child_oflags) +fdbase_opendir (char const *file_name, bool alternate, bool child, int oflags) { char const *name = file_name; int dfd = IS_ABSOLUTE_FILE_NAME (file_name) ? AT_FDCWD : chdir_fd; @@ -1311,10 +1312,10 @@ fdbase_opendir (char const *file_name, bool alternate, int child_oflags) continue; } - /* For files immediately under CHDIR_FD, and for root directories, - just use CHDIR_FD and NAME. Empty NAME is invalid, though. */ + /* For file names immediately under DFD, and for names of root directories, + just use DFD and NAME. Empty NAME is invalid, though. */ char const *base = last_component (name); - idx_t newdirlen = base + (child_oflags ? strlen (base) : 0) - name; + idx_t newdirlen = base + (child ? strlen (base) : 0) - name; if (!newdirlen | !*base) { if (*name) @@ -1380,7 +1381,7 @@ fdbase_opendir (char const *file_name, bool alternate, int child_oflags) open descendant to FD rather than to CHDIR_FD. */ bool descendant = old_prefixes_new & chdirmatch; int newfd = open_subdir (descendant ? fd : chdir_fd, - &newdir[descendant ? subdirlen : 0], child_oflags); + &newdir[descendant ? subdirlen : 0], oflags); if (newfd < 0) return (struct fdbase) { .fd = BADFD, .base = base }; @@ -1395,22 +1396,42 @@ fdbase_opendir (char const *file_name, bool alternate, int child_oflags) return (struct fdbase) { .fd = newfd, .base = base }; } +/* Return an fd open to NAME's parent directory + along with the corresponding base name. + Do not escape from chdir_fd unless ESCAPE or unless -P is used. */ +struct fdbase +fdbase_escape (char const *name, bool escape) +{ + return fdbase_opendir (name, false, false, + escape ? open_searchdir_how.flags : 0); +} + +/* Return an fd open to NAME's parent directory + along with the corresponding base name. + Do not escape from chdir_fd unless -P is used. */ struct fdbase fdbase (char const *name) { - return fdbase_opendir (name, false, 0); + return fdbase_escape (name, false); } +/* Return an fd open to NAME's parent directory + along with the corresponding base name. + Do not escape from chdir_fd unless -P is used. + Use the alternate cache instead of the main one; + this is for syscalls like 'linkat' that need two fds. */ struct fdbase fdbase1 (char const *name) { - return fdbase_opendir (name, true, 0); + return fdbase_opendir (name, true, false, 0); } +/* Return an fd open to NAME. + Do not escape from chdir_fd unless -P is used. */ int open_searchdir (char const *name) { - return fdbase_opendir (name, false, open_searchdir_how.flags).fd; + return fdbase_opendir (name, false, true, open_searchdir_how.flags).fd; } diff --git a/tests/Makefile.am b/tests/Makefile.am index 5e837857..8e4d6bd4 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -204,6 +204,7 @@ TESTSUITE_AT = \ onetop03.at\ onetop04.at\ onetop05.at\ + onetop06.at\ opcomp01.at\ opcomp02.at\ opcomp03.at\ diff --git a/tests/onetop06.at b/tests/onetop06.at new file mode 100644 index 00000000..dec81eeb --- /dev/null +++ b/tests/onetop06.at @@ -0,0 +1,39 @@ +# Process this file with autom4te to create testsuite. -*- Autotest -*- +# +# Test suite for GNU tar. +# Copyright 2026 Free Software Foundation, Inc. +# +# This file is part of GNU tar. +# +# GNU tar 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. +# +# GNU tar 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 . +# +AT_SETUP([tar --one-top-level into nonexistent absolute dir]) +AT_KEYWORDS([extract onetop onetop06]) + +AT_TAR_CHECK([ +mkdir d f && genfile --file d/file || framework_failure_ +tar -cf test.tar d || exit +tar -xf test.tar --one-top-level=$PWD/e/c || exit +diff d e/c/d || exit +tar -xf test.tar --one-top-level=$PWD/e/d || exit +diff d e/d/d || exit + +tar -cf test1.tar e/c || exit +tar -xf test1.tar -C f --one-top-level=e/c || exit +diff e/c/d f/e/c/d || exit +tar -xf test1.tar -C f --one-top-level=$PWD/g || exit +diff e/c/d g/e/c/d || exit +]) + +AT_CLEANUP diff --git a/tests/testsuite.at b/tests/testsuite.at index 7e3089eb..4b438849 100644 --- a/tests/testsuite.at +++ b/tests/testsuite.at @@ -543,6 +543,7 @@ m4_include([onetop02.at]) m4_include([onetop03.at]) m4_include([onetop04.at]) m4_include([onetop05.at]) +m4_include([onetop06.at]) AT_BANNER([Star tests]) m4_include([star/gtarfail.at])