A Primeira Pedra — ServerSocket e a Leitura do HTTP/1.1

A Primeira Pedra — ServerSocket e a Leitura do HTTP/1.1

Nesta aula nasce o projeto do Farol: um leitor de requisições que trabalha direto sobre os bytes do socket, com limites e respostas de erro precisas, um escritor de respostas que não aceita injeção de cabeçalhos e um servidor que nenhum cliente consegue derrubar.
Java

• • 42 min de leitura

Nesta aula nasce o projeto do Farol: um leitor de requisições que trabalha direto sobre os bytes do socket, com limites e respostas de erro precisas, um escritor de respostas que não aceita injeção de cabeçalhos e um servidor que nenhum cliente consegue derrubar.

"O Projeto Final Começa" terminou com um rascunho de trinta linhas que respondia ao curl, mas caía de três formas: um cliente calado travava todos os outros, um cliente que desistia produzia uma requisição "null", e uma conexão fechada no meio da resposta matava o servidor inteiro. Também mostrou que o BufferedReader do rascunho engole o corpo das requisições junto com os cabeçalhos. Esta aula constrói as duas camadas de baixo da arquitetura do Farol, o protocolo e o começo da rede, e deixa o servidor robusto, embora ainda atendendo uma conexão por vez. A concorrência fica para "A Terceira Pedra"; aqui a prioridade é que cada byte seja lido corretamente e que nenhuma entrada, por pior que seja, cause um estrago maior do que uma resposta de erro.

O projeto

O Farol é um projeto Maven, como os de "Construção Automatizada", com um módulo, como em "Fronteiras Explícitas". A estrutura de pastas segue as camadas desenhadas na aula anterior:

farol/
├── pom.xml
└── src/
    ├── main/java/
    │   ├── module-info.java
    │   └── br/com/exemplo/farol/
    │       ├── http/        Cabecalhos, Corpo, Requisicao, Resposta, Manipulador
    │       ├── protocolo/   Limites, ErroDeProtocolo, EntradaHttp, LeitorDeRequisicao, EscritorDeResposta
    │       ├── nucleo/      Servidor, Registro
    │       └── app/         Principal
    └── test/java/br/com/exemplo/farol/
        ├── protocolo/       LeitorDeRequisicaoTest
        └── nucleo/          ServidorTest

O pom.xml não tem nenhuma dependência de produção. As únicas bibliotecas são as de teste, nas versões usadas em "Provando que Funciona":

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>br.com.exemplo</groupId>
    <artifactId>farol</artifactId>
    <version>0.1.0</version>

    <properties>
        <maven.compiler.release>25</maven.compiler.release>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.junit</groupId>
                <artifactId>junit-bom</artifactId>
                <version>6.0.0</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>3.27.3</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.5.3</version>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>3.4.2</version>
                <configuration>
                    <archive>
                        <manifest>
                            <mainClass>br.com.exemplo.farol.app.Principal</mainClass>
                        </manifest>
                    </archive>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

O module-info.java exporta as camadas que outra aplicação poderia usar e mantém o pacote app fechado:

module br.com.exemplo.farol {
    exports br.com.exemplo.farol.http;
    exports br.com.exemplo.farol.protocolo;
    exports br.com.exemplo.farol.nucleo;
}

Os tipos que as camadas trocam

A camada de protocolo precisa entregar alguma coisa ao resto do servidor. Nesta aula, os tipos do pacote http são propositalmente simples; "A Segunda Pedra" vai refiná-los.

Os cabeçalhos têm duas particularidades que um Map<String, String> comum não atende: os nomes não diferenciam maiúsculas de minúsculas, e o mesmo nome pode aparecer mais de uma vez. A classe Cabecalhos resolve as duas com um TreeMap ordenado por String.CASE_INSENSITIVE_ORDER, o comparador de "Ordem e Critério", e é imutável, com um Construtor para a montagem, no padrão de "Lições de Quem Veio Antes":

package br.com.exemplo.farol.http;

import java.util.*;

/**
 * Cabeçalhos HTTP: imutáveis, com nomes que não diferenciam maiúsculas de minúsculas
 * e com a possibilidade de o mesmo nome aparecer mais de uma vez.
 */
public final class Cabecalhos {
    public static final Cabecalhos VAZIOS = new Cabecalhos(new TreeMap<>(String.CASE_INSENSITIVE_ORDER));

    private final SortedMap<String, List<String>> valores;

    private Cabecalhos(SortedMap<String, List<String>> valores) {
        this.valores = valores;          // recebido já copiado e imutável: construtor privado
    }

    /** Todos os valores de um nome, na ordem em que chegaram (lista vazia se não houver). */
    public List<String> todos(String nome) {
        return valores.getOrDefault(nome, List.of());
    }

    /** O valor de um nome; se ele aparecer mais de uma vez, os valores são unidos com ", ". */
    public Optional<String> valor(String nome) {
        List<String> lista = todos(nome);
        return lista.isEmpty() ? Optional.empty() : Optional.of(String.join(", ", lista));
    }

    public boolean contem(String nome) {
        return valores.containsKey(nome);
    }

    public Set<String> nomes() {
        return Collections.unmodifiableSet(valores.keySet());
    }

    /** Uma cópia com mais um valor para o nome dado. */
    public Cabecalhos com(String nome, String valor) {
        return construtor().copiar(this).adicionar(nome, valor).criar();
    }

    /** Uma cópia em que o nome dado tem exatamente este valor (substitui os anteriores). */
    public Cabecalhos comUnico(String nome, String valor) {
        Construtor c = construtor().copiar(this);
        c.valores.remove(nome);
        return c.adicionar(nome, valor).criar();
    }

    public static Construtor construtor() {
        return new Construtor();
    }

    @Override public boolean equals(Object o) {
        return o instanceof Cabecalhos outro && valores.equals(outro.valores);
    }
    @Override public int hashCode() { return valores.hashCode(); }
    @Override public String toString() { return valores.toString(); }

    public static final class Construtor {
        private final SortedMap<String, List<String>> valores = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);

        private Construtor() {}

        public Construtor adicionar(String nome, String valor) {
            Objects.requireNonNull(nome, "nome");
            Objects.requireNonNull(valor, "valor");
            valores.computeIfAbsent(nome, n -> new ArrayList<>()).add(valor);
            return this;
        }

        private Construtor copiar(Cabecalhos origem) {
            origem.valores.forEach((n, vs) -> valores.put(n, new ArrayList<>(vs)));
            return this;
        }

        public int quantidade() {
            return valores.size();
        }

        public Cabecalhos criar() {
            SortedMap<String, List<String>> copia = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
            valores.forEach((n, vs) -> copia.put(n, List.copyOf(vs)));
            return new Cabecalhos(Collections.unmodifiableSortedMap(copia));
        }
    }
}

O corpo é uma sequência de bytes. "Contratos à Vista" mostrou por que um array não deve ser componente de um record: ele é mutável, e o equals gerado compara identidade. A classe Corpo encapsula o array com cópia na entrada e na saída, e oferece duas operações que não copiam, comoFluxo e escreverEm, para que o escritor possa enviar um corpo de 1 MB sem duplicá-lo na memória:

package br.com.exemplo.farol.http;

import java.io.*;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;

/** O corpo de uma mensagem HTTP: uma sequência imutável de bytes. */
public final class Corpo {
    public static final Corpo VAZIO = new Corpo(new byte[0]);

    private final byte[] bytes;

    private Corpo(byte[] bytes) {
        this.bytes = bytes;
    }

    /** Cria um corpo com uma cópia dos bytes recebidos. */
    public static Corpo de(byte[] bytes) {
        return bytes.length == 0 ? VAZIO : new Corpo(bytes.clone());
    }

