Skip to content

Build a Package Manager in Rust (Step-by-Step Guide)

DodaTech Updated 2026-06-21 8 min read

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(&registry);
            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

How is this different from Cargo?

Cargo is a full-featured build system and package manager for Rust with semantic versioning, crate hosting on crates.io, build scripts, and workspaces. Our minipkg is a teaching tool — it implements the core loop in about 200 lines of Rust.

Can I use this for production?

No. Our resolver has no Conflict Resolution, no Caching, no authentication, and no security hardening. It is designed for learning, not production use.

What goes inside a package tarball?

Any directory structure. The tarball is extracted into pkgs/{name}/. A package could contain executables, libraries, configuration files, or documentation — whatever the package author chooses to distribute.

Next Steps

  • Add semantic versioning Parsing with the semver crate
  • Implement Cargo-style workspace support with multiple local packages
  • Explore Rust's standard library for implementing a concurrent downloader
  • Build a web registry frontend to publish and browse packages

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro