This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Less3 is an S3-compatible object storage platform written in C# (.NET 8.0) that can be deployed anywhere. It implements AWS S3 APIs using the S3Server library and provides both path-style and virtual-hosted URL support for bucket access.
# Build the solution
dotnet build src/Less3.sln
# Build specific configuration
dotnet build src/Less3.sln -c Release
dotnet build src/Less3.sln -c Debug
# Publish for deployment
dotnet publish src/Less3/Less3.csproj -c Release -o ./publish# Run from the project directory
cd src/Less3
dotnet run
# Run with setup wizard (creates system.json and less3.db)
dotnet run setup
# Run from published output
cd publish
dotnet Less3.dll# Using Docker Compose
cd Docker
docker compose up -d
docker compose down
# Or use the provided scripts
./Docker/compose-up.sh # Linux/Mac
./Docker/compose-up.bat # WindowsSee AWSCLI.md for comprehensive AWS CLI testing commands. Key endpoints:
- Default access key:
default - Default secret key:
default - Default endpoint:
http://localhost:8000
Program.cs (src/Less3/Program.cs)
- Entry point and initialization
- Creates all managers in specific order: Logging → Database → Config → Bucket → Auth → API Handler → Admin API Handler → Console → S3Server
- Hosts the S3Server and routes requests through PreRequestHandler → S3Server APIs → PostRequestHandler
- Handles authentication/authorization in PreRequestHandler before delegating to S3Server
Manager Layer
ConfigManager: Manages users, credentials, buckets via WatsonORMBucketManager: Manages bucket lifecycle and maintains BucketClient instances for each bucketAuthManager: Handles authentication and authorization, produces RequestMetadata with auth resultsConsoleManager: Interactive console for administration when enabled
API Layer (src/Less3/Api/)
ApiHandler: Primary S3 API facade that delegates to specialized handlersServiceHandler: Service-level APIs (ListBuckets)BucketHandler: Bucket operations (Create, Delete, Read, Write, ACLs, Tags, Versioning)ObjectHandler: Object operations (Read, Write, Delete, Range reads, ACLs, Tags)AdminApiHandler: Administrative APIs accessed via x-api-key header
Storage Layer (src/Less3/Storage/)
StorageDriverBase: Abstract base class for storage backendsDiskStorageDriver: File system-based storage implementation- Objects stored in
{bucket.DiskDirectory}/{obj.BlobFilename}
Data Models (src/Less3/Classes/)
Bucket,Obj,User,Credential: Core entities stored in WatsonORMBucketAcl,ObjectAcl: Access control listsBucketTag,ObjectTag: Tagging supportRequestMetadata: Contains authentication/authorization results, bucket/object references for each request
- HTTP request arrives →
PreRequestHandlerin Program.cs - Authentication: Extract access key from request, look up User and Credential
- Authorization: Check bucket/object ownership, ACLs (AllUsers, AuthenticatedUsers, per-user), or bucket global config (EnablePublicRead/Write)
- Store
RequestMetadatainctx.Metadata - Delegate to appropriate handler (Service/Bucket/Object)
- Handler retrieves metadata via
ApiHelper.GetRequestMetadata(ctx) - Handler performs operation, interacts with BucketClient
- Response sent,
PostRequestHandlerlogs metrics
Authentication Flow (AuthManager.AuthenticateAndBuildMetadata)
- Extracts access key from Authorization header
- Looks up Credential and User
- Populates RequestMetadata with authentication result (Authenticated, NotAuthenticated, NoMaterialSupplied, AccessKeyNotFound, UserNotFound)
- Loads Bucket, Object, ACLs into RequestMetadata
Authorization Flow (AuthManager.Authorize* methods) Authorization is checked in this order:
- Admin API key (full access)
- Bucket/Object global config (EnablePublicRead/Write)
- AllUsers ACLs (anonymous access)
- Authenticated user checks:
- Bucket/Object ownership
- AuthenticatedUsers ACLs
- Per-user ACLs
Authorization Results
AdminAuthorized: Admin API key usedPermitBucketOwnership/PermitObjectOwnership: User owns the resourcePermitBucketGlobalConfig: Bucket allows public accessPermitBucketAllUsersAcl/PermitObjectAllUsersAcl: AllUsers ACL grants accessPermitBucketAuthUserAcl/PermitObjectAuthUserAcl: AuthenticatedUsers ACL grants accessPermitBucketUserAcl/PermitObjectUserAcl: Per-user ACL grants accessNotAuthorized: Access denied
Tables initialized in Program.cs InitializeGlobals:
User: Users in the system (GUID, Name, Email, etc.)Credential: Access keys and secret keys linked to usersBucket: Bucket metadata (Name, OwnerGUID, DiskDirectory, EnablePublicRead/Write, EnableVersioning)BucketAcl: Bucket-level access controlBucketTag: Bucket tagsObj: Object metadata (Key, Version, BlobFilename, ContentLength, ContentType, OwnerGUID, DeleteMarker)ObjectAcl: Object-level access controlObjectTag: Object tags
Supports SQLite (default), SQL Server, MySQL, PostgreSQL via WatsonORM.
Created by Setup wizard if not exists. Key settings:
Webserver.Hostname: DNS hostname (must not be IP address, use*for wildcard)Webserver.Port: TCP port (default 8000)BaseDomain: For virtual hosted URLs (e.g.,.localhost), null for path-styleStorage.DiskDirectory: Root directory for object storage (default./disk/)Storage.TempDirectory: Temporary upload directory (default./temp/)Database: WatsonORM DatabaseSettings (SQLite default:./less3.db)ValidateSignatures: Enable/disable AWS signature validationAdminApiKey: API key for admin endpoints (defaultless3admin)
Path-Style (default, BaseDomain = null)
- URL format:
http://hostname:port/bucket/key - Hostname is fixed (e.g.,
localhost)
Virtual Hosted (BaseDomain set, e.g., .localhost)
- URL format:
http://bucket.hostname:port/key - Requires wildcard hostname (
*) and admin/root privileges - Bucket name extracted from subdomain
You are on branch feature/multipart. New classes have been added:
src/Less3/Classes/Upload.cs: Tracks multipart upload sessionssrc/Less3/Classes/UploadPart.cs: Tracks individual parts in multipart uploads
When implementing multipart upload APIs:
- Follow the existing handler pattern (Service/Bucket/Object handlers)
- Use
BucketClientto manage upload state in database - Store parts in temp directory during upload, merge on CompleteMultipartUpload
- Implement these S3 APIs:
- InitiateMultipartUpload
- UploadPart
- CompleteMultipartUpload
- AbortMultipartUpload
- ListParts
- ListMultipartUploads
- Add handler method to appropriate handler class (ServiceHandler, BucketHandler, or ObjectHandler)
- Wire up in ApiHandler (internal method that delegates)
- Register callback in Program.cs InitializeGlobals with S3Server instance:
_S3Server.Object.YourNewMethod = _ApiHandler.ObjectYourNewMethod;
- Handler pattern:
internal async Task YourMethod(S3Context ctx) { RequestMetadata md = ApiHelper.GetRequestMetadata(ctx); // Check authorization if (md.Authorization == AuthorizationResult.NotAuthorized) throw new S3Exception(new Error(ErrorCode.AccessDenied)); // Perform operation using md.BucketClient }
BucketClient is the primary interface for bucket operations:
- Retrieved via
BucketManager.GetClient(bucketName) - Stored in
RequestMetadata.BucketClient - Key methods:
- Object metadata:
GetObjectLatestMetadata,GetObjectVersionMetadata,GetObjectLatestVersion - Object operations:
AddObject,DeleteObjectVersion - ACLs:
GetBucketAcl,GetObjectAcl,AddObjectAcl,DeleteObjectVersionAcl - Tags:
GetBucketTags,GetObjectTags,AddObjectVersionTags,DeleteObjectVersionTags
- Object metadata:
- Versioning disabled by default on new buckets
- When versioning disabled: Overwriting existing object throws
InvalidBucketState - When enabled: New writes create new version (Version counter increments)
- Version IDs are integers (not strings like AWS S3)
- Delete creates delete marker when versioning enabled
- Version 1 is special: If version not specified, version 1 assumed
Use S3Exception with ErrorCode:
throw new S3Exception(new Error(ErrorCode.NoSuchBucket));
throw new S3Exception(new Error(ErrorCode.AccessDenied));
throw new S3Exception(new Error(ErrorCode.NoSuchKey));
throw new S3Exception(new Error(ErrorCode.NoSuchVersion));Common error codes:
NoSuchBucket,NoSuchKey,NoSuchVersionAccessDenied,InvalidBucketStateInternalError,InvalidRange
CRITICAL: These standards MUST be followed strictly in all code. They ensure consistency and maintainability across the codebase.
Namespace and Using Statements
- Namespace declaration must be at the top of the file
- All
usingstatements must be contained INSIDE the namespace block - Microsoft and standard system library usings must be listed first, in alphabetical order
- Other using statements follow, also in alphabetical order
namespace Less3.Classes
{
using System;
using System.Collections.Generic;
using System.IO;
using S3ServerLibrary;
using SyslogLogging;
public class Example { }
}File Organization
- Limit each file to containing exactly ONE class or exactly ONE enum
- Do NOT nest multiple classes or multiple enums in a single file
- Regions are NOT required for files under 500 lines
- For larger files, use regions:
Public-Members,Private-Members,Constructors-and-Factories,Public-Methods,Private-Methods
Private Member Variables
- MUST start with underscore followed by Pascal case
- Correct:
_Settings,_Logging,_BucketManager - Incorrect:
_settings,_fooBar,settings
Example:
private Settings _Settings;
private LoggingModule _Logging;
private ConfigManager _Config;Public Members, Constructors, and Public Methods
- MUST have XML code documentation
- Document parameters, return values, and exceptions
- Specify default values, minimum/maximum values where applicable
- Explain what different values mean or their effects
/// <summary>
/// Maximum number of retry attempts for failed operations.
/// Default value is 3. Minimum value is 1. Maximum value is 10.
/// Higher values increase reliability but may impact performance.
/// </summary>
public int MaxRetries { get; set; } = 3;Private Members and Private Methods
- MUST NOT have code documentation
- Keep private implementation details undocumented
Exception Documentation
/// <summary>
/// Retrieves a user by their GUID.
/// </summary>
/// <param name="guid">User GUID.</param>
/// <returns>User object if found.</returns>
/// <exception cref="ArgumentNullException">Thrown when guid is null or empty.</exception>
/// <exception cref="InvalidOperationException">Thrown when database connection fails.</exception>
public User GetUserByGuid(string guid)Public Members with Validation
- Use explicit getters and setters with backing variables when value requires range or null validation
- Validate in the setter
private int _MaxConnections = 100;
/// <summary>
/// Maximum number of concurrent connections.
/// Default value is 100. Minimum value is 1. Maximum value is 10000.
/// </summary>
public int MaxConnections
{
get { return _MaxConnections; }
set
{
if (value < 1 || value > 10000)
throw new ArgumentOutOfRangeException(nameof(value), "Must be between 1 and 10000.");
_MaxConnections = value;
}
}No var Keyword
- Do NOT use
varwhen defining variables - Always use the actual type
// Correct
List<Bucket> buckets = new List<Bucket>();
string fileName = GetFileName();
// Incorrect
var buckets = new List<Bucket>();
var fileName = GetFileName();CancellationToken Requirements
- Every async method MUST accept a CancellationToken as an input parameter
- Exception: If the class has a CancellationToken or CancellationTokenSource as a class member
- Check for cancellation at appropriate places in long-running operations
public async Task<List<Bucket>> GetBucketsAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
List<Bucket> buckets = await _ORM.SelectAsync<Bucket>(cancellationToken).ConfigureAwait(false);
return buckets;
}ConfigureAwait Usage
- Use
.ConfigureAwait(false)on await calls where appropriate - Prevents deadlocks in library code
byte[] data = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false);Sync and Async Variants
- When implementing a method that returns
IEnumerable<T>, also create an async variant - Async variant must include a CancellationToken parameter
public IEnumerable<Bucket> GetBuckets()
{
// Synchronous implementation
}
public async Task<IEnumerable<Bucket>> GetBucketsAsync(CancellationToken cancellationToken = default)
{
// Asynchronous implementation
}Specific Exception Types
- Use specific exception types rather than generic
Exception - Always include meaningful error messages with context
- Consider custom exception types for domain-specific errors
// Good
if (String.IsNullOrEmpty(bucketName))
throw new ArgumentNullException(nameof(bucketName), "Bucket name cannot be null or empty.");
if (port < 1 || port > 65535)
throw new ArgumentOutOfRangeException(nameof(port), port, "Port must be between 1 and 65535.");
// Bad
if (String.IsNullOrEmpty(bucketName))
throw new Exception("Invalid bucket name");Exception Filters
try
{
// Database operation
}
catch (SqlException ex) when (ex.Number == 2601)
{
// Handle duplicate key error specifically
}IDisposable Pattern
- Implement IDisposable/IAsyncDisposable when holding unmanaged resources or disposable objects
- Use
usingstatements orusingdeclarations for IDisposable objects - Follow the full Dispose pattern with
protected virtual void Dispose(bool disposing) - Always call
base.Dispose()in derived classes
public class ResourceManager : IDisposable
{
private bool _Disposed = false;
private FileStream _Stream;
protected virtual void Dispose(bool disposing)
{
if (_Disposed) return;
if (disposing)
{
// Dispose managed resources
if (_Stream != null)
{
_Stream.Dispose();
_Stream = null;
}
}
// Free unmanaged resources here if any
_Disposed = true;
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
}Using Statements
using (FileStream fs = new FileStream(path, FileMode.Open))
{
// Use fs
}
// Or with declaration (C# 8+)
using FileStream fs = new FileStream(path, FileMode.Open);
// fs automatically disposed at end of scopeEnable Nullable Reference Types
- Use nullable reference types (enable
<Nullable>enable</Nullable>in project files) - Document nullability in XML comments
Guard Clauses
- Validate input parameters with guard clauses at method start
- Use
ArgumentNullException.ThrowIfNull()for .NET 6+ or manual null checks - Proactively identify and eliminate situations where null might cause exceptions
public void ProcessBucket(Bucket bucket, string ownerGuid)
{
ArgumentNullException.ThrowIfNull(bucket);
if (String.IsNullOrEmpty(ownerGuid))
throw new ArgumentNullException(nameof(ownerGuid), "Owner GUID cannot be null or empty.");
// Method implementation
}Result Pattern
- Consider using the Result pattern or Option/Maybe types for methods that can fail
- Avoids throwing exceptions for expected failure cases
Documentation
- Document thread safety guarantees in XML comments
- Clearly state if a class or method is thread-safe
/// <summary>
/// Thread-safe bucket manager.
/// All public methods can be safely called from multiple threads.
/// </summary>
public class BucketManagerSynchronization
- Use
Interlockedoperations for simple atomic operations - Prefer
ReaderWriterLockSlimoverlockfor read-heavy scenarios - Use proper locking for shared state
private readonly object _BucketsLock = new object();
private List<BucketClient> _Buckets = new List<BucketClient>();
public void AddBucket(BucketClient client)
{
lock (_BucketsLock)
{
_Buckets.Add(client);
}
}Prefer LINQ when readable
- Use LINQ methods over manual loops when readability is not compromised
- Use
.Any()instead of.Count() > 0for existence checks - Use
.FirstOrDefault()with null checks rather than.First()when element might not exist
// Good - check for existence
if (buckets.Any(b => b.Name == "default"))
// Bad - counts entire collection
if (buckets.Count(b => b.Name == "default") > 0)
// Good - safe access
Bucket bucket = buckets.FirstOrDefault(b => b.Name == targetName);
if (bucket != null) { }
// Bad - throws if not found
Bucket bucket = buckets.First(b => b.Name == targetName);Multiple Enumeration
- Be aware of multiple enumeration issues
- Consider
.ToList()when enumerating multiple times
IEnumerable<Bucket> query = GetBuckets().Where(b => b.EnableVersioning);
// If using query multiple times
List<Bucket> buckets = query.ToList();
int count = buckets.Count;
foreach (Bucket bucket in buckets) { }Tuples Should Be Avoided
- Do NOT use tuples unless absolutely, absolutely necessary
- Create dedicated classes or structs instead
- Tuples reduce code readability and type safety
// Bad
public (string Name, int Count) GetBucketInfo()
// Good
public BucketInfo GetBucketInfo()
public class BucketInfo
{
public string Name { get; set; }
public int Count { get; set; }
}Configurable Values
- Avoid using constant values for things developers may later want to configure or change
- Use public members with backing private members set to reasonable defaults
- Document the default values in XML comments
// Good
private int _DefaultTimeout = 30000;
/// <summary>
/// Default timeout in milliseconds.
/// Default value is 30000 (30 seconds).
/// </summary>
public int DefaultTimeout
{
get { return _DefaultTimeout; }
set { _DefaultTimeout = value; }
}
// Bad
const int DEFAULT_TIMEOUT = 30000;Don't Make Assumptions
- Do NOT make assumptions about what class members or methods exist on opaque classes
- If a class implementation is not visible to you, ASK for the implementation
- Never guess at API surface or behavior
Manual SQL Preparation
- If code uses manually prepared strings for SQL statements, there is a good reason
- Assume the existing approach is correct
- Do not attempt to "fix" or refactor SQL statement construction without discussion
No Console Output
- Ensure NO
Console.WriteLinestatements are added to library code - Use the logging framework instead (
_Logging.Info(),_Logging.Debug(), etc.)
// Bad
Console.WriteLine("Processing bucket: " + bucketName);
// Good
_Logging.Info("Processing bucket: " + bucketName);-
Hostname Requirements:
Server.DnsHostnamein system.json cannot be an IP address (parsing will fail). Use DNS names orlocalhost. -
Wildcard Listeners: Using
*,+, or0.0.0.0for hostname requires administrative/root privileges. -
Versioning: Version IDs are integers internally, not opaque strings like AWS S3. This is a minor compatibility difference.
-
Database: SQLite default is convenient but not recommended for production containers without persistent volume mounts.
-
Authorization Check Order: Always check authorization before performing operations. The order in AuthManager determines precedence (admin → ownership → public → AllUsers → authenticated → per-user).
-
File Paths: Use forward slashes in paths. Code normalizes paths in Settings.
Admin APIs require x-api-key header with value matching Settings.AdminApiKey (default: less3admin).
Format: http://hostname:port/admin/{resource}/{operation}
Handlers in src/Less3/Api/Admin/:
GetHandler: Retrieve resourcesPostHandler: Create/update resourcesDeleteHandler: Delete resources
Access granted via AdminAuthorized authorization result, bypassing normal ACL checks.