using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using System.Xml.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace WebDrop.Api.Tests;
///
/// 只实现备份用到的 S3 子集(PUT、HEAD、DELETE、ListObjectsV2),路径风格,数据放在内存里。
/// 检查 SigV4 的凭据字段和 x-amz-content-sha256 与正文是否一致;不重算签名本身(签名算法由真实服务兜底)。
///
public sealed class FakeS3 : IAsyncDisposable
{
private readonly WebApplication _app;
/// 键(不含存储桶)→ 内容
public ConcurrentDictionary Files { get; } = new();
public bool Down { get; set; }
public string Url { get; private set; } = "";
public const string AccessKey = "AKIATEST", SecretKey = "secret", Bucket = "bk";
public FakeS3()
{
var builder = WebApplication.CreateSlimBuilder();
builder.Configuration["urls"] = "http://127.0.0.1:0";
builder.Logging.ClearProviders();
_app = builder.Build();
_app.Map("/{bucket}/{**key}", async (HttpContext ctx, string bucket, string? key) =>
{
if (Down) { ctx.Response.StatusCode = 503; return; }
string auth = ctx.Request.Headers.Authorization.ToString();
if (bucket != Bucket || !auth.StartsWith($"AWS4-HMAC-SHA256 Credential={AccessKey}/", StringComparison.Ordinal) || !auth.Contains("Signature=") || !ctx.Request.Headers.ContainsKey("x-amz-date"))
{ ctx.Response.StatusCode = 403; return; }
key = Uri.UnescapeDataString(key ?? "");
switch (ctx.Request.Method)
{
case "PUT":
{
using var ms = new MemoryStream();
await ctx.Request.Body.CopyToAsync(ms);
var body = ms.ToArray();
if (!string.Equals(ctx.Request.Headers["x-amz-content-sha256"], Convert.ToHexStringLower(SHA256.HashData(body)), StringComparison.Ordinal)) { ctx.Response.StatusCode = 400; return; }
Files[key] = body;
ctx.Response.StatusCode = 200;
break;
}
case "HEAD": ctx.Response.StatusCode = Files.ContainsKey(key) ? 200 : 404; break;
case "DELETE": Files.TryRemove(key, out _); ctx.Response.StatusCode = 204; break;
case "GET":
{
string prefix = ctx.Request.Query["prefix"].ToString(), delimiter = ctx.Request.Query["delimiter"].ToString();
var keys = Files.Keys.Where(k => k.StartsWith(prefix, StringComparison.Ordinal)).Order(StringComparer.Ordinal).ToList();
XNamespace ns = "http://s3.amazonaws.com/doc/2006-03-01/";
var root = new XElement(ns + "ListBucketResult", new XElement(ns + "IsTruncated", "false"));
var folders = new SortedSet(StringComparer.Ordinal);
foreach (var k in keys)
{
int cut = delimiter.Length == 0 ? -1 : k.IndexOf(delimiter, prefix.Length, StringComparison.Ordinal);
if (cut < 0) root.Add(new XElement(ns + "Contents", new XElement(ns + "Key", k)));
else folders.Add(k[..(cut + delimiter.Length)]);
}
foreach (var f in folders) root.Add(new XElement(ns + "CommonPrefixes", new XElement(ns + "Prefix", f)));
ctx.Response.StatusCode = 200;
ctx.Response.ContentType = "application/xml";
await ctx.Response.WriteAsync(root.ToString(), Encoding.UTF8);
break;
}
default: ctx.Response.StatusCode = 405; break;
}
});
}
public async Task StartAsync()
{
await _app.StartAsync();
Url = _app.Services.GetRequiredService().Features.Get()!.Addresses.First().TrimEnd('/');
}
public async ValueTask DisposeAsync()
{
await _app.StopAsync();
await _app.DisposeAsync();
}
}