    public static Corpo deTexto(String texto) {
        return de(texto.getBytes(StandardCharsets.UTF_8));   // getBytes já cria um array novo
    }

    public int tamanho() { return bytes.length; }

    public boolean vazio() { return bytes.length == 0; }

    public byte[] bytes() { return bytes.clone(); }

    public String comoTexto() { return comoTexto(StandardCharsets.UTF_8); }

    public String comoTexto(Charset codificacao) { return new String(bytes, codificacao); }

    /** Lê o corpo sem copiá-lo. */
    public InputStream comoFluxo() { return new ByteArrayInputStream(bytes); }

    /** Escreve o corpo sem copiá-lo. */
    public void escreverEm(OutputStream saida) throws IOException { saida.write(bytes); }

    @Override public boolean equals(Object o) {
        return o instanceof Corpo outro && Arrays.equals(bytes, outro.bytes);
    }
    @Override public int hashCode() { return Arrays.hashCode(bytes); }
    @Override public String toString() { return "Corpo[" + bytes.length + " bytes]"; }
}

A requisição e a resposta, por enquanto, são records com textos e números:

package br.com.exemplo.farol.http;

import java.util.Objects;

/** Uma requisição HTTP já lida e validada pela camada de protocolo. */
public record Requisicao(String metodo, String alvo, String versao, Cabecalhos cabecalhos, Corpo corpo) {
    public Requisicao {
        Objects.requireNonNull(metodo, "metodo");
        Objects.requireNonNull(alvo, "alvo");
        Objects.requireNonNull(versao, "versao");
        Objects.requireNonNull(cabecalhos, "cabecalhos");
        Objects.requireNonNull(corpo, "corpo");
    }
}
package br.com.exemplo.farol.http;

import java.util.Objects;

/** Uma resposta HTTP, pronta para ser escrita pela camada de protocolo. */
public record Resposta(int status, String razao, Cabecalhos cabecalhos, Corpo corpo) {
    public Resposta {
        if (status < 100 || status > 599) throw new IllegalArgumentException("status inválido: " + status);
        Objects.requireNonNull(razao, "razao");
        Objects.requireNonNull(cabecalhos, "cabecalhos");
        Objects.requireNonNull(corpo, "corpo");
    }

    public static Resposta texto(int status, String razao, String texto) {
        return new Resposta(status, razao,
                Cabecalhos.VAZIOS.com("Content-Type", "text/plain; charset=utf-8"),
                Corpo.deTexto(texto));
    }

    public Resposta comCabecalho(String nome, String valor) {
        return new Resposta(status, razao, cabecalhos.comUnico(nome, valor), corpo);
    }
}

E a interface que a aplicação implementa, apresentada em "O Projeto Final Começa":

package br.com.exemplo.farol.http;

/** O código que atende uma requisição: tudo o que uma aplicação precisa implementar. */
@FunctionalInterface
public interface Manipulador {
    Resposta atender(Requisicao requisicao) throws Exception;
}

Repare numa mudança em relação ao desenho da aula anterior: o Manipulador saiu do pacote rotas e foi para o http. O núcleo, na camada de baixo, precisa chamar o manipulador, e se a interface ficasse na camada de rotas, o núcleo dependeria de uma camada acima dele. Colocando a interface junto dos tipos básicos, o núcleo depende só de uma abstração, e as rotas a implementam. É a inversão de dependência, o "D" do SOLID de "Lições de Quem Veio Antes", aplicada para valer.

Limites e erros

A primeira regra de um servidor exposto à internet é que toda entrada tem um tamanho máximo. Os limites ficam num record, com valores padrão parecidos com os de servidores de produção e validação no construtor compacto:

package br.com.exemplo.farol.protocolo;

import java.time.Duration;

/** Os limites que protegem o servidor de clientes mal-comportados. */
public record Limites(
        int tamanhoMaximoDaLinhaDePedido,   // excedido: 414 URI Too Long
        int tamanhoMaximoDosCabecalhos,     // soma de todas as linhas; excedido: 431
        int maximoDeCabecalhos,             // excedido: 431
        long tamanhoMaximoDoCorpo,          // excedido: 413 Content Too Large
        Duration prazoDeLeitura) {          // cliente calado por mais tempo: 408

    public static final Limites PADRAO = new Limites(8 * 1024, 16 * 1024, 100, 1024 * 1024, Duration.ofSeconds(10));

    public Limites {
        if (tamanhoMaximoDaLinhaDePedido < 16) throw new IllegalArgumentException("linha de pedido pequena demais");
        if (tamanhoMaximoDosCabecalhos < 64) throw new IllegalArgumentException("área de cabeçalhos pequena demais");
        if (maximoDeCabecalhos < 1) throw new IllegalArgumentException("é preciso aceitar ao menos 1 cabeçalho");
        if (tamanhoMaximoDoCorpo < 0) throw new IllegalArgumentException("tamanho de corpo negativo");
        if (prazoDeLeitura.isNegative() || prazoDeLeitura.isZero())
            throw new IllegalArgumentException("o prazo de leitura precisa ser positivo");
    }

    public Limites comPrazoDeLeitura(Duration prazo) {
        return new Limites(tamanhoMaximoDaLinhaDePedido, tamanhoMaximoDosCabecalhos, maximoDeCabecalhos,
                tamanhoMaximoDoCorpo, prazo);
    }

    public Limites comTamanhoMaximoDoCorpo(long bytes) {
        return new Limites(tamanhoMaximoDaLinhaDePedido, tamanhoMaximoDosCabecalhos, maximoDeCabecalhos,
                bytes, prazoDeLeitura);
    }
}

Quando uma requisição viola o protocolo ou um limite, o leitor lança uma ErroDeProtocolo. Ela é uma exceção verificada, pelo critério de "Quando o Contrato se Quebra": uma requisição inválida não é um defeito do Farol, e sim uma situação esperada que quem chama o leitor é obrigado a tratar. E ela carrega o código de estado a devolver, para que o servidor não precise adivinhar:

package br.com.exemplo.farol.protocolo;

/**
 * Uma requisição que não pode ser atendida por culpa do cliente.
 * Carrega o código de estado que o servidor deve devolver.
 */
public final class ErroDeProtocolo extends Exception {
    private static final long serialVersionUID = 1L;

    private final int status;
    private final String razao;

    public ErroDeProtocolo(int status, String razao, String detalhe) {
        super(status + " " + razao + ": " + detalhe);
        this.status = status;
        this.razao = razao;
    }

    public int status() { return status; }
    public String razao() { return razao; }

    // Fábricas com nome para os erros usados pelo leitor
    static ErroDeProtocolo requisicaoInvalida(String detalhe) { return new ErroDeProtocolo(400, "Bad Request", detalhe); }
    static ErroDeProtocolo alvoLongoDemais(String detalhe) { return new ErroDeProtocolo(414, "URI Too Long", detalhe); }
    static ErroDeProtocolo cabecalhosGrandesDemais(String detalhe) { return new ErroDeProtocolo(431, "Request Header Fields Too Large", detalhe); }
    static ErroDeProtocolo corpoGrandeDemais(String detalhe) { return new ErroDeProtocolo(413, "Content Too Large", detalhe); }
    static ErroDeProtocolo tamanhoObrigatorio(String detalhe) { return new ErroDeProtocolo(411, "Length Required", detalhe); }
    static ErroDeProtocolo versaoNaoSuportada(String detalhe) { return new ErroDeProtocolo(505, "HTTP Version Not Supported", detalhe); }
}

