216 lines
9.1 KiB
C#
216 lines
9.1 KiB
C#
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<string, byte[]> _files = new(StringComparer.Ordinal);
|
|
private readonly HashSet<string> _directories = new(StringComparer.Ordinal) { "/" };
|
|
private readonly Dictionary<string, int> _staleMetadataReads = new(StringComparer.Ordinal);
|
|
private readonly Dictionary<string, int> _staleAllReads = new(StringComparer.Ordinal);
|
|
private readonly Dictionary<string, StaleLengthState> _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<RecordedWebDavRequest> Requests { get; } = new();
|
|
public IReadOnlyDictionary<string, byte[]> Files => _files;
|
|
|
|
protected override async Task<HttpResponseMessage> 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<HttpResponseMessage> 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)
|
|
? "<resourcetype><collection/></resourcetype>"
|
|
: "<resourcetype/>";
|
|
var xml = $"<?xml version=\"1.0\"?><multistatus xmlns=\"DAV:\"><response><propstat><prop><getcontentlength>{length}</getcontentlength>{resourceType}</prop></propstat></response></multistatus>";
|
|
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<byte>());
|
|
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<string, int> 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<byte>());
|
|
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);
|
|
}
|