Artigos
Arthur MairinkArthur Mairink@mairinkdev

Corrigindo um truncamento silencioso de buffer no swift-system da Apple

6 min de leitura
SwiftOpen SourceWindowsProgramação de Sistemas

Um pouco de contexto

swift-system é a biblioteca de baixo nível da Apple que dá ao Swift acesso idiomático e tipado a chamadas brutas do sistema: file descriptors, errno, paths e o restante da superfície POSIX. Ela é a base silenciosa de muita coisa no ecossistema Swift, então corretude ali importa mais do que a quantidade de linhas sugere.

No Windows não existe um pread/pwrite real, então a biblioteca emula esse comportamento por cima da API Win32 em WindowsSyscallAdapters.swift. Foi nessa camada de adaptação que passei uma tarde e encontrei um bug que falhava do pior jeito possível: silenciosamente.

O bug silencioso

POSIX recebe o tamanho do buffer como um inteiro do tamanho de um ponteiro. Em Windows 64 bits, isso é um Int de 64 bits. Mas as chamadas Win32 por baixo, ReadFile e WriteFile, aceitam apenas um DWORD, um inteiro sem sinal de 32 bits que chega no máximo a 4,294,967,295 bytes (4 GB).

O adaptador convertia o tamanho recebido direto para DWORD, sem validação. Então, quando alguém tentava ler ou escrever mais de 4 GB em uma única chamada, os bits mais altos simplesmente eram perdidos:

conceptual
swift
// nbyte is an Int (64-bit on Windows x64).// ReadFile / WriteFile only take a DWORD (32-bit unsigned) for the count.var bytesRead: DWORD = 0ReadFile(hFile, buffer, DWORD(nbyte), &bytesRead, nil)//                      ^^^^^^^^^^^^// When nbyte > 4_294_967_295, those high bits had nowhere to go.

O ponto perigoso é que isso não quebrava a execução nem retornava erro. A chamada podia indicar sucesso processando só uma quantidade truncada de dados. Um código chamador copiando um arquivo grande poderia acreditar que o buffer inteiro foi processado, enquanto uma parte nunca foi. É o tipo clássico de perda silenciosa de dados que aparece longe da causa real.

A correção

A correção aqui não era inventar um comportamento novo. Era falhar cedo e de forma explícita. Se o tamanho pedido não cabe em DWORD, o argumento é inválido, então eu defino errno como EINVAL e retorno -1 antes de tocar na chamada Win32:

Sources/System/Internals/WindowsSyscallAdapters.swift
swift
// Sources/System/Internals/WindowsSyscallAdapters.swiftlet handle: intptr_t = _get_osfhandle(fd)if handle == /* INVALID_HANDLE_VALUE */ -1 { ucrt._set_errno(EBADF); return -1 } +// Windows ReadFile accepts DWORD (32-bit) for buffer size, so validate nbyte doesn't exceed it+if nbyte > Int(DWORD.max) {+  ucrt._set_errno(EINVAL)+  return -1+}+ // NOTE: this is a non-owning handle, do *not* call CloseHandle on itlet hFile: HANDLE = HANDLE(bitPattern: handle)!

A mesma validação entra no pwrite, antes de WriteFile. É uma mudança pequena, mas altera o modo de falha de "silenciosamente errado" para "claramente errado", que é exatamente o esperado em uma biblioteca de sistemas. Sem mudança de API pública, apenas Windows, compatível com qualquer tamanho de buffer válido.

Provando que funciona

Testar um caso de 4 GB é desconfortável: CI não deveria alocar quatro gigabytes só para validar uma checagem de limite. O truque é que a validação olha para o count do buffer, não para a memória real por trás dele. Então eu aloco um buffer pequeno de 1 KB e passo um ponteiro cujo count declarado é DWORD.max + 1:

Tests/SystemTests/FileOperationsTestWindows.swift
swift
/// Test that buffer sizes exceeding DWORD.max (4GB) are properly rejectedfunc testBufferSizeLimit() throws {  try withTemporaryFilePath(basename: "testBufferSizeLimit") { path in    let fd = try FileDescriptor.open(      path.appending("test.txt"),      .readWrite,      options: [.create, .truncate],      permissions: .ownerReadWrite    )    defer { try? fd.close() }     try fd.writeAll("test data".utf8)     // A real, small allocation. We only fake the *count*.    let buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: 1024, alignment: 1)    defer { buffer.deallocate() }     // count > DWORD.max (UInt32.max = 4,294,967,295) must return EINVAL    let oversizedCount = Int(DWORD.max) + 1    let oversizedBuffer = UnsafeMutableRawBufferPointer(      start: buffer.baseAddress,      count: oversizedCount    )     // pread should fail with EINVAL    do {      _ = try fd.read(fromAbsoluteOffset: 0, into: oversizedBuffer)      XCTFail("Expected EINVAL for buffer size exceeding DWORD.max")    } catch let err as Errno {      XCTAssertEqual(err, .invalidArgument, "Expected EINVAL, got \(err)")    }     // pwrite should also fail with EINVAL    do {      _ = try fd.write(toAbsoluteOffset: 0, UnsafeRawBufferPointer(oversizedBuffer))      XCTFail("Expected EINVAL for buffer size exceeding DWORD.max")    } catch let err as Errno {      XCTAssertEqual(err, .invalidArgument, "Expected EINVAL, got \(err)")    }  }}

Tanto read quanto write devem lançar Errno.invalidArgument. O teste cobre exatamente o limite protegido pela correção, roda em milissegundos e quase não aloca memória.

Por que isso importa

Na superfície, são poucas linhas de validação. Mas o valor não está no tamanho do diff. Está no modo de falha que ele remove. Truncamento silencioso é uma das classes mais perigosas de bug porque tudo depois continua confiando em um número que mentiu. Quando o sintoma aparece, você já está depurando saída corrompida longe da causa real.

Falhar cedo transforma um bug invisível de corretude em um erro imediato e rastreável. Em código fundamental, quase sempre é a troca certa, porque um erro silencioso se multiplica em todos os projetos que dependem daquela base.

Aprendizados

Essa foi minha primeira contribuição integrada em um repositório da Apple, e a parte mais valiosa não foi só o diff. Foi a conversa de revisão sobre como publicar a correção. Discutimos se fazia sentido mirar uma branch de correção rápida antes de decidir por colocar em main. Ver como os mantenedores raciocinam sobre esse tipo de decisão ensinou mais do que o patch isolado.

A lição que ficou: em programação de sistemas, os bugs mais perigosos são os que não fazem barulho. Procure conversões que podem perder informação e faça essas conversões falharem de forma explícita. O PR completo e a discussão estão linkados abaixo.

Ver o pull request no GitHub