use serde::Serialize;
use crate::error::LificError;
pub const MAX_ZIP_ENTRIES: usize = 200;
pub const MAX_SQLITE_TABLES: usize = 200;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ZipEntry {
pub name: String,
pub size: u64,
pub compressed: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SqliteTable {
pub name: String,
pub rows: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum Preview {
Zip {
entries: Vec<ZipEntry>,
total_entries: usize,
truncated: bool,
},
Sqlite {
tables: Vec<SqliteTable>,
},
None,
}
pub fn preview_bytes(bytes: &[u8]) -> Result<Preview, LificError> {
if bytes.starts_with(crate::storage::SQLITE_MAGIC) {
return sqlite_preview(bytes);
}
if bytes.len() >= 4 && bytes[0] == 0x50 && bytes[1] == 0x4B {
return Ok(zip_preview(bytes));
}
Ok(Preview::None)
}
const EOCD_SIG: u32 = 0x0605_4B50;
const EOCD64_LOCATOR_SIG: u32 = 0x0706_4B50;
const EOCD64_SIG: u32 = 0x0606_4B50;
const CENTRAL_FILE_SIG: u32 = 0x0201_4B50;
fn le_u16(bytes: &[u8], off: usize) -> Option<u16> {
bytes
.get(off..off + 2)
.map(|s| u16::from_le_bytes([s[0], s[1]]))
}
fn le_u32(bytes: &[u8], off: usize) -> Option<u32> {
bytes
.get(off..off + 4)
.map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
}
fn le_u64(bytes: &[u8], off: usize) -> Option<u64> {
bytes
.get(off..off + 8)
.map(|s| u64::from_le_bytes([s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7]]))
}
fn find_eocd(bytes: &[u8]) -> Option<usize> {
let window = bytes.len().min(22 + 0xFFFF);
let start = bytes.len() - window;
(start..=bytes.len().saturating_sub(22))
.rev()
.find(|&i| le_u32(bytes, i) == Some(EOCD_SIG))
}
fn central_directory_location(bytes: &[u8], eocd: usize) -> Option<(usize, usize)> {
let entries = le_u16(bytes, eocd + 10)? as u64;
let offset = le_u32(bytes, eocd + 16)? as u64;
if entries != u64::from(u16::MAX) && offset != u64::from(u32::MAX) {
return Some((
usize::try_from(offset).ok()?,
usize::try_from(entries).ok()?,
));
}
let locator = eocd.checked_sub(20)?;
if le_u32(bytes, locator) != Some(EOCD64_LOCATOR_SIG) {
return None;
}
let eocd64 = usize::try_from(le_u64(bytes, locator + 8)?).ok()?;
if le_u32(bytes, eocd64) != Some(EOCD64_SIG) {
return None;
}
let entries = le_u64(bytes, eocd64 + 32)?;
let offset = le_u64(bytes, eocd64 + 48)?;
Some((
usize::try_from(offset).ok()?,
usize::try_from(entries).ok()?,
))
}
pub fn zip_preview(bytes: &[u8]) -> Preview {
let Some(eocd) = find_eocd(bytes) else {
return Preview::Zip {
entries: Vec::new(),
total_entries: 0,
truncated: false,
};
};
let (mut cursor, declared) = match central_directory_location(bytes, eocd) {
Some(location) => location,
None => {
return Preview::Zip {
entries: Vec::new(),
total_entries: 0,
truncated: false,
};
}
};
let mut entries = Vec::new();
let mut seen = 0usize;
while seen < declared.min(u32::MAX as usize) {
if le_u32(bytes, cursor) != Some(CENTRAL_FILE_SIG) {
break;
}
let Some(compressed) = le_u32(bytes, cursor + 20) else {
break;
};
let Some(size) = le_u32(bytes, cursor + 24) else {
break;
};
let Some(name_len) = le_u16(bytes, cursor + 28) else {
break;
};
let Some(extra_len) = le_u16(bytes, cursor + 30) else {
break;
};
let Some(comment_len) = le_u16(bytes, cursor + 32) else {
break;
};
let name_start = cursor + 46;
let Some(raw_name) = bytes.get(name_start..name_start + name_len as usize) else {
break;
};
seen += 1;
if entries.len() < MAX_ZIP_ENTRIES {
entries.push(ZipEntry {
name: sanitize_entry_name(raw_name),
size: u64::from(size),
compressed: u64::from(compressed),
});
}
cursor = name_start + name_len as usize + extra_len as usize + comment_len as usize;
}
Preview::Zip {
truncated: seen > entries.len(),
total_entries: seen,
entries,
}
}
fn sanitize_entry_name(raw: &[u8]) -> String {
String::from_utf8_lossy(raw)
.chars()
.filter(|c| !c.is_control())
.take(255)
.collect()
}
pub fn sqlite_preview(bytes: &[u8]) -> Result<Preview, LificError> {
use rusqlite::{Connection, OpenFlags};
let dir = tempfile::tempdir()
.map_err(|e| LificError::Internal(format!("preview scratch dir: {e}")))?;
let path = dir.path().join("preview.sqlite3");
std::fs::write(&path, bytes)
.map_err(|e| LificError::Internal(format!("preview scratch copy: {e}")))?;
let uri = format!("file:{}?immutable=1&mode=ro", path.display());
let conn = Connection::open_with_flags(
uri,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
)
.map_err(|_| LificError::BadRequest("not a readable sqlite database".into()))?;
conn.pragma_update(None, "query_only", true)
.map_err(|_| LificError::BadRequest("not a readable sqlite database".into()))?;
let names: Vec<String> = {
let mut stmt = conn
.prepare(
"SELECT name FROM sqlite_master
WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
ORDER BY name",
)
.map_err(|_| LificError::BadRequest("not a readable sqlite database".into()))?;
let rows = stmt
.query_map([], |row| row.get::<_, String>(0))
.map_err(|_| LificError::BadRequest("not a readable sqlite database".into()))?;
rows.take(MAX_SQLITE_TABLES)
.collect::<Result<Vec<_>, _>>()
.map_err(|_| LificError::BadRequest("not a readable sqlite database".into()))?
};
let mut tables = Vec::with_capacity(names.len());
for name in names {
let quoted = name.replace('"', "\"\"");
let rows: i64 = conn
.query_row(&format!("SELECT COUNT(*) FROM \"{quoted}\""), [], |row| {
row.get(0)
})
.unwrap_or(-1);
tables.push(SqliteTable { name, rows });
}
Ok(Preview::Sqlite { tables })
}
#[cfg(test)]
pub(crate) mod fixtures {
use super::CENTRAL_FILE_SIG;
use super::EOCD_SIG;
pub(crate) fn build_zip(files: &[(&str, &[u8])]) -> Vec<u8> {
let mut out = Vec::new();
let mut central = Vec::new();
for (name, data) in files {
let offset = out.len() as u32;
out.extend_from_slice(&0x0403_4B50u32.to_le_bytes()); out.extend_from_slice(&20u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u32.to_le_bytes()); out.extend_from_slice(&(data.len() as u32).to_le_bytes());
out.extend_from_slice(&(data.len() as u32).to_le_bytes());
out.extend_from_slice(&(name.len() as u16).to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(name.as_bytes());
out.extend_from_slice(data);
central.extend_from_slice(&CENTRAL_FILE_SIG.to_le_bytes());
central.extend_from_slice(&20u16.to_le_bytes()); central.extend_from_slice(&20u16.to_le_bytes()); central.extend_from_slice(&0u16.to_le_bytes()); central.extend_from_slice(&0u16.to_le_bytes()); central.extend_from_slice(&0u16.to_le_bytes()); central.extend_from_slice(&0u16.to_le_bytes()); central.extend_from_slice(&0u32.to_le_bytes()); central.extend_from_slice(&(data.len() as u32).to_le_bytes()); central.extend_from_slice(&(data.len() as u32).to_le_bytes()); central.extend_from_slice(&(name.len() as u16).to_le_bytes());
central.extend_from_slice(&0u16.to_le_bytes()); central.extend_from_slice(&0u16.to_le_bytes()); central.extend_from_slice(&0u16.to_le_bytes()); central.extend_from_slice(&0u16.to_le_bytes()); central.extend_from_slice(&0u32.to_le_bytes()); central.extend_from_slice(&offset.to_le_bytes());
central.extend_from_slice(name.as_bytes());
}
let cd_offset = out.len() as u32;
let cd_size = central.len() as u32;
out.extend_from_slice(¢ral);
out.extend_from_slice(&EOCD_SIG.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&(files.len() as u16).to_le_bytes());
out.extend_from_slice(&(files.len() as u16).to_le_bytes());
out.extend_from_slice(&cd_size.to_le_bytes());
out.extend_from_slice(&cd_offset.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes()); out
}
pub(crate) fn build_sqlite() -> Vec<u8> {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("fixture.sqlite3");
{
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch(
"CREATE TABLE widgets (id INTEGER PRIMARY KEY, name TEXT);
INSERT INTO widgets (name) VALUES ('a'), ('b'), ('c');
CREATE TABLE empty_shelf (id INTEGER PRIMARY KEY);
CREATE VIEW widget_names AS SELECT name FROM widgets;",
)
.unwrap();
}
std::fs::read(&path).unwrap()
}
}
#[cfg(test)]
mod tests {
use super::fixtures::{build_sqlite, build_zip};
use super::*;
#[test]
fn zip_preview_lists_entries_with_sizes() {
let zip = build_zip(&[("a.txt", b"hello"), ("dir/b.log", b"world!!")]);
let Preview::Zip {
entries,
total_entries,
truncated,
} = zip_preview(&zip)
else {
panic!("expected a zip preview");
};
assert_eq!(total_entries, 2);
assert!(!truncated);
assert_eq!(
entries,
vec![
ZipEntry {
name: "a.txt".into(),
size: 5,
compressed: 5,
},
ZipEntry {
name: "dir/b.log".into(),
size: 7,
compressed: 7,
},
]
);
}
#[test]
fn zip_preview_caps_entries_and_flags_truncation() {
let names: Vec<String> = (0..MAX_ZIP_ENTRIES + 5)
.map(|i| format!("f{i}.txt"))
.collect();
let files: Vec<(&str, &[u8])> = names.iter().map(|n| (n.as_str(), &b"x"[..])).collect();
let zip = build_zip(&files);
let Preview::Zip {
entries,
total_entries,
truncated,
} = zip_preview(&zip)
else {
panic!("expected a zip preview");
};
assert_eq!(entries.len(), MAX_ZIP_ENTRIES);
assert_eq!(total_entries, MAX_ZIP_ENTRIES + 5);
assert!(truncated);
}
#[test]
fn zip_preview_survives_a_headless_archive() {
let mut zip = build_zip(&[("a.txt", b"hello")]);
zip.truncate(10);
let Preview::Zip {
entries,
total_entries,
truncated,
} = zip_preview(&zip)
else {
panic!("expected a zip preview");
};
assert!(entries.is_empty());
assert_eq!(total_entries, 0);
assert!(!truncated);
}
#[test]
fn zip_preview_rejects_a_lying_name_length() {
let mut zip = build_zip(&[("a.txt", b"hello")]);
let cd = zip
.windows(4)
.position(|w| w == CENTRAL_FILE_SIG.to_le_bytes())
.unwrap();
zip[cd + 28..cd + 30].copy_from_slice(&0xFFFFu16.to_le_bytes());
let Preview::Zip { entries, .. } = zip_preview(&zip) else {
panic!("expected a zip preview");
};
assert!(entries.is_empty());
}
#[test]
fn sqlite_preview_lists_tables_with_row_counts() {
let db = build_sqlite();
let Preview::Sqlite { tables } = sqlite_preview(&db).unwrap() else {
panic!("expected a sqlite preview");
};
assert_eq!(
tables,
vec![
SqliteTable {
name: "empty_shelf".into(),
rows: 0,
},
SqliteTable {
name: "widgets".into(),
rows: 3,
},
],
"views and sqlite_ internals must not be listed"
);
}
#[test]
fn preview_bytes_dispatches_on_magic_not_mime() {
assert!(matches!(
preview_bytes(&build_sqlite()).unwrap(),
Preview::Sqlite { .. }
));
assert!(matches!(
preview_bytes(&build_zip(&[("a", b"b")])).unwrap(),
Preview::Zip { .. }
));
assert_eq!(preview_bytes(b"just some text").unwrap(), Preview::None);
}
#[test]
fn preview_serializes_with_a_kind_discriminator() {
let json = serde_json::to_value(Preview::None).unwrap();
assert_eq!(json, serde_json::json!({ "kind": "none" }));
let json = serde_json::to_value(Preview::Zip {
entries: vec![ZipEntry {
name: "a.txt".into(),
size: 5,
compressed: 3,
}],
total_entries: 1,
truncated: false,
})
.unwrap();
assert_eq!(
json,
serde_json::json!({
"kind": "zip",
"entries": [{ "name": "a.txt", "size": 5, "compressed": 3 }],
"total_entries": 1,
"truncated": false,
})
);
let json = serde_json::to_value(Preview::Sqlite {
tables: vec![SqliteTable {
name: "t".into(),
rows: 2,
}],
})
.unwrap();
assert_eq!(
json,
serde_json::json!({ "kind": "sqlite", "tables": [{ "name": "t", "rows": 2 }] })
);
}
#[test]
fn sqlite_preview_does_not_execute_attacker_sql() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("hostile.sqlite3");
{
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch(
"CREATE TABLE loot (id INTEGER PRIMARY KEY);
CREATE VIEW boom AS SELECT 1/0;
CREATE TRIGGER t AFTER INSERT ON loot BEGIN
INSERT INTO loot (id) VALUES (NEW.id + 1);
END;",
)
.unwrap();
}
let bytes = std::fs::read(&path).unwrap();
let Preview::Sqlite { tables } = sqlite_preview(&bytes).unwrap() else {
panic!("expected a sqlite preview");
};
assert_eq!(
tables,
vec![SqliteTable {
name: "loot".into(),
rows: 0,
}]
);
}
#[test]
fn sqlite_preview_rejects_garbage_wearing_the_header() {
let mut bytes = crate::storage::SQLITE_MAGIC.to_vec();
bytes.extend_from_slice(&[0xAB; 512]);
assert!(sqlite_preview(&bytes).is_err());
}
}