Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 27 additions & 8 deletions src/Adliance.AzureBlobSimulator/Attributes/BlobNameAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,36 @@ public class BlobNameAttribute : ValidationAttribute
{
public override bool IsValid(object? value)
{
if (value is string valueAsString)
if (value is not string s)
{
if (valueAsString.Length <= 0) return false;
if (valueAsString.Length > 1024) return false;
if (valueAsString.Contains('/')) return false;
if (valueAsString.Contains('\\')) return false;
if (valueAsString.Contains("..")) return false;
return false;
}

if (string.IsNullOrWhiteSpace(s))
{
return false;
}

if (s.Length > 1024)
{
return false;
}

return true;
if (s.StartsWith('/') || s.StartsWith('\\'))
{
return false;
}

if (s.Contains("..", StringComparison.Ordinal))
{
return false;
}

if (s.Contains('\\'))
{
return false;
}

return false;
return true;
}
}
136 changes: 92 additions & 44 deletions src/Adliance.AzureBlobSimulator/Controllers/BlobController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,82 +13,130 @@ public class BlobController(ContainerService containerService) : ControllerBase
public async Task<IActionResult> WriteBlob([FromRoute, ContainerName] string container, [FromRoute, BlobName] string blob)
{
var blobType = Request.Headers["x-ms-blob-type"].ToString();
if (!string.Equals(blobType, "BlockBlob", StringComparison.OrdinalIgnoreCase)) return BadRequest("Only BlockBlob uploads are supported.");
if (!string.Equals(blobType, "BlockBlob", StringComparison.OrdinalIgnoreCase))
{
return BadRequest("Only BlockBlob uploads are supported.");
}

if (!containerService.DoesContainerExist(container)) return NotFound($"Container \"{container}\" not found.");
if (!containerService.DoesContainerExist(container))
{
return NotFound($"Container \"{container}\" not found.");
}

var containerPath = containerService.GetContainerPath(container);
var targetPath = Path.Combine(containerPath, blob);

if (!targetPath.StartsWith(containerPath, StringComparison.Ordinal))
{
return BadRequest("Invalid blob path.");
}

var directory = Path.GetDirectoryName(targetPath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}

await using var fs = new FileStream(targetPath, FileMode.Create, FileAccess.Write);
await Request.Body.CopyToAsync(fs);
await fs.FlushAsync();

var fileInfo = new FileInfo(targetPath);
Response.Headers["x-ms-version"] = Constants.MsVersion;
Response.Headers["ETag"] = Guid.NewGuid().ToString();
Response.Headers["Last-Modified"] = fileInfo.LastWriteTimeUtc.ToString("R");
Response.Headers["x-ms-request-server-encrypted"] = "false";
Response.Headers.ETag = containerService.GetBlobETag(container, blob);
Response.Headers.LastModified = fileInfo.LastWriteTimeUtc.ToString("R");
return StatusCode(201);
}

[HttpGet("/{container}/{*blob}")]
public IActionResult ReadBlob([FromRoute, ContainerName] string container, [FromRoute, BlobName] string blob)
{
if (!containerService.DoesBlobExist(container, blob)) return NotFound($"Blob \"{container}/{blob}\" not found.");

var fileInfo = new FileInfo(containerService.GetBlobPath(container, blob));
var contentTypeProvider = new FileExtensionContentTypeProvider();
var contentType = contentTypeProvider.TryGetContentType(fileInfo.Name, out var c) ? c : "application/octet-stream";

Response.Headers["x-ms-version"] = Constants.MsVersion;
Response.Headers["x-ms-blob-type"] = "BlockBlob";
Response.Headers["x-ms-lease-status"] = "unlocked";
Response.Headers["x-ms-lease-state"] = "available";
Response.Headers["Last-Modified"] = fileInfo.LastWriteTimeUtc.ToString("R");
Response.Headers["ETag"] = Guid.NewGuid().ToString();
Response.Headers["Content-Length"] = fileInfo.Length.ToString(CultureInfo.InvariantCulture);

// support custom ms range header, see https://learn.microsoft.com/en-us/rest/api/storageservices/get-blob?tabs=microsoft-entra-id#request
if (Request.Headers.TryGetValue("x-ms-range", out var requestedRange))
Request.Headers["Range"] = requestedRange;

var fileStream = new FileStream(fileInfo.FullName, FileMode.Open, FileAccess.Read);
return File(fileStream, contentType, enableRangeProcessing: true);
try
{
var path = containerService.GetBlobPath(container, blob);

if (!System.IO.File.Exists(path))
{
return NotFound($"Blob \"{container}/{blob}\" not found.");
}

var fileInfo = new FileInfo(path);
var contentTypeProvider = new FileExtensionContentTypeProvider();
var contentType = contentTypeProvider.TryGetContentType(fileInfo.Name, out var c) ? c : "application/octet-stream";

Response.Headers["x-ms-version"] = Constants.MsVersion;
Response.Headers["x-ms-blob-type"] = "BlockBlob";
Response.Headers["x-ms-lease-status"] = "unlocked";
Response.Headers["x-ms-lease-state"] = "available";
Response.Headers["Content-Length"] = fileInfo.Length.ToString(CultureInfo.InvariantCulture);
Response.Headers.ETag = containerService.GetBlobETag(container, blob);
Response.Headers.LastModified = fileInfo.LastWriteTimeUtc.ToString("R");

// // support custom ms range header, see https://learn.microsoft.com/en-us/rest/api/storageservices/get-blob?tabs=microsoft-entra-id#request
if (Request.Headers.TryGetValue("x-ms-range", out var requestedRange))
{
Request.Headers.Range = requestedRange;
}

var fileStream = new FileStream(fileInfo.FullName, FileMode.Open, FileAccess.Read, FileShare.Read);
return File(fileStream, contentType, enableRangeProcessing: true);
}
catch (DirectoryNotFoundException directoryNotFoundException)
{
return NotFound(directoryNotFoundException.Message);
}
}

