A Pedra de Acabamento — Arquivos Estáticos, Testes e Empacotamento

A Pedra de Acabamento — Arquivos Estáticos, Testes e Empacotamento

O Farol aprende a servir um site: arquivos copiados do disco para o socket, o tipo de mídia de cada extensão, respostas 304 com ETag e Last-Modified e duas defesas contra a travessia de diretório. Depois vêm um teste de ponta a ponta com o HttpClient do JDK e uma imagem jlink de 42 MB.
Java

• • 34 min de leitura

Nesta aula o Farol aprende a servir um site: arquivos enviados direto do disco, o tipo de mídia certo para cada extensão, respostas 304 para o que o navegador já tem e duas camadas de defesa contra quem tenta sair da pasta. Depois vem um teste de ponta a ponta com o cliente HTTP do próprio JDK, e o servidor é empacotado num Java sob medida.

"A Terceira Pedra" deixou o Farol atendendo milhares de conexões ao mesmo tempo, mas só com respostas montadas na memória pelos manipuladores. Um site de verdade é feito, em boa parte, de arquivos: a página HTML, a folha de estilo, o JavaScript, as imagens. Esta aula completa o servidor com essa última peça e com as duas etapas que transformam código em produto: testes que exercitam o sistema inteiro e um pacote que roda em qualquer máquina. No caminho, o compilador vai mostrar mais uma vez o valor das hierarquias seladas, e o exercício 3 de "A Segunda Pedra", a travessia de diretório com %2e%2e, vai virar um teste.

A hierarquia cresce

Servir um arquivo lendo-o inteiro para um byte[] e colocando-o num Conteudo.Bytes funcionaria, mas um vídeo de 500 MB ocuparia 500 MB de memória, e cem clientes baixando o mesmo vídeo, cinquenta gigabytes. O certo é copiar o arquivo do disco para o socket em pedaços, sem nunca tê-lo inteiro na memória. Para isso, a hierarquia Conteudo ganha o terceiro caso, previsto duas aulas atrás:

package br.com.exemplo.farol.http;

import java.nio.file.Path;
import java.util.Objects;

/** O que vai no corpo de uma resposta. Hierarquia fechada: o escritor trata cada caso. */
public sealed interface Conteudo {

    /** Nenhum corpo. */
    record Vazio() implements Conteudo {}

    /** Bytes em memória, com o seu tipo de mídia. */
    record Bytes(Corpo corpo, String tipo) implements Conteudo {
        public Bytes {
            Objects.requireNonNull(corpo, "corpo");
            Objects.requireNonNull(tipo, "tipo");
        }
    }

    /**
     * Um arquivo em disco, enviado direto do disco para o socket, sem ser carregado
     * inteiro na memória. O tamanho é lido uma vez, na criação.
     */
    record Arquivo(Path caminho, long tamanho, String tipo) implements Conteudo {
        public Arquivo {
            Objects.requireNonNull(caminho, "caminho");
            Objects.requireNonNull(tipo, "tipo");
            if (tamanho < 0) throw new IllegalArgumentException("tamanho negativo");
        }
    }

    Conteudo VAZIO = new Vazio();
}

Como "A Segunda Pedra" mostrou, a compilação falha nos dois switch do escritor, e o compilador aponta exatamente o que falta. As duas mudanças no EscritorDeResposta:

long tamanho = switch (resposta.conteudo()) {
    case Conteudo.Vazio() -> 0;
    case Conteudo.Bytes(Corpo corpo, String tipo) -> {
        cabeca.append("Content-Type: ");
        linha(cabeca, tipo);
        yield corpo.tamanho();
    }
    case Conteudo.Arquivo(var _, long tamanhoDoArquivo, String tipo) -> {
        cabeca.append("Content-Type: ");
        linha(cabeca, tipo);
        yield tamanhoDoArquivo;
    }
};
if (!semCorpo) {
    switch (resposta.conteudo()) {
        case Conteudo.Vazio() -> { }
        case Conteudo.Bytes(Corpo corpo, var _) -> corpo.escreverEm(saida);
        case Conteudo.Arquivo(Path caminho, long tamanhoDoArquivo, var _) -> copiar(caminho, tamanhoDoArquivo, saida);
    }
}

A cópia tem um cuidado que parece exagero e não é:

/**
 * Copia exatamente o número de bytes anunciado no Content-Length. Se o arquivo mudou
 * de tamanho depois de a resposta ser criada, a conexão fica inconsistente: é um erro.
 */
private static void copiar(Path caminho, long tamanho, OutputStream saida) throws IOException {
    try (InputStream arquivo = Files.newInputStream(caminho)) {
        long copiados = arquivo.transferTo(new LimiteDeSaida(saida, tamanho));
        if (copiados != tamanho)
            throw new IOException(caminho + " mudou de tamanho durante o envio: " + copiados + " de " + tamanho + " bytes");
    }
}

/** Um fluxo que repassa no máximo n bytes e acusa se receber mais. */
private static final class LimiteDeSaida extends FilterOutputStream {
    private long restante;

    LimiteDeSaida(OutputStream saida, long limite) {
        super(saida);
        this.restante = limite;
    }

    @Override public void write(int b) throws IOException {
        write(new byte[]{(byte) b}, 0, 1);
    }

    @Override public void write(byte[] b, int inicio, int n) throws IOException {
        if (n > restante) throw new IOException("o arquivo cresceu durante o envio");
        out.write(b, inicio, n);
        restante -= n;
    }

    @Override public void close() {
        // não fecha a saída do socket: a conexão continua
    }
}

O Content-Length é escrito antes do corpo, com o tamanho que o arquivo tinha quando a resposta foi criada. Se alguém editar o arquivo entre esse momento e a cópia, o número de bytes enviados deixa de bater com o anunciado, e, numa conexão persistente, isso dessincroniza tudo o que vem depois, como o exercício 1 de "A Terceira Pedra" mostrou. O LimiteDeSaida, um FilterOutputStream no espírito do Decorator de "Lições de Quem Veio Antes", recusa bytes a mais, e a verificação depois do transferTo recusa bytes a menos. A IOException faz a Conexao fechar a conexão, que é a única saída honesta: é melhor um cliente ver a conexão cair do que receber um arquivo corrompido e interpretar o resto como outra resposta. O transferTo, do Java 9, copia em blocos com um buffer interno, e o close() vazio impede que o try do arquivo feche, sem querer, a saída do socket.

