Файли
openidea/crates/ide-vfs/src/bytes.rs
T

177 рядки
6.5 KiB
Rust

//! Raw byte access for non-text viewers (image viewer, hex view): a cheap
//! probe deciding whether a file is text, and bounded reads encoded as base64
//! so they travel over JSON IPC compactly.
use crate::encoding::looks_binary;
use crate::{READ_ONLY_THRESHOLD, SNIFF_LEN};
use ide_core::{IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::fs::{self, File};
use std::io::{Read, Seek, SeekFrom};
use std::path::Path;
/// Largest chunk one [`read_range`] call returns (the hex view pages through files).
pub const MAX_RANGE_LEN: u32 = 1024 * 1024;
/// Largest image [`read_data_url`] inlines.
pub const MAX_IMAGE_SIZE: u64 = 64 * 1024 * 1024;
/// What the editor needs to know before choosing a viewer for a file.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct FileProbe {
pub size: u64,
/// Content looks binary (same heuristic as text loading).
pub binary: bool,
/// Above the read-only threshold: the text editor would open it read-only.
pub too_large: bool,
}
/// A slice of a file: `data` is base64; `total` is the file size.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ByteRange {
pub offset: u64,
pub data: String,
pub total: u64,
}
/// Reads only the head of the file to classify it.
pub fn probe(path: &Path) -> IdeResult<FileProbe> {
let meta = fs::metadata(path)?;
if meta.is_dir() {
return Err(IdeError::invalid(format!("{} is a directory", path.display())));
}
let mut head = Vec::with_capacity(SNIFF_LEN);
File::open(path)?.take(SNIFF_LEN as u64).read_to_end(&mut head)?;
Ok(FileProbe { size: meta.len(), binary: looks_binary(&head), too_large: meta.len() > READ_ONLY_THRESHOLD })
}
/// Reads up to `len` bytes (capped at [`MAX_RANGE_LEN`]) from `offset`.
pub fn read_range(path: &Path, offset: u64, len: u32) -> IdeResult<ByteRange> {
let mut file = File::open(path)?;
let total = file.metadata()?.len();
let offset = offset.min(total);
let len = u64::from(len.min(MAX_RANGE_LEN)).min(total - offset);
file.seek(SeekFrom::Start(offset))?;
let mut buf = Vec::with_capacity(len as usize);
file.take(len).read_to_end(&mut buf)?;
Ok(ByteRange { offset, data: base64(&buf), total })
}
/// MIME type of the image formats the viewer shows, by extension.
pub fn image_mime(path: &Path) -> Option<&'static str> {
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
Some(match ext.as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"svg" => "image/svg+xml",
"ico" => "image/x-icon",
"bmp" => "image/bmp",
"avif" => "image/avif",
_ => return None,
})
}
/// The whole image as a `data:` URL (allowed by the webview's CSP, unlike file URLs).
pub fn read_data_url(path: &Path) -> IdeResult<String> {
let mime =
image_mime(path).ok_or_else(|| IdeError::invalid(format!("{} is not a supported image", path.display())))?;
let meta = fs::metadata(path)?;
if meta.len() > MAX_IMAGE_SIZE {
return Err(IdeError::invalid(format!(
"{} is too large to preview ({} MiB)",
path.display(),
meta.len() / (1024 * 1024)
)));
}
let bytes = fs::read(path)?;
Ok(format!("data:{mime};base64,{}", base64(&bytes)))
}
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
/// Standard base64 with padding.
pub fn base64(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let n = (u32::from(chunk[0]) << 16)
| (u32::from(chunk.get(1).copied().unwrap_or(0)) << 8)
| u32::from(chunk.get(2).copied().unwrap_or(0));
let sextet = |shift: u32| ALPHABET[((n >> shift) & 63) as usize] as char;
out.push(sextet(18));
out.push(sextet(12));
out.push(if chunk.len() > 1 { sextet(6) } else { '=' });
out.push(if chunk.len() > 2 { sextet(0) } else { '=' });
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base64_matches_rfc4648_vectors() {
for (input, expected) in [
("", ""),
("f", "Zg=="),
("fo", "Zm8="),
("foo", "Zm9v"),
("foob", "Zm9vYg=="),
("fooba", "Zm9vYmE="),
("foobar", "Zm9vYmFy"),
] {
assert_eq!(base64(input.as_bytes()), expected);
}
assert_eq!(base64(&[0xff, 0xfe, 0x00]), "//4A");
}
#[test]
fn probe_classifies_text_and_binary() {
let dir = tempfile::tempdir().unwrap();
let text = dir.path().join("a.txt");
fs::write(&text, "hello\nworld\n").unwrap();
assert_eq!(probe(&text).unwrap(), FileProbe { size: 12, binary: false, too_large: false });
let bin = dir.path().join("a.bin");
fs::write(&bin, [0u8, 1, 2, 3, 0, 0, 0xff, 0x10]).unwrap();
assert!(probe(&bin).unwrap().binary);
// BOM-less UTF-16 has NULs but is text.
let utf16 = dir.path().join("u.txt");
let bytes: Vec<u8> = "hello world, this is text".encode_utf16().flat_map(u16::to_le_bytes).collect();
fs::write(&utf16, bytes).unwrap();
assert!(!probe(&utf16).unwrap().binary);
assert!(probe(dir.path()).is_err());
}
#[test]
fn read_range_is_bounded_by_the_file() {
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("data");
fs::write(&f, b"0123456789").unwrap();
let r = read_range(&f, 4, 3).unwrap();
assert_eq!((r.offset, r.data.as_str(), r.total), (4, "NDU2", 10));
let tail = read_range(&f, 8, 100).unwrap();
assert_eq!(tail.data, base64(b"89"));
let past = read_range(&f, 50, 10).unwrap();
assert_eq!((past.offset, past.data.as_str()), (10, ""));
}
#[test]
fn data_url_for_images_only() {
let dir = tempfile::tempdir().unwrap();
let png = dir.path().join("x.PNG");
fs::write(&png, [0x89, b'P', b'N', b'G']).unwrap();
assert_eq!(
read_data_url(&png).unwrap(),
format!("data:image/png;base64,{}", base64(&[0x89, b'P', b'N', b'G']))
);
let txt = dir.path().join("x.txt");
fs::write(&txt, "x").unwrap();
assert!(read_data_url(&txt).is_err());
assert_eq!(image_mime(Path::new("a/b.svg")), Some("image/svg+xml"));
}
}