the shed // ruby x linux // filesystem

find /tmp -mtime +7 -delete has no dry-run with sizes, no per-directory rules, no idea which files are still open, and no audit trail. This Ruby tool replaces it with a small YAML policy, guard rails against catastrophic paths, and a report you can hand to whoever asks ‘what happened to my file?’

Get the code

Full script + README on GitHub: ruby-devops-toolkit/stale-file-cleaner

Step through the build below:

stale_file_cleaner.rb

The symptom: disk fills up, someone runs a hasty find ... -delete, and either nothing useful is freed or a file that a process still had open vanishes. Or worse: a typo in the path and the wrong directory is emptied.

The approach: describe what may be deleted in a YAML file (path, max age, glob, minimum size, keep-newest-N, protected globs). The script evaluates every candidate through a fixed decision chain, refuses dangerous roots outright, skips files held open by any process (via /proc/*/fd), and defaults to dry-run.

What you get: a table of DELETE/KEEP decisions with reasons, total reclaimable bytes, --apply to execute, --prune-empty-dirs to tidy up, and --json for auditing.

#!/usr/bin/env ruby
# frozen_string_literal: true
#
# stale_file_cleaner.rb -- policy-driven cleanup of stale files on Linux.
#
# The classic `find /tmp -mtime +7 -delete` one-liner works until it doesn't:
# it has no dry-run that shows sizes, no per-directory rules, no protection
# for files still held open by a process, and no audit trail. This script
# fixes all of that with a small YAML policy file:
#
#   rules:
#     - path: /var/tmp
#       max_age_days: 14
#       pattern: "**/*"          # glob relative to path (default: everything)
#     - path: /var/log/myapp
#       max_age_days: 30
#       pattern: "**/*.log.*"    # only rotated logs
#       min_size_kb: 0
#     - path: /home/deploy/releases
#       max_age_days: 45
#       keep_newest: 5           # never drop below N newest matches
#   protect:                     # never touch anything matching these globs
#     - "**/.git/**"
#     - "**/*.pid"
#
# Usage:
#   ruby stale_file_cleaner.rb --policy cleanup.yml            # dry-run (default)
#   ruby stale_file_cleaner.rb --policy cleanup.yml --apply    # really delete
#   ruby stale_file_cleaner.rb --policy cleanup.yml --json     # JSON report
#   ruby stale_file_cleaner.rb --policy cleanup.yml --apply --prune-empty-dirs
#
# Safety rails: dry-run by default, never follows symlinks, refuses to run a
# rule whose path is / or a home directory root, skips files that are still
# open (checked via /proc/*/fd), and logs every deletion with size and age.
require 'yaml'
require 'json'
require 'optparse'
require 'time'
require 'pathname'
FORBIDDEN_ROOTS = ['/', '/home', '/root', '/etc', '/usr', '/bin', '/sbin', '/lib', '/boot', '/var', '/proc', '/sys', '/dev'].freeze
# Candidate file plus the facts we decided on.
Candidate = Struct.new(:path, :size, :mtime, :age_days, :rule_path, :action, :reason)
class OpenFileIndex
  # Build a Set of every file path currently held open by any process, by
  # resolving /proc/<pid>/fd/* symlinks. Cheap enough to do once per run.
  def initialize
    @open = {}
    Dir.glob('/proc/[0-9]*/fd/*').each do |fd|
      target = File.readlink(fd)
      @open[target] = true if target.start_with?('/')
    rescue SystemCallError
      next # process exited or permission denied; both fine
    end
  end
  def open?(path)
    @open.key?(path)
  end
end
class StaleFileCleaner
  attr_reader :candidates, :errors
  def initialize(policy, apply: false, prune_empty_dirs: false, now: Time.now, logger: $stderr)
    @rules = Array(policy['rules'])
    @protect = Array(policy['protect'])
    @apply = apply
    @prune_empty_dirs = prune_empty_dirs
    @now = now
    @log = logger
    @candidates = []
    @errors = []
    @open_index = nil
  end
  def run
    @rules.each { |rule| evaluate_rule(rule) }
    execute if @apply
    self
  end
  # ---- evaluation -----------------------------------------------------
  def evaluate_rule(rule)
    root = File.expand_path(rule.fetch('path'))
    if FORBIDDEN_ROOTS.include?(root) || root =~ %r{\A/home/[^/]+\z}
      @errors << "refusing to clean #{root}: too dangerous as a rule root"
      return
    end
    unless File.directory?(root)
      @errors << "skipping #{root}: not a directory"
      return
    end
    max_age = Float(rule.fetch('max_age_days'))
    min_size = Integer(rule.fetch('min_size_kb', 0)) * 1024
    keep_newest = Integer(rule.fetch('keep_newest', 0))
    pattern = rule.fetch('pattern', '**/*')
    # File::FNM_DOTMATCH so dotfiles count; we filter dirs/symlinks ourselves.
    matches = Dir.glob(File.join(root, pattern), File::FNM_DOTMATCH)
                 .reject { |p| File.symlink?(p) || !File.file?(p) }
                 .reject { |p| protected?(p) }
                 .map { |p| [p, File.stat(p)] }
                 .sort_by { |_, st| -st.mtime.to_f } # newest first
    matches.each_with_index do |(path, st), idx|
      age = (@now - st.mtime) / 86_400.0
      c = Candidate.new(path, st.size, st.mtime, age.round(1), root, :keep, nil)
      if idx < keep_newest
        c.reason = "within keep_newest=#{keep_newest}"
      elsif age < max_age
        c.reason = "younger than #{max_age.to_i}d"
      elsif st.size < min_size
        c.reason = "smaller than #{min_size / 1024}KB"
      elsif open_index.open?(path)
        c.reason = 'still open by a process'
      else
        c.action = :delete
        c.reason = "#{age.round}d old > #{max_age.to_i}d"
      end
      @candidates << c
    end
  end
  def protected?(path)
    @protect.any? { |glob| File.fnmatch?(glob, path, File::FNM_PATHNAME | File::FNM_DOTMATCH | File::FNM_EXTGLOB) }
  end
  def open_index
    @open_index ||= OpenFileIndex.new
  end
  # ---- execution ------------------------------------------------------
  def execute
    deletable.each do |c|
      File.delete(c.path)
      c.action = :deleted
      @log.puts "deleted #{c.path} (#{human(c.size)}, #{c.age_days}d)"
    rescue SystemCallError => e
      c.action = :failed
      c.reason = e.message
      @errors << "#{c.path}: #{e.message}"
    end
    prune_dirs if @prune_empty_dirs
  end
  # Remove now-empty directories under each rule root, deepest first, but
  # never the rule root itself.
  def prune_dirs
    @rules.each do |rule|
      root = File.expand_path(rule['path'])
      next unless File.directory?(root)
      Dir.glob(File.join(root, '**/'), File::FNM_DOTMATCH).map { |d| d.chomp('/') }
         .reject { |d| d == root || d.end_with?('/.', '/..') }
         .sort_by { |d| -d.count('/') }
         .each do |d|
        next unless (Dir.children(d) rescue [nil]).empty?
        Dir.rmdir(d)
        @log.puts "pruned empty dir #{d}"
      rescue SystemCallError => e
        @errors << "#{d}: #{e.message}"
      end
    end
  end
  # ---- reporting ------------------------------------------------------
  def deletable
    @candidates.select { |c| c.action == :delete }
  end
  def summary
    done = @candidates.select { |c| c.action == :deleted }
    {
      mode: @apply ? 'apply' : 'dry-run',
      scanned: @candidates.size,
      to_delete: deletable.size + done.size,
      bytes_reclaimable: (deletable + done).sum(&:size),
      deleted: done.size,
      bytes_freed: done.sum(&:size),
      failed: @candidates.count { |c| c.action == :failed },
      errors: @errors
    }
  end
  def to_json(*_args)
    JSON.pretty_generate(summary.merge(files: @candidates.map(&:to_h)))
  end
  def to_text
    s = summary
    lines = ["stale_file_cleaner  mode=#{s[:mode]}  scanned=#{s[:scanned]}"]
    lines << ('-' * 72)
    @candidates.select { |c| c.action != :keep }.sort_by(&:path).each do |c|
      lines << format('%-8s %9s %6.1fd  %s', c.action.to_s.upcase, human(c.size), c.age_days, c.path)
    end
    lines << ('-' * 72)
    lines << "would reclaim #{human(s[:bytes_reclaimable])} across #{s[:to_delete]} file(s)" unless @apply
    lines << "freed #{human(s[:bytes_freed])} across #{s[:deleted]} file(s), #{s[:failed]} failed" if @apply
    s[:errors].each { |e| lines << "ERROR #{e}" }
    lines.join("\n")
  end
  def human(bytes)
    units = %w[B KB MB GB TB]
    i = 0
    b = bytes.to_f
    while b >= 1024 && i < units.size - 1
      b /= 1024
      i += 1
    end
    i.zero? ? "#{bytes}B" : format('%.1f%s', b, units[i])
  end
end
if __FILE__ == $PROGRAM_NAME
  opts = { policy: nil, apply: false, json: false, prune: false }
  OptionParser.new do |o|
    o.banner = 'Usage: stale_file_cleaner.rb --policy FILE [--apply] [--json] [--prune-empty-dirs]'
    o.on('--policy FILE', 'YAML policy file (required)') { |f| opts[:policy] = f }
    o.on('--apply', 'Actually delete (default is dry-run)') { opts[:apply] = true }
    o.on('--json', 'JSON report on stdout') { opts[:json] = true }
    o.on('--prune-empty-dirs', 'Remove directories left empty after deletion') { opts[:prune] = true }
  end.parse!
  abort 'error: --policy FILE is required' unless opts[:policy]
  policy = YAML.safe_load(File.read(opts[:policy]))
  cleaner = StaleFileCleaner.new(policy, apply: opts[:apply], prune_empty_dirs: opts[:prune]).run
  puts(opts[:json] ? cleaner.to_json : cleaner.to_text)
  exit(cleaner.errors.empty? ? 0 : 1)
end

Guard rails first. FORBIDDEN_ROOTS plus a regex for /home/<user> mean a policy line like path: / is rejected with an error, not executed. Symlinks are never followed and only regular files are candidates.

The decision chain in evaluate_rule is deliberately linear: keep-newest wins, then age, then size, then open-file check, and only then DELETE. Each KEEP records a human-readable reason so the JSON report explains itself.

OpenFileIndex resolves every /proc/<pid>/fd/* symlink once, lazily, into a hash. Deleting an open file on Linux does not free space until the descriptor closes, so skipping those files is both safer and more honest about what you will reclaim.

$ ruby stale_file_cleaner.rb --policy cleanup.yml
stale_file_cleaner  mode=dry-run  scanned=19
------------------------------------------------------------------------
DELETE     300.0KB   30.0d  /tmp/lab/logs/app.log.3
DELETE     300.0KB   40.0d  /tmp/lab/logs/app.log.4
DELETE     300.0KB   50.0d  /tmp/lab/logs/app.log.5
DELETE     300.0KB   60.0d  /tmp/lab/logs/app.log.6
DELETE     300.0KB   70.0d  /tmp/lab/logs/app.log.7
DELETE     700.0KB   36.0d  /tmp/lab/releases/r4/build.tar
DELETE     700.0KB   45.0d  /tmp/lab/releases/r5/build.tar
DELETE     700.0KB   54.0d  /tmp/lab/releases/r6/build.tar
DELETE     700.0KB   63.0d  /tmp/lab/releases/r7/build.tar
DELETE     700.0KB   72.0d  /tmp/lab/releases/r8/build.tar
DELETE       2.0MB   20.0d  /tmp/lab/tmp/old-upload.bin
------------------------------------------------------------------------
would reclaim 6.9MB across 11 file(s)
ERROR refusing to clean /home: too dangerous as a rule root
exit=1
$ ruby stale_file_cleaner.rb --policy cleanup.yml --apply --prune-empty-dirs
deleted /tmp/lab/tmp/old-upload.bin (2.0MB, 20.0d)
deleted /tmp/lab/logs/app.log.3 (300.0KB, 30.0d)
deleted /tmp/lab/logs/app.log.4 (300.0KB, 40.0d)
deleted /tmp/lab/logs/app.log.5 (300.0KB, 50.0d)
deleted /tmp/lab/logs/app.log.6 (300.0KB, 60.0d)
deleted /tmp/lab/logs/app.log.7 (300.0KB, 70.0d)
deleted /tmp/lab/releases/r4/build.tar (700.0KB, 36.0d)
deleted /tmp/lab/releases/r5/build.tar (700.0KB, 45.0d)
deleted /tmp/lab/releases/r6/build.tar (700.0KB, 54.0d)
deleted /tmp/lab/releases/r7/build.tar (700.0KB, 63.0d)
deleted /tmp/lab/releases/r8/build.tar (700.0KB, 72.0d)
pruned empty dir /tmp/lab/releases/r4
pruned empty dir /tmp/lab/releases/r5
pruned empty dir /tmp/lab/releases/r6
pruned empty dir /tmp/lab/releases/r7
pruned empty dir /tmp/lab/releases/r8
stale_file_cleaner  mode=apply  scanned=19
------------------------------------------------------------------------
DELETED    300.0KB   30.0d  /tmp/lab/logs/app.log.3
DELETED    300.0KB   40.0d  /tmp/lab/logs/app.log.4
DELETED    300.0KB   50.0d  /tmp/lab/logs/app.log.5
DELETED    300.0KB   60.0d  /tmp/lab/logs/app.log.6
DELETED    300.0KB   70.0d  /tmp/lab/logs/app.log.7
DELETED    700.0KB   36.0d  /tmp/lab/releases/r4/build.tar
DELETED    700.0KB   45.0d  /tmp/lab/releases/r5/build.tar
DELETED    700.0KB   54.0d  /tmp/lab/releases/r6/build.tar
DELETED    700.0KB   63.0d  /tmp/lab/releases/r7/build.tar
DELETED    700.0KB   72.0d  /tmp/lab/releases/r8/build.tar
DELETED      2.0MB   20.0d  /tmp/lab/tmp/old-upload.bin
------------------------------------------------------------------------
freed 6.9MB across 11 file(s), 0 failed
ERROR refusing to clean /home: too dangerous as a rule root
exit=1
01 // context

The problem this solves

Cleaning stale files is one of those jobs every team automates badly. The usual cron line is find /var/tmp -type f -mtime +14 -delete. It is fine until you need a second rule for rotated logs, a third that keeps the last five release tarballs regardless of age, and a way to prove to a developer that yes, their upload was deleted on Tuesday because it was 20 days old and 2 MB.

There is also a subtle Linux gotcha: deleting a file that some process still has open does not free the space. The directory entry disappears, the inode stays until the last descriptor closes, and df does not move. Engineers then delete more, still see no change, and start restarting services. A cleaner that consults /proc/*/fd before deleting avoids the confusion entirely.

This tutorial builds stale_file_cleaner.rb: a single-file Ruby tool driven by a YAML policy. It reports before it acts, refuses to operate on catastrophic roots, protects globs like **/.git/**, and can optionally remove directories left empty. It uses nothing outside Ruby’s standard library.

Pipeline: YAML policy -> guard rails -> collect -> open-file index -> per-file decision chain

Policy in, decisions out. Dry-run by default; every deletion is logged with size and age.
02 // setup

Prerequisites

you will need
  • Ruby 2.7+ (tested on 3.0.2). Stdlib only: yaml, json, optparse, pathname.
  • Linux. The open-file check reads /proc/*/fd; on other platforms it simply finds nothing open and the rest still works.
  • A policy file. Copy cleanup.yml from the repo and adjust paths. Run as a user that can read the targets (root for /var/tmp, typically).
03 // source

The complete script

Reference copy of the whole script (the widget above has the same code with a copy button).

stale_file_cleaner.rbruby
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# stale_file_cleaner.rb -- policy-driven cleanup of stale files on Linux.
#
# The classic `find /tmp -mtime +7 -delete` one-liner works until it doesn't:
# it has no dry-run that shows sizes, no per-directory rules, no protection
# for files still held open by a process, and no audit trail. This script
# fixes all of that with a small YAML policy file:
#
#   rules:
#     - path: /var/tmp
#       max_age_days: 14
#       pattern: "**/*"          # glob relative to path (default: everything)
#     - path: /var/log/myapp
#       max_age_days: 30
#       pattern: "**/*.log.*"    # only rotated logs
#       min_size_kb: 0
#     - path: /home/deploy/releases
#       max_age_days: 45
#       keep_newest: 5           # never drop below N newest matches
#   protect:                     # never touch anything matching these globs
#     - "**/.git/**"
#     - "**/*.pid"
#
# Usage:
#   ruby stale_file_cleaner.rb --policy cleanup.yml            # dry-run (default)
#   ruby stale_file_cleaner.rb --policy cleanup.yml --apply    # really delete
#   ruby stale_file_cleaner.rb --policy cleanup.yml --json     # JSON report
#   ruby stale_file_cleaner.rb --policy cleanup.yml --apply --prune-empty-dirs
#
# Safety rails: dry-run by default, never follows symlinks, refuses to run a
# rule whose path is / or a home directory root, skips files that are still
# open (checked via /proc/*/fd), and logs every deletion with size and age.
require 'yaml'
require 'json'
require 'optparse'
require 'time'
require 'pathname'
FORBIDDEN_ROOTS = ['/', '/home', '/root', '/etc', '/usr', '/bin', '/sbin', '/lib', '/boot', '/var', '/proc', '/sys', '/dev'].freeze
# Candidate file plus the facts we decided on.
Candidate = Struct.new(:path, :size, :mtime, :age_days, :rule_path, :action, :reason)
class OpenFileIndex
  # Build a Set of every file path currently held open by any process, by
  # resolving /proc/<pid>/fd/* symlinks. Cheap enough to do once per run.
  def initialize
    @open = {}
    Dir.glob('/proc/[0-9]*/fd/*').each do |fd|
      target = File.readlink(fd)
      @open[target] = true if target.start_with?('/')
    rescue SystemCallError
      next # process exited or permission denied; both fine
    end
  end
  def open?(path)
    @open.key?(path)
  end
end
class StaleFileCleaner
  attr_reader :candidates, :errors
  def initialize(policy, apply: false, prune_empty_dirs: false, now: Time.now, logger: $stderr)
    @rules = Array(policy['rules'])
    @protect = Array(policy['protect'])
    @apply = apply
    @prune_empty_dirs = prune_empty_dirs
    @now = now
    @log = logger
    @candidates = []
    @errors = []
    @open_index = nil
  end
  def run
    @rules.each { |rule| evaluate_rule(rule) }
    execute if @apply
    self
  end
  # ---- evaluation -----------------------------------------------------
  def evaluate_rule(rule)
    root = File.expand_path(rule.fetch('path'))
    if FORBIDDEN_ROOTS.include?(root) || root =~ %r{\A/home/[^/]+\z}
      @errors << "refusing to clean #{root}: too dangerous as a rule root"
      return
    end
    unless File.directory?(root)
      @errors << "skipping #{root}: not a directory"
      return
    end
    max_age = Float(rule.fetch('max_age_days'))
    min_size = Integer(rule.fetch('min_size_kb', 0)) * 1024
    keep_newest = Integer(rule.fetch('keep_newest', 0))
    pattern = rule.fetch('pattern', '**/*')
    # File::FNM_DOTMATCH so dotfiles count; we filter dirs/symlinks ourselves.
    matches = Dir.glob(File.join(root, pattern), File::FNM_DOTMATCH)
                 .reject { |p| File.symlink?(p) || !File.file?(p) }
                 .reject { |p| protected?(p) }
                 .map { |p| [p, File.stat(p)] }
                 .sort_by { |_, st| -st.mtime.to_f } # newest first
    matches.each_with_index do |(path, st), idx|
      age = (@now - st.mtime) / 86_400.0
      c = Candidate.new(path, st.size, st.mtime, age.round(1), root, :keep, nil)
      if idx < keep_newest
        c.reason = "within keep_newest=#{keep_newest}"
      elsif age < max_age
        c.reason = "younger than #{max_age.to_i}d"
      elsif st.size < min_size
        c.reason = "smaller than #{min_size / 1024}KB"
      elsif open_index.open?(path)
        c.reason = 'still open by a process'
      else
        c.action = :delete
        c.reason = "#{age.round}d old > #{max_age.to_i}d"
      end
      @candidates << c
    end
  end
  def protected?(path)
    @protect.any? { |glob| File.fnmatch?(glob, path, File::FNM_PATHNAME | File::FNM_DOTMATCH | File::FNM_EXTGLOB) }
  end
  def open_index
    @open_index ||= OpenFileIndex.new
  end
  # ---- execution ------------------------------------------------------
  def execute
    deletable.each do |c|
      File.delete(c.path)
      c.action = :deleted
      @log.puts "deleted #{c.path} (#{human(c.size)}, #{c.age_days}d)"
    rescue SystemCallError => e
      c.action = :failed
      c.reason = e.message
      @errors << "#{c.path}: #{e.message}"
    end
    prune_dirs if @prune_empty_dirs
  end
  # Remove now-empty directories under each rule root, deepest first, but
  # never the rule root itself.
  def prune_dirs
    @rules.each do |rule|
      root = File.expand_path(rule['path'])
      next unless File.directory?(root)
      Dir.glob(File.join(root, '**/'), File::FNM_DOTMATCH).map { |d| d.chomp('/') }
         .reject { |d| d == root || d.end_with?('/.', '/..') }
         .sort_by { |d| -d.count('/') }
         .each do |d|
        next unless (Dir.children(d) rescue [nil]).empty?
        Dir.rmdir(d)
        @log.puts "pruned empty dir #{d}"
      rescue SystemCallError => e
        @errors << "#{d}: #{e.message}"
      end
    end
  end
  # ---- reporting ------------------------------------------------------
  def deletable
    @candidates.select { |c| c.action == :delete }
  end
  def summary
    done = @candidates.select { |c| c.action == :deleted }
    {
      mode: @apply ? 'apply' : 'dry-run',
      scanned: @candidates.size,
      to_delete: deletable.size + done.size,
      bytes_reclaimable: (deletable + done).sum(&:size),
      deleted: done.size,
      bytes_freed: done.sum(&:size),
      failed: @candidates.count { |c| c.action == :failed },
      errors: @errors
    }
  end
  def to_json(*_args)
    JSON.pretty_generate(summary.merge(files: @candidates.map(&:to_h)))
  end
  def to_text
    s = summary
    lines = ["stale_file_cleaner  mode=#{s[:mode]}  scanned=#{s[:scanned]}"]
    lines << ('-' * 72)
    @candidates.select { |c| c.action != :keep }.sort_by(&:path).each do |c|
      lines << format('%-8s %9s %6.1fd  %s', c.action.to_s.upcase, human(c.size), c.age_days, c.path)
    end
    lines << ('-' * 72)
    lines << "would reclaim #{human(s[:bytes_reclaimable])} across #{s[:to_delete]} file(s)" unless @apply
    lines << "freed #{human(s[:bytes_freed])} across #{s[:deleted]} file(s), #{s[:failed]} failed" if @apply
    s[:errors].each { |e| lines << "ERROR #{e}" }
    lines.join("\n")
  end
  def human(bytes)
    units = %w[B KB MB GB TB]
    i = 0
    b = bytes.to_f
    while b >= 1024 && i < units.size - 1
      b /= 1024
      i += 1
    end
    i.zero? ? "#{bytes}B" : format('%.1f%s', b, units[i])
  end
end
if __FILE__ == $PROGRAM_NAME
  opts = { policy: nil, apply: false, json: false, prune: false }
  OptionParser.new do |o|
    o.banner = 'Usage: stale_file_cleaner.rb --policy FILE [--apply] [--json] [--prune-empty-dirs]'
    o.on('--policy FILE', 'YAML policy file (required)') { |f| opts[:policy] = f }
    o.on('--apply', 'Actually delete (default is dry-run)') { opts[:apply] = true }
    o.on('--json', 'JSON report on stdout') { opts[:json] = true }
    o.on('--prune-empty-dirs', 'Remove directories left empty after deletion') { opts[:prune] = true }
  end.parse!
  abort 'error: --policy FILE is required' unless opts[:policy]
  policy = YAML.safe_load(File.read(opts[:policy]))
  cleaner = StaleFileCleaner.new(policy, apply: opts[:apply], prune_empty_dirs: opts[:prune]).run
  puts(opts[:json] ? cleaner.to_json : cleaner.to_text)
  exit(cleaner.errors.empty? ? 0 : 1)
end
04 // walkthrough

How it works, step by step

The policy file

rules is a list; each rule has a path, a max_age_days, and optional pattern (a glob relative to the path, default **/*), min_size_kb, and keep_newest. protect is a list of globs that are never touched no matter which rule matched them. The file is loaded with YAML.safe_load, so no arbitrary object instantiation.

Guard rails in evaluate_rule

Before anything is globbed, the rule’s path is expanded and checked against FORBIDDEN_ROOTS and the /home/<user> regex. A rule that fails this check is turned into an error string and skipped, and the script exits non-zero so a cron mail tells you your policy is wrong. In the example run the deliberately bad path: /home rule demonstrates this.

Collecting candidates

Dir.glob with File::FNM_DOTMATCH includes dotfiles. The result is filtered to regular files that are not symlinks (so a symlink to /etc/passwd inside /tmp can never cause harm), then File.stat gives size and mtime. Sorting newest-first is what makes keep_newest a simple index comparison.

The decision chain

Each file becomes a Candidate struct with action defaulting to :keep. The chain is: within keep_newest? younger than max_age? smaller than min_size? still open? Only a file that clears all four becomes :delete. The reason string is filled in every branch, which is what makes the JSON report useful as an audit log.

The open-file index

OpenFileIndex globs /proc/[0-9]*/fd/* and readlinks each entry. Processes exit mid-scan and some are unreadable without root; both raise SystemCallError, which is rescued per entry. The index is built lazily (@open_index ||=), so a dry-run on a policy with no old files never pays the cost.

Executing and pruning

With --apply, execute deletes each candidate, logs it to stderr with size and age, and records failures without aborting the run. prune_dirs then walks directories deepest-first, removing only those that are empty and never the rule root itself. Empty-check uses Dir.children so . and .. do not count.

05 // run it

Example output

The lab tree has files aged 0-90 days, a 4 MB file held open by a sleep process, a .git directory, a .pid file, eight release tarballs and a deliberately dangerous /home rule. First a dry-run, then --apply --prune-empty-dirs.

stale_file_cleaner.rb — sandbox run
$ ruby stale_file_cleaner.rb –policy cleanup.yml
stale_file_cleaner mode=dry-run scanned=19
————————————————————————
DELETE 300.0KB 30.0d /tmp/lab/logs/app.log.3
DELETE 300.0KB 40.0d /tmp/lab/logs/app.log.4
DELETE 300.0KB 50.0d /tmp/lab/logs/app.log.5
DELETE 300.0KB 60.0d /tmp/lab/logs/app.log.6
DELETE 300.0KB 70.0d /tmp/lab/logs/app.log.7
DELETE 700.0KB 36.0d /tmp/lab/releases/r4/build.tar
DELETE 700.0KB 45.0d /tmp/lab/releases/r5/build.tar
DELETE 700.0KB 54.0d /tmp/lab/releases/r6/build.tar
DELETE 700.0KB 63.0d /tmp/lab/releases/r7/build.tar
DELETE 700.0KB 72.0d /tmp/lab/releases/r8/build.tar
DELETE 2.0MB 20.0d /tmp/lab/tmp/old-upload.bin
————————————————————————
would reclaim 6.9MB across 11 file(s)
ERROR refusing to clean /home: too dangerous as a rule root
exit=1
$ ruby stale_file_cleaner.rb –policy cleanup.yml –apply –prune-empty-dirs
deleted /tmp/lab/tmp/old-upload.bin (2.0MB, 20.0d)
deleted /tmp/lab/logs/app.log.3 (300.0KB, 30.0d)
deleted /tmp/lab/logs/app.log.4 (300.0KB, 40.0d)
deleted /tmp/lab/logs/app.log.5 (300.0KB, 50.0d)
deleted /tmp/lab/logs/app.log.6 (300.0KB, 60.0d)
deleted /tmp/lab/logs/app.log.7 (300.0KB, 70.0d)
deleted /tmp/lab/releases/r4/build.tar (700.0KB, 36.0d)
deleted /tmp/lab/releases/r5/build.tar (700.0KB, 45.0d)
deleted /tmp/lab/releases/r6/build.tar (700.0KB, 54.0d)
deleted /tmp/lab/releases/r7/build.tar (700.0KB, 63.0d)
deleted /tmp/lab/releases/r8/build.tar (700.0KB, 72.0d)
pruned empty dir /tmp/lab/releases/r4
pruned empty dir /tmp/lab/releases/r5
pruned empty dir /tmp/lab/releases/r6
pruned empty dir /tmp/lab/releases/r7
pruned empty dir /tmp/lab/releases/r8
stale_file_cleaner mode=apply scanned=19
————————————————————————
DELETED 300.0KB 30.0d /tmp/lab/logs/app.log.3
DELETED 300.0KB 40.0d /tmp/lab/logs/app.log.4
DELETED 300.0KB 50.0d /tmp/lab/logs/app.log.5
DELETED 300.0KB 60.0d /tmp/lab/logs/app.log.6
DELETED 300.0KB 70.0d /tmp/lab/logs/app.log.7
DELETED 700.0KB 36.0d /tmp/lab/releases/r4/build.tar
DELETED 700.0KB 45.0d /tmp/lab/releases/r5/build.tar
DELETED 700.0KB 54.0d /tmp/lab/releases/r6/build.tar
DELETED 700.0KB 63.0d /tmp/lab/releases/r7/build.tar
DELETED 700.0KB 72.0d /tmp/lab/releases/r8/build.tar
DELETED 2.0MB 20.0d /tmp/lab/tmp/old-upload.bin
————————————————————————
freed 6.9MB across 11 file(s), 0 failed
ERROR refusing to clean /home: too dangerous as a rule root
exit=1
11
files deleted
6.9MB
reclaimed
5
safety checks per file
06 // troubleshooting

When it does not behave

common issues
  • ‘refusing to clean /var: too dangerous’. Intentional. Point the rule at a subdirectory such as /var/tmp or /var/cache/myapp. Edit FORBIDDEN_ROOTS only if you really know what you are doing.
  • Old files show KEEP with ‘still open by a process’. Correct behaviour — something has the file open and deleting it would not free space. Find it with lsof /path/to/file or fuser.
  • Dotfiles not matched. Ensure your pattern does not exclude them; the script passes FNM_DOTMATCH, but a pattern like *.log only matches that suffix.
  • Freed space does not appear in df. Some other process still holds a deleted file (one the index could not see because you ran without root). Re-run as root or check lsof +L1.
  • Permission denied on delete. The report lists it under failed and the exit code is 1; the rest of the run continues.
Always dry-run a new policy

Run without –apply first, read the DELETE list, then add –apply. Put the dry-run in cron with MAILTO for a week before you automate the real thing.
07 // extend

Where to take it next

ideas
  • Move deletions to a quarantine directory (FileUtils.mv) with a second rule that purges the quarantine after 7 days — instant undo for a week.
  • Add min_free_percent to a rule so it only runs when the filesystem is actually under pressure (df via Sys::Filesystem or parsing /proc/mounts + statvfs).
  • Emit a Prometheus textfile metric with bytes reclaimed per rule so you can graph how fast each directory grows.
  • Support atime as an alternative to mtime for caches where ‘last used’ matters more than ‘last written’.
  • Wrap it in a systemd timer with ProtectSystem=strict and ReadWritePaths= limited to the rule roots for an extra kernel-enforced guard rail.