32.3 filesystem, path és OS-határ

Ez az alfejezet a C++20 std::filesystem szerződését a POSIX/Linux szemantikához viszonyítja. A laborprofil: Linux, x86-64, GCC 11+ vagy Clang 14+, -std=c++20, libstdc++ vagy libc++. A <filesystem> már C++17-ben bekerült a standardba, de a C++20-as szöveg több részletet pontosít (például a u8string() char8_t-re váltását), ezért a példák C++20-at használnak. A kimenet szándékosan std::cout-tal történik; a <print> és a <format> részleteit a charconv, format és C++23 print (32.2) tárgyalja.

A fejezet központi állítása: a std::filesystem kényelmes, hordozható névfeloldást és hibakód-leképezést ad, de nem emeli be az operációs rendszer atomitási, tartóssági és biztonsági szerződését. Aki ezt összekeveri, az a fájlbejáró laborban látványos, de nehezen reprodukálható hibákat kap.

32.3.1 A path típus: natív reprezentáció és generikus alak

A std::filesystem::path értékszemantikájú típus, amely egy útvonalat nem karakterláncként, hanem komponensek sorozataként tárol. A value_type a platform natív karaktertípusa (POSIX-on char, Windowson wchar_t), a string_type pedig a hozzá tartozó std::basic_string. A native() a natív, az operációs rendszer API-ja által várt alakot adja vissza, a c_str() ennek null-terminált változatát. A generic_string() és a C++20-as generic_u8string() ugyanezt / elválasztóval, illetve std::u8string-ként adja vissza; ez utóbbi C++17-ben még std::string volt, tehát a két standardverzió között itt inkompatibilitás van.

A path natív kódolása implementáció által meghatározott. POSIX-on a fájlrendszeri név egyszerűen bájtok sorozata, a / és a null bájt kivételével; a kernel nem dekódolja UTF-8-ként, és nem normalizál. A std::filesystem sem végez Unicode-normalizálást (NFC/NFD), nem ellenőrzi az UTF-8 érvényességét, és nem alakít át kódolást. Ha a programod UTF-8-at vár, az konvenció, nem a standard garanciája. A u8string() csak annyit tesz, hogy a natív reprezentációt char8_t-es sztringgé alakítja; ha a natív bájtok nem UTF-8-at kódolnak, az eredmény nem lesz érvényes UTF-8. Linuxon a UTF-8 gyakori konvenció, de nem nyelvi garancia.

A lexikális műveletek (filename(), stem(), extension(), parent_path(), lexically_normal(), lexically_relative()) tisztán szövegesek, nem érik el a fájlrendszert. A lexically_normal() a . és .. komponenseket szövegesen oldja fel: "/tmp/link/../x" eredménye "/tmp/x", még akkor is, ha a link symlink, és a valóságos feloldás más lenne. A canonical() ezzel szemben feloldja a symlinkeket, abszolút utat ad, és megköveteli, hogy a cél létezzen; a weakly_canonical() a létező prefixet oldja fel, a maradékot lexikálisan kezeli. Az equivalent(p1, p2) nem lexikális: a status()-ok alapján dönti el, hogy két név ugyanarra az objektumra mutat-e, tehát symlinket követ, és versenyérzékeny.

A file_status két dolgot hordoz: a type()-ot és a permissions()-t. A file_type enumeráció értékei: none, not_found, regular, directory, symlink, block, character, fifo, socket, unknown. A status(p) követi a symlinket (a POSIX stat mintájára), a symlink_status(p) nem (a lstat mintájára). Ha a symlink célja nem létezik, a status(p) file_type::not_found-ot ad; ez nem feltétlenül hiba, az error_code üres maradhat. Az exists(status) a not_found-ra hamis.

A klasszikus hiba a „check, then act” minta:

namespace fs = std::filesystem;
fs::path p = "/var/lib/app/config.toml";

if (fs::exists(p)) {          // 1. lépés: stat/lstat
    std::ifstream in{p};      // 2. lépés: open
    // ...
}

