Skip to content

Size formatting and parsing, extension classification, path-traversal-safe filenames, collision avoidance, and directory listing — for handling uploads without trusting them.

Accepting a file upload means accepting a name, a size, and a byte stream from someone you do not trust. Each of the three is an attack surface. The name can escape your upload directory. The declared size can lie. The extension can claim to be a JPEG while the bytes are a shell script.

This module covers the mechanical parts of handling that: formatting sizes for humans, parsing size limits from configuration, classifying extensions, and — most importantly — turning a hostile filename into one that is safe to write to disk.

from sillo.helpers import files

Stdlib-only, built on pathlib and mimetypes.

format_size(bytes_value) and format_size_binary(bytes_value)

Section titled “format_size(bytes_value) and format_size_binary(bytes_value)”

Turn a byte count into something a person can read:

files.format_size(0) # '0 B'
files.format_size(999) # '999 B'
files.format_size(1024) # '1.0 KB'
files.format_size(1536000) # '1.5 MB'
files.format_size_binary(1024) # '1.0 KiB'
files.format_size_binary(1536000) # '1.5 MiB'

Negative values format with a sign (format_size(-1024) gives '-1.0 KB'), which is occasionally useful for showing a delta.

The inverse, for reading limits out of configuration files and environment variables:

files.parse_size("10 MB") # 10485760
files.parse_size("10MB") # 10485760 — space optional
files.parse_size("1.5 GB") # 1610612736
files.parse_size("512") # 512 — bare number means bytes

Malformed input raises ValueError:

files.parse_size("banana") # ValueError: Invalid size string: banana

This is what makes size limits readable in config rather than magic numbers:

Readable upload limits
import os
from sillo.helpers.files import parse_size, format_size
MAX_UPLOAD = parse_size(os.environ.get("MAX_UPLOAD_SIZE", "25 MB"))
@app.post("/upload")
async def upload(request, response):
declared = int(request.headers.get("content-length", 0))
if declared > MAX_UPLOAD:
return response.json(
{"error": f"File too large. Maximum is {format_size(MAX_UPLOAD)}."},
status_code=413,
)
...

Parse at import time, not per request, so a malformed value crashes at boot rather than on the first upload.

files.get_extension("photo.jpg") # '.jpg'
files.get_extension("photo.JPG") # '.jpg' — lowercased
files.get_extension("noext") # ''
files.get_extension_clean("archive.tar.gz") # 'gz' — no dot, last part only
files.guess_mime_type("photo.png") # 'image/png'
files.guess_mime_type("weird.xyzzy") # None

get_extension keeps the dot and lowercases. get_extension_clean drops the dot. Note that for a double extension such as .tar.gz, both return only the final component, so a .tar.gz looks like a gzip file rather than a tar archive.

Three classifiers:

files.is_dangerous_extension("script.exe") # True
files.is_dangerous_extension("notes.txt") # False
files.is_image_extension("photo.jpg") # True
files.is_media_extension("song.mp3") # True

guess_mime_type maps an extension to a type using the stdlib mimetypes table, and returns None for anything unrecognised. It never inspects content. Treat its output as a hint for setting a response header on files you already trust, never as verification of an upload.

safe_filename(filename, replacement="_") -> str

Section titled “safe_filename(filename, replacement="_") -> str”

The most security-relevant function in the module. It strips path separators and characters that are illegal or dangerous in a filename:

files.safe_filename("user input: file?.txt") # 'user_input__file_.txt'
files.safe_filename("../../etc/passwd") # '.._.._etc_passwd'
files.safe_filename("") # 'file'

The path-traversal case is the one that matters. Without it, a filename of ../../etc/passwd joined to your upload directory writes outside it:

# Catastrophic
path = os.path.join("/var/uploads", request_filename)
# "/var/uploads/../../etc/passwd" -> "/etc/passwd"

Separators become the replacement character, so the result stays inside the directory you join it to. An empty or fully stripped name falls back to 'file' rather than returning an empty string that would resolve to the directory itself.

unique_filename(directory, filename) -> str

Section titled “unique_filename(directory, filename) -> str”

