mirror of
https://github.com/brockar/medars.git
synced 2026-01-11 15:01:00 -03:00
init commit, cli working well
This commit is contained in:
9
.gitignore
vendored
9
.gitignore
vendored
@@ -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
1218
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
Cargo.toml
Normal file
24
Cargo.toml
Normal 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"
|
||||
44
README.md
44
README.md
@@ -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
4
build.rs
Normal 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
114
src/main.rs
Normal 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
159
src/metadata.rs
Normal 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
2
src/ui/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod ratatui_ui;
|
||||
pub use ratatui_ui::RatatuiUI;
|
||||
168
src/ui/ratatui_ui.rs
Normal file
168
src/ui/ratatui_ui.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user