Files
FoFreeXit/crates/ff-engine/src/qpdf.rs
T
adminandClaude Fable 5 f62cded997 Phase 5 Iteration 2: chữ ký số PKCS#7/PAdES + redact tỉa ký tự + lưu tối ưu
Engine — chữ ký số (sign.rs MỚI):
- generate_self_signed_id: Digital ID tự ký RSA-2048 + X.509 (rcgen), ghi PEM
- sign_pdf: đúng cơ chế Adobe — lopdf dựng sig dict (/SubFilter
  adbe.pkcs7.detached) + Widget field + AcroForm/SigFlags, ByteRange/Contents
  placeholder; serialize → vá ByteRange theo offset thật → SHA-256 trên 2 đoạn
  → CMS SignedData detached (crate cms, signed attrs messageDigest+contentType,
  RSA-SHA256) nhét DER vào Contents
- verify_signatures: parse ByteRange/Contents, băm lại, kiểm chữ ký RSA trên
  signed attrs + messageDigest khớp + phủ toàn bộ file → phát hiện GIẢ MẠO
- Bài học: cắt DER về đúng độ dài (bỏ padding hex '0'); bỏ qua /Contents của
  trang (tham chiếu), chỉ nhận /Contents <hex> của chữ ký

Engine — redact tỉa theo KÝ TỰ (redact.rs):
- Text chạm một phần: page_char_boxes xác định ký tự trong vùng, GIỮ các đoạn
  ngoài vùng, dựng lại đúng vị trí/baseline/màu (font nhúng gốc → cùng họ →
  mặc định). Guard: text xoay/không đọc được font → xoá nguyên (an toàn)
- qpdf::optimize_save: --object-streams=generate + nén, dọn object rác

Tauri + UI:
- Commands: sig_create_id, sig_sign, sig_verify, security_optimize,
  pick_any_file, pick_save_pem
- Thanh Bảo mật thêm: Tạo Digital ID, Ký số (dialog + tự kiểm tra sau khi ký),
  Kiểm tra chữ ký (modal bảng trạng thái + lý do), Lưu tối ưu

Test (7 mới → 62/62 engine xanh ngoài 2 fixture qpdf Linux):
- sign_then_verify_is_valid; tampering_after_signing_is_detected (phát hiện
  sửa sau khi ký); verify_reports_none_for_unsigned
- redact tỉa ký tự: cụm mật biến mất, phần cùng dòng ngoài vùng vẫn còn
- optimize_save không phình + giữ nội dung

Deps: rsa, sha2, cms, x509-cert, rcgen, pkcs8, der, spki, const-oid.
Docs: 15 mục 6 (iter 2 + giới hạn), 16 (S13–S19), README, roadmap (Phase 5
HOÀN TẤT), 14 (kế tiếp: Phase 6 Form).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HgnbPwSoRGNH7LHsDUmRpk
2026-07-15 09:14:51 +00:00

204 lines
8.0 KiB
Rust

