use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::io;
use std::path::{Path, PathBuf};
use wasm_bindgen::JsValue;
use super::js;
use crate::engine::checksum;
pub(super) const SLOT_HEADER_LEN: u64 = 512;
const HEADER_FIXED_LEN: usize = 32;
const SLOT_MAGIC: u32 = 0x4F47_4552;
const FLAG_IN_USE: u32 = 1 << 0;
const MAX_PATH_LEN: usize = (SLOT_HEADER_LEN as usize) - HEADER_FIXED_LEN;
const SLOT_PREFIX: &str = ".regolith-sah-";
pub(super) type MountId = u64;
struct Mount {
directory: JsValue,
handles: Vec<JsValue>,
}
thread_local! {
static MOUNTS: RefCell<HashMap<MountId, Mount>> = RefCell::new(HashMap::new());
static NEXT_MOUNT: Cell<MountId> = const { Cell::new(0) };
}
fn wrong_thread() -> io::Error {
io::Error::new(
io::ErrorKind::Unsupported,
"OPFS sync access handles are bound to the thread that mounted them",
)
}
fn js_error(what: &str, value: JsValue) -> io::Error {
let detail = value
.as_string()
.or_else(|| {
js_sys::Reflect::get(&value, &JsValue::from_str("message"))
.ok()?
.as_string()
})
.unwrap_or_else(|| format!("{value:?}"));
io::Error::other(format!("OPFS {what} failed: {detail}"))
}
fn with_mount<R>(id: MountId, f: impl FnOnce(&Mount) -> io::Result<R>) -> io::Result<R> {
MOUNTS.with(|mounts| match mounts.borrow().get(&id) {
Some(mount) => f(mount),
None => Err(wrong_thread()),
})
}
#[derive(Debug, Clone)]
pub(super) struct Slot {
pub(super) path: Option<PathBuf>,
pub(super) len: u64,
pub(super) generation: u64,
pub(super) header_dirty: bool,
}
pub(super) struct SlotHeader {
pub(super) in_use: bool,
pub(super) path: String,
pub(super) len: u64,
pub(super) generation: u64,
}
fn encode_header(header: &SlotHeader) -> Vec<u8> {
let path = header.path.as_bytes();
let mut buf = vec![0u8; SLOT_HEADER_LEN as usize];
buf[0..4].copy_from_slice(&SLOT_MAGIC.to_le_bytes());
let flags = if header.in_use { FLAG_IN_USE } else { 0 };
buf[4..8].copy_from_slice(&flags.to_le_bytes());
buf[8..12].copy_from_slice(&(path.len() as u32).to_le_bytes());
buf[12..20].copy_from_slice(&header.len.to_le_bytes());
buf[20..28].copy_from_slice(&header.generation.to_le_bytes());
let sum = checksum::opfs_slot_header(&buf[0..28], path);
buf[28..32].copy_from_slice(&sum.to_le_bytes());
buf[HEADER_FIXED_LEN..HEADER_FIXED_LEN + path.len()].copy_from_slice(path);
buf
}
fn decode_header(buf: &[u8]) -> Option<SlotHeader> {
if buf.len() < SLOT_HEADER_LEN as usize {
return None;
}
if u32::from_le_bytes(buf[0..4].try_into().ok()?) != SLOT_MAGIC {
return None;
}
let flags = u32::from_le_bytes(buf[4..8].try_into().ok()?);
let path_len = u32::from_le_bytes(buf[8..12].try_into().ok()?) as usize;
if path_len > MAX_PATH_LEN {
return None;
}
let len = u64::from_le_bytes(buf[12..20].try_into().ok()?);
let generation = u64::from_le_bytes(buf[20..28].try_into().ok()?);
let stored = u32::from_le_bytes(buf[28..32].try_into().ok()?);
let path_bytes = &buf[HEADER_FIXED_LEN..HEADER_FIXED_LEN + path_len];
if checksum::opfs_slot_header(&buf[0..28], path_bytes) != stored {
return None;
}
Some(SlotHeader {
in_use: flags & FLAG_IN_USE != 0,
path: String::from_utf8(path_bytes.to_vec()).ok()?,
len,
generation,
})
}
fn slot_name(index: usize) -> String {
format!("{SLOT_PREFIX}{index:04}")
}
pub(super) fn existing_slot_count(existing: &[(String, JsValue)]) -> usize {
existing
.iter()
.filter_map(|(name, _)| name.strip_prefix(SLOT_PREFIX))
.filter_map(|suffix| suffix.parse::<usize>().ok())
.map(|index| index + 1)
.max()
.unwrap_or(0)
}
pub(super) async fn open_slots(
directory: &JsValue,
existing: &[(String, JsValue)],
indices: std::ops::Range<usize>,
) -> Result<Vec<(JsValue, Option<SlotHeader>)>, JsValue> {
let mut by_name: HashMap<&str, &JsValue> = HashMap::new();
for (name, handle) in existing {
by_name.insert(name.as_str(), handle);
}
let mut opened: Vec<(JsValue, Option<SlotHeader>)> = Vec::with_capacity(indices.len());
for index in indices {
let step = open_one_slot(directory, &by_name, index).await;
match step {
Ok(slot) => opened.push(slot),
Err(e) => {
for (handle, _) in &opened {
let _ = js::close(handle);
}
return Err(e);
}
}
}
Ok(opened)
}
async fn open_one_slot(
directory: &JsValue,
by_name: &HashMap<&str, &JsValue>,
index: usize,
) -> Result<(JsValue, Option<SlotHeader>), JsValue> {
let name = slot_name(index);
let file = match by_name.get(name.as_str()) {
Some(handle) => (*handle).clone(),
None => js::file_handle(directory, &name, true).await?,
};
let handle = js::sync_access_handle(&file).await?;
let mut header = vec![0u8; SLOT_HEADER_LEN as usize];
let read = js::read_at(&handle, 0, &mut header)?;
let decoded = if read == SLOT_HEADER_LEN as usize {
decode_header(&header)
} else {
None
};
Ok((handle, decoded))
}
pub(super) fn register_mount(directory: JsValue, handles: Vec<JsValue>) -> MountId {
let id = NEXT_MOUNT.with(|next| {
let id = next.get();
next.set(id + 1);
id
});
MOUNTS.with(|mounts| mounts.borrow_mut().insert(id, Mount { directory, handles }));
id
}
pub(super) fn extend_mount(id: MountId, handles: Vec<JsValue>) -> io::Result<()> {
MOUNTS.with(|mounts| match mounts.borrow_mut().get_mut(&id) {
Some(mount) => {
mount.handles.extend(handles);
Ok(())
}
None => Err(wrong_thread()),
})
}
pub(super) fn mount_directory(id: MountId) -> io::Result<JsValue> {
with_mount(id, |mount| Ok(mount.directory.clone()))
}
pub(super) fn release_mount(id: MountId) {
MOUNTS.with(|mounts| {
if let Some(mount) = mounts.borrow_mut().remove(&id) {
for handle in &mount.handles {
let _ = js::close(handle);
}
}
});
}
pub(super) fn read_slot(id: MountId, slot: usize, at: u64, buf: &mut [u8]) -> io::Result<usize> {
with_mount(id, |mount| {
let handle = mount
.handles
.get(slot)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "OPFS slot is not open"))?;
js::read_at(handle, SLOT_HEADER_LEN + at, buf).map_err(|e| js_error("read", e))
})
}
pub(super) fn write_slot(id: MountId, slot: usize, at: u64, buf: &[u8]) -> io::Result<()> {
with_mount(id, |mount| {
let handle = mount
.handles
.get(slot)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "OPFS slot is not open"))?;
let written =
js::write_at(handle, SLOT_HEADER_LEN + at, buf).map_err(|e| js_error("write", e))?;
if written != buf.len() {
return Err(io::Error::other(format!(
"OPFS write was short ({written} of {} bytes); the origin's storage quota is exhausted",
buf.len()
)));
}
Ok(())
})
}
pub(super) fn sync_slot(id: MountId, slot: usize, state: &Slot) -> io::Result<()> {
let header = encode_header(&SlotHeader {
in_use: state.path.is_some(),
path: state
.path
.as_ref()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default(),
len: state.len,
generation: state.generation,
});
with_mount(id, |mount| {
let handle = mount
.handles
.get(slot)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "OPFS slot is not open"))?;
let written = js::write_at(handle, 0, &header).map_err(|e| js_error("header write", e))?;
if written != header.len() {
return Err(io::Error::other(
"OPFS header write was short; the origin's storage quota is exhausted",
));
}
js::flush(handle).map_err(|e| js_error("flush", e))
})
}
pub(super) fn flush_slot(id: MountId, slot: usize) -> io::Result<()> {
with_mount(id, |mount| {
let handle = mount
.handles
.get(slot)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "OPFS slot is not open"))?;
js::flush(handle).map_err(|e| js_error("flush", e))
})
}
pub(super) fn truncate_slot(id: MountId, slot: usize, len: u64) -> io::Result<()> {
with_mount(id, |mount| {
let handle = mount
.handles
.get(slot)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "OPFS slot is not open"))?;
js::truncate(handle, SLOT_HEADER_LEN + len).map_err(|e| js_error("truncate", e))
})
}
pub(super) fn check_path_fits(path: &Path) -> io::Result<()> {
let len = path.to_string_lossy().len();
if len > MAX_PATH_LEN {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("path is {len} bytes; an OPFS slot header holds at most {MAX_PATH_LEN}"),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use wasm_bindgen_test::wasm_bindgen_test;
#[wasm_bindgen_test]
fn header_round_trips() {
let header = SlotHeader {
in_use: true,
path: "db/sst/000042.sst".to_string(),
len: 4096,
generation: 7,
};
let encoded = encode_header(&header);
assert_eq!(encoded.len(), SLOT_HEADER_LEN as usize);
let decoded = decode_header(&encoded).expect("valid header");
assert!(decoded.in_use);
assert_eq!(decoded.path, "db/sst/000042.sst");
assert_eq!(decoded.len, 4096);
assert_eq!(decoded.generation, 7);
}
#[wasm_bindgen_test]
fn a_torn_header_decodes_as_free() {
let mut encoded = encode_header(&SlotHeader {
in_use: true,
path: "db/MANIFEST".to_string(),
len: 10,
generation: 1,
});
encoded[12] ^= 0xff;
assert!(decode_header(&encoded).is_none());
}
#[wasm_bindgen_test]
fn an_unformatted_slot_decodes_as_free() {
assert!(decode_header(&vec![0u8; SLOT_HEADER_LEN as usize]).is_none());
}
#[wasm_bindgen_test]
fn a_path_longer_than_the_header_is_rejected() {
let long = PathBuf::from("x".repeat(MAX_PATH_LEN + 1));
assert!(check_path_fits(&long).is_err());
assert!(check_path_fits(Path::new("db/MANIFEST")).is_ok());
}
}