Building a Password Vault with Rust
A command-line interface (CLI) password manager built from scratch in Rust, demonstrating file I/O operations, JSON serialization/deserialization with serde, custom struct modeling, and robust error management guided by the Rust compiler.
1. Why Do We Need a Password Vault?
In modern development and day-to-day computing, managing credentials securely is a foundational requirement. Building a password vault on the CLI provides an ideal sandbox project to learn systems programming principles.
- Data Persistence: Moving beyond ephemeral in-memory storage to persistent JSON disk storage (
passwords.json). - User Interaction: Handling terminal input/output, parsing choices, and cleanly clearing screens.
- Serialization Layer: Translating native Rust data structures into text formats and vice-versa.
2. Core Mental Model & Architecture
The project follows a clean separation of concerns, divided across two core files: main.rs and password_entry.rs.
3. Project Initialization & File Layout
To begin development, initialize a new Cargo package and structure your files into a clean modular layout:
cargo new pass-vault --bin
cd pass-vault
Modular File Structure
src/main.rs: Entry point containing the main event loop, terminal clear utilities, and choice matching logic.src/password_entry.rs: Module handling struct definitions, user prompting, file reading/writing, and serialization logic.
4. Struct Modeling & Data Structures (password_entry.rs)
The vault revolves around a core data structure representing an individual credential entry. We leverage serde for serialization (Serialize, Deserialize).
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ServiceInfo {
pub service: String,
pub username: String,
pub password: String,
}
impl ServiceInfo {
// Constructor for a new service entry
pub fn new(service: String, username: String, password: String) -> Self {
ServiceInfo {
service,
username,
password,
}
}
}
5. Serialization and Deserialization Helpers
To bridge the gap between Rust structs and persistent storage on disk, we write helper functions to convert objects to and from JSON strings using serde_json.
impl ServiceInfo {
// Convert struct instance to a JSON string
pub fn to_json(&self) -> String {
serde_json::to_string(self).expect("Failed to serialize JSON")
}
// Parse a JSON string back into a ServiceInfo struct
pub fn from_json(json_str: &str) -> Self {
serde_json::from_str(json_str).expect("Failed to deserialize JSON")
}
}
6. Terminal Input Handling & Prompting
To capture user credentials cleanly, we implement a reusable prompt helper that reads from standard input (stdin), stores the response in a mutable string buffer, and trims trailing whitespaces or newlines.
use std::io;
pub fn prompt(message: &str) -> String {
println!("{}", message);
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.expect("Failed to read line");
input.trim().to_string()
}
When gathering complete entry details, the from_user_input function orchestrates multiple prompts:
pub fn from_user_input() -> ServiceInfo {
let service = prompt("Enter the service name (e.g., AWS, Render):");
let username = prompt("Enter the username:");
let password = prompt("Enter the password:");
ServiceInfo::new(service, username, password)
}
7. File Persistence: Writing to JSON
When a user adds a new entry, it must be persisted to passwords.json. We use standard library file options (OpenOptions) to create the file if it does not exist, or append to it if it does.
use std::fs::OpenOptions;
use std::io::Write;
pub fn write_to_file(&self) {
let json_output = format!("{}\n", self.to_json());
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open("passwords.json")
.expect("Error opening file");
file.write_all(json_output.as_bytes())
.expect("There was an error writing to the file");
println!("Successfully wrote to passwords.json");
}
8. File Reading: Parsing Stored Passwords
To list or search entries, the application reads passwords.json line-by-line, deserializing each line back into a ServiceInfo instance and collecting them into a Vec<ServiceInfo>.
use std::fs::File;
use std::io::{BufRead, BufReader};
pub fn read_passwords_from_file() -> Vec<ServiceInfo> {
let file = File::open("passwords.json").expect("Error opening passwords.json");
let reader = BufReader::new(file);
let mut services = Vec::new();
for line in reader.lines() {
if let Ok(content) = line {
if !content.trim().is_empty() {
let info = ServiceInfo::from_json(&content);
services.push(info);
}
}
}
services
}
9. Main CLI Event Loop (main.rs)
The main function coordinates terminal output, prints an ASCII art banner, displays the menu loop, and matches user inputs against available actions.
mod password_entry;
use password_entry::{ServiceInfo, read_passwords_from_file, prompt};
use std::io::{self, Write};
fn clear_screen() {
print!("\x1B[2J\x1B[1;1H");
}
fn main() {
loop {
clear_screen();
println!("=================================");
println!(" P A S S V A U L T ");
println!("=================================");
println!("1. Add Entry");
println!("2. List All Entries");
println!("3. Search Entry");
println!("4. Quit");
println!("---------------------------------");
let choice = prompt("Enter your choice (1-4):");
match choice.as_str() {
"1" => {
clear_screen();
println!("--- Add New Password Entry ---");
let entry = password_entry::from_user_input();
entry.write_to_file();
println!("\nPress Enter to continue...");
let _ = prompt("");
}
"2" => {
clear_screen();
println!("--- All Stored Passwords ---");
let services = read_passwords_from_file();
for item in services {
println!("Service: {}", item.service);
println!("Username: {}", item.username);
println!("Password: {}", item.password);
println!("----------------------------");
}
println!("\nPress Enter to continue...");
let _ = prompt("");
}
"3" => {
clear_screen();
println!("--- Search Password Entry ---");
let search_term = prompt("Enter service name to search:");
let services = read_passwords_from_file();
let mut found = false;
for item in services {
if item.service.to_lowercase() == search_term.to_lowercase() {
println!("\n[Match Found]");
println!("Service: {}", item.service);
println!("Username: {}", item.username);
println!("Password: {}", item.password);
found = true;
}
}
if !found {
println!("\nNo entry found for service: '{}'", search_term);
}
println!("\nPress Enter to continue...");
let _ = prompt("");
}
"4" => {
clear_screen();
println!("Exiting Pass Vault. Goodbye!");
break;
}
_ => {
println!("Invalid choice! Please select between 1 and 4.");
std::thread::sleep(std::time::Duration::from_secs(1));
}
}
}
}
10. Industry Reality & Debugging with the Rust Compiler
When developing projects without an active IDE server (like Rust Analyzer), compilation errors are inevitable. The speaker demonstrates a vital Rust mindset: embracing the compiler as a partner.
Common Pitfalls & Fixes Encountered:
Summary and Key Takeaways
- Modular Architecture: Separating data structures, file I/O, and CLI presentation into
main.rsandpassword_entry.rskeeps code maintainable. - Data Persistence via JSON: Utilizing
serdeandserde_jsonallows seamless mapping between Rust structs and text-based disk storage (passwords.json). - File Append Strategy:
OpenOptions::new().create(true).append(true)ensures new vault entries are safely added without overwriting existing records. - Line-by-Line Reading:
BufReaderand.lines()provide an efficient way to stream and parse multi-line JSON datasets from disk into vectors. - Robust Error Handling: Rust's strict typing and compiler feedback ensure memory safety and catch structural bugs before runtime execution.
- Interactive CLI Flow: Infinite loops (
loop) combined with standard input reading (io::stdin().read_line) create fluid, responsive terminal applications. - Compiler as a Co-Pilot: Rust's descriptive error messages make iterative debugging straightforward, even without real-time IDE linters.