Mert Özen Her Satırda Daha İleri
Backend Node.js Temelleri — #08

Node.js'te File System: fs/promises, path, Buffer ve Dosya İşlemlerinde Tuzaklar

Mert Özen 14 Kas 2026 17 dk 9 görüntülenme
Node.js'te File System: fs/promises, path, Buffer ve Dosya İşlemlerinde Tuzaklar

Node.js sunucusunda dosya işlemleri node:fs/promises ile yapılır; ama asıl iş encoding'i doğru ele almak, dosyayı atomik yazmak ve kullanıcıdan gelen yolu kök klasöre hapsetmektir. Gerçek çıktılarla 8 karakterlik Çalıkuşu'nun 11 byte olması, chunk sınırında bozulan Türkçe karakterler, Excel CSV'sindeki BOM, writeFile ile 62 okumanın 61'inin yarım dosya görmesi, kaybolan güncellemeler, startsWith tuzağı ve Permission Model'in fsync'i kapatması.

Sürüm notu: Bu yazıdaki örnekler Node.js 26.13.1 (LTS) ve TypeScript 7.0.2 ile çalıştırılmıştır.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) kataloğuna bir Türk klasiği ekliyoruz. Başlığı byte'lara (UTF-8; Unicode karakterlerini 1 ile 4 byte arasında kodlayan encoding) çevirip ilk byte'ı tekrar string'e dönüştürüyoruz:

// Bir Türk klasiği: başlık ne kadar uzun?
const title = "Çalıkuşu";
const bytes = Buffer.from(title, "utf8");

console.log(title.length);
console.log(bytes.length);
console.log(bytes.subarray(0, 1).toString("utf8"));

Üç satırda ne yazar? length iki satırda da 8 mi, son satır Ç mı?

node labs/08/guess.ts
8
11
�

String 8 karakter, ama UTF-8'de 11 byte: Ç, ı ve ş ikişer byte tutuyor. İlk byte tek başına geçerli bir karakter olmadığı için yerine replacement character (U+FFFD) geliyor. Dosyalarla çalışırken string'in uzunluğu ile dosyadaki byte sayısını karıştırmak, chunk'ları tek tek decode etmek ve encoding'i varsaymak, Türkçe veriyle çalışan her sistemde bir gün sorun çıkarır.

Bu yazıda Node'un file system API'lerini sunucu gözüyle görüyoruz: hangi API ailesi nerede kullanılır, path ile yol işlemleri, Buffer ve encoding'ler, atomik yazma, eşzamanlı yazma yarışları ve path traversal'a karşı savunma. Bookstore'a da iki parça ekliyoruz: kitap kapağı klasörü ve CSV (virgülle ayrılmış değerler) dosyasından katalog içe aktarma.

Üç API Ailesi: Sync, Callback ve Promise

import { readFileSync, readFile } from "node:fs";
import { readFile as readFileAsync } from "node:fs/promises";

const path = new URL("../../src/db/books.json", import.meta.url);

// 1) Sync: okuma bitene kadar event loop'u bloklar; uygulama açılırken sorun değil
const sync = readFileSync(path, "utf8");
console.log(`sync:     ${sync.length} chars`);

// 2) Callback: error-first, en eski API
readFile(path, "utf8", (err, text) => console.log(`callback: ${err ? err.code : text.length + " chars"}`));

// 3) Promise: request handler'larında kullandığımız
const text = await readFileAsync(path, "utf8");
console.log(`promise:  ${text.length} chars`);
sync:     454 chars
callback: 454 chars
promise:  454 chars

Üçü aynı işi yapıyor; fark, beklerken ne olduğunda. Sync sürüm dosya okunana kadar ana thread'i tutar. Callback ve promise sürümleri işi libuv'un thread pool'una verir (06) ve event loop'u serbest bırakır. Sunucuda kural: request yolunda yalnız node:fs/promises. Sync API'ler uygulama açılırken bir configuration dosyasını okumak ya da bir CLI script'i için uygun. Callback sürümünü yeni kodda kullanmak için bir neden yok (07).

