Build a Package Manager in Rust (Step-by-Step Guide)
In this tutorial, you'll learn about Build a Package Manager in Rust (Step. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
Build a minimal package manager in Rust that resolves dependency trees, downloads packages from a remote registry, and installs them into a target directory — similar to how apt, pip, and cargo work under the hood.
What You'll Build
You will build a CLI tool called minipkg that can install packages and their transitive dependencies. It reads a pkg.json manifest, resolves the dependency graph, downloads each package from a registry as a gzipped tarball, extracts it into a pkgs/ directory, and reports the install manifest.
Why Build Your Own Package Manager?
Every language ecosystem has a package manager — npm, pip, cargo, gem — and they all solve the same fundamental problem: dependency resolution. Understanding this teaches you directed acyclic graphs, version constraints, and registry protocols. At DodaTech, package management patterns are used in DodaZIP's plugin system, where third-party compression filters are fetched and installed from an internal registry.
Prerequisites
- Rust 1.75+ installed (
rustup install 1.75) - Basic familiarity with Cargo and JSON
- curl for testing the registry
Step 1: Project Setup
cargo new minipkg
cd minipkg
Add dependencies to Cargo.toml:
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", features = ["blocking"] }
flate2 = "1"
sha2 = "0.10"
clap = { version = "4", features = ["derive"] }
Step 2: Manifest and Package Types
A package has a name, version, list of dependencies, and a download URL. We store these in a local registry as JSON files.
// src/types.rs
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PackageManifest {
pub name: String,
pub version: String,
pub dependencies: HashMap<String, String>,
pub url: String,
pub checksum: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ProjectManifest {
pub name: String,
pub dependencies: HashMap<String, String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct LockEntry {
pub name: String,
pub version: String,
pub checksum: String,
}
pub type LockFile = Vec<LockEntry>;
Step 3: Dependency Resolution
Dependency resolution builds a graph of what needs to be installed. We use a simple top-down resolver that fetches manifests recursively.
// src/resolver.rs
use std::collections::HashMap;
use crate::types::{PackageManifest, LockEntry};
pub struct Resolver {
registry_url: String,
}
impl Resolver {
pub fn new(registry_url: &str) -> Self {
Self { registry_url: registry_url.to_string() }
}
pub fn resolve(
&self,
deps: &HashMap<String, String>,
) -> Result<Vec<PackageManifest>, String> {
let mut resolved = Vec::new();
let mut seen = std::collections::HashSet::new();
for (name, version_req) in deps {
self.resolve_dep(name, version_req, &mut resolved, &mut seen)?;
}
Ok(resolved)
}
fn resolve_dep(
&self,
name: &str,
version_req: &str,
resolved: &mut Vec<PackageManifest>,
seen: &mut std::collections::HashSet<String>,
) -> Result<(), String> {
let key = format!("{}@{}", name, version_req);
if seen.contains(&key) {
return Ok(());
}
seen.insert(key);
let manifest = self.fetch_manifest(name)?;
if !satisfies(&manifest.version, version_req) {
return Err(format!(
"{} version {} does not satisfy '{}'",
name, manifest.version, version_req
));
}
for (dep_name, dep_ver) in &manifest.dependencies {
self.resolve_dep(dep_name, dep_ver, resolved, seen)?;
}
resolved.push(manifest);
Ok(())
}
fn fetch_manifest(&self, name: &str) -> Result<PackageManifest, String> {
let url = format!("{}/{}/latest.json", self.registry_url, name);
let resp = reqwest::blocking::get(&url)
.map_err(|e| format!("fetch {}: {}", name, e))?;
resp.json()
.map_err(|e| format!("parse {}: {}", name, e))
}
}
fn satisfies(version: &str, requirement: &str) -> bool {
if let Some(expected) = requirement.strip_prefix('=') {
return version == expected;
}
if let Some(expected) = requirement.strip_prefix('^') {
return version.starts_with(expected);
}
version.starts_with(requirement)
}
Expected output: Given a project depending on "logger": "^1.0", the resolver fetches {registry}/logger/latest.json and checks version compatibility.
Step 4: Download and Install
Packages are gzipped tarballs. We download them, verify the SHA256 checksum, and extract them to the install directory.
// src/installer.rs
use std::fs;
use std::path::Path;
use flate2::read::GzDecoder;
use sha2::{Sha256, Digest};
use crate::types::PackageManifest;
pub struct Installer {
target_dir: String,
}
impl Installer {
pub fn new(target_dir: &str) -> Self {
Self { target_dir: target_dir.to_string() }
}
pub fn install(&self, pkg: &PackageManifest) -> Result<(), String> {
let pkg_dir = format!("{}/{}", self.target_dir, pkg.name);
if Path::new(&pkg_dir).exists() {
return Ok(()); // already installed
}
let resp = reqwest::blocking::get(&pkg.url)
.map_err(|e| format!("download {}: {}", pkg.name, e))?;
let data = resp.bytes()
.map_err(|e| format!("read {}: {}", pkg.name, e))?;
let actual = hex::encode(Sha256::digest(&data));
if actual != pkg.checksum {
return Err(format!(
"checksum mismatch for {}: expected {}, got {}",
pkg.name, pkg.checksum, actual
));
}
fs::create_dir_all(&pkg_dir)
.map_err(|e| format!("mkdir {}: {}", pkg_dir, e))?;
let decoder = GzDecoder::new(&data[..]);
let mut archive = tar::Archive::new(decoder);
archive.unpack(&pkg_dir)
.map_err(|e| format!("extract {}: {}", pkg.name, e))?;
Ok(())
}
}
Expected output: After install, the pkgs/logger/ directory exists with the extracted package files.
Step 5: CLI
The CLI ties everything together with install and list subcommands.
// src/main.rs
use clap::{Parser, Subcommand};
use std::collections::HashMap;
use std::fs;
mod types;
mod resolver;
mod installer;
#[derive(Parser)]
#[command(name = "minipkg", about = "A minimal package manager")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Install {
#[arg(short, long, default_value = "pkg.json")]
manifest: String,
},
List,
}
fn main() -> Result<(), String> {
let cli = Cli::parse();
let registry = std::env::var("MINIPKG_REGISTRY")
.unwrap_or_else(|_| "http://localhost:8080".to_string());
let target_dir = "pkgs";
match cli.command {
Commands::Install { manifest } => {
let content = fs::read_to_string(&manifest)
.map_err(|e| format!("read {}: {}", manifest, e))?;
let project: types::ProjectManifest = serde_json::from_str(&content)
.map_err(|e| format!("parse {}: {}", manifest, e))?;
let resolver = resolver::Resolver::new(®istry);
let packages = resolver.resolve(&project.dependencies)?;
let installer = installer::Installer::new(target_dir);
for pkg in &packages {
println!("installing {} v{}", pkg.name, pkg.version);
installer.install(pkg)?;
}
let lock: types::LockFile = packages.iter().map(|p| {
types::LockEntry {
name: p.name.clone(),
version: p.version.clone(),
checksum: p.checksum.clone(),
}
}).collect();
let lock_json = serde_json::to_string_pretty(&lock)
.map_err(|e| format!("lock: {}", e))?;
fs::write("minipkg.lock", lock_json)
.map_err(|e| format!("write lock: {}", e))?;
println!("done — installed {} packages", packages.len());
}
Commands::List => {
let dir = fs::read_dir(target_dir)
.map_err(|e| format!("read {}: {}", target_dir, e))?;
for entry in dir {
let entry = entry.map_err(|e| format!("entry: {}", e))?;
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
println!("{}", entry.file_name().to_string_lossy());
}
}
}
}
Ok(())
}
Expected output: With pkg.json containing {"name":"myapp","dependencies":{"logger":"^1.0"}}, running cargo run -- install downloads logger, verifies its checksum, extracts it to pkgs/logger/, and writes minipkg.lock.
Architecture
flowchart LR
subgraph "Client"
MAN[pkg.json]
CLI[minipkg CLI]
LOCK[minipkg.lock]
PKGS[pkgs/ Directory]
end
subgraph "Registry (HTTP)"
REG[Registry Server
localhost:8080]
STORE[(Package Tarballs)]
end
MAN -->|read| CLI
CLI -->|resolve deps| REG
REG -->|return manifest| CLI
CLI -->|download tarball| REG
REG -->|gzipped tar| CLI
CLI -->|verify checksum| CLI
CLI -->|extract| PKGS
CLI -->|write| LOCK
Common Errors
1. Registry returns 404
The package does not exist at {registry}/{name}/latest.json. Start a local registry server or verify the MINIPKG_REGISTRY environment variable points to the correct URL.
2. Checksum mismatch The tarball was corrupted during download or the registry manifest specifies a wrong checksum. Always verify checksums before extracting — this prevents installing tampered packages.
3. Dependency version conflict
Package A requires logger ^1.0 but package B requires logger ^2.0. Our simple resolver fails in this case. Real package managers (npm, cargo) use SAT solvers or Backtracking to find a compatible version.
4. Circular dependency
If A depends on B and B depends on A, our resolver enters infinite Recursion until the stack overflows. The seen set prevents this, but a warning would help debugging.
5. Permission denied on extract
The pkgs/ directory may not be writable. Run with appropriate permissions or set target_dir to a user-writable location.
What is dependency resolution in a package manager?
Dependency resolution is the Process of taking a list of required packages and their version constraints, then finding a set of concrete package versions that satisfy all constraints simultaneously. Our resolver does a simple depth-first walk — if it can't satisfy a constraint, it errors immediately rather than Backtracking.
Practice Questions
1. What is the role of the lock file?
minipkg.lock records exactly which version and checksum was installed for each package. On subsequent installs, the lock file ensures the same versions are used — guaranteeing reproducible builds across machines.
2. Why does our resolver fail on version conflicts? Our resolver takes the first version it finds without Backtracking. Real resolvers like npm's try alternative versions when a conflict is detected. Implementing Backtracking turns the resolver into a SAT problem.
3. What does the checksum verify? The SHA256 checksum verifies the tarball's integrity. If the file was truncated during download, tampered with in transit, or corrupted on disk, the checksum won't match and installation is aborted.
4. Challenge: Offline cache
Add a ~/.minipkg/cache/ directory. Before downloading, check the cache for the package. On successful download, store the tarball in the cache so subsequent installs don't require network access.
5. Challenge: Version ranges
Extend the satisfies function to support >=1.0 <2.0 and ~1.2.3 (compatible with 1.2.3). Parse the version string into semver components and compare major, minor, patch individually.
FAQ
Next Steps
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro