Skip to main content

ferrijs_std/buffer/
blob.rs

1// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2// SPDX-License-Identifier: Apache-2.0
3use std::ops::RangeInclusive;
4
5use crate::stream_web::{
6    readable_byte_stream_controller_close_stream,
7    readable_byte_stream_controller_enqueue_bytes_borrowed, utils::promise::PromisePrimordials,
8    CancelAlgorithm, PullAlgorithm, ReadableStream, ReadableStreamControllerClass,
9};
10use crate::utils::{
11    array_buffer::shared_array_buffer_view,
12    bytes::{get_lossy_string, ObjectBytes},
13    object::not_a_object_error,
14    primordials::Primordial,
15    result::ResultExt,
16    string::get_coerced_defined_string,
17};
18use rquickjs::{
19    atom::PredefinedAtom, class::Trace, function::Opt, prelude::This, Array, ArrayBuffer, Class,
20    Coerced, Ctx, Exception, FromJs, IntoJs, JsIterator, Result, TypedArray, Value,
21};
22
23use super::file::File;
24
25struct ArrayPartsIter<'js> {
26    array: Array<'js>,
27    index: usize,
28}
29
30impl<'js> ArrayPartsIter<'js> {
31    fn new(array: Array<'js>) -> Self {
32        Self { array, index: 0 }
33    }
34}
35
36impl<'js> Iterator for ArrayPartsIter<'js> {
37    type Item = Result<Value<'js>>;
38
39    fn next(&mut self) -> Option<Self::Item> {
40        let len: usize = match self.array.as_object().get(PredefinedAtom::Length) {
41            Ok(v) => v,
42            Err(e) => return Some(Err(e)),
43        };
44        if self.index >= len {
45            return None;
46        }
47        let result = self.array.get(self.index);
48        self.index += 1;
49        Some(result)
50    }
51}
52
53enum EndingType {
54    Native,
55    Transparent,
56}
57
58#[cfg(windows)]
59const LINE_ENDING: &[u8] = b"\r\n";
60#[cfg(not(windows))]
61const LINE_ENDING: &[u8] = b"\n";
62
63#[rquickjs::class]
64#[derive(Trace, Clone, rquickjs::JsLifetime)]
65pub struct Blob<'js> {
66    /// Bytes live in a JS-owned `ArrayBuffer` so `.arrayBuffer()` / `.bytes()`
67    /// / `.stream()` can hand out refcount-bumped views without copying.
68    data: ArrayBuffer<'js>,
69    mime_type: String,
70}
71
72fn normalize_type(mut mime_type: String) -> String {
73    static INVALID_RANGE: RangeInclusive<u8> = 0x0020..=0x007E;
74
75    let bytes = unsafe { mime_type.as_bytes_mut() };
76    for byte in bytes {
77        if !INVALID_RANGE.contains(byte) {
78            return String::new();
79        }
80        byte.make_ascii_lowercase();
81    }
82    mime_type
83}
84
85#[rquickjs::methods]
86impl<'js> Blob<'js> {
87    #[qjs(constructor)]
88    pub fn new(
89        ctx: Ctx<'js>,
90        this: This<Value<'js>>,
91        parts: Opt<Value<'js>>,
92        options: Opt<Value<'js>>,
93    ) -> Result<Self> {
94        if this.as_function().is_none() {
95            return Err(Exception::throw_type(
96                &ctx,
97                "Failed to construct 'Blob': Please use the 'new' operator",
98            ));
99        }
100
101        Self::from_parts(ctx, parts, options)
102    }
103
104    #[qjs(get)]
105    pub fn size(&self) -> usize {
106        self.data.len()
107    }
108
109    #[qjs(get, rename = "type")]
110    pub fn mime_type(&self) -> String {
111        self.mime_type.clone()
112    }
113
114    pub async fn text(&self) -> String {
115        String::from_utf8_lossy(self.as_bytes()).to_string()
116    }
117
118    #[qjs(rename = "arrayBuffer")]
119    pub async fn array_buffer(&self, ctx: Ctx<'js>) -> Result<ArrayBuffer<'js>> {
120        //should be mutable according to spec, thus copy is required
121        ArrayBuffer::new_copy(ctx, self.as_bytes())
122    }
123
124    pub async fn bytes(&self, ctx: Ctx<'js>) -> Result<Value<'js>> {
125        //should be mutable according to spec, thus copy is required
126        let ab = ArrayBuffer::new_copy(ctx, self.as_bytes())?;
127        TypedArray::<u8>::from_arraybuffer(ab).map(|t| t.into_value())
128    }
129
130    pub fn slice(
131        &self,
132        ctx: Ctx<'js>,
133        start: Opt<Value<'js>>,
134        end: Opt<Value<'js>>,
135        content_type: Opt<Value<'js>>,
136    ) -> Result<Blob<'js>> {
137        let start = start.0.and_then(|v| v.as_number()).map(clamp_long_long);
138        let end = end.0.and_then(|v| v.as_number()).map(clamp_long_long);
139        Self::slice_blob(self, &ctx, start, end, content_type.0)
140    }
141
142    pub fn stream(&self, ctx: Ctx<'js>) -> Result<Value<'js>> {
143        // Local delta: upstream captures `self.data` — a JS `ArrayBuffer` —
144        // in this native closure. A native closure that holds a JS value
145        // makes a cycle the collector cannot see, which trips
146        // `JS_FreeRuntime`'s `list_empty(&rt->gc_obj_list)` assertion at
147        // teardown in this runtime. The bytes are copied out instead and the
148        // buffer is rebuilt inside the closure.
149        let data = self.get_bytes();
150        let pull = PullAlgorithm::from_fn_once(
151            move |ctx: Ctx<'js>, controller: ReadableStreamControllerClass<'js>| {
152                let ctrl = match controller {
153                    ReadableStreamControllerClass::ReadableStreamByteController(c) => c,
154                    _ => return Err(Exception::throw_type(&ctx, "Expected byte controller")),
155                };
156                let len = data.len();
157                if len != 0 {
158                    let buffer = ArrayBuffer::new(ctx.clone(), data.clone())?;
159                    let view = shared_array_buffer_view(&ctx, &buffer, 0, len)?;
160                    readable_byte_stream_controller_enqueue_bytes_borrowed(
161                        ctx.clone(),
162                        ctrl.clone(),
163                        view,
164                    )?;
165                }
166                readable_byte_stream_controller_close_stream(ctx.clone(), ctrl)?;
167                Ok(PromisePrimordials::get(&ctx)?
168                    .promise_resolved_with_undefined
169                    .clone())
170            },
171        );
172        // Byte-source stream so callers can use `getReader({ mode: 'byob' })`.
173        // Matches spec: Blob.stream() returns a `type: "bytes"` ReadableStream.
174        let stream = ReadableStream::from_byte_pull_algorithm(
175            ctx,
176            pull,
177            CancelAlgorithm::ReturnPromiseUndefined,
178        )?;
179        Ok(stream.into_value())
180    }
181
182    #[qjs(prop, rename = PredefinedAtom::SymbolToStringTag, configurable)]
183    pub fn to_string_tag() -> &'static str {
184        stringify!(Blob)
185    }
186
187    #[qjs(static, rename = PredefinedAtom::SymbolHasInstance)]
188    pub fn has_instance(value: Value<'js>) -> bool {
189        if let Some(obj) = value.as_object() {
190            return obj.instance_of::<Self>() || obj.instance_of::<File>();
191        }
192        false
193    }
194
195    #[qjs(skip)]
196    pub fn slice_blob(
197        &self,
198        ctx: &Ctx<'js>,
199        start: Option<isize>,
200        end: Option<isize>,
201        content_type: Option<Value<'js>>,
202    ) -> Result<Blob<'js>> {
203        let bytes = self.as_bytes();
204        let len = bytes.len();
205        let start = start.unwrap_or_default();
206        let start = if start < 0 {
207            (len as isize + start).max(0) as usize
208        } else {
209            len.min(start as usize)
210        };
211        let end = end.unwrap_or(len as isize);
212        let end = if end < 0 {
213            (len as isize + end).max(0) as usize
214        } else {
215            len.min(end as usize)
216        };
217        let data = shared_array_buffer_view(ctx, &self.data, start, end.saturating_sub(start))?;
218        let mime_type = get_coerced_defined_string(&content_type);
219        let mime_type = mime_type.map(normalize_type).unwrap_or_default();
220        Ok(Blob { mime_type, data })
221    }
222}
223
224impl<'js> Blob<'js> {
225    pub fn from_bytes(ctx: &Ctx<'js>, data: Vec<u8>, content_type: Option<String>) -> Result<Self> {
226        let mime_type = content_type.map(normalize_type).unwrap_or_default();
227        let data = ArrayBuffer::new(ctx.clone(), data)?;
228        Ok(Self { mime_type, data })
229    }
230
231    pub fn from_parts(
232        ctx: Ctx<'js>,
233        parts: Opt<Value<'js>>,
234        options: Opt<Value<'js>>,
235    ) -> Result<Self> {
236        if let Some(options) = options.0.as_ref() {
237            if !options.is_null() && !options.is_undefined() && options.as_object().is_none() {
238                return Err(not_a_object_error(&ctx, "options"));
239            }
240        }
241
242        let mut endings = EndingType::Transparent;
243        if let Some(options) = options.0.as_ref() {
244            if let Some(opts) = options.as_object() {
245                if opts.contains_key("endings")? {
246                    if let Some(parsed) = parse_endings(&ctx, opts.get("endings")?)? {
247                        endings = parsed;
248                    }
249                }
250            }
251        }
252
253        let bytes = if let Some(parts) = parts.0 {
254            bytes_from_parts(&ctx, parts, endings)?
255        } else {
256            Vec::new()
257        };
258
259        let mut mime_type = String::new();
260        if let Some(options) = options.0.as_ref() {
261            if let Some(opts) = options.as_object() {
262                if let Some(x) = opts.get::<_, Option<Coerced<String>>>("type")? {
263                    mime_type = normalize_type(x.to_string());
264                }
265            }
266        }
267
268        // Transfer Vec ownership to JS — QuickJS calls the drop callback when
269        // the ArrayBuffer is GC'd, so no extra Rust-side copy.
270        let data = ArrayBuffer::new(ctx, bytes)?;
271
272        Ok(Self { data, mime_type })
273    }
274
275    pub fn get_bytes(&self) -> Vec<u8> {
276        self.as_bytes().to_vec()
277    }
278
279    /// Zero-copy access to the underlying `ArrayBuffer`. Cloning the handle is
280    /// cheap (it's a JS-refcount bump); no bytes are copied. Useful for
281    /// consumers that want to pass the Blob body on to hyper via
282    /// `ObjectBytes::DataView` without the `get_bytes()` allocation.
283    pub fn array_buffer_ref(&self) -> ArrayBuffer<'js> {
284        self.data.clone()
285    }
286
287    /// Borrow the underlying bytes directly. Returns `&[]` if the ArrayBuffer
288    /// has been detached (shouldn't happen in normal blob flow).
289    /// The borrow carries 0.13's obligation to the caller: no JS may run
290    /// while the slice is alive.
291    pub fn as_bytes(&self) -> &[u8] {
292        unsafe { self.data.as_bytes() }.unwrap_or(&[])
293    }
294}
295
296fn bytes_from_parts<'js>(
297    ctx: &Ctx<'js>,
298    parts: Value<'js>,
299    endings: EndingType,
300) -> Result<Vec<u8>> {
301    if parts.is_undefined() {
302        return Ok(Vec::new());
303    }
304
305    if let Some(array) = parts.clone().into_array() {
306        return process_parts(ctx, ArrayPartsIter::new(array), endings);
307    }
308
309    process_parts(ctx, JsIterator::from_js(ctx, parts)?, endings)
310}
311
312fn process_parts<'js, I>(ctx: &Ctx<'js>, iter: I, endings: EndingType) -> Result<Vec<u8>>
313where
314    I: IntoIterator<Item = Result<Value<'js>>>,
315{
316    let mut data = Vec::new();
317    for elem in iter {
318        let elem = elem?;
319        if let Some(arr) = elem.as_array() {
320            let string = array_to_string(arr)?;
321            data.extend_from_slice(string.as_bytes());
322            continue;
323        }
324        if let Some(object) = elem.as_object() {
325            if let Some(x) = Class::<Blob>::from_object(object) {
326                data.extend_from_slice(x.borrow().as_bytes());
327                continue;
328            }
329            if let Some(x) = Class::<File>::from_object(object) {
330                let file = x.borrow();
331                let end = Some(file.size().try_into().or_throw(ctx)?);
332                let mime_type = Some(file.mime_type().into_js(ctx)?);
333                let sub = file.slice(ctx.clone(), Opt(Some(0)), Opt(end), Opt(mime_type))?;
334                data.extend_from_slice(sub.as_bytes());
335                continue;
336            }
337            if let Ok(x) = ObjectBytes::from(ctx, object) {
338                data.extend_from_slice(x.as_bytes(ctx).map_err(|_| {
339                    Exception::throw_type(ctx, "Cannot create a blob with detached buffer")
340                })?);
341                continue;
342            }
343            if let Some(x) = ArrayBuffer::from_object(object.clone()) {
344                // SAFETY: copied before anything re-enters script.
345                data.extend_from_slice(unsafe { x.as_bytes() }.ok_or_else(|| {
346                    Exception::throw_type(ctx, "Cannot create a blob with detached buffer")
347                })?);
348                continue;
349            }
350        }
351
352        let string = if elem.is_string() {
353            get_lossy_string(elem)?
354        } else {
355            Coerced::<String>::from_js(ctx, elem)?.0
356        };
357        if let EndingType::Transparent = endings {
358            data.extend_from_slice(string.as_bytes());
359        } else {
360            let len = string.len();
361            data.reserve(len);
362
363            let bytes = string.as_bytes();
364            let mut iter = bytes.iter();
365
366            let mut start = 0usize;
367            let mut i = 0usize;
368            let line_ending_is_n = LINE_ENDING[0] == b'\n';
369
370            while let Some(byte) = iter.next() {
371                if byte == &b'\r' {
372                    if let Some(next_byte) = iter.next() {
373                        data.extend(&bytes[start..i]);
374                        i += 1;
375                        start = i + 1;
376                        if next_byte != &b'\n' {
377                            data.extend([b'\r', *next_byte]);
378                        } else {
379                            data.extend(LINE_ENDING);
380                        }
381                    }
382                } else if byte == &b'\n' && !line_ending_is_n {
383                    data.extend(&bytes[start..i]);
384                    data.extend(LINE_ENDING);
385                    start = i + 1;
386                };
387                i += 1;
388            }
389
390            if start < len {
391                data.extend(&bytes[start..len]);
392            }
393        }
394    }
395    Ok(data)
396}
397
398fn parse_endings<'js>(ctx: &Ctx<'js>, value: Value<'js>) -> Result<Option<EndingType>> {
399    if value.is_undefined() {
400        return Ok(None);
401    }
402    let endings = match Coerced::<String>::from_js(ctx, value)?.0.as_str() {
403        "transparent" => Some(EndingType::Transparent),
404        "native" => Some(EndingType::Native),
405        _ => {
406            return Err(Exception::throw_type(
407                ctx,
408                r#"expected 'endings' to be either 'transparent' or 'native'"#,
409            ));
410        },
411    };
412    Ok(endings)
413}
414
415fn array_to_string(array: &Array) -> Result<String> {
416    let mut itoa_buffer = itoa::Buffer::new();
417    let mut ryu_buffer = ryu::Buffer::new();
418
419    let parts = array
420        .clone()
421        .into_iter()
422        .map(|value| {
423            let value = value?;
424            if let Some(string) = value.as_string() {
425                Ok(string.to_string()?)
426            } else if let Some(number) = value.as_int() {
427                Ok(itoa_buffer.format(number).to_string())
428            } else if let Some(number) = value.as_float() {
429                Ok(ryu_buffer.format(number).to_string())
430            } else {
431                Ok(String::new())
432            }
433        })
434        .collect::<Result<Vec<_>>>()?;
435
436    Ok(parts.join(","))
437}
438
439fn clamp_long_long(value: f64) -> isize {
440    if value.is_nan() {
441        return 0;
442    }
443    let rounded = value.round_ties_even();
444    rounded.clamp(isize::MIN as f64, isize::MAX as f64) as isize
445}