Hi Mark,
On Tue, Jun 2, 2026 at 6:47 AM Mark Wielaard <[email protected]> wrote:
>
> With -o elfcompress opens the output file with O_WRONLY and O_CREAT.
> If the output file already existed then without O_TRUNC the file is
> written from the start, but keeps all existing data. That means the
> file might contain extra data if the (compressed) ELF file is shorter
> than the existing file. Without -o the input file is the output file
> and we create a temp file to create the output which should then be
> atomically replaced using rename. This might fail when the input file
> was a symlink (to a file in a different directory.)
>
> Solve both issues by first trying to open the output file with
> O_EXCL. Which makes sure we created the file so we can write directly
> to it. If it fails then the output file already exists. Then we first
> resolve the full file path (in case the output file is a symlink) and
> then open the directory where the output should go. We then make sure
> the create a temporary file inside this directory using a new system.h
> helper function xmkstempat. Finally, after the output file is fully
> created (and flushed out to disk) we use renameat to atomically
> replace the output file with our temp file. Since we still have the
> directory open this works even if the file path is changed while
> creating the temp file data.
>
> Add a new run-compress-test-out.sh testcase for input and/or output
> file being symlinks and/or pointing to the same file.
>
> * lib/system.h (xmkstempat): New helper function.
> * src/elfcompress.c (process_file): Declare new variables to
> keep copies of the (ouput) directory, dirfd, base file and
> temporary file name. Fix trailing \n in error fmt. Call open
> with O_EXCL for output file. Use realpath and xmkstempat if
> output file exists. fsync temp file before caling
> renameat. Use unlinkat in cleanup. close and free new
> temporaries.
> * tests/run-compress-test-out.sh: New test file.
> * tests/Makefile.am (TESTS): Add new test file.
> (EXTRA_DIST): Likewise.
>
> Signed-off-by: Mark Wielaard <[email protected]>
> ---
> lib/system.h | 54 ++++++++++++-
> src/elfcompress.c | 133 +++++++++++++++++++++++++--------
> tests/Makefile.am | 4 +-
> tests/run-compress-test-out.sh | 74 ++++++++++++++++++
> 4 files changed, 229 insertions(+), 36 deletions(-)
> create mode 100755 tests/run-compress-test-out.sh
>
> v2:
> - Complete comment for xbasename
> Add comments to fnew and tempname explaining what is which
> Make tempname based on bname, not fnew.
> Consistently use tempname when set instead of fnew.
> Only free tempname and set to NULL on error.
> Keep fnew around till cleanup, and always free it.
>
> diff --git a/lib/system.h b/lib/system.h
> index c17e2aa0fea1..0f97f4346558 100644
> --- a/lib/system.h
> +++ b/lib/system.h
> @@ -1,6 +1,6 @@
> /* Declarations for common convenience functions.
> Copyright (C) 2006-2011 Red Hat, Inc.
> - Copyright (C) 2022 Mark J. Wielaard <[email protected]>
> + Copyright (C) 2022, 2026 Mark J. Wielaard <[email protected]>
> Copyright (C) 2023 Khem Raj.
> This file is part of elfutils.
>
> @@ -39,6 +39,7 @@
> #endif
>
> #include <errno.h>
> +#include <fcntl.h>
> #include <stdbool.h>
> #include <stddef.h>
> #include <stdint.h>
> @@ -52,6 +53,7 @@
> #include <sys/mman.h>
> #include <sys/param.h>
> #include <unistd.h>
> +#include <sys/random.h>
>
> #if defined(HAVE_ERROR_H)
> #include <error.h>
> @@ -257,4 +259,54 @@ xbasename(const char *s)
> }
> #pragma GCC poison basename
>
> +/* There is no mkstempat needed for creating a temp file in a specific
> + directory. Needed e.g. in combination with renameat to atomicly
> + replace a file. So define one ourselves. Like mkstemp the template
> + must end in "XXXXXX", which are replaced by an unique filename
> + suffix. The file is created with user read/write permissions only
> + in the given dirfd using openat.
> + https://sourceware.org/bugzilla/show_bug.cgi?id=19866 */
> +static inline int
> +xmkstempat (int dirfd, char *templ)
> +{
> + /* Only use these 64 chars. */
> + const char chars[] =
> + "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_";
> +
> + /* Must end in 6X. */
> + size_t l = strlen (templ);
> + if (l < 6 || memcmp (templ + l - 6, "XXXXXX", 6) != 0)
> + {
> + errno = EINVAL;
> + return -1;
> + }
> +
> + int tries = 128; /* Just fail with EEXIST if 128 tries wasn't enough. */
> + do
> + {
> + uint64_t r; /* We need at least 64^6 == 2^36 */
> + if (TEMP_FAILURE_RETRY (getrandom (&r, sizeof (r), 0)) != sizeof (r))
getrandom was introduced in glibc 2.25 (Feb 2017) so it's probably safer if
we add a configure check for getrandom or <sys/random.h>. There's one already
for reallocarray (glibc 2.26) plus fallbacks if it's missing.
> + return -1;
> +
> + /* Random chars for the template. */
> + for (int i = 0; i < 6; i++)
> + {
> + templ[l - 6 + i] = chars[r % 64];
> + r /= 64;
> + }
> +
> + /* Must be able to open exclusively. */
> + int fd = openat (dirfd, templ,
> + O_RDWR | O_CREAT | O_EXCL,
> + S_IRUSR | S_IWUSR);
> + if (fd >= 0)
> + return fd;
> +
> + tries--;
> + }
> + while (tries > 0 && errno == EEXIST);
> +
> + return -1;
> +}
> +
> #endif /* system.h */
> diff --git a/src/elfcompress.c b/src/elfcompress.c
> index fdcff32847ce..272d2c431281 100644
> --- a/src/elfcompress.c
> +++ b/src/elfcompress.c
> @@ -1,5 +1,6 @@
> /* Compress or decompress an ELF file.
> Copyright (C) 2015, 2016, 2018 Red Hat, Inc.
> + Copyright (C) 2026 Mark J. Wielaard <[email protected]>
> This file is part of elfutils.
>
> This file is free software; you can redistribute it and/or modify
> @@ -37,6 +38,9 @@
> #include "libeu.h"
> #include "printversion.h"
>
> +/* Really should come from libgen.h, but we poisoned basename in system.h.
> */
> +extern char *dirname(char *path);
> +
> /* Name and version of program. */
> ARGP_PROGRAM_VERSION_HOOK_DEF = print_version;
>
> @@ -338,10 +342,18 @@ process_file (const char *fname)
> Elf *elf = NULL;
>
> /* The output ELF. */
> - char *fnew = NULL;
> + char *fnew = NULL; /* Name used if we don't need the split up tempname. */
> int fdnew = -1;
> Elf *elfnew = NULL;
>
> + /* Split up dir/base names if necessary. */
> + char *dirc = NULL;
> + char *basec = NULL;
> + const char *bname = NULL;
> + const char *dname = NULL;
> + int dirfd = -1;
> + char *tempname = NULL; /* Name used instead of fnew for output. */
> +
> /* Buffer for (one) new section name if necessary. */
> char *snamebuf = NULL;
>
> @@ -370,7 +382,7 @@ process_file (const char *fname)
> fd = open (fname, O_RDONLY);
> if (fd < 0)
> {
> - error (0, errno, "Couldn't open %s\n", fname);
> + error (0, errno, "Couldn't open %s", fname);
> goto cleanup;
> }
>
> @@ -611,26 +623,68 @@ process_file (const char *fname)
> scnnames = xcalloc (shnum, sizeof (char *));
> }
>
> - /* Create a new (temporary) ELF file for the result. */
> - if (foutput == NULL)
> - {
> - size_t fname_len = strlen (fname);
> - fnew = xmalloc (fname_len + sizeof (".XXXXXX"));
> - strcpy (mempcpy (fnew, fname, fname_len), ".XXXXXX");
> - fdnew = mkstemp (fnew);
> - }
> - else
> + /* Now deal with the output. If we can (exclusively) open the
> + output file directly, we can just use that. */
> + fnew = xstrdup (foutput == NULL ? fname : foutput);
> + fdnew = open (fnew, O_WRONLY | O_CREAT | O_EXCL | O_TRUNC,
> + st.st_mode & ALLPERMS);
I believe O_EXCL | O_CREAT makes O_TRUNC redundant. This open only
succeeds if a new file is created, in which case there is nothing to
truncate.
> +
> + /* If we cannot open the output exclusively for writing directly
> + (because it already exists), e.g. it might be the current input
> + file, then we want to write to a temporary file first and then
> + (atomically) replace it. This is slightly tricky. To make sure
> + the replacement (rename) is atomic the temp file and final file
> + need to be in the same directory. We use realpath to make sure
> + we end up in the actual directory that the output is in if it was
> + a symlink. To make sure the directory path doesn't change
> + between temp file creation and rename we need to keep a dirfd
> + open. */
> + if (fdnew < 0 && errno == EEXIST)
> {
> - fnew = xstrdup (foutput);
> - fdnew = open (fnew, O_WRONLY | O_CREAT, st.st_mode & ALLPERMS);
> + /* OK, it already existed (or was a symlink). Try again, but now
> + with the resolved path. */
> + free (fnew);
> + fnew = realpath (foutput == NULL ? fname : foutput, NULL);
> + if (fnew == NULL)
> + {
> + error (0, errno, "Couldn't get realpath for %s",
> + foutput == NULL ? fname : foutput);
> + goto cleanup;
> + }
> +
> + /* Split up the path into the dir and base parts. */
> + dirc = xstrdup (fnew);
> + dname = dirname (dirc);
> + basec = xstrdup (fnew);
> + bname = xbasename (basec);
> +
> + /* Pin the directory. */
> + dirfd = open (dname, O_RDONLY | O_DIRECTORY);
> + if (dirfd < 0)
> + {
> + error (0, errno, "Couldn't open output dir %s", dname);
> + goto cleanup;
> + }
> +
> + /* Create a temp file inside the output dir. This could
> + possibly done with O_TMPFILE and then using /proc/self/fd to
> + rename. But it is not clear how portable that is. */
> + size_t bname_len = strlen (bname);
> + tempname = xmalloc (bname_len + sizeof (".XXXXXX"));
> + sprintf (tempname, "%s.XXXXXX", bname);
> + fdnew = xmkstempat (dirfd, tempname);
> }
>
> if (fdnew < 0)
> {
> - error (0, errno, "Couldn't create output file %s", fnew);
> - /* Since we didn't create it we don't want to try to unlink it. */
> - free (fnew);
> - fnew = NULL;
> + error (0, errno, "Couldn't create output file %s",
> + tempname == NULL ? fnew : tempname);
> + /* Since we couldn't create it we don't want to try to unlink it. */
> + if (tempname != NULL)
> + {
> + free (tempname);
> + tempname = NULL;
> + }
> goto cleanup;
> }
>
> @@ -1352,22 +1406,28 @@ process_file (const char *fname)
> or fchown may clear them. */
> if (fchown (fdnew, st.st_uid, st.st_gid) != 0)
> if (verbose >= 0)
> - error (0, errno, "Couldn't fchown %s", fnew);
> + error (0, errno, "Couldn't fchown %s",
> + tempname == NULL ? fnew : tempname);
> if (fchmod (fdnew, st.st_mode & ALLPERMS) != 0)
> if (verbose >= 0)
> - error (0, errno, "Couldn't fchmod %s", fnew);
> + error (0, errno, "Couldn't fchmod %s",
> + tempname == NULL ? fnew : tempname);
>
> /* Finally replace the old file with the new file. */
> - if (foutput == NULL)
> - if (rename (fnew, fname) != 0)
> - {
> - error (0, errno, "Couldn't rename %s to %s", fnew, fname);
> - goto cleanup;
> - }
> -
> - /* We are finally done with the new file, don't unlink it now. */
> - free (fnew);
> - fnew = NULL;
> + if (tempname != NULL)
> + {
> + fsync (fdnew); /* Flush all data and metadata before replacing file.
> */
> + if (renameat (dirfd, tempname, dirfd, bname) != 0)
> + {
> + error (0, errno, "Couldn't rename %s to %s", tempname, bname);
> + goto cleanup;
> + }
> + /* We are finally done with the temp file, don't unlink it now. */
> + free (tempname);
> + tempname = NULL;
> + }
> +
> + /* Success! Now just cleanup. */
> res = 0;
>
> cleanup:
> @@ -1377,13 +1437,20 @@ cleanup:
> elf_end (elfnew);
> close (fdnew);
>
> - if (fnew != NULL)
> + free (fnew);
> +
> + if (tempname != NULL)
> {
> - unlink (fnew);
> - free (fnew);
> - fnew = NULL;
> + unlinkat (dirfd, tempname, 0);
On Tue, Jun 2, 2026 at 6:30 AM Mark Wielaard <[email protected]> wrote:
> On Mon, 2026-06-01 at 20:23 -0400, Aaron Merey wrote:
> > We don't ever unlink fnew, so if we created it during open and later there
> > is an error, we will leave an empty or partial file around. We could add a
> > flag indicating whether we created fnew to determine whether or not to
> > unlink
> > it during error cleanup.
>
> The "flag" is tempname. If it is set then we need to unlink tempname.
> fnew is always set, but is the name/path of the output file in case we
> can open it directly/exclusively. If not we split fnew (realpath) and
> create tempname to write to (temporarily). There were a couple of
> places where fnew was used were we should have used tempname. I have
> added a comment to fnew because I obviously was confused myself.
In both v1 and v2 tempname tracks if we made a temp file but not whether
we created a new file by O_EXCL | O_CREAT. So for cases like `-o NEWFILE`
(where NEWFILE didn't already exist), an empty/partial file will still
be left on disk if there's an error after the file is created.
Aaron
> + free (tempname);
> }
>
> + if (dirfd >= 0)
> + close (dirfd);
> +
> + free (dirc);
> + free (basec);
> +
> free (snamebuf);
> if (names != NULL)
> {
> diff --git a/tests/Makefile.am b/tests/Makefile.am
> index 8ae7d126636e..fb0690edaf57 100644
> --- a/tests/Makefile.am
> +++ b/tests/Makefile.am
> @@ -206,7 +206,7 @@ TESTS = run-arextract.sh run-arsymtest.sh run-ar.sh
> newfile test-nlist \
> elfshphehdr run-lfs-symbols.sh run-dwelfgnucompressed.sh \
> run-elfgetchdr.sh \
> run-elfgetzdata.sh run-elfputzdata.sh run-zstrptr.sh \
> - run-compress-test.sh \
> + run-compress-test.sh run-compress-test-out.sh \
> run-readelf-zdebug.sh run-readelf-zdebug-rel.sh \
> emptyfile vendorelf fillfile dwarf_default_lower_bound \
> dwarf_language_lower_bound \
> @@ -575,7 +575,7 @@ EXTRA_DIST = run-arextract.sh run-arsymtest.sh run-ar.sh \
> testfile-zgabi32.bz2 testfile-zgabi64.bz2 \
> testfile-zgabi32be.bz2 testfile-zgabi64be.bz2 \
> run-elfgetchdr.sh run-elfgetzdata.sh run-elfputzdata.sh \
> - run-zstrptr.sh run-compress-test.sh \
> + run-zstrptr.sh run-compress-test.sh run-compress-test-out.sh \
> run-disasm-bpf.sh \
> testfile-bpf-dis1.expect.bz2 testfile-bpf-dis1.o.bz2 \
> run-reloc-bpf.sh \
> diff --git a/tests/run-compress-test-out.sh b/tests/run-compress-test-out.sh
> new file mode 100755
> index 000000000000..8e51e1c1ac7f
> --- /dev/null
> +++ b/tests/run-compress-test-out.sh
> @@ -0,0 +1,74 @@
> +#! /bin/sh
> +# Copyright (C) 2026 Mark J. Wielaard <[email protected]>
> +# This file is part of elfutils.
> +#
> +# This file 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.
> +#
> +# elfutils 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 <http://www.gnu.org/licenses/>.
> +
> +. $srcdir/test-subr.sh
> +
> +testrun_elfcompress_out()
> +{
> + testfile="$1"
> + testfiles ${testfile}
> +
> + # Direct compress
> + testrun ${abs_top_builddir}/src/elfcompress -v -t zlib-gnu ${testfile}
> + testrun ${abs_top_builddir}/src/elflint --gnu-ld ${testfile}
> +
> + # Decompress with -o being the input file
> + testrun ${abs_top_builddir}/src/elfcompress -v -t none -o ${testfile} \
> + ${testfile}
> + testrun ${abs_top_builddir}/src/elflint --gnu-ld ${testfile}
> +
> + # Compress with -o being an existing file
> + tempfiles ${testfile}.tmp
> + touch ${testfile}.tmp
> + testrun ${abs_top_builddir}/src/elfcompress -v -t zlib -o ${testfile}.tmp \
> + ${testfile}
> + testrun ${abs_top_builddir}/src/elflint --gnu-ld ${testfile}.tmp
> +
> + # Decompress with -o being a symlink to the input
> + tempfiles ${testfile}.link
> + ln -s ${testfile}.tmp ${testfile}.link
> + testrun ${abs_top_builddir}/src/elfcompress -v -t none -o ${testfile}.link
> \
> + ${testfile}.tmp
> + testrun ${abs_top_builddir}/src/elflint --gnu-ld ${testfile}.link
> +
> + # Compress with input being a symlink to a file in a nested directory
> + tempfiles ${testfile}.deep
> + mkdir deep
> + cp ${testfile} deep/
> + ln -s deep/${testfile} ${testfile}.deep
> + testrun ${abs_top_builddir}/src/elfcompress -v -t zlib ${testfile}.deep
> + testrun ${abs_top_builddir}/src/elflint --gnu-ld deep/${testfile}
> + rm deep/${testfile}
> + rmdir deep
> +}
> +
> +# The actual test file shouldn't matter, but just use a couple of
> +# different ones.
> +
> +# Random ELF32 testfile
> +testrun_elfcompress_out testfile4
> +
> +# Random ELF64 testfile
> +testrun_elfcompress_out testfile12
> +
> +# Random ELF64BE testfile
> +testrun_elfcompress_out testfileppc64
> +
> +# Random ELF32BE testfile
> +testrun_elfcompress_out testfileppc32
> +
> +exit 0
> --
> 2.54.0
>