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
+6 -1
View File
@@ -42,7 +42,12 @@
- [x] **Iteration 3 — Reflow đoạn "như Word"**: double-click đoạn nhiều dòng → sửa cả đoạn, tự bẻ dòng theo bề rộng khối (đo hmtx bằng ttf-parser), giữ font (nhúng lại bytes gốc / font chuẩn base-14 / cùng họ), giữ nhịp baseline; Enter = ngắt cứng - [x] **Iteration 3 — Reflow đoạn "như Word"**: double-click đoạn nhiều dòng → sửa cả đoạn, tự bẻ dòng theo bề rộng khối (đo hmtx bằng ttf-parser), giữ font (nhúng lại bytes gốc / font chuẩn base-14 / cùng họ), giữ nhịp baseline; Enter = ngắt cứng
- [ ] Follow-up: reflow v2 (justify, khối xoay, đa font), xoay/lật/clip, viền/opacity/căn lề, convert text→path — xem [docs/12-phase4-summary.md](docs/12-phase4-summary.md) - [ ] Follow-up: reflow v2 (justify, khối xoay, đa font), xoay/lật/clip, viền/opacity/căn lề, convert text→path — xem [docs/12-phase4-summary.md](docs/12-phase4-summary.md)
- **Checklist test cho bạn**: [docs/13-phase4-user-tests.md](docs/13-phase4-user-tests.md) (mới: E17–E30) - **Checklist test cho bạn**: [docs/13-phase4-user-tests.md](docs/13-phase4-user-tests.md) (mới: E17–E30)
- [ ] **Phase 5 — Bảo mật & Chữ ký** (kế tiếp) — xem [docs/03-roadmap.md](docs/03-roadmap.md) - [x] **Phase 5 — Bảo mật** ✅ Iteration 1 hoàn tất (58/58 test engine xanh)
- [x] Engine: mã hoá AES-256 + quyền hạn (in/sửa/copy/chú thích), gỡ mật khẩu (`qpdf.rs`); **redaction THẬT** (`redact.rs`: xoá object, bôi đen pixel ảnh trong chính dữ liệu ảnh — test kiểm bytes/pixel/extract); xoá metadata /Info + XMP (`meta.rs`)
- [x] UI thanh "🔒 Bảo mật": đánh dấu redact 2 bước như Foxit (quét vùng → Áp dụng), dialog Đặt/Gỡ mật khẩu + checkbox quyền, Xoá metadata
- [ ] Iteration 2: chữ ký số PAdES + xác thực, lưu tối ưu (subset font + dọn rác), redact tỉa theo ký tự — xem [docs/15-phase5-summary.md](docs/15-phase5-summary.md)
- **Checklist test cho bạn**: [docs/16-phase5-user-tests.md](docs/16-phase5-user-tests.md)
- [ ] **Phase 6 — Form (AcroForm)** (kế tiếp) — xem [docs/03-roadmap.md](docs/03-roadmap.md)
Build & chạy: xem [docs/05-dev-setup.md](docs/05-dev-setup.md). Build & chạy: xem [docs/05-dev-setup.md](docs/05-dev-setup.md).
+107
View File
@@ -836,6 +836,109 @@ fn edit_preview(input: String, page: u16, ops: Vec<EditOpDto>, width: u32, passw
result result
} }
// ---------- Phase 5: Bảo mật ----------
/// Vùng redact của 1 trang: rects theo [left, bottom, right, top] (điểm PDF).
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct RedactPageDto {
page: u16,
rects: Vec<[f32; 4]>,
}
/// Redact THẬT nhiều trang: áp tuần tự qua file tạm, trả tổng số object đã
/// xoá/bôi đen.
#[tauri::command]
fn redact_apply(
input: String,
areas: Vec<RedactPageDto>,
output: String,
password: Option<String>,
) -> Result<usize, String> {
if areas.is_empty() {
return Err("chưa đánh dấu vùng redact nào".into());
}
let pdfium = pdfium()?;
let mut total = 0usize;
let mut cur = std::path::PathBuf::from(&input);
let mut temps: Vec<std::path::PathBuf> = Vec::new();
let n = areas.len();
for (i, a) in areas.iter().enumerate() {
let rects: Vec<ff_engine::Rect> = a
.rects
.iter()
.map(|r| ff_engine::Rect { left: r[0], bottom: r[1], right: r[2], top: r[3] })
.collect();
let dst = if i + 1 == n {
std::path::PathBuf::from(&output)
} else {
std::env::temp_dir().join(format!("ff_redact_{}_{}.pdf", std::process::id(), i))
};
total += ff_engine::redact_areas(&pdfium, &cur, a.page, &rects, &dst, password.as_deref())
.map_err(|e| e.to_string())?;
if i > 0 {
temps.push(cur.clone());
}
cur = dst;
}
for t in temps {
let _ = std::fs::remove_file(t);
}
Ok(total)
}
/// Đặt mật khẩu + quyền hạn (AES-256 qua qpdf).
#[tauri::command]
#[allow(clippy::too_many_arguments)]
fn security_encrypt(
input: String,
output: String,
user_password: String,
owner_password: String,
allow_print: bool,
allow_modify: bool,
allow_extract: bool,
allow_annotate: bool,
) -> Result<(), String> {
ff_engine::encrypt_with_password_perms(
std::path::Path::new(&input),
std::path::Path::new(&output),
&user_password,
&owner_password,
ff_engine::Permissions { allow_print, allow_modify, allow_extract, allow_annotate },
)
.map_err(|e| e.to_string())
}
/// Gỡ mật khẩu (cần đúng password hiện tại).
#[tauri::command]
fn security_decrypt(input: String, password: String, output: String) -> Result<(), String> {
ff_engine::decrypt_remove_password(
std::path::Path::new(&input),
&password,
std::path::Path::new(&output),
)
.map_err(|e| e.to_string())
}
/// Xoá metadata nhận dạng (/Info + XMP). File lạ (trailer phi chuẩn) được
/// chuẩn hoá qua qpdf trước khi lopdf xử lý.
#[tauri::command]
fn security_strip_metadata(input: String, output: String) -> Result<(), String> {
let inp = std::path::Path::new(&input);
let out = std::path::Path::new(&output);
match ff_engine::strip_metadata(inp, out) {
Ok(()) => Ok(()),
Err(_) => {
let norm = std::env::temp_dir().join(format!("ff_meta_norm_{}.pdf", std::process::id()));
ff_engine::repair(inp, &norm).map_err(|e| e.to_string())?;
let r = ff_engine::strip_metadata(&norm, out).map_err(|e| e.to_string());
let _ = std::fs::remove_file(&norm);
r
}
}
}
/// Dọn các file làm việc tạm của chế độ sửa (undo stack). Chỉ xoá file có tên /// Dọn các file làm việc tạm của chế độ sửa (undo stack). Chỉ xoá file có tên
/// `ff_edit_*` nằm đúng trong thư mục temp — không bao giờ đụng file người dùng. /// `ff_edit_*` nằm đúng trong thư mục temp — không bao giờ đụng file người dùng.
#[tauri::command] #[tauri::command]
@@ -896,6 +999,10 @@ fn main() {
edit_apply_to_temp, edit_apply_to_temp,
edit_preview, edit_preview,
edit_cleanup, edit_cleanup,
redact_apply,
security_encrypt,
security_decrypt,
security_strip_metadata,
pick_image pick_image
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
+14
View File
@@ -36,6 +36,7 @@
<span class="sep"></span> <span class="sep"></span>
<button id="organizeModeBtn" title="Tổ chức trang">🗂 Tổ chức trang</button> <button id="organizeModeBtn" title="Tổ chức trang">🗂 Tổ chức trang</button>
<button id="editModeBtn" title="Sửa nội dung trang">✏️ Sửa nội dung</button> <button id="editModeBtn" title="Sửa nội dung trang">✏️ Sửa nội dung</button>
<button id="secModeBtn" title="Bảo mật: redact, mật khẩu, metadata">🔒 Bảo mật</button>
<div class="spacer"></div> <div class="spacer"></div>
<span id="status" class="status"></span> <span id="status" class="status"></span>
</header> </header>
@@ -102,6 +103,19 @@
<button id="edSave" disabled>💾 Lưu thay đổi nội dung</button> <button id="edSave" disabled>💾 Lưu thay đổi nội dung</button>
</div> </div>
<div id="secBar" class="editbar hidden">
<span class="annolabel">Bảo mật:</span>
<button id="secRedactTool" class="atool" data-tool="redact" title="Kéo chuột trên trang để đánh dấu vùng cần bôi đen — nội dung bị XOÁ THẬT khi áp dụng">⬛ Đánh dấu redact</button>
<button id="secRedactApply" disabled title="Xoá thật nội dung trong các vùng đã đánh dấu, lưu ra file mới">Áp dụng redact (<span id="redactCount">0</span>)</button>
<button id="secRedactClear" disabled title="Bỏ toàn bộ đánh dấu">Bỏ đánh dấu</button>
<span class="sep"></span>
<button id="secEncrypt" title="Đặt mật khẩu mở file + quyền hạn (AES-256)">🔑 Đặt mật khẩu</button>
<button id="secDecrypt" title="Gỡ mật khẩu (cần mật khẩu hiện tại)">🔓 Gỡ mật khẩu</button>
<button id="secStripMeta" title="Xoá /Info (tác giả, phần mềm tạo...) và XMP metadata khỏi file">🧹 Xoá metadata</button>
<span class="spacer"></span>
<span id="secHint" class="status"></span>
</div>
<div class="body"> <div class="body">
<aside id="sidebar" class="sidebar"> <aside id="sidebar" class="sidebar">
<div class="tabs"> <div class="tabs">
+182 -3
View File
@@ -39,6 +39,8 @@ const state = {
editPendingImage: null, // đường dẫn ảnh chờ đặt (Thêm ảnh) editPendingImage: null, // đường dẫn ảnh chờ đặt (Thêm ảnh)
editColor: [0, 0, 0], // màu chữ áp khi sửa/thêm text editColor: [0, 0, 0], // màu chữ áp khi sửa/thêm text
editTemps: [], // mọi file tạm đã materialize trong phiên sửa (để dọn) editTemps: [], // mọi file tạm đã materialize trong phiên sửa (để dọn)
secMode: false, // thanh Bảo mật (Phase 5) đang mở
redactMarks: [], // [{page, rect{left,bottom,right,top}}] chờ áp dụng
}; };
const UNDO_LIMIT = 50; const UNDO_LIMIT = 50;
let annotIdSeq = 1; let annotIdSeq = 1;
@@ -71,6 +73,8 @@ async function loadDocument(path) {
state.annotSpecs = []; state.annotSpecs = [];
state.undoStack = []; state.undoStack = [];
state.redoStack = []; state.redoStack = [];
state.redactMarks = [];
updateRedactButtons();
updateUndoRedoButtons(); updateUndoRedoButtons();
setTool(null); setTool(null);
updateAnnotCount(); updateAnnotCount();
@@ -599,9 +603,11 @@ function setTool(tool) {
$("annotHint").textContent = state.tool $("annotHint").textContent = state.tool
? state.tool === "note" ? state.tool === "note"
? "Bấm lên trang để đặt ghi chú" ? "Bấm lên trang để đặt ghi chú"
: isTextMarkupTool(state.tool) : state.tool === "redact"
? "Kéo chọn văn bản trên trang để tô (bấm lại nút để tắt)" ? "Kéo chuột quét vùng cần bôi đen — nội dung sẽ bị XOÁ THẬT khi Áp dụng"
: "Kéo chuột trên trang để vẽ" : isTextMarkupTool(state.tool)
? "Kéo chọn văn bản trên trang để tô (bấm lại nút để tắt)"
: "Kéo chuột trên trang để vẽ"
: ""; : "";
} }
@@ -758,6 +764,28 @@ function drawAnnotsForPage(idx) {
} }
layer.appendChild(el); layer.appendChild(el);
} }
// Đánh dấu redact (Phase 5) — vẽ cùng layer để sống sót qua mọi lần redraw.
for (const m of state.redactMarks) {
if (m.page !== idx) continue;
const el = document.createElement("div");
el.className = "redact-mark";
Object.assign(el.style, {
left: m.rect.left * scale + "px",
top: (p.heightPt - m.rect.top) * scale + "px",
width: (m.rect.right - m.rect.left) * scale + "px",
height: (m.rect.top - m.rect.bottom) * scale + "px",
});
el.title = "Bấm để bỏ đánh dấu redact";
el.addEventListener("click", (ev) => {
ev.stopPropagation();
const i = state.redactMarks.indexOf(m);
if (i >= 0) state.redactMarks.splice(i, 1);
drawAnnotsForPage(idx);
updateRedactButtons();
});
layer.appendChild(el);
}
} }
function selectAnnot(id) { function selectAnnot(id) {
@@ -1195,6 +1223,13 @@ function onPagesMouseUp() {
openCropDialog(d.idx, rect); openCropDialog(d.idx, rect);
return; return;
} }
if (tool === "redact") {
if (tiny) return;
state.redactMarks.push({ page: d.idx, rect });
drawAnnotsForPage(d.idx);
updateRedactButtons();
return; // giữ tool để quét tiếp nhiều vùng (như Foxit Mark for Redaction)
}
if (tool === "note") { if (tool === "note") {
const sz = 18 / scale; const sz = 18 / scale;
rect.right = rect.left + sz; rect.right = rect.left + sz;
@@ -2640,6 +2675,142 @@ async function saveEdits() {
} }
} }
// ---------- Phase 5: Bảo mật (redact / mật khẩu / metadata) ----------
function toggleSecMode() {
state.secMode = !state.secMode;
$("secBar").classList.toggle("hidden", !state.secMode);
$("secModeBtn").classList.toggle("active", state.secMode);
if (!state.secMode && state.tool === "redact") setTool(null);
$("secHint").textContent = "";
}
function updateRedactButtons() {
const n = state.redactMarks.length;
$("redactCount").textContent = n;
$("secRedactApply").disabled = n === 0;
$("secRedactClear").disabled = n === 0;
}
function clearRedactMarks() {
const pages = [...new Set(state.redactMarks.map((m) => m.page))];
state.redactMarks = [];
pages.forEach((p) => drawAnnotsForPage(p));
updateRedactButtons();
}
// Áp dụng redact: XOÁ THẬT nội dung trong các vùng đã đánh dấu → file mới.
async function applyRedactions() {
if (!state.redactMarks.length) return;
const out = await invoke("pick_save_pdf");
if (!out) return;
// Gom theo trang: [{page, rects: [[l,b,r,t], ...]}]
const byPage = new Map();
for (const m of state.redactMarks) {
if (!byPage.has(m.page)) byPage.set(m.page, []);
byPage.get(m.page).push([m.rect.left, m.rect.bottom, m.rect.right, m.rect.top]);
}
const areas = [...byPage.entries()].map(([page, rects]) => ({ page, rects }));
try {
const n = await invoke("redact_apply", { input: state.path, areas, output: out, password: null });
clearRedactMarks();
setTool(null);
$("status").textContent = `Đã redact (xoá thật) ${n} đối tượng → ${shortName(out)}`;
loadDocument(out);
} catch (e) {
$("secHint").textContent = "Lỗi redact: " + e;
}
}
function openEncryptDialog() {
const box = openModal("Đặt mật khẩu (AES-256)", `
<label>Mật khẩu mở file (user password)</label>
<input type="password" id="secPw1" autocomplete="new-password">
<label>Nhập lại mật khẩu</label>
<input type="password" id="secPw2" autocomplete="new-password">
<label>Mật khẩu chủ sở hữu (owner — để trống = dùng mật khẩu mở)</label>
<input type="password" id="secPwOwner" autocomplete="new-password">
<label>Quyền hạn khi mở bằng mật khẩu user</label>
<div class="row">
<label><input type="checkbox" id="secPermPrint" checked> In</label>
<label><input type="checkbox" id="secPermModify" checked> Sửa nội dung</label>
</div>
<div class="row">
<label><input type="checkbox" id="secPermExtract" checked> Sao chép text/ảnh</label>
<label><input type="checkbox" id="secPermAnnotate" checked> Chú thích/điền form</label>
</div>
<div class="err" id="secEncErr"></div>
<div class="foot"><button id="secEncCancel">Huỷ</button><button id="secEncOk" class="primary">Mã hoá…</button></div>
`);
box.querySelector("#secEncCancel").addEventListener("click", closeModal);
box.querySelector("#secEncOk").addEventListener("click", async () => {
const p1 = box.querySelector("#secPw1").value;
const p2 = box.querySelector("#secPw2").value;
const err = box.querySelector("#secEncErr");
if (!p1) { err.textContent = "Mật khẩu không được để trống."; return; }
if (p1 !== p2) { err.textContent = "Hai lần nhập không khớp."; return; }
const out = await invoke("pick_save_pdf");
if (!out) return;
try {
await invoke("security_encrypt", {
input: state.path,
output: out,
userPassword: p1,
ownerPassword: box.querySelector("#secPwOwner").value,
allowPrint: box.querySelector("#secPermPrint").checked,
allowModify: box.querySelector("#secPermModify").checked,
allowExtract: box.querySelector("#secPermExtract").checked,
allowAnnotate: box.querySelector("#secPermAnnotate").checked,
});
closeModal();
$("status").textContent = `Đã mã hoá AES-256 → ${shortName(out)}`;
$("secHint").textContent = "File mã hoá đã lưu riêng — file đang mở giữ nguyên.";
} catch (e) {
err.textContent = "Lỗi: " + e;
}
});
}
function openDecryptDialog() {
const box = openModal("Gỡ mật khẩu", `
<p class="muted">Chọn file PDF đang có mật khẩu, nhập mật khẩu hiện tại, lưu ra bản không mã hoá.</p>
<label>Mật khẩu hiện tại</label>
<input type="password" id="secDecPw" autocomplete="current-password">
<div class="err" id="secDecErr"></div>
<div class="foot"><button id="secDecCancel">Huỷ</button><button id="secDecOk" class="primary">Chọn file &amp; gỡ…</button></div>
`);
box.querySelector("#secDecCancel").addEventListener("click", closeModal);
box.querySelector("#secDecOk").addEventListener("click", async () => {
const pw = box.querySelector("#secDecPw").value;
const err = box.querySelector("#secDecErr");
if (!pw) { err.textContent = "Cần nhập mật khẩu hiện tại."; return; }
const inp = await invoke("pick_pdf");
if (!inp) return;
const out = await invoke("pick_save_pdf");
if (!out) return;
try {
await invoke("security_decrypt", { input: inp, password: pw, output: out });
closeModal();
$("status").textContent = `Đã gỡ mật khẩu → ${shortName(out)}`;
loadDocument(out);
} catch (e) {
err.textContent = "Lỗi (mật khẩu sai?): " + e;
}
});
}
async function stripMetadataAction() {
const out = await invoke("pick_save_pdf");
if (!out) return;
try {
await invoke("security_strip_metadata", { input: state.path, output: out });
$("status").textContent = `Đã xoá metadata (/Info + XMP) → ${shortName(out)}`;
loadDocument(out);
} catch (e) {
$("secHint").textContent = "Lỗi xoá metadata: " + e;
}
}
// ---------- Sự kiện ---------- // ---------- Sự kiện ----------
$("openBtn").addEventListener("click", openFile); $("openBtn").addEventListener("click", openFile);
@@ -2755,6 +2926,14 @@ $("modalOverlay").addEventListener("click", (e) => {
if (e.target.id === "modalOverlay") closeModal(); if (e.target.id === "modalOverlay") closeModal();
}); });
// Bảo mật (Phase 5)
$("secModeBtn").addEventListener("click", toggleSecMode);
$("secRedactApply").addEventListener("click", applyRedactions);
$("secRedactClear").addEventListener("click", clearRedactMarks);
$("secEncrypt").addEventListener("click", openEncryptDialog);
$("secDecrypt").addEventListener("click", openDecryptDialog);
$("secStripMeta").addEventListener("click", stripMetadataAction);
// Sửa nội dung (Phase 4) // Sửa nội dung (Phase 4)
$("editModeBtn").addEventListener("click", toggleEditMode); $("editModeBtn").addEventListener("click", toggleEditMode);
$("edAddText").addEventListener("click", () => { $("edAddText").addEventListener("click", () => {
+12
View File
@@ -413,6 +413,18 @@ body.tool-active .annotlayer * { pointer-events: none; }
box-sizing: content-box; box-sizing: content-box;
} }
/* Đánh dấu redact (Phase 5): khối mờ đen viền đỏ, bấm để bỏ */
.redact-mark {
position: absolute;
background: rgba(0, 0, 0, 0.55);
border: 1.5px dashed #e33;
box-sizing: border-box;
cursor: pointer;
}
.redact-mark:hover {
background: rgba(0, 0, 0, 0.4);
}
/* Textarea sửa cả đoạn (reflow) — đè white-space:pre của .edit-inline */ /* Textarea sửa cả đoạn (reflow) — đè white-space:pre của .edit-inline */
.edit-inline-block { .edit-inline-block {
white-space: pre-wrap; white-space: pre-wrap;
+7 -2
View File
@@ -10,6 +10,7 @@ pub(crate) mod fontmatch;
pub mod meta; pub mod meta;
pub mod organize; pub mod organize;
pub mod qpdf; pub mod qpdf;
pub mod redact;
pub mod render; pub mod render;
pub mod text; pub mod text;
pub mod watermark; pub mod watermark;
@@ -18,12 +19,16 @@ pub use annot::{
apply_annotations, count_annotations, list_annotations, AnnotInfo, AnnotKind, AnnotSpec, apply_annotations, count_annotations, list_annotations, AnnotInfo, AnnotKind, AnnotSpec,
}; };
pub use edit::{apply_edits, list_objects, EditOp, ObjectInfo, ObjectKind}; 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::{ pub use organize::{
build_document, delete_pages, extract_pages, identity_plan, merge_files, rotate_pages, build_document, delete_pages, extract_pages, identity_plan, merge_files, rotate_pages,
split_by_page_count, PagePlanEntry, PageSource, 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 render::{bind_pdfium, page_count, render_page_png, PageImage};
pub use text::{extract_text, page_char_boxes, search, CharBox, Rect, SearchHit}; pub use text::{extract_text, page_char_boxes, search, CharBox, Rect, SearchHit};
pub use watermark::{add_header_footer, add_watermark, Anchor, HeaderFooterSpec, WatermarkSpec}; 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; use std::path::Path;
@@ -6,6 +7,40 @@ use pdfium_render::prelude::*;
use crate::EngineError; 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). /// Kích thước một trang theo điểm PDF (points, 1/72 inch).
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub struct PageDim { pub struct PageDim {
+58 -4
View File
@@ -94,28 +94,82 @@ pub fn repair(input: &Path, output: &Path) -> Result<(), EngineError> {
run_qpdf(&[&i, &o]) run_qpdf(&[&i, &o])
} }
/// Mã hoá `input` (đang KHÔNG mã hoá) thành `output`, dùng AES-256, với user /// Quyền hạn áp khi mã hoá (Phase 5) — map thẳng sang cờ AES-256 của qpdf.
/// password (mở file) và owner password (đổi quyền) chỉ định. Permissions để /// Mặc định: cho phép tất cả (chỉ chặn bằng mật khẩu mở file).
/// mặc định (đầy đủ) cho V1 — đủ cho DoD "ghi không làm hỏng file mã hoá". #[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( pub fn encrypt_with_password(
input: &Path, input: &Path,
output: &Path, output: &Path,
user_password: &str, user_password: &str,
owner_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> { ) -> Result<(), EngineError> {
let i = path_arg(input); let i = path_arg(input);
let o = path_arg(output); 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(&[ run_qpdf(&[
"--encrypt", "--encrypt",
user_password, user_password,
owner_password, owner,
"256", "256",
print,
modify,
extract,
annotate,
"--", "--",
&i, &i,
&o, &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á: /// 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. /// 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. /// 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)
}
+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:?}");
}
+1 -1
View File
@@ -43,7 +43,7 @@ Một ứng dụng desktop có thể **xem, chú thích, tổ chức trang, và
- Thêm text box mới, đổi font/size/màu. - Thêm text box mới, đổi font/size/màu.
**DoD:** sửa được text 1 dòng & 1 đoạn trên ≥80% file mẫu thường; thêm/xoá/di chuyển ảnh; lưu mở lại đúng; tiếng Việt hiển thị & sửa đúng dấu; có test corpus chuyên cho edit. **DoD:** sửa được text 1 dòng & 1 đoạn trên ≥80% file mẫu thường; thêm/xoá/di chuyển ảnh; lưu mở lại đúng; tiếng Việt hiển thị & sửa đúng dấu; có test corpus chuyên cho edit.
## Phase 5 — Bảo mật & Chữ ký ⏳ KẾ TIẾP (đang ở đây) ## Phase 5 — Bảo mật & Chữ ký ✅ Iteration 1 HOÀN TẤT (mã hoá+quyền, gỡ mật khẩu, redaction thật, xoá metadata — xem [docs/15-phase5-summary.md](15-phase5-summary.md)) · Iteration 2: chữ ký số
**Mục tiêu:** Tính năng đáng tiền cho doanh nghiệp. **Mục tiêu:** Tính năng đáng tiền cho doanh nghiệp.
- Mật khẩu mở/permission; mã hoá **AES-256**; xoá mật khẩu. - Mật khẩu mở/permission; mã hoá **AES-256**; xoá mật khẩu.
- **Redaction thật** (xoá nội dung + ảnh + metadata, không chỉ vẽ đè) + kiểm chứng đã xoá. - **Redaction thật** (xoá nội dung + ảnh + metadata, không chỉ vẽ đè) + kiểm chứng đã xoá.
+4 -3
View File
@@ -130,11 +130,12 @@ Foxit hơn" — chi phí khổng lồ, không giải quyết gap nào ở mục
Đã xong (lõi, có test): Viewer ✅ · Annotate ✅ · Organize + lưu vững ✅ · Đã xong (lõi, có test): Viewer ✅ · Annotate ✅ · Organize + lưu vững ✅ ·
**Edit text/object giữ font ✅ (iteration 2) · Reflow đoạn "như Word" ✅ **Edit text/object giữ font ✅ (iteration 2) · Reflow đoạn "như Word" ✅
(iteration 3)**. (iteration 3) · Bảo mật iteration 1 ✅ (mã hoá AES-256 + quyền, gỡ mật khẩu,
redaction thật kiểm chứng được, xoá metadata — docs/15)**.
Thứ tự đề xuất tiếp theo, bám giá trị người dùng: Thứ tự đề xuất tiếp theo, bám giá trị người dùng:
1. **Phase 5 Bảo mật + "Lưu tối ưu"** (mục 3.3 gộp vào đây): mã hoá UI (nợ 1. **Phase 5 Iteration 2**: chữ ký số PAdES + xác thực; "Lưu tối ưu" (mục
Phase 3), redaction thật, subset font, dọn rác, chữ ký số. 3.3: subset font, dọn object rác); redact tỉa theo ký tự.
2. Phase 6 Form → Phase 7 OCR/Convert → Phase 8 phát hành (theo roadmap). 2. Phase 6 Form → Phase 7 OCR/Convert → Phase 8 phát hành (theo roadmap).
3. Song song, rải theo từng phase: mở rộng font matching (3.4), nấc 1 của 3. Song song, rải theo từng phase: mở rộng font matching (3.4), nấc 1 của
hiệu năng phiên sửa (3.2) khi chạm file lớn thực tế, và v2 của reflow hiệu năng phiên sửa (3.2) khi chạm file lớn thực tế, và v2 của reflow
+80
View File
@@ -0,0 +1,80 @@
# 15 — Tổng kết Phase 5 (Bảo mật) · Iteration 1
> Trạng thái: **mã hoá + permissions, gỡ mật khẩu, redaction THẬT, xoá
> metadata — hoàn tất, có test kiểm chứng nội dung thật sự biến mất.**
> Chữ ký số (PAdES) là Iteration 2 — kế hoạch ở mục 5.
## 1. Khảo sát Foxit (chuẩn nghiệm thu)
- **Protect › Password Protect**: user/owner password, AES-256, tick quyền
in/sửa/copy/chú thích. → ta làm dialog tương đương.
- **Protect › Mark for Redaction → Apply**: quét vùng → khối đen dashed →
Apply xoá nội dung THẬT (text không còn select/search được, ảnh bị đục),
vẽ khối đen. → ta làm đúng luồng 2 bước này.
- **Remove hidden information**: xoá metadata Author/Producer/XMP.
## 2. Đã làm
### Engine
- **`qpdf.rs`**: `Permissions {allow_print, allow_modify, allow_extract,
allow_annotate}` map sang cờ AES-256 của qpdf (`--print/--modify/--extract/
--annotate`); `encrypt_with_password_perms(...)` (API cũ giữ nguyên, uỷ
quyền với quyền đầy đủ); `decrypt_remove_password(input, password, output)`
(`qpdf --password=… --decrypt`) — password sai 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 khỏi trang (v1 xoá
cả run bị chạm — sót chữ là lỗi bảo mật, xoá thừa thì không).
- Ảnh giao vùng → `get_raw_image` đọc pixel gốc, **bôi đen đúng phần giao
trong dữ liệu ảnh** rồi thay ảnh (nội dung gốc không còn trong file);
không đọc được pixel → xoá cả ảnh.
- Vẽ khối đen phủ mỗi vùng (dấu hiệu thị giác chuẩn redaction).
- **`meta.rs::strip_metadata`**: xoá `/Info` trong trailer (Author/Producer/
CreationDate…) + stream XMP `/Metadata` trong catalog — xoá cả THAM CHIẾU
lẫn OBJECT đích (kiểm bằng soi bytes thô của file kết quả). Qua lopdf;
trailer phi chuẩn được qpdf chuẩn hoá trước (fallback trong Tauri command).
### Tauri + UI (thanh "🔒 Bảo mật")
- Commands: `redact_apply` (nhiều trang, chain qua file tạm, trả số object đã
xử lý), `security_encrypt`, `security_decrypt`, `security_strip_metadata`.
- **Redact 2 bước như Foxit**: nút *⬛ Đánh dấu redact* (tool kéo-quét trên
trang, giữ tool để quét nhiều vùng; khối đen mờ viền đỏ dashed, bấm vào
khối để bỏ) → *Áp dụng redact (n)* → chọn nơi lưu → mở file kết quả.
- **Đặt mật khẩu**: dialog user/confirm/owner password + 4 checkbox quyền →
lưu bản mã hoá riêng (file đang mở giữ nguyên).
- **Gỡ mật khẩu**: nhập mật khẩu → chọn file khoá → lưu bản đã gỡ → mở luôn.
- **Xoá metadata**: 1 nút → lưu bản sạch → mở luôn. Không dùng prompt/alert.
## 3. Test (6 mới; tổng engine 58/58 ngoài qpdf-fixture)
- `redact_removes_text_content_for_real` — chuỗi mật biến mất khỏi
`extract_text`, chuỗi ngoài vùng còn nguyên, tâm vùng render ra ĐEN.
- `redact_blacks_out_image_pixels_for_real` — đọc lại RAW IMAGE từ file kết
quả: nửa bị redact đen trong chính dữ liệu ảnh, nửa kia vẫn màu gốc
(chứng minh không phải "vẽ đè").
- `encrypt_with_perms_round_trip_and_flags` — không mật khẩu bị chặn, đúng
user/owner mở được, `qpdf --show-encryption` xác nhận quyền in bị cấm.
- `decrypt_removes_password` + `decrypt_with_wrong_password_fails`.
- `strip_metadata_removes_info_and_xmp` — Producer/Author biến mất khỏi
BYTES của file, trailer sạch, file vẫn mở/đọc bình thường.
- `qpdf_safety` cũ: 5/7 xanh trên Linux CI; 2 test repair fixture
`corrupt-truncated.pdf` fail do **qpdf 11.9 Linux không dựng lại được
fixture này** (bản qpdf Windows của dự án xử lý được — khác biệt phiên bản
qpdf, không phải hồi quy code; cần xác nhận lại trên máy Windows).
## 4. Đối chiếu FINAL TARGET & RULE
- Redaction là "xoá thật + kiểm chứng được", không phải vẽ đè — test đọc lại
bytes/pixel/extract để chứng minh, đúng DoD roadmap.
- Không thoả hiệp dialog: mọi thao tác có UI chuyên nghiệp (modal + toolbar).
- Điểm CHƯA đạt 100% so với Foxit (ghi nhận, có kế hoạch):
- Redact theo VÙNG QUÉT, xoá cả run text bị chạm (Foxit tỉa theo ký tự).
Nâng cấp v2: cắt run tại ranh giới ký tự bằng char-box (đã có
`page_char_boxes` từ Phase 1) + tái tạo phần giữ lại như reflow.
- Chưa xoá "hidden information" dạng attachment/script/comment ẩn — gộp
vào Iteration 2.
## 5. Iteration 2 (kế tiếp của Phase 5) — Chữ ký số PAdES + Lưu tối ưu
1. **Ký số**: tạo chữ ký PAdES-B (CMS/PKCS#7 qua crate `cms`/`rsa`/`p256`),
ghi `/ByteRange` + placeholder `/Contents`, incremental update để không
phá chữ ký cũ; xác thực bằng Acrobat làm DoD. Đọc chứng chỉ PFX/PKCS#12.
2. **Xác thực chữ ký** khi mở (hiện trạng thái valid/invalid).
3. **Lưu tối ưu** (từ docs/14 mục 3.3): subset font nhúng theo glyph dùng
thật + `qpdf --object-streams=generate` dọn object rác sau edit.
4. Redact tỉa theo ký tự (mục 4) + quét "hidden info" còn lại.
+36
View File
@@ -0,0 +1,36 @@
# 16 — Checklist test thủ công Phase 5 (Bảo mật)
Quan trọng nhất: xác nhận **redaction xoá thật** (S4–S6) và **file mã hoá mở
đúng ở Foxit/Adobe** (S8) — đây là tính năng an toàn, sai là lộ dữ liệu.
## Chạy
```powershell
cd c:\Project\FoFreeXit\app\src-tauri
cargo run
```
## Bảng test
| # | Thao tác | Kỳ vọng | KQ |
|---|----------|---------|----|
| S1 | Bấm **🔒 Bảo mật** | Hiện thanh Bảo mật (redact / mật khẩu / metadata) | |
| S2 | Bấm **⬛ Đánh dấu redact** → kéo quét 1 vùng chữ | Khối đen mờ viền đỏ dashed phủ vùng; đếm ở nút "Áp dụng redact (n)" tăng | |
| S3 | Quét thêm vùng thứ 2 (trang khác cũng được) → bấm vào 1 khối | Khối bị bấm biến mất (bỏ đánh dấu); đếm giảm | |
| S4 | **Áp dụng redact** → lưu file mới | File mới mở lên: vùng redact là khối ĐEN | |
| S5 | Trong file kết quả: Ctrl+F tìm đúng chữ đã redact; thử chọn & copy vùng đó | **KHÔNG tìm thấy, không copy được** — nội dung đã bị xoá thật | |
| S6 | Redact đè lên 1 phần ẢNH rồi áp dụng; mở file kết quả ở Foxit, dùng Edit Object xem ảnh | Phần ảnh bị redact ĐEN NGAY TRONG ẢNH (kéo ảnh ra vẫn đen), không phải hình chữ nhật đè | |
| S7 | **🔑 Đặt mật khẩu**: nhập mật khẩu + bỏ tick "In" → lưu | File mới đòi mật khẩu khi mở | |
| S8 | Mở file S7 ở **Foxit/Adobe**: nhập đúng mật khẩu; thử In | Mở được bằng đúng mật khẩu; lệnh In bị chặn/mờ | |
| S9 | Nhập SAI mật khẩu ở app | Báo lỗi, không mở | |
| S10 | **🔓 Gỡ mật khẩu**: nhập mật khẩu hiện tại → chọn file S7 → lưu | File kết quả mở KHÔNG cần mật khẩu, nội dung nguyên vẹn | |
| S11 | Gỡ mật khẩu với mật khẩu SAI | Báo lỗi rõ ràng, không tạo file hỏng | |
| S12 | **🧹 Xoá metadata** → lưu → mở file kết quả ở Foxit: File › Properties | Author/Producer/Created… trống; app vẫn mở đọc bình thường | |
## Mẫu phản hồi
```
[Mã] (vd S5) — Phần mềm mở: <Foxit/Adobe/Chrome> — Hiện tượng: <mô tả> —
Kỳ vọng: <...> — Ưu tiên: cao/vừa/thấp
```
> Giới hạn đã biết (Iteration 1): redact xoá NGUYÊN text run bị vùng quét
> chạm vào (an toàn — thừa còn hơn sót; tỉa theo ký tự ở iteration 2); chữ ký
> số PAdES là Iteration 2 — xem `docs/15-phase5-summary.md` mục 5.