Зависимости это погода долгого проекта: пока он жив, кто-то обязательно попросит поднять версию, закрыть уязвимость или добавить новый крейт. Реестр crates.io хранит свыше 353 тысяч крейтов, суммарный счётчик загрузок перевалил за 493 миллиарда. При таком масштабе логично ожидать вечной возни с конфликтами, знакомой по соседним платформам. На практике сервис на Rust нередко живёт по пять лет, и регулярное обновление зависимостей остаётся рутиной на десять минут, а не проектом на квартал. Разберём, какие механизмы дают такую предсказуемость: точный снимок Cargo.lock, дисциплина семантического версионирования, резолвер с учётом версии компилятора и неизменность реестра. Заодно честно сравним с npm и pip, чтобы было видно, где именно проходит разница.

Задача манифеста Cargo.toml и точного снимка Cargo.lock в сборке

Вся работа с зависимостями держится на двух файлах с разными ролями. Манифест Cargo.toml пишет человек: там описаны намерения, диапазоны версий и фичи. Снимок Cargo.lock ведёт сам инструмент: там записаны точные версии всего графа с контрольными суммами sha256. Типичный манифест сервиса:

[package]
name = "payment-service"
version = "0.9.2"
edition = "2021"
rust-version = "1.75"

[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1.38", features = ["rt-multi-thread", "macros"] }

Строка rust-version объявляет минимальную версию компилятора для проекта. Запись serde с версией "1" означает диапазон от 1.0.0 до 2.0.0, и Cargo выбирает максимум внутри него. Результат фиксируется в снимке:

[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"

Документация Cargo прямо советует: когда сомневаетесь, коммитите снимок в систему контроля версий. Тогда сборка на новой машине и в CI повторяет локальную до последнего байта, те же версии, те же фичи, те же зависимости зависимостей. Диапазон из манифеста вычисляется один раз, в момент обновления, и замораживается снимком. Там, где снимок не обязателен, один и тот же диапазон может разрешиться в разные версии на разных машинах в один и тот же день, и это классический источник гонки сборок. Отдельное ускорение принес sparse протокол индекса, ставший умолчанием в Rust 1.70: вместо клона всего репозитория индекса Cargo забирает с index.crates.io только записи нужных крейтов, и первая синхронизация на чистой машине занимает секунды, а не минуты.

Отдельного разговора заслуживают фичи. Зависимость объявляется с набором возможностей, а Cargo объединяет фичи по всему графу: если один крейт просит serde с derive, а другой без, в сборку попадёт версия с включённым derive. Обратная сторона объединения: флаг default-features = false у одной из зависимостей не отключит фичи, которые уже запросил сосед. Итоговый набор по всему графу удобно смотреть командой cargo tree -e features, она печатает дерево вместе с включёнными фичами.

Семантическое версионирование и дисциплина крейтов держать годами одну мажорную версию

Версионирование в Cargo построено на семантических диапазонах, операторы читаются так:

  1. ^1.4.2 означает диапазон от 1.4.2 до 2.0.0, мажор не трогаем;
  2. ~1.4.2 уже, от 1.4.2 до 1.5.0;
  3. =1.4.2 точная фиксация одной версии;
  4. запись 1.4.2 без значка эквивалентна каретке.

Для домажорных линий правила строже: ^0.2.3 разрешает подъём только до 0.3.0, потому что до единицы минор считается мажором. Но главное даже не операторы. Главное в дисциплине, с которой экосистема держит совместимость.

История serde наглядна: версия 1.0.0 вышла 20 апреля 2017 года, и крейт живёт в первой мажорной линии больше девяти лет. API расширяется, внутренности переписывались, а переход с 1.0.0 на актуальную 1.0.229 это правка одной строки снимка, а не миграция кода. tokio повторяет путь: 1.0.0 вышла 23 декабря 2020 года, асинхронный рантайм держит первую мажорную линию почти шесть лет. За этим стоит понимание цены: мажор это раскол графа, половина экосистемы остаётся на старой линии, обёртки разъезжаются на две версии, страдают все.

Разрешение конфликтов версий и MSRV aware резолвер начиная с Rust 1.84

Резолвер Cargo собирает из всех манифестов один согласованный граф: каждая семантически совместимая линия схлопывается в одну версию, а несовместимые мажоры могут сосуществовать. Второе часто спасает: если одному крейту нужен regex 1.x, а другому regex 2.x, оба получат своё, и в бинарник лягут обе линии. Разобраться, кто кого тянет, помогает обратное дерево:

cargo tree -i serde

Вывод показывает всех, кто зависит от крейта, вплоть до корня проекта:

serde v1.0.229
├── serde_json v1.0.128
└── payment-service v0.9.2

Флаг -d выводит только дубликаты разных мажорных линий, кандидатов на ручное упрощение графа.

С версии Rust 1.84, январь 2025, у резолвера появился режим учёта минимальной версии компилятора. Проблема известная: свежий крейт требует новый rustc, а проект держит проверенную старую версию языка. Включается настройкой в .cargo/config.toml:

[resolver]
incompatible-rust-versions = "fallback"

После этого резолвер предпочитает версии, собираемые под заявленный rust-version. Ходовой пример: проект с rust-version 1.60 вместо свежего clap 4.5.23, требующего rustc 1.74, получит совместимую линию 4.0.32. А для проверки в CI есть переменная CARGO_RESOLVER_INCOMPATIBLE_RUST_VERSIONS, временно снимающая ограничение и показывающая, что вышло нового.

Контроль каждого обновления через cargo update и флаги locked frozen

Обновление зависимостей в Cargo это осознанный акт, а не фоновый процесс. Базовые команды:

cargo update
cargo update serde
cargo update serde --precise 1.0.229

Первая поднимет всё совместимое из диапазонов манифеста, вторая только один крейт, третья поставит ровно ту версию, что названа в команде, если она лежит в диапазоне. Для CI есть два строгих флага:

cargo build --locked
cargo build --frozen

Флаг --locked требует, чтобы снимок уже соответствовал манифесту, и падает при попытке молча его переписать. Флаг --frozen добавляет запрет на сетевые обращения, сборка работает только с готовым снимком. Для аудита устаревшего есть внешний инструмент cargo outdated с отчётом: что доступно в диапазонах, что требует расширения диапазона, что сломает сборку. И отдельная механика отзыва версии:

cargo yank Адрес электронной почты защищен от спам-ботов. Для просмотра адреса в браузере должен быть включен Javascript.

Отозванная версия исчезает из выбора для новых проектов, но остаётся доступной всем, у кого она уже стоит в снимке. Удалить опубликованное из реестра нельзя в принципе, код остаётся доступным для воспроизводимой сборки навсегда.

RustSec и cargo audit как регулярная проверка уязвимостей в графе зависимостей

Зависимости это не только обновления, но и уязвимости. База RustSec собирает описания проблем: уязвимости, неподдерживаемые крейты, небезопасные звенья. Типовая запись описывает затронутые версии интервалами, скажем от 0.4.0 до 0.6.1, исправленную версию и степень критичности, а идентификатор вида RUSTSEC-год-номер позволяет сослаться на неё в отчётах и в настройках пропусков. Подключение к проекту:

cargo install cargo-audit
cargo audit
cargo audit fix

Команда сверяет снимок с базой и печатает отчёт: какие крейты затронуты, с какой версии исправлено, есть ли готовое обновление внутри диапазонов. В связке с cargo deny появляется контроль шире:

cargo install cargo-deny
cargo deny check

Этот инструмент проверяет лицензии на совместимость, запрещённые источники крейтов и дубликаты по конфигурации из cargo-deny.toml. Для офлайн сред и сборок без сети есть cargo vendor, он складывает исходники всего графа в локальный каталог:

cargo vendor vendor/

Полученный набор подключается в .cargo/config.toml одной секцией и даёт полностью автономную сборку:

[source.crates-io]
replace-with = "vendored-sources"

[source.vendored-sources]
directory = "vendor"

Зрелые проекты ставят cargo audit в CI по расписанию, и уведомление о новой уязвимости приходит раньше, чем её успевают применить.

Рабочее пространство и наследование версий зависимостей в монорепозитории

Когда проект растёт до десятков крейтов, Cargo предлагает рабочее пространство. Общие зависимости объявляются один раз:

[workspace]
members = ["api", "worker", "core"]

[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
tokio = "1.38"

Члены пространства наследуют записи через workspace = true:

[dependencies]
serde = { workspace = true }

Выгода видна сразу: один снимок на весь репозиторий, одна согласованная линия serde для всех крейтов, один каталог сборочных артефактов вместо дюжины копий. Дубли версий между членами пространства исчезают по построению. Для локальных правок чужого крейта есть секция патчей:

[patch.crates-io]
serde = { path = "../my-serde-fork" }

Правка уходит в сборку сразу, а в реестр нет. Когда форк готов, патч снимается, и проект возвращается на официальную линию. Работает и сценарий с git репозиторием: зависимость можно временно заменить на конкретный коммит форка, не трогая манифесты членов.

Сравнение с npm и pip где предсказуемость обновлений ломается

Теперь обещанное сравнение. Историю npm помнит вся индустрия: весной 2016 года из реестра удалили пакет left-pad, всего дюжина строк кода, и посыпались тысячи сборок по всему миру, включая крупные фронтенд проекты. Реестр позволял изъять опубликованный пакет, и чужие проекты платили за это простоем. После скандала правила отзыва ужесточили, но сам принцип пришлось выстраивать регламентом. В Cargo неизменность опубликованного вшита в устройство реестра.

У npm есть вторая хроническая болячка, плоский node_modules: в коде доступен любой пакет, оказавшийся в общем каталоге установщика, даже не объявленный в манифесте. Это называют фантомными зависимостями, и они ломаются при обновлении каждого соседа по графу. В Cargo доступно ровно то, что объявлено: неописанный крейт в код не скомпилируется. И третья: package-lock.json появился только в npm 5 в 2017 году, через семь лет после запуска менеджера, и для библиотек его исторически не коммитили, отчего поведение установки зависело от локальной среды.

Классический pip в Python демонстрирует четвёртый сценарий: в одном окружении живёт одна версия пакета. Два проекта с несовместимыми требованиями к одной библиотеке не уживаются вместе, и команды тратят недели на выравнивание. requirements.txt исторически не фиксирует контрольные суммы, так что воспроизводимость собирают надстройками вроде poetry и pip-tools, то есть вторым слоем поверх базового инструмента.

Честности ради, у Rust свои шрамы. Домажорные крейты 0.x меняют API без обязательств, каретка ограничивает диапазон, но подъём с 0.3 на 0.4 всё равно требует правок. Смена мажора раскалывает граф: переход экосистемы с tokio 0.1 на 1.0 занял месяцы. Имена крейтов достаются первому пришедшему без регулятора. Но фундамент остаётся: неизменный реестр, обязательный снимок, один инструмент на весь цикл, и рутину обновлений это делает скучной в хорошем смысле.

Многолетнее сопровождение проверяет не язык, а рутину: обновился, собрал, проверил, задеплоил. Cargo делает рутину предсказуемой тремя слагаемыми: точный снимок гарантирует повторяемость сборки, семантическая дисциплина крейтов гарантирует совместимость внутри мажора, неизменный реестр гарантирует, что вчерашняя сборка не исчезнет. Резолвер умеет не тащить крейт, не собирающийся под заявленный компилятор, а cargo audit напоминает об уязвимостях раньше, чем их найдут снаружи. Именно поэтому зрелые Rust сервисы переживают годы без историй про сломанные сборки, а фраза поднимем зависимости вызывает зевок, а не дрожь. Скука в сопровождении зависимостей это не недостаток, а достигнутый результат, и Cargo с sparse индексом из Rust 1.70 и MSRV aware резолвером из 1.84 выдаёт его по умолчанию.