DKNet

DKNet.Svc.BlobStorage.Local

Local-filesystem implementation of IBlobService that stores blobs under a configured root folder with path-traversal protection.

For the operations, models, and validation rules every provider shares, read the Abstractions page first — this page covers only what is specific to this provider.

✨ Why use it?

🚀 Quick Start

dotnet add package DKNet.Svc.BlobStorage.Local
// appsettings.json
// { "BlobStorage": { "LocalFolder": { "RootFolder": "/var/app/storage" } } }

builder.Services.AddLocalDirectoryBlobService(builder.Configuration);
public sealed class ReportStorage(IBlobService blobService)
{
    public Task<string> SaveReportAsync(Stream pdf, CancellationToken ct) =>
        blobService.SaveAsync(new BlobDetails.BlobData("reports/monthly.pdf", BinaryData.FromStream(pdf)), ct);
}

AddLocalDirectoryBlobService(IServiceCollection, IConfiguration) binds LocalDirectoryOptions from "BlobStorage:LocalFolder" and registers IBlobService → LocalBlobService as Scoped; the call is idempotent.

🧩 Features

Root folder resolution

RootFolder is the base of every path this provider touches. Left unset, it falls back to {CurrentDirectory}/LocalStore — fine for a quick local run, but set it explicitly for anything you deploy, because the current directory is whatever the process happened to start in.

Path-traversal guard

Every request name is combined with the root and resolved to a full path; if the result does not sit under the root, the call throws UnauthorizedAccessException instead of touching the filesystem:

// throws UnauthorizedAccessException — resolves outside the configured root
await blobService.GetAsync(new BlobRequest("../../etc/passwd"));

Comparison is case-insensitive on Windows and ordinal elsewhere. A single leading / on the name is stripped first, so "/reports/monthly.pdf" and "reports/monthly.pdf" address the same file.

Read misses throw instead of returning null

GetAsync throws FileNotFoundException when the file does not exist. Each provider signals a miss differently — S3 returns null, Azure Storage throws Azure.RequestFailedException (404) — so code that has to run against any provider must handle every shape:

BlobDetails.BlobDataResult? found;
try
{
    found = await blobService.GetAsync(new BlobRequest("reports/monthly.pdf"), ct);
}
catch (FileNotFoundException)
{
    found = null; // Local provider's "missing" signal
}

CheckExistsAsync has no such split — it returns false for a missing file (or missing directory, for a directory request) on every provider.

GetAsync reads the file through a stream it properly closes once the content is buffered into the returned BinaryData — earlier revisions could leak the underlying FileStream handle, which on Windows blocked a subsequent write to the same path until the process released it.

Save, overwrite, and directory creation

SaveAsync validates against BlobServiceOptions, then throws InvalidOperationException("File already existed") when the target exists and Overwrite is false (the shared default). Missing parent directories are created automatically, and the returned location is the name you passed in:

var blob = new BlobDetails.BlobData("reports/2026/q1.pdf", BinaryData.FromString("..."))
{
    Overwrite = true // replace an existing file instead of throwing
};
var location = await blobService.SaveAsync(blob, ct); // "reports/2026/q1.pdf"

The write itself goes through File.WriteAllBytesAsync with the payload as a ReadOnlyMemory<byte>, so SaveAsync does not make an extra full-buffer copy of the blob’s data before writing it.

Listing a directory

ListItemsAsync against a directory yields every file underneath it recursively (each with Details populated from FileInfo), then every nested directory as a bare entry with no Details. Against a single file path it yields just that one file, or nothing when the file is absent — and a path that is neither an existing directory nor an existing file also yields an empty sequence, the same “not found ⇒ nothing” shape S3 and Azure Storage use, rather than throwing. Names come back relative to the root folder, computed with Path.GetRelativePath — correct even when a subfolder happens to share the root folder’s own name (e.g. root /var/store, file /var/store/tenants/store/a.txt resolves to tenants/store/a.txt, not a mangled name that would point at the wrong file).

No public URLs

GetPublicAccessUrl always throws NotSupportedException — a local path has no shareable URL to hand out. Use S3 or Azure Storage if the calling code needs one.

⚙️ Configuration reference

LocalDirectoryOptions extends BlobServiceOptions:

Option Type Default Effect
RootFolder string? null → {CurrentDirectory}/LocalStore at runtime Base directory for every blob; also the boundary the traversal guard enforces.
Name (static) string "BlobStorage:LocalFolder" Configuration section key AddLocalDirectoryBlobService binds from.

The shared IncludedExtensions, MaxFileNameLength, and MaxFileSizeInMb checks apply unchanged.

🧱 Where it fits

Every operation resolves the requested name to an absolute path and re-checks it against the root before touching the file system:

Workflow diagram: GetFinalPath drops a leading slash from the blob name, combines it with RootFolder and resolves it with Path.GetFullPath, then compares the result against the root prefix — ordinal on Linux, case-insensitive on Windows — throwing UnauthorizedAccessException when it escapes and otherwise reading or writing the file and returning the root-relative name.

⚠️ Gotchas & limits