Possible failure of 'rename(2) old new' then 'stat(2) new' sequence?

Viewed 137

I am investigating a customer issue using a legacy software of my company with poor evidences (no error trace). While reverse engineering the code I have spotted a C fragment that I am suspicious about, possibly at the root cause.

While I can work around the issue, I still have trouble explaining the why.

To illustrate my suspicion I have built the below C program. This program stupidly moves files between two folders back and forth and exit 1 if either rename(2) or stat(2) fails.

This program runs on Linux (RHEL 7), file system is xfs in my case, ext4 in the case of my customer.

Shall I also add that there was no system crash nor power outage nor out of space issue on the file system when the issue occurred. Also, the race-condition (commented below) was not the reason of the issue (as far as I can tell).

So far, when running this program I haven't been able to see any failure. This doesn't mean it can't happen, does it ?

#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <errno.h>
#include <sys/types.h>
#include <dirent.h>
#include <sys/stat.h>

void movefiles(char *src, char *dst) {
  DIR* fd = opendir(src);
  struct dirent* dp;
  int res = 1;
  while ((dp = readdir(fd))) {
    if(strcmp(dp->d_name, ".") && strcmp(dp->d_name, "..")) {
      char fsrc[256]; /* may overflow, out of topic though */
      char fdst[256];
      fsrc[0] = '\0';
      fdst[0] = '\0';
      strcat(fsrc, src); strcat(fsrc, "/"); strcat(fsrc, dp->d_name);
      strcat(fdst, dst); strcat(fdst, "/"); strcat(fdst, dp->d_name);
      if(rename(fsrc, fdst) != 0) {
        perror("rename failed");
        exit(1);
      }
      struct stat sb;
      if(stat(fdst, &sb) != 0) { /* file name race condition */
        perror("stat failed"); /* can this happen ? */
        exit(1);
      }
    }
  }
  closedir(fd);
}

int main() {
  /* assume random content in either directories */
  char *src = "tmp/dir1";
  char *dst = "tmp/dir2";
  while (1) {
    movefiles(src, dst);
    movefiles(dst, src);
  }
  return 0;    
}

I understand that file systems are complex sub systems that can fail / behave differently, therefore delivering the atomic promise of rename is hard.

The specific question I have is commented out in the code : can rename report success followed by stat reporting failure ?

Is it possible that some file system implement immediate return of rename while operation is still in progress asynchronously, making stat possibly/rarely reporting a failure?

When searching around I can see people talking of the open-write-close-rename problem and fsync recommendation, but rename-stat looks to me being different and can't find any link to validate/invalidate my suspicion.

Thanks for your lights.

1 Answers

There are many ways the code may fail, if another process (including shell used by a human) interferes with the files or their directories at the same time.

If we ignore all the common reasons (interference from another process, or the entire filesystem being unmounted), there is one detail in XFS that can affect this - assuming it is a 32-bit binary: the inode number for the XFS file could exceed 232-1 = 4,294,967,295 causing fs/stat.c:cp_old_stat() or fs/stat.c:cp_new_stat() to fail with EOVERFLOW.

To verify, check the original binary with file . If it is 64-bit, the inode number cannot be the reason. If it is 32-bit, then the inode number is a possible culprit. To fix, recompile the binary to 64-bit.


The code indeed is suspect, though.

The problem is relying on readdir() while modifying the contents of said directory. Because of how newer filesystems work, there is no guarantee of readdir() seeing only new files, and not seeing files already moved.

The proper approach is to obtain the full list of files first – you can use e.g. scandir(), glob(), or nftw(); scandir() or glob() if you want to process the files as a set, nftw() if you move each file in the callback function. You see, both glob() and nftw() are supposed to handle the "directory may change during walk" correctly, even if the underlying readdir() does not.

(There is also the fts family of traversing filesystem trees, but on Linux, the glibc implementation prior to 2.23 (released in February, 2016) was not safe with 64-bit file offsets.)

Consider this implementation:

#define _POSIX_C_SOURCE  200809L
#define _GNU_SOURCE
#define _ATFILE_SOURCE
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <ftw.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>

/* Because file_mover() does not descend into subdirectories, it needs a
   very small number of descriptors; four should always suffice. */
#define  FILE_MOVER_FDS  4

/* Per-thread state for file_mover(). Each thread sees separate variables! */
static __thread int   file_mover_destfd = -1;
static __thread int   file_mover_errno = 0;
static __thread long  file_mover_count = 0;

