From 0b8d39329a25d77bf9b6e44a7897326adf350485 Mon Sep 17 00:00:00 2001 From: Aaron Andersen Date: Fri, 16 Jan 2026 16:41:23 -0500 Subject: [PATCH] tmpfiles: add --clean flag for age-based cleanup Add support for the --clean (-C) flag to remove files and directories older than the age specified in tmpfiles.d configuration entries. The age field (6th column) in tmpfiles.d entries can now be used with 'd', 'D', and 'e' type entries to clean up old files. Supported time suffixes are: s (seconds), m (minutes), h (hours), d (days), w (weeks). Example configuration: d /tmp/cache 0755 root root 10d When run with --clean, files in /tmp/cache older than 10 days will be removed. The directory itself is preserved. Uses a conservative cleanup approach matching systemd-tmpfiles: - Files: kept if ANY of atime, ctime, mtime is recent - Directories: kept if ANY of atime, mtime is recent (ctime excluded because cleanup itself updates directory ctime) A value of "-" or "0" for age disables cleanup for that entry. Note: x/X exclusion patterns are recognized but not yet implemented. --- src/tmpfiles.c | 121 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 115 insertions(+), 6 deletions(-) diff --git a/src/tmpfiles.c b/src/tmpfiles.c index 3a54abde..8e410aaf 100644 --- a/src/tmpfiles.c +++ b/src/tmpfiles.c @@ -34,6 +34,7 @@ #include #include +#include #ifdef _LIBITE_LITE # include #else @@ -46,6 +47,7 @@ int debug; int create_flag = 0; +int clean_flag = 0; int remove_flag = 0; static int is_dir_empty(const char *path) @@ -261,6 +263,82 @@ static void write_arg(FILE *fp, char *arg) fputs(arg, fp); } +/** + * Parse age string and return age in seconds. + * Returns 0 on invalid/empty age ("-" or NULL). + * Examples: "10d" = 864000, "1w" = 604800, "2h" = 7200 + */ +static time_t parse_age(const char *age) +{ + time_t val; + char *end; + + if (!age || !strcmp(age, "-") || !strcmp(age, "0")) + return 0; + + errno = 0; + val = strtoul(age, &end, 10); + if (errno || end == age) + return 0; + + switch (*end) { + case 'w': + val *= 7; + /* fallthrough */ + case 'd': + val *= 24; + /* fallthrough */ + case 'h': + val *= 60; + /* fallthrough */ + case 'm': + val *= 60; + /* fallthrough */ + case 's': + case '\0': + break; + default: + return 0; /* unknown suffix */ + } + + return val; +} + +/* Globals for do_clean() callback - nftw doesn't support user data */ +static time_t clean_age; +static time_t clean_now; + +static int do_clean(const char *fpath, const struct stat *sb, int tflag, struct FTW *ftw) +{ + int is_dir; + + /* Skip the root directory itself */ + if (ftw->level == 0) + return 0; + + is_dir = (tflag == FTW_D || tflag == FTW_DP); + + /* + * Conservative cleanup matching systemd-tmpfiles behavior: + * - Files: keep if ANY of atime, ctime, mtime is recent + * - Directories: keep if ANY of atime, mtime is recent (ctime + * excluded because cleanup itself updates directory ctime) + */ + if (clean_now - sb->st_mtime < clean_age) + return 0; /* mtime is too new, keep it */ + + if (clean_now - sb->st_atime < clean_age) + return 0; /* atime is too new, keep it */ + + if (!is_dir && clean_now - sb->st_ctime < clean_age) + return 0; /* ctime is too new, keep it (files only) */ + + if (remove(fpath) && errno != ENOENT && errno != EBUSY) + warn("Failed cleaning %s", fpath); + + return 0; +} + /* * The configuration format is one line per path, containing type, path, * mode, ownership, age, and argument fields. The lines are separated by @@ -312,7 +390,6 @@ static void tmpfiles(char *line) group = "root"; age = strtok(NULL, "\t "); - (void)age; /* unused atm. */ arg = strtok(NULL, "\n"); strc = stat(path, &st); @@ -349,7 +426,7 @@ static void tmpfiles(char *line) break; case 'X': case 'x': - dbg("Unsupported x/X command, ignoring %s, no support for clean at runtime.", path); + /* handled by clean_flag (--clean) */ break; case 'Z': case 'z': @@ -360,6 +437,32 @@ static void tmpfiles(char *line) } } + /* age-based cleanup logic */ + if (clean_flag) { + time_t max_age = parse_age(age); + + /* Only process if age is specified */ + if (max_age > 0) { + switch (type[0]) { + case 'd': + case 'D': + case 'e': + if (!fisdir(path)) + break; + clean_age = max_age; + clean_now = time(NULL); + nftw(path, do_clean, 20, FTW_DEPTH | FTW_PHYS); + break; + case 'x': + case 'X': + /* exclusion patterns - not yet implemented */ + break; + default: + break; + } + } + } + // file & directory creation logic if (create_flag) { switch (type[0]) { @@ -505,7 +608,7 @@ static void tmpfiles(char *line) break; case 'X': case 'x': - dbg("Unsupported x/X command, ignoring %s, no support for clean at runtime.", path); + /* handled by clean_flag (--clean) */ break; case 'Z': opts = "-R"; @@ -538,6 +641,7 @@ static int usage(int rc) "Usage: tmpfiles [COMMAND...]\n" "\n" "Commands:\n" + " -C, --clean Clean files and directories based on age\n" " -c, --create Create files and directories\n" " -d, --debug Show developer debug messages\n" " -r, --remove Remove files and directories marked for removal\n" @@ -550,6 +654,7 @@ static int usage(int rc) int main(int argc, char *argv[]) { struct option long_options[] = { + { "clean", 0, NULL, 'C' }, { "create", 0, NULL, 'c' }, { "debug", 0, NULL, 'd' }, { "remove", 0, NULL, 'r' }, @@ -559,8 +664,12 @@ int main(int argc, char *argv[]) int c; - while ((c = getopt_long(argc, argv, "cdrh?", long_options, NULL)) != EOF) { + while ((c = getopt_long(argc, argv, "Ccdrh?", long_options, NULL)) != EOF) { switch(c) { + case 'C': + clean_flag = 1; + break; + case 'c': create_flag = 1; break; @@ -582,8 +691,8 @@ int main(int argc, char *argv[]) } } - if (create_flag + remove_flag == 0) { - fprintf(stderr, "You need to specify at least one of --create or --remove.\n"); + if (create_flag + clean_flag + remove_flag == 0) { + fprintf(stderr, "You need to specify at least one of --clean, --create, or --remove.\n"); return 1; }