Skip to main content

StoragePath

StoragePath is a utility type for building safe, consistent, cloud-ready file paths. It normalizes path separators, sanitizes unsafe characters, and provides composable helpers for adding time-based prefixes, hash suffixes, and random suffixes that make object keys unique and sortable.


Why StoragePath?​

Cloud storage systems identify objects by string keys (S3), blob names (Azure), or object names (GCP). Without a consistent path strategy, issues accumulate quickly:

  • User-supplied filenames contain spaces, parentheses, and Unicode characters that may not be safe in all storage backends.
  • Mixing OS path separators (\ on Windows) causes objects to be created under unexpected prefixes.
  • Files with the same name from different users collide.
  • Hot-spot prefixes (all files under "uploads/") degrade S3 performance on large buckets.

StoragePath solves all of these with a single, composable API.

// Fragile — concatenation, OS separator, raw user filename
var path = userId + "/" + file.FileName; // "123/My Report (Final) v2.pdf"

// Safe — normalized, sanitized, unique
var path = StoragePath
.From("users", userId, StoragePath.Sanitize(file.FileName))
.WithDatePrefix()
.WithRandomSuffix();
// → "2026/03/18/users/123/my-report-final-v2_k9p2x.pdf"

Creating Paths​

StoragePath.From(params string[] segments)​

Joins one or more segments with / and normalizes the result:

StoragePath.From("users", "123", "avatar.png")
// → "users/123/avatar.png"

StoragePath.From("uploads", DateTime.UtcNow.Year.ToString(), "report.pdf")
// → "uploads/2026/report.pdf"

StoragePath.From("a", "b", "c", "d", "file.txt")
// → "a/b/c/d/file.txt"

Empty and whitespace-only segments are ignored, and leading/trailing slashes in any segment are trimmed:

StoragePath.From("users", "", "123", "/avatar.png", "")
// → "users/123/avatar.png"

StoragePath.From("/uploads/", "subdir/", "/file.txt")
// → "uploads/subdir/file.txt"

Path Helpers​

All helper methods return a new StoragePath instance. The original is never modified (the type is immutable). Helpers can be chained freely.

WithDatePrefix()​

Prepends the current UTC date as YYYY/MM/DD/:

StoragePath.From("users", "123", "avatar.png").WithDatePrefix()
// → "2026/03/18/users/123/avatar.png"

Why: Time-partitioned prefixes improve ListObjects performance on large S3 buckets, and make it easy to find or expire files by date. This is a best practice for S3 at scale.

WithTimestampPrefix()​

Prepends a compact UTC timestamp as YYYYMMDDTHHmmss/:

StoragePath.From("uploads", "report.pdf").WithTimestampPrefix()
// → "20260318T143022/uploads/report.pdf"

Why: When you need chronological ordering within a prefix and the date alone is not granular enough. Suitable for event logs, audit records, or any scenario where order of creation matters.

WithHashSuffix()​

Appends a short 6-character SHA-256 hash of the full path string to the filename stem, before the extension:

StoragePath.From("users", "123", "avatar.png").WithHashSuffix()
// → "users/123/avatar_a3f7b2.png"

Why: The hash is deterministic — the same path always yields the same suffix. This makes paths stable for cache-busting: changing the file content without changing the path still changes the suffix, invalidating CDN caches.

WithRandomSuffix()​

Appends a cryptographically random 5-character alphanumeric string to the filename stem:

StoragePath.From("users", "123", "avatar.png").WithRandomSuffix()
// → "users/123/avatar_x9k2m.png"

// Each call produces a different suffix
StoragePath.From("users", "123", "avatar.png").WithRandomSuffix()
// → "users/123/avatar_7bqnp.png"

Why: Prevents filename collisions when the same user uploads a file with the same name multiple times. Unlike WithHashSuffix, this always produces a new path even for identical content.


Sanitization​

StoragePath.Sanitize(string rawInput)​