.NET'ten gelenler için bir fark: Linux'ta File.ReadAllTextAsync gibi C# API'leri de dosya okumasını ThreadPool'daki bir thread'de bekler, ama ThreadPool ihtiyaca göre büyüdüğü için bunu genelde fark etmezsin. Node'un pool'u ise varsayılan olarak sabit 4 thread; çok sayıda eşzamanlı dosya işlemi ve crypto aynı pool'u paylaşır.

Dosya ve Klasör İşlemleri: mkdir, readdir, stat ve glob

Bookstore'da kitap kapakları uploads/covers/<isbn>.jpg olarak duracak. src/storage/coverStorage.ts'teki listeleme fonksiyonu üç temel çağrıyı bir arada gösteriyor:

export async function listCovers(): Promise<CoverInfo[]> {
    const entries = await readdir(coversRoot, { withFileTypes: true }).catch((err: NodeJS.ErrnoException) => {
        if (err.code === "ENOENT") {
            return [];
        }
        throw err;
    });
    const files = entries.filter((e) => e.isFile() && e.name.endsWith(".jpg"));
    return Promise.all(
        files.map(async (e) => {
            const info = await stat(path.join(coversRoot, e.name));
            return { isbn: path.basename(e.name, ".jpg"), sizeBytes: info.size, modifiedUtc: info.mtime.toISOString() };
        }),
    );
}

readdir'a withFileTypes: true verince isim yerine Dirent object'leri döner; isFile() ve isDirectory() için ayrıca stat çağırmak gerekmez. Klasör yoksa ENOENT gelir; burada onu "henüz kapak yok" olarak yorumluyoruz, diğer hataları yukarı fırlatıyoruz. Hata kodları (ENOENT, EACCES, EEXIST, EPERM) işletim sisteminden gelir ve err.code'da durur; .NET'teki FileNotFoundException gibi ayrı exception tipleri yok, ayrımı bu kodla yaparsın. Klasör oluşturmak için mkdir(dir, { recursive: true }) kullanıyoruz: ara klasörleri de oluşturur, klasör zaten varsa hata vermez.

Node 22'de gelen ve 24'te stable olan fs.glob, pattern ile dosya bulmayı ek paket olmadan yapıyor. Kapakları kaydedip iki yolla listeleyelim:

import { glob } from "node:fs/promises";
import { saveCover, listCovers } from "#storage/coverStorage";

// En küçük taklitler: JPEG FF D8 FF ile, PNG 89 50 4E 47 ile başlar
const fakeJpeg = (size: number) => Uint8Array.from({ length: size }, (_, i) => [0xff, 0xd8, 0xff, 0xe0][i] ?? 0);
const fakePng = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);

for (const [isbn, size] of [["9780441172719", 48_213], ["9780547928227", 35_870]] as const) {
    const saved = await saveCover(isbn, fakeJpeg(size));
    console.log(`saved ${saved.isbn}: ${saved.sizeBytes} bytes`);
}

for (const [isbn, bytes] of [["9780141439600", fakePng], ["../../src/server", fakeJpeg(10)]] as const) {
    try {
        await saveCover(isbn, bytes);
    } catch (err) {
        console.log(`${(err as Error).name}: ${(err as Error).message}`);
    }
}

console.log(`listCovers: ${(await listCovers()).map((c) => c.isbn).join(", ")}`);
console.log(`glob:       ${(await Array.fromAsync(glob("uploads/**/*.jpg"))).join(", ")}`);
saved 9780441172719: 48213 bytes
saved 9780547928227: 35870 bytes
InvalidCoverError: cover must be a JPEG file
InvalidCoverError: invalid ISBN-13: ../../src/server
listCovers: 9780441172719, 9780547928227
glob:       uploads/covers/9780441172719.jpg, uploads/covers/9780547928227.jpg

PNG (bir resim formatı) ve geçersiz ISBN'li (ISBN: kitapların uluslararası standart numarası) kayıt reddedildi; nedenini aşağıdaki güvenlik bölümünde göreceğiz.

FileHandle: Açtığını Kapat

Dosyanın tamamını değil yalnız bir kısmını okumak için open ile bir FileHandle alırsın. Handle bir işletim sistemi kaynağı (file descriptor) tutar ve kapatılması gerekir. C#'taki await using var stream = File.OpenRead(...) ifadesinin JavaScript'teki karşılığı artık var: FileHandle, Symbol.asyncDispose'u destekliyor ve V8 (Node'un JavaScript engine'i) 14.6 await using'i çalıştırıyor.

