From 3590d9c2b76ab0d2d98ac2ac5b5eeb197788cbc0 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sat, 22 Aug 2026 09:41:33 +0530 Subject: [PATCH] feat(fs): add bounded byte-range reads --- src/fileSystem/externalFs.js | 20 +++ src/fileSystem/index.js | 43 +++++ src/fileSystem/internalFs.js | 34 ++++ src/fileSystem/readRange.js | 43 +++++ src/fileSystem/sftp.js | 34 ++++ src/plugins/sdcard/index.d.ts | 8 + src/plugins/sdcard/src/android/SDcard.java | 85 +++++++++ src/plugins/sdcard/www/plugin.js | 3 + src/plugins/sftp/index.d.ts | 4 +- .../sftp/src/com/foxdebug/sftp/Sftp.java | 51 ++++++ src/plugins/sftp/www/sftp.js | 9 +- src/test/fs.tests.js | 10 ++ tests/unit/readFileRange.test.js | 163 ++++++++++++++++++ 13 files changed, 503 insertions(+), 4 deletions(-) create mode 100644 src/fileSystem/readRange.js create mode 100644 tests/unit/readFileRange.test.js diff --git a/src/fileSystem/externalFs.js b/src/fileSystem/externalFs.js index df6744238..8a65a98c5 100644 --- a/src/fileSystem/externalFs.js +++ b/src/fileSystem/externalFs.js @@ -2,6 +2,7 @@ import loader from "dialogs/loader"; import { decode, encode, getEncodingName } from "utils/encodings"; import helpers from "utils/helpers"; import Url from "utils/Url"; +import { decodeReadRange, validateReadRange } from "./readRange"; const externalFs = { async readFile(url) { @@ -11,6 +12,21 @@ const externalFs = { }); }, + async readFileRange(url, start, end) { + const range = validateReadRange(start, end); + if (range.length === 0) return { data: new ArrayBuffer(0) }; + url = await this.formatUri(url); + return new Promise((resolve, reject) => { + sdcard.readRange( + url, + range.start, + range.end, + (data) => resolve({ data }), + reject, + ); + }); + }, + async readAsText(url, encoding) { url = await this.formatUri(url); return new Promise((resolve, reject) => { @@ -203,6 +219,10 @@ function createFs(url) { let { data } = await externalFs.readFile(url); return data; }, + async readFileRange(start, end, encoding) { + const { data } = await externalFs.readFileRange(url, start, end); + return decodeReadRange(data, encoding); + }, async writeFile(content, encoding) { if (typeof content === "string" && encoding) { const charset = getEncodingName( diff --git a/src/fileSystem/index.js b/src/fileSystem/index.js index 7169afdec..a7a7ab77b 100644 --- a/src/fileSystem/index.js +++ b/src/fileSystem/index.js @@ -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} exists Check if file or directory exists * @property {() => Promise} stat Get file or directory stat * @property {(encoding:string) => Promise} readFile Read file + * @property {(start:number, end:number, encoding?:string) => Promise} [readFileRange] Read a half-open byte range without loading the whole file * @property {(data:FileContent, encoding: string) => Promise} writeFile Write file content * @property {(name:string, data:FileContent) => Promise} createFile Create file and return url of the created file * @property {(name:string) => Promise} 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) { + throw new Error("HTTP server returned more data than requested"); + } + + return decodeReadRange(data, encoding); + }, async writeFile(content, progress) { return ajax.post(url, { data: content, diff --git a/src/fileSystem/internalFs.js b/src/fileSystem/internalFs.js index b26bd585e..b0ef399a4 100644 --- a/src/fileSystem/internalFs.js +++ b/src/fileSystem/internalFs.js @@ -3,6 +3,7 @@ import ajax from "lib/ajax"; import { decode, encode } from "utils/encodings"; import helpers from "utils/helpers"; import Url from "utils/Url"; +import { decodeReadRange, validateReadRange } from "./readRange"; const internalFs = { /** @@ -122,6 +123,35 @@ const internalFs = { }); }, + /** + * Read a half-open byte range without loading the complete file. + * @param {string} filename + * @param {number} start Inclusive byte offset + * @param {number} end Exclusive byte offset + * @returns {Promise<{data: ArrayBuffer}>} + */ + readFileRange(filename, start, end) { + const range = validateReadRange(start, end); + if (range.length === 0) { + return Promise.resolve({ data: new ArrayBuffer(0) }); + } + return new Promise((resolve, reject) => { + reject = setMessage(reject); + window.resolveLocalFileSystemURL( + filename, + (fileEntry) => { + fileEntry.file((file) => { + const fileReader = new FileReader(); + fileReader.onerror = () => reject(fileReader.error); + fileReader.onload = () => resolve({ data: fileReader.result }); + fileReader.readAsArrayBuffer(file.slice(range.start, range.end)); + }, reject); + }, + reject, + ); + }); + }, + /** * Rename a file or directory * @param {string} url @@ -413,6 +443,10 @@ function createFs(url) { return data; }, + async readFileRange(start, end, encoding) { + const { data } = await internalFs.readFileRange(url, start, end); + return decodeReadRange(data, encoding); + }, async writeFile(content, encoding) { if (typeof content === "string" && encoding) { content = await encode(content, encoding); diff --git a/src/fileSystem/readRange.js b/src/fileSystem/readRange.js new file mode 100644 index 000000000..97b31978e --- /dev/null +++ b/src/fileSystem/readRange.js @@ -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} + */ +export async function decodeReadRange(data, encoding) { + return encoding ? decode(data, encoding) : data; +} diff --git a/src/fileSystem/sftp.js b/src/fileSystem/sftp.js index 803be8af4..105e58600 100644 --- a/src/fileSystem/sftp.js +++ b/src/fileSystem/sftp.js @@ -5,6 +5,7 @@ import helpers from "utils/helpers"; import Path from "utils/Path"; import Url from "utils/Url"; import internalFs from "./internalFs"; +import { decodeReadRange, validateReadRange } from "./readRange"; let pendingConnection = null; let pendingConnectionID = null; @@ -210,6 +211,22 @@ class SftpClient { }); } + /** Read a half-open byte range directly from the remote file. */ + async readFileRange(start, end) { + const range = validateReadRange(start, end); + if (range.length === 0) return { data: new ArrayBuffer(0) }; + await this.#connectIfNotConnected(); + return new Promise((resolve, reject) => { + sftp.getFileRange( + this.#safeName(this.#path), + range.start, + range.end, + (data) => resolve({ data }), + reject, + ); + }); + } + async copyTo(dest) { const src = this.#path; return new Promise((resolve, reject) => { @@ -585,6 +602,19 @@ class SftpClient { ); } + async #connectIfNotConnected() { + return new Promise((resolve, reject) => { + sftp.isConnected(async (connectionID) => { + try { + if (this.#notConnected(connectionID)) await this.connect(); + resolve(); + } catch (error) { + reject(error); + } + }, reject); + }); + } + async #setStat() { if (!this.#stat) { this.#stat = await this.stat(); @@ -635,6 +665,10 @@ function createFs(sftp) { return data; }, + async readFileRange(start, end, encoding) { + const { data } = await sftp.readFileRange(start, end); + return decodeReadRange(data, encoding); + }, async writeFile(content, encoding) { if (typeof content === "string" && encoding) { content = await encode(content, encoding); diff --git a/src/plugins/sdcard/index.d.ts b/src/plugins/sdcard/index.d.ts index f151eab34..f4b409e8f 100644 --- a/src/plugins/sdcard/index.d.ts +++ b/src/plugins/sdcard/index.d.ts @@ -115,6 +115,14 @@ interface SDcard { onSuccess: (url: string) => void, onFail: (err: any) => void, ): void; + /** Reads the half-open byte range [start, end) without loading the whole file. */ + readRange( + src: string, + start: number, + end: number, + onSuccess: (data: ArrayBuffer) => void, + onFail: (err: any) => void, + ): void; /** * Checks if given file/directory * @param src File/Directory url diff --git a/src/plugins/sdcard/src/android/SDcard.java b/src/plugins/sdcard/src/android/SDcard.java index 1b33e7ede..ed8969b76 100644 --- a/src/plugins/sdcard/src/android/SDcard.java +++ b/src/plugins/sdcard/src/android/SDcard.java @@ -111,6 +111,14 @@ public boolean execute( case "read": readFile(arg1, callback); break; + case "readRange": + readFileRange( + arg1, + args.optLong(1, -1), + args.optLong(2, -1), + callback + ); + break; case "readAsText": readAsText(arg1, arg2, callback); break; @@ -497,6 +505,83 @@ public void run() { ); } + private void readFileRange( + String filename, + long start, + long end, + CallbackContext callback + ) { + cordova + .getThreadPool() + .execute( + new Runnable() { + public void run() { + if (start < 0 || end < start || end - start > Integer.MAX_VALUE) { + callback.error("Invalid byte range"); + return; + } + + Uri uri = Uri.parse(formatUri(filename)); + try ( + InputStream input = context + .getContentResolver() + .openInputStream(uri) + ) { + if (input == null) { + callback.error("File not found"); + return; + } + + if (!skipFully(input, start)) { + callback.success(new byte[0]); + return; + } + + callback.success(readAtMost(input, end - start)); + } catch (Exception e) { + callback.error(e.toString()); + } + } + } + ); + } + + private static boolean skipFully(InputStream input, long count) + throws IOException { + long remaining = count; + while (remaining > 0) { + long skipped = input.skip(remaining); + if (skipped > 0) { + remaining -= skipped; + } else if (input.read() == -1) { + return false; + } else { + remaining--; + } + } + return true; + } + + private static byte[] readAtMost(InputStream input, long count) + throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream( + (int) Math.min(count, 32768) + ); + byte[] buffer = new byte[32768]; + long remaining = count; + while (remaining > 0) { + int bytesRead = input.read( + buffer, + 0, + (int) Math.min(buffer.length, remaining) + ); + if (bytesRead == -1) break; + output.write(buffer, 0, bytesRead); + remaining -= bytesRead; + } + return output.toByteArray(); + } + private void readAsText(final String filename, final String encoding, final CallbackContext callback) { cordova .getThreadPool() diff --git a/src/plugins/sdcard/www/plugin.js b/src/plugins/sdcard/www/plugin.js index 7d994f8aa..4a119cb75 100644 --- a/src/plugins/sdcard/www/plugin.js +++ b/src/plugins/sdcard/www/plugin.js @@ -44,6 +44,9 @@ module.exports = { read: function (filename, onSuccess, onFail) { cordova.exec(onSuccess, onFail, 'SDcard', 'read', [filename]); }, + readRange: function (filename, start, end, onSuccess, onFail) { + cordova.exec(onSuccess, onFail, 'SDcard', 'readRange', [filename, String(start), String(end)]); + }, readAsText: function (filename, encoding, onSuccess, onFail) { cordova.exec(onSuccess, onFail, 'SDcard', 'readAsText', [filename, encoding]); }, diff --git a/src/plugins/sftp/index.d.ts b/src/plugins/sftp/index.d.ts index ca7db1d09..e89b67c24 100644 --- a/src/plugins/sftp/index.d.ts +++ b/src/plugins/sftp/index.d.ts @@ -58,7 +58,9 @@ interface Sftp { * @param onSuccess * @param onFail */ - getFile(filename: String, localFilename: String, onSuccess: (url: String) => void, onFail: (err: any) => void): void; + getFile(filename: String, localFilename: String, onSuccess: (url: String) => void, onFail: (err: any) => void): void; + /** Reads the half-open byte range [start, end) directly from the server. */ + getFileRange(filename: String, start: Number, end: Number, onSuccess: (data: ArrayBuffer) => void, onFail: (err: any) => void): void; /** * Uploaded the file to server diff --git a/src/plugins/sftp/src/com/foxdebug/sftp/Sftp.java b/src/plugins/sftp/src/com/foxdebug/sftp/Sftp.java index a72466607..cb5e50c29 100644 --- a/src/plugins/sftp/src/com/foxdebug/sftp/Sftp.java +++ b/src/plugins/sftp/src/com/foxdebug/sftp/Sftp.java @@ -969,6 +969,7 @@ private static boolean isAllowedAction(String action) { case "getProfileInfo": case "deleteProfile": case "getFile": + case "getFileRange": case "putFile": case "lsDir": case "stat": @@ -1199,6 +1200,56 @@ public void run() { ); } + public void getFileRange(JSONArray args, CallbackContext callback) { + cordova + .getThreadPool() + .execute( + new Runnable() { + public void run() { + String filename = args.optString(0); + long start = args.optLong(1, -1); + long end = args.optLong(2, -1); + if (start < 0 || end < start || end - start > Integer.MAX_VALUE) { + callback.error("Invalid byte range"); + return; + } + if (ssh == null || sftp == null) { + callback.error("Not connected"); + return; + } + + try ( + InputStream inputStream = sftp.getInputStream(filename, start) + ) { + callback.success(readAtMost(inputStream, end - start)); + } catch (SftpStatusException | SshException | IOException e) { + callback.error("SFTP range read error: " + errMessage(e)); + } + } + } + ); + } + + private static byte[] readAtMost(InputStream input, long count) + throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream( + (int) Math.min(count, 32768) + ); + byte[] buffer = new byte[32768]; + long remaining = count; + while (remaining > 0) { + int bytesRead = input.read( + buffer, + 0, + (int) Math.min(buffer.length, remaining) + ); + if (bytesRead == -1) break; + output.write(buffer, 0, bytesRead); + remaining -= bytesRead; + } + return output.toByteArray(); + } + public void putFile(JSONArray args, CallbackContext callback) { cordova .getThreadPool() diff --git a/src/plugins/sftp/www/sftp.js b/src/plugins/sftp/www/sftp.js index 855cf1cdf..496b65047 100644 --- a/src/plugins/sftp/www/sftp.js +++ b/src/plugins/sftp/www/sftp.js @@ -23,9 +23,12 @@ module.exports = { deleteProfile: function (profileId, onSuccess, onFail) { cordova.exec(onSuccess, onFail, 'Sftp', 'deleteProfile', [profileId]); }, - getFile: function (filename, localFilename, onSuccess, onFail) { - cordova.exec(onSuccess, onFail, 'Sftp', 'getFile', [filename, localFilename]); - }, + getFile: function (filename, localFilename, onSuccess, onFail) { + cordova.exec(onSuccess, onFail, 'Sftp', 'getFile', [filename, localFilename]); + }, + getFileRange: function (filename, start, end, onSuccess, onFail) { + cordova.exec(onSuccess, onFail, 'Sftp', 'getFileRange', [filename, start, end]); + }, putFile: function (filename, localFilename, onSuccess, onFail) { cordova.exec(onSuccess, onFail, 'Sftp', 'putFile', [filename, localFilename]); }, diff --git a/src/test/fs.tests.js b/src/test/fs.tests.js index fa853a258..a10a40e81 100644 --- a/src/test/fs.tests.js +++ b/src/test/fs.tests.js @@ -22,6 +22,10 @@ export async function runFsTests(writeOutput) { "createFile should be a function", ); test.assert(typeof fs.exists === "function", "exists should be a function"); + test.assert( + typeof fs.readFileRange === "function", + "readFileRange should be available for local files", + ); }); runner.test( @@ -52,6 +56,12 @@ export async function runFsTests(writeOutput) { "initial content", "Read content should match initial content", ); + const range = await fileFs.readFileRange(8, 15, "utf-8"); + test.assertEqual( + range, + "content", + "Range read should return only the requested bytes", + ); // 4. Write new content await fileFs.writeFile("updated content"); diff --git a/tests/unit/readFileRange.test.js b/tests/unit/readFileRange.test.js new file mode 100644 index 000000000..82f675a4d --- /dev/null +++ b/tests/unit/readFileRange.test.js @@ -0,0 +1,163 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + decode: vi.fn(async (data) => new TextDecoder().decode(data)), +})); + +vi.mock("lib/ajax", () => ({ default: vi.fn() })); +vi.mock("dialogs/loader", () => ({ default: { destroy: vi.fn() } })); +vi.mock("utils/encodings", () => ({ + decode: mocks.decode, + encode: vi.fn(), + getEncodingName: vi.fn((encoding) => encoding), +})); +vi.mock("utils/helpers", () => ({ + default: { + defineDeprecatedProperty: vi.fn(), + parseJSON: JSON.parse, + uuid: vi.fn(() => "uuid"), + }, +})); +vi.mock("fileSystem/ftp", () => { + function Ftp() {} + Ftp.test = () => false; + Ftp.fromUrl = vi.fn(); + return { default: Ftp }; +}); +vi.mock("fileSystem/sftp", () => { + function Sftp() {} + Sftp.test = () => false; + Sftp.fromUrl = vi.fn(); + return { default: Sftp }; +}); + +import externalFs from "fileSystem/externalFs"; +import fsOperation from "fileSystem/index"; +import internalFs from "fileSystem/internalFs"; +import { + MAX_READ_RANGE_LENGTH, + validateReadRange, +} from "fileSystem/readRange"; + +describe("filesystem byte-range reads", () => { + const originalWindow = globalThis.window; + const originalFileReader = globalThis.FileReader; + const originalSdcard = globalThis.sdcard; + const originalFetch = globalThis.fetch; + + beforeEach(() => { + mocks.decode.mockClear(); + }); + + afterEach(() => { + globalThis.window = originalWindow; + globalThis.FileReader = originalFileReader; + globalThis.sdcard = originalSdcard; + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it("validates half-open ranges before a backend allocates memory", () => { + expect(validateReadRange(4, 9)).toEqual({ start: 4, end: 9, length: 5 }); + expect(() => validateReadRange(-1, 2)).toThrow(RangeError); + expect(() => validateReadRange(3, 2)).toThrow(RangeError); + expect(() => + validateReadRange(0, MAX_READ_RANGE_LENGTH + 1), + ).toThrow(RangeError); + }); + + it("uses Blob.slice for internal files instead of calling the full reader", async () => { + const requestedData = new TextEncoder().encode("range").buffer; + const slice = vi.fn(() => ({ requestedData })); + const fullRead = vi.spyOn(internalFs, "readFile"); + + globalThis.window = { + resolveLocalFileSystemURL: vi.fn((_url, resolve) => { + resolve({ + file(callback) { + callback({ slice }); + }, + }); + }), + }; + globalThis.FileReader = class { + readAsArrayBuffer(blob) { + this.result = blob.requestedData; + queueMicrotask(() => this.onload()); + } + }; + + const result = await internalFs + .createFs("file:///large.txt") + .readFileRange(1024, 1029, "utf-8"); + + expect(result).toBe("range"); + expect(slice).toHaveBeenCalledOnce(); + expect(slice).toHaveBeenCalledWith(1024, 1029); + expect(fullRead).not.toHaveBeenCalled(); + }); + + it("delegates external ranges to the native bounded-read bridge", async () => { + const requestedData = new Uint8Array([2, 3, 4]).buffer; + globalThis.sdcard = { + formatUri: vi.fn((_url, resolve) => resolve("content://real/file")), + readRange: vi.fn((_url, _start, _end, resolve) => + resolve(requestedData), + ), + read: vi.fn(), + }; + + const result = await externalFs + .createFs("content://virtual/file") + .readFileRange(10, 13); + + expect(result).toBe(requestedData); + expect(globalThis.sdcard.readRange).toHaveBeenCalledWith( + "content://real/file", + 10, + 13, + expect.any(Function), + expect.any(Function), + ); + expect(globalThis.sdcard.read).not.toHaveBeenCalled(); + }); + + it("sends an HTTP Range request and accepts only partial responses", async () => { + const requestedData = new TextEncoder().encode("chunk").buffer; + globalThis.fetch = vi.fn(async () => ({ + status: 206, + headers: new Headers({ + "content-length": "5", + "content-range": "bytes 20-24/1000", + }), + arrayBuffer: vi.fn(async () => requestedData), + body: { cancel: vi.fn() }, + })); + + const result = await fsOperation("https://example.com/large.txt") + .readFileRange(20, 25); + + expect(result).toBe(requestedData); + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://example.com/large.txt", + { headers: { Range: "bytes=20-24" } }, + ); + }); + + it("cancels an HTTP response when the server ignores the range", async () => { + const cancel = vi.fn(async () => {}); + const arrayBuffer = vi.fn(); + globalThis.fetch = vi.fn(async () => ({ + status: 200, + headers: new Headers(), + arrayBuffer, + body: { cancel }, + })); + + await expect( + fsOperation("https://example.com/large.txt").readFileRange(20, 25), + ).rejects.toThrow("does not support byte-range reads"); + expect(cancel).toHaveBeenCalledOnce(); + expect(arrayBuffer).not.toHaveBeenCalled(); + }); +});