-
Notifications
You must be signed in to change notification settings - Fork 1.5k
feat(fs): add bounded byte-range reads #2792
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ import Url from "utils/Url"; | |
| import externalFs from "./externalFs"; | ||
| import Ftp from "./ftp"; | ||
| import internalFs from "./internalFs"; | ||
| import { decodeReadRange, validateReadRange } from "./readRange"; | ||
| import Sftp from "./sftp"; | ||
|
|
||
| const fsList = []; | ||
|
|
@@ -39,6 +40,7 @@ const fsList = []; | |
| * @property {() => Promise<boolean>} exists Check if file or directory exists | ||
| * @property {() => Promise<Stat>} stat Get file or directory stat | ||
| * @property {(encoding:string) => Promise<FileContent>} readFile Read file | ||
| * @property {(start:number, end:number, encoding?:string) => Promise<FileContent>} [readFileRange] Read a half-open byte range without loading the whole file | ||
| * @property {(data:FileContent, encoding: string) => Promise<void>} writeFile Write file content | ||
| * @property {(name:string, data:FileContent) => Promise<string>} createFile Create file and return url of the created file | ||
| * @property {(name:string) => Promise<string>} createDirectory Create directory and return url of the created directory | ||
|
|
@@ -94,6 +96,47 @@ fsOperation.extend( | |
|
|
||
| return data; | ||
| }, | ||
| async readFileRange(start, end, encoding) { | ||
| const range = validateReadRange(start, end); | ||
| if (range.length === 0) { | ||
| return decodeReadRange(new ArrayBuffer(0), encoding); | ||
| } | ||
|
|
||
| const response = await fetch(url, { | ||
| headers: { | ||
| Range: `bytes=${range.start}-${range.end - 1}`, | ||
| }, | ||
| }); | ||
|
|
||
| if (response.status !== 206) { | ||
| await response.body?.cancel(); | ||
| throw new Error("HTTP server does not support byte-range reads"); | ||
| } | ||
|
|
||
| const contentRange = response.headers.get("content-range"); | ||
| const returnedRange = contentRange?.match(/^bytes (\d+)-(\d+)\//i); | ||
| if ( | ||
| returnedRange && | ||
| (Number(returnedRange[1]) !== range.start || | ||
| Number(returnedRange[2]) >= range.end) | ||
| ) { | ||
| await response.body?.cancel(); | ||
| throw new Error("HTTP server returned a different byte range"); | ||
| } | ||
|
|
||
| const contentLength = Number(response.headers.get("content-length")); | ||
| if (Number.isFinite(contentLength) && contentLength > range.length) { | ||
| await response.body?.cancel(); | ||
| throw new Error("HTTP server returned more data than requested"); | ||
| } | ||
|
|
||
| const data = await response.arrayBuffer(); | ||
| if (data.byteLength > range.length) { | ||
|
Comment on lines
+133
to
+134
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a server returns How this was verified: The optional header guards can be skipped, while the response-size check runs only after the complete body has been buffered. Knowledge Base Used: File System |
||
| throw new Error("HTTP server returned more data than requested"); | ||
| } | ||
|
|
||
| return decodeReadRange(data, encoding); | ||
| }, | ||
| async writeFile(content, progress) { | ||
| return ajax.post(url, { | ||
| data: content, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| import { decode } from "utils/encodings"; | ||
|
|
||
| // Native bridges return byte arrays, whose length is limited to a signed int. | ||
| export const MAX_READ_RANGE_LENGTH = 0x7fffffff; | ||
|
|
||
| /** | ||
| * Validate a half-open byte range used by filesystem implementations. | ||
| * | ||
| * @param {number} start Inclusive byte offset | ||
| * @param {number} end Exclusive byte offset | ||
| * @returns {{start: number, end: number, length: number}} | ||
| */ | ||
| export function validateReadRange(start, end) { | ||
| if (!Number.isSafeInteger(start) || start < 0) { | ||
| throw new RangeError("Range start must be a non-negative safe integer"); | ||
| } | ||
|
|
||
| if (!Number.isSafeInteger(end) || end < start) { | ||
| throw new RangeError( | ||
| "Range end must be a safe integer greater than or equal to start", | ||
| ); | ||
| } | ||
|
|
||
| const length = end - start; | ||
| if (length > MAX_READ_RANGE_LENGTH) { | ||
| throw new RangeError( | ||
| `Range length must not exceed ${MAX_READ_RANGE_LENGTH} bytes`, | ||
| ); | ||
| } | ||
|
|
||
| return { start, end, length }; | ||
| } | ||
|
|
||
| /** | ||
| * Decode range data only after the backend has performed a bounded byte read. | ||
| * | ||
| * @param {ArrayBuffer} data | ||
| * @param {string} [encoding] | ||
| * @returns {Promise<ArrayBuffer|string|object>} | ||
| */ | ||
| export async function decodeReadRange(data, encoding) { | ||
| return encoding ? decode(data, encoding) : data; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a range contains an entire BOM-prefixed JSON document and uses the
jsonencoding, this path passes the BOM directly to JSON decoding instead of applying the normalization used byreadFile, causing the range read to reject for a document the full-file path accepts.Knowledge Base Used: File System