O serialVersionUID existe porque toda exceção é Serializable, e sem ele o compilador, com -Xlint:all, emite um aviso.

Lendo bytes, não caracteres

A peça central da aula é a classe que lê do socket. Ela resolve o problema do BufferedReader: há um único buffer, um BufferedInputStream, e as duas operações, ler uma linha e ler um bloco de bytes, tiram dados dele. O que uma leitura não usou fica no buffer para a próxima, e nada se perde entre os cabeçalhos e o corpo:

package br.com.exemplo.farol.protocolo;

import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.function.Supplier;

/**
 * Lê linhas terminadas em CRLF e blocos de bytes de um mesmo fluxo,
 * sem nunca ler além do que foi pedido.
 */
final class EntradaHttp {
    private static final int CR = '\r', LF = '\n';

    private final InputStream entrada;

    EntradaHttp(InputStream entrada) {
        // Um único buffer para linhas e corpo: nada se perde entre as duas leituras
        this.entrada = entrada instanceof BufferedInputStream ? entrada : new BufferedInputStream(entrada);
    }

    /**
     * Lê uma linha e devolve o texto sem o CRLF final, ou null se a conexão
     * terminou antes do primeiro byte da linha.
     *
     * @param limite    número máximo de bytes da linha, sem contar o CRLF
     * @param excedente fabrica o erro a lançar se o limite for ultrapassado
     */
    String lerLinha(int limite, Supplier<ErroDeProtocolo> excedente) throws IOException, ErroDeProtocolo {
        ByteArrayOutputStream linha = new ByteArrayOutputStream(Math.min(limite, 256));
        int b = entrada.read();
        if (b == -1) return null;                        // fim limpo: nenhum byte desta linha chegou
        while (true) {
            if (b == -1) throw ErroDeProtocolo.requisicaoInvalida("conexão encerrada no meio de uma linha");
            if (b == LF) break;                          // aceita LF sozinho, como a RFC 9112 permite
            if (b == CR) {
                int seguinte = entrada.read();
                if (seguinte == LF) break;
                throw ErroDeProtocolo.requisicaoInvalida("CR sem LF dentro de uma linha");
            }
            if (linha.size() == limite) throw excedente.get();
            linha.write(b);
            b = entrada.read();
        }
        // ISO-8859-1: um byte, um caractere; nenhum byte se perde na conversão
        return linha.toString(StandardCharsets.ISO_8859_1);
    }

    /** Lê exatamente n bytes, ou falha se a conexão terminar antes. */
    byte[] lerExatamente(int n) throws IOException, ErroDeProtocolo {
        byte[] bytes = entrada.readNBytes(n);
        if (bytes.length < n)
            throw ErroDeProtocolo.requisicaoInvalida("corpo incompleto: esperados " + n + " bytes, chegaram " + bytes.length);
        return bytes;
    }
}

Ler um byte por vez parece lento, mas não é: o read() de um BufferedInputStream só vai ao sistema operacional quando o buffer interno, de 8 KB, se esvazia. As outras chamadas são uma leitura de array e um incremento de índice, que o JIT reduz a quase nada.

Quatro decisões de desenho merecem destaque. A primeira é a distinção entre fim limpo e fim sujo: se a conexão termina antes de qualquer byte da linha, o método devolve null, porque o cliente simplesmente foi embora, e isso não é um erro; se termina no meio de uma linha, é uma requisição quebrada, e merece um 400. É essa distinção que elimina a requisição "null" do rascunho. A segunda é o limite verificado a cada byte: uma linha de dois gigabytes é recusada assim que passa do limite, sem que o servidor guarde mais do que o limite na memória. A terceira é a tolerância a LF sozinho, que a RFC 9112 permite aos servidores aceitarem, com a recusa de um CR solto no meio da linha, um truque usado em alguns ataques. A quarta é o Supplier<ErroDeProtocolo>: em vez de receber o erro pronto, o método recebe uma fábrica, e a exceção só é criada se o limite for de fato ultrapassado. Criar uma exceção custa caro, porque a JVM preenche a pilha de chamadas, e o exercício 4 mede quanto.

O leitor de requisições

Com a entrada pronta, o leitor segue a gramática da RFC 9112, parte por parte:

package br.com.exemplo.farol.protocolo;

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

import java.io.*;
import java.util.*;
import java.util.regex.Pattern;

/**
 * Transforma os bytes que chegam de uma conexão em objetos Requisicao,
 * seguindo a RFC 9112 e os limites configurados.
 */
public final class LeitorDeRequisicao {
    // tchar da RFC 9110: os caracteres permitidos em métodos e nomes de cabeçalho
    private static final Pattern TOKEN = Pattern.compile("[!#$%&'*+.^_`|~0-9A-Za-z-]+");
    private static final Pattern VERSAO = Pattern.compile("HTTP/\\d\\.\\d");
    private static final int LINHAS_VAZIAS_TOLERADAS = 2;

    private final EntradaHttp entrada;
    private final Limites limites;

    public LeitorDeRequisicao(InputStream entrada, Limites limites) {
        this.entrada = new EntradaHttp(entrada);
        this.limites = Objects.requireNonNull(limites);
    }

    /**
     * Lê a próxima requisição da conexão.
     *
     * @return a requisição, ou vazio se o cliente encerrou a conexão antes de começar outra
     * @throws ErroDeProtocolo se a requisição violar o protocolo ou os limites
     * @throws IOException     se a conexão falhar (inclusive por prazo esgotado)
     */
    public Optional<Requisicao> ler() throws IOException, ErroDeProtocolo {
        String linhaDePedido = lerLinhaDePedido();
        if (linhaDePedido == null) return Optional.empty();

        // 1. A linha de pedido: exatamente três campos separados por um espaço
        String[] partes = linhaDePedido.split(" ", -1);
        if (partes.length != 3)
            throw ErroDeProtocolo.requisicaoInvalida("linha de pedido malformada: '" + linhaDePedido + "'");
        String metodo = partes[0], alvo = partes[1], versao = partes[2];
        if (!TOKEN.matcher(metodo).matches())
            throw ErroDeProtocolo.requisicaoInvalida("método inválido: '" + metodo + "'");
        if (!alvo.startsWith("/"))
            throw ErroDeProtocolo.requisicaoInvalida("alvo deve começar com '/': '" + alvo + "'");
        validarVersao(versao);

        // 2. Os cabeçalhos, até a linha vazia
        Cabecalhos cabecalhos = lerCabecalhos();
        if (versao.equals("HTTP/1.1")) {
            int hosts = cabecalhos.todos("Host").size();
            if (hosts != 1)
                throw ErroDeProtocolo.requisicaoInvalida("HTTP/1.1 exige exatamente um cabeçalho Host, veio " + hosts);
        }

        // 3. O corpo, se houver
        Corpo corpo = lerCorpo(cabecalhos);
        return Optional.of(new Requisicao(metodo, alvo, versao, cabecalhos, corpo));
    }

    private String lerLinhaDePedido() throws IOException, ErroDeProtocolo {
        int limite = limites.tamanhoMaximoDaLinhaDePedido();
        // A RFC 9112 recomenda ignorar linhas vazias antes da linha de pedido
        for (int i = 0; i <= LINHAS_VAZIAS_TOLERADAS; i++) {
            String linha = entrada.lerLinha(limite,
                    () -> ErroDeProtocolo.alvoLongoDemais("linha de pedido passou de " + limite + " bytes"));
            if (linha == null) return null;
            if (!linha.isEmpty()) return linha;
        }
        throw ErroDeProtocolo.requisicaoInvalida("linhas vazias demais antes da linha de pedido");
    }

