Phase 5 Iteration 1: Bảo mật — mã hoá + quyền hạn, gỡ mật khẩu, redaction THẬT, xoá metadata

Engine:
- qpdf.rs: Permissions (in/sửa/copy/chú thích) map sang cờ AES-256 của qpdf;
  encrypt_with_password_perms (API cũ giữ tương thích); decrypt_remove_password
  (--password --decrypt, sai mật khẩu trả lỗi không ghi file rác)
- redact.rs MỚI — redaction thật, nghiêng về xoá thừa (an toàn):
  text/path/shading/form giao vùng → xoá nguyên object; ảnh giao vùng → đọc
  pixel gốc qua get_raw_image, BÔI ĐEN đúng phần giao trong dữ liệu ảnh rồi
  thay ảnh (không đọc được → xoá cả ảnh); vẽ khối đen phủ vùng
- meta.rs: strip_metadata — xoá /Info trailer + XMP /Metadata catalog, xoá cả
  tham chiếu lẫn object đích (lopdf; file trailer phi chuẩn qua qpdf trước)

Tauri + UI (thanh 🔒 Bảo mật):
- Commands: redact_apply (nhiều trang, chain file tạm), security_encrypt,
  security_decrypt, security_strip_metadata
- Redact 2 bước như Foxit: tool quét vùng (khối đen mờ viền đỏ, bấm để bỏ,
  giữ tool quét nhiều vùng) → Áp dụng (n) → lưu file mới
- Dialog Đặt mật khẩu (user/confirm/owner + 4 checkbox quyền), Gỡ mật khẩu,
  Xoá metadata — modal chuyên nghiệp, không prompt/alert

Test (6 mới, tổng 58/58 ngoài qpdf-fixture):
- redact: chuỗi mật biến mất khỏi extract + tâm vùng render đen; đọc lại RAW
  image từ file kết quả — nửa redact đen trong chính dữ liệu ảnh
- encrypt+perms round-trip + qpdf --show-encryption xác nhận cấm in;
  decrypt gỡ mật khẩu, mật khẩu sai fail; metadata biến mất khỏi BYTES file
- qpdf_safety: 5/7 xanh trên Linux; 2 test repair fixture corrupt-truncated
  fail do khác biệt qpdf 11.9 Linux (bản Windows dự án xử lý được) — ghi chú
  trong docs/15, cần xác nhận lại trên máy Windows

