feat(notfiles): hexagonal architecture refactor with adapters, integration tests, and notsecrets cleanup
Some checks failed
Public Repo Readiness / Check Public Repo Readiness (push) Has been cancelled

- Extract FileStore port and InMemoryFileStore/FileStoreImpl/Reporter adapters
- Add Reporter trait to notcore with TerminalReporter and JsonReporter
- Refactor linker, package, status modules to use FileStore port
- Add integration test suite with dotfiles fixtures
- Add cross-crate integration tests
- Remove ssh_rsa identity/recipient (unsupported)
- Add rustqual.toml, .sccignore, notfiles.toml config files
- Update CLAUDE.md, README.md, AGENTS.md with current architecture
This commit is contained in:
2026-07-08 13:07:42 -04:00
parent 12ad322608
commit 6427d188e6
64 changed files with 1922 additions and 287 deletions

BIN
crates/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -3,10 +3,16 @@ name = "notcore"
version = "0.1.0"
edition = "2024"
license.workspace = true
repository.workspace = true
homepage.workspace = true
keywords.workspace = true
categories.workspace = true
description = "Shared types and config for the notfiles dotfiles manager"
[dependencies]
serde = { workspace = true }
toml = { workspace = true }
thiserror = { workspace = true }
dirs = { workspace = true }
strsim = { workspace = true }
anyhow = { workspace = true }

View File

