Trabalhar com arquivos de texto é uma das tarefas mais comuns na programação. Neste artigo, vamos explorar o parsing de texto em Go, usando como exemplo o cache ARP. Utilizaremos esses dados para estabelecer a correspondência entre endereços MAC e IP na rede local.
No caso mais simples, o arquivo contém um único valor (token) por linha – como geralmente são construídas as wordlists para brute-force ou fuzzing. Em outros casos, o arquivo tem uma estrutura regular definida, como várias variações de CSV, adequadas para dados tabulares, telemetria e conjuntos de dados semelhantes.
O pior cenário é quando o arquivo de entrada é originalmente destinado à leitura humana e contém formatação adicional – recuos, alinhamento com espaços, subtítulos, pseudográficos. Esses arquivos não podem ser lidos diretamente "como estão" – é necessário encontrar e usar apenas fragmentos específicos. Neste artigo, abordaremos as principais técnicas para obter as informações que nos interessam de um arquivo de texto, ou seja, seu parsing.
Como exemplo prático, usaremos algo um pouco abstrato, mas presente em qualquer computador: o cache ARP. Utilizaremos os dados desse cache para estabelecer a correspondência entre endereços IP e MAC de dispositivos na rede local.
Como o acesso ao conteúdo do cache é um pouco diferente em diferentes sistemas operacionais, também abordaremos o trabalho com código dependente da plataforma: no Linux, podemos ler o conteúdo de /proc/net/arp, enquanto no Windows, podemos executar arp -a. Finalmente, transformaremos o código do parser que escrevemos em um pacote adequado para uso em outros projetos.
Info: O cache ARP é preenchido automaticamente pelo sistema operacional (geralmente ao tentar estabelecer uma conexão com um nó na rede local). O acesso a essas informações não requer elevação de privilégios, ao contrário de abrir um socket e enviar um pacote ARP discovery manualmente (e para fazer isso em um host Windows, geralmente é necessário instalar previamente o Npcap/WinPcap).
Começaremos, como sempre, criando um novo pacote:
bashmkdir readarp && cd $_ go mod init readarptouch main.go touch arp.go
Como parte do código dependerá da plataforma de destino, criaremos dois arquivos imediatamente. Em main.go estará nosso ponto de entrada, e em arp.go – o código para trabalhar com o cache ARP.
No futuro, pretendemos obter o endereço MAC correspondente a um determinado IP. Nesse cenário, não é aconselhável ler todo o conteúdo do cache a cada solicitação. É muito mais eficiente fazer isso uma vez e salvar imediatamente as informações que nos interessam para acessos subsequentes.
Para armazenar os dados processados, poderíamos criar uma estrutura com dois campos – IP e MAC – e salvar um conjunto de suas instâncias em um slice, no qual procuraríamos o necessário. Mas é melhor aplicar outra abordagem e armazenar os dados como uma coleção de pares chave-valor, onde os endereços IP serão chaves únicas, para as quais usaremos a estrutura de dados map.
Embora ainda não estejamos fazendo nada disso, a decisão sobre a escolha da estrutura de dados é tomada agora para não ter que reescrever muito código depois.
Conteúdo do arquivo arp.go:
gopackage main func retrieveArpTable() map[string]string { result := make(map[string]string) // TODO not implemented return result }
Aqui declaramos uma função que processa os dados do cache e retorna um dicionário com os valores que precisamos.
E o conteúdo do arquivo main.go:
gopackage main import "fmt" func main() { arpResuls := retrieveArpTable() fmt.Print(arpResuls) }
Aqui simplesmente exibimos os dados do dicionário formado pela função retrieveArpTable() na tela. Por enquanto, não é exatamente o que queremos fazer no final: é apenas uma função stub que nos dá a oportunidade de ver os resultados intermediários da execução. Mais tarde, nos livraremos dela completamente.
Observe: embora os arquivos sejam diferentes, eles pertencem ao mesmo pacote – neste caso, main – e no arquivo main.go, sem nenhuma ação adicional, chamamos a função definida em arp.go.
Diferença entre estrutura e estrutura de dados
Talvez, neste ponto, alguma confusão tenha surgido em sua cabeça. Anteriormente, falamos sobre uma estrutura como um tipo de dados que declaramos com a palavra-chave struct e, em seguida, trabalhamos com seus campos. Mas também usamos uma expressão como "estrutura de dados", falando sobre map ou slice.
De fato, tanto map quanto slice são internamente uma estrutura com os campos e métodos correspondentes. Mas o ponto aqui é outro. Em ciência da computação, "estrutura de dados" é um conceito fundamental que descreve uma maneira específica de alocar e organizar uma coleção de dados na memória.
Estruturas de dados são coisas como listas, pilhas, filas, árvores, tabelas hash e assim por diante. E elas podem ser implementadas usando estruturas (que são struct), ou classes, ou algo mais. Por sua vez, o tipo (type MySet struct{}), que é a implementação de uma ou outra estrutura de dados, também costumamos chamar simplesmente de estrutura de dados, ou seja, há um certo desfoque de limites entre os conceitos.
Dicionários, ou mapas (map)
A função retrieveArpTable() retorna um dicionário (palavra-chave map) com chaves do tipo string (o tipo de chave é especificado entre colchetes) e valores do tipo string (o tipo de valor é especificado após os colchetes).
Info: Map é uma coleção de pares chave-valor, onde as chaves são únicas e usadas para extrair valores, semelhante a como um índice é usado em arrays ou slices. Em diferentes literaturas sobre diferentes linguagens de programação, existem diferentes nomes para essa estrutura: "array associativo", "dicionário", "tabela hash", tradução literal de "mapa" e até mesmo um calque do inglês "mapa". Em fontes sobre Go, os dois últimos são geralmente usados, no entanto, eu prefiro "dicionário". Um dicionário é bom porque fornece acesso a um valor por chave em tempo constante O(1), independentemente do tamanho da coleção – ao contrário da pesquisa em um array ou slice.
Assim como os slices, os dicionários são criados com o comando make. O segundo argumento pode especificar a capacidade da coleção, se for conhecida de antemão. Se a capacidade for excedida, ela será aumentada automaticamente – aqui tudo é igual ao caso dos slices.
O acesso a um elemento da coleção ocorre por chave, que é passada entre colchetes, assim como o índice de um array ou slice: value := result[key]. Se a chave não for encontrada, o valor padrão para este tipo (zero value) será retornado.
Talvez, aqui você tenha imediatamente uma pergunta: e se zero value for um valor legítimo para um elemento de nossa coleção? Como distinguir duas situações: a coleção tem a chave desejada e um valor 0 (ou uma string vazia) está associado a ela, ou a coleção não tem essa chave e recebemos 0 (ou uma string vazia) como o valor padrão para o tipo int (ou string)?
Na verdade, dois valores são retornados aqui: value, ok := result[key], e é o segundo que indica se a chave foi encontrada. Se precisarmos apenas verificar a existência de uma chave, o primeiro valor pode ser descartado completamente: _, ok := result[key].
A gravação de um valor ocorre de forma semelhante: result[key] = value. Nesse caso, o valor será adicionado à coleção se essa chave ainda não estiver nela ou substituído se essa chave já existir.
A função delete() é usada para excluir uma chave. Ao contrário de append(), com a qual nos familiarizamos anteriormente, delete() altera a coleção existente e não retorna uma nova. Se a chave a ser excluída já estiver ausente na coleção, delete() será concluído sem erros.
Info: Exemplos de trabalho com map
Implementando conjuntos
Em algumas linguagens de programação, você pode ter visto outra coleção de dados, como set (conjunto). Um conjunto é semelhante a um map no sentido de que também é um conjunto de chaves únicas e fornece complexidade de acesso constante O(1). A diferença é que um conjunto contém apenas chaves, sem valores.
Se você precisar de tal estrutura de dados em Go, a implementação idiomática é um map com chaves do tipo necessário e valores do tipo "estrutura vazia" – struct{}{}. Por que exatamente uma estrutura vazia? É simples: em Go, seu tamanho é exatamente zero bytes.
go// Criando um set de strings mySet := make(map[string]struct{}{}) // Adicionando um elemento mySet["newElement"] = struct{}{} // Removendo um elemento delete(mySet, "spareElement") // Verificando a presença de um elemento _, ok := mySet["checkElement"] if ok { // Encontrado! }
Pacote strings
Agora vamos passar para o parsing de dados de texto. O pacote strings da biblioteca padrão do Go nos ajudará com isso (veja, quanto já fizemos e ainda estamos usando os recursos da biblioteca padrão!). Como você já deve ter adivinhado pelo nome, este pacote combina ferramentas para trabalhar com strings.
[Conteúdo adicional não traduzido devido a restrições de acesso]





