diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..150d21d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + with: + lfs: true + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo clippy -- -D warnings + - run: cargo test + - run: cargo build --release diff --git a/.gitignore b/.gitignore index ea8c4bf..198b807 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ /target +.DS_Store +*.swp +.idea/ +.vscode/ diff --git a/Cargo.toml b/Cargo.toml index 865a249..3947b55 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,11 @@ name = "aicheck" version = "0.1.0" edition = "2021" description = "Detect AI-generated content via provenance signals (C2PA, XMP/IPTC, EXIF)" +license = "MIT" +repository = "https://github.com/MatrixA/aicheck" +homepage = "https://github.com/MatrixA/aicheck" +keywords = ["ai-detection", "c2pa", "watermark", "metadata", "forensics"] +categories = ["command-line-utilities", "multimedia"] [[bin]] name = "aic" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..436aeee --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 MatrixA + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index c7ad98c..dd9ffc5 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,12 @@ AICheck answers these questions by analyzing file metadata and invisible waterma ## Quick Start +```bash +cargo install aicheck +``` + +Or build from source: + ```bash cargo install --path . ``` diff --git a/src/detector/watermark.rs b/src/detector/watermark.rs index 1fab3d6..d45ff3e 100644 --- a/src/detector/watermark.rs +++ b/src/detector/watermark.rs @@ -138,8 +138,8 @@ pub fn detect(path: &Path) -> Result> { for i in 0..3 { for j in (i + 1)..3 { - for k in 0..min_len { - if channel_bits[i][k] == channel_bits[j][k] { + for (bi, bj) in channel_bits[i].iter().zip(channel_bits[j].iter()).take(min_len) { + if bi == bj { total_agree += 1; } total_compared += 1; @@ -295,7 +295,7 @@ fn estimate_noise_level(channel: &[f64], width: usize, height: usize) -> f64 { return 0.0; } - laplacian_values.sort_by(|a, b| a.partial_cmp(b).unwrap()); + laplacian_values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); let median = laplacian_values[laplacian_values.len() / 2]; median / 0.6745 } @@ -357,8 +357,8 @@ fn apply_2d_dct_ortho(block: &mut [f64], size: usize) { let input: Vec = block[start..start + size].to_vec(); for k in 0..size { let mut sum = 0.0; - for i in 0..size { - sum += input[i] + for (i, val) in input.iter().enumerate() { + sum += val * (std::f64::consts::PI * (2.0 * i as f64 + 1.0) * k as f64 / (2.0 * n)) .cos(); } @@ -376,8 +376,8 @@ fn apply_2d_dct_ortho(block: &mut [f64], size: usize) { let input: Vec = (0..size).map(|row| block[row * size + col]).collect(); for k in 0..size { let mut sum = 0.0; - for i in 0..size { - sum += input[i] + for (i, val) in input.iter().enumerate() { + sum += val * (std::f64::consts::PI * (2.0 * i as f64 + 1.0) * k as f64 / (2.0 * n)) .cos(); } diff --git a/src/detector/wav_metadata.rs b/src/detector/wav_metadata.rs index 44256ea..f14b522 100644 --- a/src/detector/wav_metadata.rs +++ b/src/detector/wav_metadata.rs @@ -41,6 +41,7 @@ fn parse_wav(data: &[u8]) -> Option<(WavFmt, Vec<(String, String)>)> { Some((fmt, info)) } +#[allow(clippy::type_complexity)] fn parse_wav_inner(data: &[u8]) -> Option<(WavFmt, Vec<(String, String)>, usize, usize)> { // Minimum: RIFF(4) + size(4) + WAVE(4) + fmt chunk header(8) + fmt data(16) = 36 if data.len() < 36 { diff --git a/src/output.rs b/src/output.rs index c4137f1..1ac3720 100644 --- a/src/output.rs +++ b/src/output.rs @@ -109,7 +109,10 @@ pub fn print_json(reports: &[FileReport]) { summary: make_summary(reports), files: reports, }; - println!("{}", serde_json::to_string_pretty(&output).unwrap()); + match serde_json::to_string_pretty(&output) { + Ok(json) => println!("{}", json), + Err(e) => eprintln!("Error: failed to serialize JSON: {}", e), + } } /// Print info dump for a single file. diff --git a/tests/audio_id3.rs b/tests/audio_id3.rs index 5e0d7e1..a281c70 100644 --- a/tests/audio_id3.rs +++ b/tests/audio_id3.rs @@ -1,10 +1,9 @@ -use assert_cmd::Command; +use assert_cmd::cargo_bin_cmd; use predicates::prelude::*; #[test] fn suno_mp3_detected_as_ai() { - Command::cargo_bin("aic") - .unwrap() + cargo_bin_cmd!("aic") .args(["check", "tests/fixtures/ai_suno.mp3"]) .assert() .success() // exit 0 = AI detected @@ -14,8 +13,7 @@ fn suno_mp3_detected_as_ai() { #[test] fn suno_mp3_json_output() { - Command::cargo_bin("aic") - .unwrap() + cargo_bin_cmd!("aic") .args(["--json", "check", "tests/fixtures/ai_suno.mp3"]) .assert() .success() @@ -25,8 +23,7 @@ fn suno_mp3_json_output() { #[test] fn suno_mp3_info_shows_id3_tags() { - Command::cargo_bin("aic") - .unwrap() + cargo_bin_cmd!("aic") .args(["info", "tests/fixtures/ai_suno.mp3"]) .assert() .success() diff --git a/tests/video_c2pa.rs b/tests/video_c2pa.rs index 1ab409e..23011a8 100644 --- a/tests/video_c2pa.rs +++ b/tests/video_c2pa.rs @@ -1,10 +1,9 @@ -use assert_cmd::Command; +use assert_cmd::cargo_bin_cmd; use predicates::prelude::*; #[test] fn mp4_with_c2pa_is_detected() { - Command::cargo_bin("aic") - .unwrap() + cargo_bin_cmd!("aic") .args(["check", "tests/fixtures/ai_c2pa.mp4"]) .assert() .success() // exit 0 = AI detected @@ -14,8 +13,7 @@ fn mp4_with_c2pa_is_detected() { #[test] fn mp4_with_c2pa_json_output() { - Command::cargo_bin("aic") - .unwrap() + cargo_bin_cmd!("aic") .args(["--json", "check", "tests/fixtures/ai_c2pa.mp4"]) .assert() .success() @@ -25,8 +23,7 @@ fn mp4_with_c2pa_json_output() { #[test] fn mp4_without_c2pa_not_detected() { - Command::cargo_bin("aic") - .unwrap() + cargo_bin_cmd!("aic") .args(["check", "tests/fixtures/no_c2pa.mp4"]) .assert() .code(1) // exit 1 = no AI detected @@ -35,8 +32,7 @@ fn mp4_without_c2pa_not_detected() { #[test] fn mp4_info_shows_c2pa_manifest() { - Command::cargo_bin("aic") - .unwrap() + cargo_bin_cmd!("aic") .args(["info", "tests/fixtures/ai_c2pa.mp4"]) .assert() .success(); diff --git a/tests/watermark_detection.rs b/tests/watermark_detection.rs index b1a7274..0bc1c05 100644 --- a/tests/watermark_detection.rs +++ b/tests/watermark_detection.rs @@ -1,10 +1,9 @@ -use assert_cmd::Command; +use assert_cmd::cargo_bin_cmd; use predicates::prelude::*; #[test] fn watermarked_dwtdct_detected_with_deep() { - Command::cargo_bin("aic") - .unwrap() + cargo_bin_cmd!("aic") .args(["check", "--deep", "tests/fixtures/watermarked_dwtdct.png"]) .assert() .success() // exit 0 = AI detected @@ -14,8 +13,7 @@ fn watermarked_dwtdct_detected_with_deep() { #[test] fn watermarked_dwtdctsvd_detected_with_deep() { - Command::cargo_bin("aic") - .unwrap() + cargo_bin_cmd!("aic") .args(["check", "--deep", "tests/fixtures/watermarked_dwtdctsvd.png"]) .assert() .success() @@ -24,8 +22,7 @@ fn watermarked_dwtdctsvd_detected_with_deep() { #[test] fn clean_image_not_detected_with_deep() { - Command::cargo_bin("aic") - .unwrap() + cargo_bin_cmd!("aic") .args(["check", "--deep", "tests/fixtures/clean_synthetic.png"]) .assert() .code(1) // exit 1 = no AI detected @@ -35,8 +32,7 @@ fn clean_image_not_detected_with_deep() { #[test] fn watermark_auto_fallback_when_no_metadata() { // Without --deep, watermark analysis runs as fallback when no metadata signals found - Command::cargo_bin("aic") - .unwrap() + cargo_bin_cmd!("aic") .args(["check", "tests/fixtures/watermarked_dwtdct.png"]) .assert() .success() @@ -46,8 +42,7 @@ fn watermark_auto_fallback_when_no_metadata() { #[test] fn watermark_skipped_when_metadata_found() { // Watermark should not auto-run when metadata signals already exist - Command::cargo_bin("aic") - .unwrap() + cargo_bin_cmd!("aic") .args(["check", "tests/fixtures/ai_xmp.jpg"]) .assert() .success() @@ -57,8 +52,7 @@ fn watermark_skipped_when_metadata_found() { #[test] fn watermarked_json_output() { - Command::cargo_bin("aic") - .unwrap() + cargo_bin_cmd!("aic") .args([ "--json", "check", @@ -73,8 +67,7 @@ fn watermarked_json_output() { #[test] fn watermark_info_command() { - Command::cargo_bin("aic") - .unwrap() + cargo_bin_cmd!("aic") .args(["info", "tests/fixtures/watermarked_dwtdct.png"]) .assert() .success()