@@ -18,6 +18,14 @@ pub struct Defaults {
pub target: String,
#[serde(default = "default_ignore")]
pub ignore: Vec<String>,
/// If set, only these package names are discovered.
/// Mutually exclusive with `exclude`.
#[serde(default)]
pub include: Vec<String>,
/// If set, these package names are skipped during discovery.
/// Mutually exclusive with `include`.
#[serde(default)]
pub exclude: Vec<String>,
}
impl Default for Defaults {
@@ -25,6 +33,8 @@ impl Default for Defaults {
Self {
target: default_target(),
ignore: default_ignore(),
include: Vec::new(),
exclude: Vec::new(),
}
}
}
@@ -52,6 +62,10 @@ pub struct PackageConfig {
pub target: Option<String>,
#[serde(default)]
pub ignore: Vec<String>,
/// If non-empty, this package is only linked on these platforms.
/// Valid values: "macos", "linux".
#[serde(default)]
pub platforms: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
@@ -100,6 +114,50 @@ impl Config {
.unwrap_or(&self.defaults.target)
}
/// Returns the include list if non-empty, or None (meaning all).
pub fn included_packages(&self) -> Option<Vec<&str>> {
if self.defaults.include.is_empty() {
None
} else {
let mut v: Vec<&str> = self.defaults.include.iter().map(|s| s.as_str()).collect();
v.sort();
Some(v)
}
}
/// Returns true if this package name appears in the exclude list.
pub fn is_package_excluded(&self, name: &str) -> bool {
self.defaults.exclude.iter().any(|e| e == name)
}
/// Validate config invariants. Returns Err if include and exclude
/// are both non-empty.
pub fn validate(&self) -> Result<(), NotfilesError> {
if !self.defaults.include.is_empty() && !self.defaults.exclude.is_empty() {
return Err(NotfilesError::Validation(
"include and exclude are mutually exclusive".into(),
));
}
Ok(())
}
/// Returns true if the package should be linked on the current OS.
/// Empty platforms list means "all platforms".
pub fn is_package_for_current_platform(&self, package: &str) -> bool {
let platforms = match self.packages.get(package) {
Some(pkg) if !pkg.platforms.is_empty() => &pkg.platforms,
_ => return true,
};
let current = if cfg!(target_os = "macos") {
"macos"
} else if cfg!(target_os = "linux") {
"linux"
} else {
return false;
};
platforms.iter().any(|p| p == current)
}
pub fn ignore_patterns_for(&self, package: &str) -> Vec<&str> {
let mut patterns: Vec<&str> = self.defaults.ignore.iter().map(|s| s.as_str()).collect();
if let Some(pkg) = self.packages.get(package) {
@@ -111,6 +169,23 @@ impl Config {
}
}
/// Suggest the closest package name if the user made a typo.
/// Returns None if no close match (distance > 2).
pub fn suggest_package<'a>(name: &str, available: &[&'a str]) -> Option<&'a str> {
available
.iter()
.filter_map(|candidate| {
let dist = strsim::damerau_levenshtein(name, candidate);
if dist <= 2 {
Some((*candidate, dist))
} else {
None
}
})
.min_by_key(|(_, d)| *d)
.map(|(name, _)| name)
}
pub fn starter_toml() -> &'static str {
r#"[defaults]
target = "~"
@@ -138,6 +213,92 @@ mod tests {
assert_eq!(config.target_for("anything"), "~");
}
#[test]
fn test_include_filter_restricts_packages() {
let toml_str = r#"
[defaults]
target = "~"
ignore = [".git"]
include = ["git", "zsh", "nushell"]
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(
config.included_packages(),
Some(vec!["git", "nushell", "zsh"]),
);
}
#[test]
fn test_exclude_filter_blocks_packages() {
let toml_str = r#"
[defaults]
target = "~"
ignore = [".git"]
exclude = ["scripts", "docs", "tests"]
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert!(config.is_package_excluded("scripts"));
assert!(!config.is_package_excluded("zsh"));
}
#[test]
fn test_include_and_exclude_mutual_exclusion() {
let toml_str = r#"
[defaults]
target = "~"
include = ["git"]
exclude = ["scripts"]
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert!(config.validate().is_err());
}
#[test]
fn test_no_include_no_exclude_returns_none() {
let config = Config::default();
assert_eq!(config.included_packages(), None);
assert!(!config.is_package_excluded("anything"));
assert!(config.validate().is_ok());
}
#[test]
fn test_platform_filter_linux_only() {
let toml_str = r#"
[defaults]
target = "~"
[packages.nixos]
platforms = ["linux"]
[packages.git]
"#;
let config: Config = toml::from_str(toml_str).unwrap();
// On macOS this should be false, on Linux true
if cfg!(target_os = "macos") {
assert!(!config.is_package_for_current_platform("nixos"));
} else if cfg!(target_os = "linux") {
assert!(config.is_package_for_current_platform("nixos"));
}
// git has no platform filter — always matches
assert!(config.is_package_for_current_platform("git"));
// unknown package — no config entry, always matches
assert!(config.is_package_for_current_platform("zsh"));
}
#[test]
fn test_platform_filter_macos_only() {
let toml_str = r#"
[packages.vscode]
platforms = ["macos"]
"#;
let config: Config = toml::from_str(toml_str).unwrap();
if cfg!(target_os = "macos") {
assert!(config.is_package_for_current_platform("vscode"));
} else {
assert!(!config.is_package_for_current_platform("vscode"));
}
}
#[test]
fn test_parse_config() {
let toml_str = r#"

View File

@@ -17,6 +17,12 @@ pub enum NotfilesError {
#[error("state file error: {0}")]
State(String),
#[error("glob error: {0}")]
Glob(String),
#[error("validation error: {0}")]
Validation(String),
#[error("{0}")]
Io(#[from] std::io::Error),

View File

@@ -1,9 +1,17 @@
//! Core types and configuration for the notfiles dotfiles manager.
//!
//! This crate provides shared types used across the notfiles workspace:
//! configuration parsing, error types, path utilities, and the reporter
//! trait for output abstraction.
pub mod config;
pub mod error;
pub mod paths;
pub mod reporter;
pub mod types;
pub use config::{Config, Defaults, Method, PackageConfig};
pub use config::{Config, Defaults, Method, PackageConfig, suggest_package};
pub use error::NotfilesError;
pub use paths::{dotfiles_dir, expand_tilde};
pub use reporter::{LinkEvent, Reporter, SilentReporter};
pub use types::{HookPhase, HookSpec, PackageSpec, Report, Step, StepStatus};

View File

@@ -0,0 +1,46 @@
use std::path::Path;
/// Events emitted during link/unlink/status operations.
#[derive(Debug, Clone)]
pub enum LinkEvent<'a> {
Link {
source: &'a str,
target: &'a Path,
},
Copy {
source: &'a str,
target: &'a Path,
},
Skip {
source: &'a str,
reason: &'a str,
},
Backup {
from: &'a Path,
to: &'a Path,
},
Remove {
target: &'a Path,
},
CreateDir {
path: &'a Path,
},
DryRun {
action: &'a str,
source: &'a str,
target: &'a Path,
},
}
/// Port for reporting link/unlink progress. Library code calls this
/// instead of println!.
pub trait Reporter {
fn report(&self, event: &LinkEvent<'_>);
}
/// No-op reporter for when output is unwanted.
pub struct SilentReporter;
impl Reporter for SilentReporter {
fn report(&self, _event: &LinkEvent<'_>) {}
}

BIN
crates/notfiles/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -3,6 +3,10 @@ name = "notfiles"
version = "0.1.0"
edition = "2024"
license.workspace = true
repository.workspace = true
homepage.workspace = true
keywords.workspace = true
categories.workspace = true
description = "A modern dotfiles manager — pure Rust alternative to GNU Stow"
[[bin]]
@@ -16,9 +20,11 @@ path = "src/lib.rs"
[dependencies]
notcore = { workspace = true }
clap = { workspace = true }
clap_complete = { workspace = true }
globset = { workspace = true }
chrono = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
toml = { workspace = true }
anyhow = { workspace = true }
dirs = { workspace = true }

View File

@@ -0,0 +1,293 @@
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use crate::ports::FileStore;
/// In-memory file system for testing. Not thread-safe.
pub struct InMemoryFileStore {
files: RefCell<HashMap<PathBuf, Vec<u8>>>,
dirs: RefCell<HashSet<PathBuf>>,
symlinks: RefCell<HashMap<PathBuf, PathBuf>>,
}
impl InMemoryFileStore {
pub fn new() -> Self {
Self {
files: RefCell::new(HashMap::new()),
dirs: RefCell::new(HashSet::new()),
symlinks: RefCell::new(HashMap::new()),
}
}
pub fn add_file(&self, path: impl AsRef<Path>, content: &[u8]) {
let path = path.as_ref().to_path_buf();
// Ensure parent dirs exist
if let Some(parent) = path.parent() {
self.ensure_parents(parent);
}
self.files.borrow_mut().insert(path, content.to_vec());
}
pub fn add_dir(&self, path: impl AsRef<Path>) {
self.ensure_parents(path.as_ref());
}
pub fn has_symlink(&self, link: &Path) -> bool {
self.symlinks.borrow().contains_key(link)
}
pub fn symlink_target(&self, link: &Path) -> Option<PathBuf> {
self.symlinks.borrow().get(link).cloned()
}
fn ensure_parents(&self, path: &Path) {
let mut current = path.to_path_buf();
let mut dirs = self.dirs.borrow_mut();
loop {
if !dirs.insert(current.clone()) {
break;
}
match current.parent() {
Some(p) if p != current => current = p.to_path_buf(),
_ => break,
}
}
}
}
impl Default for InMemoryFileStore {
fn default() -> Self {
Self::new()
}
}
impl FileStore for InMemoryFileStore {
fn read(&self, path: &Path) -> Result<Vec<u8>, std::io::Error> {
// Follow symlinks
if let Some(target) = self.symlinks.borrow().get(path) {
return self.read(target);
}
self.files.borrow().get(path).cloned().ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::NotFound, path.display().to_string())
})
}
fn read_to_string(&self, path: &Path) -> Result<String, std::io::Error> {
let bytes = self.read(path)?;
String::from_utf8(bytes)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
}
fn write(&self, path: &Path, contents: &[u8]) -> Result<(), std::io::Error> {
if let Some(parent) = path.parent() {
self.ensure_parents(parent);
}
self.files
.borrow_mut()
.insert(path.to_path_buf(), contents.to_vec());
Ok(())
}
fn rename(&self, from: &Path, to: &Path) -> Result<(), std::io::Error> {
let content = self.files.borrow_mut().remove(from);
if let Some(content) = content {
if let Some(parent) = to.parent() {
self.ensure_parents(parent);
}
self.files.borrow_mut().insert(to.to_path_buf(), content);
return Ok(());
}
if self.symlinks.borrow_mut().remove(from).is_some() {
return Ok(());
}
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
from.display().to_string(),
))
}
fn remove_file(&self, path: &Path) -> Result<(), std::io::Error> {
if self.files.borrow_mut().remove(path).is_some() {
return Ok(());
}
if self.symlinks.borrow_mut().remove(path).is_some() {
return Ok(());
}
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
path.display().to_string(),
))
}
fn remove_dir_all(&self, path: &Path) -> Result<(), std::io::Error> {
let path = path.to_path_buf();
self.files.borrow_mut().retain(|k, _| !k.starts_with(&path));
self.dirs.borrow_mut().retain(|k| !k.starts_with(&path));
self.symlinks
.borrow_mut()
.retain(|k, _| !k.starts_with(&path));
Ok(())
}
fn remove_dir(&self, path: &Path) -> Result<(), std::io::Error> {
self.dirs.borrow_mut().remove(path);
Ok(())
}
fn read_link(&self, path: &Path) -> Result<PathBuf, std::io::Error> {
self.symlinks.borrow().get(path).cloned().ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::NotFound, path.display().to_string())
})
}
fn symlink_metadata(&self, path: &Path) -> Result<std::fs::Metadata, std::io::Error> {
// We can't construct real Metadata, but we can indicate existence
// by returning NotFound when the path doesn't exist
if self.files.borrow().contains_key(path)
|| self.dirs.borrow().contains(path)
|| self.symlinks.borrow().contains_key(path)
{
// Return metadata of a real temp file as a stand-in
// This is a known limitation of the in-memory store
Err(std::io::Error::other(
"InMemoryFileStore: metadata not available",
))
} else {
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
path.display().to_string(),
))
}
}
fn metadata(&self, path: &Path) -> Result<std::fs::Metadata, std::io::Error> {
self.symlink_metadata(path)
}
fn create_dir_all(&self, path: &Path) -> Result<(), std::io::Error> {
self.ensure_parents(path);
Ok(())
}
fn read_dir(&self, path: &Path) -> Result<Vec<PathBuf>, std::io::Error> {
let path = path.to_path_buf();
let mut children = Vec::new();
for key in self.files.borrow().keys() {
if key.parent() == Some(&path) {
children.push(key.clone());
}
}
for dir in self.dirs.borrow().iter() {
if dir.parent() == Some(&path) && dir != &path && !children.contains(dir) {
children.push(dir.clone());
}
}
for key in self.symlinks.borrow().keys() {
if key.parent() == Some(&path) && !children.contains(key) {
children.push(key.clone());
}
}
Ok(children)
}
#[cfg(unix)]
fn symlink(&self, target: &Path, link: &Path) -> Result<(), std::io::Error> {
if let Some(parent) = link.parent() {
self.ensure_parents(parent);
}
self.symlinks
.borrow_mut()
.insert(link.to_path_buf(), target.to_path_buf());
Ok(())
}
fn exists(&self, path: &Path) -> bool {
self.files.borrow().contains_key(path)
|| self.dirs.borrow().contains(path)
|| self.symlinks.borrow().contains_key(path)
}
fn is_dir(&self, path: &Path) -> bool {
self.dirs.borrow().contains(path)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add_file_creates_parents() {
let fs = InMemoryFileStore::new();
fs.add_file("/a/b/c.txt", b"hello");
assert!(fs.is_dir(Path::new("/a")));
assert!(fs.is_dir(Path::new("/a/b")));
assert!(fs.exists(Path::new("/a/b/c.txt")));
}
#[test]
fn test_read_write_roundtrip() {
let fs = InMemoryFileStore::new();
fs.write(Path::new("/tmp/f.txt"), b"data").unwrap();
assert_eq!(fs.read(Path::new("/tmp/f.txt")).unwrap(), b"data");
assert_eq!(fs.read_to_string(Path::new("/tmp/f.txt")).unwrap(), "data");
}
#[test]
fn test_symlink_roundtrip() {
let fs = InMemoryFileStore::new();
fs.add_file("/src/config", b"content");
fs.symlink(Path::new("/src/config"), Path::new("/home/config"))
.unwrap();
assert!(fs.has_symlink(Path::new("/home/config")));
assert_eq!(
fs.read_link(Path::new("/home/config")).unwrap(),
PathBuf::from("/src/config")
);
// Reading through symlink
assert_eq!(fs.read(Path::new("/home/config")).unwrap(), b"content");
}
#[test]
fn test_rename_file() {
let fs = InMemoryFileStore::new();
fs.add_file("/a.txt", b"hello");
fs.rename(Path::new("/a.txt"), Path::new("/b.txt")).unwrap();
assert!(!fs.exists(Path::new("/a.txt")));
assert_eq!(fs.read(Path::new("/b.txt")).unwrap(), b"hello");
}
#[test]
fn test_remove_dir_all() {
let fs = InMemoryFileStore::new();
fs.add_file("/dir/a.txt", b"a");
fs.add_file("/dir/sub/b.txt", b"b");
fs.remove_dir_all(Path::new("/dir")).unwrap();
assert!(!fs.exists(Path::new("/dir/a.txt")));
assert!(!fs.exists(Path::new("/dir/sub/b.txt")));
assert!(!fs.is_dir(Path::new("/dir")));
}
#[test]
fn test_read_dir_lists_children() {
let fs = InMemoryFileStore::new();
fs.add_file("/dir/a.txt", b"a");
fs.add_file("/dir/b.txt", b"b");
fs.add_dir("/dir/sub");
let mut children = fs.read_dir(Path::new("/dir")).unwrap();
children.sort();
assert_eq!(
children,
vec![
PathBuf::from("/dir/a.txt"),
PathBuf::from("/dir/b.txt"),
PathBuf::from("/dir/sub"),
]
);
}
}

