mirror of
https://github.com/flectar/mail.git
synced 2026-11-14 10:54:50 +00:00
385 lines
14 KiB
Rust
385 lines
14 KiB
Rust
//! Desktop uses a short-lived renderer process; mobile uses one bounded native
|
|
//! worker with cooperative cancellation. Both load only the packaged PDFium
|
|
//! library on first preview and return one bounded RGBA page.
|
|
//! No PDF scripts, links, forms actions, or document attachments are executed.
|
|
#[cfg(desktop)]
|
|
use std::sync::Arc;
|
|
|
|
pub(crate) const MAX_DOCUMENT_BYTES: usize = 16 * 1024 * 1024;
|
|
const MAX_EDGE: u32 = 2880;
|
|
#[cfg(desktop)]
|
|
const MAGIC: &[u8; 4] = b"FPD1";
|
|
|
|
#[derive(Debug)]
|
|
pub(crate) struct Page {
|
|
pub pixels: Vec<u8>,
|
|
pub width: u32,
|
|
pub height: u32,
|
|
pub count: u32,
|
|
pub index: u32,
|
|
}
|
|
|
|
fn dimensions(width: u32, height: u32) -> Result<usize, String> {
|
|
if width == 0 || height == 0 || width > MAX_EDGE || height > MAX_EDGE {
|
|
return Err("PDF page exceeds the preview pixel limit.".into());
|
|
}
|
|
Ok(width as usize * height as usize * 4)
|
|
}
|
|
|
|
#[cfg(desktop)]
|
|
pub(crate) async fn render(data: Arc<Vec<u8>>, index: u32, zoom: u32) -> Result<Page, String> {
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
if data.len() > MAX_DOCUMENT_BYTES || !(50..=200).contains(&zoom) {
|
|
return Err("This PDF exceeds the preview limit. Download it to view locally.".into());
|
|
}
|
|
let executable = std::env::current_exe().map_err(|e| e.to_string())?;
|
|
let library = library_path(&executable)?;
|
|
if !library.is_file() {
|
|
return Err("The PDF preview component is missing from this installation. Download the file to view it locally.".into());
|
|
}
|
|
let mut command = tokio::process::Command::new(executable);
|
|
command
|
|
.arg("--render-pdf-page")
|
|
.env_clear()
|
|
.stdin(std::process::Stdio::piped())
|
|
.stdout(std::process::Stdio::piped())
|
|
.stderr(std::process::Stdio::null())
|
|
.kill_on_drop(true);
|
|
// Windows needs SystemRoot to resolve native system libraries. Do not pass
|
|
// credentials, profile paths, PATH or dynamic-loader overrides to PDFium.
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
if let Some(root) = std::env::var_os("SystemRoot") {
|
|
command.env("SystemRoot", root);
|
|
}
|
|
command.creation_flags(0x08000000); // CREATE_NO_WINDOW
|
|
}
|
|
let mut child = command.spawn().map_err(|e| e.to_string())?;
|
|
#[cfg(target_os = "windows")]
|
|
let _job = WindowsJob::constrain(child.id().ok_or("PDF worker has no process ID")?)?;
|
|
let work = async {
|
|
let mut input = child.stdin.take().ok_or("Missing PDF input pipe")?;
|
|
input
|
|
.write_all(&index.to_le_bytes())
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
input
|
|
.write_all(&zoom.to_le_bytes())
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
input
|
|
.write_all(&(data.len() as u32).to_le_bytes())
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
input.write_all(&data).await.map_err(|e| e.to_string())?;
|
|
drop(input);
|
|
let mut output = child.stdout.take().ok_or("Missing PDF output pipe")?;
|
|
let mut header = [0u8; 16];
|
|
output.read_exact(&mut header).await.map_err(|_| {
|
|
"Unable to preview this PDF safely on this device. Download it to view locally."
|
|
.to_string()
|
|
})?;
|
|
let number = |start| u32::from_le_bytes(header[start..start + 4].try_into().unwrap());
|
|
let (count, width, height) = (number(4), number(8), number(12));
|
|
if &header[..4] != MAGIC || count == 0 || index >= count {
|
|
return Err("Invalid response from the PDF renderer.".into());
|
|
}
|
|
let mut pixels = vec![0u8; dimensions(width, height)?];
|
|
output
|
|
.read_exact(&mut pixels)
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
if !child.wait().await.map_err(|e| e.to_string())?.success() {
|
|
return Err("The PDF renderer could not finish this page.".into());
|
|
}
|
|
Ok(Page {
|
|
pixels,
|
|
width,
|
|
height,
|
|
count,
|
|
index,
|
|
})
|
|
};
|
|
tokio::time::timeout(std::time::Duration::from_secs(20), work)
|
|
.await
|
|
.map_err(|_| {
|
|
"This PDF page took too long to render. Download it to view locally.".to_string()
|
|
})?
|
|
}
|
|
|
|
#[cfg(any(test, mobile))]
|
|
#[path = "pdf_preview_mobile.rs"]
|
|
mod mobile;
|
|
#[cfg(mobile)]
|
|
pub use mobile::cancel_active;
|
|
#[cfg(mobile)]
|
|
pub(crate) use mobile::render;
|
|
|
|
/// Called by the thin desktop launcher BEFORE PlatformContext or the UI exists.
|
|
/// The reserved argument alone enables the worker; documents arrive only via stdin.
|
|
#[cfg(desktop)]
|
|
pub fn run_worker_if_requested() -> Option<i32> {
|
|
if std::env::args_os().nth(1).as_deref() != Some(std::ffi::OsStr::new("--render-pdf-page")) {
|
|
return None;
|
|
}
|
|
Some(if worker().is_ok() { 0 } else { 1 })
|
|
}
|
|
|
|
#[cfg(desktop)]
|
|
fn worker() -> Result<(), Box<dyn std::error::Error>> {
|
|
use pdfium_render::prelude::*;
|
|
use std::io::{Read, Write};
|
|
// Resource limits affect this child only. The parent's wall-clock deadline
|
|
// additionally covers native code that stalls without consuming CPU time.
|
|
#[cfg(unix)]
|
|
unsafe {
|
|
let cpu = libc::rlimit {
|
|
rlim_cur: 15,
|
|
rlim_max: 15,
|
|
};
|
|
let core = libc::rlimit {
|
|
rlim_cur: 0,
|
|
rlim_max: 0,
|
|
};
|
|
if libc::setrlimit(libc::RLIMIT_CPU, &cpu) != 0
|
|
|| libc::setrlimit(libc::RLIMIT_CORE, &core) != 0
|
|
{
|
|
return Err("Could not constrain PDF renderer".into());
|
|
}
|
|
#[cfg(linux_desktop)]
|
|
{
|
|
let memory = libc::rlimit {
|
|
rlim_cur: 768 * 1024 * 1024,
|
|
rlim_max: 768 * 1024 * 1024,
|
|
};
|
|
if libc::setrlimit(libc::RLIMIT_AS, &memory) != 0 {
|
|
return Err("Could not limit PDF memory".into());
|
|
}
|
|
libc::setpriority(libc::PRIO_PROCESS, 0, 10);
|
|
}
|
|
}
|
|
let mut input = std::io::stdin().lock();
|
|
let mut header = [0u8; 12];
|
|
input.read_exact(&mut header)?;
|
|
let number = |start| u32::from_le_bytes(header[start..start + 4].try_into().unwrap());
|
|
let (index, zoom, length) = (number(0), number(4), number(8) as usize);
|
|
if length > MAX_DOCUMENT_BYTES || !(50..=200).contains(&zoom) {
|
|
return Err("Invalid PDF request".into());
|
|
}
|
|
let mut data = vec![0; length];
|
|
input.read_exact(&mut data)?;
|
|
let executable = std::env::current_exe()?;
|
|
let library = library_path(&executable)?;
|
|
let pdfium = Pdfium::new(Pdfium::bind_to_library(library)?);
|
|
#[cfg(linux_desktop)]
|
|
sandbox()?;
|
|
let document = pdfium.load_pdf_from_byte_slice(&data, None)?;
|
|
let count = u32::try_from(document.pages().len())?;
|
|
if index >= count {
|
|
return Err("PDF page out of range".into());
|
|
}
|
|
let page = document.pages().get(index.try_into()?)?;
|
|
let config = PdfRenderConfig::new()
|
|
.set_target_width((1440 * zoom / 100) as i32)
|
|
.set_maximum_width(MAX_EDGE as i32)
|
|
.set_maximum_height(MAX_EDGE as i32);
|
|
let bitmap = page.render_with_config(&config)?;
|
|
let (width, height) = (bitmap.width() as u32, bitmap.height() as u32);
|
|
let expected = dimensions(width, height)?;
|
|
let pixels = bitmap.as_rgba_bytes();
|
|
if pixels.len() != expected {
|
|
return Err("Invalid PDF bitmap".into());
|
|
}
|
|
let mut output = std::io::stdout().lock();
|
|
output.write_all(MAGIC)?;
|
|
for value in [count, width, height] {
|
|
output.write_all(&value.to_le_bytes())?;
|
|
}
|
|
output.write_all(&pixels)?;
|
|
output.flush()?;
|
|
Ok(())
|
|
}
|
|
|
|
// Files arrive through stdin. Landlock leaves only the system font directories
|
|
// readable. Kernels without Landlock, such as Raspberry Pi OS's, can't limit
|
|
// file access to those directories, so there seccomp blocks opening any file
|
|
// and PDFs that rely on installed fonts render with PDFium's built-in ones.
|
|
// Seccomp also denies networking, process creation, ptrace and signal delivery.
|
|
#[cfg(linux_desktop)]
|
|
fn sandbox() -> Result<(), Box<dyn std::error::Error>> {
|
|
use landlock::{
|
|
ABI, Access, AccessFs, Ruleset, RulesetAttr, RulesetCreatedAttr, RulesetStatus,
|
|
path_beneath_rules,
|
|
};
|
|
use seccompiler::{BpfProgram, SeccompAction, SeccompFilter};
|
|
let status = Ruleset::default()
|
|
.handle_access(AccessFs::from_all(ABI::V1))?
|
|
.create()?
|
|
.add_rules(path_beneath_rules(
|
|
[
|
|
"/usr/share/fonts",
|
|
"/usr/local/share/fonts",
|
|
"/etc/fonts",
|
|
"/var/cache/fontconfig",
|
|
],
|
|
AccessFs::from_read(ABI::V1),
|
|
))?
|
|
.restrict_self()?;
|
|
if !status.no_new_privs {
|
|
return Err("PDF sandbox requires no_new_privs".into());
|
|
}
|
|
let read_fonts = match status.ruleset {
|
|
RulesetStatus::FullyEnforced => true,
|
|
RulesetStatus::NotEnforced => false,
|
|
// Part of the file access would remain unrestricted.
|
|
RulesetStatus::PartiallyEnforced => {
|
|
return Err("PDF sandbox requires full Linux Landlock support".into());
|
|
}
|
|
};
|
|
let mut calls = vec![
|
|
libc::SYS_read,
|
|
libc::SYS_write,
|
|
libc::SYS_close,
|
|
libc::SYS_readv,
|
|
libc::SYS_writev,
|
|
libc::SYS_pread64,
|
|
libc::SYS_lseek,
|
|
libc::SYS_fstat,
|
|
// glibc implements fstat() with newfstatat on an empty path.
|
|
libc::SYS_newfstatat,
|
|
libc::SYS_fcntl,
|
|
libc::SYS_flock,
|
|
libc::SYS_brk,
|
|
libc::SYS_mmap,
|
|
libc::SYS_mprotect,
|
|
libc::SYS_munmap,
|
|
libc::SYS_mremap,
|
|
libc::SYS_madvise,
|
|
libc::SYS_futex,
|
|
libc::SYS_clock_gettime,
|
|
libc::SYS_gettimeofday,
|
|
libc::SYS_getpid,
|
|
libc::SYS_gettid,
|
|
libc::SYS_getuid,
|
|
libc::SYS_geteuid,
|
|
libc::SYS_getgid,
|
|
libc::SYS_getegid,
|
|
libc::SYS_rt_sigreturn,
|
|
libc::SYS_rt_sigprocmask,
|
|
libc::SYS_rt_sigaction,
|
|
libc::SYS_sigaltstack,
|
|
libc::SYS_getrandom,
|
|
libc::SYS_sched_yield,
|
|
libc::SYS_exit,
|
|
libc::SYS_exit_group,
|
|
];
|
|
if read_fonts {
|
|
calls.extend([
|
|
libc::SYS_openat,
|
|
libc::SYS_statx,
|
|
libc::SYS_faccessat,
|
|
libc::SYS_faccessat2,
|
|
libc::SYS_getdents64,
|
|
libc::SYS_readlinkat,
|
|
]);
|
|
#[cfg(target_arch = "x86_64")]
|
|
calls.extend([
|
|
libc::SYS_open,
|
|
libc::SYS_access,
|
|
libc::SYS_stat,
|
|
libc::SYS_lstat,
|
|
libc::SYS_readlink,
|
|
]);
|
|
}
|
|
let filter: BpfProgram = SeccompFilter::new(
|
|
calls.into_iter().map(|number| (number, vec![])).collect(),
|
|
SeccompAction::Errno(libc::EPERM as u32),
|
|
SeccompAction::Allow,
|
|
std::env::consts::ARCH.try_into()?,
|
|
)?
|
|
.try_into()?;
|
|
seccompiler::apply_filter(&filter)?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(desktop)]
|
|
fn library_path(executable: &std::path::Path) -> Result<std::path::PathBuf, String> {
|
|
let directory = executable.parent().ok_or("Missing application directory")?;
|
|
let name = pdfium_render::prelude::Pdfium::pdfium_platform_library_name();
|
|
#[cfg(linux_desktop)]
|
|
{
|
|
// Linux packages keep private libraries out of the executable directory.
|
|
for libdir in ["../lib64/flectar-mail", "../lib/flectar-mail"] {
|
|
let packaged = directory.join(libdir).join(&name);
|
|
if packaged.is_file() {
|
|
return Ok(packaged);
|
|
}
|
|
}
|
|
}
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
let packaged = directory.join("../Frameworks").join(&name);
|
|
if packaged.is_file() {
|
|
return Ok(packaged);
|
|
}
|
|
}
|
|
Ok(directory.join(name))
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
struct WindowsJob(std::os::windows::io::OwnedHandle);
|
|
#[cfg(target_os = "windows")]
|
|
impl WindowsJob {
|
|
fn constrain(pid: u32) -> Result<Self, String> {
|
|
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
|
|
use windows_sys::Win32::System::{JobObjects::*, Threading::*};
|
|
// Both handles are immediately owned, including every error path.
|
|
// Assignment happens before sending any document bytes to the child.
|
|
unsafe {
|
|
let raw = CreateJobObjectW(std::ptr::null(), std::ptr::null());
|
|
if raw.is_null() {
|
|
return Err(std::io::Error::last_os_error().to_string());
|
|
}
|
|
let job = Self(OwnedHandle::from_raw_handle(raw));
|
|
let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
|
|
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
|
| JOB_OBJECT_LIMIT_ACTIVE_PROCESS
|
|
| JOB_OBJECT_LIMIT_PROCESS_MEMORY
|
|
| JOB_OBJECT_LIMIT_PROCESS_TIME;
|
|
limits.BasicLimitInformation.ActiveProcessLimit = 1;
|
|
limits.BasicLimitInformation.PerProcessUserTimeLimit = 15 * 10_000_000;
|
|
limits.ProcessMemoryLimit = 768 * 1024 * 1024;
|
|
if SetInformationJobObject(
|
|
job.0.as_raw_handle(),
|
|
JobObjectExtendedLimitInformation,
|
|
(&limits as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(),
|
|
std::mem::size_of_val(&limits) as u32,
|
|
) == 0
|
|
{
|
|
return Err(std::io::Error::last_os_error().to_string());
|
|
}
|
|
let raw = OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid);
|
|
if raw.is_null() {
|
|
return Err(std::io::Error::last_os_error().to_string());
|
|
}
|
|
let process = OwnedHandle::from_raw_handle(raw);
|
|
if AssignProcessToJobObject(job.0.as_raw_handle(), process.as_raw_handle()) == 0 {
|
|
return Err(std::io::Error::last_os_error().to_string());
|
|
}
|
|
Ok(job)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
#[test]
|
|
fn reject_unbounded_renderer_dimensions() {
|
|
assert!(dimensions(0, 10).is_err());
|
|
assert!(dimensions(u32::MAX, u32::MAX).is_err());
|
|
assert!(dimensions(MAX_EDGE + 1, 1).is_err());
|
|
assert_eq!(dimensions(1440, 1920).unwrap(), 11_059_200);
|
|
}
|
|
}
|