using System.Net; using System.Net.Http.Headers; using System.Text; namespace dy.net.Tests.TestInfrastructure; internal sealed record RecordedWebDavRequest( string Method, string RawUri, string DecodedPath, string Authorization, string Range, string Destination, string CacheControl); internal sealed class InMemoryWebDavHandler : HttpMessageHandler { private readonly Dictionary _files = new(StringComparer.Ordinal); private readonly HashSet _directories = new(StringComparer.Ordinal) { "/" }; private readonly Dictionary _staleMetadataReads = new(StringComparer.Ordinal); private readonly Dictionary _staleAllReads = new(StringComparer.Ordinal); private readonly Dictionary _staleLengthReads = new(StringComparer.Ordinal); public InMemoryWebDavHandler(string userName, string password) { var token = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{userName}:{password}")); ExpectedAuthorization = "Basic " + token; } public string ExpectedAuthorization { get; } public bool SupportsRange { get; set; } = true; public bool IncludeContentRange { get; set; } = true; public HttpStatusCode ExistingDirectoryStatusCode { get; set; } = HttpStatusCode.MethodNotAllowed; public bool FailNextMove { get; set; } public int StaleMetadataReadsAfterMove { get; set; } public int StaleAllReadsAfterMove { get; set; } public int StaleLengthReadsAfterMove { get; set; } public List Requests { get; } = new(); public IReadOnlyDictionary Files => _files; protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var path = DecodePath(request.RequestUri); var authorization = request.Headers.Authorization?.ToString(); var destination = request.Headers.TryGetValues("Destination", out var destinationValues) ? destinationValues.Single() : null; Requests.Add(new RecordedWebDavRequest( request.Method.Method, request.RequestUri.AbsoluteUri, path, authorization, request.Headers.Range?.ToString(), destination, request.Headers.CacheControl?.ToString())); if (authorization != ExpectedAuthorization) return Response(HttpStatusCode.Unauthorized); return request.Method.Method switch { "MKCOL" => CreateDirectory(path), "PUT" => await PutAsync(path, request, cancellationToken), "HEAD" => Head(path), "PROPFIND" => PropFind(path), "MOVE" => Move(path, destination), "GET" => Get(path, request.Headers.Range), "DELETE" => Delete(path), _ => Response(HttpStatusCode.MethodNotAllowed) }; } private HttpResponseMessage CreateDirectory(string path) { if (!_directories.Add(path)) return Response(ExistingDirectoryStatusCode); return Response(HttpStatusCode.Created); } private async Task PutAsync(string path, HttpRequestMessage request, CancellationToken cancellationToken) { _files[path] = await request.Content.ReadAsByteArrayAsync(cancellationToken); return Response(HttpStatusCode.Created); } private HttpResponseMessage Head(string path) { if (ConsumeStaleRead(_staleAllReads, path) || ConsumeStaleRead(_staleMetadataReads, path)) return Response(HttpStatusCode.NotFound); if (ConsumeStaleLength(path, out var staleLength)) return LengthResponse(staleLength); if (!_files.TryGetValue(path, out var bytes)) return Response(HttpStatusCode.NotFound); return LengthResponse(bytes.LongLength); } private HttpResponseMessage PropFind(string path) { if (!_files.TryGetValue(path, out var bytes) && !_directories.Contains(path)) return Response(HttpStatusCode.NotFound); var length = bytes?.LongLength ?? 0; var resourceType = _directories.Contains(path) ? "" : ""; var xml = $"{length}{resourceType}"; var response = Response((HttpStatusCode)207); response.Content = new StringContent(xml, Encoding.UTF8, "application/xml"); return response; } private HttpResponseMessage Move(string sourcePath, string destination) { if (FailNextMove) { FailNextMove = false; return Response(HttpStatusCode.InternalServerError); } if (!_files.Remove(sourcePath, out var bytes)) return Response(HttpStatusCode.NotFound); var destinationPath = DecodePath(new Uri(destination, UriKind.Absolute)); var oldLength = _files.TryGetValue(destinationPath, out var oldBytes) ? oldBytes.LongLength : 0; _files[destinationPath] = bytes; if (StaleMetadataReadsAfterMove > 0) _staleMetadataReads[destinationPath] = StaleMetadataReadsAfterMove; if (StaleAllReadsAfterMove > 0) _staleAllReads[destinationPath] = StaleAllReadsAfterMove; if (StaleLengthReadsAfterMove > 0 && oldLength > 0) _staleLengthReads[destinationPath] = new StaleLengthState(StaleLengthReadsAfterMove, oldLength); return Response(HttpStatusCode.Created); } private HttpResponseMessage Get(string path, RangeHeaderValue range) { if (ConsumeStaleRead(_staleAllReads, path)) return Response(HttpStatusCode.NotFound); if (ConsumeStaleLength(path, out var staleLength)) { var stale = Response(HttpStatusCode.PartialContent); stale.Content = new ByteArrayContent(new byte[] { 0 }); stale.Content.Headers.ContentRange = new ContentRangeHeaderValue(0, 0, staleLength); return stale; } if (!_files.TryGetValue(path, out var bytes)) return Response(HttpStatusCode.NotFound); if (!SupportsRange || range == null) { var full = Response(HttpStatusCode.OK); full.Content = new ByteArrayContent(bytes); return full; } var requested = range.Ranges.Single(); var start = requested.From ?? 0; var end = Math.Min(requested.To ?? bytes.LongLength - 1, bytes.LongLength - 1); if (start < 0 || start >= bytes.LongLength || end < start) { var invalid = Response(HttpStatusCode.RequestedRangeNotSatisfiable); invalid.Content = new ByteArrayContent(Array.Empty()); invalid.Content.Headers.ContentRange = new ContentRangeHeaderValue(bytes.LongLength); return invalid; } var content = bytes.Skip((int)start).Take((int)(end - start + 1)).ToArray(); var partial = Response(HttpStatusCode.PartialContent); partial.Content = new ByteArrayContent(content); if (IncludeContentRange) partial.Content.Headers.ContentRange = new ContentRangeHeaderValue(start, end, bytes.LongLength); return partial; } private HttpResponseMessage Delete(string path) { var deleted = _files.Remove(path); foreach (var child in _files.Keys.Where(x => IsChildOf(x, path)).ToList()) { deleted |= _files.Remove(child); } foreach (var child in _directories.Where(x => x == path || IsChildOf(x, path)).ToList()) { if (child == "/") continue; deleted |= _directories.Remove(child); } return Response(deleted ? HttpStatusCode.NoContent : HttpStatusCode.NotFound); } private static bool IsChildOf(string candidate, string parent) => candidate.StartsWith(parent.TrimEnd('/') + "/", StringComparison.Ordinal); private static bool ConsumeStaleRead(Dictionary reads, string path) { if (!reads.TryGetValue(path, out var remaining) || remaining <= 0) return false; if (remaining == 1) reads.Remove(path); else reads[path] = remaining - 1; return true; } private bool ConsumeStaleLength(string path, out long length) { length = 0; if (!_staleLengthReads.TryGetValue(path, out var state) || state.Remaining <= 0) return false; length = state.Length; if (state.Remaining == 1) _staleLengthReads.Remove(path); else _staleLengthReads[path] = state with { Remaining = state.Remaining - 1 }; return true; } private static HttpResponseMessage LengthResponse(long length) { var response = Response(HttpStatusCode.OK); response.Content = new ByteArrayContent(Array.Empty()); response.Content.Headers.ContentLength = length; return response; } private static string DecodePath(Uri uri) => Uri.UnescapeDataString(uri.AbsolutePath); private static HttpResponseMessage Response(HttpStatusCode statusCode) => new(statusCode); private sealed record StaleLengthState(int Remaining, long Length); }