Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ Thumbs.db
# Cargo.lock # Keep for binaries
**/target/

# Zig
.zig-cache/
zig-out/

# Elixir
/cover/
/doc/
Expand Down
112 changes: 107 additions & 5 deletions crates/januskey-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ struct Cli {
#[arg(short = 'C', long, global = true)]
dir: Option<PathBuf>,

/// Repository directory to operate on (alias of --dir; takes precedence)
#[arg(short = 'r', long, global = true)]
repo: Option<PathBuf>,

/// Dry run mode (don't actually make changes)
#[arg(long, global = true)]
dry_run: bool,
Expand All @@ -47,8 +51,12 @@ struct Cli {

#[derive(Subcommand)]
enum Commands {
/// Initialize JanusKey in the current directory
Init,
/// Initialize JanusKey in a directory (defaults to the working directory)
Init {
/// Directory to initialize (created if it does not exist).
/// Overrides --dir / --repo when given.
path: Option<PathBuf>,
},

/// Delete files (reversible)
#[command(alias = "rm")]
Expand Down Expand Up @@ -101,6 +109,14 @@ enum Commands {
new_name: PathBuf,
},

/// Obliterate a file: securely overwrite then remove it (NOT reversible).
/// Implements GDPR Article 17 "right to erasure".
Obliterate {
/// File(s) to obliterate
#[arg(required = true)]
paths: Vec<PathBuf>,
},

/// Undo the last operation(s)
Undo {
/// Number of operations to undo
Expand Down Expand Up @@ -156,14 +172,17 @@ enum Commands {
fn main() -> Result<()> {
let cli = Cli::parse();

// Determine working directory
let working_dir = match cli.dir {
// Determine working directory. --repo takes precedence over --dir; both
// fall back to the current directory.
let working_dir = match cli.repo.or(cli.dir) {
Some(dir) => dir,
None => std::env::current_dir().context("Failed to get current directory")?,
};

match cli.command {
Commands::Init => cmd_init(&working_dir),
// `jk init <path>` targets the positional path when given; otherwise
// it initialises the working directory.
Commands::Init { path } => cmd_init(&path.unwrap_or(working_dir)),
Commands::Delete { paths, recursive } => {
cmd_delete(&working_dir, &paths, recursive, cli.dry_run, cli.yes)
}
Expand All @@ -179,6 +198,9 @@ fn main() -> Result<()> {
Commands::Rename { old_name, new_name } => {
cmd_move(&working_dir, &old_name.to_string_lossy(), &new_name, cli.dry_run)
}
Commands::Obliterate { paths } => {
cmd_obliterate(&working_dir, &paths, cli.dry_run, cli.yes)
}
Commands::Undo { count, id } => cmd_undo(&working_dir, count, id),
Commands::Begin { name } => cmd_begin(&working_dir, name),
Commands::Commit => cmd_commit(&working_dir),
Expand Down Expand Up @@ -562,6 +584,86 @@ fn cmd_copy(dir: &PathBuf, source: &PathBuf, destination: &PathBuf, dry_run: boo
Ok(())
}

fn cmd_obliterate(
dir: &PathBuf,
paths: &[PathBuf],
dry_run: bool,
auto_yes: bool,
) -> Result<()> {
use januskey::obliteration::obliterate_file;

// Resolve each path against the working directory if it is relative.
let targets: Vec<PathBuf> = paths
.iter()
.map(|p| if p.is_absolute() { p.clone() } else { dir.join(p) })
.collect();

if dry_run {
println!("{} Dry run - would obliterate:", "[DRY RUN]".cyan());
for t in &targets {
println!(" - {}", t.display());
}
return Ok(());
}

// Obliteration is irreversible — confirm unless --yes was given. Refuse
// outright (rather than silently auto-confirming) when stdin is not a
// terminal and no --yes was supplied, so destructive erasure never runs
// unattended without explicit consent.
if !auto_yes {
use std::io::IsTerminal;
if !std::io::stdin().is_terminal() {
anyhow::bail!(
"refusing to obliterate without confirmation in non-interactive mode; \
pass --yes/-y to confirm"
);
}
println!(
"{} Obliteration is {} — content will be unrecoverable:",
"⚠".yellow(),
"irreversible".red()
);
for t in &targets {
println!(" - {}", t.display());
}
if !Confirm::new()
.with_prompt("Continue?")
.default(false)
.interact()?
{
println!("{}", "Cancelled".red());
return Ok(());
}
}

let mut obliterated = 0;
for t in &targets {
match obliterate_file(t) {
Ok(proof) => {
obliterated += 1;
println!(
"{} Obliterated {} ({} passes, proof {})",
"✓".green(),
t.display(),
proof.overwrite_passes,
&proof.id[..8]
);
}
Err(e) => {
eprintln!("{} Failed to obliterate {}: {}", "✗".red(), t.display(), e);
}
}
}

println!(
"{} Obliterated {} file(s) — erasure is permanent",
"✓".green(),
obliterated
);

Ok(())
}

fn cmd_undo(dir: &PathBuf, count: usize, id: Option<String>) -> Result<()> {
let mut jk = JanusKey::open(dir).context("Failed to open JanusKey directory")?;

Expand Down
31 changes: 31 additions & 0 deletions crates/januskey-cli/src/obliteration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,37 @@ fn secure_overwrite(path: &Path) -> Result<usize> {
Ok(OVERWRITE_PASSES)
}

/// Obliterate an arbitrary file on disk (not necessarily in the content
/// store): hash its current content, securely overwrite it with
/// [`OVERWRITE_PASSES`] passes, remove it, and return a proof of erasure.
///
/// This is the GDPR Article 17 "right to erasure" primitive applied to a
/// concrete filesystem path, used by the `jk obliterate <path>` command.
/// Unlike [`ObliterationManager::obliterate`] it does not consult the content
/// store, so it works on files the repository never ingested.
///
/// TODO(product): also scrub any content-store copies and prune the
/// associated operation-log entries so no recoverable trace remains, and
/// thread the resulting proof into the obliteration audit log.
pub fn obliterate_file(path: &Path) -> Result<ObliterationProof> {
if !path.exists() {
return Err(JanusError::FileNotFound(format!(
"{} not found",
path.display()
)));
}

// Record what we are about to destroy (content hash, for the proof).
let content = fs::read(path)?;
let content_hash = ContentHash::from_bytes(&content);

// DoD 5220.22-M style multi-pass overwrite, then unlink.
let passes = secure_overwrite(path)?;
fs::remove_file(path)?;

Ok(ObliterationProof::generate(&content_hash, passes))
}

/// Verify that content no longer exists at a path
pub fn verify_obliteration(path: &Path, original_hash: &ContentHash) -> Result<bool> {
if !path.exists() {
Expand Down
Loading
Loading