Files

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