A két lépés nem atomi. A kettő között egy másik folyamat törölheti, kicserélheti vagy symlinkre cserélheti a fájlt; a program így más objektumot nyit meg, mint amit ellenőrzött. Ez a TOCTOU (time-of-check to time-of-use) rés. A std::filesystem nem kínál atomi „check and open” műveletet. POSIX-on a megoldás az open() O_NOFOLLOW (és O_CLOEXEC) jelzővel, majd fstat() a megnyitott file descriptoron; így a névfeloldás egyszer történik, és az ellenőrzés ugyanarra az objektumra vonatkozik, amit később olvasol. A std::ifstream nem ad hordozhatóan hozzáférést a mögöttes fd-hez, ezért ehhez POSIX réteg kell.

Hasonló csapda a create_directory(): visszatérési értéke true, ha létrehozta, és false, ha a könyvtár már létezett. A false nem hiba. Aki a visszatérési értéket elhagyja, azt hiheti, hogy ő hozta létre a könyvtárat, holott egy másik folyamat vagy egy korábbi futás tette meg.

32.3.3 Bejárás: directory_iterator és rekurzió

A directory_iterator(dir) a dir közvetlen bejegyzéseit adja; a sorrend nem specifikált. A directory_entry egy bejegyzést reprezentál, és a status(), symlink_status(), file_size(), is_regular_file() tagfüggvényei error_code& overloaddal is elérhetők. A C++20-as szöveg szerint a directory_entry cache-elheti az attribútumokat; a részletek implementációfüggők, ezért a hordozható minta az, hogy a szükséges attribútumot explicit módon, error_code-dal kéred le, és nem feltételezed, hogy a cache friss. A refresh() a cache-elt értéket frissíti.

A recursive_directory_iterator rekurzívan járja be a fát. Alapértelmezésben nem követi a symlinkeket; a follow_directory_symlink opció kapcsolja be a követést. Alapból kivételt dob a jogosultsági hibára; a skip_permission_denied opció kihagyja az ilyen bejegyzéseket. A depth() az aktuális mélységet adja, a disable_recursion_pending() pedig az aktuális bejegyzés alatti rekurziót tiltja le.

A symlink-policy a legfontosabb döntés. Ha bekapcsolod a follow_directory_symlink-et, egy a -> b, b -> a symlink-pár végtelen rekurziót okozhat; a standard nem detektál ciklust. A védelem vagy egy mélységkorlát, vagy a canonical() szerinti halmaz, amelyben a már látott célokat tárolod. Utóbbi minden híváskor feloldja a symlinkeket, hibát ad, ha a cél nem létezik, és maga is versenyérzékeny; ezért a bejárás közbeni használata drága. A legegyszerűbb és legbiztonságosabb alapértelmezés a symlinkek nem követése, és a követés csak külön kapcsolóval, mélységkorláttal.

Az iteráció közbeni módosítás veszélyes: a mögöttes könyvtár módosítása nem specifikált viselkedést ad. Ne törölj és ne hozz létre bejegyzéseket a bejárás alatt; ha kell, gyűjtsd össze a neveket, és utána módosíts.

32.3.4 Kivétel vagy error_code?

A legtöbb std::filesystem műveletnek két változata van: (path) és (path, error_code&). A (path) változat std::filesystem::filesystem_error-t dob, amely a std::system_error leszármazottja, és a path1(), path2(), code() tagokkal rendelkezik. Az (path, error_code&) változat az operációsrendszer-szintű hibát ec-ben jelzi, siker esetén pedig törli az ec-t. Ez azonban nem jelent noexcept-ot: a szabvány szerint ([C++20] [fs.err.report]) a tárfoglalási hibát ezek az overloadok is std::bad_alloc kivétellel jelzik, és csak egy részük deklarált noexcept (a konkrét overload szignatúráját kell megnézni). A saját kódunk — például a push_back egy gyűjtővektorba — szintén dobhat. Ezért ha a program kontrollált hibás kilépést ígér, a CLI szélén kell egy kivételhatár.

A választás indoklása:

32.3.5 Ami nincs benne: atomitás és tartósság

