using WebDrop.Core; namespace WebDrop.Storage; /// /// 磁盘 blob 存储。键形如 "channelHex/itemHex/index.digest",直接映射为目录与文件。 /// 键只能由服务生成(十六进制和数字),这里仍做路径逃逸检查作为纵深防御。 /// public sealed class DiskBlobStore : IBlobStore { private readonly string _root; public DiskBlobStore(string root) { _root = Path.GetFullPath(root); Directory.CreateDirectory(_root); } public async ValueTask PutAsync(string key, ReadOnlyMemory data, CancellationToken ct = default) { string path = Resolve(key); // 键里含内容摘要:文件已存在就意味着内容相同,无需重写(也避免并发覆盖在 Windows 上冲突) if (File.Exists(path)) return; Directory.CreateDirectory(Path.GetDirectoryName(path)!); // 先写临时文件再重命名,读者永远看不到半个文件 string tmp = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; await using (var fs = new FileStream(tmp, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.Asynchronous)) { await fs.WriteAsync(data, ct).ConfigureAwait(false); } try { File.Move(tmp, path, overwrite: false); } catch (Exception e) when (e is IOException or UnauthorizedAccessException && File.Exists(path)) { File.Delete(tmp); // 并发写入同一内容,对方先到了 } } public async ValueTask ReadAsync(string key, CancellationToken ct = default) { string path = Resolve(key); try { return await File.ReadAllBytesAsync(path, ct).ConfigureAwait(false); } catch (FileNotFoundException) { return null; } catch (DirectoryNotFoundException) { return null; } } public void Delete(string key) { try { File.Delete(Resolve(key)); } catch (DirectoryNotFoundException) { } } public void DeletePrefix(string prefix) { string dir = Resolve(prefix); try { if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); } catch (DirectoryNotFoundException) { } } public IEnumerable<(string Key, DateTimeOffset LastWrite)> Enumerate() { foreach (string file in Directory.EnumerateFiles(_root, "*", SearchOption.AllDirectories)) { if (file.EndsWith(".tmp", StringComparison.Ordinal)) continue; string key = Path.GetRelativePath(_root, file).Replace('\\', '/'); yield return (key, new DateTimeOffset(File.GetLastWriteTimeUtc(file), TimeSpan.Zero)); } } private string Resolve(string key) { string full = Path.GetFullPath(Path.Combine(_root, key)); if (!full.StartsWith(_root + Path.DirectorySeparatorChar, StringComparison.Ordinal)) throw new ArgumentException("非法 blob 键", nameof(key)); return full; } }