APIs de Sistema de Arquivos e Paths Multiplataforma
Objective
A maioria dos bugs com arquivos em .NET não está em ler bytes, e sim em tudo ao
redor: um path montado com o método errado que escapa em silêncio da pasta que
você queria, um arquivo aberto com um modo de compartilhamento que trava o
próximo chamador, uma enumeração que é um retrato do momento quando você queria
uma visão ao vivo, um link simbólico que faz uma varredura recursiva passar duas
vezes pelo mesmo diretório, ou um "salvar" que deixa um arquivo pela metade
depois de um crash. Este conceito cobre as APIs de sistema de arquivos de
System.IO com foco nessas bordas: Path.Join versus Path.Combine, o que muda
entre sistemas operacionais, as flags FileMode, FileAccess, FileShare e
FileOptions, enumeração preguiçosa com EnumerationOptions, links simbólicos,
substituição segura de arquivos e acesso por offset com File.OpenHandle e
RandomAccess. Os tipos de stream em si são tratados no conceito da hierarquia
de Stream.
Use Cases
- Montar um path a partir de um nome de arquivo informado pelo usuário sem deixar que ele substitua a pasta base.
- Varrer uma árvore de diretórios grande sem carregar todos os paths na memória de uma vez.
- Salvar um arquivo de configuração de modo que um crash nunca o deixe truncado.
- Ler e atualizar registros de tamanho fixo num arquivo grande, a partir de várias threads, sem uma posição de stream compartilhada.
Deep Dive
Path.Combine versus Path.Join
Path.Combine e Path.Join juntam as partes com um separador, mas divergem
quando uma parte posterior é rooted (um path absoluto). Combine trata essa
parte como um novo começo e descarta tudo o que veio antes. Join só
concatena.
csharpConsole.WriteLine(Path.Combine("data", "/etc/passwd")); // /etc/passwd
Console.WriteLine(Path.Join("data", "/etc/passwd")); // data/etc/passwd
Console.WriteLine(Path.Join("data/", "/reports")); // data//reports
Console.WriteLine(Path.Join("a", "b", "c", "d", "e")); // a/b/c/d/eA primeira linha é a perigosa: se o segundo argumento vem de um usuário, um valor
rooted escapa de data. A documentação de Path.Join faz exatamente esse
alerta, e é por isso que Join (disponível desde o .NET Core 2.1, com overloads
para spans e, a partir do .NET 9, para ReadOnlySpan<string>) é o padrão mais
seguro para pedaços não confiáveis. Duas coisas que Join não faz: não remove
separadores duplicados (a terceira linha, observada nesta máquina, mantém as duas
barras) e não torna o path seguro. Um pedaço como ../../x continua subindo
níveis. Para confinar um path, resolva e compare:
csharpvar baseDir = Path.GetFullPath("data") + Path.DirectorySeparatorChar;
var full = Path.GetFullPath(Path.Join("data", userInput));
if (!full.StartsWith(baseDir, StringComparison.Ordinal))
throw new InvalidOperationException("Path escapes the base folder");GetFullPath normaliza os segmentos . e .. (nesta máquina,
a/../b/./c.txt virou <cwd>/b/c.txt) e GetRelativePath("/a/b", "/a/c/d.txt")
devolveu ../c/d.txt. Outros membros úteis: IsPathFullyQualified, mais
estrito que IsPathRooted no Windows (um path como \temp é rooted mas não é
fully qualified), GetFileName, GetExtension (só a última extensão: .gz para
z.tar.gz) e EndsInDirectorySeparator.
O que muda entre sistemas operacionais
Três coisas diferem, e ajuda separar o que foi observado do que vem da documentação.
- Separadores.
Path.DirectorySeparatorCharé/no Linux e no macOS e\no Windows, que também aceita/. Observado aqui no macOS:DirectorySeparatorChareAltDirectorySeparatorChareram ambos/, ePath.GetFileName(@"C:\x\y.txt")devolveu a string inteira, porque a barra invertida é um caractere comum num nome de arquivo Unix. Gravar um arquivo chamadoa\b.txtcriou exatamente um arquivo com esse nome. A documentação dePath.Joinavisa sobre a mesma armadilha com paths do Windows escritos literalmente no código. - Maiúsculas e minúsculas. Se
Report.txteREPORT.TXTsão o mesmo arquivo depende do sistema de arquivos, não só do SO. Observado aqui (macOS, APFS com a configuração padrão sem distinção de caixa),File.Existsdeutruepara as duas grafias. Sistemas de arquivos Linux costumam distinguir caixa e os do Windows não, mas isso vem da documentação e de conhecimento geral, não desta execução. Nunca presuma um comportamento: compare nomes comStringComparison.Ordinalquando precisar de correspondência estrita. O casamento de padrões na enumeração tem seu próprio controle,EnumerationOptions.MatchCasing, cujo padrão éPlatformDefault. - Permissões. O Windows usa ACLs e o Unix usa bits de modo. O .NET expõe o
segundo como
File.GetUnixFileModeeSetUnixFileMode(marcados como não suportados no Windows, então o analisador avisa quando você os chama sem proteção).
FileMode, FileAccess, FileShare, FileOptions
O construtor de FileStream e File.OpenHandle recebem quatro escolhas
independentes. FileMode diz o que fazer quanto à existência, FileAccess diz o
que este handle pode fazer, FileShare diz o que os outros podem fazer enquanto
isso, e FileOptions traz dicas e comportamentos.
| Modo | Arquivo não existe | Arquivo existe |
|---|---|---|
CreateNew |
cria | IOException |
Create |
cria | trunca |
Open |
FileNotFoundException |
abre |
OpenOrCreate |
cria | abre |
Truncate |
FileNotFoundException |
esvazia |
Append |
cria | abre, posicionado no fim |
Append só funciona com acesso de escrita: combiná-lo com
FileAccess.ReadWrite lança ArgumentException (confirmado rodando). O
compartilhamento é checado nos dois lados: a segunda abertura precisa ser
compatível com o que a primeira permitiu, e o que a primeira permitiu precisa ser
compatível com o que a segunda pede.
csharpusing var first = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.None);
using var second = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
// IOException: The process cannot access the file '...' because it is being used by another process.Dois leitores que permitem FileShare.Read conviveram sem problema. No Windows,
FileShare é imposto pelo SO. No Unix, o código-fonte do runtime
(SafeFileHandle.Unix.cs no dotnet/runtime) emula o recurso com um flock que o
próprio comentário chama de "only advisory": FileShare.None pega um lock
exclusivo e os demais valores um lock compartilhado. Isso protege você de outro
código .NET abrindo o arquivo com flags incompatíveis, mas um programa que nunca
chama flock pode ignorá-lo. Minha execução foi dentro de um único processo,
então a afirmação entre processos vem desse código-fonte, não deste experimento.
FileOptions combina flags como Asynchronous (necessária para I/O assíncrono
de verdade, veja o conceito de async I/O), SequentialScan e RandomAccess
(dicas de read-ahead), WriteThrough e DeleteOnClose. DeleteOnClose é uma
forma simples de ter um arquivo temporário que se limpa sozinho:
csharpvar tmp = Path.Join(dir, Path.GetRandomFileName());
using (var t = new FileStream(tmp, new FileStreamOptions
{
Mode = FileMode.CreateNew, Access = FileAccess.ReadWrite,
Share = FileShare.None, Options = FileOptions.DeleteOnClose
}))
{
t.WriteByte(1);
Console.WriteLine(File.Exists(tmp)); // True
}
Console.WriteLine(File.Exists(tmp)); // FalsePath.GetRandomFileName() devolve um nome de 12 caracteres e não cria nada,
enquanto Path.GetTempFileName() cria um arquivo vazio na pasta temporária do
sistema. Prefira o primeiro junto com CreateNew para não deixar arquivos
esquecidos.
Enumeração preguiçosa e EnumerationOptions
Directory.GetFiles devolve um array: o diretório é lido uma vez, de antemão.
Directory.EnumerateFiles devolve um IEnumerable<string> que lê conforme você
itera. Os dois comportamentos apareceram numa mesma execução: um arquivo criado
depois de chamar EnumerateFiles mas antes de iterar apareceu na sequência,
enquanto o array de GetFiles ficou com 4 entradas quando um quinto arquivo
surgiu. Numa árvore grande, Enumerate* também permite parar cedo sem nunca
guardar todos os paths. O custo é que o resultado não é um retrato estável, e o
handle subjacente vive até você terminar ou descartar o enumerador.
EnumerationOptions substitui SearchOption com controle mais fino:
csharpvar opts = new EnumerationOptions
{
RecurseSubdirectories = true,
MatchCasing = MatchCasing.CaseInsensitive,
IgnoreInaccessible = true, // ignora pastas ilegíveis (já é o padrão)
AttributesToSkip = FileAttributes.Hidden | FileAttributes.ReparsePoint,
MaxRecursionDepth = 8,
};
foreach (var f in Directory.EnumerateFiles(root, "*.log", opts)) { }Com MatchCasing.CaseSensitive, o padrão *.LOG casou 0 arquivos aqui e com
CaseInsensitive casou 6, na mesma pasta, que é a forma limpa de obter uma
resposta determinística independente do padrão da plataforma.
Links simbólicos
File.CreateSymbolicLink e Directory.CreateSymbolicLink criam links (no
Windows isso pode exigir privilégios ou o modo desenvolvedor). Um
FileSystemInfo expõe LinkTarget (o texto literal gravado no link, sub aqui,
relativo à pasta do link) e ResolveLinkTarget(returnFinalTarget), e um link tem
FileAttributes.ReparsePoint ligado. A consequência importante para varreduras:
por padrão AttributesToSkip é só Hidden | System, então uma enumeração
recursiva segue links de diretório. Com uma pasta sub real e um link para ela,
uma busca recursiva por deep.log encontrou 2 arquivos, e acrescentar
AttributesToSkip = FileAttributes.ReparsePoint trouxe o resultado de volta para
- Um link que aponta para um de seus próprios pais pode fazer a varredura descer
muito fundo até
MaxRecursionDepthou o limite de tamanho do path pará-la.
Substituir arquivos sem perdê-los
File.WriteAllText(path, ...) trunca primeiro, então um crash no meio deixa um
arquivo quebrado. A correção usual é gravar um arquivo temporário irmão e trocá-lo
no lugar: File.Move(tmp, target, overwrite: true) (o overload com overwrite
existe desde o .NET Core 3.0) ou File.Replace(tmp, target, backupPath), que
também guarda o conteúdo antigo como backup (os dois funcionaram como esperado
aqui). Coloque o temporário no mesmo diretório, porque mover entre volumes é uma
cópia seguida de exclusão e perde a atomicidade. Chame FlushToDisk (ou
FileStream.Flush(true)) antes da troca quando os dados precisarem sobreviver a
uma queda de energia, já que um Flush simples só entrega os bytes ao sistema
operacional.
Acesso aleatório com OpenHandle e RandomAccess
Um FileStream tem uma única posição, então duas threads lendo offsets
diferentes precisam de lock ou de dois streams. File.OpenHandle devolve um
SafeFileHandle sem posição nenhuma, e a classe estática RandomAccess lê e
escreve num offset explícito. A documentação a descreve como baseada em offset e
thread-safe, e afirma que só arquivos regulares em disco e com seek são
suportados (não pipes).
csharpusing var h = File.OpenHandle(path, FileMode.Create, FileAccess.ReadWrite);
for (var i = 0; i < 4; i++)
RandomAccess.Write(h, BitConverter.GetBytes(i * 100), fileOffset: i * 4);
var buf = new byte[4];
RandomAccess.Read(h, buf, fileOffset: 8); // 200
Console.WriteLine(RandomAccess.GetLength(h)); // 16
RandomAccess.Read(h, buf, fileOffset: 1000); // devolve 0 no fim ou além dele
RandomAccess.SetLength(h, 8);
RandomAccess.FlushToDisk(h);Abra com FileOptions.Asynchronous para usar RandomAccess.ReadAsync e
WriteAsync; várias chamadas podem rodar ao mesmo tempo num único handle (duas
leituras em paralelo devolveram 0,-1 depois que um registro foi sobrescrito
com -1). Também há overloads que recebem uma lista de buffers para I/O
scatter/gather: uma chamada preencheu dois buffers de 4 bytes e devolveu 8. Como
não há cursor, também não há buffer, então muitas leituras minúsculas custam uma
chamada de sistema cada. Leia um registro inteiro por vez, ou ponha um cache seu
na frente.
Trade-offs
Joinremove a surpresa do path rooted, mas não o risco de traversal. Ele nunca descarta a base, mas..ainda sobe níveis, então a entrada do usuário precisa da checagem de resolver e comparar de qualquer forma.csharpPath.Join("data", "../secret.txt"); // data/../secret.txt, que GetFullPath resolve para um irmão de dataCombinecontinua certo quando a segunda parte é confiável e pode ser absoluta. Uma configuração como "pasta de logs, relativa ao app ou absoluta" é exatamente o que a regra dele modela. Escolha por quem controla o segundo argumento.FileShare.Noneé um lock, não uma garantia. Ele impede a segunda abertura, mas dá uma falha para tratar (IOException) em vez de esperar. No Unix o lock é advisory, então não barra programas que não são .NET. Tente de novo com um pequeno atraso ou use um mecanismo de coordenação de verdade quando a disputa for normal.- Enumeração preguiçosa economiza memória e abre mão de consistência.
Arquivos que aparecem ou somem durante a varredura mudam o resultado, e um
arquivo apagado pode lançar exceção quando você o abre depois. Use
GetFiles(uma cópia) quando precisar de uma lista estável, e ainda assim espere que a abertura posterior possa falhar. - Substituição atômica exige temporário no mesmo volume. Um temporário na pasta temp do sistema é cômodo e pode estar em outro volume, transformando o move "atômico" numa cópia.
RandomAccessé rápido e de baixo nível. Sem buffer, sem cursor, sem suporte a pipes, e cada chamada precisa de um offset que você calculou. Para texto sequencial ou pipelines de stream,FileStreamcom buffer é mais simples e geralmente tão rápido.