7 pontos por GN⁺ 2025-05-10 | 3 comentários | Compartilhar no WhatsApp
  • O sistema de gerenciamento de dependências do Rust facilita o desenvolvimento, mas a quantidade e a qualidade das dependências são uma preocupação
  • Até Crates bem utilizadas podem não estar atualizadas, então às vezes é melhor implementar por conta própria
  • Depois de adicionar Crates conhecidas como Axum e Tokio, o total de linhas de código incluindo dependências chegou a 3,6 milhões, algo difícil de administrar
  • O código que realmente escrevi tem só cerca de 1.000 linhas, mas na prática é impossível revisar e auditar o código ao redor
  • Não existe uma solução clara sobre expandir a biblioteca padrão do Rust ou sobre como implementar a infraestrutura central, e toda a comunidade precisa pensar junto no equilíbrio entre desempenho, segurança e manutenção

Visão geral do problema de dependências no Rust

  • Rust é minha linguagem favorita, e a comunidade e a usabilidade da linguagem são excelentes
  • A produtividade no desenvolvimento é alta, mas recentemente surgiram preocupações no aspecto de gerenciamento de dependências

Vantagens dos Crates do Rust e do Cargo

  • Com o Cargo, é possível fazer gerenciamento de pacotes e automação do processo de build, o que melhora muito a produtividade
  • É fácil migrar entre vários sistemas operacionais e arquiteturas, sem precisar se preocupar com gerenciar arquivos manualmente ou configurar ferramentas de build
  • Dá para começar a escrever código imediatamente, sem ter que pensar em gerenciamento de pacotes à parte

Desvantagens do gerenciamento de Crates no Rust

  • Como se pensa menos no gerenciamento de pacotes, acaba-se dando menos atenção à estabilidade
  • Por exemplo, usei o crate dotenv, mas descobri por meio de um Security Advisory que sua manutenção foi descontinuada
  • Considerei um crate alternativo (dotenvy), mas acabei implementando diretamente só a parte de que precisava, em cerca de 35 linhas
  • Como problemas de pacotes sem manutenção acontecem com frequência em várias linguagens, a essência do problema está em situações em que a dependência é inevitável

O aumento explosivo de volume de código causado pelas dependências

  • Estou usando pacotes importantes e de boa qualidade do ecossistema Rust, como Tokio e Axum
  • Como dependências, adicionei Axum, Reqwest, ripunzip, serde, serde_json, tokio, tower-http, tracing e tracing-subscriber
  • O objetivo principal é ter um servidor web, descompressão de arquivos e logging, então o projeto em si é simples
  • Usei o recurso Cargo vendor para baixar localmente todos os crates dependentes
  • Ao analisar as linhas de código com o tokei, cheguei a cerca de 3,6 milhões de linhas incluindo dependências (ou cerca de 11.136 linhas sem contar os crates vendorizados)
  • Como referência, diz-se que o kernel Linux inteiro tem cerca de 27,8 milhões de linhas, então meu pequeno projeto equivale a aproximadamente um sétimo disso
  • O código que eu realmente escrevi tem só cerca de 1.000 linhas
  • Na prática, é impossível monitorar e auditar tanta linha de código de dependências

Reflexões sobre uma solução

  • No momento, não há uma solução clara
  • Alguns defendem expandir a biblioteca padrão como no Go, mas isso também traz novos problemas, como o peso de manutenção
  • Como o Rust busca alto desempenho, segurança e modularidade, além de competir com embarcados e C++, a expansão da biblioteca padrão precisa ser tratada com cautela
  • Por exemplo, até um runtime sofisticado como o Tokio é mantido de forma muito ativa no GitHub e no Discord
  • Na prática, implementar por conta própria infraestruturas centrais como runtime assíncrono ou servidor web está além do alcance de um desenvolvedor individual
  • Até um serviço de grande porte como a Cloudflare usa dependências de tokio e crates.io como estão, e não está claro com que frequência faz auditorias
  • A Clickhouse também menciona problemas relacionados ao tamanho dos binários e à quantidade de crates
  • Com o Cargo, é difícil identificar com precisão quais linhas de código entram no binário final, e há a limitação de incluir também código desnecessário para cada plataforma
  • No fim, a realidade é que só resta pedir uma resposta à comunidade como um todo