View File

@@ -1,3 +1,7 @@
pub mod fs;
pub mod memory;
pub mod reporter;
pub use fs::FileStoreImpl;
pub use memory::InMemoryFileStore;
pub use reporter::{JsonReporter, TerminalReporter};

View File

@@ -0,0 +1,81 @@
use serde_json::json;
use notcore::reporter::{LinkEvent, Reporter};
/// Terminal reporter with ANSI color output.
pub struct TerminalReporter;
/// JSON reporter: one JSON object per line to stdout.
pub struct JsonReporter;
impl Reporter for TerminalReporter {
fn report(&self, event: &LinkEvent<'_>) {
match event {
LinkEvent::Link { source, target } => {
println!(" \x1b[32mlink\x1b[0m {source} -> {}", target.display());
}
LinkEvent::Copy { source, target } => {
println!(" \x1b[32mcopy\x1b[0m {source} -> {}", target.display());
}
LinkEvent::Skip { source, reason } => {
println!(" \x1b[90mskip\x1b[0m {source} ({reason})");
}
LinkEvent::Backup { from, to } => {
println!(
" \x1b[33mbackup\x1b[0m {} -> {}",
from.display(),
to.display()
);
}
LinkEvent::Remove { target } => {
println!(" \x1b[31mremove\x1b[0m {}", target.display());
}
LinkEvent::CreateDir { path } => {
println!(" \x1b[90mcreate dir\x1b[0m {}", path.display());
}
LinkEvent::DryRun {
action,
source,
target,
} => {
println!(
" \x1b[36mwould {action}\x1b[0m {source} -> {}",
target.display()
);
}
}
}
}
impl Reporter for JsonReporter {
fn report(&self, event: &LinkEvent<'_>) {
let obj = match event {
LinkEvent::Link { source, target } => {
json!({"event": "link", "source": source, "target": target.to_string_lossy()})
}
LinkEvent::Copy { source, target } => {
json!({"event": "copy", "source": source, "target": target.to_string_lossy()})
}
LinkEvent::Skip { source, reason } => {
json!({"event": "skip", "source": source, "reason": reason})
}
LinkEvent::Backup { from, to } => {
json!({"event": "backup", "from": from.to_string_lossy(), "to": to.to_string_lossy()})
}
LinkEvent::Remove { target } => {
json!({"event": "remove", "target": target.to_string_lossy()})
}
LinkEvent::CreateDir { path } => {
json!({"event": "create_dir", "path": path.to_string_lossy()})
}
LinkEvent::DryRun {
action,
source,
target,
} => {
json!({"event": "dry_run", "action": action, "source": source, "target": target.to_string_lossy()})
}
};
println!("{}", serde_json::to_string(&obj).unwrap_or_default());
}
}

View File