import { open } from "node:fs/promises";

// Dosyanın ilk byte'larını okur; read() hata fırlatsa da handle kapanır
async function readMagic(file: string): Promise<string> {
    await using handle = await open(file, "r");
    const { buffer, bytesRead } = await handle.read(Buffer.alloc(4), 0, 4, 0);
    return buffer.subarray(0, bytesRead).toString("hex");
}

// Aynı işin eski yazımı
async function readMagicFinally(file: string): Promise<string> {
    const handle = await open(file, "r");
    try {
        const { buffer, bytesRead } = await handle.read(Buffer.alloc(4), 0, 4, 0);
        return buffer.subarray(0, bytesRead).toString("hex");
    } finally {
        await handle.close();
    }
}

console.log(await readMagic("data/catalog-sample.csv"), await readMagicFinally("data/catalog-sample.csv"));
efbbbf69 efbbbf69

Kapatmayı unutursan Node, garbage collector handle'ı topladığında descriptor'ı kapatmaya çalışır ve bir uyarı yazar; ama doküman bu davranışa güvenmemeni açıkça söylüyor. Yoğun bir sunucuda kapatılmayan handle'lar EMFILE: too many open files hatasıyla biter. Çıktıdaki efbbbf da tesadüf değil; birazdan ona döneceğiz.

path: join, resolve ve İşletim Sistemi Farkları

import path from "node:path";

console.log(path.join("uploads", "covers", "9780441172719.jpg"));
console.log(path.join("uploads", "/etc/passwd"));
console.log(path.resolve("uploads", "/etc/passwd"));
console.log(path.join("uploads/covers", "../../src/server.ts"));
console.log(path.win32.join("C:\\bookstore", "uploads", "covers"));
console.log(path.extname("cover.final.JPG"), path.basename("/data/catalog.csv", ".csv"));
console.log(import.meta.dirname === path.dirname(import.meta.filename));
uploads/covers/9780441172719.jpg
uploads/etc/passwd
/etc/passwd
src/server.ts
C:\bookstore\uploads\covers
.JPG catalog
true

join parçaları ayraçla birleştirip normalize eder; mutlak bir parça görse bile onu sıradan bir parça gibi ekler. resolve ise sağdan sola mutlak bir yol oluşturur ve mutlak bir parça gördüğü anda soldakileri atar: /etc/passwd doğrudan sonuç oldu. İkisi de ..'yu işler, bu yüzden join ile birleştirdiğin bir kullanıcı girdisi klasörün dışına çıkabilir. C#'taki Path.Combine da ikinci parça mutlaksa ilkini atar; resolve'a benzer davranır.

Çıktılar Linux ve macOS'ta / ile, Windows'ta \ ile yazılır; path module'ü çalıştığı işletim sisteminin kurallarını kullanır. Belirli bir sistemin kurallarını istiyorsan path.posix ya da path.win32'yi açıkça seç. Yolları elle "/" ile birleştirme, dosya adı karşılaştırırken büyük/küçük harfe dikkat et: Windows ve varsayılan macOS dosya sistemi büyük/küçük harfe duyarsızdır, Linux duyarlıdır. cover.JPG Windows'ta çalışıp Linux container'ında bulunamayan bir dosyaya dönüşebilir.