    private void validarVersao(String versao) throws ErroDeProtocolo {
        if (!VERSAO.matcher(versao).matches())
            throw ErroDeProtocolo.requisicaoInvalida("versão malformada: '" + versao + "'");
        if (!versao.equals("HTTP/1.1") && !versao.equals("HTTP/1.0"))
            throw ErroDeProtocolo.versaoNaoSuportada(versao);
    }

    private Cabecalhos lerCabecalhos() throws IOException, ErroDeProtocolo {
        Cabecalhos.Construtor construtor = Cabecalhos.construtor();
        int restante = limites.tamanhoMaximoDosCabecalhos();
        int linhas = 0;
        while (true) {
            int limiteDestaLinha = Math.max(0, restante);
            String linha = entrada.lerLinha(limiteDestaLinha,
                    () -> ErroDeProtocolo.cabecalhosGrandesDemais("cabeçalhos passaram de "
                            + limites.tamanhoMaximoDosCabecalhos() + " bytes"));
            if (linha == null) throw ErroDeProtocolo.requisicaoInvalida("conexão encerrada nos cabeçalhos");
            if (linha.isEmpty()) return construtor.criar();         // a linha vazia encerra os cabeçalhos

            restante -= linha.length() + 2;
            if (++linhas > limites.maximoDeCabecalhos())
                throw ErroDeProtocolo.cabecalhosGrandesDemais("mais de " + limites.maximoDeCabecalhos() + " cabeçalhos");

            if (linha.charAt(0) == ' ' || linha.charAt(0) == '\t')
                throw ErroDeProtocolo.requisicaoInvalida("cabeçalho dobrado em várias linhas (obsoleto)");
            int doisPontos = linha.indexOf(':');
            if (doisPontos <= 0)
                throw ErroDeProtocolo.requisicaoInvalida("cabeçalho sem nome ou sem ':': '" + linha + "'");
            String nome = linha.substring(0, doisPontos);
            if (!TOKEN.matcher(nome).matches())   // inclui o caso de espaço antes dos dois-pontos
                throw ErroDeProtocolo.requisicaoInvalida("nome de cabeçalho inválido: '" + nome + "'");
            String valor = linha.substring(doisPontos + 1).strip();
            construtor.adicionar(nome, valor);
        }
    }

    private Corpo lerCorpo(Cabecalhos cabecalhos) throws IOException, ErroDeProtocolo {
        boolean temTransferEncoding = cabecalhos.contem("Transfer-Encoding");
        List<String> tamanhos = cabecalhos.todos("Content-Length");

        if (temTransferEncoding && !tamanhos.isEmpty())
            // Os dois juntos são a base do ataque de "contrabando de requisições"
            throw ErroDeProtocolo.requisicaoInvalida("Transfer-Encoding e Content-Length na mesma requisição");
        if (temTransferEncoding)
            // Simplificação do Farol: corpos em partes (chunked) não são aceitos
            throw ErroDeProtocolo.tamanhoObrigatorio("envie o corpo com Content-Length");
        if (tamanhos.isEmpty()) return Corpo.VAZIO;           // sem os dois cabeçalhos, não há corpo

        long tamanho = tamanhoDeclarado(tamanhos);
        if (tamanho > limites.tamanhoMaximoDoCorpo())
            throw ErroDeProtocolo.corpoGrandeDemais(tamanho + " bytes; o máximo é " + limites.tamanhoMaximoDoCorpo());
        return tamanho == 0 ? Corpo.VAZIO : Corpo.de(entrada.lerExatamente((int) tamanho));
    }

    /** Content-Length pode vir repetido ou como lista ("5, 5"), mas todos os valores precisam ser iguais. */
    private static long tamanhoDeclarado(List<String> valores) throws ErroDeProtocolo {
        Set<String> distintos = new HashSet<>();
        for (String valor : valores)
            for (String parte : valor.split(",", -1)) distintos.add(parte.strip());
        if (distintos.size() != 1)
            throw ErroDeProtocolo.requisicaoInvalida("Content-Length com valores diferentes: " + valores);
        String texto = distintos.iterator().next();
        if (texto.isEmpty() || texto.length() > 18 || !texto.chars().allMatch(c -> c >= '0' && c <= '9'))
            throw ErroDeProtocolo.requisicaoInvalida("Content-Length inválido: '" + texto + "'");
        return Long.parseLong(texto);
    }
}

A linha de pedido é dividida com split(" ", -1): o -1 preserva os campos vazios, então GET / HTTP/1.1, com dois espaços, produz quatro partes e é recusado, como a gramática exige. O método precisa ser um token, o conjunto de caracteres que a RFC 9110 chama de tchar, expresso aqui numa expressão regular de "Tempo, Dinheiro e Padrões". Uma versão que tem o formato certo mas não é 1.0 nem 1.1 recebe 505; uma versão malformada, 400.

Os cabeçalhos têm três regras que parecem detalhes e são, na verdade, defesas. Um espaço entre o nome e os dois-pontos (Host : x) é recusado, porque a RFC 9112 exige isso: servidores e proxies diferentes interpretavam esse caso de formas diferentes, e a divergência era explorada em ataques. Linhas que começam com espaço, a antiga "dobra" de cabeçalhos em várias linhas, também são recusadas, pelo mesmo motivo. E o tamanho é verificado de duas formas: pela soma de bytes de todas as linhas, através da variável restante, que vira o limite da linha seguinte, e pelo número de linhas. O HTTP/1.1 exige exatamente um Host, e o leitor verifica isso depois de ler todos.

O corpo é onde mora o ataque mais sério contra servidores HTTP, o contrabando de requisições (request smuggling). Uma requisição pode indicar o tamanho do corpo de duas formas, Content-Length e Transfer-Encoding: chunked, e se um proxy usa uma e o servidor usa a outra, o atacante consegue esconder uma segunda requisição dentro do corpo da primeira, que o proxy nunca inspecionou. A defesa é recusar qualquer requisição com os dois cabeçalhos, e recusar Content-Length com valores diferentes. O tamanhoDeclarado aceita a forma repetida (Content-Length: 5 duas vezes) e a forma de lista (5, 5), desde que todos os valores sejam iguais, e só aceita dígitos: Long.parseLong sozinho aceitaria +5 e -1.

Há aqui uma simplificação honesta: a RFC 9112 exige que todo servidor HTTP/1.1 entenda corpos em partes (chunked), e o Farol não entende. Em vez de fingir, ele responde 411, "Length Required", pedindo ao cliente que reenvie com Content-Length. Clientes como o curl e os navegadores sempre enviam o tamanho quando o conhecem, e na prática o caso raramente aparece. Implementar o formato em partes é um bom exercício depois do curso.

Sem nenhum dos dois cabeçalhos, a requisição não tem corpo: essa é a regra do protocolo para requisições. E o corpo é lido com lerExatamente, os Content-Length bytes, nem um a mais. Os bytes seguintes, se houver, pertencem à próxima requisição da mesma conexão, o que vai importar muito em "A Terceira Pedra".

O escritor de respostas

O caminho de volta é mais simples, mas tem as suas armadilhas:

package br.com.exemplo.farol.protocolo;

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

import java.io.*;
import java.nio.charset.StandardCharsets;
import java.time.*;
import java.time.format.DateTimeFormatter;

/** Transforma um objeto Resposta nos bytes que o cliente vai receber. */
public final class EscritorDeResposta {
    private final Clock relogio;

    public EscritorDeResposta(Clock relogio) {
        this.relogio = relogio;
    }

    public EscritorDeResposta() {
        this(Clock.systemUTC());
    }