Docs: 15-phase5-summary (kèm kế hoạch Iteration 2: chữ ký số PAdES, lưu tối
ưu, redact tỉa ký tự), 16-phase5-user-tests (S1–S12), README, roadmap, 14.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HgnbPwSoRGNH7LHsDUmRpk
This commit is contained in:
2026-07-15 08:26:20 +00:00
co-authored by Claude Fable 5
parent fed43b2fb7
commit 8d9076f60c
15 changed files with 978 additions and 15 deletions
+152
View File
@@ -0,0 +1,152 @@
//! Test redaction THẬT (Phase 5): nội dung phải BIẾN MẤT khỏi file (extract
//! không còn, pixel ảnh gốc bị bôi đen thật) — không phải chỉ vẽ đè.
use std::path::PathBuf;
use ff_engine::{redact_areas, EditOp, Rect};
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.canonicalize()
.expect("workspace root")
}
fn pdfium() -> pdfium_render::prelude::Pdfium {
if std::env::var("FOFREEXIT_PDFIUM_PATH").is_err() {
std::env::set_var("FOFREEXIT_PDFIUM_PATH", workspace_root());
}
ff_engine::bind_pdfium().expect("nạp PDFium")
}
fn sample() -> PathBuf {
workspace_root().join("corpus").join("sample-multipage.pdf")
}
fn tmp(name: &str) -> PathBuf {
let p = std::env::temp_dir().join(name);
let _ = std::fs::remove_file(&p);
p
}
/// Rect của text object đầu tiên chứa `needle` trên trang 0.
fn find_text_rect(pdf: &pdfium_render::prelude::Pdfium, input: &std::path::Path, needle: &str) -> Rect {
ff_engine::list_objects(pdf, input, 0, None)
.expect("list")
.into_iter()
.find(|o| o.text.as_deref().map(|t| t.contains(needle)).unwrap_or(false))
.unwrap_or_else(|| panic!("không thấy text {needle:?}"))
.rect
}
/// Redact text: chuỗi mật phải biến mất khỏi extract, chuỗi công khai giữ
/// nguyên, và vùng redact hiển thị ĐEN khi render.
#[test]
fn redact_removes_text_content_for_real() {
let pdf = pdfium();
let fx = tmp("ff_redact_fx.pdf");
let out = tmp("ff_redact_out.pdf");
let mk = |x: f32, y: f32, s: &str| EditOp::AddText {
x,
y,
text: s.into(),
font_size: 14.0,
color: [0, 0, 0, 255],
font_family: None,
bold: false,
italic: false,
};
ff_engine::apply_edits(
&pdf,
&sample(),
0,
&[mk(60.0, 700.0, "PUBLICINFO vẫn còn"), mk(60.0, 600.0, "TOPSECRET42 tuyệt mật")],
&fx,
None,
)
.expect("fixture");
let mut area = find_text_rect(&pdf, &fx, "TOPSECRET42");
// Nới 2pt mỗi phía như người dùng quét chuột.
area = Rect { left: area.left - 2.0, bottom: area.bottom - 2.0, right: area.right + 2.0, top: area.top + 2.0 };
let touched = redact_areas(&pdf, &fx, 0, &[area], &out, None).expect("redact");
assert!(touched >= 1, "phải xoá ít nhất 1 object");
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
assert!(!text.contains("TOPSECRET42"), "nội dung mật phải BIẾN MẤT: {text:?}");
assert!(text.contains("PUBLICINFO"), "nội dung ngoài vùng phải còn: {text:?}");
// Render: tâm vùng redact phải ĐEN.
let png = tmp("ff_redact_out.png");
ff_engine::render_page_png(&pdf, &out, 0, &png, 600, None).expect("render");
let img = image::open(&png).expect("mở png").to_rgba8();
let dims = ff_engine::page_dims(&pdf, &out, None).expect("dims");
let scale = 600.0 / dims[0].width_pt;
let cx = ((area.left + area.right) / 2.0 * scale) as u32;
let cy = ((dims[0].height_pt - (area.bottom + area.top) / 2.0) * scale) as u32;
let p = img.get_pixel(cx, cy);
assert!(p[0] < 40 && p[1] < 40 && p[2] < 40, "tâm vùng redact phải đen, được {p:?}");
}
/// Redact ảnh: pixel vùng giao bị BÔI ĐEN TRONG CHÍNH DỮ LIỆU ẢNH (đọc lại
/// raw image từ file kết quả), phần ngoài vùng giữ nguyên màu gốc.
#[test]
fn redact_blacks_out_image_pixels_for_real() {
use pdfium_render::prelude::*;
let pdf = pdfium();
let fx = tmp("ff_redact_img_fx.pdf");
let out = tmp("ff_redact_img_out.pdf");
// Ảnh đỏ 40×32 đặt tại (300,500) khung 100×80pt → bounds (300,500)-(400,580).
let png = tmp("ff_redact_red.png");
let mut img = image::RgbImage::new(40, 32);
for p in img.pixels_mut() {
*p = image::Rgb([220, 30, 30]);
}
img.save(&png).expect("save png");
ff_engine::apply_edits(
&pdf,
&sample(),
0,
&[EditOp::AddImage {
x: 300.0,
y: 500.0,
width_pt: 100.0,
height_pt: 80.0,
image_path: png.to_string_lossy().into_owned(),
}],
&fx,
None,
)
.expect("fixture ảnh");
// Redact NỬA TRÁI của ảnh.
let area = Rect { left: 300.0, bottom: 500.0, right: 350.0, top: 580.0 };
redact_areas(&pdf, &fx, 0, &[area], &out, None).expect("redact ảnh");
// Đọc lại raw image từ file kết quả — kiểm content thật, không phải hình vẽ đè.
let document = pdf.load_pdf_from_file(&out, None).expect("mở out");
let page = document.pages().get(0).expect("trang 0");
let mut checked = false;
for obj in page.objects().iter() {
let Some(io) = obj.as_image_object() else { continue };
let Ok(raw) = io.get_raw_image() else { continue };
let rgba = raw.to_rgba8();
let (w, h) = (rgba.width(), rgba.height());
if w < 4 {
continue;
}
// Nửa trái đen, nửa phải vẫn đỏ.
let left_px = rgba.get_pixel(w / 4, h / 2);
let right_px = rgba.get_pixel(3 * w / 4, h / 2);
assert!(
left_px[0] < 40 && left_px[1] < 40 && left_px[2] < 40,
"pixel nửa trái phải ĐEN trong dữ liệu ảnh: {left_px:?}"
);
assert!(right_px[0] > 150 && right_px[1] < 90, "nửa phải vẫn đỏ: {right_px:?}");
checked = true;
}
assert!(checked, "phải còn image object (đã thay bằng bản bôi đen) để kiểm pixel");
}
@@ -0,0 +1,124 @@
//! Test bảo mật Phase 5: mã hoá AES-256 + permissions, gỡ mật khẩu, xoá
//! metadata nhận dạng. Cần binary `qpdf` (như qpdf_safety).
use std::path::PathBuf;
use std::process::Command;
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.canonicalize()
.expect("workspace root")
}
fn pdfium() -> pdfium_render::prelude::Pdfium {
if std::env::var("FOFREEXIT_PDFIUM_PATH").is_err() {
std::env::set_var("FOFREEXIT_PDFIUM_PATH", workspace_root());
}
ff_engine::bind_pdfium().expect("nạp PDFium")
}
fn hello() -> PathBuf {
workspace_root().join("corpus").join("hello.pdf")
}
fn tmp(name: &str) -> PathBuf {
let p = std::env::temp_dir().join(name);
let _ = std::fs::remove_file(&p);
p
}
/// Mã hoá với permissions: mở KHÔNG mật khẩu phải fail, đúng mật khẩu OK, và
/// qpdf --show-encryption xác nhận cờ quyền in = none.
#[test]
fn encrypt_with_perms_round_trip_and_flags() {
let pdf = pdfium();
let out = tmp("ff_sec_enc.pdf");
let perms = ff_engine::Permissions { allow_print: false, ..Default::default() };
ff_engine::encrypt_with_password_perms(&hello(), &out, "u123", "o456", perms).expect("encrypt");
assert!(pdf.load_pdf_from_file(&out, None).is_err(), "không mật khẩu phải bị chặn");
assert!(pdf.load_pdf_from_file(&out, Some("u123")).is_ok(), "đúng user password phải mở được");
assert!(pdf.load_pdf_from_file(&out, Some("o456")).is_ok(), "owner password phải mở được");
let qpdf = ff_engine::find_qpdf().expect("qpdf");
let show = Command::new(qpdf)
.args(["--password=u123", "--show-encryption", &out.to_string_lossy()])
.output()
.expect("show-encryption");
let s = String::from_utf8_lossy(&show.stdout).to_lowercase();
assert!(s.contains("print"), "phải có dòng quyền in: {s}");
// qpdf in "print high resolution: not allowed" (và/hoặc low) khi --print=none.
assert!(s.contains("not allowed"), "quyền in phải bị chặn: {s}");
}
/// Gỡ mật khẩu: sau decrypt_remove_password, file mở được KHÔNG cần mật khẩu
/// và nội dung còn nguyên.
#[test]
fn decrypt_removes_password() {
let pdf = pdfium();
let enc = tmp("ff_sec_enc2.pdf");
let dec = tmp("ff_sec_dec2.pdf");
ff_engine::encrypt_with_password(&hello(), &enc, "pw789", "pw789").expect("encrypt");
assert!(pdf.load_pdf_from_file(&enc, None).is_err(), "fixture phải đang khoá");
ff_engine::decrypt_remove_password(&enc, "pw789", &dec).expect("decrypt");
assert!(pdf.load_pdf_from_file(&dec, None).is_ok(), "sau gỡ phải mở tự do");
let text = ff_engine::extract_text(&pdf, &dec, 0, None).expect("extract");
assert!(text.contains("Hello"), "nội dung giữ nguyên: {text:?}");
}
/// Gỡ mật khẩu bằng password SAI phải lỗi (không âm thầm ghi file rỗng).
#[test]
fn decrypt_with_wrong_password_fails() {
let enc = tmp("ff_sec_enc3.pdf");
let dec = tmp("ff_sec_dec3.pdf");
ff_engine::encrypt_with_password(&hello(), &enc, "right", "right").expect("encrypt");
assert!(
ff_engine::decrypt_remove_password(&enc, "wrong", &dec).is_err(),
"password sai phải trả lỗi"
);
}
/// Xoá metadata: /Info (Producer/Author…) và XMP /Metadata phải biến mất KHỎI
/// FILE (soi bytes thô), file vẫn mở/đọc bình thường.
#[test]
fn strip_metadata_removes_info_and_xmp() {
let pdf = pdfium();
let fx = tmp("ff_sec_meta_fx.pdf");
let out = tmp("ff_sec_meta_out.pdf");
// Fixture: chuẩn hoá qua qpdf trước (trailer của hello.pdf viết tay, lopdf
// không parse thẳng được — đúng luồng thật: file luôn qua repair/save của
// engine trước khi strip), rồi chèn /Info với Producer nhận dạng được.
let norm = tmp("ff_sec_meta_norm.pdf");
ff_engine::repair(&hello(), &norm).expect("qpdf normalize");
let mut doc = lopdf::Document::load(&norm).expect("lopdf load");
let mut info = lopdf::Dictionary::new();
info.set("Producer", lopdf::Object::string_literal("FoFreeXitSecretProducer"));
info.set("Author", lopdf::Object::string_literal("NguoiDungBiMat"));
let info_id = doc.add_object(lopdf::Object::Dictionary(info));
doc.trailer.set("Info", lopdf::Object::Reference(info_id));
doc.save(&fx).expect("save fixture");
let raw_before = std::fs::read(&fx).expect("đọc fixture");
assert!(
raw_before.windows(23).any(|w| w == b"FoFreeXitSecretProducer"),
"fixture phải chứa Producer"
);
ff_engine::strip_metadata(&fx, &out).expect("strip");
let raw = std::fs::read(&out).expect("đọc out");
assert!(
!raw.windows(23).any(|w| w == b"FoFreeXitSecretProducer"),
"Producer phải biến mất khỏi bytes của file"
);
assert!(!raw.windows(14).any(|w| w == b"NguoiDungBiMat"), "Author phải biến mất");
let cleaned = lopdf::Document::load(&out).expect("lopdf load out");
assert!(cleaned.trailer.get(b"Info").is_err(), "trailer không còn /Info");
// Vẫn mở & đọc được bình thường.
assert!(pdf.load_pdf_from_file(&out, None).is_ok());
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
assert!(text.contains("Hello"), "nội dung trang giữ nguyên: {text:?}");
}