diff --git a/drivers/fs/driver.ts b/drivers/fs/driver.ts index 07c67c5..33df083 100644 --- a/drivers/fs/driver.ts +++ b/drivers/fs/driver.ts @@ -12,6 +12,7 @@ import mimeTypes from 'mime-types' import * as fsp from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { type Readable } from 'node:stream' +import { buffer } from 'node:stream/consumers' import string from '@poppinss/utils/string' import { Retrier } from '@humanwhocodes/retry' import { dirname, join, relative } from 'node:path' @@ -22,7 +23,13 @@ import debug from './debug.js' import type { FSDriverOptions } from './types.js' import { DriveFile } from '../../src/driver_file.js' import { DriveDirectory } from '../../src/drive_directory.js' +import { + isRangeRequest, + validateRangeRequest, + validateRangeSatisfiable, +} from '../../src/range_utils.js' import type { + ReadOptions, WriteOptions, ObjectMetaData, DriverContract, @@ -102,6 +109,19 @@ export class FSDriver implements DriverContract { }) } + /** + * Creates a readable stream for the given key, optionally restricted to a byte range. + */ + async #createReadStream(key: string, options?: ReadOptions): Promise { + const location = join(this.#rootUrl, key) + if (isRangeRequest(options?.range)) { + validateRangeRequest(key, options.range) + const { size } = await fsp.stat(location) + validateRangeSatisfiable(key, options.range, size) + } + return createReadStream(location, options?.range) + } + /** * Synchronously check if a file exists */ @@ -140,19 +160,23 @@ export class FSDriver implements DriverContract { /** * Returns the contents of the file as a stream. An * exception is thrown when the file is missing. + * When a range is provided, only the specified bytes are streamed. */ - async getStream(key: string): Promise { + async getStream(key: string, options?: ReadOptions): Promise { debug('reading file contents as a stream %s:%s', this.#rootUrl, key) - const location = join(this.#rootUrl, key) - return createReadStream(location) + return this.#createReadStream(key, options) } /** * Returns the contents of the file as an Uint8Array. An * exception is thrown when the file is missing. + * When a range is provided, only the specified bytes are returned. */ - async getBytes(key: string): Promise { + async getBytes(key: string, options?: ReadOptions): Promise { debug('reading file contents as array buffer %s:%s', this.#rootUrl, key) + if (isRangeRequest(options?.range)) { + return new Uint8Array(await buffer(await this.#createReadStream(key, options))) + } return this.#read(key).then((value) => new Uint8Array(value.buffer)) } diff --git a/drivers/gcs/driver.ts b/drivers/gcs/driver.ts index 20af159..67f39ed 100644 --- a/drivers/gcs/driver.ts +++ b/drivers/gcs/driver.ts @@ -8,6 +8,7 @@ */ import { type Readable } from 'node:stream' +import { buffer } from 'node:stream/consumers' import string from '@poppinss/utils/string' import { Storage, @@ -21,7 +22,13 @@ import debug from './debug.js' import type { GCSDriverOptions } from './types.js' import { DriveFile } from '../../src/driver_file.js' import { DriveDirectory } from '../../src/drive_directory.js' +import { + isRangeRequest, + validateRangeRequest, + validateRangeSatisfiable, +} from '../../src/range_utils.js' import type { + ReadOptions, WriteOptions, CopyMoveOptions, ObjectMetaData, @@ -153,6 +160,20 @@ export class GCSDriver implements DriverContract { }) } + /** + * Creates a readable stream for the given key, optionally restricted to a byte range. + */ + async #createReadStream(key: string, options?: ReadOptions): Promise { + const file = this.#storage.bucket(this.options.bucket).file(key) + if (isRangeRequest(options?.range)) { + validateRangeRequest(key, options.range) + const [metadata] = await file.getMetadata() + const size = Number(metadata.size) + validateRangeSatisfiable(key, options.range, size) + } + return file.createReadStream(options?.range) + } + /** * Returns a boolean indicating if the file exists * or not. @@ -180,22 +201,24 @@ export class GCSDriver implements DriverContract { /** * Returns the contents of the file as a Readable stream. An * exception is thrown when the file is missing. + * When a range is provided, only the specified bytes are streamed. */ - async getStream(key: string): Promise { + async getStream(key: string, options?: ReadOptions): Promise { debug('reading file contents as a stream %s:%s', this.options.bucket, key) - const bucket = this.#storage.bucket(this.options.bucket) - - return bucket.file(key).createReadStream() + return this.#createReadStream(key, options) } /** * Returns the contents of the file as an Uint8Array. An * exception is thrown when the file is missing. + * When a range is provided, only the specified bytes are returned. */ - async getBytes(key: string): Promise { + async getBytes(key: string, options?: ReadOptions): Promise { debug('reading file contents as array buffer %s:%s', this.options.bucket, key) + if (isRangeRequest(options?.range)) { + return new Uint8Array(await buffer(await this.#createReadStream(key, options))) + } const bucket = this.#storage.bucket(this.options.bucket) - const response = await bucket.file(key).download() return new Uint8Array(response[0]) } diff --git a/drivers/s3/driver.ts b/drivers/s3/driver.ts index ed73ece..b2778b6 100644 --- a/drivers/s3/driver.ts +++ b/drivers/s3/driver.ts @@ -37,7 +37,13 @@ import debug from './debug.js' import { type S3DriverOptions } from './types.js' import { DriveFile } from '../../src/driver_file.js' import { DriveDirectory } from '../../src/drive_directory.js' +import { + isRangeRequest, + validateRangeRequest, + validateRangeSatisfiable, +} from '../../src/range_utils.js' import type { + ReadOptions, WriteOptions, CopyMoveOptions, DriverContract, @@ -314,35 +320,47 @@ export class S3Driver implements DriverContract { } /** - * Returns the contents of the file as a Readable stream. An - * exception is thrown when the file is missing. + * Sends a GetObjectCommand with optional range request handling */ - async getStream(key: string): Promise { - debug('reading file contents as a stream %s:%s', this.options.bucket, key) - const response = await this.#client.send( + async #getObject(key: string, options?: ReadOptions) { + let rangeHeader = {} + if (isRangeRequest(options?.range)) { + validateRangeRequest(key, options.range) + const head = await this.#client.send( + this.createHeadObjectCommand(this.#client, { Key: key, Bucket: this.options.bucket }) + ) + validateRangeSatisfiable(key, options.range, head.ContentLength!) + rangeHeader = { Range: `bytes=${options.range.start ?? 0}-${options.range.end ?? ''}` } + } + return this.#client.send( this.createGetObjectCommand(this.#client, { Key: key, Bucket: this.options.bucket, + ...rangeHeader, }) ) + } - return response.Body! as Readable + /** + * Returns the contents of the file as a Readable stream. An + * exception is thrown when the file is missing. + * When a range is provided, only the specified bytes are streamed. + */ + async getStream(key: string, options?: ReadOptions): Promise { + debug('reading file contents as a stream %s:%s', this.options.bucket, key) + const { Body } = await this.#getObject(key, options) + return Body! as Readable } /** * Returns the contents of the file as an Uint8Array. An * exception is thrown when the file is missing. + * When a range is provided, only the specified bytes are returned. */ - async getBytes(key: string): Promise { + async getBytes(key: string, options?: ReadOptions): Promise { debug('reading file contents as array buffer %s:%s', this.options.bucket, key) - const response = await this.#client.send( - this.createGetObjectCommand(this.#client, { - Key: key, - Bucket: this.options.bucket, - }) - ) - - return response.Body!.transformToByteArray() + const { Body } = await this.#getObject(key, options) + return Body!.transformToByteArray() } /** diff --git a/src/disk.ts b/src/disk.ts index 31528df..13201c8 100644 --- a/src/disk.ts +++ b/src/disk.ts @@ -16,6 +16,7 @@ import { DriveFile } from './driver_file.js' import { KeyNormalizer } from './key_normalizer.js' import { type DriveDirectory } from './drive_directory.js' import type { + ReadOptions, WriteOptions, FileSnapshot, ObjectMetaData, @@ -76,15 +77,15 @@ export class Disk { /** * Returns file contents as a Readable stream. */ - getStream(key: string): Promise { - return this.file(key).getStream() + getStream(key: string, options?: ReadOptions): Promise { + return this.file(key).getStream(options) } /** * Returns file contents as a Uint8Array. */ - getBytes(key: string): Promise { - return this.file(key).getBytes() + getBytes(key: string, options?: ReadOptions): Promise { + return this.file(key).getBytes(options) } /** diff --git a/src/driver_file.ts b/src/driver_file.ts index 8c61bd3..fb9b791 100644 --- a/src/driver_file.ts +++ b/src/driver_file.ts @@ -13,6 +13,7 @@ import { type Readable } from 'node:stream' import * as errors from './errors.js' import { KeyNormalizer } from './key_normalizer.js' import type { + ReadOptions, DriverContract, FileSnapshot, ObjectMetaData, @@ -92,9 +93,9 @@ export class DriveFile { /** * Returns file contents as a Readable stream. */ - async getStream(): Promise { + async getStream(options?: ReadOptions): Promise { try { - return await this.#driver.getStream(this.key) + return await this.#driver.getStream(this.key, options) } catch (error) { throw new errors.E_CANNOT_READ_FILE([this.key], { cause: error }) } @@ -103,9 +104,9 @@ export class DriveFile { /** * Returns file contents as a Uint8Array. */ - async getBytes(): Promise { + async getBytes(options?: ReadOptions): Promise { try { - return await this.#driver.getBytes(this.key) + return await this.#driver.getBytes(this.key, options) } catch (error) { throw new errors.E_CANNOT_READ_FILE([this.key], { cause: error }) } diff --git a/src/errors.ts b/src/errors.ts index 9a9cf8f..25a8468 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -112,3 +112,11 @@ export const E_PATH_TRAVERSAL_DETECTED = createError<[key: string]>( 'Path traversal segment detected in key "%s"', 'E_PATH_TRAVERSAL_DETECTED' ) + +/** + * The requested byte range is invalid or not satisfiable + */ +export const E_RANGE_UNSATISFIABLE = createError<[key: string]>( + 'The specified range is invalid or exceeds the file size for "%s"', + 'E_RANGE_UNSATISFIABLE' +) diff --git a/src/range_utils.ts b/src/range_utils.ts new file mode 100644 index 0000000..45b3b2a --- /dev/null +++ b/src/range_utils.ts @@ -0,0 +1,59 @@ +/* + * flydrive + * + * (c) FlyDrive + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import * as errors from './errors.js' +import type { RangeRequest } from './types.js' + +/** + * Returns true if the range has at least one bound defined. + * Guards against empty range objects being treated as ranged reads. + */ +export function isRangeRequest(range?: RangeRequest): range is RangeRequest { + return range !== undefined && (range.start !== undefined || range.end !== undefined) +} + +/** + * Validates the syntax of a range request. Checks for negative values, + * non-integers, and start > end. Should be called before any I/O. + */ +export function validateRangeRequest(key: string, range: RangeRequest): void { + const { start, end } = range + + if (start !== undefined && (!Number.isInteger(start) || start < 0)) { + throw new errors.E_RANGE_UNSATISFIABLE([key]) + } + + if (end !== undefined && (!Number.isInteger(end) || end < 0)) { + throw new errors.E_RANGE_UNSATISFIABLE([key]) + } + + if (start !== undefined && end !== undefined && start > end) { + throw new errors.E_RANGE_UNSATISFIABLE([key]) + } +} + +/** + * Validates that the range falls within the known content length. + * Should be called after a preflight stat or metadata fetch. + */ +export function validateRangeSatisfiable( + key: string, + range: RangeRequest, + contentLength: number +): void { + const { start, end } = range + + if (start !== undefined && start >= contentLength) { + throw new errors.E_RANGE_UNSATISFIABLE([key]) + } + + if (end !== undefined && end >= contentLength) { + throw new errors.E_RANGE_UNSATISFIABLE([key]) + } +} diff --git a/src/types.ts b/src/types.ts index 5389c1f..d6eb4c3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -72,6 +72,24 @@ export type UploadSignedURLOptions = { [key: string]: any } +/** + * Represents an inclusive byte range for partial reads. + * Both start and end must be positive integers representing + * absolute byte positions. Both start and end are inclusive + * and start counting at 0. + */ +export type RangeRequest = { + start?: number + end?: number +} + +/** + * Options accepted by read operations. + */ +export type ReadOptions = { + range?: RangeRequest +} + /** * Representation of file snapshot. It can be persisted * inside any database storage. @@ -106,14 +124,14 @@ export interface DriverContract { * Should throw "E_CANNOT_READ_FILE" error when the file * does not exists. */ - getStream(key: string): Promise + getStream(key: string, options?: ReadOptions): Promise /** * Return contents of an object for the given key as an Uint8Array. * Should throw "E_CANNOT_READ_FILE" error when the file * does not exists. */ - getBytes(key: string): Promise + getBytes(key: string, options?: ReadOptions): Promise /** * Return metadata of an object for the given key. diff --git a/tests/core/range_utils.spec.ts b/tests/core/range_utils.spec.ts new file mode 100644 index 0000000..a3bc568 --- /dev/null +++ b/tests/core/range_utils.spec.ts @@ -0,0 +1,128 @@ +/* + * flydrive + * + * (c) FlyDrive + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { + isRangeRequest, + validateRangeRequest, + validateRangeSatisfiable, +} from '../../src/range_utils.js' + +test.group('isRangeRequest', () => { + test('returns false for undefined', ({ assert }) => { + assert.isFalse(isRangeRequest(undefined)) + }) + + test('returns false for empty object', ({ assert }) => { + assert.isFalse(isRangeRequest({})) + }) + + test('returns true when start is defined', ({ assert }) => { + assert.isTrue(isRangeRequest({ start: 0 })) + }) + + test('returns true when end is defined', ({ assert }) => { + assert.isTrue(isRangeRequest({ end: 10 })) + }) + + test('returns true when both start and end are defined', ({ assert }) => { + assert.isTrue(isRangeRequest({ start: 0, end: 10 })) + }) +}) + +test.group('validateRangeRequest', () => { + test('throws for negative start', ({ assert }) => { + assert.throws( + () => validateRangeRequest('foo.txt', { start: -1 }), + /The specified range is invalid or exceeds the file size/ + ) + }) + + test('throws for negative end', ({ assert }) => { + assert.throws( + () => validateRangeRequest('foo.txt', { end: -1 }), + /The specified range is invalid or exceeds the file size/ + ) + }) + + test('throws for non-integer start', ({ assert }) => { + assert.throws( + () => validateRangeRequest('foo.txt', { start: 1.5 }), + /The specified range is invalid or exceeds the file size/ + ) + }) + + test('throws for non-integer end', ({ assert }) => { + assert.throws( + () => validateRangeRequest('foo.txt', { end: 1.5 }), + /The specified range is invalid or exceeds the file size/ + ) + }) + + test('throws when start is greater than end', ({ assert }) => { + assert.throws( + () => validateRangeRequest('foo.txt', { start: 10, end: 5 }), + /The specified range is invalid or exceeds the file size/ + ) + }) + + test('passes for valid start and end', ({ assert }) => { + assert.doesNotThrow(() => validateRangeRequest('foo.txt', { start: 0, end: 10 })) + }) + + test('passes when start equals end (single byte)', ({ assert }) => { + assert.doesNotThrow(() => validateRangeRequest('foo.txt', { start: 5, end: 5 })) + }) + + test('passes for start only', ({ assert }) => { + assert.doesNotThrow(() => validateRangeRequest('foo.txt', { start: 0 })) + }) + + test('passes for end only', ({ assert }) => { + assert.doesNotThrow(() => validateRangeRequest('foo.txt', { end: 10 })) + }) +}) + +test.group('validateRangeSatisfiable', () => { + test('throws when start equals content length', ({ assert }) => { + assert.throws( + () => validateRangeSatisfiable('foo.txt', { start: 10 }, 10), + /The specified range is invalid or exceeds the file size/ + ) + }) + + test('throws when start exceeds content length', ({ assert }) => { + assert.throws( + () => validateRangeSatisfiable('foo.txt', { start: 11 }, 10), + /The specified range is invalid or exceeds the file size/ + ) + }) + + test('throws when end equals content length', ({ assert }) => { + assert.throws( + () => validateRangeSatisfiable('foo.txt', { end: 10 }, 10), + /The specified range is invalid or exceeds the file size/ + ) + }) + + test('throws when end exceeds content length', ({ assert }) => { + assert.throws( + () => validateRangeSatisfiable('foo.txt', { end: 11 }, 10), + /The specified range is invalid or exceeds the file size/ + ) + }) + + test('passes for in-bounds range', ({ assert }) => { + assert.doesNotThrow(() => validateRangeSatisfiable('foo.txt', { start: 0, end: 9 }, 10)) + }) + + test('passes for start at last valid byte', ({ assert }) => { + assert.doesNotThrow(() => validateRangeSatisfiable('foo.txt', { start: 9 }, 10)) + }) +}) diff --git a/tests/drivers/fs/get.spec.ts b/tests/drivers/fs/get.spec.ts index de9dcf7..012f400 100644 --- a/tests/drivers/fs/get.spec.ts +++ b/tests/drivers/fs/get.spec.ts @@ -60,6 +60,35 @@ test.group('FS Driver | getStream', () => { await getStream(await fdfs.getStream(key)) }, /ENOENT: no such file or directory/) }) + + test('get file contents for range - {label}') + .with([ + { label: 'start and end', range: { start: 3, end: 7 }, expected: 'lo wo' }, + { label: 'start only', range: { start: 6 }, expected: 'world' }, + { label: 'end only', range: { end: 4 }, expected: 'Hello' }, + { label: 'single byte', range: { start: 0, end: 0 }, expected: 'H' }, + { label: 'empty object', range: {}, expected: 'Hello world' }, + ]) + .run(async ({ fs, assert }, { range, expected }) => { + const fdfs = new FSDriver({ location: fs.baseUrl, visibility: 'public' }) + await fdfs.put('hello.txt', 'Hello world') + assert.equal(await getStream(await fdfs.getStream('hello.txt', { range })), expected) + }) + + test('throws E_RANGE_UNSATISFIABLE - {label}') + .with([ + { label: 'invalid range syntax', range: { start: -1 } }, + { label: 'range exceeds file size', range: { start: 0, end: 99999 } }, + { label: 'start exceeds file size', range: { start: 99999 } }, + ]) + .run(async ({ fs, assert }, { range }) => { + const fdfs = new FSDriver({ location: fs.baseUrl, visibility: 'public' }) + await fdfs.put('hello.txt', 'Hello world') + await assert.rejects( + async () => fdfs.getStream('hello.txt', { range }), + /The specified range is invalid or exceeds the file size/ + ) + }) }) test.group('FS Driver | getBytes', () => { @@ -81,4 +110,33 @@ test.group('FS Driver | getBytes', () => { await fdfs.getBytes(key) }, /ENOENT: no such file or directory/) }) + + test('get file contents for range - {label}') + .with([ + { label: 'start and end', range: { start: 3, end: 7 }, expected: 'lo wo' }, + { label: 'start only', range: { start: 6 }, expected: 'world' }, + { label: 'end only', range: { end: 4 }, expected: 'Hello' }, + { label: 'single byte', range: { start: 0, end: 0 }, expected: 'H' }, + { label: 'empty object', range: {}, expected: 'Hello world' }, + ]) + .run(async ({ fs, assert }, { range, expected }) => { + const fdfs = new FSDriver({ location: fs.baseUrl, visibility: 'public' }) + await fdfs.put('hello.txt', 'Hello world') + assert.equal(new TextDecoder().decode(await fdfs.getBytes('hello.txt', { range })), expected) + }) + + test('throws E_RANGE_UNSATISFIABLE - {label}') + .with([ + { label: 'invalid range syntax', range: { start: -1 } }, + { label: 'range exceeds file size', range: { start: 0, end: 99999 } }, + { label: 'start exceeds file size', range: { start: 99999 } }, + ]) + .run(async ({ fs, assert }, { range }) => { + const fdfs = new FSDriver({ location: fs.baseUrl, visibility: 'public' }) + await fdfs.put('hello.txt', 'Hello world') + await assert.rejects( + async () => fdfs.getBytes('hello.txt', { range }), + /The specified range is invalid or exceeds the file size/ + ) + }) }) diff --git a/tests/drivers/gcs/get.spec.ts b/tests/drivers/gcs/get.spec.ts index 3ecdc9c..99184f3 100644 --- a/tests/drivers/gcs/get.spec.ts +++ b/tests/drivers/gcs/get.spec.ts @@ -96,6 +96,47 @@ test.group('GCS Driver | getBytes', (group) => { await fdgcs.getBytes(key) }, /No such object:/) }) + + test('get file contents for range - {label}') + .with([ + { label: 'start and end', range: { start: 3, end: 7 }, expected: 'lo wo' }, + { label: 'start only', range: { start: 6 }, expected: 'world' }, + { label: 'end only', range: { end: 4 }, expected: 'Hello' }, + { label: 'single byte', range: { start: 0, end: 0 }, expected: 'H' }, + { label: 'empty object', range: {}, expected: 'Hello world' }, + ]) + .run(async ({ assert }, { range, expected }) => { + const key = `${string.random(6)}.txt` + const fdgcs = new GCSDriver({ + visibility: 'public', + bucket: GCS_BUCKET, + credentials: GCS_KEY, + usingUniformAcl: true, + }) + await fdgcs.put(key, 'Hello world') + assert.equal(new TextDecoder().decode(await fdgcs.getBytes(key, { range })), expected) + }) + + test('throws E_RANGE_UNSATISFIABLE - {label}') + .with([ + { label: 'invalid range syntax', range: { start: -1 } }, + { label: 'range exceeds file size', range: { start: 0, end: 99999 } }, + { label: 'start exceeds file size', range: { start: 99999 } }, + ]) + .run(async ({ assert }, { range }) => { + const key = `${string.random(6)}.txt` + const fdgcs = new GCSDriver({ + visibility: 'public', + bucket: GCS_BUCKET, + credentials: GCS_KEY, + usingUniformAcl: true, + }) + await fdgcs.put(key, 'Hello world') + await assert.rejects( + async () => fdgcs.getBytes(key, { range }), + /The specified range is invalid or exceeds the file size/ + ) + }) }) test.group('GCS Driver | getStream', (group) => { @@ -134,4 +175,45 @@ test.group('GCS Driver | getStream', (group) => { await getStream(await fdgcs.getStream(key)) }, /No such object:/) }) + + test('get file contents for range - {label}') + .with([ + { label: 'start and end', range: { start: 3, end: 7 }, expected: 'lo wo' }, + { label: 'start only', range: { start: 6 }, expected: 'world' }, + { label: 'end only', range: { end: 4 }, expected: 'Hello' }, + { label: 'single byte', range: { start: 0, end: 0 }, expected: 'H' }, + { label: 'empty object', range: {}, expected: 'Hello world' }, + ]) + .run(async ({ assert }, { range, expected }) => { + const key = `${string.random(6)}.txt` + const fdgcs = new GCSDriver({ + visibility: 'public', + bucket: GCS_BUCKET, + credentials: GCS_KEY, + usingUniformAcl: true, + }) + await fdgcs.put(key, 'Hello world') + assert.equal(await getStream(await fdgcs.getStream(key, { range })), expected) + }) + + test('throws E_RANGE_UNSATISFIABLE - {label}') + .with([ + { label: 'invalid range syntax', range: { start: -1 } }, + { label: 'range exceeds file size', range: { start: 0, end: 99999 } }, + { label: 'start exceeds file size', range: { start: 99999 } }, + ]) + .run(async ({ assert }, { range }) => { + const key = `${string.random(6)}.txt` + const fdgcs = new GCSDriver({ + visibility: 'public', + bucket: GCS_BUCKET, + credentials: GCS_KEY, + usingUniformAcl: true, + }) + await fdgcs.put(key, 'Hello world') + await assert.rejects( + async () => fdgcs.getStream(key, { range }), + /The specified range is invalid or exceeds the file size/ + ) + }) }) diff --git a/tests/drivers/s3/get.spec.ts b/tests/drivers/s3/get.spec.ts index 3b2a5cc..3d8f1f8 100644 --- a/tests/drivers/s3/get.spec.ts +++ b/tests/drivers/s3/get.spec.ts @@ -109,6 +109,47 @@ test.group('S3 Driver | getBytes', (group) => { await s3fs.getBytes(key) }, /UnknownError|The specified key does not exist/) }) + + test('get file contents for range - {label}') + .with([ + { label: 'start and end', range: { start: 3, end: 7 }, expected: 'lo wo' }, + { label: 'start only', range: { start: 6 }, expected: 'world' }, + { label: 'end only', range: { end: 4 }, expected: 'Hello' }, + { label: 'single byte', range: { start: 0, end: 0 }, expected: 'H' }, + { label: 'empty object', range: {}, expected: 'Hello world' }, + ]) + .run(async ({ assert }, { range, expected }) => { + const key = `${string.random(6)}.txt` + const s3fs = new S3Driver({ + visibility: 'public', + client: client, + bucket: S3_BUCKET, + supportsACL: SUPPORTS_ACL, + }) + await s3fs.put(key, 'Hello world') + assert.equal(new TextDecoder().decode(await s3fs.getBytes(key, { range })), expected) + }) + + test('throws E_RANGE_UNSATISFIABLE - {label}') + .with([ + { label: 'invalid range syntax', range: { start: -1 } }, + { label: 'range exceeds file size', range: { start: 0, end: 99999 } }, + { label: 'start exceeds file size', range: { start: 99999 } }, + ]) + .run(async ({ assert }, { range }) => { + const key = `${string.random(6)}.txt` + const s3fs = new S3Driver({ + visibility: 'public', + client: client, + bucket: S3_BUCKET, + supportsACL: SUPPORTS_ACL, + }) + await s3fs.put(key, 'Hello world') + await assert.rejects( + async () => s3fs.getBytes(key, { range }), + /The specified range is invalid or exceeds the file size/ + ) + }) }) test.group('S3 Driver | getStream', (group) => { @@ -147,4 +188,45 @@ test.group('S3 Driver | getStream', (group) => { await getStream(await s3fs.getStream(key)) }, /UnknownError|The specified key does not exist/) }) + + test('get file contents for range - {label}') + .with([ + { label: 'start and end', range: { start: 3, end: 7 }, expected: 'lo wo' }, + { label: 'start only', range: { start: 6 }, expected: 'world' }, + { label: 'end only', range: { end: 4 }, expected: 'Hello' }, + { label: 'single byte', range: { start: 0, end: 0 }, expected: 'H' }, + { label: 'empty object', range: {}, expected: 'Hello world' }, + ]) + .run(async ({ assert }, { range, expected }) => { + const key = `${string.random(6)}.txt` + const s3fs = new S3Driver({ + visibility: 'public', + client: client, + bucket: S3_BUCKET, + supportsACL: SUPPORTS_ACL, + }) + await s3fs.put(key, 'Hello world') + assert.equal(await getStream(await s3fs.getStream(key, { range })), expected) + }) + + test('throws E_RANGE_UNSATISFIABLE - {label}') + .with([ + { label: 'invalid range syntax', range: { start: -1 } }, + { label: 'range exceeds file size', range: { start: 0, end: 99999 } }, + { label: 'start exceeds file size', range: { start: 99999 } }, + ]) + .run(async ({ assert }, { range }) => { + const key = `${string.random(6)}.txt` + const s3fs = new S3Driver({ + visibility: 'public', + client: client, + bucket: S3_BUCKET, + supportsACL: SUPPORTS_ACL, + }) + await s3fs.put(key, 'Hello world') + await assert.rejects( + async () => s3fs.getStream(key, { range }), + /The specified range is invalid or exceeds the file size/ + ) + }) })