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
+7 -2
View File
@@ -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};
+36 -1
View File
@@ -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 {
+58 -4
View File
@@ -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.
+159
View File
@@ -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)
}