[HttpHead("/{container}/{*blob}")]
public IActionResult GetBlobProperties([FromRoute, ContainerName] string container, [FromRoute, BlobName] string blob)
{
if (!containerService.DoesBlobExist(container, blob)) return NotFound($"Blob \"{container}/{blob}\" not found.");

var fileInfo = new FileInfo(containerService.GetBlobPath(container, blob));
var contentTypeProvider = new FileExtensionContentTypeProvider();

var contentType = contentTypeProvider.TryGetContentType(fileInfo.FullName, out var detectedContentType) ? detectedContentType : "application/octet-stream";
Response.Headers["x-ms-version"] = Constants.MsVersion;
Response.Headers["x-ms-blob-type"] = "BlockBlob";
Response.Headers["x-ms-lease-status"] = "unlocked";
Response.Headers["x-ms-lease-state"] = "available";
Response.Headers["Last-Modified"] = fileInfo.LastWriteTimeUtc.ToString("R");
Response.Headers["ETag"] = Guid.NewGuid().ToString();
Response.Headers["Content-Length"] = fileInfo.Length.ToString(CultureInfo.InvariantCulture);
Response.Headers["Content-Type"] = contentType;
Response.Headers["Accept-Ranges"] = "bytes";
Response.Headers["x-ms-server-encrypted"] = "false";
return Ok();
try
{
var path = containerService.GetBlobPath(container, blob);

if (!System.IO.File.Exists(path))
{
return NotFound($"Blob \"{container}/{blob}\" not found.");
}

var fileInfo = new FileInfo(path);
var contentTypeProvider = new FileExtensionContentTypeProvider();

var contentType = contentTypeProvider.TryGetContentType(fileInfo.FullName, out var detectedContentType) ? detectedContentType : "application/octet-stream";
Response.Headers["x-ms-version"] = Constants.MsVersion;
Response.Headers["x-ms-blob-type"] = "BlockBlob";
Response.Headers["x-ms-lease-status"] = "unlocked";
Response.Headers["x-ms-lease-state"] = "available";
Response.Headers["Content-Length"] = fileInfo.Length.ToString(CultureInfo.InvariantCulture);
Response.Headers["x-ms-server-encrypted"] = "false";
Response.Headers.AcceptRanges = "bytes";
Response.Headers.ContentType = contentType;
Response.Headers.ETag = containerService.GetBlobETag(container, blob);
Response.Headers.LastModified = fileInfo.LastWriteTimeUtc.ToString("R");
return Ok();
}
catch (DirectoryNotFoundException directoryNotFoundException)
{
return NotFound(directoryNotFoundException.Message);
}
}