A std::filesystem nem ad tranzakciót, nem ad fsync-et, és nem garantálja a rename atomitását. A POSIX rename(2) ugyanazon fájlrendszeren belül atomi, de ez a platform szemantikája, nem a C++ standard ígérete. A copy_file nem atomi; a remove_all nem tranzakcionális, részleges törlés lehetséges. A tartós íráshoz fsync() kell a fájlra és a könyvtárra; ehhez POSIX open/fsync szükséges, mert a std::filesystem erre nem ad API-t. A current_path() a folyamat globális aktuális könyvtárát kezeli, tehát több szálon versenyhelyzet forrása lehet.

Ez DevOps szempontból a legfontosabb: a kényelmes API nem helyettesíti az operációs rendszer atomitási és tartóssági szerződését. A Fájlrendszer-műveletek, mmap és tartósság (12.2) részben tárgyalt rename + fsync minta itt is érvényes; a std::filesystem csak a névfeloldást és a hibakódokat adja hozzá.

32.3.6 Kidolgozott példa: fájlbejáró labor

A következő program összegyűjti a reguláris fájlokat, összegzi a méretüket, nem követi a symlinkeket, és a jogosultság miatt nem megnyitható könyvtárakat a skip_permission_denied opcióval kihagyja. Az OS-hibákat error_code-dal kezeli, az allokációs és egyéb kivételeket a main kivételhatára fogja meg. A skip_permission_denied csak a könyvtár megnyitásának jogosultsági hibáját (EACCES) nyeli el; minden más léptetési hiba (it.increment(ec)) után a bejárás nem folytatható megbízhatóan, ezért a program megszakítja, az eredményt részlegesnek jelöli, és 3-as kóddal lép ki — az ec törlése nem helyreállítás.

// walk.cpp — C++20, Linux
#include <cstdint>
#include <filesystem>
#include <iostream>
#include <system_error>
#include <vector>

namespace fs = std::filesystem;

struct entry_info {
    fs::path path;
    std::uintmax_t size = 0;
};

static int run(int argc, char** argv) {
    if (argc != 2) {
        std::cerr << "hasznalat: walk KONYVTAR\n";
        return 2;
    }

    fs::path root{argv[1]};
    std::error_code ec;

    fs::file_status root_status = fs::symlink_status(root, ec);
    if (ec) {
        std::cerr << "symlink_status: " << ec.message() << '\n';
        return 1;
    }
    if (!fs::is_directory(root_status)) {
        std::cerr << root << ": nem konyvtar\n";
        return 1;
    }

    const auto opts = fs::directory_options::skip_permission_denied;
    fs::recursive_directory_iterator it{root, opts, ec};
    const fs::recursive_directory_iterator end{};
    if (ec) {
        std::cerr << "iterator: " << ec.message() << '\n';
        return 1;
    }

    std::vector<entry_info> files;
    std::uintmax_t total = 0;
    bool partial = false;

    while (it != end) {
        const fs::directory_entry& de = *it;

        std::error_code sec;
        fs::file_status st = de.symlink_status(sec);
        if (sec) {
            std::cerr << "symlink_status(" << de.path() << "): "
                      << sec.message() << " (kihagyva)\n";
            it.increment(ec);
            if (ec) { partial = true; std::cerr << "increment: " << ec.message() << '\n'; break; }
            continue;
        }

        if (fs::is_symlink(st)) {
            std::error_code tec;
            fs::file_status tgt = de.status(tec);
            if (tec) {
                std::cerr << "status(" << de.path() << "): "
                          << tec.message() << '\n';
            } else if (tgt.type() == fs::file_type::not_found) {
                std::cerr << "dangling symlink: " << de.path() << '\n';
            }
        } else if (fs::is_regular_file(st)) {
            std::error_code fec;
            std::uintmax_t sz = de.file_size(fec);
            if (fec) {
                std::cerr << "file_size(" << de.path() << "): "
                          << fec.message() << '\n';
            } else {
                total += sz;
                files.push_back({de.path(), sz});
            }
        }

        it.increment(ec);
        if (ec) {
            // A hibás increment után az iterátor állapota nem folytatható
            // megbízhatóan: a bejárást lezárjuk, és az eredményt részlegesnek jelöljük.
            partial = true;
            std::cerr << "increment: " << ec.message() << " (bejaras megszakadt)\n";
            break;
        }
    }

    for (const auto& f : files) {
        std::cout << f.size << '\t' << f.path.generic_string() << '\n';
    }
    std::cout << (partial ? "# RESZLEGES: " : "# osszesen ") << files.size()
              << " fajl, " << total << " bajt\n";
    return partial ? 3 : 0;
}