@@ -15,7 +15,7 @@ impl IgnoreMatcher {
let glob = Glob::new(pattern)
.or_else(|_| Glob::new(&format!("**/{pattern}")))
.map_err(|e| {
NotfilesError::Other(format!("invalid ignore pattern '{pattern}': {e}"))
NotfilesError::Glob(format!("invalid ignore pattern '{pattern}': {e}"))
})?;
builder.add(glob);
// Also add a recursive variant so "foo" matches "a/foo" etc.
@@ -28,7 +28,7 @@ impl IgnoreMatcher {
}
let globset = builder
.build()
.map_err(|e| NotfilesError::Other(format!("building ignore set: {e}")))?;
.map_err(|e| NotfilesError::Glob(format!("building ignore set: {e}")))?;
Ok(Self { globset })
}

View File

@@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
use crate::package::collect_files_with_store;
use crate::ports::FileStore;
use notcore::reporter::{LinkEvent, Reporter};
use notcore::{Config, Method, NotfilesError, expand_tilde};
const STATE_FILE: &str = ".notfiles-state.toml";
@@ -81,6 +82,15 @@ pub struct LinkOptions {
pub verbose: bool,
}
#[derive(Debug, Default)]
pub struct LinkResult {
pub package: String,
pub linked: usize,
pub copied: usize,
pub skipped: usize,
pub backed_up: usize,
}
pub fn link_package(
dotfiles_dir: &Path,
config: &Config,
@@ -88,17 +98,23 @@ pub fn link_package(
package: &str,
opts: &LinkOptions,
fs: &dyn FileStore,
) -> Result<(), NotfilesError> {
reporter: &dyn Reporter,
) -> Result<LinkResult, NotfilesError> {
let package_dir = dotfiles_dir.join(package);
let method = config.method_for(package);
let target_base = expand_tilde(config.target_for(package))?;
let files = collect_files_with_store(&package_dir, config, package, fs)?;
let mut result = LinkResult {
package: package.to_string(),
..Default::default()
};
if files.is_empty() {
if opts.verbose {
println!(" {package}: no files to link");
}
return Ok(());
reporter.report(&LinkEvent::Skip {
source: package,
reason: "no files to link",
});
return Ok(result);
}
for relative in &files {
@@ -108,19 +124,22 @@ pub fn link_package(
// Check if already correctly linked / copied
if is_already_linked(&source, &target, method, fs) {
if opts.verbose {
println!(" \x1b[90mskip\x1b[0m {source_display} (already linked)");
}
reporter.report(&LinkEvent::Skip {
source: &source_display,
reason: "already linked",
});
result.skipped += 1;
continue;
}
// Helper: save partial state then return an error.
let save_and_return = |state: &mut State, e: NotfilesError| -> Result<(), NotfilesError> {
if !opts.dry_run {
let _ = state.save(dotfiles_dir, fs);
}
Err(e)
};
let save_and_return =
|state: &mut State, e: NotfilesError| -> Result<LinkResult, NotfilesError> {
if !opts.dry_run {
let _ = state.save(dotfiles_dir, fs);
}
Err(e)
};
// Conflict detection
if fs.exists(&target) || fs.symlink_metadata(&target).is_ok() {
@@ -139,22 +158,20 @@ pub fn link_package(
if !opts.no_backup {
let backup = backup_path(&target);
if opts.dry_run {
println!(
" \x1b[33mwould backup\x1b[0m {} -> {}",
target.display(),
backup.display()
);
reporter.report(&LinkEvent::DryRun {
action: "backup",
source: &source_display,
target: &backup,
});
} else {
if opts.verbose {
println!(
" \x1b[33mbackup\x1b[0m {} -> {}",
target.display(),
backup.display()
);
}
reporter.report(&LinkEvent::Backup {
from: &target,
to: &backup,
});
if let Err(e) = fs.rename(&target, &backup) {
return save_and_return(state, e.into());
}
result.backed_up += 1;
}
} else if !opts.dry_run {
let rm_result = if fs.is_dir(&target) {
@@ -171,9 +188,7 @@ pub fn link_package(
// Create parent directories
if let Some(parent) = target.parent().filter(|p| !fs.exists(p)) {
if opts.dry_run {
if opts.verbose {
println!(" \x1b[90mwould create dir\x1b[0m {}", parent.display());
}
reporter.report(&LinkEvent::CreateDir { path: parent });
} else if let Err(e) = fs.create_dir_all(parent) {
return save_and_return(state, e.into());
}
@@ -186,12 +201,13 @@ pub fn link_package(
};
if opts.dry_run {
println!(
" \x1b[36mwould {action_word}\x1b[0m {source_display} -> {}",
target.display()
);
reporter.report(&LinkEvent::DryRun {
action: action_word,
source: &source_display,
target: &target,
});
} else {
let link_result = match method {
let io_result = match method {
Method::Symlink => {
#[cfg(unix)]
{
@@ -210,14 +226,24 @@ pub fn link_package(
fs.write(&target, &content).map(|_| content.len() as u64)
}
};
if let Err(e) = link_result {
if let Err(e) = io_result {
return save_and_return(state, e.into());
}
if opts.verbose {
println!(
" \x1b[32m{action_word}\x1b[0m {source_display} -> {}",
target.display()
);
match method {
Method::Symlink => {
reporter.report(&LinkEvent::Link {
source: &source_display,
target: &target,
});
result.linked += 1;
}
Method::Copy => {
reporter.report(&LinkEvent::Copy {
source: &source_display,
target: &target,
});
result.copied += 1;
}
}
state.add_entry(StateEntry {
@@ -233,7 +259,7 @@ pub fn link_package(
if !opts.dry_run {
state.save(dotfiles_dir, fs)?;
}
Ok(())
Ok(result)
}
pub fn unlink_package(
@@ -242,6 +268,7 @@ pub fn unlink_package(
package: &str,
opts: &LinkOptions,
fs: &dyn FileStore,
reporter: &dyn Reporter,
) -> Result<(), NotfilesError> {
// Validate the package: it must either exist as a directory in dotfiles_dir
// or have entries in state. A name that satisfies neither is a user error.
@@ -260,9 +287,10 @@ pub fn unlink_package(
.collect();
if entries.is_empty() {
if opts.verbose {
println!(" {package}: nothing to unlink");
}
reporter.report(&LinkEvent::Skip {
source: package,
reason: "nothing to unlink",
});
return Ok(());
}
@@ -273,9 +301,10 @@ pub fn unlink_package(
let source = PathBuf::from(&entry.source);
if !fs.exists(&target) && fs.symlink_metadata(&target).is_err() {
if opts.verbose {
println!(" \x1b[90mskip\x1b[0m {} (already gone)", target.display());
}
reporter.report(&LinkEvent::Skip {
source: &target.to_string_lossy(),
reason: "already gone",
});
if !opts.dry_run {
removed_entries.push((entry.source.clone(), entry.target.clone()));
}
@@ -287,64 +316,59 @@ pub fn unlink_package(
// Verify it's a symlink pointing to our source
if let Ok(link_target) = fs.read_link(&target) {
if link_target != source {
if opts.verbose {
println!(
" \x1b[33mskip\x1b[0m {} (symlink points elsewhere)",
target.display()
);
}
reporter.report(&LinkEvent::Skip {
source: &target.to_string_lossy(),
reason: "symlink points elsewhere",
});
continue;
}
} else {
if opts.verbose {
println!(" \x1b[33mskip\x1b[0m {} (not a symlink)", target.display());
}
reporter.report(&LinkEvent::Skip {
source: &target.to_string_lossy(),
reason: "not a symlink",
});
continue;
}
}
Method::Copy => match (fs.read(&source), fs.read(&target)) {
(Ok(source_bytes), Ok(target_bytes)) if source_bytes == target_bytes => {}
(Ok(_), Ok(_)) => {
if opts.verbose {
println!(
" \x1b[33mskip\x1b[0m {} (copied file diverged from source)",
target.display()
);
}
reporter.report(&LinkEvent::Skip {
source: &target.to_string_lossy(),
reason: "copied file diverged from source",
});
continue;
}
(Err(_), _) => {
if opts.verbose {
println!(
" \x1b[33mskip\x1b[0m {} (source missing for copied file)",
target.display()
);
}
reporter.report(&LinkEvent::Skip {
source: &target.to_string_lossy(),
reason: "source missing for copied file",
});
continue;
}
(_, Err(_)) => {
if opts.verbose {
println!(
" \x1b[33mskip\x1b[0m {} (cannot read copied target)",
target.display()
);
}
reporter.report(&LinkEvent::Skip {
source: &target.to_string_lossy(),
reason: "cannot read copied target",
});
continue;
}
},
}
if opts.dry_run {
println!(" \x1b[36mwould remove\x1b[0m {}", target.display());
reporter.report(&LinkEvent::DryRun {
action: "remove",
source: &target.to_string_lossy(),
target: &target,
});
} else {
if fs.is_dir(&target) {
fs.remove_dir_all(&target)?;
} else {
fs.remove_file(&target)?;
}
if opts.verbose {
println!(" \x1b[31mremove\x1b[0m {}", target.display());
}
reporter.report(&LinkEvent::Remove { target: &target });
// Clean up empty parent dirs
cleanup_empty_parents(&target, fs);
@@ -362,6 +386,100 @@ pub fn unlink_package(
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub fn adopt_files(
dotfiles_dir: &Path,
config: &Config,
state: &mut State,
package: &str,
files: &[String],
opts: &LinkOptions,
fs: &dyn FileStore,
reporter: &dyn Reporter,
) -> Result<LinkResult, NotfilesError> {
let package_dir = dotfiles_dir.join(package);
let target_base = expand_tilde(config.target_for(package))?;
let mut result = LinkResult {
package: package.to_string(),
..Default::default()
};
if !fs.is_dir(&package_dir) {
if opts.dry_run {
reporter.report(&LinkEvent::CreateDir { path: &package_dir });
} else {
fs.create_dir_all(&package_dir)?;
}
}
for file in files {
let relative = PathBuf::from(file);
let target = target_base.join(&relative);
let source = package_dir.join(&relative);
let display = format!("{package}/{}", relative.display());
if !fs.exists(&target) {
reporter.report(&LinkEvent::Skip {
source: &display,
reason: "target file does not exist",
});
result.skipped += 1;
continue;
}
if fs.exists(&source) {
reporter.report(&LinkEvent::Skip {
source: &display,
reason: "already exists in package",
});
result.skipped += 1;
continue;
}
if opts.dry_run {
reporter.report(&LinkEvent::DryRun {
action: "adopt",
source: &display,
target: &target,
});
continue;
}
// Create parent dirs in package
if let Some(parent) = source.parent().filter(|p| !fs.exists(p)) {
fs.create_dir_all(parent)?;
}
// Move target -> package source
fs.rename(&target, &source)?;
// Create symlink target -> source
#[cfg(unix)]
{
fs.symlink(&source, &target)?;
}
reporter.report(&LinkEvent::Link {
source: &display,
target: &target,
});
state.add_entry(StateEntry {
package: package.to_string(),
source: source.to_string_lossy().to_string(),
target: target.to_string_lossy().to_string(),
method: Method::Symlink,
linked_at: Utc::now().to_rfc3339(),
});
result.linked += 1;
}
if !opts.dry_run {
state.save(dotfiles_dir, fs)?;
}
Ok(result)
}
fn is_already_linked(source: &Path, target: &Path, method: Method, fs: &dyn FileStore) -> bool {
match method {
Method::Symlink => {

View File

@@ -5,6 +5,38 @@ use crate::ignore::IgnoreMatcher;
use crate::ports::FileStore;
use notcore::{Config, NotfilesError};
/// Discover packages filtered by the config's include/exclude lists and
/// platform constraints.
pub fn discover_packages_filtered(
dotfiles_dir: &Path,
config: &Config,
) -> Result<Vec<String>, NotfilesError> {
discover_packages_filtered_with_store(dotfiles_dir, config, &FileStoreImpl)
}
pub fn discover_packages_filtered_with_store(
dotfiles_dir: &Path,
config: &Config,
fs: &dyn FileStore,
) -> Result<Vec<String>, NotfilesError> {
let all = discover_packages_with_store(dotfiles_dir, fs)?;
let filtered: Vec<String> = if let Some(include) = config.included_packages() {
all.into_iter()
.filter(|p| include.contains(&p.as_str()))
.collect()
} else {
all.into_iter()
.filter(|p| !config.is_package_excluded(p))
.collect()
};
// Apply platform filter
let filtered = filtered
.into_iter()
.filter(|p| config.is_package_for_current_platform(p))
.collect();
Ok(filtered)
}
/// Discover available packages (subdirectories of the dotfiles dir).
pub fn discover_packages(dotfiles_dir: &Path) -> Result<Vec<String>, NotfilesError> {
discover_packages_with_store(dotfiles_dir, &FileStoreImpl)
@@ -48,13 +80,28 @@ pub fn resolve_packages_with_store(
requested: &[String],
fs: &dyn FileStore,
) -> Result<Vec<String>, NotfilesError> {
let available = discover_packages_with_store(dotfiles_dir, fs)?;
resolve_packages_filtered_with_store(dotfiles_dir, requested, &Config::default(), fs)
}
pub fn resolve_packages_filtered_with_store(
dotfiles_dir: &Path,
requested: &[String],
config: &Config,
fs: &dyn FileStore,
) -> Result<Vec<String>, NotfilesError> {
let available = discover_packages_filtered_with_store(dotfiles_dir, config, fs)?;
if requested.is_empty() {
return Ok(available);
}
for name in requested {
if !available.contains(name) {
return Err(NotfilesError::PackageNotFound { name: name.clone() });
let available_refs: Vec<&str> = available.iter().map(|s| s.as_str()).collect();
let suggestion = notcore::suggest_package(name, &available_refs);
let mut msg = name.clone();
if let Some(s) = suggestion {
msg = format!("{name} (did you mean '{s}'?)");
}
return Err(NotfilesError::PackageNotFound { name: msg });
}
}
Ok(requested.to_vec())
@@ -123,6 +170,40 @@ mod tests {
assert_eq!(pkgs, vec!["git", "zsh"]);
}
#[test]
fn test_discover_respects_include_filter() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join("git")).unwrap();
fs::create_dir(tmp.path().join("zsh")).unwrap();
fs::create_dir(tmp.path().join("scripts")).unwrap();
let toml_str = r#"
[defaults]
target = "~"
include = ["git", "zsh"]
"#;
let config: Config = toml::from_str(toml_str).unwrap();
let pkgs = discover_packages_filtered(tmp.path(), &config).unwrap();
assert_eq!(pkgs, vec!["git", "zsh"]);
}
#[test]
fn test_discover_respects_exclude_filter() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join("git")).unwrap();
fs::create_dir(tmp.path().join("scripts")).unwrap();
fs::create_dir(tmp.path().join("docs")).unwrap();
let toml_str = r#"
[defaults]
target = "~"
exclude = ["scripts", "docs"]
"#;
let config: Config = toml::from_str(toml_str).unwrap();
let pkgs = discover_packages_filtered(tmp.path(), &config).unwrap();
assert_eq!(pkgs, vec!["git"]);
}
#[test]
fn test_resolve_specific_packages() {
let tmp = TempDir::new().unwrap();

View File

@@ -1,5 +1,7 @@
use std::path::{Path, PathBuf};
use serde_json::json;
use crate::linker::State;
use crate::package::collect_files_with_store;
use crate::ports::FileStore;
@@ -121,6 +123,126 @@ pub fn package_status(
results
}
#[derive(Debug, PartialEq)]
pub enum DiffKind {
Identical,
Modified,
SourceOnly,
TargetOnly,
}
pub struct DiffEntry {
pub source_display: String,
pub target: PathBuf,
pub kind: DiffKind,
}
pub fn diff_package(
dotfiles_dir: &Path,
config: &Config,
state: &State,
package: &str,
fs: &dyn FileStore,
) -> Vec<DiffEntry> {
let mut results = Vec::new();
let method = config.method_for(package);
if method != Method::Copy {
return results;
}
let package_dir = dotfiles_dir.join(package);
let target_base = match expand_tilde(config.target_for(package)) {
Ok(p) => p,
Err(_) => return results,
};
// Check files tracked in state for this package
for entry in state.entries_for_package(package) {
let source = PathBuf::from(&entry.source);
let target = PathBuf::from(&entry.target);
let source_display = format!(
"{package}/{}",
source
.strip_prefix(&package_dir)
.unwrap_or(&source)
.display()
);
let kind = match (fs.read(&source), fs.read(&target)) {
(Ok(src), Ok(tgt)) => {
if src == tgt {
DiffKind::Identical
} else {
DiffKind::Modified
}
}
(Ok(_), Err(_)) => DiffKind::SourceOnly,
(Err(_), Ok(_)) => DiffKind::TargetOnly,
(Err(_), Err(_)) => continue,
};
results.push(DiffEntry {
source_display,
target,
kind,
});
}
// Also check untracked files from the package dir
if let Ok(files) = collect_files_with_store(&package_dir, config, package, fs) {
for relative in &files {
let source = package_dir.join(relative);
let target = target_base.join(relative);
// Skip if already covered by state entries
if results.iter().any(|r| r.target == target) {
continue;
}
let source_display = format!("{package}/{}", relative.display());
let kind = if !fs.exists(&target) {
DiffKind::SourceOnly
} else {
match (fs.read(&source), fs.read(&target)) {
(Ok(src), Ok(tgt)) if src == tgt => DiffKind::Identical,
(Ok(_), Ok(_)) => DiffKind::Modified,
_ => continue,
}
};
results.push(DiffEntry {
source_display,
target,
kind,
});
}
}
results
}
pub fn print_diff(package: &str, entries: &[DiffEntry]) {
let non_identical: Vec<_> = entries
.iter()
.filter(|e| e.kind != DiffKind::Identical)
.collect();
if non_identical.is_empty() {
println!(" {package}: all copies identical");
return;
}
println!(" \x1b[1m{package}\x1b[0m:");
for entry in &non_identical {
let label = match entry.kind {
DiffKind::Modified => "\x1b[33mmodified\x1b[0m",
DiffKind::SourceOnly => "\x1b[36msource only\x1b[0m",
DiffKind::TargetOnly => "\x1b[35mtarget only\x1b[0m",
DiffKind::Identical => unreachable!(),
};
println!(
" {label} {} -> {}",
entry.source_display,
entry.target.display()
);
}
}
pub fn print_status(package: &str, entries: &[StatusEntry]) {
if entries.is_empty() {
println!(" {package}: (empty)");
@@ -136,3 +258,18 @@ pub fn print_status(package: &str, entries: &[StatusEntry]) {
);
}
}
pub fn print_status_json(package: &str, entries: &[StatusEntry]) {
let items: Vec<_> = entries
.iter()
.map(|e| {
json!({
"source": e.source_display,
"target": e.target.to_string_lossy(),
"status": format!("{:?}", e.status).to_lowercase(),
})
})
.collect();
let obj = json!({"package": package, "entries": items});
println!("{}", serde_json::to_string(&obj).unwrap_or_default());
}

View File

@@ -0,0 +1,23 @@
# Test Fixture — Dotfiles
This directory is a test fixture representing a realistic dotfiles repo.
It is used by notfiles integration tests and serves as an example of the
expected directory structure.
## Package directories (linked to ~)
- `git/``.gitconfig` at root
- `zsh/``.zshrc` at root
- `nushell/``.config/nushell/` tree
- `starship/``.config/starship.toml`
- `alacritty/``.config/alacritty/alacritty.toml`
- `mise/``.config/mise/config.toml`
- `fish/``.config/fish/config.fish`
- `nixos/` — platform-gated to Linux only
- `vscode/` — deep `Library/Application Support/` path (macOS)
## Non-package directories (excluded via include list)
- `scripts/` — shell scripts, not a stow target
- `docs/` — documentation
- `secrets/` — encrypted env files, copy-only with ignore

View File

@@ -0,0 +1,2 @@
[font]
size = 14.0

View File

@@ -0,0 +1,3 @@
# Bootstrap Runbook
Steps for setting up a new machine.

View File

@@ -0,0 +1,2 @@
# Example fish config
set -gx EDITOR nvim

View File

@@ -0,0 +1,5 @@
[user]
name = Example User
email = user@example.com
[init]
defaultBranch = main

View File

@@ -0,0 +1,2 @@
[tools]
node = "lts"

View File

@@ -0,0 +1,4 @@
{ config, pkgs, ... }:
{
environment.systemPackages = with pkgs; [ vim git ];
}

View File

@@ -0,0 +1,34 @@
# Example notfiles.toml — mirrors a realistic dotfiles repo.
#
# This fixture is used by integration tests and serves as documentation
# for how to configure notfiles with a mixed-purpose dotfiles repo.
[defaults]
target = "~"
ignore = [".git", ".DS_Store", "README.md", "LICENSE", "notfiles.toml", ".notfiles-state.toml", ".nothooks-state.toml"]
# Only link actual stow packages — skip operational dirs.
include = ["git", "zsh", "nushell", "starship", "alacritty", "mise", "fish", "nixos", "vscode"]
[packages.git]
[packages.zsh]
[packages.nushell]
[packages.starship]
[packages.alacritty]
[packages.mise]
[packages.fish]
[packages.nixos]
platforms = ["linux"]
[packages.vscode]
[packages.secrets]
method = "copy"
ignore = ["*.example"]

View File

@@ -0,0 +1,2 @@
# Example aliases
alias ll = ls -l

View File

@@ -0,0 +1,2 @@
# Example nushell config
$env.config.show_banner = false

View File

@@ -0,0 +1,2 @@
# Example nushell env
$env.EDITOR = "nvim"

View File

@@ -0,0 +1,3 @@
#!/usr/bin/env bash
# Health check script — NOT a stow package.
echo "All good"

View File

@@ -0,0 +1,3 @@
#!/usr/bin/env bash
# Shared helpers — NOT a stow package.
log() { echo "[$(date)] $*"; }

View File

@@ -0,0 +1,3 @@
{
"EXAMPLE_KEY": "replace-me"
}

View File

@@ -0,0 +1,2 @@
[character]
success_symbol = "[>](bold green)"

View File

@@ -0,0 +1,4 @@
{
"editor.fontSize": 14,
"editor.tabSize": 2
}

View File

@@ -0,0 +1,2 @@
# Example zshrc
export EDITOR=nvim

View File

@@ -463,3 +463,164 @@ fn test_unlink_cleans_empty_dirs() {
// The .config/zsh directory should be cleaned up
assert!(!target.join(".config/zsh").exists());
}
// ── Fixture-based tests ─────────────────────────────────────────────────────
/// Copy the static fixture into a temp dir with a rewritten target, so tests
/// don't touch the real home directory.
fn setup_from_fixture(tmp: &TempDir) {
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/dotfiles");
let dotfiles = tmp.path().join("dotfiles");
let target = tmp.path().join("home");
fs::create_dir_all(&target).unwrap();
copy_dir_recursive(&fixture, &dotfiles).unwrap();
// Rewrite notfiles.toml to point target at our temp home
let config_path = dotfiles.join("notfiles.toml");
let content = fs::read_to_string(&config_path).unwrap();
let rewritten = content.replace(
"target = \"~\"",
&format!("target = \"{}\"", target.display()),
);
fs::write(&config_path, rewritten).unwrap();
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if src_path.is_dir() {
copy_dir_recursive(&src_path, &dst_path)?;
} else {
fs::copy(&src_path, &dst_path)?;
}
}
Ok(())
}
#[test]
fn test_fixture_include_excludes_non_packages() {
let tmp = TempDir::new().unwrap();
setup_from_fixture(&tmp);
let dotfiles = tmp.path().join("dotfiles");
let target = tmp.path().join("home");
let (_, _, ok) = run(&dotfiles, &["link"]);
assert!(ok);
// Stow packages should be linked
assert!(
target.join(".gitconfig").exists(),
".gitconfig should be linked"
);
assert!(target.join(".zshrc").exists(), ".zshrc should be linked");
assert!(
target.join(".config/nushell/config.nu").exists(),
"nushell config should be linked",
);
assert!(
target.join(".config/starship.toml").exists(),
"starship config should be linked",
);
// Non-package dirs should NOT be linked
assert!(
!target.join("lib/common.sh").exists(),
"scripts/ should not be linked",
);
assert!(
!target.join("bootstrap-runbook.md").exists(),
"docs/ should not be linked",
);
assert!(
!target.join("doctor.sh").exists(),
"scripts/ should not be linked",
);
}
#[test]
fn test_fixture_status_shows_all_packages() {
let tmp = TempDir::new().unwrap();
setup_from_fixture(&tmp);
let dotfiles = tmp.path().join("dotfiles");
let (stdout, _, ok) = run(&dotfiles, &["status"]);
assert!(ok);
// Should list stow packages
assert!(stdout.contains("git"), "status should show git package");
assert!(stdout.contains("zsh"), "status should show zsh package");
assert!(
stdout.contains("nushell"),
"status should show nushell package"
);
// Should NOT list excluded dirs
assert!(
!stdout.contains("scripts"),
"status should not show scripts"
);
assert!(!stdout.contains("docs"), "status should not show docs");
}
#[test]
fn test_fixture_deep_paths_link_correctly() {
let tmp = TempDir::new().unwrap();
setup_from_fixture(&tmp);
let dotfiles = tmp.path().join("dotfiles");
let target = tmp.path().join("home");
let (_, _, ok) = run(&dotfiles, &["link", "vscode"]);
assert!(ok);
let vscode_settings = target.join("Library/Application Support/Code/User/settings.json");
assert!(
vscode_settings.exists(),
"deep path should be linked: {}",
vscode_settings.display(),
);
}
#[test]
fn test_fixture_platform_filter_skips_linux_on_macos() {
if !cfg!(target_os = "macos") {
return; // This test is macOS-specific
}
let tmp = TempDir::new().unwrap();
setup_from_fixture(&tmp);
let dotfiles = tmp.path().join("dotfiles");
let target = tmp.path().join("home");
let (_, _, ok) = run(&dotfiles, &["link"]);
assert!(ok);
// nixos has platforms = ["linux"], so it should NOT be linked on macOS
assert!(
!target.join("configuration.nix").exists(),
"nixos package should be skipped on macOS",
);
// But macos-compatible packages should be linked
assert!(target.join(".gitconfig").exists());
}
#[test]
fn test_fixture_link_and_unlink_round_trip() {
let tmp = TempDir::new().unwrap();
setup_from_fixture(&tmp);
let dotfiles = tmp.path().join("dotfiles");
let target = tmp.path().join("home");
// Link all
let (_, _, ok) = run(&dotfiles, &["link"]);
assert!(ok);
assert!(target.join(".gitconfig").exists());
// Unlink all
let (_, _, ok) = run(&dotfiles, &["unlink"]);
assert!(ok);
assert!(!target.join(".gitconfig").exists());
assert!(!target.join(".zshrc").exists());
}

BIN
crates/notgraph/.DS_Store vendored Normal file

Binary file not shown.

BIN
crates/nothooks/.DS_Store vendored Normal file

Binary file not shown.

BIN
crates/notsecrets/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -14,7 +14,6 @@ serde = { workspace = true }
toml = { workspace = true }
x25519-dalek = { version = "2", features = ["static_secrets"] }
ed25519-dalek = "2"
rsa = { version = "0.9", features = ["sha2"] }
sha2 = "0.10"
hkdf = "0.12"
chacha20poly1305 = "0.10"

View File

@@ -9,9 +9,6 @@ pub use ssh_ed25519::SshEd25519Identity;
pub mod scrypt;
pub use scrypt::ScryptIdentity;
pub mod ssh_rsa;
pub use ssh_rsa::SshRsaIdentity;
pub mod encrypted;
pub use encrypted::EncryptedIdentity;

View File

@@ -1,110 +0,0 @@
use crate::error::AgeError;
use crate::identities::{FileKey, Identity, Stanza};
use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD};
use rsa::{Oaep, RsaPrivateKey, RsaPublicKey, traits::PublicKeyParts};
use sha2::{Digest, Sha256};
const TAG: &str = "ssh-rsa";
pub struct SshRsaIdentity {
private_key: RsaPrivateKey,
}
impl SshRsaIdentity {
pub fn from_private_key(private_key: RsaPrivateKey) -> Self {
Self { private_key }
}
fn fingerprint(&self) -> [u8; 4] {
rsa_pubkey_fingerprint(&RsaPublicKey::from(&self.private_key))
}
}
impl Identity for SshRsaIdentity {
fn unwrap_file_key(&self, stanza: &Stanza) -> Option<Result<FileKey, AgeError>> {
if stanza.tag != TAG {
return None;
}
if stanza.args.len() != 1 {
return Some(Err(AgeError::ParseError(
"ssh-rsa stanza must have 1 arg".to_string(),
)));
}
let fp = match STANDARD_NO_PAD.decode(&stanza.args[0]) {
Ok(b) => b,
Err(e) => {
return Some(Err(AgeError::ParseError(format!(
"fingerprint base64: {e}"
))));
}
};
if fp.as_slice() != self.fingerprint() {
return None;
}
Some(unwrap(stanza, &self.private_key))
}
}
fn unwrap(stanza: &Stanza, private_key: &RsaPrivateKey) -> Result<FileKey, AgeError> {
let padding = Oaep::new::<Sha256>();
let file_key_bytes = private_key
.decrypt(padding, &stanza.body)
.map_err(|_| AgeError::CryptoError("RSA-OAEP decrypt failed".to_string()))?;
FileKey::try_from(file_key_bytes.as_slice())
}
pub(crate) fn rsa_pubkey_fingerprint(public_key: &RsaPublicKey) -> [u8; 4] {
let n_bytes = public_key.n().to_bytes_be();
let e_bytes = public_key.e().to_bytes_be();
let mut hasher = Sha256::new();
hasher.update(&n_bytes);
hasher.update(&e_bytes);
let hash = hasher.finalize();
hash[..4].try_into().expect("SHA-256 is 32 bytes")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::recipients::Recipient;
use crate::recipients::ssh_rsa::SshRsaRecipient;
use rand::rngs::OsRng;
fn test_rsa_keypair() -> (RsaPrivateKey, RsaPublicKey) {
let private_key = RsaPrivateKey::new(&mut OsRng, 2048).expect("RSA keygen failed");
let public_key = RsaPublicKey::from(&private_key);
(private_key, public_key)
}
#[test]
fn ssh_rsa_wrap_unwrap_roundtrip() {
let (private_key, public_key) = test_rsa_keypair();
let recipient = SshRsaRecipient::from_public_key(public_key);
let identity = SshRsaIdentity::from_private_key(private_key);
let file_key = FileKey::new([0x55u8; 16]);
let stanza = recipient
.wrap_file_key(&file_key)
.expect("wrap should succeed");
assert_eq!(stanza.tag, "ssh-rsa");
assert_eq!(stanza.args.len(), 1);
let unwrapped = identity
.unwrap_file_key(&stanza)
.expect("identity should match")
.expect("unwrap should succeed");
assert_eq!(unwrapped.as_bytes(), file_key.as_bytes());
}
#[test]
fn ssh_rsa_wrong_fingerprint_returns_none() {
let (private_key1, _) = test_rsa_keypair();
let (_, public_key2) = test_rsa_keypair();
let recipient = SshRsaRecipient::from_public_key(public_key2);
let identity = SshRsaIdentity::from_private_key(private_key1);
let file_key = FileKey::new([0x55u8; 16]);
let stanza = recipient.wrap_file_key(&file_key).unwrap();
assert!(identity.unwrap_file_key(&stanza).is_none());
}
}

View File

@@ -15,13 +15,11 @@ pub use encrypt::Encryptor;
pub use error::AgeError;
pub use error::SecretsError;
pub use identities::{
EncryptedIdentity, FileKey, Header, Identity, ScryptIdentity, SshEd25519Identity,
SshRsaIdentity, Stanza, X25519Identity,
EncryptedIdentity, FileKey, Header, Identity, ScryptIdentity, SshEd25519Identity, Stanza,
X25519Identity,
};
pub use ports::{EnumerableSecretSource, IdentitySource, SecretSource};
pub use recipients::{
Recipient, ScryptRecipient, SshEd25519Recipient, SshRsaRecipient, X25519Recipient,
};
pub use recipients::{Recipient, ScryptRecipient, SshEd25519Recipient, X25519Recipient};
pub use resolver::SecretResolver;
pub use sources::{
BitwardenSource, DirenvSource, DotenvxSource, DotenvySource, EnvSource, FileSource, GsmSource,

View File

@@ -10,9 +10,6 @@ pub use ssh_ed25519::SshEd25519Recipient;
pub mod scrypt;
pub use scrypt::ScryptRecipient;
pub mod ssh_rsa;
pub use ssh_rsa::SshRsaRecipient;
/// Domain port: a recipient that can wrap a file key into a stanza.
pub trait Recipient {
fn wrap_file_key(&self, file_key: &FileKey) -> Result<Stanza, AgeError>;

View File

@@ -1,38 +0,0 @@
use crate::error::AgeError;
use crate::identities::ssh_rsa::rsa_pubkey_fingerprint;
use crate::identities::{FileKey, Stanza};
use crate::recipients::Recipient;
use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD};
use rsa::{Oaep, RsaPublicKey};
use sha2::Sha256;
const TAG: &str = "ssh-rsa";
pub struct SshRsaRecipient {
public_key: RsaPublicKey,
}
impl SshRsaRecipient {
pub fn from_public_key(public_key: RsaPublicKey) -> Self {
Self { public_key }
}
}
impl Recipient for SshRsaRecipient {
fn wrap_file_key(&self, file_key: &FileKey) -> Result<Stanza, AgeError> {
use rand::rngs::OsRng;
let padding = Oaep::new::<Sha256>();
let wrapped = self
.public_key
.encrypt(&mut OsRng, padding, file_key.as_bytes())
.map_err(|_| AgeError::CryptoError("RSA-OAEP encrypt failed".to_string()))?;
let fingerprint = rsa_pubkey_fingerprint(&self.public_key);
Ok(Stanza {
tag: TAG.to_string(),
args: vec![STANDARD_NO_PAD.encode(fingerprint)],
body: wrapped,
})
}
}