gibblox-web-file 0.0.1

Block reader backed by browser File objects.
Documentation
#![cfg_attr(not(feature = "std"), no_std)]

extern crate alloc;

#[cfg(target_arch = "wasm32")]
mod wasm {
    use alloc::{boxed::Box, format, string::String};
    use async_trait::async_trait;
    use core::{
        fmt,
        future::Future,
        pin::Pin,
        task::{Context, Poll},
    };
    use gibblox_core::{
        BlockByteReader, BlockReader, BlockReaderConfigIdentity, ByteReader, GibbloxError,
        GibbloxErrorKind, GibbloxResult, ReadContext,
    };
    use js_sys::{Promise, Uint8Array};
    use tracing::{debug, trace};
    use wasm_bindgen::JsValue;
    use wasm_bindgen_futures::JsFuture;
    use web_sys::{Blob, File};

    const JS_SAFE_INTEGER_MAX: u64 = 9_007_199_254_740_991;

    struct SendJsFuture(JsFuture);

    unsafe impl Send for SendJsFuture {}

    impl From<Promise> for SendJsFuture {
        fn from(promise: Promise) -> Self {
            Self(JsFuture::from(promise))
        }
    }

    impl Future for SendJsFuture {
        type Output = Result<JsValue, JsValue>;

        fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
            Pin::new(&mut self.0).poll(cx)
        }
    }

    #[derive(Clone)]
    struct SendFile(File);

    unsafe impl Send for SendFile {}
    unsafe impl Sync for SendFile {}

    impl SendFile {
        fn as_blob(&self) -> &Blob {
            self.0.as_ref()
        }

        fn name(&self) -> String {
            self.0.name()
        }

        fn last_modified(&self) -> f64 {
            self.0.last_modified()
        }
    }

    #[derive(Clone, Debug)]
    pub struct WebFileReaderConfig {
        pub block_size: u32,
        pub file_name: String,
        pub size_bytes: u64,
        pub last_modified: i64,
    }

    impl WebFileReaderConfig {
        fn from_send_file(file: &SendFile, block_size: u32) -> GibbloxResult<Self> {
            if block_size == 0 || !block_size.is_power_of_two() {
                return Err(GibbloxError::with_message(
                    GibbloxErrorKind::InvalidInput,
                    "block size must be non-zero power of two",
                ));
            }

            Ok(Self {
                block_size,
                file_name: file.name(),
                size_bytes: f64_to_u64(file.as_blob().size(), "file size")?,
                last_modified: f64_to_i64(file.last_modified(), "file last_modified")?,
            })
        }

        pub fn from_file(file: &File, block_size: u32) -> GibbloxResult<Self> {
            Self::from_send_file(&SendFile(file.clone()), block_size)
        }
    }

    impl BlockReaderConfigIdentity for WebFileReaderConfig {
        fn write_identity(&self, out: &mut dyn fmt::Write) -> fmt::Result {
            write!(
                out,
                "web-file:{}:{}:{}",
                self.file_name, self.size_bytes, self.last_modified
            )
        }
    }

    pub struct WebFileReader {
        file: SendFile,
        config: WebFileReaderConfig,
    }

    impl WebFileReader {
        pub fn new(file: File, block_size: u32) -> GibbloxResult<Self> {
            let file = SendFile(file);
            let config = WebFileReaderConfig::from_send_file(&file, block_size)?;
            debug!(
                name = %config.file_name,
                size_bytes = config.size_bytes,
                last_modified = config.last_modified,
                block_size = config.block_size,
                "opening web file-backed source"
            );

            Ok(Self { file, config })
        }

        pub fn config(&self) -> &WebFileReaderConfig {
            &self.config
        }

        pub fn size_bytes(&self) -> u64 {
            self.config.size_bytes
        }
    }

    #[async_trait]
    impl ByteReader for WebFileReader {
        async fn size_bytes(&self) -> GibbloxResult<u64> {
            Ok(self.config.size_bytes)
        }

        fn write_identity(&self, out: &mut dyn fmt::Write) -> fmt::Result {
            self.config.write_identity(out)
        }

        async fn read_at(
            &self,
            offset: u64,
            buf: &mut [u8],
            _ctx: ReadContext,
        ) -> GibbloxResult<usize> {
            if buf.is_empty() {
                return Ok(0);
            }
            if offset >= self.config.size_bytes {
                return Ok(0);
            }

            let remaining = self.config.size_bytes.checked_sub(offset).ok_or_else(|| {
                GibbloxError::with_message(GibbloxErrorKind::OutOfRange, "offset out of range")
            })?;
            let read_len = (buf.len() as u64).min(remaining) as usize;
            let end = offset.checked_add(read_len as u64).ok_or_else(|| {
                GibbloxError::with_message(GibbloxErrorKind::OutOfRange, "read range overflow")
            })?;

            let promise = self
                .file
                .as_blob()
                .slice_with_f64_and_f64(
                    to_js_number(offset, "read start")?,
                    to_js_number(end, "read end")?,
                )
                .map_err(js_io("slice file"))?
                .array_buffer();
            let buffer = SendJsFuture::from(promise)
                .await
                .map_err(js_io("await file read"))?;
            let bytes = Uint8Array::new(&buffer);

            let available = bytes.length() as usize;
            if available < read_len {
                return Err(GibbloxError::with_message(
                    GibbloxErrorKind::Io,
                    format!(
                        "short read while reading web file slice: expected {read_len}, got {available}"
                    ),
                ));
            }
            bytes
                .subarray(0, read_len as u32)
                .copy_to(&mut buf[..read_len]);

            trace!(offset, requested = read_len, "performed web file byte read");
            Ok(read_len)
        }
    }

    #[async_trait]
    impl BlockReader for WebFileReader {
        fn block_size(&self) -> u32 {
            self.config.block_size
        }

        async fn total_blocks(&self) -> GibbloxResult<u64> {
            Ok(self
                .config
                .size_bytes
                .div_ceil(self.config.block_size as u64))
        }

        fn write_identity(&self, out: &mut dyn fmt::Write) -> fmt::Result {
            self.config.write_identity(out)
        }

        async fn read_blocks(
            &self,
            lba: u64,
            buf: &mut [u8],
            ctx: ReadContext,
        ) -> GibbloxResult<usize> {
            let adapter = BlockByteReader::new(self, self.config.block_size)?;
            let read = adapter.read_blocks(lba, buf, ctx).await?;
            trace!(
                lba,
                requested = buf.len(),
                read,
                "performed web file block read"
            );
            Ok(read)
        }
    }

    fn to_js_number(value: u64, label: &'static str) -> GibbloxResult<f64> {
        if value > JS_SAFE_INTEGER_MAX {
            return Err(GibbloxError::with_message(
                GibbloxErrorKind::OutOfRange,
                format!("{label} exceeds JavaScript safe integer"),
            ));
        }
        Ok(value as f64)
    }

    fn f64_to_u64(value: f64, label: &'static str) -> GibbloxResult<u64> {
        if !value.is_finite() || value < 0.0 || value.fract() != 0.0 {
            return Err(GibbloxError::with_message(
                GibbloxErrorKind::InvalidInput,
                format!("invalid {label} from web file"),
            ));
        }
        to_js_number(value as u64, label).map(|_| value as u64)
    }

    fn f64_to_i64(value: f64, label: &'static str) -> GibbloxResult<i64> {
        if !value.is_finite() || value.fract() != 0.0 {
            return Err(GibbloxError::with_message(
                GibbloxErrorKind::InvalidInput,
                format!("invalid {label} from web file"),
            ));
        }
        if value.abs() > JS_SAFE_INTEGER_MAX as f64
            || value < i64::MIN as f64
            || value > i64::MAX as f64
        {
            return Err(GibbloxError::with_message(
                GibbloxErrorKind::OutOfRange,
                format!("{label} exceeds supported range"),
            ));
        }
        Ok(value as i64)
    }

    fn js_io(op: &'static str) -> impl FnOnce(JsValue) -> GibbloxError {
        move |err| {
            GibbloxError::with_message(
                GibbloxErrorKind::Io,
                format!("{op}: {}", js_value_to_string(err)),
            )
        }
    }

    fn js_value_to_string(value: JsValue) -> String {
        js_sys::JSON::stringify(&value)
            .ok()
            .and_then(|s| s.as_string())
            .unwrap_or_else(|| format!("{value:?}"))
    }
}

#[cfg(target_arch = "wasm32")]
pub use wasm::*;