int main(int argc, char** argv) {
    // Az error_code-os overloadok az OS-hibát jelzik ec-ben, de az allokációs
    // hibát (std::bad_alloc) továbbra is kivétellel; a CLI szélén ezt kezeljük.
    try {
        return run(argc, argv);
    } catch (const std::exception& e) {
        std::cerr << "walk: " << e.what() << '\n';
        return 1;
    }
}

Fordítás és futtatás a laborban:

g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 walk.cpp -o walk
./walk /usr/include | tail -n 5

GCC 8 és korábbi verziók külön -lstdc++fs linkelést igényeltek; GCC 9+ és a korszerű libc++ nem. Ez verziófüggő részlet, ezért a laborprofilban rögzíteni kell. Buildjegyzőkönyv (lab-gcc profil). A program figyelmeztetés nélkül fordult. Egy három fájlt és egy chmod 000 könyvtárat tartalmazó fán a kimenet # osszesen 3 fajl, 12 bajt, 0-s kóddal (a jogosultság nélküli könyvtárat csendben kihagyta). A léptetési hibát egy tesztbuildben injektáltuk: a bejárás egy alkönyvtárat a belépés előtt törölt. Erre a program increment: No such file or directory (bejaras megszakadt) üzenetet, # RESZLEGES: ... összesítést és 3-as kilépési kódot adott. A javítás előtti, ec.clear()-rel „továbblépő” változat ugyanerre # osszesen 0 fajl, 0 bajt kimenettel és 0-s kóddal, teljes eredményként állt le, mert a hibás léptetés után az iterátor a végére ugrott.

32.3.7 Tipikus hibák és javításuk

Hiba Következmény Javítás
exists() után open() TOCTOU, más objektum nyílik meg open(O_NOFOLLOW) + fstat
create_directory visszatérési értékének elhagyása azt hiszed, te hoztad létre bool created = ... és kezelés
follow_directory_symlink ciklusdetektálás nélkül végtelen rekurzió depth() korlát vagy disable_recursion_pending()
Cache-elt directory_entry státusz elavult információ explicit status(ec) / refresh()
p.string() UTF-8-ként kezelése hibás kódolás native() / u8string() tudatos használata
remove_all tranzakcionálisnak feltételezése részleges törlés idempotens újrafuttatás, naplózás
ec ellenőrzésének elhagyása csendes hiba minden ec ellenőrzése

32.3.8 Feladatok

1. feladat (magyarázó és kódolási). Az alábbi kódrészlet TOCTOU-t tartalmaz. Nevezd meg a versenyt, és írj POSIX-szintű javítást, amely a megnyitott fd-en ellenőriz, és nem követi a symlinket.

fs::path p = "/var/lib/app/config.toml";
if (fs::exists(p)) {
    std::ifstream in{p};
    // ...
}

2. feladat (kódolási). Írj du-szerű függvényt, amely összegzi egy fa reguláris fájljainak méretét. Alapból ne kövesse a symlinkeket, hagyja ki a jogosultsági hibákat, és opcionális follow módban alkalmazzon mélységkorlátot. Indokold, miért nem detektál ciklust a standard iterator.

3. feladat (diagnosztikai). Egy bejáró lassú egy hálózati fájlrendszeren. Sorolj fel három lehetséges okot, és mindegyikhez adj egy ellenőrzési módot. Ne állítsd, hogy mértél; fogalmazz hipotézisként.

32.3.9 Megoldások

1. megoldás. A verseny az exists() és az ifstream megnyitása között van: a névfeloldás kétszer történik, és a kettő között a fájl kicserélhető. A javítás:

#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
#include <cerrno>