Converts a raw filename or path string into a safe storage path:

  • Converts to lowercase
  • Replaces spaces with hyphens
  • Replaces unsafe characters ((, ), [, ], @, #, !, etc.) with hyphens
  • Collapses consecutive hyphens into a single hyphen
  • Preserves / as path separator
  • Preserves . before extensions
  • Removes leading/trailing slashes and whitespace
StoragePath.Sanitize("Hello World.txt")
// → "hello-world.txt"

StoragePath.Sanitize("My Report (Final) v2.pdf")
// → "my-report-final-v2.pdf"

StoragePath.Sanitize(" /uploads/Report Q1 2026.PDF ")
// → "uploads/report-q1-2026.pdf"

StoragePath.Sanitize("avatar@user#123!.png")
// → "avatar-user-123-.png"

StoragePath.Sanitize("file___name---.txt")
// → "file-name-.txt"
Always sanitize user-supplied filenames

Never use IFormFile.FileName directly in a StoragePath. Users can submit filenames with path traversal sequences (../../etc/passwd), Unicode characters, or characters that are invalid in certain cloud storage backends.

// Safe
var safeName = StoragePath.Sanitize(file.FileName);
var path = StoragePath.From("uploads", userId, safeName);

// Unsafe — never do this
var path = StoragePath.From("uploads", userId, file.FileName);

Input → Output Reference Table​

InputOutputNotes
"Hello World.txt""hello-world.txt"Sanitize: space → hyphen
"My File (2024).pdf""my-file-2024.pdf"Sanitize: parentheses removed
"avatar.png" with WithDatePrefix()"2026/03/18/avatar.png"Date prefix
"report.pdf" with WithTimestampPrefix()"20260318T143022/report.pdf"Timestamp prefix
"users/123/avatar.png" with WithHashSuffix()"users/123/avatar_a3f7b2.png"Hash suffix (deterministic)
"users/123/avatar.png" with WithRandomSuffix()"users/123/avatar_x9k2m.png"Random suffix (non-deterministic)
From("a", "", "b", "/c")"a/b/c"Empty and slash segments trimmed
From("/uploads/", "file.txt")"uploads/file.txt"Leading/trailing slashes removed
Sanitize("UPPERCASE.PNG")"uppercase.png"Lowercased
Sanitize("file___name.txt")"file-name.txt"Consecutive hyphens collapsed

Implicit Conversion​

StoragePath implicitly converts to and from string, so you can use it anywhere a string path is expected:

// Implicit to string
string pathString = StoragePath.From("uploads", "file.txt");
// pathString = "uploads/file.txt"

// Implicit from string
StoragePath path = "uploads/file.txt";
Console.WriteLine(path); // "uploads/file.txt"

// Works with all IStorageProvider methods that accept string paths
var exists = await provider.ExistsAsync(StoragePath.From("uploads", "file.txt"));
var deleted = await provider.DeleteAsync(StoragePath.From("uploads", "old.txt"));

Properties and Decomposition​

var path = StoragePath.From("users", "123", "avatar.png");

Console.WriteLine(path); // "users/123/avatar.png"
Console.WriteLine(path.Value); // "users/123/avatar.png"
Console.WriteLine(path.Directory); // "users/123"
Console.WriteLine(path.FileName); // "avatar.png"
Console.WriteLine(path.Extension); // ".png"
Console.WriteLine(path.Stem); // "avatar" (filename without extension)

Combining Helpers​

Helpers chain in any order. The most common patterns:

// User-uploaded file: sanitize name, add date prefix, add random suffix for uniqueness
var path = StoragePath
.From("users", userId, StoragePath.Sanitize(file.FileName))
.WithDatePrefix()
.WithRandomSuffix();
// → "2026/03/18/users/abc123/my-report_k2x9p.pdf"

// Versioned document: no random suffix needed because version is in the name
var path = StoragePath.From("documents", docId, $"v{version}.pdf");
// → "documents/doc-xyz/v3.pdf"

// Time-ordered event log: timestamp prefix for natural ordering
var path = StoragePath
.From("events", eventType, "payload.json")
.WithTimestampPrefix();
// → "20260318T143022/events/order-placed/payload.json"

// CDN-cacheable asset: hash suffix for cache busting
var path = StoragePath
.From("assets", "styles", "main.css")
.WithHashSuffix();
// → "assets/styles/main_f7a3b2.css"

Use Cases​

User file upload endpoint​

app.MapPost("/users/{userId}/files", async (
string userId,
IFormFile file,
IStorageFactory factory) =>
{
var safeName = StoragePath.Sanitize(file.FileName);
var storagePath = StoragePath
.From("users", userId, safeName)
.WithDatePrefix()
.WithRandomSuffix();

var result = await factory.Create().UploadAsync(new UploadRequest
{
Path = storagePath,
Content = file.OpenReadStream(),
ContentType = file.ContentType,
ContentLength = file.Length
});

return result.IsSuccess
? Results.Ok(new { path = storagePath.Value, url = result.Value.Url })
: Results.Problem(result.ErrorMessage);
}).DisableAntiforgery();

Versioned document storage​

public StoragePath BuildVersionedPath(string documentId, int version, string extension)
{
// "documents/doc-abc123/v3.pdf"
return StoragePath.From("documents", documentId, $"v{version}{extension}");
}

Time-partitioned media archive​

public StoragePath BuildMediaPath(string category, string rawFileName)
{
// "2026/03/18/videos/my-conference-talk.mp4"
return StoragePath
.From(category, StoragePath.Sanitize(rawFileName))
.WithDatePrefix();
}

Generating a thumbnail path from an original path​

public StoragePath ToThumbnailPath(StoragePath originalPath)
{
// "users/123/avatar.png" → "thumbnails/users/123/avatar.png"
return StoragePath.From("thumbnails", originalPath.Value);
}

  • Upload — UploadRequest.Path accepts StoragePath
  • Download — DownloadRequest.Path accepts StoragePath
  • Conflict Resolution — Use WithRandomSuffix to avoid conflicts automatically
  • Listing — Use a StoragePath as the prefix for ListFilesAsync