3 comentários

 
codemasterkimc 2025-05-11

Ao rodar o Trivy, ele mostra muito menos vulnerabilidades high ou critical, e parece mais seguro do que o ecossistema de JS com NPM ou de Java com Maven; então o que exatamente esse texto quer afirmar sobre Rust?

 
GN⁺ 2025-05-10
Opiniões no Hacker News
  • Na minha opinião, sistemas em que dependências são adicionadas "facilmente" e sem penalidade de tamanho ou custo inevitavelmente acabam levando a problemas com dependências. Se você olhar para como software foi distribuído nos últimos 40 anos, nos anos 80 as bibliotecas eram compradas e, em ambientes com restrição de espaço, você selecionava só as partes necessárias. Hoje em dia, continuamos empilhando biblioteca em cima de biblioteca. Dá para usar com uma linha import foolib, e ninguém se importa com o que tem lá dentro. Em cada etapa, você precisa de só uns 5% das funcionalidades, mas quanto mais profunda a árvore, mais código inútil se acumula. No fim, um binário simples vira 500MiB, e você acaba puxando uma dependência por causa de uma única formatação numérica. Go e Rust incentivam colocar tudo em um único arquivo, então fica difícil quando você quer usar só uma parte. A verdadeira solução no longo prazo seria um rastreamento ultrafino de símbolos/dependências, em que toda função/tipo declara exatamente o que precisa, usa-se só o código necessário e o resto é descartado. Pessoalmente não gosto muito dessa ideia, mas não consigo pensar em outra forma de resolver o sistema atual, que sai varrendo o universo inteiro da árvore de dependências
    • Talvez eu não saiba muito por ser universitário, mas o compilador do Rust já detecta código, variáveis e funções não usados. A maioria das IDEs também consegue fazer isso em várias linguagens. Então não seria só remover essas partes? Código não usado não é compilado
    • Na prática, enquanto eu trabalhava em uma biblioteca Rust com uma árvore de dependências relativamente pesada (Xilem), tentei enxugar com feature flag, mas quase todas as dependências realmente precisavam continuar por causa das funcionalidades necessárias (vulkan, decodificação de PNG, unicode shaping etc.). As dependências desnecessárias eram, em geral, muito pequenas, e só removi serde_json com uma pequena modificação. Dependências maiores (winit/wgpu etc.) exigem mudanças estruturais, então não dá para tirar facilmente
    • Go e C# (.NET) são bons contraexemplos. Eles têm gerenciamento de pacotes e ecossistemas tão eficazes quanto Rust ou JS (Node), mas relativamente não sofrem de dependency hell. Isso acontece porque a biblioteca padrão é excelente. A vastidão da biblioteca padrão é algo em que só grandes empresas (Google, Microsoft) conseguem investir
    • Então por que os compiladores atuais não removem código não utilizado?
    • Antigamente, criava-se um arquivo .o para cada função e juntava-se tudo em um arquivo .a, e o linker puxava só as funções necessárias. O namespacing também era feito como foolib_do_thing(). Hoje, com um padrão tipo god object, todas as funções ficam em um objeto de topo, então ao importar foolib você arrasta tudo. Nesse estado, fica difícil para o linker saber quais funções são realmente necessárias. Em compensação, Go é excelente em remoção de código morto, então o que não é usado é cortado do resultado da compilação
    • Compiladores e linkers modernos já fazem extração de símbolos e remoção de código morto, e Rust também oferece suporte a isso com projetos como min-sized-rust
    • No passado, também se gerenciava tudo incluindo todas as bibliotecas no projeto e integrando manualmente aos arquivos de build. Dá muito trabalho e é irritante, mas gera um entendimento muito mais profundo do que simplesmente adicionar uma linha num arquivo de deps
    • Na prática, Go não insiste só em arquivo único; ele também dá suporte fácil à divisão lógica em arquivos. Gosto bastante disso
    • O Dotnet já está implementando essa ideia com Trimming e Ahead Of Time Compilation. Outras linguagens poderiam aprender com o Dotnet
    • Com LTO (Link Time Optimization), esse problema fica completamente resolvido do ponto de vista do tamanho do binário. As partes não usadas são eliminadas pela otimização. O tempo de build, porém, continua custando
    • Eu acho que o problema não é exatamente a biblioteca em si, mas a falta de visibilidade sobre o que e quanto é usado internamente depois que você adiciona uma dependência. Precisamos de um ambiente em que seja fácil obter feedback sobre desempenho por pacote, proporção de código excedente no build etc.
    • A linguagem Unison adota algo parcialmente parecido com essa ideia. Cada função é definida pela sua estrutura AST e é carregada e reutilizada a partir de um registro global baseado em hash
    • Em vez da manutenção dispersa de muitos fragmentos minúsculos de biblioteca, como isEven, isOdd e leftpad do npm, bibliotecas grandes e genéricas mantidas por equipes federadas oferecem muito mais garantia de futuro e continuidade
    • Em vez de buscar símbolos/dependências ultrafinos, outra ideia é usar módulos ultrapequenos e aproveitar os sistemas existentes de tree-shaking
    • O gerenciamento real de dependências do Go é, na verdade, bem próximo do ideal descrito no post original. Um módulo é um conjunto de pacotes, e no vendoring ele inclui só os pacotes e símbolos realmente usados (não sei se funciona exatamente no nível de símbolo)
    • O sistema de módulos do JS já oferece exatamente esse tipo de gerenciamento ultrafino de símbolos e tree shaking
    • A ideia originalmente proposta de dependências ultrafinas já é resolvida em Rust com section splitting, como --gc-sections
    • Rust é uma linguagem em que imports finos funcionam muito bem por meio da segmentação de API com crate feature. É diferente de Go
    • Dependendo da arquitetura — por exemplo, um thick client centrado no local — mesmo 800MB na instalação inicial não são um problema, porque no uso real só há comunicação extremamente limitada pela rede. Dependências grandes e repetitivas para colaboração na UI também podem ser aceitáveis
    • A melhor maneira de reutilizar código é justamente usar essas dependências. Otimizar só onde realmente for necessário
    • Já nos anos 80, a ideia de componentes de software reutilizáveis se tornou realidade com linguagens como Objective-C. Um dos grandes sucessos do Rust foi fazer essa componentização de software ser amplamente adotada também em linguagens de programação de sistemas
    • Com tree shaking, os problemas de tamanho/inchaço de código podem ser mitigados até certo ponto (em servidores, isso geralmente nem importa). O problema mais sério é risco de supply chain e segurança nas dependências. Quanto maior a empresa, mais costuma haver um processo de aprovação para usar open source. Mesmo que você aumente a granularidade, se 1000 funcionalidades vierem de 1000 autores de NPM, isso não muda o problema de segurança
    • Se em cada camada de abstração de pacote você aproveitar só 50%, então em cada camada o tamanho total dobra em relação ao necessário. Em 3 camadas, 88% é código inútil. Exemplo: a calculadora do Windows 11 vem acompanhada de bibliotecas inúteis (até ferramenta de recuperação de conta). É um caso em que a facilidade de adicionar funcionalidades leva ao aumento da complexidade
    • Concordo que o acúmulo de dependências é um problema. A melhor defesa que temos agora é gerenciar dependências do sistema com extremo rigor. Em vez de trazer uma biblioteca externa por causa de uma função de 10 linhas, às vezes colo o código diretamente. Um ecossistema de bibliotecas saudável é a exceção. Costumo frear imediatamente engenheiros juniores quando começam a adicionar dependências indiscriminadamente
    • Faz tempo que eu não via alguém falar com tanta convicção sem nem conhecer o básico sobre Rust
    • Graças à remoção de código morto, em linguagens compiladas como Rust uma árvore de dependências grande não leva a inchaço do binário
  • O problema que sinto no ecossistema npm é que muitos desenvolvedores trazem dependências sem refletir sobre o design. Por exemplo, uma biblioteca glob deveria ser só uma função simples de globbing, mas o autor também empacota uma ferramenta de linha de comando e adiciona um parser grande como dependência. Isso gera alertas frequentes de "dependency out-of-date". Além disso, o escopo de responsabilidade da própria biblioteca glob também é discutível. Fazer apenas matching de padrões de string é um design mais flexível (facilita testes e abstração de sistema de arquivos). Muita gente quer bibliotecas onipotentes de "faz tudo", mas quanto mais elas crescem, maiores os efeitos colaterais. Imagino que Rust não seja tão diferente
    • Senso de design é importante, e boas linguagens não apoiam nem atrapalham esse gosto do desenvolvedor. Rust, Zig e C são assim. O problema acontece menos por estatística. Quando uma "multidão" de desenvolvedores se reúne, surge um "modelo bazaar", em que "qualquer um é livre" para empilhar crates. No fim das contas, espero que Rust também tenha uma biblioteca padrão oficial "com baterias incluídas", com namespaces organizados, algo como stdlib::data_structures::automata::weighted_finite_state_transducer. Como a própria linguagem embute versionamento e compatibilidade retroativa, dá para esperar melhorias no futuro
    • A função glob do POSIX realmente percorre o sistema de arquivos. Para matching de strings, existe fnmatch. O ideal seria manter fnmatch em um módulo separado e fazer com que ele fosse uma dependência de glob. Implementar glob por conta própria é bem difícil, porque há exigências complexas como estrutura de diretórios, brace expansion etc., então é preciso uma boa composição de funções bem projetadas
    • Em Rust, o borrow checker funcionou como uma espécie de escudo contra desenvolvedores com pouco senso de design. Não sabemos até quando esse efeito vai durar
    • Uma das grandes vantagens do Rust é que, em geral, os desenvolvedores são muito competentes e a qualidade dos crates costuma ser alta
    • O Bun também inclui funcionalidade de glob
  • Não é preciso apontar o dedo especificamente para Rust; o problema das dependências e dos ataques à supply chain já é real. Se fôssemos projetar uma linguagem nova, ela precisaria embutir um capability system para isolar com segurança toda a árvore de bibliotecas. Por exemplo, ao projetar uma biblioteca de carregamento de imagens, ela deveria processar apenas um stream, e não arquivos diretamente, ou então declarar explicitamente que "não tem permissão para abrir arquivos", bloqueando em tempo de compilação o uso de funções perigosas. Isso não é fácil nos ecossistemas atuais, mas, se fosse feito direito, reduziria ao mínimo a superfície de ataque. Uma cultura de minimizar dependências também não resolve isso na raiz, e linguagens como Go também não estão livres de ataques à supply chain
    • Precisamos difundir com mais força a cultura Sans-IO (projetar dependências para não fazer IO diretamente). Ao lançar novas bibliotecas, também seria importante criar uma cultura de apontar quando elas implementam IO por conta própria. Claro que só revisão pública não basta, mas seria ótimo se o princípio Sans-IO se espalhasse
    • Como exemplo, existe a linguagem de propósito específico WUFFS. Na prática, ela nem consegue dar um Hello world e não tem nem tipo string. Em compensação, é especializada apenas em parsing de formatos de arquivo não confiáveis. Precisamos de mais linguagens desse tipo. Elas são rápidas, seguras e reduzem checagens desnecessárias
    • Java e .NET Framework tinham mecanismos de partial trust/capabilities décadas atrás, mas não foram amplamente usados e acabaram sendo abandonados
    • Rust também tem uma leve tendência nessa direção. Com #![deny(unsafe_code)], é possível transformar o uso de código unsafe em erro de compilação e informar isso ao usuário. Não é uma verificação forçada em todos os casos, e se você permitir explicitamente ainda pode usar código unsafe. Dá para imaginar um capability system que controle transitivamente recursos da biblioteca padrão, como uma feature flag
    • Eu gostaria de construir algo assim com as próprias mãos e espero que algum dia isso vire realidade. Em Rust, rastreamento de capabilities baseado em linter é parcialmente possível. Ainda seria preciso resolver problemas de unsoundness no compilador
    • Introduzir uma imposição perfeitamente estática em linguagens e ecossistemas existentes é difícil, mas só com validação em runtime já daria para colher a maior parte dos benefícios. Se o código da biblioteca for compilado a partir do source, dá para colocar wrappers de checagem de permissão em cada system call. Em caso de violação, dispara-se um panic, e seria necessário escrever/distribuir um perfil de capabilities por biblioteca. Algo semelhante já foi demonstrado no ecossistema TypeScript
    • Haskell realiza parcialmente essa abordagem com o IO monad. Funções que não podem fazer IO são restringidas pela assinatura de tipo
    • Pelo que penso, talvez seja necessário mudar por completo a forma como esse sistema conversa com o OS. A armadilha é que até ler um stream pode, na prática, usar system calls de leitura de arquivo
    • Existe um projeto chamado Capslock que funciona de forma parecida em Go
    • Se você restringir desde o programa de entrada para que bibliotecas não possam importar APIs do sistema, então a passagem de capabilities pode ser feita só por injeção de dependência. Isso já é projetável nas linguagens atuais; o problema prático é que quebra a compatibilidade com bibliotecas existentes
    • Fico curioso se algo parecido com essa ideia já foi implementado antes. Parece muito difícil aplicar isso nas linguagens atuais
    • Uma única linguagem não basta; é preciso um ecossistema multilíngue
    • No ecossistema TypeScript, por exemplo, se não houver uma classe de operação de arquivos no ambiente, a compilação falha e a restrição acaba sendo aplicada naturalmente
  • É um problema universal do desenvolvimento moderno de software. A barreira de entrada caiu e o reaproveitamento de código aumentou. Dependências são, no fim, código não confiável. Sem uma solução técnica, alguém terá de continuar revisando e mantendo código, além de sustentar sistemas sociais e jurídicos de confiança. Se tudo for puxado para a stdlib do Rust, o time central terá de se responsabilizar por esse código todo, e isso aumenta a carga de manutenção
    • O grau aparente do problema varia por linguagem. Linguagens com biblioteca padrão forte têm vantagem, porque conseguem fazer muita coisa com poucas dependências externas. Em linguagens com poucos recursos nativos, como JS/Node, dependência externa vira o padrão. "Leveza" nem sempre é uma virtude
    • Acho que Rust precisa incorporar mais coisas à biblioteca padrão. Go tem uma stdlib excelente, enquanto no Rust até funcionalidades básicas (web, tls, x509, base64 etc.) doem na hora de escolher e gerenciar bibliotecas
    • Gilad Bracha propôs uma abordagem interessante para sandbox de bibliotecas de terceiros: remover import e fazer tudo por injeção de dependência. Se você não injetar algo como um subsistema de IO, o código de terceiros nunca consegue acessar essa área. Se quiser conceder só leitura, pode injetar um wrapper com apenas leitura. Em programação de sistemas, porém, há limitações (por causa de unsafe code etc.)
    • Também já sugeriram uma estrutura como a do QubesOS: rodar todas as bibliotecas em ambientes isolados, seu próprio código no dom0, cada biblioteca em uma VM de template separada e a comunicação feita por namespaces de rede. Em setores sensíveis, isso pode ser prático
    • Pelo que eu vejo, não estamos fazendo coisas mais complexas; estamos apenas fazendo as mesmas coisas de forma mais complexa. O objetivo em si não ficou mais difícil
    • Na verdade, cada linguagem tem uma situação diferente. Em C/C++, adicionar dependências já é difícil e, se você quiser suporte cross-platform, fica ainda mais trabalhoso, então um problema parecido não surge tanto
    • O complexo é o inchaço de código desnecessário. Quase todo projeto está cheio de complexidade desnecessária e overengineering. Esse é o problema da indústria
  • O blessed.rs recomenda uma lista de bibliotecas úteis que são difíceis de colocar na biblioteca padrão. Gosto disso porque, com esse sistema, a maioria dos pacotes fica limitada a objetivos específicos e mais fácil de gerenciar
    • cargo-vet também vale muito a recomendação. Ele permite rastrear e definir pacotes confiáveis, desde políticas em que certos pacotes exigem auditoria de especialistas antes da importação até algo semi-YOLO como "pacotes mantidos pelos mantenedores do tokio a gente simplesmente confia". É mais formal que blessed.rs e muito bom para compartilhar uma lista oficial ou semi-oficial dentro da equipe
    • Seria ótimo se Python também tivesse um sistema assim
    • Dei uma olhada e realmente parece um projeto de recomendação muito bom
  • Desde o caso do leftpad, ficou uma imagem negativa dos package managers. Coisas como tokio são, na prática, funcionalidades de nível de linguagem, então seria irrealista esperar que o OP quisesse auditar por conta própria o Go inteiro ou até o V8 do Node
    • Na prática, alguém audita tokio continuamente. Não são muitas pessoas, mas ainda assim alguém faz isso
    • O fato de o cargo incluir duas versões quando duas dependências usam versões diferentes é um suporte bastante característico dele
  • Os feature flags em pacotes cargo são realmente ótimos. Eu frequentemente abro PRs para esconder dependências desnecessárias atrás desses flags. Dá para ver a árvore de dependências facilmente com cargo tree. Uma visão de linhas de código que realmente entram no binário não significa muita coisa; quando funções são inlined, em geral quase tudo vai parar dentro de main
    • Sinto falta de feature flags no npm. Fico curioso se existe algum package manager que já suporte isso. Eu gostaria de isolar, dentro de bibliotecas internas, código dependente de certos frameworks para poder expandi-las
  • Eu também sou assim. Adicionar dependências com Cargo é fácil demais, então mesmo que eu tome cuidado, basta acrescentar algumas para vir junto dezenas de dependências transitivas. Ainda assim, dizer que não devemos usá-las não é realista. Em C++ esse fenômeno é menor. No Rust, como há muita fragmentação em pacotes pequenos, parece que você está trazendo código aleatório da internet. Eu gosto do Rust em si, mas não gosto dessa estrutura
    • Num texto linkado no subreddit de Rust, dizia-se que o motivo de as dependências em C++ parecerem menos visíveis é que em geral são fornecidas como bibliotecas dinâmicas. Por outro lado, depender da capacidade do gerenciador de pacotes do sistema operacional em manter estabilidade e segurança também é uma vantagem. Seria bom se Rust ao menos introduzisse algo como uma extensão da biblioteca padrão
    • Acho que dependências complexas e sistemas de build caóticos do C++ tornam as dependências menos estáveis do Rust uma opção melhor. Na prática, as dependências transitivas do C++ ficam ainda menos visíveis por estarem em formato pré-compilado
    • Em Rust, a divisão em pacotes pequenos não é tanto uma "filosofia", e sim uma questão de velocidade de build. Quando o projeto cresce, você acaba quebrando em crates. Não por abstração, mas porque a performance de build força esse tipo de reorganização
    • Não é preciso concordar automaticamente com a lógica de "então é só não usar". Vale pensar um pouco mais
    • C++ e CMake são difíceis demais, e por isso muito software acaba simplesmente deixando de ser usado
  • Eu gerencio assim: para bibliotecas centrais, uso bibliotecas open source; para funcionalidades pequenas, pego referência no open source e faço copy-paste do código para dentro do meu projeto. O código fica um pouco maior do que o necessário, mas isso reduz a carga de revisar código externo e a exposição à supply chain. Bibliotecas grandes continuam sendo um problema, mas não dá para escrever tudo do zero. Isso não é um problema só do Rust; é algo geral
  • No passado (em outras linguagens), eu definia uma política de mínimo de módulos/pacotes para sistemas importantes e movia todos os pacotes usados para um repositório interno, auditando branches e atualizações. Em áreas como frontend, esse tipo de controle rígido é inviável na prática. Mais recentemente, também vejo preocupações parecidas com ferramentas e modelos de IA open source barulhentos no tema de gerenciamento de dependências. Mesmo em projetos pessoais com Rust, o que mais me incomoda é a explosão de dependências de bibliotecas de UI/async. Basta uma ficar vulnerável para tudo ser comprometido; é só questão de tempo
    • Na prática, faz sentido conectar o sistema de CI/CD apenas ao repositório interno oficial. O desenvolvedor pode instalar o que quiser localmente, mas commits não autorizados são barrados no servidor de build
    • Existem RFCs tentando resolver riscos de segurança, mas por razões culturais (provavelmente) não há mudanças radicais
    • Uma coisa legal no Rust é que até async pode ser implementado do jeito que você quiser. Você não fica preso a uma implementação específica
 
iolothebard 2025-05-11

Não é um problema exclusivo do Rust.
É uma vantagem comum e, ao mesmo tempo, um problema potencial compartilhado por todas as linguagens que têm repositórios públicos de pacotes e gerenciadores de pacotes com suporte a dependências transitivas.
No fim das contas, quem usa é que precisa usar direito...
Mesmo depois do caso do leftpad no Node&npm, nada mudou.