Files
ElyOffice/crates/ely-office/src/files.rs
T
ZacharyZhang-NY b7fca0146d Ely Office: six offline-first desktop apps in Rust and GPUI
ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git),
ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
2026-10-08 21:20:08 -04:00

265 lines
8.8 KiB
Rust

//! Formats and the kernel half of a save (FILE-04).
use std::{
io,
path::{Path, PathBuf},
};
use gpui::SharedString;
use quick_xml::{
events::Event,
name::{Namespace, ResolveResult},
reader::NsReader,
};
use crate::{Engine, Kind};
/// The lowercase extension of `path`.
fn extension(path: &Path) -> String {
path.extension()
.and_then(|ext| ext.to_str())
.unwrap_or_default()
.to_lowercase()
}
/// The format a save of `path` writes: its own (FILE-04).
pub fn kept_format(kind: &Kind, path: &Path) -> io::Result<&'static str> {
let ext = extension(path);
kind.kept
.iter()
.find(|kept| **kept == ext)
.copied()
.ok_or_else(|| io::Error::new(io::ErrorKind::Unsupported, format!("cannot save .{ext}")))
}
/// The format a Save As to `path` writes, or None.
pub fn save_as_format(kind: &Kind, path: &Path) -> Option<&'static str> {
let ext = extension(path);
kind.save_as.iter().find(|format| **format == ext).copied()
}
/// The kernel writes `format` into `work`; the bytes come back.
pub fn kernel_bytes<G: Engine>(
worker: &mut G::Worker,
doc: &G::Doc,
work: &Path,
format: &str,
) -> io::Result<Vec<u8>> {
let copy = work.join(format!("save.{format}"));
G::write(worker, doc, &copy, format)?;
let bytes = std::fs::read(&copy)?;
log::info!("office: the kernel wrote {} bytes of {format}", bytes.len());
Ok(bytes)
}
/// Whether `path` carries macros Ely will not round-trip (LOK-11).
pub fn macro_risk(kind: &Kind, path: &Path) -> io::Result<bool> {
let Some(macros) = kind.macros else {
return Ok(false);
};
if macros.contains(&extension(path).as_str()) {
return Ok(true);
}
let bytes = std::fs::read(path)?;
if bytes.starts_with(b"PK\x03\x04") {
let names = zip_names(&bytes)?;
return Ok(names
.iter()
.any(|name| name.ends_with("vbaProject.bin") || name.starts_with("Basic/")));
}
if bytes.starts_with(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]) {
return Ok(ole_has_vba(&bytes));
}
flat_odf_has_basic(&bytes)
}
/// The office namespace that carries Basic libraries.
const OOO: &[u8] = b"http://openoffice.org/2004/office";
/// Whether flat ODF embeds a Basic library, namespaces in scope.
fn flat_odf_has_basic(bytes: &[u8]) -> io::Result<bool> {
let text = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
if !text.trim_ascii_start().starts_with(b"<") {
return Ok(false);
}
let mut reader = NsReader::from_reader(text);
let mut buf = Vec::new();
loop {
let (bound, event) = reader
.read_resolved_event_into(&mut buf)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
match (bound, event) {
(ResolveResult::Bound(Namespace(OOO)), Event::Start(tag) | Event::Empty(tag))
if tag.local_name().as_ref() == b"library-embedded" =>
{
return Ok(true);
}
(_, Event::Eof) => return Ok(false),
_ => {}
}
buf.clear();
}
}
/// The entry names in a zip's central directory.
fn zip_names(bytes: &[u8]) -> io::Result<Vec<String>> {
let bad = || io::Error::new(io::ErrorKind::InvalidData, "a damaged zip directory");
let u16_at = |at: usize| {
bytes
.get(at..at + 2)
.map(|b| u16::from_le_bytes([b[0], b[1]]) as usize)
};
let u32_at = |at: usize| {
bytes
.get(at..at + 4)
.map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize)
};
let end = (0..bytes.len().saturating_sub(21))
.rev()
.find(|&at| bytes[at..].starts_with(b"PK\x05\x06"))
.ok_or_else(bad)?;
let (count, mut at) = (
u16_at(end + 10).ok_or_else(bad)?,
u32_at(end + 16).ok_or_else(bad)?,
);
let mut names = Vec::with_capacity(count);
for _ in 0..count {
if !bytes
.get(at..)
.is_some_and(|rest| rest.starts_with(b"PK\x01\x02"))
{
return Err(bad());
}
let name_len = u16_at(at + 28).ok_or_else(bad)?;
let skip = u16_at(at + 30).ok_or_else(bad)? + u16_at(at + 32).ok_or_else(bad)?;
let name = bytes.get(at + 46..at + 46 + name_len).ok_or_else(bad)?;
names.push(String::from_utf8_lossy(name).into_owned());
at += 46 + name_len + skip;
}
Ok(names)
}
/// Whether an OLE file has a `_VBA_PROJECT` directory entry.
fn ole_has_vba(bytes: &[u8]) -> bool {
let name: Vec<u8> = "_VBA_PROJECT"
.encode_utf16()
.flat_map(u16::to_le_bytes)
.collect();
// 128-byte entries: name, byte length, type 2.
(512..bytes.len().saturating_sub(128))
.step_by(128)
.any(|at| {
let entry = &bytes[at..at + 128];
entry.starts_with(&name)
&& u16::from_le_bytes([entry[64], entry[65]]) as usize == name.len() + 2
&& entry[66] == 2
})
}
/// A path's file name for tab titles.
pub fn file_name(path: &Path) -> SharedString {
path.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default()
.into()
}
/// A fresh scratch folder for one document's kernel copies.
pub fn work_dir(root: &Path, id: u64) -> io::Result<PathBuf> {
let dir = root.join(format!("doc-{id}"));
if dir.try_exists()? {
std::fs::remove_dir_all(&dir)?;
}
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
#[cfg(test)]
mod tests {
use super::*;
const KIND: Kind = Kind {
app: ely_platform::AppId::Doc,
kept: &["docx", "odt", "doc"],
save_as: &["docx", "odt"],
macros: Some(&["docm", "dotm"]),
};
#[test]
fn saves_keep_the_format_and_save_as_converts() {
let kept = |path: &str| kept_format(&KIND, Path::new(path));
assert_eq!(kept("/a/Contract.DOC").expect("doc"), "doc");
assert_eq!(kept("/a/b.odt").expect("odt"), "odt");
assert!(kept("/a/b.pages").is_err(), "unknown formats fail");
assert_eq!(save_as_format(&KIND, Path::new("/a/b.docx")), Some("docx"));
assert_eq!(save_as_format(&KIND, Path::new("/a/b.pdf")), None);
}
#[test]
fn macros_are_found_by_structure_never_by_text() {
let dir = std::env::temp_dir().join(format!("ely-office-risk-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("a dir");
let write = |name: &str, body: &[u8]| {
let path = dir.join(name);
std::fs::write(&path, body).expect("writes");
path
};
assert!(macro_risk(&KIND, &write("a.docm", b"PK")).expect("docm"));
let notes = write(
"notes.txt",
b"The archive path is Basic/Standard/Module1.xml and vbaProject.bin",
);
assert!(
!macro_risk(&KIND, &notes).expect("text"),
"words in text are not macros"
);
let example = write(
"xml-example.txt",
b"Example: <ooo:library-embedded ooo:name=\"Standard\">",
);
assert!(
!macro_risk(&KIND, &example).expect("text with a tag"),
"unbound tags are text"
);
let root = |prefix: &str| {
format!(
"<office:document xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\" xmlns:{prefix}=\"http://openoffice.org/2004/office\">"
)
};
let basic = format!(
"{}<ooo:libraries><ooo:library-embedded ooo:name=\"Standard\"/>",
root("ooo")
);
assert!(macro_risk(&KIND, &write("f.fodt", basic.as_bytes())).expect("flat basic"));
let alias = format!(
"{}<basic:libraries><basic:library-embedded basic:name=\"Standard\"/>",
root("basic")
);
assert!(
macro_risk(&KIND, &write("g.fodt", alias.as_bytes())).expect("aliased"),
"any bound prefix counts"
);
let nested = format!(
"{}<office:scripts><office:script xmlns:x=\"http://openoffice.org/2004/office\"><x:libraries><x:library-embedded x:name=\"Standard\"/>",
root("ooo")
);
assert!(
macro_risk(&KIND, &write("n.fods", nested.as_bytes())).expect("nested"),
"a child may declare the namespace"
);
let shadowed = format!(
"{}<ooo:p xmlns:ooo=\"urn:other\"><ooo:library-embedded/></ooo:p>",
root("ooo")
);
assert!(
!macro_risk(&KIND, &write("s.fodt", shadowed.as_bytes())).expect("shadowed"),
"a prefix rebound elsewhere is not Basic"
);
assert!(
macro_risk(&KIND, &write("bad.fodt", b"<office:document><a></b>")).is_err(),
"broken XML is an error"
);
std::fs::remove_dir_all(&dir).expect("cleans");
}
}