//! Lớp an toàn bao ngoài PDFium, dùng QPDF qua CLI (`qpdf.exe` prebuilt, xem
//! `scripts/fetch-qpdf.ps1`) — đúng vai trò đã chốt ở `docs/02-tech-stack.md`
//! ("QPDF cho cấu trúc/mã hoá/repair"):
//!
//! 1. `repair()` — sửa file hỏng (xref/trailer sai) TRƯỚC khi PDFium mở, để
//! không bị `FormatError` trên file PDFium tự đọc thô không nổi.
//! 2. `encrypt_with_password()` — áp lại mật khẩu/mã hoá lên file đã được
//! PDFium/lopdf chỉnh sửa (ghi ra bản KHÔNG mã hoá), để thao tác trang trên
//! file có mật khẩu không làm hỏng/mất mã hoá gốc.
//!
//! QPDF thoát mã 0 = không vấn đề, 3 = thành công nhưng có cảnh báo (file vẫn
//! ghi ra được — vd khi repair xref hỏng), 2 = lỗi thật. Coi 0 và 3 là OK.
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::EngineError;
/// Tìm `qpdf.exe`. Thứ tự: biến môi trường `FOFREEXIT_QPDF_PATH` (đường dẫn
/// tới file exe hoặc thư mục chứa nó) → `<cwd>/qpdf/bin/qpdf.exe` → cạnh file
/// thực thi hiện tại → `qpdf` trong PATH hệ thống.
pub fn find_qpdf() -> Result<PathBuf, EngineError> {
let exe_name = if cfg!(windows) { "qpdf.exe" } else { "qpdf" };
let mut candidates: Vec<PathBuf> = Vec::new();
if let Ok(p) = std::env::var("FOFREEXIT_QPDF_PATH") {
let p = PathBuf::from(p);
if p.is_dir() {
candidates.push(p.join(exe_name));
} else {
candidates.push(p);
}
}
if let Ok(cwd) = std::env::current_dir() {
candidates.push(cwd.join("qpdf").join("bin").join(exe_name));
candidates.push(cwd.join(exe_name));
}
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
candidates.push(dir.join(exe_name));
}
}
for c in &candidates {
if c.is_file() {
return Ok(c.clone());
}
}
// Cuối cùng thử PATH hệ thống.
if Command::new(exe_name).arg("--version").output().is_ok() {
return Ok(PathBuf::from(exe_name));
}
Err(EngineError::Pdfium(format!(
"không tìm thấy qpdf. Đặt biến môi trường FOFREEXIT_QPDF_PATH, hoặc chạy scripts/fetch-qpdf.ps1. Đã thử: {}",
candidates.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join("; ")
)))
}
/// `qpdf.exe` hiểu nhầm tiền tố verbatim `\\?\...` (sinh ra bởi
/// `Path::canonicalize()` trên Windows) thành đường dẫn UNC, làm sai lệch
/// path truyền qua command-line. Bỏ tiền tố này trước khi đưa vào argv —
/// an toàn vì các path engine dùng đều ngắn, không cần hỗ trợ path dài.
fn path_arg(p: &Path) -> String {
let s = p.to_string_lossy();
s.strip_prefix(r"\\?\").unwrap_or(&s).to_string()
}
fn run_qpdf(args: &[&str]) -> Result<(), EngineError> {
let qpdf = find_qpdf()?;
let output = Command::new(&qpdf)
.args(args)
.output()
.map_err(|e| EngineError::Pdfium(format!("chạy qpdf thất bại: {e}")))?;
// 0 = OK, 3 = OK nhưng có cảnh báo (vd đã tự sửa xref hỏng). Khác đi = lỗi thật.
match output.status.code() {
Some(0) | Some(3) => Ok(()),
_ => Err(EngineError::Pdfium(format!(
"qpdf lỗi (exit {:?}): {}",
output.status.code(),
String::from_utf8_lossy(&output.stderr)
))),
}
}
/// Sửa file PDF hỏng (xref/trailer sai, thiếu startxref...) bằng cách đọc rồi
/// ghi lại qua QPDF — QPDF tự dựng lại cross-reference table khi cần. Ghi kết
/// quả ra `output`; không sửa `input`.
pub fn repair(input: &Path, output: &Path) -> Result<(), EngineError> {
let i = path_arg(input);
let o = path_arg(output);
run_qpdf(&[&i, &o])
}
/// 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,
"256",
print,
modify,
extract,
annotate,
"--",
&i,
&o,
])
}
/// Lưu tối ưu: nén object stream + dọn object không còn tham chiếu (rác sau
/// khi sửa/redact — object đã tách khỏi trang nhưng vẫn nằm trong file).
/// `--object-streams=generate` gộp object; qpdf tự loại object mồ côi khi ghi.
pub fn optimize_save(input: &Path, output: &Path) -> Result<(), EngineError> {
let i = path_arg(input);
let o = path_arg(output);
run_qpdf(&["--object-streams=generate", "--compress-streams=y", "--", &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.
/// Trả về đường dẫn THỰC SỰ nên dùng để mở tiếp (có thể là `input` hoặc 1 file tạm đã repair).
pub fn ensure_openable(
pdfium: &pdfium_render::prelude::Pdfium,
input: &Path,
password: Option<&str>,
) -> Result<PathBuf, EngineError> {
if pdfium.load_pdf_from_file(input, password).is_ok() {
return Ok(input.to_path_buf());
}
let repaired = std::env::temp_dir().join(format!(
"ff_repaired_{}.pdf",
input.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
));
repair(input, &repaired)?;
pdfium
.load_pdf_from_file(&repaired, password)
.map_err(|e| EngineError::Pdfium(format!("vẫn không mở được sau khi repair: {e}")))?;
Ok(repaired)
}