Compression ve Arşivler: GZip, Brotli, ZipArchive'ın Async API'leri ve Zip Slip
Büyük bir zip'i kullanıcıya göndermek için dosya ya da MemoryStream gerekmez: ZipArchive'ın Create modu entry'leri doğrudan response body'ye yazar, ama .NET 10'un async ZIP API'leri her entry'nin sonunu hâlâ senkron yazdığı için Kestrel'de buna ayrıca izin vermek gerekir. GZip, ZLib, Deflate ve Brotli'yi seviyeleriyle ölçüyor, zip ile tar.gz'yi karşılaştırıyor ve yüklenen zip'i Zip Slip ve zip bomb'a karşı güvenle açıyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 ile derlenip denenmiştir; bir karşılaştırma ayrıca .NET 11 (SDK 11.0.100) ile çalıştırılmıştır.
Önce Tahmin Et
Bookstore'un (sitedeki serilerin ortak örnek uygulaması, bir online kitapçı) 20.000 siparişlik, 6,8 MB'lık NDJSON (Newline Delimited JSON; her satırında ayrı bir JSON değeri bulunan metin formatı) export'unu BrotliStream ile sıkıştırıyoruz. CompressionLevel.Optimal ile iş 10,6 ms sürüyor. Aynı veriyi CompressionLevel.SmallestSize ile sıkıştırmak kaç ms sürer: 20 mi, 100 mü, daha fazlası mı?
İkinci soru: 1.000 fatura XML'ini (toplam 1,7 MB) bir kez zip'e, bir kez de tar.gz'ye koyuyoruz. Hangisi daha küçük çıkar ve fark ne kadardır?
Üçüncüsü: "tüm faturalar" zip'ini ASP.NET Core'da doğrudan response'a yazmak için .NET 10'un yeni async API'lerini kullanıyorsun: ZipArchive.CreateAsync, ZipArchiveEntry.OpenAsync, her yerde await using. Kestrel'de bu kod çalışır mı?
Önceki yazılarda JSON, XML ve CSV (Comma-Separated Values; değerleri ayırıcıyla sıralayan düz metin formatı) dosyalarını ürettik; bu yazıda onları taşıyan katmana bakıyoruz. Örnekler serinin repo'sunda Bookstore.IO.Compression console uygulamasında ve Bookstore.Api'de:
dotnet run --project src/Bookstore.IO.Compression -- formats
dotnet run --project src/Bookstore.Api --urls http://127.0.0.1:5188
Dört Format: Deflate, ZLib, GZip ve Brotli
.NET'te dört sıkıştırma stream'i var ve ilk üçü aslında aynı algoritma. Deflate (RFC 1951) ham sıkıştırılmış veridir, başında header yoktur. ZLib (RFC 1950) aynı veriye 2 byte'lık bir header ve sonuna Adler-32 checksum'ı ekler. GZip (RFC 1952) 10 byte'lık bir header, CRC32 (32 bitlik döngüsel artıklık kontrolü; verinin bozulmadığını doğrulayan checksum) ve orijinal boyutu ekler; .gz dosyalarının ve HTTP'deki Content-Encoding: gzip'in formatı budur. Brotli (RFC 7932) Google'ın geliştirdiği, web için tasarlanmış ayrı bir algoritmadır. Aynı export'u dördüyle ve üç seviyeyle sıkıştırdık; süre, bir geliştirici makinesinde beş denemenin en kısası:
Input: 6,808,653 bytes NDJSON
Format Level Bytes Ratio ms Header
Deflate Fastest 798,052 8.5x 4.6 AA56F2
Deflate Optimal 325,989 20.9x 14.0 ECBD4D
Deflate SmallestSize 315,959 21.5x 31.1 ECBD4B
ZLib Fastest 798,058 8.5x 6.0 7801AA
ZLib Optimal 325,995 20.9x 15.3 789CEC
ZLib SmallestSize 315,965 21.5x 32.6 78DAEC
GZip Fastest 798,070 8.5x 4.9 1F8B08
GZip Optimal 326,007 20.9x 14.1 1F8B08
GZip SmallestSize 315,977 21.5x 31.4 1F8B08
Brotli Fastest 602,912 11.3x 4.8 ABFFFF
Brotli Optimal 411,196 16.6x 10.6 9B4CE4
Brotli SmallestSize 242,577 28.1x 6921.1 9B4CE4
Tablodan okunacak birkaç şey var. Deflate, ZLib ve GZip'in boyutları byte'ı byte'ına aynı veriyi gösteriyor; aradaki 6 ve 18 byte'lık fark yalnız header ve footer. ZLib'in ilk byte'ı hep 78, ikinci byte'ı seviyeyi kodluyor (01, 9C, DA). GZip her zaman 1F 8B 08 ile başlar; gelen bir dosyanın gerçekten gzip olup olmadığını uzantıya değil bu üç byte'a bakarak anlarsın. Deflate'in ve Brotli'nin tanınabilir bir başlangıcı yok, bu yüzden formatı dışarıdan bilmen gerekir.
İlk sorunun cevabı son satırda: 6.921 ms, yani Optimal'in 650 katı. Brotli SmallestSize en küçük dosyayı veriyor (GZip'ten yüzde 23 küçük), ama bu bir request sırasında yapılacak iş değil. Statik dosyaları build sırasında bir kez sıkıştırıp saklamak için anlamlı; her istekte çalışan bir endpoint'te ise sunucuyu kilitler. Bir de beklenmedik bir sonuç: Brotli Optimal, GZip Optimal'den daha büyük çıktı. .NET'te Optimal Brotli için hızlı bir seviyeye karşılık geliyor; bunu bir sonraki bölümde göreceğiz.
Format seçimi için kısa kural: HTTP response'ları için Brotli ya da GZip (istemcinin Accept-Encoding'ine göre; ASP.NET Core'un response compression middleware'i bunu senin yerine yapar), dosya ve arşiv için GZip, kendi iki service'in arasındaki bir protokolde ZLib ya da Deflate. HTTP'deki deflate encoding'inin aslında ZLib formatı olduğunu da not et; ham Deflate göndermek bazı istemcilerde sorun çıkarır.
CompressionLevel ve .NET 9 Option'ları
CompressionLevel enum'u yalnız dört değer taşır: Optimal, Fastest, NoCompression ve .NET 6'da eklenen SmallestSize. .NET 9 iki algoritma için ayrıntılı ayar getirdi: ZLib ailesi için ZLibCompressionOptions (0–9 arası seviye ve strateji), Brotli için BrotliCompressionOptions (0–11 arası quality). Aynı veriyle:
var zlib = new ZLibCompressionOptions { CompressionLevel = 9, CompressionStrategy = ZLibCompressionStrategy.Default };
await using var zlibStream = new ZLibStream(output, zlib, leaveOpen: true);
var brotli = new BrotliCompressionOptions { Quality = 9 };
await using var brotliStream = new BrotliStream(output, brotli, leaveOpen: true);
ZLib level 1: 798,058 bytes 6.2 ms
ZLib level 6: 325,995 bytes 15.8 ms
ZLib level 9: 315,965 bytes 33.2 ms
Brotli quality 1: 602,912 bytes 4.9 ms
Brotli quality 4: 411,196 bytes 10.8 ms
Brotli quality 9: 294,010 bytes 62.4 ms
Brotli quality 11: 242,577 bytes 7002.2 ms
Boyutları önceki tabloyla karşılaştırınca enum'un nereye eşlendiği ortaya çıkıyor: ZLib ailesinde Fastest seviye 1, Optimal 6, SmallestSize 9; Brotli'de Fastest quality 1, Optimal 4, SmallestSize 11. Brotli'nin asıl tatlı noktası enum'da yok: quality 9, GZip'in en iyisinden yüzde 7 küçük dosyayı 62 ms'de veriyor. Brotli kullanacaksan enum yerine option ile quality seç ve kendi verinle ölç.
.NET 9'daki ikinci değişiklik görünmez ama önemli: DeflateStream, ZLibStream, GZipStream ve ZipArchive artık zlib yerine zlib-ng kullanıyor; Microsoft'un notuna göre amaç farklı işletim sistemleri ve donanımlarda daha tutarlı ve verimli sonuç. Bunun bir yan etkisi var: aynı girdi ve seviye için sıkıştırılmış byte'lar .NET sürümleri arasında değişebilir. Sıkıştırılmış çıktının hash'ini test fixture'ı olarak saklayan testler sürüm yükseltmede kırılabilir; testlerde açılmış veriyi karşılaştır.
ZipArchive: Create, Read ve Update
GZip tek bir stream'i sıkıştırır; birden çok dosyayı tek pakette taşımak için ZIP (dosyaları ayrı ayrı sıkıştırıp tek arşivde toplayan format) ya da tar gerekir. ZipArchive üç modda açılır. Create yalnız yazar ve hedef stream'in seek edilebilir olmasını istemez: her entry'yi sırayla yazar, sonunda central directory'yi (arşivin sonundaki entry listesi) ekler. Read önce sondaki central directory'yi okur, bu yüzden seek edilebilir bir stream ister. Update ikisini birden yapar. Faturaları zip'e yazan kod:
public static async Task WriteAsync(Stream destination, IEnumerable<Order> orders, CancellationToken ct = default)
{
await using ZipArchive archive = await ZipArchive.CreateAsync(destination, ZipArchiveMode.Create, leaveOpen: true, entryNameEncoding: null, ct);
foreach (Order order in orders)
{
ZipArchiveEntry entry = archive.CreateEntry($"invoices/{order.OrderNumber}.xml", CompressionLevel.Optimal);
await using Stream entryStream = await entry.OpenAsync(ct);
await InvoiceXml.Create(order, IssueDate).SaveAsync(entryStream, SaveOptions.None, ct);
}
}
Wrote 1,000 invoices to out/invoices.zip: 652,184 bytes in 81 ms
invoices/BS-0000001.xml: Length 2,250, CompressedLength 562
invoices/BS-0000002.xml: Length 2,250, CompressedLength 551
Sum of Length: 1,748,940, sum of CompressedLength: 530,162
InvoiceXml, serinin XML yazısında yazdığımız UBL (Universal Business Language; e-faturada kullanılan XML standardı) benzeri fatura. Entry'nin iki boyutu var ve karıştırmak sık yapılan bir hata: Length açılmış boyut, CompressedLength arşivdeki boyut. İkisi de arşivin içinde yazan sayılardır, kimse doğrulamaz; birazdan bunun güvenlik tarafına geleceğiz. Entry adları her zaman / ile ayrılır; Windows'ta Path.Combine ile ad üretip \ koymak, arşivi başka platformlarda açanlara tuhaf dosya adları bırakır.
Create modunda aynı anda yalnız bir entry açık olabilir. İlk entry'nin stream'i açıkken ikinciyi açmaya çalışırsan:
IOException: Entries cannot be created while previously created entries are still open.
Bu yüzden faturaları Parallel.ForEachAsync ile aynı archive'a yazamazsın. Paralellik istiyorsan XML'leri paralel üret, arşive tek bir yazıcı sırayla yazsın; Channel ile bu yapıyı Pipelines yazısında kurmuştuk.
.NET 8 ZipFile'a dosya yolu yerine stream alan overload'lar ekledi (ZipFile.CreateFromDirectory(string, Stream), ZipFile.ExtractToDirectory(Stream, string)); bir klasörü dosyaya yazmadan doğrudan bir stream'e zip'lemek tek satır. .NET 10 da Update modunu iyileştirdi: Microsoft'un notuna göre eskiden bu modda bütün entry'ler belleğe yüklenip yeniden yazılıyordu, artık bu yapılmıyor ve extraction paralel çalışıyor.
Bir not daha: Read modu seek edilebilir stream ister dedik; seek edilemeyen bir stream verirsen ZipArchive hata vermez, bütün stream'i önce belleğe kopyalar. 13,4 MB'lık, 20.000 entry'li bir zip'i seek edilemeyen bir sarmalayıcıyla açtığımızda heap 35,7 MB büyüdü, dosyanın kendisiyle açtığımızda 18,8 MB. Upload edilen zip'leri okurken stream'in CanSeek değerine bak.
.NET 10 Async ZIP API'leri ve Gizli Senkron Yazma
.NET 10'un en çok beklenen I/O yeniliklerinden biri async ZIP API'leriydi: ZipArchive.CreateAsync, ZipArchiveEntry.OpenAsync, ZipFile.OpenAsync, ZipFile.CreateFromDirectoryAsync, ZipFile.ExtractToDirectoryAsync ve ExtractToFileAsync gibi extension'lar. Yukarıdaki kod bunları kullanıyor. Üçüncü sorunun cevabını bulmak için önce Kestrel'in response body'si gibi seek edilemeyen, senkron ve async yazmaları ayrı sayan küçük bir stream yazdık ve aynı kodu ona yazdırdık:
Runtime 10.0.13: 1,000 entries
async writes: 5,001 (650,184 bytes)
sync writes: 2,000 (18,000 bytes)
Her şeyi async yazdığımız hâlde her entry için iki senkron yazma var, toplam 18 byte. Sebep runtime'da: Create modunda OpenAsync'in döndüğü iç stream DisposeAsync'i override etmiyor, temel Stream.DisposeAsync senkron Dispose'u çağırıyor ve DeflateStream son bloğunu senkron yazıyor. Hata dotnet/runtime deposunda 121624 numaralı issue olarak kayıtlı ve 11.0.0 milestone'unda düzeltildi. Aynı kodu .NET 11 ile çalıştırdık:
Runtime 11.0.0: 1,000 entries
async writes: 7,001 (668,184 bytes)
sync writes: 0 (0 bytes)
Dosyaya ya da MemoryStream'e yazarken bu fark önemsiz. Ama Kestrel senkron yazmaya izin vermez. .NET 10'da bu kodu bir endpoint'te kullandığımızda ilk faturanın sonunda istek çöktü ve istemci 720 byte'lık bozuk bir zip aldı:
fail: Microsoft.AspNetCore.Server.Kestrel[13]
Connection id "0HNQ1U65K0RU4", Request id "0HNQ1U65K0RU4:00000001": An unhandled exception was thrown by the application.
System.InvalidOperationException: Synchronous operations are disallowed. Call WriteAsync or set AllowSynchronousIO to true instead.
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.HttpResponseStream.Write(Byte[] buffer, Int32 offset, Int32 count)
at System.IO.Compression.DeflateStream.PurgeBuffers(Boolean disposing)
at System.IO.Compression.DeflateStream.Dispose(Boolean disposing)
...
at System.IO.Compression.WrappedStream.Dispose(Boolean disposing)
at System.IO.Stream.Close()
at System.IO.Stream.DisposeAsync()
Üçüncü sorunun cevabı: .NET 10'da hayır, .NET 11'de evet. Önemli olan, bu hatayı ancak gerçek ortamda, seek edilemeyen ve senkron yazmayı reddeden bir stream'le görmen. Dosyaya yazan bir unit test hep geçer.
"Tüm Faturalar" Zip'ini Response'a Streaming Yazmak
Angular arayüzündeki "tüm faturaları indir" butonu basit bir indirme linki; işin tamamı sunucuda. Klasik çözüm zip'i önce geçici bir dosyaya ya da MemoryStream'e yazıp sonra göndermek. 20.000 faturada bu, sunucuda 13 MB'lık bir buffer ve kullanıcının ilk byte'ı ancak zip tamamen hazır olunca alması demek. ZipArchive'in Create modu seek gerektirmediği için entry'leri doğrudan response body'ye yazabiliriz. .NET 10'daki senkron son blok için en küçük çözüm, yalnız bu endpoint'te senkron yazmaya izin vermek:
// Tüm faturalar tek zip'te: disk ya da MemoryStream yok, entry'ler üretildikçe response'a gider.
app.MapGet("/api/invoices/zip", async (int count, HttpContext context) =>
{
// .NET 10'da her entry'nin son birkaç byte'ı senkron yazılır (dotnet/runtime#121624, .NET 11'de düzeldi).
context.Features.GetRequiredFeature<IHttpBodyControlFeature>().AllowSynchronousIO = true;
context.Response.ContentType = "application/zip";
context.Response.Headers.ContentDisposition = "attachment; filename=\"invoices.zip\"";
await InvoiceZip.WriteAsync(context.Response.Body, SampleOrders.Generate(count), context.RequestAborted);
});
$ curl -s -D - -o invoices.zip -w "first byte %{time_starttransfer}s, total %{time_total}s, %{size_download} bytes\n" "http://127.0.0.1:5188/api/invoices/zip?count=20000"
HTTP/1.1 200 OK
Content-Type: application/zip
Date: Sat, 14 Nov 2026 01:50:57 GMT
Server: Kestrel
Transfer-Encoding: chunked
Content-Disposition: attachment; filename="invoices.zip"
first byte 0.000451s, total 0.990429s, 13390980 bytes
$ unzip -tq invoices.zip
No errors detected in compressed data of invoices.zip.
Response Content-Length olmadan, Transfer-Encoding: chunked ile gidiyor: sunucu zip'in son boyutunu bilmiyor, bilmesi de gerekmiyor. İlk byte milisaniyenin altında geldi, 20.000 faturalık 13,4 MB'lık zip bir saniyede bitti ve unzip bütünlüğünü doğruladı. Senkron yazmaya izin vermek normalde kaçınılan bir şey, çünkü yazma süresince bir thread'i bloklar; burada yalnız her entry'nin son 18 byte'ı için geçerli ve kapsamı tek bir istek. .NET 11'e geçtiğinde bu satırı sil. İstemci indirmeyi yarıda keserse RequestAborted token'ı üretimi durdurur. Dosya indirmenin genel kuralları (Content-Disposition, range istekleri, limitler) ASP.NET Core Web API serisinin dosya yükleme ve indirme yazısında.
Tar ve tar.gz: System.Formats.Tar
.NET 7'den beri System.Formats.Tar ile tar arşivleri de doğrudan destekleniyor. Tar sıkıştırma yapmaz, dosyaları art arda dizer; sıkıştırma için tar stream'ini bir GZipStream'e yazarsın. İkinci sorunun cevabı için aynı 1.000 faturayı klasöre yazıp iki yolla paketledik:
await using (var file = File.Create(tarGz))
await using (var gzip = new GZipStream(file, CompressionLevel.Optimal))
{
await TarFile.CreateFromDirectoryAsync(dir, gzip, includeBaseDirectory: false);
}
await using (var file = File.Create(zip))
{
// .NET 8: ZipFile'ın stream alan overload'ı.
ZipFile.CreateFromDirectory(dir, file, CompressionLevel.Optimal, includeBaseDirectory: false);
}
1,000 files, 1,748,940 bytes
zip: 627,796 bytes
tar.gz: 72,306 bytes
First tar entry: BS-0000206.xml, 1,266 bytes, format Pax
tar.gz, zip'in neredeyse dokuzda biri. Sebep yapısal: ZIP her entry'yi ayrı ayrı sıkıştırır, bu yüzden 1.000 faturada tekrar eden namespace tanımlarını, element adlarını ve ISBN'leri (kitaplara verilen uluslararası standart numara) 1.000 kez ayrı ayrı sıkıştırır. tar.gz ise bütün dosyaları tek bir stream olarak sıkıştırır ve bir faturada gördüğü kalıbı sonrakilerde yeniden kullanır. Karşılığında tar.gz'de rastgele erişim yoktur: 900. faturayı okumak için önceki 899'u açman gerekir; ZIP'te central directory sayesinde doğrudan gidersin. Kullanıcıya gidecek ve Windows'ta çift tıkla açılacak arşivlerde ZIP; Linux sunucular arasında taşınan, tamamı açılacak yedek ve export'larda tar.gz. Varsayılan tar formatı Pax; uzun dosya adlarını ve büyük dosyaları sorunsuz taşır.
Yüklenen Zip'i Güvenle Açmak: Zip Slip ve Zip Bomb
Kurumsal müşteriler toplu sipariş dosyalarını zip olarak yüklüyor. Bir arşivin içindeki iki bilgi saldırganın kontrolündedir: entry adı ve entry'nin boyutları. Zip Slip, entry adında ../ kullanıp dosyayı hedef klasörün dışına yazdırmaktır. Test için iki entry'li bir zip ürettik: invoices/BS-0000001.xml ve ../outside.txt.
ZipFile.ExtractToDirectory: IOException: Extracting Zip entry would have resulted in a file outside the specified destination directory.
Naive: invoices/BS-0000001.xml -> extract/naive/invoices/BS-0000001.xml
Naive: ../outside.txt -> extract/outside.txt
SafeZipExtractor: Entry '../outside.txt' is outside the target directory.
.NET'in kendi ExtractToDirectory'si bu kontrolü yapıyor. Sorun, entry'leri tek tek dolaşıp Path.Combine(target, entry.FullName) ile kendi path'ini üreten kodda: ikinci satırda dosya naive klasörünün dışına yazıldı. Zip'i elle açman gerektiğinde (filtreleme, limit, dosya başına doğrulama) kontrolü kendin yapmalısın. Path traversal'ın genel anlatımı OWASP (Open Web Application Security Project; web güvenliği üzerine çalışan açık topluluk) Top 10 serisinin path traversal'ı anlatan yazısında; buradaki kontrol aynı ilkeyi zip'e uyguluyor:
string root = Path.GetFullPath(destination);
if (!Path.EndsInDirectorySeparator(root)) root += Path.DirectorySeparatorChar;
StringComparison comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
await using ZipArchive archive = await ZipArchive.CreateAsync(zip, ZipArchiveMode.Read, leaveOpen: false, entryNameEncoding: null, ct);
if (archive.Entries.Count > limits.MaxEntries)
throw new InvalidDataException($"Archive has {archive.Entries.Count} entries, limit is {limits.MaxEntries}.");
foreach (ZipArchiveEntry entry in archive.Entries)
{
// Path.Join, Path.Combine'ın aksine kök dizinle başlayan bir entry adında hedefi değiştirmez.
string target = Path.GetFullPath(Path.Join(root, entry.FullName));
if (!target.StartsWith(root, comparison))
throw new InvalidDataException($"Entry '{entry.FullName}' is outside the target directory.");
if (entry.FullName.EndsWith('/'))
{
Directory.CreateDirectory(target);
continue;
}
if (entry.CompressedLength > 0 && (double)entry.Length / entry.CompressedLength > limits.MaxCompressionRatio)
throw new InvalidDataException($"Entry '{entry.FullName}' has compression ratio {entry.Length / entry.CompressedLength:N0}:1.");
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
await using Stream input = await entry.OpenAsync(ct);
await using var output = new FileStream(target, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, useAsync: true);
int read;
while ((read = await input.ReadAsync(buffer, ct)) > 0)
{
// Header'daki Length'e güvenme: gerçekten açılan byte'ları say.
total += read;
if (total > limits.MaxTotalBytes)
throw new InvalidDataException($"Extracted size exceeds {limits.MaxTotalBytes:N0} bytes.");
await output.WriteAsync(buffer.AsMemory(0, read), ct);
}
files++;
}
Üç ayrıntı önemli. Kök path'in sonuna ayırıcı eklemek, /data/extract kökünün /data/extract-evil gibi bir kardeş klasörle eşleşmesini engeller. Path.Join kullanmak, /etc/x gibi kök dizinle başlayan bir entry adında Path.Combine'ın hedefi tamamen o path'e çevirmesini engeller. FileMode.CreateNew ise var olan bir dosyanın üzerine yazmayı reddeder.
Zip bomb ise küçük bir arşivin açılınca diski ya da belleği doldurmasıdır. Test için 1 GB'lık sıfırlardan oluşan tek bir entry ürettik:
1,043,756 byte zip, entry Length 1,073,741,824, CompressedLength 1,043,638
ratio check: Entry 'report.csv' has compression ratio 1,028:1.
size check: Extracted size exceeds 100,000,000 bytes.
1 MB'lık zip 1 GB açılıyor. İlk savunma oran kontrolü: bu yazıdaki faturalar 4 kat, NDJSON export'u 21 kat sıkıştı; bin kat sıkışan bir entry şüphelidir. Ama oran, arşivin içinde yazan sayılardan hesaplanıyor ve bu sayılar yalan söyleyebilir. Bu yüzden asıl savunma ikinci satırda: oran kontrolünü kapattığımızda bile açılan byte'ları sayan döngü 100 MB'da durdu. Entry sayısı limiti de milyonlarca boş entry'li arşivlere karşı. Bunların hepsi upload boyutu limitinin üstüne gelir, yerine değil.
Üretimde Dikkat
Seviyeyi işin sıklığına göre seç. Her istekte yapılan sıkıştırmada Fastest ya da Optimal; bir kez üretilip çok kez indirilen dosyada daha yüksek seviye. Brotli'nin quality 11'i 6,8 MB için 7 saniye sürdü; bunu bir request'te çalıştırmak, sunucunun o istek boyunca bir CPU çekirdeğini kaybetmesi demek.
Zaten sıkıştırılmış veriyi tekrar sıkıştırma. PDF, JPEG (fotoğraflar için kayıplı sıkıştırılmış görüntü formatı), PNG (kayıpsız sıkıştırılmış görüntü formatı) ve zip dosyaları zaten sıkıştırılmıştır; onları bir zip'e koyarken CompressionLevel.NoCompression CPU harcamadan hemen hemen aynı boyutu verir.
Streaming endpoint'lerde hata yönetimi farklıdır. Response başladıktan sonra oluşan bir exception'da status code'u değiştiremezsin; istemci yarım bir zip alır. Bu yüzden riskli işleri (yetki, veri doğrulama) ilk byte'tan önce bitir ve indirmenin bütünlüğünü istemci tarafında kontrol edilebilir kıl.
Upload edilen arşivleri ayrı bir klasöre ve limitlerle aç: entry sayısı, toplam açılmış boyut, oran, izin verilen uzantılar. Açılan dosyaları da diğer kullanıcı girdileri gibi doğrula; arşivin içinden gelmesi onları güvenilir yapmaz.
Sık Düşülen Tuzaklar
GZipStream'i dispose etmeden output'u kullanmak. Son blok ve footer dispose'ta yazılır; öncesinde okunan MemoryStream bozuk bir gzip içerir. Bunu serinin Stream modeli yazısında görmüştük.
Entry boyutunu yanlış alan üzerinden okumak. CompressedLength arşivdeki, Length açılmış boyuttur; ikisi de arşivin beyanıdır, doğrulanmış bilgi değildir. Bellekte buffer ayırmak için Length'e güvenmek, saldırganın buffer boyutunu seçmesine izin vermektir.
Aynı ZipArchive'e paralel yazmak. Create modunda aynı anda tek entry açık olabilir; paralel kod ya exception alır ya da arşivi bozar. Üretimi paralelleştir, yazmayı tek bir yazıcıda topla.
Async API kullanınca her şeyin async olduğunu varsaymak. .NET 10'da OpenAsync'in döndüğü stream dispose sırasında senkron yazıyor; senkron yazmayı reddeden stream'lerle (Kestrel response body'si gibi) gerçek ortamda test et.
Entry adını doğrulamadan path'e çevirmek. Path.Combine(target, entry.FullName) Zip Slip'e açıktır; GetFullPath + kök kontrolü olmadan hiçbir entry'yi diske yazma.
Küçük Bir Deneme
★☆☆ formats komutuna ZLibCompressionStrategy.HuffmanOnly ve RunLengthEncoding stratejilerini ekle. NDJSON verisinde boyut ve süre nasıl değişiyor? Hangi tür veri için bu stratejiler anlamlı olabilir?
★★☆ /api/invoices/zip endpoint'ini AllowSynchronousIO olmadan çalışacak şekilde yeniden yaz. İpucu: WriteCountingStream'e benzeyen bir sarmalayıcı yaz; senkron yazmaları kendi buffer'ında biriktirsin, her entry kapandıktan sonra çağırdığın bir FlushPendingAsync method'u bu byte'ları response'a async göndersin. Buffer en fazla kaç byte'a çıkıyor?
★★★ SafeZipExtractor'a entry adları için bir izin listesi ekle (yalnız .xml ve .csv, en fazla iki klasör derinliği) ve aynı entry adının arşivde iki kez geçtiği durumu yakala. Bu durum neden ayrı bir risk?
Bu yazıdaki dosyaların en büyüğü 13 MB'tı. Bir sonraki yazıda ölçeği büyütüyoruz: 5 GB'lık bir sipariş log'undan günlük ciroyu sabit bellekle çıkarmak, dosyayı offset aralıklarına bölüp paralel işlemek ve memory-mapped file'ların ne zaman gerçekten hızlandırdığını üç yaklaşımın gerçek ölçümleriyle göreceğiz.