    /**
     * Escreve a resposta completa.
     *
     * @param semCorpo  true para respostas a HEAD: cabeçalhos iguais, corpo omitido
     * @param fechar    true para avisar o cliente de que a conexão será encerrada
     */
    public void escrever(Resposta resposta, OutputStream saida, boolean semCorpo, boolean fechar) throws IOException {
        StringBuilder cabeca = new StringBuilder(256);
        cabeca.append("HTTP/1.1 ").append(resposta.status()).append(' ');
        linha(cabeca, resposta.razao());

        Cabecalhos cabecalhos = resposta.cabecalhos();
        for (String nome : cabecalhos.nomes()) {
            if (nome.equalsIgnoreCase("Content-Length") || nome.equalsIgnoreCase("Connection")
                    || nome.equalsIgnoreCase("Date") || nome.equalsIgnoreCase("Server")) continue;  // o escritor controla estes
            for (String valor : cabecalhos.todos(nome)) {
                cabeca.append(nome).append(": ");
                linha(cabeca, valor);
            }
        }
        cabeca.append("Content-Length: ").append(resposta.corpo().tamanho()).append("\r\n");
        cabeca.append("Date: ").append(DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now(relogio))).append("\r\n");
        cabeca.append("Server: Farol\r\n");
        if (fechar) cabeca.append("Connection: close\r\n");
        cabeca.append("\r\n");

        saida.write(cabeca.toString().getBytes(StandardCharsets.ISO_8859_1));
        if (!semCorpo) resposta.corpo().escreverEm(saida);
        saida.flush();
    }

    /** Acrescenta um texto seguido de CRLF, recusando quebras de linha dentro dele. */
    private static void linha(StringBuilder destino, String texto) {
        for (int i = 0; i < texto.length(); i++) {
            char c = texto.charAt(i);
            if (c == '\r' || c == '\n')
                // Sem esta verificação, um valor vindo do usuário poderia injetar cabeçalhos
                throw new IllegalArgumentException("quebra de linha em cabeçalho: " + texto.replace("\r", "\\r").replace("\n", "\\n"));
            if (c > 0xFF)
                throw new IllegalArgumentException("caractere fora do ISO-8859-1 em cabeçalho: " + texto);
        }
        destino.append(texto).append("\r\n");
    }
}

O escritor controla quatro cabeçalhos, que a aplicação não pode definir. O Content-Length é calculado a partir dos bytes do corpo, e nunca informado à mão, o que elimina o defeito do exercício 2 da aula anterior. O Date, que a RFC 9110 exige de servidores com relógio, usa o formato RFC 1123 do java.time e um Clock injetado, como em "Dublês e Disciplina", para que os testes possam fixar a hora. O Server identifica o Farol. E o Connection: close depende da decisão de fechar a conexão.

O método linha fecha uma brecha antiga chamada divisão de resposta (HTTP response splitting). Imagine uma rota que redireciona para um endereço vindo da própria requisição, colocando-o no cabeçalho Location. Se o endereço contiver \r\n, o atacante consegue terminar o cabeçalho e escrever outros, como Set-Cookie, na resposta que o navegador de outra pessoa vai receber. Recusar quebras de linha em qualquer valor de cabeçalho elimina o problema na raiz, num único lugar, em vez de depender de cada rota lembrar de validar.

O servidor que não cai

Com leitor e escritor, o núcleo desta aula é um laço que aceita uma conexão por vez e trata cada uma dentro de uma rede de proteção:

package br.com.exemplo.farol.nucleo;

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

import java.io.*;
import java.net.*;
import java.util.Objects;
import java.util.Optional;

/**
 * Primeira versão do núcleo do Farol: uma conexão por vez, uma requisição por conexão,
 * mas sem que nenhum cliente consiga derrubar o servidor.
 */
public final class Servidor implements AutoCloseable {
    private final ServerSocket soqueteDoServidor;
    private final Limites limites;
    private final Manipulador manipulador;
    private final EscritorDeResposta escritor = new EscritorDeResposta();

    public Servidor(int porta, Limites limites, Manipulador manipulador) throws IOException {
        this.limites = Objects.requireNonNull(limites);
        this.manipulador = Objects.requireNonNull(manipulador);
        this.soqueteDoServidor = new ServerSocket(porta);   // porta 0: o sistema escolhe uma livre
    }

    public int porta() {
        return soqueteDoServidor.getLocalPort();
    }

    /** Aceita e atende conexões até o servidor ser fechado. */
    public void atenderParaSempre() {
        Registro.info("Farol ouvindo em http://localhost:" + porta());
        while (!soqueteDoServidor.isClosed()) {
            try {
                Socket conexao = soqueteDoServidor.accept();
                atender(conexao);
            } catch (IOException e) {
                if (!soqueteDoServidor.isClosed()) Registro.erro("falha ao aceitar conexão", e);
            }
        }
        Registro.info("Farol desligado");
    }

    private void atender(Socket soquete) {
        try (soquete) {
            soquete.setSoTimeout((int) limites.prazoDeLeitura().toMillis());
            var leitor = new LeitorDeRequisicao(soquete.getInputStream(), limites);
            var saida = new BufferedOutputStream(soquete.getOutputStream());

            Optional<Requisicao> lida;
            try {
                lida = leitor.ler();
            } catch (ErroDeProtocolo e) {
                Registro.info("recusada: " + e.getMessage());
                responder(Resposta.texto(e.status(), e.razao(), e.getMessage() + "\n"), saida, false);
                return;
            } catch (SocketTimeoutException e) {
                Registro.info("cliente calado por " + limites.prazoDeLeitura().toMillis() + " ms");
                responder(Resposta.texto(408, "Request Timeout", "tempo esgotado\n"), saida, false);
                return;
            }
            if (lida.isEmpty()) return;                           // o cliente desistiu sem pedir nada

            Requisicao requisicao = lida.get();
            Resposta resposta = executar(requisicao);
            responder(resposta, saida, requisicao.metodo().equals("HEAD"));
            Registro.info(requisicao.metodo() + " " + requisicao.alvo() + " → " + resposta.status());
        } catch (IOException e) {
            Registro.info("conexão perdida: " + e.getMessage());   // o cliente foi embora; o servidor segue
        } catch (RuntimeException e) {
            Registro.erro("falha inesperada numa conexão", e);      // um defeito do Farol, não do cliente
        }
    }

    private Resposta executar(Requisicao requisicao) {
        try {
            return manipulador.atender(requisicao);
        } catch (Exception e) {
            Registro.erro("o manipulador falhou em " + requisicao.metodo() + " " + requisicao.alvo(), e);
            return Resposta.texto(500, "Internal Server Error", "erro interno\n");   // sem detalhes para o cliente
        }
    }

    private void responder(Resposta resposta, OutputStream saida, boolean semCorpo) throws IOException {
        escritor.escrever(resposta, saida, semCorpo, true);    // nesta versão, toda conexão é fechada
    }

    @Override
    public void close() throws IOException {
        soqueteDoServidor.close();                            // o accept() bloqueado lança SocketException e o laço termina
    }
}

Cada uma das três formas de derrubar o rascunho tem agora uma resposta. O cliente calado esbarra no setSoTimeout: depois do prazo, a leitura lança SocketTimeoutException, o cliente recebe 408 e a conexão é fechada. O cliente que desiste produz um Optional vazio, e o servidor simplesmente segue para a próxima conexão. E qualquer falha de entrada e saída fica presa no catch (IOException e) de atender, sem subir até o laço principal. O catch (RuntimeException e) é a última rede: se houver um defeito no próprio Farol, ele é registrado, a conexão é fechada, e o servidor continua.