static int file_mover(const char *srcpath, const struct stat *srcinfo, int typeflag, struct FTW *details)
{
    /* Initial argument (srcpath)? */
    if (details->level == 0) {
        /* If it specifies a directory, move all its files. */
        if (typeflag == FTW_D)
            return FTW_CONTINUE;
        /* Otherwise fail. */
        file_mover_errno = ENODEV;
        return FTW_STOP;
    }

    /* Skip directories. */
    if (typeflag == FTW_D || typeflag == FTW_DNR || typeflag == FTW_DP)
        return FTW_SKIP_SUBTREE;

    /* Ignore all but ordinary files. */
    if (typeflag != FTW_F)
        return FTW_CONTINUE;

    /* Obtain file name part, and its length. */
    const char   *name = srcpath + details->base;
    const size_t  namelen = strlen(name);

    /* Zero-length files should never occur; detect them anyway. */
    if (namelen < 1) {
        file_mover_errno = ENOENT;
        return FTW_STOP;
    }

    /* Detect if destination descriptor is invalid. */
    if (file_mover_destfd == -1) {
        file_mover_errno = EBADF;
        return FTW_STOP;
    }

    /* Source path is either absolute, or relative to current working directory. */
    if (renameat(AT_FDCWD, srcpath, file_mover_destfd, name) == -1) {
        file_mover_errno = errno;
        return FTW_STOP;
    }

    /* Verify the target file exists and matches the original file. */
    {
        struct stat  destinfo;
        if (fstatat(file_mover_destfd, name, &destinfo, 0) == -1) {
            file_mover_errno = errno;
            return FTW_STOP;
        }

        /* Size and mode matches? */
        if (destinfo.st_size != srcinfo->st_size || destinfo.st_mode != srcinfo->st_mode) {
            file_mover_errno = EIO;
            return FTW_STOP;
        }
    }

    /* Add to the running count. */
    file_mover_count++;

    return FTW_CONTINUE;
}

/* Move files from directory srcdir to directory destdir.
   Returns -1 if an error occurs with errno set to indicate the error,
   and the number of files moved otherwise.
*/
static long move_files(const char *srcdir, const char *destdir)
{
    /* Paranoid sanity checks. */
    if (!srcdir || !destdir) {
        errno = EINVAL;
        return -1;
    }

    /* Open the destination directory as a handle. */
    do {
        file_mover_destfd = open(destdir, O_PATH | O_CLOEXEC);
    } while (file_mover_destfd == -1 && errno == EINTR);
    if (file_mover_destfd == -1) {
        return -1;
    }

    file_mover_errno = 0;
    file_mover_count = 0;

    if (nftw(srcdir, file_mover, FILE_MOVER_FDS, FTW_ACTIONRETVAL) != 0) {
        /* Failed. Return reason in errno. */
        close(file_mover_destfd);
        file_mover_destfd = -1;
        errno = file_mover_errno;
        return -1;
    }

    if (close(file_mover_destfd) == -1) {
        file_mover_destfd = -1;
        /* errno set by close() */
        return -1;
    }
    file_mover_destfd = -1;

    /* Success. (Note: technically, the count could overflow on 32-bit arches.) */
    return file_mover_count;
}


int main(int argc, char *argv[])
{
    long  n;

    if (argc != 3) {
        const char *cmd = (argc > 0 && argv[0] && argv[0][0]) ? argv[0] : "(this)";
        fprintf(stderr, "\n");
        fprintf(stderr, "Usage: %s [ -h | --help]\n", cmd);
        fprintf(stderr, "       %s SOURCE-DIRECTORY DESTINATION-DIRECTORY\n", cmd);
        fprintf(stderr, "\n");
        if (argc == 2 && (!strcmp(argv[1], "-h") || !strcmp(argv[1], "--help")))
            return EXIT_SUCCESS;
        else
            return EXIT_FAILURE;
    }

    n = move_files(argv[1], argv[2]);
    if (n < 0) {
        fprintf(stderr, "Failed: %s (%d)\n", strerror(errno), errno);
        return EXIT_FAILURE;
    } else
    if (!n) {
        fprintf(stderr, "No files to move.\n");
        return EXIT_SUCCESS;
    }

    if (n == 1)
        printf("1 file moved.\n");
    else
        printf("%ld files moved.\n", n);

    return EXIT_SUCCESS;
}

To run it in an endless loop, use

int main(int argc, char *argv[])
{
    long  n, expected = 0;

    if (argc != 3) {
        const char *cmd = (argc > 0 && argv[0] && argv[0][0]) ? argv[0] : "(this)";
        fprintf(stderr, "\n");
        fprintf(stderr, "Usage: %s [ -h | --help]\n", cmd);
        fprintf(stderr, "       %s SOURCE-DIRECTORY DESTINATION-DIRECTORY\n", cmd);
        fprintf(stderr, "\n");
        if (argc == 2 && (!strcmp(argv[1], "-h") || !strcmp(argv[1], "--help")))
            return EXIT_SUCCESS;
        else
            return EXIT_FAILURE;
    }

    while(1) {

        n = move_files(argv[1], argv[2]);
        if (n < 0) {
            fprintf(stderr, "Failed: %s (%d)\n", strerror(errno), errno);
            return EXIT_FAILURE;
        } else
        if (!n) {
            fprintf(stderr, "No files to move.\n");
            return EXIT_SUCCESS;
        }

        if (!expected) {
            expected = n;
            fprintf(stderr, "Moving %ld files around.\n", n);
        } else
        if (n != expected) {
            fprintf(stderr, "Moved %ld of %ld files!\n", n, expected);
            return EXIT_FAILURE;
        }

        n = move_files(argv[2], argv[1]);
        if (n < 0) {
            fprintf(stderr, "Failed: %s (%d)\n", strerror(errno), errno);
            return EXIT_FAILURE;
        } else
        if (n != expected) {
            fprintf(stderr, "Moved only %ld of %ld files!\n", n, expected);
            return EXIT_FAILURE;
        }
    }

    return EXIT_SUCCESS;
}

which will detect if you e.g. delete one of the files during its testing.

Related