c++unit-testingportabilitytemporary-files

How can I safely and portably create and use a temporary file with C++


I'm writing a library. In one of my unit tests, I want to create a temporary file (possibly using its path rather than its handle), make some library calls involving that file, and delete the file on exit (success, failure and exception - cleanup is important!).

Over 10 years ago, this was asked: How to create a temporary text file in C++? . But - the answers there are either non-portable, or not a proper complete solution (e.g. you get a file descriptor with no obvious way to get the name, so you can't access your file by name (std::tmpfile); you get a name (std::tmpnam) but can't be certain it's not already used; etc.) If that were resolved, I suppose my question would be answered by some RAII wrapper around some of these calls. But - I'm concerned with current reality.

My motivation for this question is a library which must be usable with C++11, so I would prefer a solution relying only on that if possible - but newer versions of the standard are acceptable. Use of popular, non-standard libraries (e.g. Boost) is also acceptable but preferably avoided.


Solution

  • ... but newer versions of the standard are acceptable.

    Here's a version that requires C++23 to open a std::ofstream with the attribute noreplace, which is essentially the same as the Posix's open O_EXCL option. That is, a voluntary restriction to not overwrite a file in case it already exists - and it is an atomic operation. If the OS/filesystem is not capable of doing this atomically, the call should fail.

    With a little tinkering it can be made to work with the std::fopen flag x that was added in C++17, which does the same thing. In that case, try std::fopen(filename, "wx") and if it succeeds, std::fclose the std::FILE and then use the std::ofstream::open call to overwrite it. Any other process using O_EXCL, noreplace or "x" will be blocked by the existing file even though you close it momentarily.


    First, a new ofstream type called oftmpstream that remembers the filename of the file it opened to be able to remove the file when an instance goes out of scope. We only need the move constructor for this to be useful in this context, but you could add a constructor taking a std::filesystem::path too if you'd like.

    template <class CharT, class Traits = std::char_traits<CharT>>
    class basic_oftmpstream : public std::basic_ofstream<CharT, Traits> {
        using base = std::basic_ofstream<CharT, Traits>;
    
    public:
        basic_oftmpstream() = default;
    
        basic_oftmpstream(basic_oftmpstream&& o)
            : base(std::move(o)),
              m_path(std::move(o.m_path)),
              m_created(std::exchange(o.m_created, false)) {}
    
        void open(const std::filesystem::path& filename) {
            cleanup();
            // Open the file for writing in "exclusive" mode. That is,
            // if it already exists, opening it will fail:
            base::open(filename, std::ios::noreplace);
            if ((m_created = this->is_open())) {
                m_path = filename;  // save the filename if it was opened ok
            }
        }
    
        ~basic_oftmpstream() override { cleanup(); }
    
        // get the filename of the file it created:
        const auto& path() const { return m_path; }
    
    private:
        void cleanup() {
            if (m_created) {
                this->close();
                std::filesystem::remove(m_path);
                m_created = false;
            }
        }
        std::filesystem::path m_path;
        bool m_created = false;
    };
    using oftmpstream = basic_oftmpstream<char>;
    using woftmpstream = basic_oftmpstream<wchar_t>;
    

    Then a function to create filenames to try to open, open_temporary_file(). The function is stateful and I've chosen to keep the state, baseidx and basename, thread_local instead of static to not have to use a std::mutex to lock the state when permutating it - but if space is scarce, go for a static and a static std::mutex that you lock until you return.

    template <class CharT = char>
    [[nodiscard]] basic_oftmpstream<CharT> open_temporary_file() {
        static const auto chars = [] {
            // the characters you'd like to include
            auto arr = std::to_array(
                {'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm',
                 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
                 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M',
                 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z'});
            // randomize the order of the characters:
            std::shuffle(arr.begin(), arr.end(),
                         std::mt19937(std::random_device{}()));
            return arr;
        }();
        constexpr auto length_of_basename = 8;
    
        thread_local auto baseidx = [&] {
            // randomize the initial state:
            std::array<uint_least8_t, length_of_basename> rv;
            std::mt19937 prng(std::random_device{}());
            std::uniform_int_distribution<size_t> dist(0, chars.size() - 1);
            std::generate(rv.begin(), rv.end(),
                          [&] { return static_cast<uint_least8_t>(dist(prng)); });
            return rv;
        }();
        // create the basename from the baseidices:
        thread_local auto basename = [&] {
            std::array<char, length_of_basename + 1> rv{};
            std::transform(baseidx.begin(), baseidx.end(), rv.data(),
                           [&](auto idx) { return chars[idx]; });
            return rv;
        }();
    
        namespace fs = std::filesystem;
        auto tmpdir = fs::temp_directory_path();
        oftmpstream ofs;
    
        for (int i = 0; i < 10000; ++i) {  // try a few different filenames
            // generate the next basename:
            size_t idx = length_of_basename;
            do {
                --idx;
                baseidx[idx] = (baseidx[idx] + 1) % chars.size();
                basename[idx] = chars[baseidx[idx]];
            } while (idx && baseidx[idx] == 0);
    
            // ...and create a full path:
            fs::path tmpfile = tmpdir / basename.data();
    
            if (ofs.open(tmpfile); ofs) break;  // success
        }
        return ofs;
    }
    

    Example usage:

    int main() {
        if (auto ofs = open_temporary_file(); ofs) {
            std::cout << "opened " << ofs.path() << " for writing\n";
            ofs << "Hello\nworld\n";
            ofs.close();
            // the file is there until `ofs` goes out of scope:
            other_function(ofs.path());
        }
    }
    

    Demo