` | Endereços sondados ao mesmo tempo, de 1 a 256. Padrão: 64 |
@@ -111,6 +112,7 @@ Se o valor bater com o do `.txt`, o executável é exatamente o que foi publicad
- **Fabricante:** pelos três primeiros bytes do MAC, na tabela OUI do IEEE embutida no programa (35.084 prefixos). MAC com o bit de administração local ligado aparece como "MAC aleatório (privativo)", que é o caso do celular com endereço privado.
- **Nome:** DNS reverso, NetBIOS (o mesmo do `nbtstat -A`, que traz também o grupo de trabalho ou domínio) e mDNS (nomes `.local` de celulares, Macs, impressoras e aparelhos Linux).
- **Relatório HTML:** arquivo único, com resumo da varredura, cartões de contagem, tabela filtrável e ordenável, detalhe de cada host ao clicar na linha e a lista de fabricantes.
+- **CSV e XML (desde a v1.0.0):** o CSV tem uma linha por host, com ponto e vírgula e acentos certos no Excel, e protege contra texto que viraria fórmula. O XML traz tudo o que o HTML mostra, para outro programa ler.
- **Portas (desde a v0.3.0, desligadas por padrão):** para cada host encontrado, abre e fecha a conexão TCP em cada porta da lista, sem enviar nenhum dado, com até 128 conexões ao mesmo tempo, 8 por host e 800 ms de espera por porta. Porta que aceita a conexão aparece como aberta. Este computador e os aparelhos com MAC aleatório ficam de fora, e o motivo aparece no detalhe. O relatório ganha a coluna Portas e diz quais portas foram verificadas.
- **Tipo provável (desde a v0.5.0):** roteador, impressora, câmera ou gravador, NAS, computador Windows, Linux, access point, TV ou mídia, videogame, celular ou tablet, automação ou desconhecido. É um palpite pelo que as outras etapas levantaram (fabricante, nomes, TTL, portas e serviços), sem mandar nada para a rede, e o detalhe mostra os motivos.
diff --git a/docs/superpowers/pendencias.md b/docs/superpowers/pendencias.md
index a25e0cc..283cf84 100644
--- a/docs/superpowers/pendencias.md
+++ b/docs/superpowers/pendencias.md
@@ -80,6 +80,13 @@ Fechado em parte na fatia 3 (0.2.2). No tamanho padrão da janela, IP, Origem, M
| Tabela de sinais | Os fabricantes e as palavras que apontam cada tipo saíram do que é comum em escritório e do teste na rede do Manfred. Equipamento de marca que não está na lista cai em Desconhecido | Acrescentar fabricante ou palavra quando o técnico achar um caso errado no campo |
| Switch gerenciável | Switch quase não se mostra: não costuma ter nome, porta aberta diferente nem UPnP. A fatia não arrisca esse tipo | Rever se aparecer sinal confiável, como título de página de switch conhecido |
+## Fatia 10: relatório em CSV e XML
+
+| Item | Motivo | O que fecha |
+|---|---|---|
+| Esquema do XML | O XML tem o atributo `formato="1"` para mudar sem quebrar quem lê, mas não tem um XSD publicado | Publicar o esquema se alguém for integrar o XML a outro sistema |
+| CSV no Excel com outro idioma | O ponto e vírgula é o separador do Excel em português. Num Excel em inglês, o arquivo abre numa coluna só | Importar pelo assistente de texto, ou oferecer vírgula como opção se fizer falta |
+
## Página de privacidade
| Item | Motivo | O que fecha |
diff --git a/docs/superpowers/plans/2026-09-26-mapnet-1.0-plano-geral.md b/docs/superpowers/plans/2026-09-26-mapnet-1.0-plano-geral.md
index f2f1020..d7bcd4d 100644
--- a/docs/superpowers/plans/2026-09-26-mapnet-1.0-plano-geral.md
+++ b/docs/superpowers/plans/2026-09-26-mapnet-1.0-plano-geral.md
@@ -156,4 +156,20 @@ Sai como v0.5.0. Escrita junto com o código, porque o Manfred pediu em 26/09/20
### Testes
-Cada tipo pelos seus sinais, o mínimo para arriscar, o sinal mais forte ganhando, a palavra inteira, este computador como Windows, e o tipo na tabela, no detalhe e no relatório.
\ No newline at end of file
+Cada tipo pelos seus sinais, o mínimo para arriscar, o sinal mais forte ganhando, a palavra inteira, este computador como Windows, e o tipo na tabela, no detalhe e no relatório.
+
+## Fatia 10: relatório em XML e CSV
+
+Sai como v1.0.0 e fecha a versão 1.0. Escrita junto com o código, como a fatia 9.
+
+### O que entra
+
+- **CSV**, uma linha por host, para abrir no Excel: IP, nome e origem, MAC, fabricante, tipo provável e motivos, ping, TTL, portas, serviço, serviços, observação e grupo NetBIOS. Separador ponto e vírgula, que é o do Excel em português, e UTF-8 com BOM, para os acentos abrirem certos.
+- **Proteção contra fórmula:** campo que começa com `=`, `+`, `-` ou `@` ganha um apóstrofo na frente. Nome de equipamento vem da rede e não pode virar fórmula na planilha do cliente.
+- **XML**, com tudo o que o HTML mostra (varredura, Minha máquina, avisos e cada host com nomes, ping, ARP, tipo e motivos, portas e serviços), para outro programa ler. Caractere que o XML recusa é tirado antes.
+- **Janela:** o botão **Salvar relatório** oferece os três tipos na lista da janela de salvar.
+- **Linha de comando:** `--formato html|csv|xml|todos`. Arquivo com extensão em `--saida` define o formato; com `todos`, `--saida` é a pasta dos três.
+
+### Testes
+
+XML válido e completo, texto da rede codificado, caractere inválido tirado; CSV com cabeçalho, aspas, ponto e vírgula e a proteção contra fórmula; BOM no CSV e não no XML; formato pela extensão; caminhos da linha de comando e os erros de formato.
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
index 09516b4..85afd0e 100644
--- a/public/index.html
+++ b/public/index.html
@@ -51,7 +51,7 @@ O que ele faz
Detalhe de cada equipamento
Nomes, TTL, portas abertas, serviços e atalhos para ping, navegador e área de trabalho remota.
Ferramentas de rede
Ping, tracert, DNS, ARP, conexões e rotas, com a saída em português.
Manutenção
Limpar o cache DNS, renovar o IP, limpar o ARP e resetar o Winsock.
- Relatório em HTML
Arquivo único para guardar com a documentação do cliente.
+ Relatório em HTML, CSV e XML
Para guardar com a documentação do cliente, abrir no Excel ou levar a outro programa.
Sem instalar nada
Um arquivo só, sem administrador e sem precisar de internet.
diff --git a/src/mapnet.nucleo/linha-de-comando/argumentos.cs b/src/mapnet.nucleo/linha-de-comando/argumentos.cs
index 8292bb2..6551cf9 100644
--- a/src/mapnet.nucleo/linha-de-comando/argumentos.cs
+++ b/src/mapnet.nucleo/linha-de-comando/argumentos.cs
@@ -28,7 +28,10 @@ mapnet abre a janela
Opções de --varrer:
--interface interface pelo número de --interfaces ou por parte do nome
(padrão: a primeira da lista, que é a que tem gateway)
- --saida onde gravar o relatório (padrão: pasta atual)
+ --saida onde gravar o relatório (padrão: pasta atual). Arquivo
+ terminado em .html, .csv ou .xml define o formato
+ --formato html, csv, xml ou todos (padrão: html). Com todos,
+ grava os três na pasta de --saida
--abrir abre o relatório no navegador ao terminar
--tempo-ping espera de cada ping, de 100 a 10000 (padrão: 1000)
--paralelo endereços sondados ao mesmo tempo, de 1 a 256 (padrão: 64)
@@ -58,6 +61,9 @@ Use a verificação de portas só em rede que você tem autorização para verif
public bool Abrir { get; private set; }
+ /// Formatos a gravar. Padrão: só o HTML.
+ public IReadOnlyList Formatos { get; private set; } = [FormatoRelatorio.Html];
+
public OpcoesVarredura Opcoes { get; } = new();
public List Erros { get; } = [];
@@ -69,6 +75,7 @@ public static ArgumentosCli Interpretar(IReadOnlyList args)
var a = new ArgumentosCli();
var comandos = 0;
var semIdentificar = false;
+ var formatoDado = false;
for (var i = 0; i < args.Count; i++)
{
@@ -101,6 +108,25 @@ public static ArgumentosCli Interpretar(IReadOnlyList args)
case "--abrir":
a.Abrir = true;
break;
+ case "--formato":
+ var formato = Valor(args, ref i, arg, a.Erros)?.Trim().ToLowerInvariant();
+ IReadOnlyList? escolhidos = formato switch
+ {
+ null => null,
+ "html" => [FormatoRelatorio.Html],
+ "csv" => [FormatoRelatorio.Csv],
+ "xml" => [FormatoRelatorio.Xml],
+ "todos" => [FormatoRelatorio.Html, FormatoRelatorio.Csv, FormatoRelatorio.Xml],
+ _ => null,
+ };
+ if (escolhidos is null && formato != null)
+ {
+ a.Erros.Add($"Formato desconhecido: {formato}. Use html, csv, xml ou todos.");
+ }
+
+ a.Formatos = escolhidos ?? a.Formatos;
+ formatoDado = true;
+ break;
case "--sem-arp":
a.Opcoes.UsarArp = false;
break;
@@ -149,10 +175,23 @@ public static ArgumentosCli Interpretar(IReadOnlyList args)
a.Erros.Add("Use só um comando por vez: --varrer, --interfaces, --ajuda ou --versao.");
}
- var opcoesDeVarredura = a.Interface != null || a.Saida != null || a.Abrir || a.Opcoes.OlharPortas;
+ var opcoesDeVarredura = a.Interface != null || a.Saida != null || a.Abrir || a.Opcoes.OlharPortas || formatoDado;
if (opcoesDeVarredura && a.Comando == ComandoCli.Janela)
{
- a.Erros.Add("As opções --interface, --saida, --abrir e --portas pedem o comando --varrer.");
+ a.Erros.Add("As opções --interface, --saida, --formato, --abrir e --portas pedem o comando --varrer.");
+ }
+
+ // Arquivo com extensão conhecida em --saida define o formato. Com mais de um formato, --saida tem que ser pasta.
+ if (a.Saida is { } saida && Path.HasExtension(saida) && Path.GetExtension(saida).ToLowerInvariant() is ".html" or ".htm" or ".csv" or ".xml")
+ {
+ if (a.Formatos.Count > 1)
+ {
+ a.Erros.Add("Com --formato todos, --saida tem que ser uma pasta.");
+ }
+ else
+ {
+ a.Formatos = [Relatorios.FormatoDe(saida)];
+ }
}
if (a.Opcoes.PortasEmMacAleatorio && !a.Opcoes.OlharPortas)
@@ -168,6 +207,22 @@ public static ArgumentosCli Interpretar(IReadOnlyList args)
return a;
}
+ ///
+ /// Arquivos a gravar, um por formato. Sem --saida, vão para a pasta atual; com uma pasta, para
+ /// ela; com um arquivo de extensão conhecida, é ele mesmo.
+ ///
+ public IReadOnlyList Caminhos(ResultadoVarredura resultado, string pastaAtual)
+ {
+ if (Saida is { } saida && Formatos.Count == 1
+ && Path.GetExtension(saida).ToLowerInvariant() is ".html" or ".htm" or ".csv" or ".xml")
+ {
+ return [saida];
+ }
+
+ var pasta = string.IsNullOrWhiteSpace(Saida) ? pastaAtual : Saida;
+ return Formatos.Select(f => Path.Combine(pasta, Relatorios.NomeArquivo(resultado, f))).ToList();
+ }
+
///
/// Escolhe a interface pelo número (a partir de 1, na ordem de --interfaces) ou por parte
/// do nome ou da descrição. Sem seletor, fica a primeira da lista.
diff --git a/src/mapnet.nucleo/painel/demonstracao.cs b/src/mapnet.nucleo/painel/demonstracao.cs
index d208d70..e301332 100644
--- a/src/mapnet.nucleo/painel/demonstracao.cs
+++ b/src/mapnet.nucleo/painel/demonstracao.cs
@@ -47,7 +47,7 @@ public static DependenciasPainel Dependencias()
Opcoes = opcoes,
ListarInterfaces = () => [wifi, cabo],
Varrer = (i, p, c) => VarrerAsync(i, opcoes, p, c),
- SalvarEm = (r, caminho) => RelatorioHtml.SalvarAsync(r, caminho),
+ SalvarEm = (r, caminho) => Relatorios.SalvarAsync(r, caminho),
// Documentos Públicos: o caminho não traz o nome do usuário, que apareceria nas imagens.
PastaRelatorios = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonDocuments), "MapNet - MT", "demonstracao"),
LerMaquina = i => Task.FromResult(LeitorMaquina.Ler(i, Fontes(), new OpcoesVarredura().PrefixoMinimo)),
diff --git a/src/mapnet.nucleo/painel/painel-varredura.cs b/src/mapnet.nucleo/painel/painel-varredura.cs
index 756b9c0..8d62b07 100644
--- a/src/mapnet.nucleo/painel/painel-varredura.cs
+++ b/src/mapnet.nucleo/painel/painel-varredura.cs
@@ -91,7 +91,7 @@ public static DependenciasPainel Padrao()
Opcoes = opcoes,
ListarInterfaces = LeitorInterfaces.Listar,
Varrer = (i, p, c) => new Varredor(opcoes).VarrerAsync(i, p, c),
- SalvarEm = (r, caminho) => RelatorioHtml.SalvarAsync(r, caminho),
+ SalvarEm = (r, caminho) => Relatorios.SalvarAsync(r, caminho),
LerMaquina = i => Task.Run(() => LeitorMaquina.Ler(i, FontesMaquina.Padrao(), prefixo)),
IpPublico = new IpPublicoCloudflare(),
Ferramentas = FerramentasPadrao,
diff --git a/src/mapnet.nucleo/relatorios/relatorio-dados.cs b/src/mapnet.nucleo/relatorios/relatorio-dados.cs
new file mode 100644
index 0000000..63c563f
--- /dev/null
+++ b/src/mapnet.nucleo/relatorios/relatorio-dados.cs
@@ -0,0 +1,226 @@
+using System.Globalization;
+using System.Text;
+using System.Xml;
+using System.Xml.Linq;
+
+namespace MapNet.Nucleo;
+
+/// Formatos em que o relatório pode ser gravado.
+public enum FormatoRelatorio
+{
+ Html,
+ Xml,
+ Csv,
+}
+
+/// Escolhe o formato pela extensão do arquivo e grava.
+public static class Relatorios
+{
+ public static string Extensao(FormatoRelatorio formato) => formato switch
+ {
+ FormatoRelatorio.Xml => ".xml",
+ FormatoRelatorio.Csv => ".csv",
+ _ => ".html",
+ };
+
+ /// .xml e .csv pelo nome; qualquer outra extensão vira HTML.
+ public static FormatoRelatorio FormatoDe(string caminho) => Path.GetExtension(caminho).ToLowerInvariant() switch
+ {
+ ".xml" => FormatoRelatorio.Xml,
+ ".csv" => FormatoRelatorio.Csv,
+ _ => FormatoRelatorio.Html,
+ };
+
+ /// Nome sugerido, como mapnet-20260926-1430-192-0-2-0-24.csv.
+ public static string NomeArquivo(ResultadoVarredura r, FormatoRelatorio formato) =>
+ Path.ChangeExtension(RelatorioHtml.NomeArquivo(r), Extensao(formato));
+
+ public static async Task SalvarAsync(ResultadoVarredura r, string caminho, CancellationToken cancelamento = default)
+ {
+ var formato = FormatoDe(caminho);
+ if (formato == FormatoRelatorio.Html)
+ {
+ return await RelatorioHtml.SalvarAsync(r, caminho, cancelamento).ConfigureAwait(false);
+ }
+
+ var pasta = Path.GetDirectoryName(Path.GetFullPath(caminho));
+ if (!string.IsNullOrEmpty(pasta))
+ {
+ Directory.CreateDirectory(pasta);
+ }
+
+ // CSV com BOM, para o Excel abrir os acentos certos; XML sem, como é o costume.
+ var (texto, codificacao) = formato == FormatoRelatorio.Csv
+ ? (RelatorioCsv.Gerar(r), new UTF8Encoding(true))
+ : (RelatorioXml.Gerar(r), new UTF8Encoding(false));
+ await File.WriteAllTextAsync(caminho, texto, codificacao, cancelamento).ConfigureAwait(false);
+ return Path.GetFullPath(caminho);
+ }
+}
+
+///
+/// Relatório em CSV, uma linha por host, para abrir em planilha. Separador ponto e vírgula, que é
+/// o que o Excel em português espera. Campo que começa com =, +, - ou @ ganha um apóstrofo na
+/// frente, para a planilha não tratar texto vindo da rede como fórmula.
+///
+public static class RelatorioCsv
+{
+ public const char Separador = ';';
+
+ public static readonly string[] Colunas =
+ [
+ "IP", "Nome", "Origem do nome", "MAC", "Fabricante", "Tipo provável", "Motivos do tipo", "Ping (ms)", "TTL",
+ "Portas abertas", "Serviço", "Serviços", "Observação", "Grupo NetBIOS",
+ ];
+
+ public static string Gerar(ResultadoVarredura r)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine(string.Join(Separador, Colunas.Select(Campo)));
+ foreach (var h in r.Hosts.OrderBy(h => h.IpNumero))
+ {
+ var c = h.Classificacao;
+ var valores = new[]
+ {
+ h.Ip.ToString(), h.Nome, h.OrigemNome, h.MacTexto, h.Fabricante,
+ c.Tipo == TipoEquipamento.Desconhecido ? string.Empty : c.Nome, string.Join(", ", c.Motivos),
+ h.TempoPingMs?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, h.Ttl?.ToString(CultureInfo.InvariantCulture) ?? string.Empty,
+ h.PortasTexto, h.ServicoResumo, string.Join(" | ", h.Servicos.Select(s => s.Texto)),
+ string.Join(", ", h.Marcas), h.GrupoNetBios ?? string.Empty,
+ };
+ sb.AppendLine(string.Join(Separador, valores.Select(Campo)));
+ }
+
+ return sb.ToString();
+ }
+
+ /// Aspas quando precisa, aspas dobradas por dentro e o apóstrofo contra fórmula.
+ public static string Campo(string? valor)
+ {
+ var v = (valor ?? string.Empty).Replace("\r", " ").Replace("\n", " ");
+ if (v.Length > 0 && v[0] is '=' or '+' or '-' or '@' or '\t')
+ {
+ v = "'" + v;
+ }
+
+ return v.IndexOfAny([Separador, '"', ',']) >= 0 ? "\"" + v.Replace("\"", "\"\"") + "\"" : v;
+ }
+}
+
+///
+/// Relatório em XML, com tudo o que o HTML mostra, para outro programa ler. Caractere que o XML não
+/// aceita é tirado antes, porque nome de equipamento pode trazer qualquer coisa.
+///
+public static class RelatorioXml
+{
+ public const string Versao = "1";
+
+ public static string Gerar(ResultadoVarredura r)
+ {
+ var i = r.Interface;
+ var raiz = new XElement("inventario",
+ new XAttribute("formato", Versao),
+ new XAttribute("programa", $"MapNet - MT {ResultadoVarredura.VersaoPrograma}"),
+ new XElement("varredura",
+ A("inicio", r.Inicio.ToString("o", CultureInfo.InvariantCulture)),
+ A("fim", r.Fim.ToString("o", CultureInfo.InvariantCulture)),
+ A("duracaoSegundos", ((int)r.Duracao.TotalSeconds).ToString(CultureInfo.InvariantCulture)),
+ A("interrompida", r.Cancelada ? "sim" : "nao"),
+ A("computador", r.NomeComputador),
+ A("subRede", r.SubRedeVarrida.ToString()),
+ A("interface", i.Nome),
+ A("ip", i.Ip.ToString()),
+ A("gateway", i.Gateway?.ToString()),
+ A("dns", string.Join(" ", i.Dns)),
+ A("ipPublico", r.IpPublico),
+ A("portasVerificadas", r.PortasVerificadas is { } p ? string.Join(" ", p) : null),
+ A("identificacaoDeServicos", r.IdentificacaoFeita ? "sim" : "nao")),
+ new XElement("avisos", r.Avisos.Select(a => new XElement("aviso", T(a)))));
+
+ if (r.Maquina is { } maquina)
+ {
+ raiz.Add(new XElement("maquina", maquina.Grupos(r.Fim, r.IpPublico).Select(g =>
+ new XElement("grupo", A("titulo", g.Titulo), g.Itens.Select(it => new XElement("item", A("rotulo", it.Rotulo), T(it.Valor)))))));
+ }
+
+ raiz.Add(new XElement("hosts", r.Hosts.OrderBy(h => h.IpNumero).Select(Host)));
+ var doc = new XDocument(new XDeclaration("1.0", "utf-8", null), raiz);
+
+ var sb = new StringBuilder();
+ using (var escritor = XmlWriter.Create(new StringWriterUtf8(sb), new XmlWriterSettings { Indent = true, Encoding = new UTF8Encoding(false) }))
+ {
+ doc.Save(escritor);
+ }
+
+ return sb.ToString();
+ }
+
+ private static XElement Host(HostEncontrado h)
+ {
+ var c = h.Classificacao;
+ return new XElement("host",
+ A("ip", h.Ip.ToString()),
+ A("mac", h.MacTexto),
+ A("fabricante", h.Fabricante),
+ A("macAleatorio", h.MacAleatorio ? "sim" : null),
+ A("gateway", h.EhGateway ? "sim" : null),
+ A("esteComputador", h.EhEsteComputador ? "sim" : null),
+ new XElement("nomes",
+ A("exibido", h.Nome), A("origem", h.OrigemNome), A("dns", h.NomeDns), A("netbios", h.NomeNetBios), A("grupo", h.GrupoNetBios), A("mdns", h.NomeMdns)),
+ new XElement("ping", A("respondeu", h.RespondeuPing ? "sim" : "nao"), A("ms", h.TempoPingMs?.ToString(CultureInfo.InvariantCulture)), A("ttl", h.Ttl?.ToString(CultureInfo.InvariantCulture))),
+ new XElement("arp", A("respondeu", h.RespondeuArp ? "sim" : "nao")),
+ new XElement("tipo", A("nome", c.Nome), c.Motivos.Select(m => new XElement("motivo", T(m)))),
+ new XElement("portas",
+ A("verificadas", h.PortasVerificadas ? "sim" : "nao"),
+ A("motivo", h.MotivoSemPortas),
+ h.PortasAbertas.Select(p => new XElement("porta", A("numero", p.ToString(CultureInfo.InvariantCulture)), A("servico", ListaPortas.Servico(p))))),
+ new XElement("servicos", h.Servicos.Select(s => new XElement("servico",
+ A("porta", s.Porta > 0 ? s.Porta.ToString(CultureInfo.InvariantCulture) : null),
+ A("protocolo", s.Protocolo),
+ A("codigoHttp", s.CodigoHttp?.ToString(CultureInfo.InvariantCulture)),
+ A("titulo", s.Titulo),
+ A("servidor", s.Servidor),
+ A("redireciona", s.Redireciona),
+ A("certificado", s.CertificadoNome),
+ A("emissor", s.CertificadoEmissor),
+ A("validade", s.CertificadoValidade?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)),
+ A("banner", s.Banner),
+ A("modelo", s.Modelo)))),
+ new XElement("marcas", h.Marcas.Select(m => new XElement("marca", T(m)))));
+ }
+
+ /// Atributo só quando há valor, já sem caractere que o XML recusa.
+ private static XAttribute? A(string nome, string? valor) =>
+ string.IsNullOrEmpty(valor) ? null : new XAttribute(nome, T(valor));
+
+ /// Tira o caractere que o XML 1.0 não aceita.
+ public static string T(string? texto)
+ {
+ if (string.IsNullOrEmpty(texto))
+ {
+ return string.Empty;
+ }
+
+ var sb = new StringBuilder(texto.Length);
+ for (var k = 0; k < texto.Length; k++)
+ {
+ var c = texto[k];
+ if (char.IsHighSurrogate(c) && k + 1 < texto.Length && char.IsLowSurrogate(texto[k + 1]))
+ {
+ sb.Append(c).Append(texto[++k]);
+ }
+ else if (XmlConvert.IsXmlChar(c))
+ {
+ sb.Append(c);
+ }
+ }
+
+ return sb.ToString();
+ }
+
+ /// StringWriter que declara UTF-8, para o cabeçalho do XML sair certo.
+ private sealed class StringWriterUtf8(StringBuilder sb) : StringWriter(sb, CultureInfo.InvariantCulture)
+ {
+ public override Encoding Encoding => new UTF8Encoding(false);
+ }
+}
diff --git a/src/mapnet/app.manifest b/src/mapnet/app.manifest
index d88abcd..7d2d2a0 100644
--- a/src/mapnet/app.manifest
+++ b/src/mapnet/app.manifest
@@ -1,6 +1,6 @@
-
+
diff --git a/src/mapnet/janela-principal.xaml.cs b/src/mapnet/janela-principal.xaml.cs
index 66a227c..f37349e 100644
--- a/src/mapnet/janela-principal.xaml.cs
+++ b/src/mapnet/janela-principal.xaml.cs
@@ -126,11 +126,14 @@ private async Task SalvarComDialogoAsync()
}
Directory.CreateDirectory(_painel.PastaInicial);
+ // O nome vai sem extensão: o tipo escolhido na lista acrescenta .html, .csv ou .xml.
var dialogo = new SaveFileDialog
{
Title = "Salvar relatório",
- Filter = "Relatório HTML (*.html)|*.html",
- FileName = _painel.NomeSugerido,
+ Filter = "Relatório HTML, para abrir no navegador (*.html)|*.html|Planilha CSV, para o Excel (*.csv)|*.csv|Dados XML, para outro programa (*.xml)|*.xml",
+ FileName = Path.GetFileNameWithoutExtension(_painel.NomeSugerido),
+ DefaultExt = ".html",
+ AddExtension = true,
InitialDirectory = _painel.PastaInicial,
};
if (dialogo.ShowDialog(this) != true)
@@ -138,7 +141,13 @@ private async Task SalvarComDialogoAsync()
return false;
}
- await _painel.SalvarComoAsync(dialogo.FileName!);
+ var caminho = dialogo.FileName!;
+ if (!Path.HasExtension(caminho))
+ {
+ caminho += dialogo.FilterIndex switch { 2 => ".csv", 3 => ".xml", _ => ".html" };
+ }
+
+ await _painel.SalvarComoAsync(caminho);
return !_painel.RelatorioNaoSalvo;
}
diff --git a/src/mapnet/modo-linha-de-comando.cs b/src/mapnet/modo-linha-de-comando.cs
index ba9650b..5577736 100644
--- a/src/mapnet/modo-linha-de-comando.cs
+++ b/src/mapnet/modo-linha-de-comando.cs
@@ -148,30 +148,23 @@ private static async Task VarrerAsync(ArgumentosCli argumentos)
// Na linha de comando não há clique, então o relatório sai sem o IP público.
resultado.Maquina = LeitorMaquina.Ler(interfaceRede, FontesMaquina.Padrao(), argumentos.Opcoes.PrefixoMinimo);
- var caminho = DefinirCaminho(argumentos.Saida, resultado);
- caminho = await RelatorioHtml.SalvarAsync(resultado, caminho).ConfigureAwait(false);
Console.WriteLine();
- Console.WriteLine($"Relatório gravado em: {caminho}");
-
- if (argumentos.Abrir)
+ var gravados = new List();
+ foreach (var caminho in argumentos.Caminhos(resultado, Environment.CurrentDirectory))
{
- Process.Start(new ProcessStartInfo(caminho) { UseShellExecute = true });
+ var gravado = await Relatorios.SalvarAsync(resultado, caminho).ConfigureAwait(false);
+ gravados.Add(gravado);
+ Console.WriteLine($"Relatório gravado em: {gravado}");
}
- return resultado.Cancelada ? CodigoCancelado : CodigoSucesso;
- }
-
- private static string DefinirCaminho(string? saida, ResultadoVarredura resultado)
- {
- var nome = RelatorioHtml.NomeArquivo(resultado);
- if (string.IsNullOrWhiteSpace(saida))
+ // Abre o HTML, se houver; senão, o primeiro arquivo gravado.
+ if (argumentos.Abrir && gravados.Count > 0)
{
- return Path.Combine(Environment.CurrentDirectory, nome);
+ var abrir = gravados.FirstOrDefault(g => Relatorios.FormatoDe(g) == FormatoRelatorio.Html) ?? gravados[0];
+ Process.Start(new ProcessStartInfo(abrir) { UseShellExecute = true });
}
- return saida.EndsWith(".html", StringComparison.OrdinalIgnoreCase) || saida.EndsWith(".htm", StringComparison.OrdinalIgnoreCase)
- ? saida
- : Path.Combine(saida, nome);
+ return resultado.Cancelada ? CodigoCancelado : CodigoSucesso;
}
private static string Cortar(string texto, int tamanho) => texto.Length <= tamanho ? texto : texto[..(tamanho - 3)] + "...";
diff --git a/testes/mapnet.testes/relatorios-dados-testes.cs b/testes/mapnet.testes/relatorios-dados-testes.cs
new file mode 100644
index 0000000..2be750d
--- /dev/null
+++ b/testes/mapnet.testes/relatorios-dados-testes.cs
@@ -0,0 +1,190 @@
+using System.Net;
+using System.Text;
+using System.Xml.Linq;
+using MapNet.Nucleo;
+
+namespace MapNet.Testes;
+
+/// Relatório em XML e CSV: conteúdo, codificação do texto vindo da rede e escolha do formato.
+public class RelatoriosDadosTestes
+{
+ [Fact]
+ public void Xml_e_valido_e_traz_tudo_do_host()
+ {
+ var xml = RelatorioXml.Gerar(Resultado());
+ var doc = XDocument.Parse(xml);
+ var host = doc.Root!.Element("hosts")!.Elements("host").First();
+
+ Assert.StartsWith("", xml);
+ Assert.Equal("1", doc.Root.Attribute("formato")!.Value);
+ Assert.Equal("192.0.2.0/24", doc.Root.Element("varredura")!.Attribute("subRede")!.Value);
+ Assert.Equal("192.0.2.5", host.Attribute("ip")!.Value);
+ Assert.Equal("Impressora", host.Element("tipo")!.Attribute("nome")!.Value);
+ Assert.Contains(host.Element("portas")!.Elements("porta"), p => p.Attribute("numero")!.Value == "9100" && p.Attribute("servico")!.Value == "Impressão direta");
+ Assert.Equal("Impressora & \"cia\"", host.Element("servicos")!.Element("servico")!.Attribute("titulo")!.Value);
+ Assert.Equal("IMPRESSORA", host.Element("nomes")!.Attribute("netbios")!.Value);
+ }
+
+ [Fact]
+ public void Xml_tira_caractere_que_o_xml_recusa()
+ {
+ var r = Resultado();
+ r.Hosts[0].NomeMdns = "nome\u0001com\u0008controle.local";
+
+ var doc = XDocument.Parse(RelatorioXml.Gerar(r));
+
+ Assert.Equal("nomecomcontrole.local", doc.Root!.Element("hosts")!.Element("host")!.Element("nomes")!.Attribute("mdns")!.Value);
+ }
+
+ [Fact]
+ public void Csv_tem_cabecalho_e_uma_linha_por_host()
+ {
+ var linhas = RelatorioCsv.Gerar(Resultado()).TrimEnd().Split(Environment.NewLine);
+
+ Assert.Equal(3, linhas.Length);
+ Assert.StartsWith("IP;Nome;Origem do nome;MAC;Fabricante;Tipo provável", linhas[0]);
+ Assert.StartsWith("192.0.2.5;IMPRESSORA;NetBIOS;00:80:77:12:34:05;", linhas[1]);
+ Assert.Contains(";Impressora;", linhas[1]);
+ }
+
+ [Theory]
+ [InlineData("simples", "simples")]
+ [InlineData("a;b", "\"a;b\"")]
+ [InlineData("diz \"oi\"", "\"diz \"\"oi\"\"\"")]
+ [InlineData("=HYPERLINK(\"x\")", "\"'=HYPERLINK(\"\"x\"\")\"")]
+ [InlineData("+1", "'+1")]
+ [InlineData("-cmd", "'-cmd")]
+ [InlineData("@soma", "'@soma")]
+ [InlineData("duas\nlinhas", "duas linhas")]
+ [InlineData(null, "")]
+ public void Campo_do_csv_e_protegido(string? valor, string esperado)
+ {
+ Assert.Equal(esperado, RelatorioCsv.Campo(valor));
+ }
+
+ [Theory]
+ [InlineData("r.html", FormatoRelatorio.Html)]
+ [InlineData("r.HTM", FormatoRelatorio.Html)]
+ [InlineData("r.csv", FormatoRelatorio.Csv)]
+ [InlineData("r.XML", FormatoRelatorio.Xml)]
+ [InlineData("r", FormatoRelatorio.Html)]
+ public void Formato_pela_extensao(string caminho, FormatoRelatorio esperado)
+ {
+ Assert.Equal(esperado, Relatorios.FormatoDe(caminho));
+ }
+
+ [Fact]
+ public async Task Salvar_grava_cada_formato_com_a_codificacao_certa()
+ {
+ var pasta = Path.Combine(Path.GetTempPath(), "mapnet-testes-formatos-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ var r = Resultado();
+ var csv = await Relatorios.SalvarAsync(r, Path.Combine(pasta, "a.csv"));
+ var xml = await Relatorios.SalvarAsync(r, Path.Combine(pasta, "a.xml"));
+ var html = await Relatorios.SalvarAsync(r, Path.Combine(pasta, "a.html"));
+
+ var bytesCsv = await File.ReadAllBytesAsync(csv);
+ Assert.Equal(Encoding.UTF8.GetPreamble(), bytesCsv[..3]);
+ Assert.NotEqual(0xEF, (await File.ReadAllBytesAsync(xml))[0]);
+ XDocument.Load(xml);
+ Assert.StartsWith("", await File.ReadAllTextAsync(html));
+ }
+ finally
+ {
+ if (Directory.Exists(pasta))
+ {
+ Directory.Delete(pasta, recursive: true);
+ }
+ }
+ }
+
+ [Fact]
+ public void Nome_sugerido_por_formato()
+ {
+ var r = Resultado();
+
+ Assert.EndsWith(".csv", Relatorios.NomeArquivo(r, FormatoRelatorio.Csv));
+ Assert.Equal(Path.GetFileNameWithoutExtension(RelatorioHtml.NomeArquivo(r)), Path.GetFileNameWithoutExtension(Relatorios.NomeArquivo(r, FormatoRelatorio.Xml)));
+ }
+
+ [Fact]
+ public void Linha_de_comando_grava_html_por_padrao()
+ {
+ var a = ArgumentosCli.Interpretar(["--varrer"]);
+
+ var caminhos = a.Caminhos(Resultado(), @"C:\atual");
+
+ Assert.Single(caminhos);
+ Assert.StartsWith(@"C:\atual", caminhos[0]);
+ Assert.EndsWith(".html", caminhos[0]);
+ }
+
+ [Fact]
+ public void Linha_de_comando_todos_os_formatos_numa_pasta()
+ {
+ var a = ArgumentosCli.Interpretar(["--varrer", "--formato", "todos", "--saida", "relatorios"]);
+
+ var caminhos = a.Caminhos(Resultado(), "atual");
+
+ Assert.True(a.Valido);
+ Assert.Equal([".html", ".csv", ".xml"], caminhos.Select(Path.GetExtension));
+ Assert.All(caminhos, c => Assert.StartsWith("relatorios", c));
+ }
+
+ [Fact]
+ public void Linha_de_comando_arquivo_define_o_formato()
+ {
+ var a = ArgumentosCli.Interpretar(["--varrer", "--saida", "inventario.csv"]);
+
+ Assert.Equal([FormatoRelatorio.Csv], a.Formatos);
+ Assert.Equal(["inventario.csv"], a.Caminhos(Resultado(), "atual"));
+ }
+
+ [Theory]
+ [InlineData(new[] { "--varrer", "--formato", "pdf" }, "Formato desconhecido")]
+ [InlineData(new[] { "--varrer", "--formato", "todos", "--saida", "a.html" }, "tem que ser uma pasta")]
+ [InlineData(new[] { "--formato", "csv" }, "pedem o comando --varrer")]
+ public void Linha_de_comando_recusa_formato_errado(string[] args, string trecho)
+ {
+ var a = ArgumentosCli.Interpretar(args);
+
+ Assert.False(a.Valido);
+ Assert.Contains(a.Erros, e => e.Contains(trecho));
+ }
+
+ private static ResultadoVarredura Resultado()
+ {
+ var i = new InterfaceRede
+ {
+ Id = "teste",
+ Nome = "Ethernet",
+ Descricao = "Placa de teste",
+ Tipo = TipoInterface.Cabo,
+ Ip = IPAddress.Parse("192.0.2.10"),
+ Prefixo = 24,
+ Gateway = IPAddress.Parse("192.0.2.1"),
+ Dns = [IPAddress.Parse("192.0.2.1")],
+ };
+ var inicio = new DateTimeOffset(2026, 9, 26, 10, 0, 0, TimeSpan.Zero);
+ var r = new ResultadoVarredura { Interface = i, SubRedeVarrida = i.SubRede, Inicio = inicio, Fim = inicio.AddSeconds(30), PortasVerificadas = ListaPortas.Padrao, IdentificacaoFeita = true };
+ r.Hosts.Add(new HostEncontrado
+ {
+ Ip = IPAddress.Parse("192.0.2.5"),
+ Mac = [0x00, 0x80, 0x77, 0x12, 0x34, 0x05],
+ Fabricante = "Brother industries, LTD.",
+ RespondeuPing = true,
+ RespondeuArp = true,
+ TempoPingMs = 3,
+ Ttl = 64,
+ NomeNetBios = "IMPRESSORA",
+ PortasAbertas = [80, 9100],
+ PortasTestadas = ListaPortas.Padrao,
+ PortasVerificadas = true,
+ Servicos = [new ServicoIdentificado { Porta = 80, Protocolo = "HTTP", CodigoHttp = 200, Titulo = "Impressora & \"cia\"" }],
+ ServicosIdentificados = true,
+ });
+ r.Hosts.Add(new HostEncontrado { Ip = IPAddress.Parse("192.0.2.10"), EhEsteComputador = true });
+ return r;
+ }
+}