diff --git a/.gitignore b/.gitignore index 476a964..6865e9b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ docs *.log tmp build -.coverage \ No newline at end of file +.coverage +.DS_Store \ No newline at end of file diff --git a/src/iso/fs.ts b/src/iso/fs.ts index 27bcd09..6f3f046 100644 --- a/src/iso/fs.ts +++ b/src/iso/fs.ts @@ -1,11 +1,12 @@ // SPDX-License-Identifier: LGPL-3.0-or-later import { FileSystem, Inode, type UsageInfo } from '@zenfs/core'; -import type { Backend } from '@zenfs/core/backends/backend.js'; +import type { Backend, SharedConfig } from '@zenfs/core/backends/backend.js'; import { S_IFDIR, S_IFREG } from '@zenfs/core/constants'; import { Readonly, Sync } from '@zenfs/core/mixins/index.js'; import { resolve } from '@zenfs/core/path'; import { log, withErrno } from 'kerium'; import { decodeASCII } from 'utilium'; +import type { Directory } from './Directory.js'; import type { DirectoryRecord } from './DirectoryRecord.js'; import { PrimaryVolumeDescriptor, VolumeDescriptorType } from './VolumeDescriptor.js'; import { PXEntry, TFEntry, TFFlag } from './entries.js'; @@ -13,7 +14,7 @@ import { PXEntry, TFEntry, TFFlag } from './entries.js'; /** * Options for IsoFS file system instances. */ -export interface IsoOptions { +export interface IsoOptions extends SharedConfig { /** * The ISO file in a buffer. */ @@ -33,6 +34,8 @@ export interface IsoOptions { * * Microsoft Joliet and Rock Ridge extensions to the ISO9660 standard */ export class IsoFS extends Readonly(Sync(FileSystem)) { + protected data: Uint8Array; + protected readonly options: IsoOptions; protected pvd: PrimaryVolumeDescriptor; /** @@ -40,10 +43,15 @@ export class IsoFS extends Readonly(Sync(FileSystem)) { * @param data The ISO file in a buffer. * @param name The name of the ISO (optional; used for debug messages / identification). */ - public constructor(protected data: Uint8Array) { + public constructor(options: IsoOptions) { super(0x2069736f, 'iso9660'); + this.options = options; + this.data = options.data; + this.label = options.name; + let candidate: PrimaryVolumeDescriptor | undefined; + const data = this.data; for (let i = 16 * 2048, terminatorFound = false; i < data.length && !terminatorFound; i += 2048) { switch (data[i] as VolumeDescriptorType) { @@ -127,13 +135,33 @@ export class IsoFS extends Readonly(Sync(FileSystem)) { for (const part of path.split('/').slice(1)) { if (!dir.isDirectory()) return; - dir = dir.directory.get(part); + const directory: Directory = dir.directory; + let next: DirectoryRecord | undefined = directory.get(part); + if (!next && this.options.caseFold) { + const foldedPart = this._caseFold(part); + for (const [name, record] of directory) { + if (this._caseFold(name) === foldedPart) { + next = record; + break; + } + } + } + + dir = next; if (!dir) return; } return dir; } + private _caseFold(original: string): string { + if (!this.options.caseFold) { + return original; + } + + return this.options.caseFold === 'upper' ? original.toUpperCase() : original.toLowerCase(); + } + private _get(path: string, record: DirectoryRecord): Inode | undefined { if (record.isSymlink) { const target = resolve(path, record.symlinkPath); @@ -183,9 +211,7 @@ const _Iso = { }, create(options: IsoOptions) { - const fs = new IsoFS(options.data); - fs.label = options.name; - return fs; + return new IsoFS(options); }, } as const satisfies Backend; type _Iso = typeof _Iso; diff --git a/src/zip/fs.ts b/src/zip/fs.ts index 30e03c5..bbb0358 100644 --- a/src/zip/fs.ts +++ b/src/zip/fs.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: LGPL-3.0-or-later import { FileSystem, Inode, type UsageInfo } from '@zenfs/core'; -import type { Backend } from '@zenfs/core/backends/backend.js'; +import type { Backend, SharedConfig } from '@zenfs/core/backends/backend.js'; import { S_IFDIR, S_IFREG } from '@zenfs/core/constants'; import { Readonly } from '@zenfs/core/mixins/readonly.js'; import { parse } from '@zenfs/core/path'; @@ -19,7 +19,7 @@ export interface ZipDataSource { /** * Configuration options for a ZipFS file system. */ -export interface ZipOptions { +export interface ZipOptions extends SharedConfig { /** * The zip file as a binary buffer. */ @@ -93,7 +93,7 @@ export class ZipFS extends Readon while (ptr < cdEnd) { const cd = await FileEntry.from(this.data, ptr); - if (!this.lazy) await cd.loadContents(); + if (!this.options.lazy) await cd.loadContents(); /* Paths must be absolute, yet zip file paths are always relative to the zip root. So we prepend '/' and call it a day. */ @@ -102,14 +102,15 @@ export class ZipFS extends Readon } // Strip the trailing '/' if it exists const name = cd.name.endsWith('/') ? cd.name.slice(0, -1) : cd.name; - this.files.set('/' + name, cd); + this.files.set('/'+ this._caseFold(name), cd); ptr += cd.size; } // Parse directory entries for (const entry of this.files.keys()) { - const { dir, base } = parse(entry); + let { dir, base } = parse(entry); + dir = this._caseFold(dir); if (!this.directories.has(dir)) { this.directories.set(dir, new Set()); } @@ -119,8 +120,9 @@ export class ZipFS extends Readon // Add subdirectories to their parent's entries for (const entry of this.directories.keys()) { - const { dir, base } = parse(entry); + let { dir, base } = parse(entry); + dir = this._caseFold(dir); if (base == '') continue; if (!this.directories.has(dir)) { @@ -134,7 +136,7 @@ export class ZipFS extends Readon public constructor( public label: string, protected data: ZipDataSource, - public readonly lazy: boolean = false + protected readonly options: ZipOptions ) { super(0x207a6970, 'zipfs'); } @@ -151,8 +153,9 @@ export class ZipFS extends Readon } public statSync(path: string): Inode { + const folded = this._caseFold(path); // The EOCD/Header does not track directories, so it does not exist in `entries` - if (this.directories.has(path)) { + if (this.directories.has(folded)) { return new Inode({ mode: 0o555 | S_IFDIR, size: 4096, @@ -163,7 +166,7 @@ export class ZipFS extends Readon }); } - const entry = this.files.get(path); + const entry = this.files.get(folded); if (!entry) throw withErrno('ENOENT'); @@ -178,7 +181,7 @@ export class ZipFS extends Readon const inode = await this.stat(path); if (!(inode.mode & S_IFDIR)) throw withErrno('ENOTDIR'); - const entries = this.directories.get(path); + const entries = this.directories.get(this._caseFold(path)); if (!entries) throw withErrno('ENODATA'); return Array.from(entries); @@ -188,16 +191,17 @@ export class ZipFS extends Readon const inode = this.statSync(path); if (!(inode.mode & S_IFDIR)) throw withErrno('ENOTDIR'); - const entries = this.directories.get(path); + const entries = this.directories.get(this._caseFold(path)); if (!entries) throw withErrno('ENODATA'); return Array.from(entries); } public async read(path: string, buffer: Uint8Array, offset: number, end: number): Promise { - if (this.directories.has(path)) throw withErrno('EISDIR'); + const folded = this._caseFold(path); + if (this.directories.has(folded)) throw withErrno('EISDIR'); - const file = this.files.get(path) ?? _throw(withErrno('ENOENT')); + const file = this.files.get(folded) ?? _throw(withErrno('ENOENT')); if (!file.contents) await file.loadContents(); @@ -205,9 +209,10 @@ export class ZipFS extends Readon } public readSync(path: string, buffer: Uint8Array, offset: number, end: number): void { - if (this.directories.has(path)) throw withErrno('EISDIR'); + const folded = this._caseFold(path); + if (this.directories.has(folded)) throw withErrno('EISDIR'); - const file = this.files.get(path) ?? _throw(withErrno('ENOENT')); + const file = this.files.get(folded) ?? _throw(withErrno('ENOENT')); if (!file.contents) { void file.loadContents(); @@ -216,6 +221,13 @@ export class ZipFS extends Readon buffer.set(file.contents.subarray(offset, end)); } + + private _caseFold(original: string): string { + if (!this.options.caseFold) { + return original; + } + return this.options.caseFold == 'upper' ? original.toUpperCase() : original.toLowerCase(); + } } const _isShared = (b: unknown): b is SharedArrayBuffer => typeof b == 'object' && b !== null && b.constructor.name === 'SharedArrayBuffer'; @@ -293,16 +305,7 @@ const _Zip = { name: 'Zip', options: { - data: { - type: [ - ArrayBuffer, - Object.getPrototypeOf(Uint8Array) /* %TypedArray% */, - function ZipDataSource(v: unknown): v is ZipDataSource { - return typeof v == 'object' && v !== null && 'size' in v && typeof v.size == 'number' && 'get' in v && typeof v.get == 'function'; - }, - ], - required: true, - }, + data: { type: 'object', required: true }, name: { type: 'string', required: false }, lazy: { type: 'boolean', required: false }, }, @@ -312,7 +315,7 @@ const _Zip = { }, create(opt: ZipOptions): ZipFS { - return new ZipFS(opt.name ?? '', getSource(opt.data), opt.lazy); + return new ZipFS(opt.name ?? '', getSource(opt.data), opt); }, } satisfies Backend; type _Zip = typeof _Zip; diff --git a/tests/iso.test.ts b/tests/iso.test.ts index 9eb5c65..63f5aa0 100644 --- a/tests/iso.test.ts +++ b/tests/iso.test.ts @@ -11,10 +11,9 @@ import { setupLogs } from '@zenfs/core/tests/logs.js'; setupLogs(); -await suite('Basic ISO9660 operations', () => { +suite('Basic ISO9660 operations', () => { test('Configure', async () => { const data = readFileSync(dirname(fileURLToPath(import.meta.url)) + '/files/data.iso'); - //const data = buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); await configureSingle({ backend: Iso, data }); }); @@ -38,3 +37,30 @@ await suite('Basic ISO9660 operations', () => { assert.equal(fs.readFileSync('/nested/omg.txt', 'utf8'), 'This is a nested file!'); }); }); + +await suite('ISO case fold', {}, () => { + test('Configure', async () => { + const data = readFileSync(dirname(fileURLToPath(import.meta.url)) + '/files/data.iso'); + await configureSingle({ backend: Iso, data, caseFold: 'upper' }); + }); + + test('read /ONES.TXT', () => { + assert.equal(fs.readFileSync('/ONE.TXT', 'utf8'), '1'); + }); + + test('read /NESTED/OMG.TXT', () => { + assert.equal(fs.readFileSync('/NESTED/OMG.TXT', 'utf8'), 'This is a nested file!'); + }); + + test('readdir /NESTED', () => { + assert.equal(fs.readdirSync('/NESTED').length, 1); + }); + + test('read /nested/omg.txt (all lower)', () => { + assert.equal(fs.readFileSync('/nested/omg.txt', 'utf8'), 'This is a nested file!'); + }); + + test('readdir /Nested (mixed case)', () => { + assert.equal(fs.readdirSync('/Nested').length, 1); + }); +}); diff --git a/tests/zip.test.ts b/tests/zip.test.ts index cca0577..5934232 100644 --- a/tests/zip.test.ts +++ b/tests/zip.test.ts @@ -26,7 +26,7 @@ function _runTests() { assert.equal(fs.readdirSync('/nested').length, 1); }); - test('readdir /nested/omg.txt', () => { + test('read /nested/omg.txt', () => { assert.equal(fs.readFileSync('/nested/omg.txt', 'utf8'), 'This is a nested file!'); }); } @@ -41,6 +41,34 @@ suite('Basic ZIP operations', () => { _runTests(); }); +await suite('ZIP case fold', {}, () => { + test('Configure', async () => { + const buffer = readFileSync(import.meta.dirname + '/files/data.zip'); + const data = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); + await configureSingle({ backend: Zip, data, caseFold: 'upper' }); + }); + + test('read /ONES.TXT', () => { + assert.equal(fs.readFileSync('/ONE.TXT', 'utf8'), '1'); + }); + + test('read /NESTED/OMG.TXT', () => { + assert.equal(fs.readFileSync('/NESTED/OMG.TXT', 'utf8'), 'This is a nested file!'); + }); + + test('readdir /NESTED', () => { + assert.equal(fs.readdirSync('/NESTED').length, 1); + }); + + test('read /nested/omg.txt (all lower)', () => { + assert.equal(fs.readFileSync('/nested/omg.txt', 'utf8'), 'This is a nested file!'); + }); + + test('readdir /Nested (mixed case)', () => { + assert.equal(fs.readdirSync('/Nested').length, 1); + }); +}); + await using handle = await open(import.meta.dirname + '/files/data.zip'); await suite('ZIP Streaming', () => {