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.
This commit is contained in:
Aaron Andersen committed 2026-01-18 18:59:20 -05:00
1 parent 84098dd8b7
commit 0b8d39329a
1 file changed
+115 -6
+115 -6
View File
@@ -34,6 +34,7 @@
#include <stdlib.h>
#include <string.h>
#include <time.h>
#ifdef _LIBITE_LITE
# include <libite/lite.h>
#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;
}