Module'ün kendi klasörü için ESM'de (ECMAScript module'lerinde) import.meta.dirname var (03). Göreli yollar ise process.cwd()'ye, yani process'in başlatıldığı klasöre göre çözülür. Bookstore'da uploads/covers bu yüzden COVERS_DIR environment variable'ıyla değiştirilebiliyor ve açılışta bir kez mutlak yola çevriliyor.

Buffer ve Encoding'ler: Türkçe Karakterler, Parçalı Okuma ve BOM

Buffer, Node'un byte dizisi; Uint8Array'den türüyor, yani C#'taki byte[] ile Span<byte> arasında bir yerde. Dosya encoding vermeden okunursa Buffer döner, "utf8" verilirse string. Desteklenen encoding'ler arasında utf8, utf16le, latin1, base64, base64url ve hex var; Windows-1254 (Türkçe Windows) yok. Eski sistemlerden gelen Windows-1254 dosyalar için new TextDecoder("windows-1254") kullanılabilir.

Asıl tuzak, verinin parça parça geldiği durum: stream'den okunan chunk'lar, socket'ten gelen paketler. Bir chunk sınırı çok byte'lı bir karakterin ortasına denk gelirse:

import { StringDecoder } from "node:string_decoder";

// Byte'lar parça parça geliyor; bir parça sınırı çok byte'lı bir karakteri bölebilir
const bytes = Buffer.from("Çalıkuşu;Reşat Nuri Güntekin", "utf8");
const chunks = [bytes.subarray(0, 5), bytes.subarray(5, 9), bytes.subarray(9)];

let naive = "";
for (const chunk of chunks) {
    naive += chunk.toString("utf8");
}
console.log(`naive:       ${naive}`);

const decoder = new TextDecoder("utf-8");
let streamed = "";
for (const chunk of chunks) {
    streamed += decoder.decode(chunk, { stream: true });
}
streamed += decoder.decode();
console.log(`TextDecoder: ${streamed}`);

const sd = new StringDecoder("utf8");
console.log(`StringDecoder: ${chunks.map((c) => sd.write(c)).join("") + sd.end()}`);

console.log(`base64: ${Buffer.from("Çalıkuşu").toString("base64")}`);
naive:       Çal��ku��u;Reşat Nuri Güntekin
TextDecoder: Çalıkuşu;Reşat Nuri Güntekin
StringDecoder: Çalıkuşu;Reşat Nuri Güntekin
base64: w4dhbMSxa3XFn3U=

Her chunk'ı ayrı decode edince ı ve ş ikiye bölünüp bozuldu. TextDecoder'ın stream: true seçeneği ve Node'un StringDecoder'ı yarım kalan byte'ları bir sonraki chunk'a saklıyor. Test verin ASCII (yalnız İngilizce harfler, rakamlar ve temel işaretlerden oluşan 7 bitlik karakter seti) ise bu hatayı hiç görmezsin; Türkçe karakterli test verisi kullanmanın bir nedeni de bu. Stream'lerde setEncoding("utf8") aynı işi yapar (09).

Dosyanın başındaki efbbbf ise UTF-8 BOM (Byte Order Mark; dosyanın başına eklenen ve encoding'i belirten görünmez işaret). Excel "CSV UTF-8" olarak kaydettiğinde dosyanın başına bunu ekler ve readFile(..., "utf8") onu silmez:

import { readFile } from "node:fs/promises";

const text = await readFile(new URL("../../data/catalog-sample.csv", import.meta.url), "utf8");
const firstColumn = text.split(/\r?\n/)[0].split(";")[0];
console.log(firstColumn, firstColumn.length, firstColumn === "isbn", firstColumn.charCodeAt(0).toString(16));
isbn 5 false feff

Ekranda isbn yazıyor ama uzunluğu 5 ve "isbn"'e eşit değil; ilk karakter U+FEFF. .NET'in StreamReader'ı BOM'u varsayılan olarak tanıyıp atlar, Node atlamaz. Kullanıcıdan gelen her metin dosyasında BOM'u kendin temizle.

Bookstore: CSV ile Katalog İçe Aktarma

Mağaza ekibi yeni kitapları Excel'den CSV olarak veriyor. Türkçe yerel ayarlı Excel ayraç olarak ;, ondalık ayraç olarak , kullanıyor ve satır sonları \r\n. Örnek dosya:

isbn;title;author;price;currency
9780441172719;Dune;Frank Herbert;349,90;TRY
9780547928227;The Hobbit;J.R.R. Tolkien;289,50;TRY
9780141439600;Middlemarch;George Eliot;310,00;TRY
9780141439587;Emma;Jane Austen;245,00;TRY
9789750719387;Çalıkuşu;Reşat Nuri Güntekin;185,00;TRY
9780141439518;Pride and Prejudice;Jane Austen;12,99;EUR
9780141439519;Persuasion;Jane Austen;11,50;EUR
9780141441146;Jane Eyre;Charlotte Bronte;abc;TRY
// Türkçe yerel ayarlı Excel'in "CSV UTF-8" çıktısı: isteğe bağlı BOM, ';' ayraç, ',' ondalık işareti
export function parseCatalogCsv(text: string): CatalogImportResult {
    const lines = text.replace(/^\uFEFF/, "").split(/\r?\n/);
    const header = lines[0].split(";").map((c) => c.trim().toLowerCase());
    if (header.join(";") !== HEADER.join(";")) {
        throw new Error(`unexpected header: ${header.join(";")}`);
    }

    const books: Book[] = [];
    const errors: string[] = [];
    lines.slice(1).forEach((line, index) => {
        const lineNo = index + 2;
        if (line.trim() === "") {
            return;
        }
        const fields = line.split(";").map((f) => f.trim());
        if (fields.length !== HEADER.length) {
            errors.push(`line ${lineNo}: expected ${HEADER.length} fields, got ${fields.length}`);
            return;
        }
        const [isbn, title, author, priceText, currency] = fields;
        const price = Number(priceText.replace(",", "."));
        if (!isValidIsbn13(isbn)) {
            errors.push(`line ${lineNo}: invalid ISBN-13 ${isbn}`);
        } else if (!Number.isFinite(price) || price <= 0) {
            errors.push(`line ${lineNo}: invalid price ${priceText}`);
        } else if (!CURRENCIES.includes(currency)) {
            errors.push(`line ${lineNo}: unknown currency ${currency}`);
        } else {
            books.push({ isbn, title, author, price, currency: currency as Currency });
        }
    });
    return { books, errors };
}

// Yalnız küçük dosyalar: dosyanın tamamı belleğe okunur (büyük dosyalar: stream'ler, sonraki yazı)
export async function readCatalogCsv(csvPath: string): Promise<CatalogImportResult> {
    return parseCatalogCsv(await readFile(csvPath, "utf8"));
}

Parser bilerek basit: tırnak içinde ; barındıran alanları desteklemiyor. Gerçek bir içe aktarmada CSV'nin resmî tarifini (RFC 4180) uygulayan bir kütüphane kullan; burada amacımız dosya ve encoding tarafı. isValidIsbn13, 04'te kurduğumuz @bookstore/contracts paketinden geliyor. Script, sonucu data/catalog.json'a yazıyor ve atlanan satır varsa exit code 1 ile bitiyor; böylece CI'da ya da bir zamanlanmış görevde hatayı kaçırmazsın:

node scripts/import-catalog.ts data/catalog-sample.csv; echo "exit=$?"
skipped line 8: invalid ISBN-13 9780141439519
skipped line 9: invalid price abc
imported 6 books into data/catalog.json, 2 rows skipped
exit=1

Bu JSON'u 13'te SQL Server'daki BookstoreDb'ye yükleyeceğiz. Yazma işi writeFileAtomic ile yapılıyor; nedeni bir sonraki bölümde.

Atomik Yazma ve Eşzamanlı Yazma Yarışları

writeFile dosyayı önce boşaltır, sonra içeriği parça parça yazar. Bu sırada dosyayı okuyan biri yarım bir dosya görür. Bir okuyucu dosyayı sürekli okurken bir yazıcı 7,6 MB'lık bir katalog dosyasını 20 kez değiştirsin:

// Bir yazıcı dosyayı 20 kez değiştirirken bir okuyucu dosyayı sürekli okuyor
async function run(label: string, write: (data: string) => Promise<void>): Promise<void> {
    await writeFileAtomic(target, catalog(0));
    let writing = true;
    let reads = 0;
    let broken = 0;
    const reader = (async () => {
        while (writing) {
            const text = await readFile(target, "utf8");
            reads++;
            try {
                JSON.parse(text);
            } catch {
                broken++;
            }
        }
    })();
    for (let n = 1; n <= 20; n++) {
        await write(catalog(n));
    }
    writing = false;
    await reader;
    console.log(`${label.padEnd(16)} reads: ${reads}, half-written: ${broken}`);
}

await run("writeFile", (data) => writeFile(target, data));
await run("writeFileAtomic", (data) => writeFileAtomic(target, data));
writeFile        reads: 62, half-written: 61
writeFileAtomic  reads: 31, half-written: 0

Düz writeFile ile 62 okumanın 61'i parse edilemeyen bir dosya gördü. Atomik sürümde hiçbiri. Çözüm, içeriği önce aynı klasördeki geçici bir dosyaya yazıp sonra rename ile hedefin üzerine taşımak:

import { writeFile, rename, rm } from "node:fs/promises";
import { randomUUID } from "node:crypto";

// Okuyan taraf ya eski dosyayı ya yenisini görür, yarım yazılmış dosyayı asla
export async function writeFileAtomic(target: string, data: string | Uint8Array): Promise<void> {
    const temp = `${target}.${randomUUID()}.tmp`;
    try {
        await writeFile(temp, data);
        await rename(temp, target);
    } catch (err) {
        await rm(temp, { force: true });
        throw err;
    }
}

Aynı dosya sistemi içinde rename POSIX (Linux ve macOS gibi Unix benzeri sistemlerin ortak standardı) sistemlerde atomiktir; Windows'ta libuv hedefin üzerine yazan bir taşıma yapar, ama hedef dosyayı başka bir process açık tutuyorsa (antivirüs, yedekleme aracı) EPERM alabilirsin. Geçici dosya bu yüzden hedefle aynı klasörde: farklı bir disk ya da volume'a rename yapılamaz (EXDEV). Elektrik kesintisine karşı da dayanıklı olmak istersen dosyayı rename'den önce diske flush etmen gerekir; bunun Permission Model ile beklenmedik bir etkileşimini aşağıda göreceğiz.

Atomik yazma, okuyanı korur ama iki yazıcıyı korumaz. Aynı dosyada oku-değiştir-yaz yapan 50 eşzamanlı rezervasyon:

// Koordinasyonsuz oku-değiştir-yaz: 50 eşzamanlı rezervasyon
async function reserveOne(): Promise<void> {
    const stock = JSON.parse(await readFile(file, "utf8"));
    stock.reserved += 1;
    await writeFile(file, JSON.stringify(stock));
}

await Promise.all(Array.from({ length: 50 }, reserveOne));
const final = JSON.parse(await readFile(file, "utf8"));
console.log(`expected 50, file says ${final.reserved}`);
for i in 1 2 3 4 5 6; do node labs/08/lost-update.ts 2>&1 | grep -E "expected|SyntaxError"; done
expected 50, file says 1
expected 50, file says 2
expected 50, file says 1
SyntaxError: Unexpected end of JSON input
expected 50, file says 1
expected 50, file says 1

Tek thread'de çalışmamıza rağmen yarış var: her await'te başka bir rezervasyon araya giriyor, hepsi aynı eski değeri okuyup üzerine yazıyor. 50 rezervasyondan 1–2'si kaldı, bir koşuda da yarım yazılmış dosya okunup process çöktü. Node dokümanı da promise tabanlı file system işlemlerinin birbirine göre senkronize edilmediğini, aynı dosyada eşzamanlı değişikliklerin veriyi bozabileceğini söylüyor. Paylaşılan ve değişen veri dosyada değil veritabanında durur; stok için 13'te transaction kullanacağız. Dosyada kalmak zorundaysa yazmaları tek bir queue'dan geçir.

Son bir not: fs.watch ile dosya değişikliğini izlemek platformlar arasında tutarlı değil. Aynı kayıt bazı sistemlerde iki event üretir, bazılarında dosya adı gelmez, ağ sürücülerinde ve bazı container mount'larında hiç çalışmaz. Configuration'ı yeniden yüklemek gibi işler için watch'a güvenme; process'i yeniden başlat ya da açık bir reload endpoint'i kullan.

Path Traversal ve Permission Model

Kullanıcıdan gelen bir değer dosya yolunun parçası olduğunda, ../ ile izin verilen klasörün dışına çıkılmasına path traversal denir. Yaygın bir savunma, yolu çözüp kök klasörle başlayıp başlamadığına bakmak. Ama bu kontrolün de bir tuzağı var:

import path from "node:path";
import { resolveInsideRoot } from "#storage/safePath";

const root = "/srv/bookstore/uploads/covers";

// Saf kontrol: çözülen yol kök klasörün string'iyle mi başlıyor?
function naiveResolve(requested: string): string {
    const full = path.resolve(root, requested);
    if (!full.startsWith(root)) {
        throw new Error(`blocked ${requested}`);
    }
    return full;
}

function attempt(resolve: () => string): string {
    try {
        return `allowed ${resolve()}`;
    } catch {
        return "blocked";
    }
}

for (const requested of ["9780441172719.jpg", "../../../../etc/passwd", "../covers-backup/keys.txt", decodeURIComponent("..%2F..%2Fconfig.json")]) {
    console.log(requested);
    console.log(`  naive: ${attempt(() => naiveResolve(requested))}`);
    console.log(`  safe:  ${attempt(() => resolveInsideRoot(root, requested))}`);
}
9780441172719.jpg
  naive: allowed /srv/bookstore/uploads/covers/9780441172719.jpg
  safe:  allowed /srv/bookstore/uploads/covers/9780441172719.jpg
../../../../etc/passwd
  naive: blocked
  safe:  blocked
../covers-backup/keys.txt
  naive: allowed /srv/bookstore/uploads/covers-backup/keys.txt
  safe:  blocked
../../config.json
  naive: blocked
  safe:  blocked

covers-backup, covers ile başladığı için saf kontrolden geçti. Doğru kontrol, kökün sonuna ayracı ekleyip karşılaştırmak:

// Kullanıcıdan gelen göreli yolu çözer ve kök klasörün dışındaki her şeyi reddeder
export function resolveInsideRoot(root: string, requested: string): string {
    const absoluteRoot = path.resolve(root);
    const full = path.resolve(absoluteRoot, requested);
    if (full !== absoluteRoot && !full.startsWith(absoluteRoot + path.sep)) {
        throw new PathOutsideRootError(requested);
    }
    return full;
}

URL'den gelen değeri bu kontrolden önce decode et; kontrol edip sonra decode edersen %2F kontrolden sonra /'a dönüşür. Ama en iyi savunma, kullanıcının dosya adını hiç belirleyememesi. coverStorage'da dosya adı yalnız geçerli bir ISBN-13 olabiliyor; resolveInsideRoot ikinci bir güvenlik katmanı. İçeriği de uzantıya değil ilk byte'lara bakarak kontrol ediyoruz: JPEG (en yaygın fotoğraf formatı) FF D8 FF ile başlar. Path traversal'ın genel hâli Web Güvenliği (OWASP Top 10) serisinde, .NET'teki dosya yükleme tarafı ASP.NET Core Web API serisinin "Dosya Yükleme ve İndirme" yazısında.

function coverPath(isbn: string): string {
    // Birinci savunma: dosya adı yalnız geçerli bir ISBN-13 olabilir
    if (!isValidIsbn13(isbn)) {
        throw new InvalidCoverError(`invalid ISBN-13: ${isbn}`);
    }
    // İkinci savunma: çözülen yol kapak klasörünün altında kalmalı
    return resolveInsideRoot(coversRoot, `${isbn}.jpg`);
}

Permission Model: Process'i Sınırlamak

Kodda bir hata olsa bile process'in diskte nereye yazabileceğini sınırlamak için Node'un Permission Model'i var; 23.5'ten beri stable. --permission ile açılır ve açıkça izin verilmeyen file system, network, child process ve worker erişimini ERR_ACCESS_DENIED ile reddeder. Kapak klasörüne yazma izni verip deneyelim:

mkdir -p uploads
node --permission --allow-fs-read=. --allow-fs-write=./uploads labs/08/covers.ts
node --permission --allow-fs-read=. --allow-fs-write=./uploads scripts/import-catalog.ts data/catalog-sample.csv
saved 9780441172719: 48213 bytes
saved 9780547928227: 35870 bytes
...
skipped line 8: invalid ISBN-13 9780141439519
skipped line 9: invalid price abc
node:internal/fs/promises:1720
    binding.mkdir(
            ^

Error: Access to this API has been restricted. Use --allow-fs-write to manage permissions.
    at mkdir (node:internal/fs/promises:1720:13)
    at file:///home/user/bookstore-api/scripts/import-catalog.ts:17:7 {
  code: 'ERR_ACCESS_DENIED',
  permission: 'FileSystemWrite',
  resource: 'data'
}

Kapaklar yazıldı, data/'ya yazma reddedildi. Denemelerde üç şey öğrendim. Birincisi, izin verdiğin klasör henüz yoksa mkdir reddedildi; klasörü önceden oluşturman ya da ./uploads* gibi bir wildcard kullanman gerekiyor. İkincisi, writeFileAtomic'in ilk sürümünde dayanıklılık için writeFile(temp, data, { flush: true }) kullanmıştım ve Permission Model altında şu hatayı aldım:

Error [ERR_ACCESS_DENIED]: fsync API is disabled when Permission Model is enabled.
    at fsync (node:internal/fs/promises:1694:11)
    ...
    at async writeFileAtomic (file:///home/user/bookstore-api/src/storage/atomicWrite.ts:8:9)

flush arkada fsync çağırıyor ve fsync gibi file descriptor üzerinden çalışan çağrılar Permission Model'de kapalı. Bookstore'da kapaklar için Permission Model'i seçtim ve flush'ı kaldırdım; elektrik kesintisinde son kapak kaybolabilir, ama yarım dosya oluşmaz. Üçüncüsü, network de kısıtlanıyor: API'yi --allow-net olmadan başlatınca listen, Use --allow-net to manage permissions hatasıyla reddedildi. Doküman bu modeli bir emniyet kemeri olarak tarif ediyor: güvendiğin kodun yanlışlıkla zarar vermesini sınırlar, kötü niyetli koda karşı bir sınır değildir. Symlink'ler izin verilen klasörün dışına çıkabilir. İzolasyon için container ve işletim sistemi izinleri asıl katman olarak kalır.

Üretimde Dikkat

Yüklenen dosyalar container'ın içinde kaybolur. Container yeniden oluşturulunca uploads/ gider; birden fazla replika varsa her birinin klasörü farklıdır. Production'da kapakları bir volume'a ya da Azure Blob, Amazon S3 (Amazon'un object storage servisi) gibi bir object storage'a koy; coverStorage'ın arayüzü aynı kalır, implementasyonu değişir.

Disk ve descriptor limitlerini izle. Kapatılmayan handle'lar EMFILE, dolan disk ENOSPC ile görünür; ikisi de hata kodlarıyla gelir, log'da err.code'u mutlaka yaz.

Thread pool'u hesaba kat. Yoğun dosya işlemi yapan bir API, crypto ve dns.lookup ile aynı 4 thread'i paylaşır (06).

Encoding'i her zaman açıkça belirt. readFile(path) Buffer döndürür, readFile(path, "utf8") string; içe aktardığın dosyaların encoding'ini ve BOM durumunu dokümante et.

Sık Düşülen Tuzaklar

Request handler'ında readFileSync. Küçük bir dosyada bile event loop'u bloklar ve yük altında bütün request'leri bekletir.

startsWith(root) ile path kontrolü. Ayraç olmadan covers-backup gibi kardeş klasörler kontrolden geçer.

Chunk'ları tek tek toString() ile decode etmek. Türkçe karakterler chunk sınırında bozulur; TextDecoder ya da setEncoding kullan.

BOM'u unutmak. İlk kolon adı "\uFEFFisbn" olur ve header kontrolü sessizce başarısız olur.

Paylaşılan durumu dosyada tutmak. Tek thread olması yarışı engellemez; her await bir yarış penceresidir.

Küçük Bir Deneme

★☆☆ labs/08/decode-chunks.ts'teki metni "Iğdır;Ödemiş" yap ve chunk sınırlarını her karakteri bölecek şekilde değiştir. Kaç tane � görüyorsun?

★★☆ parseCatalogCsv'yi, aynı ISBN iki kez geçtiğinde ikinci satırı hata olarak raporlayacak şekilde genişlet. Örnek dosyaya tekrar eden bir satır ekleyip script'in exit code'unu kontrol et.

★★★ labs/08/lost-update.ts'teki yarışı, yazmaları sıraya koyan küçük bir SerialQueue class'ı ile düzelt (her iş bir öncekinin promise'ine zincirlensin). 50 rezervasyonun 50 olarak kaldığını ve toplam süreyi ölç.

Bu yazıdaki her şey dosyanın tamamını belleğe alıyordu. Bir sonraki yazıda bunun sınırına geliyoruz: 500 MB'lık bir sipariş export'unu Readable, Transform ve Writable stream'lerle, pipeline() ve backpressure ile bellek şişirmeden üretip HTTP response'a gzip'leyerek yazacağız.