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:
@@ -10,6 +10,7 @@ pub(crate) mod fontmatch;
|
||||
pub mod meta;
|
||||
pub mod organize;
|
||||
pub mod qpdf;
|
||||
pub mod redact;
|
||||
pub mod render;
|
||||
pub mod text;
|
||||
pub mod watermark;
|
||||
@@ -18,12 +19,16 @@ pub use annot::{
|
||||
apply_annotations, count_annotations, list_annotations, AnnotInfo, AnnotKind, AnnotSpec,
|
||||
};
|
||||
pub use edit::{apply_edits, list_objects, EditOp, ObjectInfo, ObjectKind};
|
||||
pub use meta::{outline, page_dims, OutlineItem, PageDim};
|
||||
pub use meta::{outline, page_dims, strip_metadata, OutlineItem, PageDim};
|
||||
pub use organize::{
|
||||
build_document, delete_pages, extract_pages, identity_plan, merge_files, rotate_pages,
|
||||
split_by_page_count, PagePlanEntry, PageSource,
|
||||
};
|
||||
pub use qpdf::{encrypt_with_password, ensure_openable, find_qpdf, repair};
|
||||
pub use qpdf::{
|
||||
decrypt_remove_password, encrypt_with_password, encrypt_with_password_perms, ensure_openable,
|
||||
find_qpdf, repair, Permissions,
|
||||
};
|
||||
pub use redact::redact_areas;
|
||||
pub use render::{bind_pdfium, page_count, render_page_png, PageImage};
|
||||
pub use text::{extract_text, page_char_boxes, search, CharBox, Rect, SearchHit};
|
||||
pub use watermark::{add_header_footer, add_watermark, Anchor, HeaderFooterSpec, WatermarkSpec};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Metadata tài liệu: kích thước trang & outline (bookmarks).
|
||||
//! Metadata tài liệu: kích thước trang & outline (bookmarks) + xoá metadata
|
||||
//! nhận dạng (Phase 5).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
@@ -6,6 +7,40 @@ use pdfium_render::prelude::*;
|
||||
|
||||
use crate::EngineError;
|
||||
|
||||
/// Xoá metadata nhận dạng khỏi file (Phase 5 — riêng tư): /Info trong trailer
|
||||
/// (Author/Producer/CreationDate…) và stream XMP `/Metadata` trong catalog —
|
||||
/// xoá cả THAM CHIẾU lẫn OBJECT đích để nội dung không còn nằm trong file.
|
||||
/// Thao tác tầng PDF object qua lopdf; file mã hoá cần gỡ mật khẩu trước.
|
||||
pub fn strip_metadata(input: &Path, output: &Path) -> Result<(), EngineError> {
|
||||
let mut doc = lopdf::Document::load(input)
|
||||
.map_err(|e| EngineError::Pdfium(format!("lopdf load: {e}")))?;
|
||||
|
||||
// /Info: gỡ tham chiếu trong trailer + xoá object đích.
|
||||
if let Ok(id) = doc.trailer.get(b"Info").and_then(|o| o.as_reference()) {
|
||||
doc.objects.remove(&id);
|
||||
}
|
||||
doc.trailer.remove(b"Info");
|
||||
|
||||
// /Metadata (XMP) trong catalog: gỡ tham chiếu + xoá stream đích.
|
||||
if let Ok(root_id) = doc.trailer.get(b"Root").and_then(|o| o.as_reference()) {
|
||||
let meta_id = doc
|
||||
.get_object(root_id)
|
||||
.ok()
|
||||
.and_then(|c| c.as_dict().ok())
|
||||
.and_then(|d| d.get(b"Metadata").ok().and_then(|o| o.as_reference().ok()));
|
||||
if let Ok(lopdf::Object::Dictionary(dict)) = doc.get_object_mut(root_id) {
|
||||
dict.remove(b"Metadata");
|
||||
}
|
||||
if let Some(id) = meta_id {
|
||||
doc.objects.remove(&id);
|
||||
}
|
||||
}
|
||||
|
||||
doc.save(output)
|
||||
.map_err(|e| EngineError::Pdfium(format!("lopdf save: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Kích thước một trang theo điểm PDF (points, 1/72 inch).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct PageDim {
|
||||
|
||||
@@ -94,28 +94,82 @@ pub fn repair(input: &Path, output: &Path) -> Result<(), EngineError> {
|
||||
run_qpdf(&[&i, &o])
|
||||
}
|
||||
|
||||
/// Mã hoá `input` (đang KHÔNG mã hoá) thành `output`, dùng AES-256, với user
|
||||
/// password (mở file) và owner password (đổi quyền) chỉ định. Permissions để
|
||||
/// mặc định (đầy đủ) cho V1 — đủ cho DoD "ghi không làm hỏng file mã hoá".
|
||||
/// Quyền hạn áp khi mã hoá (Phase 5) — map thẳng sang cờ AES-256 của qpdf.
|
||||
/// Mặc định: cho phép tất cả (chỉ chặn bằng mật khẩu mở file).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Permissions {
|
||||
/// In tài liệu (`--print=full|none`).
|
||||
pub allow_print: bool,
|
||||
/// Sửa nội dung (`--modify=all|none`).
|
||||
pub allow_modify: bool,
|
||||
/// Sao chép/trích text & ảnh (`--extract=y|n`).
|
||||
pub allow_extract: bool,
|
||||
/// Thêm/sửa chú thích + điền form (`--annotate=y|n`).
|
||||
pub allow_annotate: bool,
|
||||
}
|
||||
|
||||
impl Default for Permissions {
|
||||
fn default() -> Self {
|
||||
Self { allow_print: true, allow_modify: true, allow_extract: true, allow_annotate: true }
|
||||
}
|
||||
}
|
||||
|
||||
/// Mã hoá `input` (đang KHÔNG mã hoá) thành `output`, AES-256, permissions
|
||||
/// đầy đủ. Giữ cho tương thích — bản đầy đủ: [`encrypt_with_password_perms`].
|
||||
pub fn encrypt_with_password(
|
||||
input: &Path,
|
||||
output: &Path,
|
||||
user_password: &str,
|
||||
owner_password: &str,
|
||||
) -> Result<(), EngineError> {
|
||||
encrypt_with_password_perms(input, output, user_password, owner_password, Permissions::default())
|
||||
}
|
||||
|
||||
/// Mã hoá AES-256 với user password (mở file), owner password (đổi quyền) và
|
||||
/// bộ quyền hạn chỉ định. Owner password rỗng → qpdf dùng user password làm
|
||||
/// owner (tránh file "khoá quyền nhưng ai cũng mở được" thiếu chủ đích).
|
||||
pub fn encrypt_with_password_perms(
|
||||
input: &Path,
|
||||
output: &Path,
|
||||
user_password: &str,
|
||||
owner_password: &str,
|
||||
perms: Permissions,
|
||||
) -> Result<(), EngineError> {
|
||||
let i = path_arg(input);
|
||||
let o = path_arg(output);
|
||||
let owner = if owner_password.is_empty() { user_password } else { owner_password };
|
||||
let print = if perms.allow_print { "--print=full" } else { "--print=none" };
|
||||
let modify = if perms.allow_modify { "--modify=all" } else { "--modify=none" };
|
||||
let extract = if perms.allow_extract { "--extract=y" } else { "--extract=n" };
|
||||
let annotate = if perms.allow_annotate { "--annotate=y" } else { "--annotate=n" };
|
||||
run_qpdf(&[
|
||||
"--encrypt",
|
||||
user_password,
|
||||
owner_password,
|
||||
owner,
|
||||
"256",
|
||||
print,
|
||||
modify,
|
||||
extract,
|
||||
annotate,
|
||||
"--",
|
||||
&i,
|
||||
&o,
|
||||
])
|
||||
}
|
||||
|
||||
/// GỠ mật khẩu/mã hoá: giải mã `input` (mở bằng `password`) và ghi bản KHÔNG
|
||||
/// mã hoá ra `output`. Cần đúng password (user hoặc owner).
|
||||
pub fn decrypt_remove_password(
|
||||
input: &Path,
|
||||
password: &str,
|
||||
output: &Path,
|
||||
) -> Result<(), EngineError> {
|
||||
let i = path_arg(input);
|
||||
let o = path_arg(output);
|
||||
let pw = format!("--password={password}");
|
||||
run_qpdf(&[&pw, "--decrypt", &i, &o])
|
||||
}
|
||||
|
||||
/// Mở `input` bằng PDFium an toàn cho cả file hỏng và file mã hoá:
|
||||
/// 1. Nếu mở thẳng được (đúng password nếu có) → dùng luôn.
|
||||
/// 2. Nếu PDFium báo lỗi format (không phải do password) → thử `repair()` rồi mở lại.
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Redaction THẬT (Phase 5): xoá nội dung khỏi file — không phải vẽ đè.
|
||||
//!
|
||||
//! Nguyên tắc an toàn (nghiêng về XOÁ THỪA, không bao giờ xoá thiếu):
|
||||
//! - Text/path/shading/form GIAO vùng redact → xoá NGUYÊN object (v1 xoá cả
|
||||
//! run text bị chạm — cắt theo ký tự là nâng cấp v2; xoá thừa an toàn hơn
|
||||
//! sót chữ).
|
||||
//! - Ảnh giao vùng → đọc pixel gốc, BÔI ĐEN đúng phần giao rồi thay ảnh
|
||||
//! (nội dung gốc không còn trong file); không đọc được pixel → xoá cả ảnh.
|
||||
//! - Sau khi xoá, vẽ khối đen phủ mỗi vùng (dấu hiệu thị giác chuẩn redaction).
|
||||
//!
|
||||
//! Kiểm chứng: caller (test/UI) extract_text + render lại để xác nhận nội
|
||||
//! dung đã biến mất thật — xem `tests/redact_roundtrip.rs`.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use pdfium_render::prelude::*;
|
||||
|
||||
use crate::text::Rect;
|
||||
use crate::EngineError;
|
||||
|
||||
fn intersection(a: &Rect, b: &Rect) -> Option<Rect> {
|
||||
let r = Rect {
|
||||
left: a.left.max(b.left),
|
||||
bottom: a.bottom.max(b.bottom),
|
||||
right: a.right.min(b.right),
|
||||
top: a.top.min(b.top),
|
||||
};
|
||||
(r.left < r.right && r.bottom < r.top).then_some(r)
|
||||
}
|
||||
|
||||
fn obj_rect(obj: &PdfPageObject) -> Option<Rect> {
|
||||
obj.bounds().ok().map(|q| Rect {
|
||||
left: q.left().value,
|
||||
bottom: q.bottom().value,
|
||||
right: q.right().value,
|
||||
top: q.top().value,
|
||||
})
|
||||
}
|
||||
|
||||
/// Xoá thật nội dung trong các vùng `areas` (điểm PDF) trên trang `page_index`,
|
||||
/// ghi ra `output`. Trả về số object đã xoá/bôi đen (để UI báo cáo).
|
||||
pub fn redact_areas(
|
||||
pdfium: &Pdfium,
|
||||
input: &Path,
|
||||
page_index: u16,
|
||||
areas: &[Rect],
|
||||
output: &Path,
|
||||
password: Option<&str>,
|
||||
) -> Result<usize, EngineError> {
|
||||
let err = |e: PdfiumError| EngineError::Pdfium(format!("redact: {e}"));
|
||||
let document = pdfium
|
||||
.load_pdf_from_file(input, password)
|
||||
.map_err(|e| EngineError::Pdfium(e.to_string()))?;
|
||||
let touched;
|
||||
{
|
||||
let mut page = document
|
||||
.pages()
|
||||
.get(page_index)
|
||||
.map_err(|e| EngineError::Pdfium(format!("không lấy được trang {page_index}: {e}")))?;
|
||||
page.set_content_regeneration_strategy(PdfPageContentRegenerationStrategy::Manual);
|
||||
|
||||
// (1) Phân loại object bị chạm: ảnh → bôi đen pixel; còn lại → xoá.
|
||||
let mut to_remove: Vec<usize> = Vec::new();
|
||||
struct ImgFix {
|
||||
idx: usize,
|
||||
bounds: Rect,
|
||||
cuts: Vec<Rect>,
|
||||
}
|
||||
let mut img_fixes: Vec<ImgFix> = Vec::new();
|
||||
for (i, obj) in page.objects().iter().enumerate() {
|
||||
let Some(b) = obj_rect(&obj) else { continue };
|
||||
let cuts: Vec<Rect> = areas.iter().filter_map(|a| intersection(a, &b)).collect();
|
||||
if cuts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if obj.object_type() == PdfPageObjectType::Image {
|
||||
img_fixes.push(ImgFix { idx: i, bounds: b, cuts });
|
||||
} else {
|
||||
to_remove.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
// (2) Ảnh: đọc pixel gốc, tô đen phần giao. Không đọc được → xoá cả ảnh.
|
||||
struct NewImg {
|
||||
bounds: Rect,
|
||||
img: image::DynamicImage,
|
||||
}
|
||||
let mut new_imgs: Vec<NewImg> = Vec::new();
|
||||
for fix in &img_fixes {
|
||||
let obj = page.objects().get(fix.idx).map_err(err)?;
|
||||
let raw = obj.as_image_object().and_then(|io| io.get_raw_image().ok());
|
||||
match raw {
|
||||
Some(img) => {
|
||||
let mut rgba = img.to_rgba8();
|
||||
let (w, h) = (rgba.width() as f32, rgba.height() as f32);
|
||||
let bw = (fix.bounds.right - fix.bounds.left).max(0.01);
|
||||
let bh = (fix.bounds.top - fix.bounds.bottom).max(0.01);
|
||||
for cut in &fix.cuts {
|
||||
// Trang → pixel: gốc pixel (0,0) là góc TRÊN-trái của ảnh.
|
||||
let x0 = (((cut.left - fix.bounds.left) / bw) * w).floor().max(0.0) as u32;
|
||||
let x1 = (((cut.right - fix.bounds.left) / bw) * w).ceil().min(w) as u32;
|
||||
let y0 = (((fix.bounds.top - cut.top) / bh) * h).floor().max(0.0) as u32;
|
||||
let y1 = (((fix.bounds.top - cut.bottom) / bh) * h).ceil().min(h) as u32;
|
||||
for y in y0..y1 {
|
||||
for x in x0..x1 {
|
||||
rgba.put_pixel(x, y, image::Rgba([0, 0, 0, 255]));
|
||||
}
|
||||
}
|
||||
}
|
||||
new_imgs.push(NewImg { bounds: fix.bounds, img: image::DynamicImage::ImageRgba8(rgba) });
|
||||
to_remove.push(fix.idx);
|
||||
}
|
||||
None => to_remove.push(fix.idx),
|
||||
}
|
||||
}
|
||||
|
||||
// (3) Xoá theo index GIẢM DẦN (bẫy Drop của PDFium → mem::forget).
|
||||
to_remove.sort_unstable();
|
||||
to_remove.dedup();
|
||||
touched = to_remove.len();
|
||||
for idx in to_remove.into_iter().rev() {
|
||||
let removed = page.objects_mut().remove_object_at_index(idx).map_err(err)?;
|
||||
std::mem::forget(removed);
|
||||
}
|
||||
|
||||
// (4) Đặt lại ảnh đã bôi đen (giữ khung cũ).
|
||||
for ni in &new_imgs {
|
||||
let w = (ni.bounds.right - ni.bounds.left).max(1.0);
|
||||
let h = (ni.bounds.top - ni.bounds.bottom).max(1.0);
|
||||
page.objects_mut()
|
||||
.create_image_object(
|
||||
PdfPoints::new(ni.bounds.left),
|
||||
PdfPoints::new(ni.bounds.bottom),
|
||||
&ni.img,
|
||||
Some(PdfPoints::new(w)),
|
||||
Some(PdfPoints::new(h)),
|
||||
)
|
||||
.map_err(err)?;
|
||||
}
|
||||
|
||||
// (5) Khối đen phủ mỗi vùng — dấu hiệu thị giác chuẩn của redaction.
|
||||
for a in areas {
|
||||
page.objects_mut()
|
||||
.create_path_object_rect(
|
||||
PdfRect::new_from_values(a.bottom, a.left, a.top, a.right),
|
||||
None,
|
||||
None,
|
||||
Some(PdfColor::new(0, 0, 0, 255)),
|
||||
)
|
||||
.map_err(err)?;
|
||||
}
|
||||
|
||||
page.regenerate_content().map_err(err)?;
|
||||
}
|
||||
document
|
||||
.save_to_file(output)
|
||||
.map_err(|e| EngineError::Pdfium(format!("lưu file: {e}")))?;
|
||||
Ok(touched)
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
Reference in New Issue
Block a user