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(); } }