Returns a name that does not already exist in the directory, appending a counter if needed:

files.unique_filename("/tmp", "log.txt") # 'log (1).txt' when log.txt exists
files.unique_filename("/tmp", "new.txt") # 'new.txt' when it does not

The suffix goes before the extension, so the file type is preserved.

Creates a directory and any missing parents, returning the Path. Idempotent:

uploads = files.ensure_directory("/data/uploads/2026/01")

Useful at startup and for date-partitioned upload trees. It does not raise if the directory already exists.

list_files(directory, pattern="*", recursive=False) -> list[Path]

Section titled “list_files(directory, pattern="*", recursive=False) -> list[Path]”

Globs a directory:

files.list_files("/data") # everything, top level
files.list_files("/data", "*.csv") # CSVs, top level
files.list_files("/data", "*.csv", recursive=True) # CSVs, all subdirectories

Returns Path objects, not strings.

Seconds since last modification, and a human-readable version:

files.file_age("/var/log/app.log") # 10842.3
files.file_age_human("/var/log/app.log") # '3h ago'

The obvious use is cleanup:

Purging stale temporary uploads
from sillo.helpers.files import list_files, file_age
TWENTY_FOUR_HOURS = 24 * 60 * 60
async def purge_stale_uploads():
for path in list_files("/tmp/uploads", "*"):
if file_age(path) > TWENTY_FOUR_HOURS:
path.unlink(missing_ok=True)

Run it from the scheduler (Scheduled Jobs) rather than in a request. Both functions call stat() and raise OSError for a missing path, so guard against a file deleted between listing and checking.

Do not join a user-supplied filename to a path. Sanitize it, or better, do not use it as the stored name at all.

Do not trust an extension. It is a string the uploader chose. Check magic bytes.

Do not use is_dangerous_extension as your upload filter. It is a blocklist. Allowlist instead.

Do not trust Content-Length. Enforce the limit while streaming.

Do not serve uploads from your application’s origin. A separate domain, or Content-Disposition: attachment plus nosniff.

Do not rely on unique_filename under concurrency. It is check-then-write.

Do not call list_files or hash_file in an async handler. Blocking I/O stalls the whole event loop.

Do not display format_size output next to a cloud provider’s number. The units disagree.

Do not assume get_extension understands double extensions. .tar.gz reports as gz.

Formatting, parsing, and extension functions are pure string arithmetic — nanoseconds, no I/O.

The filesystem functions are the ones to watch. file_age, file_age_human, and unique_filename each perform a stat(). list_files walks the directory and, with recursive=True, the entire tree beneath it. ensure_directory is a mkdir syscall. All are synchronous and block the event loop.

The rule for an async handler is simple: string functions are free, anything that touches the disk is not. Push disk work to a thread with anyio.to_thread.run_sync, or out of the request entirely with Background Tasks.

FunctionSignatureReturns
format_size(bytes_value: int | float)Human size, 1024-based with decimal labels
format_size_binary(bytes_value: int | float)Human size with KiB/MiB labels
parse_size(size_str: str)Byte count; raises ValueError
get_extension(filename: str)Lowercased extension with dot, or ''
get_extension_clean(filename: str)Extension without the dot
guess_mime_type(filename: str)MIME type or None. Extension-based only.
is_dangerous_extension(filename: str)bool. Blocklist — do not rely on it.
is_image_extension(filename: str)bool
is_media_extension(filename: str)bool
safe_filename(filename: str, replacement: str = "_")Single-component safe name
unique_filename(directory: str | Path, filename: str)Non-colliding name. Racy.
file_age(path: str | Path)Seconds since mtime; raises OSError
file_age_human(path: str | Path)Human string such as '3h ago'
ensure_directory(path: str | Path)Path, created with parents
list_files(directory, pattern: str = "*", recursive: bool = False)list[Path], eager
  • File Uploads — receiving and streaming uploads safely
  • Static Files — serving files, and why not from the upload directory
  • Strings — generating stored filenames
  • Hashing — content-addressed storage and deduplication
  • Background Tasks — moving disk work out of the request