ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git), ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
202 lines
7.2 KiB
Rust
202 lines
7.2 KiB
Rust
//! The diagnostics a user may export: shown first, redactable (§18.2).
|
|
|
|
use std::{
|
|
io::{self, Read as _, Seek as _, SeekFrom},
|
|
path::{Path, PathBuf},
|
|
time::{Duration, SystemTime},
|
|
};
|
|
|
|
use crate::AppId;
|
|
|
|
/// Only the newest part of a long log goes out.
|
|
const LOG_TAIL: u64 = 4 * 1024 * 1024;
|
|
const CRASH_AGE: Duration = Duration::from_secs(7 * 24 * 3600);
|
|
|
|
/// One file the bundle would carry.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct Part {
|
|
pub path: PathBuf,
|
|
pub bytes: u64,
|
|
}
|
|
|
|
/// `app`'s log and its recent crash reports.
|
|
pub fn gather(app: AppId) -> io::Result<Vec<Part>> {
|
|
let mut parts = Vec::new();
|
|
let log = crate::data_dir(app).map_err(io::Error::other)?.join("ely.log");
|
|
parts.extend(log_part(log)?);
|
|
for dir in crash_dirs() {
|
|
parts.extend(crashes_in(&dir, &crashing(app), SystemTime::now())?);
|
|
}
|
|
Ok(parts)
|
|
}
|
|
|
|
/// The log's newest part; only a missing log is nothing.
|
|
fn log_part(log: PathBuf) -> io::Result<Option<Part>> {
|
|
match std::fs::metadata(&log) {
|
|
Ok(meta) => Ok(Some(Part { bytes: meta.len().min(LOG_TAIL), path: log })),
|
|
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
|
|
Err(error) => Err(io::Error::new(error.kind(), format!("{}: {error}", log.display()))),
|
|
}
|
|
}
|
|
|
|
/// The processes whose crashes belong to `app`.
|
|
fn crashing(app: AppId) -> Vec<String> {
|
|
let mut names = vec![app.slug().to_string()];
|
|
match app {
|
|
AppId::Doc | AppId::Table | AppId::Show => names.push("ely-lok-worker".into()),
|
|
AppId::Pdf => names.push("ely-pdf-worker".into()),
|
|
_ => {}
|
|
}
|
|
names
|
|
}
|
|
|
|
/// Where the system keeps crash reports.
|
|
fn crash_dirs() -> Vec<PathBuf> {
|
|
let home = directories::BaseDirs::new().map(|base| base.home_dir().to_path_buf());
|
|
match (cfg!(target_os = "macos"), home) {
|
|
(true, Some(home)) => vec![home.join("Library/Logs/DiagnosticReports")],
|
|
_ => Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn crashes_in(dir: &Path, names: &[String], now: SystemTime) -> io::Result<Vec<Part>> {
|
|
let entries = match std::fs::read_dir(dir) {
|
|
Ok(entries) => entries,
|
|
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
|
|
Err(error) => return Err(error),
|
|
};
|
|
let mut found = Vec::new();
|
|
for entry in entries {
|
|
let entry = entry?;
|
|
let name = entry.file_name().to_string_lossy().into_owned();
|
|
// `ely-doc-2026-10-08-013420.ips`, helpers with their suffix.
|
|
let ours = names.iter().any(|own| name.starts_with(&format!("{own}-")) || name.starts_with(&format!("{own} ")));
|
|
let meta = entry.metadata()?;
|
|
let fresh = meta.modified().is_ok_and(|at| now.duration_since(at).is_ok_and(|age| age < CRASH_AGE));
|
|
if ours && fresh && meta.is_file() {
|
|
found.push(Part { path: entry.path(), bytes: meta.len() });
|
|
}
|
|
}
|
|
found.sort_by(|a, b| a.path.cmp(&b.path));
|
|
Ok(found)
|
|
}
|
|
|
|
/// Redaction replaces every path, URL and user name.
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct Redact {
|
|
pub names: Vec<String>,
|
|
}
|
|
|
|
impl Redact {
|
|
/// This account's names and the set author; unreadable ones fail.
|
|
pub fn of_account() -> io::Result<Self> {
|
|
Self::of(crate::user::account(), crate::settings::Shared::load())
|
|
}
|
|
|
|
fn of(account: io::Result<String>, shared: io::Result<crate::settings::Shared>) -> io::Result<Self> {
|
|
let mut names = vec![account?, shared?.author];
|
|
names.extend(std::env::var("USER").ok());
|
|
names.extend(std::env::var("USERNAME").ok());
|
|
Ok(Self { names })
|
|
}
|
|
|
|
pub fn apply(&self, text: &str) -> String {
|
|
let mut out: String = text.split_inclusive('\n').map(path_free).collect();
|
|
// Longest first, so short names never split long ones.
|
|
let mut names: Vec<&str> = self.names.iter().map(|name| name.trim()).filter(|name| !name.is_empty()).collect();
|
|
names.sort_by_key(|name| std::cmp::Reverse(name.len()));
|
|
for name in names {
|
|
out = out.replace(name, "<user>");
|
|
}
|
|
out
|
|
}
|
|
}
|
|
|
|
/// Paths and URLs become `<path>`, unquoted ones to line end.
|
|
fn path_free(line: &str) -> String {
|
|
let chars: Vec<char> = line.chars().collect();
|
|
let mut out = String::with_capacity(line.len());
|
|
let mut at = 0;
|
|
while at < chars.len() {
|
|
let before = at.checked_sub(1).map(|i| chars[i]);
|
|
let opens = before.is_none_or(|c| c.is_whitespace() || "\"'([=:<".contains(c));
|
|
if !(opens && starts_path(&chars[at..])) {
|
|
out.push(chars[at]);
|
|
at += 1;
|
|
continue;
|
|
}
|
|
// Quoted: up to the quote; else the whole line.
|
|
let end = match before.filter(|c| *c == '"' || *c == '\'') {
|
|
Some(quote) => closing(&chars, at, quote),
|
|
None => chars.len() - usize::from(chars.last() == Some(&'\n')),
|
|
};
|
|
out.push_str("<path>");
|
|
at = end;
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Where `quote` closes; a backslash escapes in double quotes (JSON).
|
|
fn closing(chars: &[char], from: usize, quote: char) -> usize {
|
|
let mut at = from;
|
|
while at < chars.len() {
|
|
match chars[at] {
|
|
'\\' if quote == '"' => at += 2,
|
|
c if c == quote => return at,
|
|
_ => at += 1,
|
|
}
|
|
}
|
|
chars.len()
|
|
}
|
|
|
|
/// `/x`, `~/x`, `C:\x`, `\\host`, or `scheme://`.
|
|
fn starts_path(rest: &[char]) -> bool {
|
|
let at = |n: usize| rest.get(n).copied();
|
|
let drive = at(0).is_some_and(|c| c.is_ascii_alphabetic()) && at(1) == Some(':') && matches!(at(2), Some('\\' | '/'));
|
|
let scheme = {
|
|
let letters = rest.iter().take_while(|c| c.is_ascii_alphanumeric() || **c == '+' || **c == '-').count();
|
|
letters > 1 && rest[letters..].starts_with(&[':', '/', '/'])
|
|
};
|
|
let rooted = matches!(at(0), Some('/') | Some('\\')) && at(1).is_some_and(|c| !c.is_whitespace());
|
|
rooted || (at(0) == Some('~') && at(1) == Some('/')) || drive || scheme
|
|
}
|
|
|
|
/// One text file holding every part, redacted when asked.
|
|
pub fn bundle(app: AppId, parts: &[Part], redact: Option<&Redact>) -> io::Result<String> {
|
|
let mut text = format!(
|
|
"{} {} diagnostics\nsystem: {} {}\n",
|
|
app.name(),
|
|
env!("CARGO_PKG_VERSION"),
|
|
std::env::consts::OS,
|
|
std::env::consts::ARCH
|
|
);
|
|
for part in parts {
|
|
let mut file = std::fs::File::open(&part.path)?;
|
|
let length = file.metadata()?.len();
|
|
let skipped = length.saturating_sub(part.bytes);
|
|
// A cut line may hold half a path: skip it.
|
|
let mut before = [b'\n'];
|
|
if skipped > 0 {
|
|
file.seek(SeekFrom::Start(skipped - 1))?;
|
|
file.read_exact(&mut before)?;
|
|
}
|
|
file.seek(SeekFrom::Start(skipped))?;
|
|
let mut body = Vec::new();
|
|
file.take(part.bytes).read_to_end(&mut body)?;
|
|
if before[0] != b'\n' {
|
|
let whole = body.iter().position(|b| *b == b'\n').map_or(body.len(), |n| n + 1);
|
|
body.drain(..whole);
|
|
}
|
|
let name = part.path.file_name().map(|name| name.to_string_lossy().into_owned()).unwrap_or_default();
|
|
text.push_str(&format!("\n===== {name} =====\n"));
|
|
text.push_str(&String::from_utf8_lossy(&body));
|
|
}
|
|
Ok(match redact {
|
|
Some(redact) => redact.apply(&text),
|
|
None => text,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|