int fd = ::open(p.c_str(), O_RDONLY | O_CLOEXEC | O_NOFOLLOW);
if (fd < 0) {
    // ENOENT: nincs ilyen; ELOOP: symlink; EACCES: jogosultsag
    return -1;
}
struct stat st{};
if (::fstat(fd, &st) != 0 || !S_ISREG(st.st_mode)) {
    ::close(fd);
    return -1;
}
// innen a fd egy ellenorzott, regularis fajlra mutat

Az indoklás: az open egyszer oldja fel a nevet, az fstat ugyanarra az objektumra vonatkozik, amit az fd azonosít. Az O_NOFOLLOW megakadályozza, hogy a végső komponens symlink legyen; az O_CLOEXEC megakadályozza a fd szivárgását exec esetén. Ellenőrzési feltétel: strace-ben egy openat és egy fstat látszik, nem stat + open pár; symlink esetén ELOOP jön. Megjegyzés: a std::ifstream nem ad hordozhatóan fd-t, ezért itt POSIX réteg kell; C++23-ban sem standard a „no-follow” nyitás a std::fstream-ben.

2. megoldás. A függvény váza:

std::uintmax_t dir_size(const fs::path& root, bool follow, int max_depth,
                        std::error_code& ec) {
    std::uintmax_t total = 0;
    auto opts = fs::directory_options::skip_permission_denied;
    if (follow) opts |= fs::directory_options::follow_directory_symlink;

    fs::recursive_directory_iterator it{root, opts, ec};
    const fs::recursive_directory_iterator end{};
    if (ec) return 0;

    while (it != end) {
        if (follow && it.depth() >= max_depth) {
            it.disable_recursion_pending();
        }
        const fs::directory_entry& de = *it;
        std::error_code sec;
        auto st = de.symlink_status(sec);
        if (!sec && fs::is_regular_file(st)) {
            std::error_code fec;
            auto sz = de.file_size(fec);
            if (!fec) total += sz;
        }
        it.increment(ec);
        if (ec) ec.clear();
    }
    return total;
}

Az indoklás: follow_directory_symlink nélkül a könyvtár-symlinkekbe nem lép be, tehát ciklus sem alakul ki. follow módban a depth() korlát és a disable_recursion_pending() a védelem; a standard nem detektál ciklust, a canonical() szerinti halmaz alternatíva, de drága és versenyérzékeny. Ellenőrzési feltétel: az eredmény egyezik a find DIR -type f -printf '%s\n' | awk '{s+=$1} END{print s}' értékkel, és symlinkes fán nem fut végtelen ideig.

3. megoldás. Lehetséges okok: (a) a directory_entry attribútum-lekérdezése külön stat/lstat syscallt válthat, ha a cache nem tartalmazza, és a hálózati fájlrendszeren a latencia nagy; ellenőrzés: strace -f -c -e trace=newfstatat,stat,lstat,getdents64 ./walk DIR. (b) A file_size újabb stat-ot válthat, tehát a syscall-szám a bejegyzésszámmal nő; ellenőrzés: a syscall-szám összevetése a bejegyzésszámmal. (c) A skip_permission_denied nem csökkenti a syscallokat, csak a kivételt; a jogosultsági hibák továbbra is syscallt igényelnek. Ellenőrzés: a kivételes és a nem kivételes változat syscall-száma. Ezek hipotézisek; a tanuló laborja méri őket.

32.3.10 Összefoglaló

A std::filesystem hordozható névfeloldást, bejárást és hibakód-leképezést ad, de nem ad atomi „check and act” műveletet, nem ad fsync-et, és nem garantálja a rename atomitását. A path natív kódolása implementációfüggő; a generic_string() csak az elválasztót cseréli, nem konvertál. A status követi a symlinket, a symlink_status nem; a kettő közötti különbség és a TOCTOU-rés a biztonsági hibák fő forrása. A throwing és az error_code overload közötti választás a hiba várt gyakoriságán és a kontextuson múlik, nem pusztán a teljesítményen. A bejárásnál a symlink-policy és a mélységkorlát az elsődleges védelmi vonal.