As falhas do manipulador têm tratamento separado, em executar. O catch (Exception e) amplo, que "Quando o Contrato se Quebra" desaconselha na maior parte do código, é aqui exatamente o certo: o servidor é a fronteira entre o código da aplicação e a rede, e nenhum erro da aplicação pode escapar dessa fronteira. O cliente recebe um 500 genérico, e os detalhes vão só para o registro do servidor.

O close() mostra uma técnica comum com sockets: para interromper um accept() bloqueado, fecha-se o ServerSocket, e o accept() lança SocketException. O laço percebe que o soquete está fechado e termina sem registrar erro.

A classe Registro é um registro mínimo, com hora e nome da thread, que será útil quando houver milhares delas:

package br.com.exemplo.farol.nucleo;

import java.time.LocalTime;
import java.time.format.DateTimeFormatter;

/** Um registro mínimo: hora, thread e mensagem, numa linha. */
final class Registro {
    private static final DateTimeFormatter HORA = DateTimeFormatter.ofPattern("HH:mm:ss.SSS");

    private Registro() {}

    static void info(String mensagem) {
        System.out.println(HORA.format(LocalTime.now()) + " [" + nomeDaThread() + "] " + mensagem);
    }

    static void erro(String mensagem, Throwable causa) {
        System.out.println(HORA.format(LocalTime.now()) + " [" + nomeDaThread() + "] ERRO " + mensagem + ": " + causa);
    }

    private static String nomeDaThread() {
        Thread t = Thread.currentThread();
        return t.getName().isEmpty() ? (t.isVirtual() ? "virtual-" : "thread-") + t.threadId() : t.getName();
    }
}

Pondo para funcionar

Por enquanto, a aplicação tem um único manipulador, que descreve o que recebeu:

package br.com.exemplo.farol.app;

import br.com.exemplo.farol.http.*;
import br.com.exemplo.farol.nucleo.Servidor;
import br.com.exemplo.farol.protocolo.Limites;

public class Principal {
    public static void main(String[] args) throws Exception {
        int porta = args.length > 0 ? Integer.parseInt(args[0]) : 8080;

        // Por enquanto, um único manipulador que descreve o que recebeu
        Manipulador eco = req -> Resposta.texto(200, "OK",
                "Farol recebeu " + req.metodo() + " " + req.alvo() + " (" + req.versao() + ")\n"
                + "cabeçalhos: " + req.cabecalhos().nomes() + "\n"
                + "corpo: " + req.corpo().tamanho() + " bytes: " + req.corpo().comoTexto() + "\n");

        try (Servidor farol = new Servidor(porta, Limites.PADRAO, eco)) {
            farol.atenderParaSempre();
        }
    }
}

Compilado com mvn package e executado como módulo:

$ java -p target/farol-0.1.0.jar -m br.com.exemplo.farol/br.com.exemplo.farol.app.Principal
14:59:40.146 [main] Farol ouvindo em http://localhost:8080

Três requisições válidas, com o curl:

$ curl -i "http://localhost:8080/tarefas?lista=casa"
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 106
Date: Tue, 29 Sep 2026 17:59:41 GMT
Server: Farol
Connection: close

Farol recebeu GET /tarefas?lista=casa (HTTP/1.1)
cabeçalhos: [Accept, Host, User-Agent]
corpo: 0 bytes: 

$ curl -X POST -H "Content-Type: text/plain" -d "Comprar pão" http://localhost:8080/tarefas
Farol recebeu POST /tarefas (HTTP/1.1)
cabeçalhos: [Accept, Content-Length, Content-Type, Host, User-Agent]
corpo: 12 bytes: Comprar pão

$ curl -I http://localhost:8080/
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 89
Date: Tue, 29 Sep 2026 17:59:41 GMT
Server: Farol
Connection: close

O corpo "Comprar pão" chegou com seus 12 bytes intactos, e o HEAD recebeu os mesmos cabeçalhos de um GET, com o Content-Length de 89, mas sem o corpo, como a RFC 9110 pede.

Para as requisições inválidas, o curl é educado demais: ele sempre monta requisições corretas. O próprio bash consegue abrir uma conexão TCP pelo caminho especial /dev/tcp, e isso permite enviar bytes arbitrários:

enviar() { exec 3<>/dev/tcp/127.0.0.1/8080; printf "$1" >&3; timeout 3 cat <&3; exec 3<&-; }
enviar 'GET /tarefas\r\nHost: x\r\n\r\n'
enviar 'GET / HTTP/1.1\r\n\r\n'
enviar 'GET / HTTP/2.0\r\nHost: x\r\n\r\n'
enviar 'POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 5000000000\r\n\r\n'
enviar 'POST / HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n'
enviar 'GET / HTTP/1.1\r\nHost : x\r\n\r\n'

Cada uma recebe a resposta certa, e o registro do servidor conta a história:

14:59:41.276 [main] recusada: 400 Bad Request: linha de pedido malformada: 'GET /tarefas'
14:59:41.283 [main] recusada: 400 Bad Request: HTTP/1.1 exige exatamente um cabeçalho Host, veio 0
14:59:41.286 [main] recusada: 505 HTTP Version Not Supported: HTTP/2.0
14:59:41.297 [main] recusada: 413 Content Too Large: 5000000000 bytes; o máximo é 1048576
14:59:41.299 [main] recusada: 411 Length Required: envie o corpo com Content-Length
14:59:41.302 [main] recusada: 400 Bad Request: nome de cabeçalho inválido: 'Host '

O corpo de cinco gigabytes foi recusado só pelo cabeçalho, sem que um único byte dele fosse lido.

As três formas de derrubar, outra vez

O teste decisivo é repetir os ataques da aula anterior. Um cliente que conecta e fica calado, lendo o que o servidor mandar, e, enquanto ele espera, um curl normal:

$ curl -s -o /dev/null -w "curl: %{http_code} em %{time_total}s\n" http://localhost:8080/fila
curl: 200 em 9.379886s

cliente lento recebeu, após 10020 ms: HTTP/1.1 408 Request Timeout
15:00:20.451 [main] cliente calado por 10000 ms
15:00:20.453 [main] GET /fila → 200

O cliente calado recebeu seu 408 depois de dez segundos, o curl foi atendido logo em seguida, e o servidor continuou de pé. Um cliente que conecta e desiste, e outro que envia o pedido e fecha a conexão antes da resposta, também não causam nada além de uma linha no registro, e a requisição seguinte é atendida normalmente.

Mas a saída mostra, com honestidade, o que ainda falta: o curl esperou 9,4 segundos por uma resposta que leva milissegundos, porque o Farol atende uma conexão por vez. O prazo limitou o estrago de um cliente calado, mas não o eliminou. Com mil clientes calados, o servidor ficaria parado por quase três horas. É o problema de "A Terceira Pedra".

Os testes

Cada regra do leitor vira um teste, e os testes não precisam de rede nenhuma: um ByteArrayInputStream com os bytes da requisição faz o papel do socket. Os limites do teste são minúsculos, para que os casos de excesso sejam fáceis de escrever:

package br.com.exemplo.farol.protocolo;

import br.com.exemplo.farol.http.Requisicao;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;

class LeitorDeRequisicaoTest {

    // Limites pequenos, para que os testes de limite sejam fáceis de escrever
    private static final Limites LIMITES = new Limites(64, 128, 5, 16, java.time.Duration.ofSeconds(1));

    private static Optional<Requisicao> ler(String bytes) throws Exception {
        var entrada = new ByteArrayInputStream(bytes.getBytes(StandardCharsets.UTF_8));
        return new LeitorDeRequisicao(entrada, LIMITES).ler();
    }

    private static int statusDoErro(String bytes) {
        return assertThrows(ErroDeProtocolo.class, () -> ler(bytes)).status();
    }

