// Copyright (C) 2025, The Duplicati Team // https://duplicati.com, hello@duplicati.com // // Permission is hereby granted, free of charge, to any person obtaining a // copy of this software and associated documentation files (the "Software"), // to deal in the Software without restriction, including without limitation // the rights to use, copy, modify, merge, publish, distribute, sublicense, // and/or sell copies of the Software, and to permit persons to whom the // Software is furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System.Net.Http.Headers; using System.Net.Http.Json; using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; using Duplicati.Library.Interface; namespace Duplicati.Library.Backend.Filen; /// /// The Filen client implementation /// public class FilenClient : IDisposable { /// /// The URLs of the Filen gateways /// public static IReadOnlyList GatewayUrls = [ "https://gateway.filen.io", "https://gateway.filen.net", "https://gateway.filen-1.net", "https://gateway.filen-2.net", "https://gateway.filen-3.net", "https://gateway.filen-4.net", "https://gateway.filen-5.net", "https://gateway.filen-6.net" ]; /// /// The URLs of the Filen egest servers /// public static IReadOnlyList EgestUrls = [ "https://egest.filen.io", "https://egest.filen.net", "https://egest.filen-1.net", "https://egest.filen-2.net", "https://egest.filen-3.net", "https://egest.filen-4.net", "https://egest.filen-5.net", "https://egest.filen-6.net" ]; /// /// The URLs of the Filen ingest servers /// public static IReadOnlyList IngestURLs = [ "https://ingest.filen.io", "https://ingest.filen.net", "https://ingest.filen-1.net", "https://ingest.filen-2.net", "https://ingest.filen-3.net", "https://ingest.filen-4.net", "https://ingest.filen-5.net", "https://ingest.filen-6.net" ]; /// /// The log tag for the Filen client /// private static string LOGTAG = Logging.Log.LogTagFromType(); /// /// The authentication result from the initial login /// private sealed record FilenAuthResult { /// /// The API key to use for auntehticated requests /// public required string ApiKey { get; init; } /// /// The account master key /// public required DerivedKey AccountMasterKey { get; init; } /// /// The master keys for the account /// public required IReadOnlyList MasterKeys { get; init; } /// /// Encrypts metadata with the latest master key /// /// The metadata to encrypt /// The encrypted metadata public string EncryptMetadata(string data) => MasterKeys.Last().EncryptMetadata(data); /// /// Decrypts metadata with the correct master key /// /// The metadata to decrypt /// The decrypted metadata public string DecryptMetadata(string data) { Exception? firstExecption = null; var keyIx = MasterKeys.Count; foreach (var key in MasterKeys.Reverse()) { try { return key.DecryptMetadata(data); } catch (Exception ex) { keyIx--; firstExecption ??= ex; Logging.Log.WriteVerboseMessage(LOGTAG, "DecryptMetadataAttemptFailed", ex, "Failed to decrypt metadata with key {0}", keyIx); } } throw new Exception("Failed to decrypt metadata", firstExecption); } } /// /// The base url for all requests /// private readonly string _baseUrl; /// /// The fixed chunk size for uploads /// private const int ChunkSize = 1024 * 1024; // 1 MB /// /// The HTTP client to use for requests /// private readonly HttpClient _httpClient; /// /// The UUID of the root folder; null until loaded /// private string? _rootFolderUuid; /// /// The authentication result from the initial login /// private FilenAuthResult _authResult; /// /// The time the auth token is valid until /// private readonly DateTime _validUntil; /// /// The cached files to avoid re-fetching /// private readonly Dictionary _cachedFiles = new(); /// /// Creates a new Filen client /// /// The HTTP client to use for requests /// The authentication result from the initial login /// The base url for all requests private FilenClient(HttpClient httpClient, FilenAuthResult authResult, string baseUrl) { _httpClient = httpClient; _authResult = authResult; _baseUrl = baseUrl; _validUntil = DateTime.Now + TimeSpan.FromMinutes(50); } /// /// The time the client is valid /// public DateTime ValidUntil => _validUntil; /// /// The API key for the client /// internal string ApiKey => _authResult.ApiKey; /// /// Creates a new Filen client and authenticates /// /// The HTTP client to use for requests /// The email address to use for login /// The password to use for login /// The two-factor code to use for login /// The API key to use for login /// The cancellation token to use for the operation /// The authenticated Filen client public static async Task CreateClientAsync(HttpClient httpClient, string email, string password, string? twoFactorCode, string? apiKey, CancellationToken cancellationToken) { var baseUrl = GatewayUrls[Random.Shared.Next(0, GatewayUrls.Count)]; var authResult = await AuthenticateAsync(httpClient, baseUrl, email, password, twoFactorCode, apiKey, cancellationToken).ConfigureAwait(false); return new FilenClient(httpClient, authResult, baseUrl); } /// /// Returns the authentication information for the user /// /// The HTTP client to use for requests /// The base url for all requests /// The email address to use for login /// The cancellation token to use for the operation /// The authentication information for the user private static async Task GetAuthInfoAsync(HttpClient httpClient, string baseUrl, string email, CancellationToken cancellationToken) { var loginUrl = $"{baseUrl}/v3/auth/info"; using var request = new HttpRequestMessage(HttpMethod.Post, loginUrl); request.Content = JsonContent.Create(new { email }); var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); return await ExtractDataFromResponse(response, cancellationToken).ConfigureAwait(false); } /// /// Authenticates the user with the Filen API /// /// The HTTP client to use for requests /// The base url for all requests /// The email address to use for login /// The password to use for login /// The two-factor code to use for login /// The API key to use for login /// The cancellation token to use for the operation /// The authentication result from the initial login private static async Task AuthenticateAsync( HttpClient httpClient, string baseUrl, string email, string password, string? twoFactorCode, string? apiKey, CancellationToken cancellationToken) { // Always need authInfo to derive the account master key from password var authInfo = await GetAuthInfoAsync(httpClient, baseUrl, email, cancellationToken) .ConfigureAwait(false); var rootKeys = FilenCrypto.GeneratePasswordAndMasterKeyBasedOnAuthVersion( password, authInfo.AuthVersion, authInfo.Salt); // 1) Fast-path: if apiKey is provided, try to use it to fetch master keys first if (!string.IsNullOrWhiteSpace(apiKey)) { try { var mkUrl = $"{baseUrl}/v3/user/masterKeys"; using var mkReq = new HttpRequestMessage(HttpMethod.Post, mkUrl); mkReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); mkReq.Content = JsonContent.Create(new { masterKeys = rootKeys.MasterKey.Key }); using var mkResp = await httpClient.SendAsync(mkReq, cancellationToken) .ConfigureAwait(false); mkResp.EnsureSuccessStatusCode(); var mkResult = await ExtractDataFromResponse(mkResp, cancellationToken) .ConfigureAwait(false); var masterKeysPlain = string.IsNullOrWhiteSpace(mkResult.MasterKeys) ? "" : rootKeys.MasterKey.DecryptMetadata(mkResult.MasterKeys); return new FilenAuthResult { ApiKey = apiKey, AccountMasterKey = rootKeys.MasterKey, MasterKeys = masterKeysPlain .Split('|', StringSplitOptions.RemoveEmptyEntries) .Prepend(rootKeys.MasterKey.Key) .Distinct() .Select(DerivedKey.Create) .ToList() }; } catch (Exception ex) { Logging.Log.WriteWarningMessage(LOGTAG, "ApiKeyAuthFailed", ex, "Failed to authenticate with API key, falling back to password login"); } } // 2) Fallback: login endpoint (requires MFA if enabled) if (string.IsNullOrWhiteSpace(twoFactorCode)) twoFactorCode = "XXXXXX"; var loginUrl = $"{baseUrl}/v3/login"; using var loginReq = new HttpRequestMessage(HttpMethod.Post, loginUrl); loginReq.Content = JsonContent.Create(new { email, password = rootKeys.Password, twoFactorCode, authVersion = authInfo.AuthVersion }); using var loginResp = await httpClient.SendAsync(loginReq, cancellationToken) .ConfigureAwait(false); var loginResult = await ExtractDataFromResponse(loginResp, cancellationToken) .ConfigureAwait(false); var masterKeys = string.IsNullOrWhiteSpace(loginResult.MasterKeys) ? "" : rootKeys.MasterKey.DecryptMetadata(loginResult.MasterKeys); return new FilenAuthResult { ApiKey = loginResult.ApiKey, AccountMasterKey = rootKeys.MasterKey, MasterKeys = masterKeys .Split('|', StringSplitOptions.RemoveEmptyEntries) .Prepend(rootKeys.MasterKey.Key) .Distinct() .Select(DerivedKey.Create) .ToList() }; } private sealed class MasterKeysResponse { public string MasterKeys { get; set; } = ""; } /// /// Extracts the data from a response or throws an exception /// /// The data type to extract /// The response to extract from /// The cancellation token to use for the operation /// The extracted data private static async Task ExtractDataFromResponse(HttpResponseMessage response, CancellationToken cancellationToken) { response.EnsureSuccessStatusCode(); var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); var result = JsonSerializer.Deserialize>(json); //var result = await response.Content.ReadFromJsonAsync>(cancellationToken).ConfigureAwait(false); if (result is null) throw new Exception("Failed to read response"); if (!result.Status || !string.IsNullOrWhiteSpace(result.Error)) throw new UserInformationException($"{result.Error ?? result.Message ?? "Unknown"}", "FilenAPIError"); return result.Data; } /// /// Gets the file entry for a file in a folder /// /// The UUID of the folder /// The name of the file /// The timeout for the operation /// The cancellation token to use for the operation /// The file entry for the file, or null if not found public async Task GetFileEntryAsync(string folderUuid, string filename, TimeSpan timeout, CancellationToken cancellationToken) { var f = _cachedFiles.GetValueOrDefault($"{folderUuid}:{filename}"); if (f is not null) return f; return await ListFolderDecryptedAsync(folderUuid, timeout, cancellationToken) .FirstOrDefaultAsync(e => e.Name == filename, cancellationToken) .ConfigureAwait(false); } /// /// Lists the contents of a folder /// /// The folder UUID to list /// /// /// public async IAsyncEnumerable ListFolderDecryptedAsync(string folderUuid, TimeSpan timeout, [EnumeratorCancellation] CancellationToken cancellationToken) { var result = await Utility.Utility.WithTimeout(timeout, cancellationToken, async ct => { var url = $"{_baseUrl}/v3/dir/content"; using var request = new HttpRequestMessage(HttpMethod.Post, url); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _authResult.ApiKey); request.Content = JsonContent.Create(new { uuid = folderUuid }); using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); return await ExtractDataFromResponse(response, cancellationToken).ConfigureAwait(false); }).ConfigureAwait(false); var entries = new List(); foreach (var folder in result.Folders) { NameEntry? decryptedName = null; try { decryptedName = JsonSerializer.Deserialize(_authResult.DecryptMetadata(folder.EncryptedName)) ?? throw new Exception("Failed to decrypt name"); } catch (Exception ex) { Logging.Log.WriteVerboseMessage(LOGTAG, "SkipFolderDueToDecryptionError", ex, "Failed to decrypt folder name"); } if (!string.IsNullOrWhiteSpace(decryptedName?.Name)) yield return new FilenFileEntry { Uuid = folder.UUID, Name = decryptedName.Name, IsFolder = true, LastModified = DateTimeOffset.FromUnixTimeMilliseconds(folder.LastModified).UtcDateTime, Size = 0, Region = string.Empty, Bucket = string.Empty, Chunks = 0, FileKey = string.Empty, Version = 0 }; } foreach (var file in result.Files) { FileInfoMetadata? fileInfo = null; try { fileInfo = JsonSerializer.Deserialize(_authResult.DecryptMetadata(file.MetadataEncrypted)) ?? throw new Exception("Failed to decrypt metadata"); } catch (Exception ex) { Logging.Log.WriteVerboseMessage(LOGTAG, "SkipFileDueToDecryptionError", ex, "Failed to decrypt file name"); } if (!string.IsNullOrWhiteSpace(fileInfo?.Name)) yield return _cachedFiles[$"{folderUuid}:{fileInfo.Name}"] = new FilenFileEntry { Uuid = file.Uuid, Name = fileInfo.Name, IsFolder = false, Size = file.Size, LastModified = DateTimeOffset.FromUnixTimeMilliseconds(fileInfo.LastModified).UtcDateTime, Region = file.Region, Bucket = file.Bucket, Chunks = file.Chunks, FileKey = fileInfo.Key, Version = file.Version }; } } /// /// Deletes a file from the Filen API /// /// The UUID of the file to delete /// True to permanently delete the file, false to move it to the trash /// The cancellation token to use for the operation /// A task that completes when the file is deleted public async Task DeleteFileAsync(string fileUuid, bool permanent, CancellationToken cancellationToken) { var url = permanent ? $"{_baseUrl}/v3/file/delete/permanent" : $"{_baseUrl}/v3/file/trash"; using var request = new HttpRequestMessage(HttpMethod.Post, url); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _authResult.ApiKey); request.Content = JsonContent.Create(new { uuid = fileUuid }); var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); await ExtractDataFromResponse(response, cancellationToken).ConfigureAwait(false); var key = _cachedFiles.FirstOrDefault(kv => kv.Value.Uuid == fileUuid).Key; if (string.IsNullOrWhiteSpace(key)) _cachedFiles.Clear(); else _cachedFiles.Remove(key); } /// /// Uploads a file to the Filen API /// /// The stream to upload /// The name of the file /// The UUID of the parent folder /// The cancellation token to use for the operation /// A task that completes when the file is uploaded public async Task UploadStreamedEncryptedFileAsync(Stream stream, string remoteName, string parentFolderUuid, CancellationToken cancellationToken) { var size = stream.Length; if (stream.Length == 0) throw new InvalidOperationException("Cannot upload empty file"); var uploadKey = FilenCrypto.GenerateRandomString(32); var fileKey = DerivedKey.Create(FilenCrypto.GenerateRandomString(32)); var fileUuid = Guid.NewGuid().ToString(); var chunks = await UploadEncryptedChunksAsync(stream, fileUuid, parentFolderUuid, fileKey, uploadKey, cancellationToken).ConfigureAwait(false); if (chunks == 0) throw new Exception("Failed to upload any chunks"); await CompleteChunkedUploadAsync(fileUuid, remoteName, size, chunks, fileKey, uploadKey, cancellationToken).ConfigureAwait(false); } /// /// Uploads encrypted chunks of a file to the Filen API /// /// The stream to read from /// The UUID of the file /// The UUID of the parent folder /// The encryption key for the file /// The upload key for the file /// The cancellation token to use for the operation /// The number of chunks uploaded private async Task UploadEncryptedChunksAsync(Stream inputStream, string uuid, string parentUuid, DerivedKey fileKey, string uploadKey, CancellationToken cancellationToken) { var buffer = new byte[ChunkSize]; var chunk = 0; while (true) { int bytesRead = 0; while (bytesRead < ChunkSize) { int read = await inputStream.ReadAsync(buffer, bytesRead, ChunkSize - bytesRead, cancellationToken).ConfigureAwait(false); if (read == 0) break; bytesRead += read; } Logging.Log.WriteVerboseMessage(LOGTAG, "UploadingChunk", "Uploading chunk {0}, size {1}", chunk, bytesRead); // Stream is exhausted if (bytesRead == 0) break; var cipherText = fileKey.EncryptData(buffer.AsSpan().Slice(0, bytesRead)); var hash = FilenCrypto.HashChunk(cipherText); var url = $"{IngestURLs[Random.Shared.Next(0, IngestURLs.Count)]}/v3/upload"; var qp = new Dictionary { { "uuid", uuid }, { "index", chunk.ToString() }, { "parent", parentUuid }, { "uploadKey", uploadKey }, { "hash", hash } }; url += "?" + string.Join("&", qp.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}")); using var request = new HttpRequestMessage(HttpMethod.Post, url); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _authResult.ApiKey); request.Content = new ByteArrayContent(cipherText); var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); var res = await ExtractDataFromResponse(response, cancellationToken).ConfigureAwait(false); chunk++; } Logging.Log.WriteVerboseMessage(LOGTAG, "UploadingChunk", "Finished uploading {0} chunks", chunk); return chunk; } /// /// Completes a chunked upload of a file to the Filen API /// /// The UUID of the file /// The name of the file /// The size of the file /// The number of chunks /// The encryption key for the file /// The upload key for the file /// The cancellation token to use for the operation /// A task that completes when the file is uploaded private async Task CompleteChunkedUploadAsync(string fileUuid, string remoteName, long size, int chunks, DerivedKey fileKey, string uploadKey, CancellationToken cancellationToken) { var encryptedName = _authResult.EncryptMetadata(JsonSerializer.Serialize(new NameEntry() { Name = remoteName })); var nameHashed = FilenCrypto.HashFn(remoteName); var now = (long)(DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds; var encryptedMime = fileKey.EncryptMetadata("application/octet-stream"); var encryptedFilesize = fileKey.EncryptMetadata(JsonSerializer.Serialize(size)); var encryptedMetadata = _authResult.EncryptMetadata(JsonSerializer.Serialize(new FileInfoMetadata { Name = remoteName, Size = size, Mime = "application/octet-stream", Key = fileKey.Key, LastModified = now, Create = now })); var completeBody = new { uuid = fileUuid, name = encryptedName, nameHashed = nameHashed, size = encryptedFilesize, chunks = chunks, mime = encryptedMime, rm = FilenCrypto.GenerateRandomString(32), metadata = encryptedMetadata, version = 2, uploadKey = uploadKey, }; var url = $"{_baseUrl}/v3/upload/done"; using var request = new HttpRequestMessage(HttpMethod.Post, url); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _authResult.ApiKey); request.Content = JsonContent.Create(completeBody); var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); var res = await ExtractDataFromResponse(response, cancellationToken).ConfigureAwait(false); if (res.Size != size) throw new Exception($"Failed to upload file, size mismatch. Expected {size}, got {res.Size}"); } /// /// Downloads and decrypts a file to a stream /// /// The file to download /// The stream to write to /// The cancellation token to use for the operation /// A task that completes when the file is downloaded public async Task DownloadAndDecryptToStreamAsync(FilenFileEntry file, Stream outputStream, CancellationToken cancellationToken) { var chunk = 0; var downloaded = 0L; var fileKey = DerivedKey.Create(file.FileKey); var totalSize = file.Size; while (downloaded < totalSize) { if (chunk >= file.Chunks) throw new Exception($"Attempted to download more chunks than available, expected {file.Chunks}, got {chunk}. File: {file.Name}, total size: {totalSize}, downloaded: {downloaded}"); Logging.Log.WriteVerboseMessage(LOGTAG, "DownloadingChunk", "Downloading chunk {0}, size {1}", chunk, totalSize - downloaded); var url = $"{EgestUrls[Random.Shared.Next(0, EgestUrls.Count)]}/{file.Region}/{file.Bucket}/{file.Uuid}/{chunk}"; using var request = new HttpRequestMessage(HttpMethod.Get, url); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _authResult.ApiKey); var response = await _httpClient.SendAsync(request, cancellationToken); var encrypted = await response.Content.ReadAsByteArrayAsync(cancellationToken); var decrypted = fileKey.DecryptData(file.Version, encrypted); await outputStream.WriteAsync(decrypted, cancellationToken); downloaded += decrypted.Length; chunk++; } } /// /// Creates a folder in the Filen API /// /// The UUID of the parent folder /// The name of the folder /// The cancellation token to use for the operation /// The UUID of the created folder public async Task CreateFolderAsync(string parentUuid, string folderName, CancellationToken cancellationToken) { var self = Guid.NewGuid().ToString(); var encryptedName = _authResult.EncryptMetadata(JsonSerializer.Serialize(new NameEntry() { Name = folderName })); var hashedName = FilenCrypto.HashFn(folderName); var body = new { uuid = self, name = encryptedName, nameHashed = hashedName, parent = parentUuid }; var url = $"{_baseUrl}/v3/dir/create"; using var request = new HttpRequestMessage(HttpMethod.Post, url); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _authResult.ApiKey); request.Content = JsonContent.Create(body); var response = await _httpClient.SendAsync(request, cancellationToken); var result = await ExtractDataFromResponse(response, cancellationToken).ConfigureAwait(false); return result.Uuid; } /// /// Gets the UUID of the user's base folder /// /// The cancellation token to use for the operation /// The UUID of the user's base folder public async Task GetUserBaseFolder(CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(_rootFolderUuid)) { using var request = new HttpRequestMessage(HttpMethod.Get, $"{_baseUrl}/v3/user/baseFolder"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _authResult.ApiKey); var response = await _httpClient.SendAsync(request); var result = await ExtractDataFromResponse(response, cancellationToken); return _rootFolderUuid = result.Uuid; } return _rootFolderUuid; } /// /// Resolves a folder path to a UUID /// /// The path to resolve /// The timeout for the operation /// The cancellation token to use for the operation /// The UUID of the resolved folder public async Task ResolveFolderPathAsync(string path, TimeSpan timeout, CancellationToken cancellationToken) { var rootFolderUuid = await GetUserBaseFolder(cancellationToken); if (string.IsNullOrWhiteSpace(path)) return rootFolderUuid; var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); var currentUuid = rootFolderUuid; var prev = "/"; foreach (var segment in segments) { var match = await ListFolderDecryptedAsync(currentUuid, timeout, cancellationToken) .FirstOrDefaultAsync(f => f.Name == segment) .ConfigureAwait(false); if (match == null) throw new FolderMissingException($"Path segment '{segment}' not found under {prev} {currentUuid}"); currentUuid = match.Uuid; prev += segment + "/"; } return currentUuid; } public void Dispose() { _httpClient.Dispose(); } /// /// Renames a file in the Filen API /// /// The UUID of the file to rename /// The new name of the file /// The cancellation token to use for the operation /// A task that completes when the file is renamed public async Task RenameFileAsync(string fileUuid, string newName, CancellationToken cancellationToken) { var encryptedName = _authResult.EncryptMetadata(JsonSerializer.Serialize(new NameEntry() { Name = newName })); var url = $"{_baseUrl}/v3/file/rename"; using var request = new HttpRequestMessage(HttpMethod.Post, url); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _authResult.ApiKey); request.Content = JsonContent.Create(new { uuid = fileUuid, name = encryptedName }); var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); await ExtractDataFromResponse(response, cancellationToken).ConfigureAwait(false); // Update cache var key = _cachedFiles.FirstOrDefault(kv => kv.Value.Uuid == fileUuid).Key; if (!string.IsNullOrWhiteSpace(key)) _cachedFiles.Remove(key); } }