Rust idiomático#
Saber a sintaxe não chega: o que separa código Rust de sistemas bom é fazer o compilador trabalhar para ti. A ideia central deste capítulo, e de todo o delonix: parse, don't validate — valida uma vez, na fronteira, e a partir daí o tipo prova que o valor é válido.
Newtype: validar uma vez, à entrada#
Um id de container entra em caminhos de disco, nomes de cgroup e argumentos de ssh. Se for uma String solta, todo o código a jusante tem de desconfiar dele — e basta um esquecer-se para haver um path traversal. Com um newtype, a validação acontece uma vez e o tipo carrega a garantia:
/// Um id de container **validado uma vez**, à entrada. Todo o código a jusante recebe
/// `ContainerId` e não precisa de re-validar — e não há forma de construir um inválido.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ContainerId(String);
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum IdError {
#[error("container id must not be empty")]
Empty,
#[error("container id too long ({0} > 64)")]
TooLong(usize),
#[error("invalid character {0:?} in container id")]
BadChar(char),
#[error("container id must not start with '-' or '.'")]
BadStart,
}
impl TryFrom<&str> for ContainerId {
type Error = IdError;
fn try_from(s: &str) -> Result<Self, IdError> {
if s.is_empty() {
return Err(IdError::Empty);
}
if s.len() > 64 {
return Err(IdError::TooLong(s.len()));
}
if s.starts_with(['-', '.']) {
return Err(IdError::BadStart);
}
if let Some(c) = s.chars().find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))) {
return Err(IdError::BadChar(c));
}
Ok(Self(s.to_owned()))
}
}
impl ContainerId {
pub fn as_str(&self) -> &str {
&self.0
}
}ContainerId tem o campo privado e só se constrói por TryFrom. Não há forma de obter um id inválido — logo nenhuma função que o receba precisa de o re-validar.
Um bug real desta classe
O delonix já teve um path traversal por nome de VM (metadata.name: "../../.ssh/authorized_keys" escrevia fora do directório de estado) e outro por --name registry.npmjs.org a sequestrar o DNS interno. A correcção nos dois casos foi a mesma: validar na fronteira do motor (valid_vm_name, valid_container_name), com lista branca de caracteres — nunca «limpar» o input.
Typestate: transições ilegais que não compilam#
Em vez de um campo status verificado em runtime, codifica o estado no tipo. Cada transição consome self, por isso a fase antiga deixa de existir:
#[derive(Debug)]
pub struct Created;
#[derive(Debug)]
pub struct Running;
#[derive(Debug)]
pub struct Stopped;
/// O parâmetro `S` é o estado. Cada método só existe no estado onde faz sentido, e
/// **consome** `self`: a fase antiga deixa de existir.
///
/// ```
/// use examples::ch02_idiomatico::{Container, ContainerId};
/// let id = ContainerId::try_from("web").unwrap();
/// let c = Container::new(id).start(4242).stop(0);
/// assert_eq!(c.exit_code(), 0);
/// ```
///
/// Isto **não compila** (`stop` só existe em `Container<Running>`):
///
/// ```compile_fail
/// use examples::ch02_idiomatico::{Container, ContainerId};
/// let c = Container::new(ContainerId::try_from("web").unwrap());
/// c.stop(0);
/// ```
///
/// Nem isto (`c` foi consumido pela primeira transição):
///
/// ```compile_fail
/// use examples::ch02_idiomatico::{Container, ContainerId};
/// let c = Container::new(ContainerId::try_from("web").unwrap());
/// let _r = c.start(1);
/// c.start(2);
/// ```
#[derive(Debug)]
pub struct Container<S> {
id: ContainerId,
pid: i32,
exit_code: i32,
_state: PhantomData<S>,
}
impl Container<Created> {
pub fn new(id: ContainerId) -> Self {
Self { id, pid: 0, exit_code: 0, _state: PhantomData }
}
pub fn start(self, pid: i32) -> Container<Running> {
Container { id: self.id, pid, exit_code: 0, _state: PhantomData }
}
}
impl Container<Running> {
pub fn pid(&self) -> i32 {
self.pid
}
pub fn stop(self, exit_code: i32) -> Container<Stopped> {
Container { id: self.id, pid: self.pid, exit_code, _state: PhantomData }
}
}
impl Container<Stopped> {
pub fn exit_code(&self) -> i32 {
self.exit_code
}
pub fn restart(self) -> Container<Created> {
Container::new(self.id)
}
}
impl<S> Container<S> {
pub fn id(&self) -> &ContainerId {
&self.id
}
}Os compile_fail nos comentários de documentação são testes: cargo test --doc confirma que c.stop(0) num container ainda Created não compila. É a mesma técnica que o motor usa no ciclo de vida do container:
//! Typestate of a container's lifecycle (Sprint 5 — Damas: *correctness by
//! construction*). The states are **types**, and the illegal transitions **do not
//! compile** — instead of being caught (or not) at runtime by a `match` over
//! a [`Status`].
//!
//! The model: `Created → Running → Stopped → (restart) Created`. Each transition
//! **consumes** the previous phase, so an obsolete phase cannot be reused.
//!
//! ```
//! use delonix_model::typestate::Phase;
//! use delonix_model::records::Status;
//!
//! let created = Phase::new("abc123"); // Phase<Created>
//! assert_eq!(created.status(), Status::Created);
//! let running = created.start(4242); // Created → Running
//! assert_eq!(running.pid(), 4242);
//! let stopped = running.stop(0); // Running → Stopped
//! assert_eq!(stopped.status(), Status::Stopped);
//! let _again = stopped.restart(); // Stopped → Created (reuses the id)
//! ```
//!
//! The invalid transitions are **compilation errors**, not runtime bugs:
//!
//! ```compile_fail
//! use delonix_model::typestate::Phase;
//! let created = Phase::new("abc123"); // Phase<Created>
//! created.stop(0); // ERROR: `stop` only exists on Phase<Running>
//! ```
//!
//! ```compile_fail
//! use delonix_model::typestate::Phase;
//! let running = Phase::new("abc123").start(1); // Phase<Running>
//! running.start(2); // ERROR: `start` only exists on Phase<Created>
//! ```
//!
//! ```compile_fail
//! use delonix_model::typestate::Phase;
//! let created = Phase::new("abc123");
//! let _running = created.start(1);
//! created.start(2); // ERROR: `created` was consumed by the 1st transition
//! ```
use crate::records::Status;
use std::marker::PhantomData;
/// State: created, still without a `pid`.
pub struct Created;
/// State: running, with a live init `pid`.
pub struct Running;
/// State: terminated, with an exit code.
pub struct Stopped;
/// A **typed** phase of the lifecycle. The `S` parameter is the current state; the
/// transition methods only exist in the phases where they are valid.
pub struct Phase<S> {
id: String,
pid: Option<i32>,
code: Option<i32>,
_state: PhantomData<S>,
}Quando usar — e quando não
Typestate brilha quando as fases são poucas, lineares e conhecidas em compilação (ciclo de vida de um objecto que vive num só processo). Não o uses para estado que vem do disco ou da rede (aí o estado só se conhece em runtime: usa um enum e match exaustivo). O delonix usa enum Status persistido e typestate no código que orquestra a transição.
Builder: muitos opcionais, um único ponto de validação#
#[derive(Debug, PartialEq, Eq)]
pub struct RunSpec {
pub image: String,
pub memory: Option<u64>,
pub env: Vec<String>,
pub read_only: bool,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum SpecError {
#[error("an image is required")]
NoImage,
#[error("env entry {0:?} is not KEY=VALUE")]
BadEnv(String),
}
#[derive(Debug, Default)]
pub struct RunSpecBuilder {
image: Option<String>,
memory: Option<u64>,
env: Vec<String>,
read_only: bool,
}
impl RunSpecBuilder {
pub fn image(mut self, i: impl Into<String>) -> Self {
self.image = Some(i.into());
self
}
pub fn memory(mut self, bytes: u64) -> Self {
self.memory = Some(bytes);
self
}
pub fn env(mut self, kv: impl Into<String>) -> Self {
self.env.push(kv.into());
self
}
pub fn read_only(mut self, yes: bool) -> Self {
self.read_only = yes;
self
}
/// Toda a validação vive aqui — `RunSpec` só existe válido.
pub fn build(self) -> Result<RunSpec, SpecError> {
let image = self.image.ok_or(SpecError::NoImage)?;
if let Some(bad) = self.env.iter().find(|e| !e.contains('=')) {
return Err(SpecError::BadEnv(bad.clone()));
}
Ok(RunSpec { image, memory: self.memory, env: self.env, read_only: self.read_only })
}
}RunSpec só existe válido: toda a validação vive em build(). Chamar .env("SEM_IGUAL") só falha quando construíres — com uma mensagem que nomeia o valor, não com um pânico três camadas abaixo.
Erros tipados, com classe#
Duas escolas, e ambas têm o seu sítio:
thiserror (bibliotecas) |
anyhow (aplicações) |
|
|---|---|---|
| Tipo de erro | enum fechado, cada variante documentada |
anyhow::Error opaco |
| O chamador pode… | fazer match e decidir |
só imprimir/propagar |
| Usa em | crates de motor (delonix-*) |
o main de uma ferramenta pequena |
O motor vai um passo além: o erro tem uma classe que vira código de saída, num único sítio.
/// The single place an engine error becomes an exit code.
///
/// Pure on purpose: two places deciding the same number is how they start
/// disagreeing, and a table this small is only worth anything if it is the
/// whole truth.
pub fn for_error(e: &Error) -> i32 {
match e {
Error::NotFound(_) | Error::VmNotFound(_) => NOT_FOUND,
Error::NotRunning(_) => NOT_RUNNING,
Error::Conflict(_) => CONFLICT,
Error::Unavailable(_) => UNAVAILABLE,
Error::Timeout(_) => TIMEOUT,
Error::Coded { inner, .. } => for_error(inner),
// The KIND is inspected rather than the variant: `Error::Io` is the
// wrapper every filesystem refusal arrives in, and EACCES inside it is
// a different answer for the caller than a full disk or a bad path.
Error::Io(e) if e.kind() == std::io::ErrorKind::PermissionDenied => NO_PERMISSION,
Error::Io(_) => IO,
Error::Json(_) | Error::Runtime { .. } => GENERIC,
Error::Invalid(_) | Error::Registry(_) => GENERIC,
}
}Isto não é cosmético. As mensagens são traduzidas (--l18n=pt) e mudam; um script que faça grep 'no such' funciona na máquina onde foi escrito e deixa de classificar num nó em português. O código de saída é contrato: 4 = «não existe», 5 = «conflito», 1 = «rebentou». É o que um reconciliador precisa para decidir cria ou pára. Vais implementar o mesmo no minicontainer (erros).
Armadilha: um match com _ =>
O match da tabela acima é exaustivo de propósito. Se lhe pusesses um _ =>, uma variante nova de Error seria arquivada em «genérico» sem ninguém decidir. Sem o _, o compilador pára e obriga a escolher.
unsafe: pequeno, comentado, isolado#
Um runtime tem de usar unsafe (fork, prctl, ioctl). A disciplina:
- Mínimo — o bloco mais pequeno possível, e nunca em torno de lógica.
// SAFETY:— um comentário que diz porque as pré-condições se verificam ali.- Isolado — atrás de uma função segura, para o resto do código não ver
unsafe.
// SAFETY: o processo é single-thread neste ponto (nenhuma thread foi lançada), logo
// `fork` é seguro; o filho só chama funções async-signal-safe até ao `exec`/`_exit`.
match unsafe { fork() }? { /* ... */ }
Este comentário é do minicontainer, e a razão é real: fork() num processo multi-thread só deixa viva a thread que chamou, e se outra segurava o lock do malloc, o filho bloqueia para sempre. O delonix pagou isto: o servidor da API Docker era multi-thread e o clone() do arranque de containers bloqueava sob pedidos concorrentes; a correcção foi arrancar por re-exec de um processo novo.
Lint que ajuda
unsafe_op_in_unsafe_fn = "deny" (ver o Cargo.toml do workspace) obriga cada operação insegura, mesmo dentro de uma unsafe fn, a ter o seu próprio bloco unsafe — e portanto o seu próprio SAFETY.
Iteradores em vez de laços com estado#
/// Soma a memória pedida dos containers que vão correr, ignorando os sem limite.
pub fn total_memory(specs: &[RunSpec]) -> u64 {
specs.iter().filter_map(|s| s.memory).sum()
}
Sem índices, sem acumulador mutável, sem off-by-one. filter_map combina «filtra os None» e «desembrulha os Some». Os iteradores são abstracções de custo zero: o compilador gera o mesmo código que o laço à mão.
Ferramentas que a comunidade espera de ti#
cargo fmt --check # formatação canónica: zero discussões de estilo
cargo clippy --all-targets -- -D warnings # centenas de lints: apanha bugs, não só estilo
cargo test --doc # os exemplos da documentação são testes
cargo deny check # licenças e advisories das dependências
Neste tutorial, tudo isto corre em CI (.github/workflows/ci.yml) — e é o mesmo conjunto que o delonix impõe antes de aceitar um PR.
Verifica o que aprendeste#
cargo test -p examples ch02 # 3 testes + 1 doctest + 2 compile_fail
Exercício: acrescenta o estado Paused ao typestate (Running → Paused → Running). Que métodos precisas de escrever? Que não deves escrever (por exemplo, stop em Container<Paused>)?