    @Nested
    class RequisicoesValidas {
        @Test
        void leLinhaDePedidoECabecalhos() throws Exception {
            Requisicao r = ler("GET /tarefas?lista=casa HTTP/1.1\r\nHost: localhost\r\nAccept: */*\r\n\r\n").orElseThrow();
            assertThat(r.metodo()).isEqualTo("GET");
            assertThat(r.alvo()).isEqualTo("/tarefas?lista=casa");
            assertThat(r.versao()).isEqualTo("HTTP/1.1");
            assertThat(r.cabecalhos().valor("accept")).hasValue("*/*");   // nome sem diferenciar caixa
            assertThat(r.corpo().vazio()).isTrue();
        }

        @Test
        void leCorpoPeloContentLengthEmBytes() throws Exception {
            // "pão" tem 3 caracteres e 4 bytes em UTF-8
            Requisicao r = ler("POST /t HTTP/1.1\r\nHost: x\r\nContent-Length: 4\r\n\r\npão").orElseThrow();
            assertThat(r.corpo().tamanho()).isEqualTo(4);
            assertThat(r.corpo().comoTexto()).isEqualTo("pão");
        }

        @Test
        void naoLeAlemDoCorpo() throws Exception {
            // Duas requisições seguidas na mesma conexão: cada leitura pega só a sua
            var entrada = new ByteArrayInputStream(("POST /a HTTP/1.1\r\nHost: x\r\nContent-Length: 2\r\n\r\nok"
                    + "GET /b HTTP/1.1\r\nHost: x\r\n\r\n").getBytes(StandardCharsets.ISO_8859_1));
            var leitor = new LeitorDeRequisicao(entrada, LIMITES);
            assertThat(leitor.ler().orElseThrow().corpo().comoTexto()).isEqualTo("ok");
            assertThat(leitor.ler().orElseThrow().alvo()).isEqualTo("/b");
            assertThat(leitor.ler()).isEmpty();
        }

        @Test
        void conexaoFechadaSemNadaEhFimLimpo() throws Exception {
            assertThat(ler("")).isEmpty();
        }
    }

    @Nested
    class RequisicoesRecusadas {
        @Test
        void linhaDePedidoSemVersao() {
            assertThat(statusDoErro("GET /tarefas\r\nHost: x\r\n\r\n")).isEqualTo(400);
        }

        @Test
        void alvoLongoDemais() {
            assertThat(statusDoErro("GET /" + "a".repeat(100) + " HTTP/1.1\r\nHost: x\r\n\r\n")).isEqualTo(414);
        }

        @Test
        void cabecalhosGrandesDemais() {
            assertThat(statusDoErro("GET / HTTP/1.1\r\nHost: x\r\nX-Grande: " + "b".repeat(200) + "\r\n\r\n")).isEqualTo(431);
        }

        @Test
        void corpoGrandeDemais() {
            assertThat(statusDoErro("POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 17\r\n\r\n")).isEqualTo(413);
        }

        @Test
        void transferEncodingEContentLengthJuntos() {
            assertThat(statusDoErro("POST / HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\nContent-Length: 3\r\n\r\n")).isEqualTo(400);
        }

        @Test
        void corpoIncompleto() {
            assertThat(statusDoErro("POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 10\r\n\r\nabc")).isEqualTo(400);
        }
    }
}

O arquivo completo tem 21 testes, no mesmo formato: além dos acima, a tolerância a LF sozinho e a linhas vazias antes do pedido, o HTTP/1.0 sem Host, espaços duplos na linha de pedido, versão 2.0, HTTP/1.1 sem Host, espaço antes dos dois-pontos, cabeçalhos demais, Content-Length com valores diferentes ou negativo, chunked e conexão fechada no meio dos cabeçalhos. O teste naoLeAlemDoCorpo é o mais importante de todos: ele prova que o leitor consegue ler duas requisições seguidas do mesmo fluxo, o que o BufferedReader do rascunho não conseguia, e é a base das conexões persistentes.

O servidor também tem testes, que sobem um Farol de verdade na porta 0, onde o sistema operacional escolhe uma porta livre, com um prazo de leitura curto:

package br.com.exemplo.farol.nucleo;

import br.com.exemplo.farol.http.Resposta;
import br.com.exemplo.farol.protocolo.Limites;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.io.*;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.time.Duration;

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

class ServidorTest {
    private Servidor servidor;
    private Thread threadDoServidor;

    @BeforeEach
    void subir() throws IOException {
        Limites limites = Limites.PADRAO.comPrazoDeLeitura(Duration.ofMillis(300));
        servidor = new Servidor(0, limites, req -> {
            if (req.alvo().equals("/quebra")) throw new IllegalStateException("defeito no manipulador");
            return Resposta.texto(200, "OK", "oi");
        });
        threadDoServidor = Thread.ofPlatform().name("farol-teste").start(servidor::atenderParaSempre);
    }

    @AfterEach
    void descer() throws Exception {
        servidor.close();
        threadDoServidor.join(2000);
    }