Um curinga no roteador

Um site tem arquivos em qualquer profundidade de pasta: /index.html, /css/estilo.css, /img/trilhas/lagoinha.jpg. O roteador de "A Segunda Pedra" só casa caminhos com o mesmo número de segmentos do padrão, então é preciso um segmento curinga, {*nome}, que capture o resto do caminho. É um novo tipo de segmento, e o Padrao ganha um terceiro caso na sua hierarquia selada:

sealed interface Segmento {}
record Literal(String valor) implements Segmento {}
record Parametro(String nome) implements Segmento {}
record Resto(String nome) implements Segmento {}

Mais uma vez, o compilador não deixa esquecer nada:

Padrao.java:43: error: the switch statement does not cover all possible input values
            switch (segmentos.get(i)) {
            ^
Padrao.java:62: error: the switch expression does not cover all possible input values
            sb.append('/').append(switch (s) {
                                  ^
2 errors

O Padrao.de passa a reconhecer {*nome} e a exigir que ele seja o último segmento:

String[] partes = texto.substring(1).split("/");
for (int i = 0; i < partes.length; i++) {
    String s = partes[i];
    if (s.isEmpty()) continue;
    if (s.startsWith("{*") && s.endsWith("}")) {
        String nome = s.substring(2, s.length() - 1);
        if (nome.isBlank()) throw new IllegalArgumentException("curinga sem nome em: " + texto);
        if (i != partes.length - 1) throw new IllegalArgumentException("{*" + nome + "} precisa ser o último segmento: " + texto);
        if (!nomes.add(nome)) throw new IllegalArgumentException("parâmetro {" + nome + "} repetido em: " + texto);
        segmentos.add(new Resto(nome));
    } else if (s.startsWith("{") && s.endsWith("}")) {
        // ... como antes

O casar aceita caminhos mais longos quando o padrão termina com o curinga, e junta o resto com /:

Optional<Map<String, String>> casar(List<String> caminho) {
    boolean temResto = !segmentos.isEmpty() && segmentos.getLast() instanceof Resto;
    int fixos = temResto ? segmentos.size() - 1 : segmentos.size();
    if (temResto ? caminho.size() < fixos : caminho.size() != fixos) return Optional.empty();
    Map<String, String> parametros = new LinkedHashMap<>();
    for (int i = 0; i < segmentos.size(); i++) {
        switch (segmentos.get(i)) {
            case Literal(String valor) -> {
                if (!valor.equals(caminho.get(i))) return Optional.empty();
            }
            case Parametro(String nome) -> parametros.put(nome, caminho.get(i));
            case Resto(String nome) -> parametros.put(nome, String.join("/", caminho.subList(i, caminho.size())));
        }
    }
    return Optional.of(parametros);
}

E a especificidade, que decide entre rotas concorrentes, deixa de contar só os segmentos fixos e passa a dar um peso a cada tipo de segmento, para que o curinga perca para qualquer rota mais precisa:

/**
 * A especificidade do padrão: cada segmento fixo vale 2, cada parâmetro vale 1
 * e o curinga vale 0. Quanto maior, mais específico.
 */
long especificidade() {
    return segmentos.stream().mapToLong(s -> switch (s) {
        case Literal(var _) -> 2;
        case Parametro(var _) -> 1;
        case Resto(var _) -> 0;
    }).sum();
}

O forma() ganha a linha case Resto(var _) -> "{*}";, e o roteador troca literais() por especificidade() na escolha da candidata. Com isso, /{*arquivo} pode ser registrado como a última rota da aplicação: ele fica com tudo o que nenhuma outra rota quis, e /tarefas/42 continua indo para a rota de tarefas.

O tipo de cada arquivo

O navegador decide o que fazer com um arquivo pelo Content-Type, e não pela extensão. Um CSS servido como application/octet-stream é ignorado, e o site aparece sem estilo. O Files.probeContentType do JDK depende de configurações do sistema operacional e devolve resultados diferentes em máquinas diferentes, então o Farol usa uma tabela própria, pequena e previsível:

package br.com.exemplo.farol.estatico;

import java.util.Locale;
import java.util.Map;

/** O Content-Type de cada extensão de arquivo que um site costuma ter. */
final class TiposDeMidia {
    private static final Map<String, String> POR_EXTENSAO = Map.ofEntries(
            Map.entry("html", "text/html; charset=utf-8"),
            Map.entry("css", "text/css; charset=utf-8"),
            Map.entry("js", "text/javascript; charset=utf-8"),
            Map.entry("json", "application/json"),
            Map.entry("txt", "text/plain; charset=utf-8"),
            Map.entry("svg", "image/svg+xml"),
            Map.entry("png", "image/png"),
            Map.entry("jpg", "image/jpeg"),
            Map.entry("jpeg", "image/jpeg"),
            Map.entry("gif", "image/gif"),
            Map.entry("webp", "image/webp"),
            Map.entry("ico", "image/x-icon"),
            Map.entry("woff2", "font/woff2"),
            Map.entry("pdf", "application/pdf"));

    private TiposDeMidia() {}

    static String de(String nomeDoArquivo) {
        int ponto = nomeDoArquivo.lastIndexOf('.');
        if (ponto < 0) return "application/octet-stream";
        String extensao = nomeDoArquivo.substring(ponto + 1).toLowerCase(Locale.ROOT);
        return POR_EXTENSAO.getOrDefault(extensao, "application/octet-stream");
    }
}

O charset=utf-8 nos tipos de texto diz ao navegador como interpretar os bytes, e sem ele um "ã" pode aparecer como "ã". O Locale.ROOT no toLowerCase evita o problema clássico do turco, em que o minúsculo de "I" não é "i".

O manipulador de arquivos

A classe central da aula é um Manipulador como qualquer outro:

package br.com.exemplo.farol.estatico;

import br.com.exemplo.farol.http.*;

import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.temporal.ChronoUnit;
import java.util.*;

/**
 * Serve os arquivos de uma pasta. Registrado numa rota com curinga, como
 * {@code rotas.get("/{*arquivo}", new ArquivosEstaticos(pasta, "arquivo"))}.
 */
public final class ArquivosEstaticos implements Manipulador {
    private final Path raiz;
    private final String parametro;

    /**
     * @param pasta     a pasta a servir; nada fora dela será entregue
     * @param parametro o nome do curinga da rota que contém o caminho pedido
     */
    public ArquivosEstaticos(Path pasta, String parametro) throws IOException {
        this.raiz = pasta.toRealPath();                       // absoluta, normalizada, sem ligações simbólicas
        if (!Files.isDirectory(raiz)) throw new IllegalArgumentException("não é uma pasta: " + pasta);
        this.parametro = Objects.requireNonNull(parametro);
    }

    @Override
    public Resposta atender(Requisicao req) throws IOException {
        Optional<Path> encontrado = localizar(req.parametro(parametro));
        if (encontrado.isEmpty()) return Resposta.erro(Status.NAO_ENCONTRADO, req.alvo().caminho());

        Path arquivo = encontrado.get();
        BasicFileAttributes atributos = Files.readAttributes(arquivo, BasicFileAttributes.class);
        Instant modificado = atributos.lastModifiedTime().toInstant().truncatedTo(ChronoUnit.SECONDS);
        String etag = "\"" + Long.toHexString(atributos.size()) + "-" + Long.toHexString(modificado.getEpochSecond()) + "\"";
        String ultimaModificacao = DateTimeFormatter.RFC_1123_DATE_TIME.format(modificado.atZone(ZoneOffset.UTC));

        Resposta resposta = naoModificado(req, etag, modificado)
                ? Resposta.vazia(Status.NAO_MODIFICADO)
                : new Resposta(Status.OK, Cabecalhos.VAZIOS,
                        new Conteudo.Arquivo(arquivo, atributos.size(), TiposDeMidia.de(arquivo.getFileName().toString())));
        return resposta
                .comCabecalho("ETag", etag)
                .comCabecalho("Last-Modified", ultimaModificacao)
                .comCabecalho("Cache-Control", "no-cache");      // o navegador guarda, mas pergunta antes de usar
    }

    /**
     * Converte o caminho pedido num arquivo dentro da raiz, ou vazio se ele não existir,
     * for oculto ou tentar sair da raiz. Uma pasta é trocada pelo seu index.html.
     */
    Optional<Path> localizar(String pedido) throws IOException {
        Path candidato = raiz;
        for (String parte : pedido.split("/")) {
            if (parte.isEmpty()) continue;
            if (parte.startsWith(".") || parte.indexOf('\0') >= 0 || parte.indexOf('\\') >= 0)
                return Optional.empty();                      // "..", arquivos ocultos, truques de separador
            candidato = candidato.resolve(parte);
        }
        if (Files.isDirectory(candidato)) candidato = candidato.resolve("index.html");
        if (!Files.isRegularFile(candidato)) return Optional.empty();

        // A verificação decisiva: o arquivo real, com as ligações simbólicas resolvidas, está dentro da raiz?
        Path real = candidato.toRealPath();
        return real.startsWith(raiz) ? Optional.of(real) : Optional.empty();
    }

    /** O cliente já tem esta versão? If-None-Match tem prioridade sobre If-Modified-Since (RFC 9110). */
    private static boolean naoModificado(Requisicao req, String etag, Instant modificado) {
        Optional<String> seNenhumCombinar = req.cabecalhos().valor("If-None-Match");
        if (seNenhumCombinar.isPresent()) {
            for (String candidata : seNenhumCombinar.get().split(","))
                if (candidata.strip().equals(etag) || candidata.strip().equals("*")) return true;
            return false;
        }
        Optional<String> seModificadoDesde = req.cabecalhos().valor("If-Modified-Since");
        if (seModificadoDesde.isPresent()) {
            try {
                Instant data = ZonedDateTime.parse(seModificadoDesde.get(), DateTimeFormatter.RFC_1123_DATE_TIME).toInstant();
                return !modificado.isAfter(data);
            } catch (DateTimeParseException e) {
                return false;                                  // data inválida: a RFC manda ignorar o cabeçalho
            }
        }
        return false;
    }
}

O método localizar é onde mora a segurança, e ele tem duas camadas de defesa, uma para cada tipo de ataque.

A primeira examina o nome de cada parte do caminho, depois da decodificação feita pelo Alvo. Qualquer parte que comece com ponto é recusada, o que elimina de uma vez o .. da travessia de diretório, seja ele escrito como .. ou como %2e%2e, e também os arquivos ocultos, como o .env onde muitos projetos guardam senhas, ou a pasta .git, que exporia todo o histórico do código. Barras invertidas e o caractere nulo, que alguns sistemas de arquivos interpretam de formas inesperadas, também são recusados.

A segunda camada é a decisiva, e protege contra um ataque que nenhum filtro de nomes vê: uma ligação simbólica dentro da pasta do site que aponta para fora dela, criada por descuido ou por alguém com acesso ao disco. O toRealPath() resolve todas as ligações e devolve o caminho verdadeiro do arquivo, e o startsWith(raiz) verifica que ele continua dentro da raiz, que também foi resolvida com toRealPath() no construtor. O startsWith de Path compara componentes inteiros, e não caracteres: para ele, /srv/site-privado/senha.txt não começa com /srv/site, enquanto o startsWith de String diria que começa. Esse detalhe é o tema do exercício 2.

Qualquer recusa responde 404, e não 403. Um 403 confirmaria a quem está sondando que o arquivo existe; um 404 não revela nada.

O resto do método trata do cache. Um navegador que já baixou estilo.css guarda uma cópia, e na próxima visita pergunta ao servidor se ela ainda vale, enviando os identificadores da versão que tem. O Farol oferece dois. O ETag (entity tag) é uma etiqueta opaca da versão do arquivo; o Farol a monta com o tamanho e a data de modificação em hexadecimal, entre aspas, como a RFC exige. O Last-Modified é a data da última modificação. O navegador devolve o primeiro em If-None-Match e o segundo em If-Modified-Since, e se a versão não mudou, o Farol responde 304 Not Modified, sem corpo, e o navegador usa a cópia local. A RFC 9110 manda dar prioridade ao If-None-Match quando os dois vierem, e ignorar uma data que não possa ser interpretada. A data de modificação é truncada para segundos porque as datas HTTP não têm frações, e sem o truncamento um arquivo modificado às 18:28:07,531 pareceria sempre "mais novo" que a data 18:28:07 que o próprio servidor enviou, e o 304 nunca aconteceria. O Cache-Control: no-cache, apesar do nome, não proíbe o cache: diz ao navegador que ele pode guardar a cópia, mas precisa perguntar antes de cada uso, o que garante que uma mudança no site apareça na hora.

O site

Com o manipulador pronto, o Principal registra a API, as rotas de exemplo e, por último, o curinga que serve a pasta do site:

package br.com.exemplo.farol.app;

import br.com.exemplo.farol.estatico.ArquivosEstaticos;
import br.com.exemplo.farol.http.Resposta;
import br.com.exemplo.farol.nucleo.Configuracao;
import br.com.exemplo.farol.nucleo.Servidor;
import br.com.exemplo.farol.rotas.Roteador;

import java.nio.file.Path;
import java.time.Duration;

/**
 * Sobe o Farol com a API de tarefas e os arquivos de uma pasta.
 * Uso: farol [porta] [pasta-do-site]
 */
public class Principal {
    public static void main(String[] args) throws Exception {
        int porta = args.length > 0 ? Integer.parseInt(args[0]) : 8080;
        Path site = Path.of(args.length > 1 ? args[1] : "site");

        Roteador rotas = new Roteador()
                .get("/ola/{nome}", req -> Resposta.texto("Olá, " + req.parametro("nome") + "!\n"))
                .get("/lento", req -> {                        // simula uma consulta demorada ao banco
                    Thread.sleep(Duration.ofSeconds(1));
                    return Resposta.texto("demorou, mas veio\n");
                });
        new ApiDeTarefas().registrarEm(rotas);
        rotas.get("/{*arquivo}", new ArquivosEstaticos(site, "arquivo"));   // o menos específico: fica com o resto

        Servidor farol = new Servidor(porta, Configuracao.PADRAO, rotas).iniciar();
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            try {
                farol.close();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }));
        farol.aguardar();
    }
}

O module-info.java passa a exportar também br.com.exemplo.farol.estatico. A rota / que devolvia um HTML fixo saiu: agora é o index.html da pasta que responde por ela.

O site é uma pequena interface para a API de tarefas de "A Segunda Pedra", em três arquivos, sem nenhuma biblioteca. A página, site/index.html:

<!doctype html>
<html lang="pt-BR">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Tarefas do Farol</title>
  <link rel="stylesheet" href="/css/estilo.css">
</head>
<body>
  <main>
    <h1>Tarefas do Farol</h1>
    <p class="subtitulo">Servido por um servidor HTTP escrito do zero em Java.</p>

    <form id="nova">
      <input id="titulo" name="titulo" placeholder="O que precisa ser feito?" maxlength="200" required>
      <button type="submit">Adicionar</button>
    </form>

    <ul id="lista"></ul>
    <p id="vazia" hidden>Nenhuma tarefa. Aproveite o dia!</p>
  </main>
  <script src="/js/app.js"></script>
</body>
</html>

O estilo, site/css/estilo.css:

:root { --fundo: #f4f1ea; --texto: #1d2733; --destaque: #1f6f8b; --feito: #7a8691; }
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; background: var(--fundo); color: var(--texto); }
main { max-width: 36rem; margin: 3rem auto; padding: 0 1rem; }
h1 { margin-bottom: 0.25rem; }
.subtitulo { margin-top: 0; color: var(--feito); }
form { display: flex; gap: 0.5rem; margin: 1.5rem 0; }
input { flex: 1; padding: 0.6rem; border: 1px solid #c9c2b4; border-radius: 6px; font: inherit; }
button { padding: 0.6rem 1rem; border: 0; border-radius: 6px; background: var(--destaque); color: white; font: inherit; cursor: pointer; }
ul { list-style: none; padding: 0; }
li { display: flex; align-items: center; gap: 0.75rem; padding: 0.6rem 0; border-bottom: 1px solid #ddd5c7; }
li span { flex: 1; }
li.concluida span { text-decoration: line-through; color: var(--feito); }
li button { background: transparent; color: var(--destaque); padding: 0.2rem 0.5rem; }

E o comportamento, site/js/app.js, que conversa com a API usando o fetch do navegador:

// Interface mínima para a API de tarefas do Farol, sem bibliotecas.
const lista = document.querySelector("#lista");
const vazia = document.querySelector("#vazia");

async function carregar() {
  const resposta = await fetch("/tarefas");
  const tarefas = await resposta.json();
  lista.replaceChildren(...tarefas.map(item));
  vazia.hidden = tarefas.length > 0;
}

function item(tarefa) {
  const li = document.createElement("li");
  li.className = tarefa.concluida ? "concluida" : "";
  const texto = document.createElement("span");
  texto.textContent = tarefa.titulo;                 // textContent: nunca interpreta HTML vindo dos dados
  li.append(texto);
  if (!tarefa.concluida) li.append(botao("concluir", () => fetch(`/tarefas/${tarefa.id}/concluida`, { method: "PUT" })));
  li.append(botao("remover", () => fetch(`/tarefas/${tarefa.id}`, { method: "DELETE" })));
  return li;
}

function botao(rotulo, acao) {
  const b = document.createElement("button");
  b.textContent = rotulo;
  b.addEventListener("click", async () => { await acao(); await carregar(); });
  return b;
}

document.querySelector("#nova").addEventListener("submit", async (evento) => {
  evento.preventDefault();
  const campo = document.querySelector("#titulo");
  await fetch("/tarefas", { method: "POST", body: campo.value });
  campo.value = "";
  await carregar();
});

carregar();

O detalhe de segurança do lado do navegador é o textContent: o título da tarefa é inserido como texto, e um título como <img src=x onerror=alert(1)> aparece literalmente na tela, em vez de ser executado. Montar o HTML concatenando o título num innerHTML abriria uma falha de injeção de script (cross-site scripting, XSS), o equivalente, no navegador, da injeção de cabeçalhos que o escritor do Farol recusa.

Para testar o site num navegador de verdade, sem abrir um, um Chromium sem interface, controlado por um script, abriu a página, criou três tarefas, concluiu a primeira e recarregou a página. A tela ficou com "Estudar sockets" riscada e "Ler a RFC 9112" e "Escrever os testes do Farol" pendentes, cada uma com seus botões. O registro do Farol mostra a conversa inteira:

[conexao-11] GET / → 200
[conexao-11] GET /css/estilo.css → 200
[conexao-12] GET /js/app.js → 200
[conexao-12] GET /tarefas → 200
[conexao-12] POST /tarefas → 201
[conexao-12] GET /tarefas → 200
[conexao-12] POST /tarefas → 201
[conexao-12] GET /tarefas → 200
[conexao-12] POST /tarefas → 201
[conexao-12] GET /tarefas → 200
[conexao-12] PUT /tarefas/1/concluida → 200
[conexao-12] GET /tarefas → 200
[conexao-12] GET / → 304
[conexao-12] GET /css/estilo.css → 304
[conexao-11] GET /js/app.js → 304
[conexao-11] GET /tarefas → 200

Dezesseis requisições em duas conexões: o navegador abriu duas conexões persistentes e distribuiu as requisições entre elas, e o keep-alive de "A Terceira Pedra" fez o resto. Na recarga, a página, o CSS e o JavaScript voltaram como 304: o navegador perguntou com If-None-Match, o Farol confirmou que nada mudou, e nenhum byte de arquivo trafegou de novo. Só a lista de tarefas, que é dinâmica, veio completa.

O curl confirma as defesas. A opção --path-as-is impede que o próprio curl resolva os .. antes de enviar:

$ curl --path-as-is http://localhost:8080/../../etc/passwd
404 Not Found: /../../etc/passwd

$ curl --path-as-is "http://localhost:8080/%2e%2e/%2e%2e/etc/passwd"
404 Not Found: /../../etc/passwd

$ curl -i -H 'If-None-Match: "3d8-6abc0337"' http://localhost:8080/css/estilo.css
HTTP/1.1 304 Not Modified
Cache-Control: no-cache
ETag: "3d8-6abc0337"
Last-Modified: Tue, 29 Sep 2026 18:28:07 GMT

Os testes

O ArquivosEstaticosTest cria um site temporário, com um arquivo oculto dentro e um arquivo "secreto" ao lado da pasta, e ataca por todos os caminhos:

package br.com.exemplo.farol.estatico;

import br.com.exemplo.farol.http.*;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.io.IOException;
import java.nio.file.*;
import java.util.Comparator;
import java.util.Map;
import java.util.stream.Stream;

import static org.assertj.core.api.Assertions.assertThat;

class ArquivosEstaticosTest {
    private Path base, site;
    private ArquivosEstaticos estaticos;

    @BeforeEach
    void criarSite() throws IOException {
        base = Files.createTempDirectory("farol-teste");
        site = Files.createDirectories(base.resolve("site"));
        Files.writeString(site.resolve("index.html"), "<h1>início</h1>");
        Files.createDirectories(site.resolve("css"));
        Files.writeString(site.resolve("css/estilo.css"), "body{}");
        Files.writeString(site.resolve(".env"), "SENHA=123");
        Files.writeString(base.resolve("segredo.txt"), "fora do site");   // ao lado da pasta, não dentro
        estaticos = new ArquivosEstaticos(site, "arquivo");
    }

    @AfterEach
    void apagar() throws IOException {
        try (Stream<Path> tudo = Files.walk(base)) {
            tudo.sorted(Comparator.reverseOrder()).forEach(p -> p.toFile().delete());
        }
    }

    /** Simula o que o roteador faz com a rota "/{*arquivo}". */
    private Resposta pedir(String alvo, String... cabecalhos) throws IOException {
        Cabecalhos.Construtor c = Cabecalhos.construtor();
        for (int i = 0; i < cabecalhos.length; i += 2) c.adicionar(cabecalhos[i], cabecalhos[i + 1]);
        Alvo a = Alvo.de(alvo);
        var req = new Requisicao(Metodo.GET, a, "HTTP/1.1", c.criar(), Corpo.VAZIO)
                .comParametros(Map.of("arquivo", String.join("/", a.segmentos())));
        return estaticos.atender(req);
    }

    @Test
    void serveArquivoComTipoETamanho() throws IOException {
        Resposta r = pedir("/css/estilo.css");
        assertThat(r.status()).isEqualTo(Status.OK);
        assertThat(r.conteudo() instanceof Conteudo.Arquivo(var _, long tamanho, String tipo)
                && tamanho == 6 && tipo.equals("text/css; charset=utf-8")).isTrue();
    }

    @Test
    void pastaServeOIndex() throws IOException {
        assertThat(pedir("/").status()).isEqualTo(Status.OK);
        assertThat(pedir("/css").status()).isEqualTo(Status.NAO_ENCONTRADO);   // pasta sem index.html
    }

    @Test
    void recusaSairDaRaiz() throws IOException {
        assertThat(pedir("/../segredo.txt").status()).isEqualTo(Status.NAO_ENCONTRADO);
        assertThat(pedir("/%2e%2e/segredo.txt").status()).isEqualTo(Status.NAO_ENCONTRADO);
        assertThat(pedir("/css/%2e%2e/%2e%2e/segredo.txt").status()).isEqualTo(Status.NAO_ENCONTRADO);
    }

    @Test
    void recusaArquivosOcultos() throws IOException {
        assertThat(pedir("/.env").status()).isEqualTo(Status.NAO_ENCONTRADO);
    }

    @Test
    void recusaLigacaoSimbolicaParaFora() throws IOException {
        Files.createSymbolicLink(site.resolve("atalho.txt"), base.resolve("segredo.txt"));
        assertThat(pedir("/atalho.txt").status()).isEqualTo(Status.NAO_ENCONTRADO);
    }

    @Test
    void respondeNaoModificadoPelaEtag() throws IOException {
        String etag = pedir("/css/estilo.css").cabecalhos().valor("ETag").orElseThrow();
        Resposta r = pedir("/css/estilo.css", "If-None-Match", etag);
        assertThat(r.status()).isEqualTo(Status.NAO_MODIFICADO);
        assertThat(r.conteudo() instanceof Conteudo.Vazio).isTrue();
        assertThat(pedir("/css/estilo.css", "If-None-Match", "\"outra\"").status()).isEqualTo(Status.OK);
    }

    @Test
    void respondeNaoModificadoPelaData() throws IOException {
        String data = pedir("/index.html").cabecalhos().valor("Last-Modified").orElseThrow();
        assertThat(pedir("/index.html", "If-Modified-Since", data).status()).isEqualTo(Status.NAO_MODIFICADO);
        assertThat(pedir("/index.html", "If-Modified-Since", "Mon, 01 Jan 2024 00:00:00 GMT").status()).isEqualTo(Status.OK);
        assertThat(pedir("/index.html", "If-Modified-Since", "ontem").status()).isEqualTo(Status.OK);
    }
}

O teste da ligação simbólica é o que exercita a segunda camada: o nome atalho.txt passa pelo filtro de nomes, o arquivo existe, mas o toRealPath() revela que ele está fora da raiz. O instanceof com padrão de record no primeiro teste, Conteudo.Arquivo(var _, long tamanho, String tipo), desmonta o conteúdo e verifica dois componentes numa só expressão. Em JUnit, a anotação @TempDir cria e apaga pastas temporárias automaticamente; o teste as cria à mão para mostrar o que ela faz por você, e trocar pela anotação é uma boa simplificação.

Houve uma surpresa ao compilar os testes: o RoteadorTest de "A Segunda Pedra" parou de compilar. Ele tem um método auxiliar com um switch sobre Conteudo, e o compilador exigiu o caso novo também ali. A garantia de exaustividade vale para o código de teste tanto quanto para o de produção. O auxiliar ganhou a linha case Conteudo.Arquivo(var caminho, var _, var _) -> "<arquivo " + caminho + ">";, e o arquivo ganhou dois testes para o curinga:

@Test
void curingaCapturaORestoEPerdeParaRotasMaisEspecificas() throws Exception {
    rotas.get("/{*arquivo}", req -> Resposta.texto("arquivo " + req.parametro("arquivo")));
    assertThat(texto(pedir(Metodo.GET, "/css/site/estilo.css"))).isEqualTo("arquivo css/site/estilo.css");
    assertThat(texto(pedir(Metodo.GET, "/"))).isEqualTo("arquivo ");
    assertThat(texto(pedir(Metodo.GET, "/tarefas/42"))).isEqualTo("tarefa 42");
}

@Test
void curingaSoNoFim() {
    assertThatThrownBy(() -> rotas.get("/{*resto}/fim", req -> Resposta.texto("x")))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessageContaining("último segmento");
}

Todos os testes até aqui usaram clientes escritos por nós: os bytes crus do LeitorDeRequisicaoTest, o Cliente do ServidorTest. Existe um risco sutil nisso: se o Farol e os testes compartilharem o mesmo mal-entendido sobre o protocolo, os testes passam e o servidor continua errado. O teste de integração final usa um cliente que não foi escrito por nós, o HttpClient do próprio JDK, de "Do JAR ao Executável":

package br.com.exemplo.farol.integracao;

import br.com.exemplo.farol.estatico.ArquivosEstaticos;
import br.com.exemplo.farol.http.Resposta;
import br.com.exemplo.farol.nucleo.Configuracao;
import br.com.exemplo.farol.nucleo.Servidor;
import br.com.exemplo.farol.rotas.Roteador;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.io.IOException;
import java.net.URI;
import java.net.http.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;

import static org.assertj.core.api.Assertions.assertThat;

/** O Farol inteiro, testado por um cliente HTTP que não fomos nós que escrevemos: o do próprio JDK. */
class FarolComHttpClientTest {
    private Servidor servidor;
    private Path site;
    private final HttpClient cliente = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build();

    @BeforeEach
    void subir() throws IOException {
        site = Files.createTempDirectory("farol-site");
        Files.writeString(site.resolve("index.html"), "<h1>Farol</h1>");
        Roteador rotas = new Roteador()
                .post("/eco", req -> Resposta.texto(req.corpoComoTexto().toUpperCase()))
                .get("/{*arquivo}", new ArquivosEstaticos(site, "arquivo"));
        servidor = new Servidor(0, Configuracao.PADRAO, rotas).iniciar();
    }

    @AfterEach
    void descer() throws IOException {
        servidor.close();
        cliente.close();
        Files.deleteIfExists(site.resolve("index.html"));
        Files.deleteIfExists(site);
    }

    private URI uri(String caminho) {
        return URI.create("http://localhost:" + servidor.porta() + caminho);
    }

    @Test
    void servePaginaEEco() throws Exception {
        var pagina = cliente.send(HttpRequest.newBuilder(uri("/")).build(), HttpResponse.BodyHandlers.ofString());
        assertThat(pagina.statusCode()).isEqualTo(200);
        assertThat(pagina.headers().firstValue("Content-Type")).hasValue("text/html; charset=utf-8");
        assertThat(pagina.body()).isEqualTo("<h1>Farol</h1>");

        var eco = cliente.send(HttpRequest.newBuilder(uri("/eco"))
                .POST(HttpRequest.BodyPublishers.ofString("ação")).build(), HttpResponse.BodyHandlers.ofString());
        assertThat(eco.body()).isEqualTo("AÇÃO");
    }

    @Test
    void milRequisicoesConcorrentes() throws Exception {
        List<CompletableFuture<HttpResponse<String>>> respostas = new ArrayList<>();
        for (int i = 0; i < 1000; i++)
            respostas.add(cliente.sendAsync(HttpRequest.newBuilder(uri("/eco"))
                    .POST(HttpRequest.BodyPublishers.ofString("n" + i)).build(), HttpResponse.BodyHandlers.ofString()));
        for (int i = 0; i < 1000; i++)
            assertThat(respostas.get(i).get(10, TimeUnit.SECONDS).body()).isEqualTo("N" + i);
    }
}

O HttpClient é forçado a usar HTTP/1.1, porque por padrão ele tentaria negociar o HTTP/2, que o Farol não fala. O segundo teste dispara mil requisições assíncronas com o sendAsync, que devolve CompletableFutures como os de "Encadeando o que Ainda Não Aconteceu", e verifica que cada resposta corresponde ao seu pedido: nenhuma resposta trocada, nenhuma perdida, com o cliente do JDK gerenciando seu próprio conjunto de conexões persistentes. A classe HttpClient é AutoCloseable desde o Java 21, e o close() no @AfterEach libera as conexões.

Esse teste usa o módulo java.net.http, que o módulo do Farol não declara em requires, porque o servidor não precisa dele. Para que o Surefire não tente executar os testes dentro do módulo, onde esse outro módulo não estaria visível, a configuração do plugin no pom.xml passa a rodar os testes no classpath:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>3.5.3</version>
    <configuration>
        <useModulePath>false</useModulePath>
    </configuration>
</plugin>

A suíte completa:

Tests run: 67, Failures: 0, Skipped: 0

Empacotando o Farol

A última etapa é a entrega, com as ferramentas de "Do JAR ao Executável". O mvn package produz um JAR de 68 KB, e o jdeps confirma o que o module-info.java já dizia:

$ jdeps -s target/farol-0.1.0.jar
br.com.exemplo.farol -> java.base

O Farol inteiro, com servidor, protocolo, roteador, arquivos estáticos e API, depende só de java.base. Não há serviço carregado por nome que o jdeps possa ter deixado de ver, como os dados de idioma da agenda daquela aula, porque o Farol não usa nenhum. Um script pequeno, empacotar.sh, faz a imagem sob medida:

#!/bin/sh
# Constrói o Farol e monta um Java sob medida para ele, com o site ao lado
set -e
mvn -q clean package
rm -rf target/farol-imagem
jlink --module-path target/farol-0.1.0.jar \
      --add-modules br.com.exemplo.farol \
      --strip-debug --no-man-pages --no-header-files --compress zip-6 \
      --generate-cds-archive \
      --output target/farol-imagem
cp -r site target/farol-imagem/site

A imagem tem 69 MB com o arquivo CDS, ou 42 MB sem ele, e roda em qualquer Linux da mesma arquitetura, sem Java instalado:

$ target/farol-imagem/bin/java -m br.com.exemplo.farol/br.com.exemplo.farol.app.Principal 8080 target/farol-imagem/site

Medindo o tempo desde o comando até a primeira resposta a /tarefas, e a memória residente do processo em repouso, três execuções de cada:

imagem sem CDS:    268, 286, 292 ms    59–60 MB
imagem com CDS:    185, 184, 185 ms    58 MB
JDK completo:      166, 161, 176 ms    56–57 MB

Os números confirmam a lição daquela aula: sem o arquivo CDS, a imagem pequena inicia mais devagar que o JDK completo, e com ele fica quase igual, em menos de 200 ms. Um servidor HTTP completo, com milhares de conexões possíveis, pronto para responder em um quinto de segundo, ocupando uns 60 MB de memória.

Há um detalhe prático que só aparece quando se mede. O jlink pode criar um lançador com --launcher, como a agenda usou, mas esse lançador é um script de shell que chama o java sem o comando exec:

#!/bin/sh
JLINK_VM_OPTIONS=
DIR=`dirname $0`
$DIR/java $JLINK_VM_OPTIONS -m br.com.exemplo.farol/br.com.exemplo.farol.app.Principal "$@"

O processo que recebe os sinais do sistema operacional é o shell, e não a JVM. Na primeira tentativa de medição, o SIGTERM enviado ao lançador encerrou o shell e deixou o Farol rodando, sozinho, ocupando a porta 8080, e a execução seguinte falhou com BindException: Address already in use. Num contêiner, o efeito seria pior: o desligamento ordenado de "A Terceira Pedra" nunca seria acionado, e o orquestrador, depois do prazo, mataria tudo com SIGKILL. Por isso, o script acima não usa --launcher, e o Farol é iniciado chamando o java da imagem diretamente. O exercício 5 volta a esse ponto.

Com esta aula, o Farol está completo: lê HTTP/1.1 com rigor, responde com tipos que o compilador verifica, roteia com parâmetros e curingas, atende milhares de conexões com threads virtuais, serve um site com cache e sem vazar nada fora da pasta, desliga sem cortar ninguém, tem 67 testes e cabe num Java de 42 MB.

Fontes e leituras recomendadas

Exercícios

Exercício 1

O Conteudo.Arquivo guarda o tamanho do arquivo no momento em que a resposta é criada. Descreva o que acontece se o arquivo crescer ou encolher entre a criação da resposta e a cópia, e por que o escritor lança uma exceção em vez de simplesmente enviar o arquivo como ele estiver.

Ver resposta

✓ Resposta: O Content-Length já foi escrito com o tamanho antigo quando a cópia começa. Num teste com um arquivo de 5 bytes, trocado por um de 10 depois de a resposta existir, o escritor falhou com "o arquivo cresceu durante o envio"; trocado por um de 2, falhou com "mudou de tamanho durante o envio: 2 de 5 bytes". Se o escritor enviasse o arquivo como estivesse, no caso do crescimento o cliente leria os 5 bytes anunciados e interpretaria os outros 5 como o começo da próxima resposta, e todas as respostas seguintes naquela conexão sairiam corrompidas; no caso do encolhimento, o cliente ficaria esperando 3 bytes que nunca chegam, até um prazo esgotar, enquanto o servidor, achando que terminou, esperaria a próxima requisição: as duas pontas bloqueadas, cada uma esperando a outra. A exceção faz a Conexao fechar a conexão, e o cliente vê uma falha clara, que um navegador trata tentando de novo. Ler o tamanho de novo na hora da cópia não resolveria, porque o cabeçalho já saiu. A solução completa para sites que mudam durante o uso é publicar as versões novas em outro caminho, ou trocá-las com uma renomeação atômica do sistema de arquivos, que o Files.move com ATOMIC_MOVE oferece.

Exercício 2

O localizar verifica o resultado com real.startsWith(raiz), sobre objetos Path. Mostre por que a mesma verificação feita sobre String seria uma falha de segurança, e explique por que a primeira camada de defesa, o filtro de nomes, não torna a segunda desnecessária.

Ver resposta

✓ Resposta: Com a raiz /srv/site e um arquivo resolvido para /srv/site-privado/senha.txt, "/srv/site-privado/senha.txt".startsWith("/srv/site") é true, porque compara caracteres, enquanto Path.of("/srv/site-privado/senha.txt").startsWith(Path.of("/srv/site")) é false, porque compara componentes inteiros do caminho, e site-privado não é site. Um atacante que conseguisse chegar a uma pasta vizinha com nome começando igual à raiz passaria pela verificação de String. As duas camadas protegem contra coisas diferentes. O filtro de nomes olha o texto pedido pelo cliente e barra .., arquivos ocultos e separadores estranhos, mas não sabe nada sobre o que está no disco. Uma ligação simbólica dentro da pasta do site, com um nome inocente como atalho.txt, passa pelo filtro, porque o nome é válido, e só o toRealPath() revela que o arquivo verdadeiro está fora da raiz; é exatamente o que o teste recusaLigacaoSimbolicaParaFora verifica. No sentido contrário, a segunda camada sozinha não bloquearia o .env, que está legitimamente dentro da raiz. Defesa em profundidade significa que cada camada cobre uma classe de ataque que a outra não cobre, e que um erro numa delas não abre o sistema inteiro.

Exercício 3

O ArquivosEstaticos trunca a data de modificação para segundos antes de montar o ETag e o Last-Modified. O que aconteceria sem o truncamento, com cada um dos dois cabeçalhos condicionais?

Ver resposta

✓ Resposta: Os sistemas de arquivos modernos guardam datas de modificação com frações de segundo, até nanossegundos, mas as datas HTTP, no formato RFC 1123, têm precisão de um segundo. Sem o truncamento, com o If-Modified-Since, o servidor enviaria Last-Modified: ... 18:28:07 GMT, perdendo a fração, e o navegador devolveria exatamente esse valor. Na comparação, o arquivo, modificado às 18:28:07,531, pareceria posterior à data recebida, !modificado.isAfter(data) seria falso, e o servidor responderia 200 com o arquivo inteiro todas as vezes: o cache nunca funcionaria, sem nenhum erro visível, só banda desperdiçada. Com o ETag, o efeito dependeria de como a etiqueta é montada: se usasse os nanossegundos, ela seria estável e o If-None-Match funcionaria, porque o navegador devolve a etiqueta exatamente como a recebeu. O truncamento mantém as duas representações coerentes entre si, e a regra geral é que um valor que vai e volta pela rede precisa ser normalizado antes de ser comparado, na mesma precisão em que viaja.

Exercício 4

O filtro de nomes recusa qualquer parte do caminho que comece com ponto. O serviço Let's Encrypt, que emite certificados HTTPS gratuitos, verifica a posse de um domínio pedindo um arquivo em /.well-known/acme-challenge/<código>. Altere o filtro para permitir exatamente essa pasta, sem abrir brechas.

Ver resposta

✓ Resposta: A mudança é permitir a parte .well-known e continuar recusando qualquer outra que comece com ponto, inclusive . e ..:

private static final Set<String> PASTAS_PERMITIDAS_COM_PONTO = Set.of(".well-known");

// dentro do laço de localizar:
        if (parte.startsWith(".") && !PASTAS_PERMITIDAS_COM_PONTO.contains(parte))
            return Optional.empty();
        if (parte.indexOf('\0') >= 0 || parte.indexOf('\\') >= 0)
            return Optional.empty();

A lista é de nomes exatos, comparados com contains de um Set, e não com startsWith(".well"), que deixaria passar .well-known-backup ou algo pior. O .. continua bloqueado porque não está na lista. Os arquivos dentro de .well-known que começam com ponto também continuam bloqueados, porque cada parte do caminho é verificada separadamente. A segunda camada, o toRealPath() com startsWith(raiz), continua valendo para tudo. Por fim, um teste para cada lado da regra: /.well-known/acme-challenge/abc deve devolver 200, e /.well-known/../.env e /.git/config devem continuar devolvendo 404.

Exercício 5

Explique por que o lançador criado pelo --launcher do jlink atrapalha o desligamento ordenado num contêiner Docker, e escreva um script de lançamento que não tenha o problema.

Ver resposta

✓ Resposta: O lançador executa $DIR/java ... como um processo filho do shell. Num contêiner, o processo principal, que o Docker trata como o processo de número 1, é o shell, e é para ele que o docker stop envia o SIGTERM. O shell, sem nenhum tratamento de sinais, não repassa o sinal ao filho, então a JVM nunca fica sabendo que precisa desligar: o gancho de desligamento não roda, as conexões ociosas não são fechadas, e depois do prazo de tolerância, 10 segundos por padrão no docker stop, o Docker envia SIGKILL e mata tudo, cortando as requisições em andamento. Fora de contêineres, o mesmo defeito apareceu na medição desta aula, com um Farol órfão ocupando a porta. A correção é o comando exec, que substitui o processo do shell pelo da JVM, sem criar um filho:

#!/bin/sh
DIR=$(dirname "$0")
exec "$DIR/java" -m br.com.exemplo.farol/br.com.exemplo.farol.app.Principal "$@"

Com o exec, a JVM passa a ser o próprio processo que o shell era, com o mesmo número, e recebe os sinais diretamente. Num Dockerfile, a alternativa ainda mais simples é chamar o java direto na forma de lista do ENTRYPOINT, ENTRYPOINT ["/opt/farol/bin/java", "-m", "br.com.exemplo.farol/br.com.exemplo.farol.app.Principal"], que não passa por shell nenhum. É o que "Do Laptop ao Servidor" vai fazer na Trilha A.

Comentários

Mais em Java

Não Crie Threads, Entregue Tarefas — ExecutorService e Futures
Não Crie Threads, Entregue Tarefas — ExecutorService e Futures

Como entregar tarefas a um pool de threads em vez de criar threads à mão…

Uma Linguagem para Torradeiras que Conquistou o Mundo — a História do Java
Uma Linguagem para Torradeiras que Conquistou o Mundo — a História do Java

Como um projeto da Sun para eletrodomésticos fracassou, foi salvo pela web e…

Um Valor que Pode Não Existir — Optional e a Guerra contra o null
Um Valor que Pode Não Existir — Optional e a Guerra contra o null

Por que a referência nula é o erro de um bilhão de dólares e como o Optional…