stat -c %F reports the wrong file type under a non-English locale

GNU stat translates its %F file-type field, so code that matches the English words regular file or directory misreads every path once the locale is, say, Swedish. Pinning LC_ALL=C on the stat call fixes it.

Errors or symptoms
stat -c '%F' prints normal fil or katalog instead of regular file or directorya remote check reports a regular file as not a file, or a directory as not a directory
Affects
GNU coreutils · Rust
Checked
with GNU coreutils 9.11
Depth
one layer down
Tags
locale · stat · coreutils · ssh · rust

What went wrong

My Linux Hardener project reads a system in two ways, a local executor for the machine it runs on and an SSH executor for a remote host. The SSH side learns what a path is from one stat call:

fn metadata_probe_command(path: &Path) -> String {
    let escaped = shell_escape(&path.display().to_string());
    format!(
        "test -e {escaped} && echo E || echo N; stat -c '%F %a %s %u %g' {escaped} 2>/dev/null || true"
    )
}

The parser then matched the %F field against English words:

let is_dir = file_type.contains("directory");
is_file: file_type.contains("regular"),

%F is translated. Under sv_SE.utf8, stat -c '%F' /etc/passwd prints normal fil, and on /etc it prints katalog. Neither contains regular or directory, so on a remote host set to Swedish the parser recognised neither files nor directories.

Why nobody noticed

No error showed up anywhere. Checkpoint capture only records a path when is_file is true, so on a non-English remote host it recorded nothing for a file it had found. A directory got the file type bit instead of the directory one. The permission bits kept the recorded mode non-zero either way, so rollback's marker for "this path was absent" was never reached. The bug left gaps in the captures, but it never made rollback delete a path.

The local executor never had the problem. It reads Rust's std::fs::Metadata, which carries no locale, so the two executors only disagreed on a non-English remote host.

The fix

Pin the locale on the stat call alone:

"test -e {escaped} && echo E || echo N; LC_ALL=C stat -c '%F %a %s %u %g' {escaped} 2>/dev/null || true"

LC_ALL overrides every other locale variable, LANG included, so C forces stat back to its untranslated output whatever the remote account has set. It only needs to sit on stat. Every other remote probe parses paths, numbers, exit codes or strings the command chose itself, and none of those get translated.

Check it worked

The same measurement on GNU coreutils 9.11, with sv_SE.utf8 installed:

$ LC_ALL=C stat -c '%F' /etc/passwd
regular file
$ LC_ALL=C stat -c '%F' /etc
directory
$ LC_ALL=sv_SE.utf8 stat -c '%F' /etc/passwd
normal fil
$ LC_ALL=sv_SE.utf8 stat -c '%F' /etc
katalog

The project's test for this runs the real probe through a real shell with a non-English LC_ALL set, over a file and a directory. It first looks through the installed locales for one that changes stat's answer. A hard-coded locale that isn't installed would leave stat answering in English, and the test would pass without testing anything.

Before parsing any command's output, I think it is worth asking whether that output can be translated. Numbers and paths are safe. A word such as a file type is not, unless the command runs under a pinned locale.

Back to all guides