    /** Envia bytes crus e devolve tudo o que o servidor responder até fechar a conexão. */
    private String trocar(String pedido) throws IOException {
        try (Socket s = new Socket("localhost", servidor.porta())) {
            s.getOutputStream().write(pedido.getBytes(StandardCharsets.ISO_8859_1));
            return new String(s.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
        }
    }

    @Test
    void clienteCaladoRecebe408EOServidorSegue() throws IOException {
        try (Socket calado = new Socket("localhost", servidor.porta())) {
            String resposta = new String(calado.getInputStream().readAllBytes(), StandardCharsets.ISO_8859_1);
            assertThat(resposta).startsWith("HTTP/1.1 408 Request Timeout");
        }
        assertThat(trocar("GET / HTTP/1.1\r\nHost: x\r\n\r\n")).startsWith("HTTP/1.1 200");
    }

    @Test
    void clienteQueDesisteNaoDerrubaOServidor() throws IOException {
        new Socket("localhost", servidor.porta()).close();                        // conecta e vai embora
        try (Socket apressado = new Socket("localhost", servidor.porta())) {     // pede e vai embora
            apressado.getOutputStream().write("GET / HTTP/1.1\r\nHost: x\r\n\r\n".getBytes(StandardCharsets.ISO_8859_1));
        }
        assertThat(trocar("GET / HTTP/1.1\r\nHost: x\r\n\r\n")).startsWith("HTTP/1.1 200");
    }

    @Test
    void falhaNoManipuladorViraErro500SemDetalhes() throws IOException {
        String resposta = trocar("GET /quebra HTTP/1.1\r\nHost: x\r\n\r\n");
        assertThat(resposta).startsWith("HTTP/1.1 500 Internal Server Error");
        assertThat(resposta).doesNotContain("defeito no manipulador");
        assertThat(trocar("GET / HTTP/1.1\r\nHost: x\r\n\r\n")).startsWith("HTTP/1.1 200");
    }
}

Com mais dois testes, um para uma requisição simples e outro para o HEAD, o total é:

Tests run: 26, Failures: 0, Skipped: 0

As três formas de derrubar o rascunho viraram testes, e qualquer mudança futura que as reintroduza vai falhar no mvn test antes de chegar a um servidor.

O Farol agora lê o protocolo corretamente, mas os tipos que ele entrega ainda são "stringly typed": o método é uma String comparada com equals("HEAD"), o código de estado e a frase da resposta são dois valores soltos que podem discordar, e o alvo é um texto cru, com a consulta e os escapes %XX ainda por decodificar. E o servidor só tem um manipulador para tudo.

Fontes e leituras recomendadas

Exercícios

Exercício 1

O EntradaHttp lê a linha byte a byte de um BufferedInputStream. Explique por que ler byte a byte diretamente do InputStream do socket seria muito mais lento, e por que o leitor não pode criar um BufferedInputStream novo a cada chamada de lerLinha.

Ver resposta

✓ Resposta: O InputStream de um socket não tem buffer próprio no lado Java: cada read() sem buffer vira uma chamada ao sistema operacional, que atravessa a fronteira entre o programa e o núcleo do sistema. Para uma requisição de 500 bytes, seriam 500 chamadas ao sistema, cada uma custando da ordem de microssegundos, contra uma ou duas chamadas que o BufferedInputStream faz para encher o seu buffer de 8 KB; os demais read() são uma leitura de array. Criar um buffer novo a cada chamada seria pior que lento, seria errado: o primeiro BufferedInputStream puxaria do socket um bloco inteiro, com a linha de pedido, os cabeçalhos e o começo do corpo, entregaria só a primeira linha e seria descartado com o resto dentro dele. A chamada seguinte criaria outro buffer e leria do socket a partir de onde o anterior parou, perdendo os bytes que ficaram no buffer abandonado. É o mesmo defeito do BufferedReader do rascunho. Por isso o EntradaHttp cria o buffer uma vez, no construtor, e todas as leituras, de linhas e do corpo, passam por ele.

Exercício 2

A ErroDeProtocolo é uma exceção verificada, enquanto o construtor de Limites lança IllegalArgumentException, que não é verificada. Justifique cada escolha com o critério de "Quando o Contrato se Quebra".

Ver resposta

✓ Resposta: O critério é se a situação é uma condição esperada, da qual quem chama pode e deve se recuperar, ou um erro de programação. Uma requisição inválida vinda da rede é esperada: acontece com clientes defeituosos, ataques e conexões ruins, e o servidor precisa reagir, respondendo com o código certo e seguindo em frente. Tornar a exceção verificada obriga todo código que chama LeitorDeRequisicao.ler() a tratá-la, e o compilador impede que alguém esqueça, o que é exatamente o que se quer numa fronteira com o mundo externo. Já Limites com um prazo negativo ou um tamanho de linha de 3 bytes é um erro de quem escreveu a configuração, um defeito no código ou no arquivo de configuração. Não há recuperação sensata em tempo de execução: o certo é falhar imediatamente na inicialização, com uma mensagem clara, como "Contratos à Vista" recomendou. Uma exceção verificada ali obrigaria todo código que cria um Limites a escrever um try que nunca teria nada de útil a fazer no catch.

Exercício 3

Escreva dois testes para o LeitorDeRequisicaoTest que verifiquem que um cabeçalho sem dois-pontos e um cabeçalho com nome vazio (: valor) são recusados com 400.

Ver resposta

✓ Resposta:

@Test
void cabecalhoSemDoisPontos() {
    assertThat(statusDoErro("GET / HTTP/1.1\r\nHost: x\r\nSou um cabeçalho\r\n\r\n")).isEqualTo(400);
}

@Test
void cabecalhoComNomeVazio() {
    assertThat(statusDoErro("GET / HTTP/1.1\r\nHost: x\r\n: sem nome\r\n\r\n")).isEqualTo(400);
}

Os dois passam, e os dois exercitam a mesma linha do leitor: if (doisPontos <= 0). No primeiro caso, indexOf(':') devolve -1, porque não há dois-pontos; no segundo, devolve 0, porque o dois-pontos é o primeiro caractere e o nome ficaria vazio. A condição <= 0 cobre os dois de uma vez, e os testes documentam que isso é intencional. Sem o segundo teste, alguém poderia "simplificar" a condição para < 0 no futuro, e um cabeçalho de nome vazio passaria a ser aceito, sem que nenhum teste percebesse. Um teste para cada caso de fronteira, mesmo que exercitem a mesma linha, é o que protege a regra.

Exercício 4

O lerLinha recebe um Supplier<ErroDeProtocolo> em vez de um ErroDeProtocolo pronto. Estime o custo da alternativa ansiosa num servidor que atende 20 mil requisições por segundo, cada uma com uma linha de pedido e dez cabeçalhos.

Ver resposta

✓ Resposta: Com a versão ansiosa, cada chamada de lerLinha criaria uma exceção, mesmo que ela nunca fosse lançada: onze por requisição, 220 mil por segundo. Criar uma exceção é caro porque o construtor de Throwable chama fillInStackTrace, que percorre a pilha de chamadas da thread e registra cada quadro. Uma medição simples, criando um milhão de exceções numa máquina de dois núcleos, levou cerca de 900 ms, algo como 0,9 µs por exceção, enquanto a versão com Supplier, que só cria a exceção quando necessário, levou menos de 10 ms para o mesmo milhão de chamadas. Com 220 mil exceções por segundo, a versão ansiosa gastaria cerca de 200 ms de CPU por segundo só criando objetos que nunca seriam usados, algo como 20% de um núcleo, além de produzir lixo para o coletor. A lambda () -> ErroDeProtocolo.alvoLongoDemais(...) custa quase nada para ser criada, e o JIT frequentemente elimina até essa alocação. É o mesmo princípio do orElseThrow(Supplier) do Optional e das mensagens de log preguiçosas: não pague pelo caso raro no caminho comum.

Exercício 5

Explique, com um exemplo de bytes, como um atacante poderia explorar uma requisição com Content-Length: 4 e Transfer-Encoding: chunked se um proxy na frente do Farol usasse um dos cabeçalhos e o Farol usasse o outro.

Ver resposta

✓ Resposta: Suponha um proxy que protege o Farol e só deixa passar requisições para /publico, e que use o Transfer-Encoding, enquanto o servidor de trás usa o Content-Length. O atacante envia:

POST /publico HTTP/1.1\r\n
Host: x\r\n
Content-Length: 4\r\n
Transfer-Encoding: chunked\r\n
\r\n
5c\r\n
GET /admin HTTP/1.1\r\n
Host: x\r\n
...\r\n
0\r\n
\r\n

O proxy lê o corpo no formato em partes: uma parte de 0x5c bytes, que contém o texto GET /admin ..., e a parte final de tamanho 0. Para ele, é uma única requisição a /publico, permitida, e ele a repassa inteira. O servidor de trás usa o Content-Length: 4 e lê como corpo só os quatro bytes 5c\r\n. O resto, GET /admin HTTP/1.1..., fica na conexão e é interpretado como a próxima requisição, uma requisição a /admin que o proxy nunca viu e, portanto, nunca bloqueou. Pior: como o proxy costuma reaproveitar a mesma conexão com o servidor para vários clientes, a resposta de /admin pode ser entregue a outro usuário. O Farol se protege recusando com 400 qualquer requisição com os dois cabeçalhos, uma das duas reações que a RFC 9112 permite (a outra seria usar só o Transfer-Encoding), e recusando Content-Length ambíguo. Com isso, não existe uma interpretação dos bytes diferente da que o proxy fez que o Farol aceite.

Comentários

Mais em Java

Tarefas em Família — Structured Concurrency e Scoped Values
Tarefas em Família — Structured Concurrency e Scoped Values

Concorrência estruturada e valores com escopo no Java 25: subtarefas que não…

Provando que Funciona — Testes com JUnit e AssertJ
Provando que Funciona — Testes com JUnit e AssertJ

Como escrever testes unitários com JUnit 6 e AssertJ: a estrutura preparar…

Extensão — Um App Desktop que Consome a API (MVVM)
Extensão — Um App Desktop que Consome a API (MVVM)

Nesta aula a janela de catálogo passa a falar com a API REST das Trilhas da…