init commit, cli working well

This commit is contained in:
2025-08-06 19:03:08 -03:00
parent 0aba7e5103
commit 8df0c55345
10 changed files with 1734 additions and 14 deletions

9
.gitignore vendored
View File

@@ -13,9 +13,6 @@ target
# Contains mutation testing data
**/mutants.out*/
# RustRover
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
imgs/
.github/
brainstorm.md

1218
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

24
Cargo.toml Normal file
View File

@@ -0,0 +1,24 @@
[package]
name = "medars"
version = "0.1.0"
edition = "2021"
description = "A command-line tool to inspect and remove metadata from image files"
authors = ["brockar <martinnguzman.mg@gmail.com>"]
license = "MIT"
[[bin]]
name = "medars"
path = "src/main.rs"
[dependencies]
tokio = { version = "1.47.1", features = ["full"] }
clap = { version = "4.5.43", features = ["derive"] }
kamadak-exif = "0.6.1"
ratatui = "0.29.0"
crossterm = "0.29.0"
anyhow = "1.0.98"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.142"
log = "0.4"
env_logger = "0.11.8"
rexiv2 = "0.10.0"

View File

@@ -1,2 +1,42 @@
# medars
medars is a simple and fast command-line application written in RuSt that allows users to inspect and remove MEtaDAta from image files.
# MEDARS
**ME**ta**DA**ta from image files in **R**u**S**t - A fast and simple command-line tool for inspecting and removing metadata from image files.
---
## WIP
## Features
- **View metadata**: Display metadata in human-readable table or JSON format
- **Remove metadata**: Clean images by removing all embedded metadata
- **Interactive TUI**: Terminal user interface for easy navigation
## Privacy & Security
MEDARS helps protect your privacy by:
- Removing potentially sensitive EXIF data (GPS coordinates, camera settings, timestamps)
- Working locally - no data sent to external services
- Preserving image quality while removing metadata
## Dependencies
This project requires the `gexiv2` library and its development headers.
On Ubuntu/Debian:
sudo apt install libgexiv2-dev
On Arch:
yay -S libgexiv2
If you see an error about `gexiv2.pc` or `gexiv2` not found, make sure the library is installed and `PKG_CONFIG_PATH` is set correctly.
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
## Acknowledgments
- Built with [Rust](https://www.rust-lang.org/)
- Uses [exif](https://crates.io/crates/exif) for metadata reading
- Terminal UI powered by [ratatui](https://crates.io/crates/ratatui)

4
build.rs Normal file
View File

@@ -0,0 +1,4 @@
fn main() {
// Link to exiv2 for rexiv2
println!("cargo:rustc-link-lib=dylib=exiv2");
}

114
src/main.rs Normal file
View File

@@ -0,0 +1,114 @@
use clap::{Parser, Subcommand};
use std::path::PathBuf;
mod metadata;
use metadata::MetadataHandler;
mod ui;
use ui::RatatuiUI;
#[derive(Parser)]
#[command(name = "medars")]
#[command(about = "A CLI tool to inspect and remove metadata from image files")]
#[command(version = "0.1.0")]
struct Cli {
/// Suppress output
#[arg(short, long, global = true)]
quiet: bool,
/// Optional file argument for default interactive mode
#[arg(value_name = "FILE")]
file: Option<PathBuf>,
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
/// Check if an image contains metadata
Check {
#[arg(value_name = "FILE")]
file: PathBuf,
},
/// View metadata in a readable format
View {
#[arg(value_name = "FILE")]
file: PathBuf,
/// Output format (json, table)
#[arg(short, long, default_value = "table")]
format: String,
},
/// Remove metadata from an image
Remove {
#[arg(value_name = "FILE")]
file: PathBuf,
/// Output file path (if not specified, overwrites original)
#[arg(short, long)]
output: Option<PathBuf>,
},
/// Interactive mode with TUI
Interactive {
#[arg(value_name = "FILE")]
file: Option<PathBuf>,
},
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
// If a subcommand is provided, handle as usual
if let Some(command) = &cli.command {
if let Commands::Interactive { file } = command {
let mut ui = RatatuiUI::new();
if !cli.quiet {
ui.run(file.clone()).await?;
}
return Ok(());
}
match command {
Commands::Check { file } => {
let handler = MetadataHandler::new();
let has_metadata = handler.has_metadata(&file)?;
if !cli.quiet {
if has_metadata {
log::info!("❌ Image contains metadata");
println!("❌ Image contains metadata");
} else {
log::warn!("✅ No metadata found in image");
eprintln!("✅ No metadata found in image");
}
}
}
Commands::View { file, format } => {
let handler = MetadataHandler::new();
if let Err(e) = handler.display_metadata(&file, &format, cli.quiet) {
log::error!("Error: {}", e);
eprintln!("Error: {}", e);
}
}
Commands::Remove { file, output } => {
let handler = MetadataHandler::new();
let output_path = output.as_ref().cloned().unwrap_or_else(|| file.clone());
handler.remove_metadata(&file, &output_path)?;
if !cli.quiet {
log::info!("✅ Metadata removed successfully, saved on: {}", output_path.display());
println!("✅ Metadata removed successfully, saved on: {}", output_path.display());
}
}
_ => {}
}
return Ok(());
}
// If no subcommand but a file is provided, run interactive mode
if cli.file.is_some() {
let mut ui = RatatuiUI::new();
if !cli.quiet {
ui.run(cli.file.clone()).await?;
}
return Ok(());
}
Ok(())
}

159
src/metadata.rs Normal file
View File

@@ -0,0 +1,159 @@
use std::{collections::HashMap, fs::File, io::BufReader, path::Path};
use anyhow::{Context, Result};
use exif;
pub struct MetadataHandler;
impl MetadataHandler {
pub fn new() -> Self {
Self
}
/// Get a formatted metadata table as a String
pub fn get_metadata_table(&self, path: &Path) -> Result<String> {
let metadata = self.extract_metadata(path)?;
if metadata.is_empty() {
return Ok("❌ No metadata found".to_string());
}
let mut table = String::from("📋 Image Metadata:\n");
table.push_str(&"".repeat(40));
table.push('\n');
for (key, value) in &metadata {
table.push_str(&format!("{}: {}\n", key, value));
}
table.push_str(&"".repeat(40));
table.push('\n');
table.push_str(&format!("📊 Total metadata fields: {}", metadata.len()));
Ok(table)
}
/// Check if an image has any metadata
pub fn has_metadata(&self, path: &Path) -> Result<bool> {
if !path.exists() {
anyhow::bail!("File does not exist: {}", path.display());
}
let file = File::open(path)?;
let mut bufreader = BufReader::new(&file);
match exif::Reader::new().read_from_container(&mut bufreader) {
Ok(exif_data) => Ok(exif_data.fields().count() > 0),
Err(_) => self.check_other_metadata(path),
}
}
/// Display metadata in the specified format ("json" or "table")
pub fn display_metadata(&self, path: &Path, format: &str, quiet: bool) -> Result<()> {
if !path.exists() {
anyhow::bail!("File does not exist: {}", path.display());
}
let metadata = self.extract_metadata(path)?;
match format.to_lowercase().as_str() {
"json" => self.display_json(&metadata, quiet)?,
_ => self.display_table(&metadata, quiet)?,
}
Ok(())
}
/// Remove all metadata from an image and save to output_path
pub fn remove_metadata(&self, input_path: &Path, output_path: &Path) -> Result<()> {
if !input_path.exists() {
anyhow::bail!("Input file does not exist: {}", input_path.display());
}
let image = rexiv2::Metadata::new_from_path(input_path)
.context("Failed to open image with rexiv2")?;
image.clear();
image.save_to_file(output_path)
.context("Failed to save image without metadata using rexiv2")?;
Ok(())
}
/// Extract all available metadata from an image
fn extract_metadata(&self, path: &Path) -> Result<HashMap<String, String>> {
let mut metadata = HashMap::new();
// EXIF
if let Ok(exif_data) = self.extract_exif_metadata(path) {
metadata.extend(exif_data);
}
// File info
if let Ok(file_metadata) = std::fs::metadata(path) {
metadata.insert("File Size".to_string(), format!("{} bytes", file_metadata.len()));
if let Ok(modified) = file_metadata.modified() {
metadata.insert("Modified".to_string(), format!("{:?}", modified));
}
}
// Dimensions
if let Ok(meta) = rexiv2::Metadata::new_from_path(path) {
let width = meta.get_pixel_width();
let height = meta.get_pixel_height();
if width > 0 && height > 0 {
metadata.insert("Dimensions".to_string(), format!("{}x{}", width, height));
}
}
Ok(metadata)
}
/// Extract EXIF metadata only
fn extract_exif_metadata(&self, path: &Path) -> Result<HashMap<String, String>> {
let file = File::open(path)?;
let mut bufreader = BufReader::new(&file);
let mut metadata = HashMap::new();
if let Ok(exif_data) = exif::Reader::new().read_from_container(&mut bufreader) {
for f in exif_data.fields() {
let tag_name = format!("{}", f.tag);
let value = f.display_value().with_unit(&exif_data).to_string();
metadata.insert(tag_name, value);
}
}
Ok(metadata)
}
/// Check for other metadata using rexiv2 (returns false if no EXIF)
fn check_other_metadata(&self, path: &Path) -> Result<bool> {
match rexiv2::Metadata::new_from_path(path) {
Ok(_) => Ok(false),
Err(_) => Ok(false),
}
}
/// Display metadata as a table in stdout
fn display_table(&self, metadata: &HashMap<String, String>, quiet: bool) -> Result<()> {
let has_exif = metadata.keys().any(|k| k != "File Size" && k != "Modified" && k != "Dimensions");
if !has_exif {
if !quiet {
eprintln!("No metadata in this image.");
if let Some(size) = metadata.get("File Size") {
println!("File Size: {}", size);
}
if let Some(modified) = metadata.get("Modified") {
println!("Modified: {}", modified);
}
if let Some(dim) = metadata.get("Dimensions") {
println!("Dimensions: {}", dim);
}
}
return Ok(());
}
if !quiet {
println!("📋 Image Metadata:");
println!("{}", "".repeat(60));
for (key, value) in metadata {
println!("{}: {}", key, value);
}
println!("{}", "".repeat(60));
println!("📊 Total metadata fields: {}", metadata.len());
}
Ok(())
}
/// Display metadata as pretty JSON in stdout
fn display_json(&self, metadata: &HashMap<String, String>, quiet: bool) -> Result<()> {
if !quiet {
if metadata.is_empty() {
eprintln!("⚠️ No Metadata found in image");
} else {
println!("{}", serde_json::to_string_pretty(metadata)?);
}
}
Ok(())
}
}

2
src/ui/mod.rs Normal file
View File

@@ -0,0 +1,2 @@
pub mod ratatui_ui;
pub use ratatui_ui::RatatuiUI;

168
src/ui/ratatui_ui.rs Normal file
View File

@@ -0,0 +1,168 @@
use std::path::PathBuf;
use anyhow::Result;
pub struct RatatuiUI;
impl RatatuiUI {
pub fn new() -> Self {
RatatuiUI
}
pub async fn run(&mut self, file: Option<PathBuf>) -> Result<()> {
use ratatui::{prelude::*, widgets::*};
use crossterm::{terminal, ExecutableCommand};
use std::io::stdout;
use tokio::task;
let mut stdout = stdout();
terminal::enable_raw_mode()?;
stdout.execute(terminal::EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = match Terminal::new(backend) {
Ok(t) => t,
Err(e) => {
let _ = terminal::disable_raw_mode();
let _ = std::io::stdout().execute(terminal::LeaveAlternateScreen);
return Err(e.into());
}
};
// Menu options
let menu_items = vec![
"✅ Detect metadata",
"👁️ View metadata",
"🗑️ Remove metadata",
"🚪 Quit",
];
let mut selected = 0;
let mut output = String::new();
use crossterm::event::{self, Event, KeyCode};
let mut running = true;
while running {
terminal.draw(|f| {
let area = f.area();
// Horizontal split: left (menu/output), right (image)
let chunks = Layout::default()
.direction(Direction::Horizontal)
.margin(2)
.constraints([
Constraint::Percentage(60),
Constraint::Percentage(40),
])
.split(area);
// Left: vertical split for menu and output
let left_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(menu_items.len() as u16 + 2),
Constraint::Min(3),
])
.split(chunks[0]);
let items: Vec<ListItem> = menu_items
.iter()
.enumerate()
.map(|(i, item)| {
if i == selected {
ListItem::new(format!("> {} <", item)).style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
} else {
ListItem::new(item.to_string())
}
})
.collect();
let menu = List::new(items)
.block(Block::default().title("medars TUI").borders(Borders::ALL))
.highlight_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
f.render_widget(menu, left_chunks[0]);
let paragraph = Paragraph::new(output.clone())
.block(Block::default().title("Output").borders(Borders::ALL))
.wrap(Wrap { trim: true });
f.render_widget(paragraph, left_chunks[1]);
})?;
// Use blocking for event polling and reading
let poll_res = task::spawn_blocking(|| event::poll(std::time::Duration::from_millis(200))).await;
if let Ok(Ok(true)) = poll_res {
let read_res = task::spawn_blocking(|| event::read()).await;
if let Ok(Ok(Event::Key(key))) = read_res {
match key.code {
KeyCode::Char('q') => running = false,
KeyCode::Down => {
selected = (selected + 1) % menu_items.len();
}
KeyCode::Up => {
if selected == 0 {
selected = menu_items.len() - 1;
} else {
selected -= 1;
}
}
KeyCode::Enter => {
match selected {
0 => {
if let Some(ref path) = file {
let path = path.clone();
let res = task::spawn_blocking(move || {
crate::metadata::MetadataHandler::new().has_metadata(&path)
}).await;
output = match res {
Ok(Ok(true)) => "✅ Image contains metadata".to_string(),
Ok(Ok(false)) => "❌ No metadata found in image".to_string(),
Ok(Err(e)) => format!("❌ Error: {}", e),
Err(e) => format!("❌ Task error: {}", e),
};
} else {
output = "No file provided.".to_string();
}
}
1 => {
if let Some(ref path) = file {
let path = path.clone();
let res = task::spawn_blocking(move || {
crate::metadata::MetadataHandler::new().get_metadata_table(&path)
}).await;
output = match res {
Ok(Ok(table)) => table,
Ok(Err(e)) => format!("❌ Error: {}", e),
Err(e) => format!("❌ Task error: {}", e),
};
} else {
output = "No file provided.".to_string();
}
}
2 => {
if let Some(ref path) = file {
let path = path.clone();
let res = task::spawn_blocking(move || {
crate::metadata::MetadataHandler::new().remove_metadata(&path, &path)
}).await;
output = match res {
Ok(Ok(_)) => "✅ Metadata removed successfully".to_string(),
Ok(Err(e)) => format!("❌ Error: {}", e),
Err(e) => format!("❌ Task error: {}", e),
};
} else {
output = "No file provided.".to_string();
}
}
3 => running = false,
_ => output = String::new(),
}
}
_ => {}
}
}
}
}
// Always restore terminal state
let _ = terminal::disable_raw_mode();
let _ = std::io::stdout().execute(terminal::LeaveAlternateScreen);
Ok(())
}
}

View File

@@ -1,6 +0,0 @@
- [ ] Detect if an image contains metadata
- [ ] View metadata in a clean, readable format
- [ ] Remove metadata from images
- [ ] Show image
- viuer if the terminal can render
- termimage if the terminal can't render