[HttpDelete("/{container}/{*blob}")]
public IActionResult DeleteBlob(
[FromRoute, ContainerName] string container,
[FromRoute, BlobName] string blob)
{
if (!containerService.DoesBlobExist(container, blob))
var path = containerService.GetBlobPath(container, blob);

if (!System.IO.File.Exists(path))
{
return NotFound($"Blob \"{container}/{blob}\" not found.");
}

var blobPath = containerService.GetBlobPath(container, blob);
System.IO.File.Delete(path);

System.IO.File.Delete(blobPath);
containerService.CleanupBlobDirectoryChain(path, containerService.GetContainerPath(container));

Response.Headers["x-ms-version"] = Constants.MsVersion;
Response.Headers["x-ms-delete-type-permanent"] = "true";
Expand Down
73 changes: 45 additions & 28 deletions src/Adliance.AzureBlobSimulator/Controllers/BlobsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,36 +11,42 @@ public class BlobsController(ContainerService containerService) : ControllerBase
{
[HttpGet("/{container}")]
public IActionResult HandleGetRequest([FromRoute, ContainerName] string container, [FromQuery] string? comp, [FromQuery] string? restype)
{
if (restype == "container" && comp == "list") return ListBlobs(container);
if (restype == "container" && comp == null) return GetContainerProperties(container);
return BadRequest("Unsupported operation on GET /<container>.");
}
=> restype switch
{
"container" when comp == "list" => ListBlobs(container),
"container" when comp == null => GetContainerProperties(container),
_ => BadRequest("Unsupported operation on GET /<container>.")
};

private IActionResult ListBlobs(string container)
{
if (!containerService.DoesContainerExist(container)) return NotFound($"Container \"{container}\" not found.");
if (!containerService.DoesContainerExist(container))
{
return NotFound($"Container \"{container}\" not found.");
}

var contentTypeProvider = new FileExtensionContentTypeProvider();
var filePaths = containerService.GetFilePaths(container);

var blobs = filePaths.Select(filePath =>
{
var fileInfo = new FileInfo(filePath);
return new Blob
var blobs = containerService.GetBlobNames(container)
.Select(blobName =>
{
Name = fileInfo.Name,
Properties = new BlobProperties
var meta = containerService.GetBlobMetadata(container, blobName);

return new Blob
{
LastModified = fileInfo.LastWriteTimeUtc.ToString("R"),
Created = fileInfo.CreationTimeUtc.ToString("R"),
Etag = Guid.NewGuid().ToString(),
ContentLength = fileInfo.Length,
ContentType = contentTypeProvider.TryGetContentType(fileInfo.Name, out var contentType) ? contentType : "application/octet-stream",
BlobType = "BlockBlob"
}
};
});
// we can suppress this warning because we know that the name and ContentType are not null
Name = meta.Name!,
Properties = new BlobProperties
{
LastModified = meta.LastModified.ToString("R"),
Created = meta.Created.ToString("R"),
Etag = containerService.GetBlobETag(container, blobName),
ContentLength = meta.ContentLength,
ContentType = meta.ContentType!,
BlobType = "BlockBlob"
}
};
});

var response = new BlobEnumerationResults
{
Expand All @@ -49,25 +55,36 @@ private IActionResult ListBlobs(string container)
Blobs = new BlobsCollection(blobs.ToList())
};

var responseXml = XmlSerializationService.SerializeToXml(response);

Response.Headers["x-ms-version"] = Constants.MsVersion;
return Content(responseXml, "application/xml");

return Content(XmlSerializationService.SerializeToXml(response), "application/xml");
}

private IActionResult GetContainerProperties(string container)
{
if (!containerService.DoesContainerExist(container)) return NotFound($"Container \"{container}\" not found.");
if (!containerService.DoesContainerExist(container))
{
return NotFound($"Container \"{container}\" not found.");
}

Response.Headers["x-ms-version"] = Constants.MsVersion;
Response.Headers["Last-Modified"] = DateTime.UtcNow.ToString("R");
Response.Headers["ETag"] = $"\"{Guid.NewGuid()}\"";
Response.Headers["x-ms-lease-status"] = "unlocked";
Response.Headers["x-ms-lease-state"] = "available";
Response.Headers["x-ms-has-immutability-policy"] = "false";
Response.Headers["x-ms-has-legal-hold"] = "false";
Response.Headers["x-ms-default-encryption-scope"] = "$account-encryption-key";
Response.Headers["x-ms-deny-encryption-scope-override"] = "false";
Response.Headers.ETag = containerService.GetContainerETag(container);

var containerPath = containerService.GetContainerPath(container);

var lastModifiedTicks = Directory
.GetFiles(containerPath, "*", SearchOption.AllDirectories)
.Select(f => new FileInfo(f).LastWriteTimeUtc.Ticks)
.DefaultIfEmpty(DateTime.UtcNow.Ticks)
.Max();

Response.Headers.LastModified = new DateTime(lastModifiedTicks, DateTimeKind.Utc).ToString("R");

return Ok();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ public class ContainersController(ContainerService containerService) : Controlle
{
[HttpGet("/")]
public IActionResult HandleGetRequest([FromQuery] string comp, [FromQuery] string? restype)
{
if (comp == "properties" && restype == "account") return GetAccountProperties();
if (comp == "list") return ListContainers();
return BadRequest("Unsupported operation on GET /.");
}
=> comp switch
{
"properties" when restype == "account" => GetAccountProperties(),
"list" => ListContainers(),
_ => BadRequest("Unsupported operation on GET /.")
};

private IActionResult GetAccountProperties()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System.Reflection;
using System.Reflection;
using Microsoft.AspNetCore.Mvc;

namespace Adliance.AzureBlobSimulator.Controllers;
Expand Down
15 changes: 15 additions & 0 deletions src/Adliance.AzureBlobSimulator/Models/BlobMetadata.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace Adliance.AzureBlobSimulator.Models;

public class BlobMetadata
{
public string? Path { get; set; }
public string? Name { get; set; }

public long ContentLength { get; set; }
public string? ContentType { get; set; }

public DateTime LastModified { get; set; }
public DateTime Created { get; set; }

public string? ETag { get; set; }
}
2 changes: 1 addition & 1 deletion src/Adliance.AzureBlobSimulator/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<StorageOptions>(builder.Configuration.GetSection(StorageOptions.SectionName));
builder.Services.AddSingleton<ContainerService>();
builder.Services.AddScoped<ContainerService>();
builder.Services.AddTransient<SasValidatorService>();
builder.Services.AddTransient<SharedKeyAuthService>();
builder.Services.AddLogging();
Expand Down
Loading
Loading