Skip to content

Commit e9cd630

Browse files
authored
Merge branch 'main' into fix/data-url-encoding-issue-174
2 parents b35a8f7 + a115575 commit e9cd630

17 files changed

+216
-14
lines changed
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<!--
2+
A new scriv changelog fragment.
3+
4+
Uncomment the section that is right (remove the HTML comment wrapper).
5+
For top level release notes, leave all the headers commented out.
6+
-->
7+
8+
<!--
9+
### Added
10+
11+
- A bullet item for the Added category.
12+
13+
-->
14+
<!--
15+
### Changed
16+
17+
- A bullet item for the Changed category.
18+
19+
-->
20+
### Fixed
21+
22+
- File access failures consistently produce `FILE_READ` errors across all file types.
23+
24+
<!--
25+
### Deprecated
26+
27+
- A bullet item for the Deprecated category.
28+
29+
-->
30+
<!--
31+
### Removed
32+
33+
- A bullet item for the Removed category.
34+
35+
-->
36+
<!--
37+
### Security
38+
39+
- A bullet item for the Security category.
40+
41+
-->
42+
<!--
43+
### Infrastructure
44+
45+
- A bullet item for the Infrastructure category.
46+
47+
-->
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<!--
2+
A new scriv changelog fragment.
3+
4+
Uncomment the section that is right (remove the HTML comment wrapper).
5+
For top level release notes, leave all the headers commented out.
6+
-->
7+
8+
<!--
9+
### Added
10+
11+
- A bullet item for the Added category.
12+
13+
-->
14+
### Changed
15+
16+
- Parsed JSON files are now cached to reduce I/O and parsing costs.
17+
18+
<!--
19+
### Fixed
20+
21+
- A bullet item for the Fixed category.
22+
23+
-->
24+
<!--
25+
### Deprecated
26+
27+
- A bullet item for the Deprecated category.
28+
29+
-->
30+
<!--
31+
### Removed
32+
33+
- A bullet item for the Removed category.
34+
35+
-->
36+
<!--
37+
### Security
38+
39+
- A bullet item for the Security category.
40+
41+
-->
42+
<!--
43+
### Infrastructure
44+
45+
- A bullet item for the Infrastructure category.
46+
47+
-->

src/files/access.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { assert, assertArrayIncludes, assertObjectMatch } from '@std/assert'
2+
import { basename, dirname } from '@std/path'
3+
import { BIDSFileDeno } from './deno.ts'
4+
5+
export function testAsyncFileAccess(
6+
name: string,
7+
fn: (file: BIDSFileDeno, ...args: any[]) => Promise<any>,
8+
...args: any[]
9+
) {
10+
Deno.test({
11+
name,
12+
ignore: Deno.build.os === 'windows',
13+
async fn(t) {
14+
await t.step('Dangling symlink', async () => {
15+
const file = new BIDSFileDeno('tests/data', '/broken-symlink')
16+
try {
17+
await fn(file, ...args)
18+
assert(false, 'Expected error')
19+
} catch (e: any) {
20+
assertObjectMatch(e, {
21+
code: 'FILE_READ',
22+
location: '/broken-symlink',
23+
})
24+
assertArrayIncludes(['NotFound', 'FilesystemLoop'], [e.subCode])
25+
}
26+
})
27+
await t.step('Insufficient permissions', async () => {
28+
const tmpfile = await Deno.makeTempFile()
29+
await Deno.chmod(tmpfile, 0o000)
30+
const file = new BIDSFileDeno('', tmpfile)
31+
try {
32+
await fn(file, ...args)
33+
assert(false, 'Expected error')
34+
} catch (e: any) {
35+
assertObjectMatch(e, { code: 'FILE_READ', subCode: 'PermissionDenied' })
36+
}
37+
})
38+
},
39+
})
40+
}

src/files/access.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { type BIDSFile } from '../types/filetree.ts'
2+
import { type Issue } from '../types/issues.ts'
3+
4+
function IOErrorToIssue(err: { code: string; name: string }): Issue {
5+
const subcode = err.name
6+
let issueMessage: string | undefined = undefined
7+
if (err.code === 'ENOENT' || err.code === 'ELOOP') {
8+
issueMessage = 'Possible dangling symbolic link'
9+
}
10+
return { code: 'FILE_READ', subCode: err.name, issueMessage }
11+
}
12+
13+
export function openStream(file: BIDSFile): ReadableStream<Uint8Array<ArrayBuffer>> {
14+
try {
15+
return file.stream
16+
} catch (err: any) {
17+
throw { location: file.path, ...IOErrorToIssue(err) }
18+
}
19+
}
20+
21+
export async function readBytes(
22+
file: BIDSFile,
23+
size: number,
24+
offset = 0,
25+
): Promise<Uint8Array<ArrayBuffer>> {
26+
return file.readBytes(size, offset).catch((err: any) => {
27+
throw { location: file.path, ...IOErrorToIssue(err) }
28+
})
29+
}
30+
31+
export async function readText(file: BIDSFile): Promise<string> {
32+
return file.text().catch((err: any) => {
33+
throw { location: file.path, ...IOErrorToIssue(err) }
34+
})
35+
}

src/files/gzip.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { assert, assertObjectMatch } from '@std/assert'
22
import { parseGzip } from './gzip.ts'
33
import { BIDSFileDeno } from './deno.ts'
4+
import { testAsyncFileAccess } from './access.test.ts'
45

56
Deno.test('parseGzip', async (t) => {
67
await t.step('parses anonymized file', async () => {
@@ -40,3 +41,5 @@ Deno.test('parseGzip', async (t) => {
4041
assert(!gzip)
4142
})
4243
})
44+
45+
testAsyncFileAccess('Test file access errors for parseGzip', parseGzip)

src/files/gzip.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*/
55
import type { Gzip } from '@bids/schema/context'
66
import type { BIDSFile } from '../types/filetree.ts'
7+
import { readBytes } from './access.ts'
78

89
/**
910
* Parse a gzip header from a file
@@ -19,7 +20,7 @@ export async function parseGzip(
1920
file: BIDSFile,
2021
maxBytes: number = 512,
2122
): Promise<Gzip | undefined> {
22-
const buf = await file.readBytes(maxBytes)
23+
const buf = await readBytes(file, maxBytes)
2324
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
2425
if (view.byteLength < 2 || view.getUint16(0, false) !== 0x1f8b) return undefined
2526

src/files/json.test.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { type assert, assertObjectMatch } from '@std/assert'
22
import type { BIDSFile } from '../types/filetree.ts'
33
import type { FileIgnoreRules } from './ignore.ts'
4+
import { testAsyncFileAccess } from './access.test.ts'
45

6+
import { pathsToTree } from '../files/filetree.ts'
57
import { loadJSON } from './json.ts'
68

79
function encodeUTF16(text: string) {
@@ -17,9 +19,12 @@ function encodeUTF16(text: string) {
1719
return buffer
1820
}
1921

20-
function makeFile(text: string, encoding: string): BIDSFile {
22+
function makeFile(path: string, text: string, encoding: string): BIDSFile {
2123
const bytes = encoding === 'utf-8' ? new TextEncoder().encode(text) : encodeUTF16(text)
24+
const file = pathsToTree([path]).get(path) as BIDSFile
2225
return {
26+
path: file.path,
27+
parent: file.parent,
2328
readBytes: async (size: number) => {
2429
return new Uint8Array(bytes)
2530
},
@@ -29,13 +34,13 @@ function makeFile(text: string, encoding: string): BIDSFile {
2934

3035
Deno.test('Test JSON error conditions', async (t) => {
3136
await t.step('Load valid JSON', async () => {
32-
const JSONfile = makeFile('{"a": 1}', 'utf-8')
37+
const JSONfile = makeFile('/valid-contents.json', '{"a": 1}', 'utf-8')
3338
const result = await loadJSON(JSONfile)
3439
assertObjectMatch(result, { a: 1 })
3540
})
3641

3742
await t.step('Error on BOM', async () => {
38-
const BOMfile = makeFile('\uFEFF{"a": 1}', 'utf-8')
43+
const BOMfile = makeFile('/BOM.json', '\uFEFF{"a": 1}', 'utf-8')
3944
let error: any = undefined
4045
await loadJSON(BOMfile).catch((e) => {
4146
error = e
@@ -44,7 +49,7 @@ Deno.test('Test JSON error conditions', async (t) => {
4449
})
4550

4651
await t.step('Error on UTF-16', async () => {
47-
const UTF16file = makeFile('{"a": 1}', 'utf-16')
52+
const UTF16file = makeFile('/utf16.json', '{"a": 1}', 'utf-16')
4853
let error: any = undefined
4954
await loadJSON(UTF16file).catch((e) => {
5055
error = e
@@ -53,11 +58,14 @@ Deno.test('Test JSON error conditions', async (t) => {
5358
})
5459

5560
await t.step('Error on invalid JSON syntax', async () => {
56-
const badJSON = makeFile('{"a": 1]', 'utf-8')
61+
const badJSON = makeFile('/bad-syntax.json', '{"a": 1]', 'utf-8')
5762
let error: any = undefined
5863
await loadJSON(badJSON).catch((e) => {
5964
error = e
6065
})
6166
assertObjectMatch(error, { code: 'JSON_INVALID' })
6267
})
68+
loadJSON.cache.clear()
6369
})
70+
71+
testAsyncFileAccess('Test file access errors for loadJSON', loadJSON)

src/files/json.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1+
import { filememoizeAsync } from '../utils/memoize.ts'
12
import type { BIDSFile } from '../types/filetree.ts'
3+
import { readBytes } from './access.ts'
24

35
async function readJSONText(file: BIDSFile): Promise<string> {
46
// Read JSON text from a file
57
// JSON must be encoded in UTF-8 without a byte order mark (BOM)
68
const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true })
79
// Streaming TextDecoders are buggy in Deno and Chrome, so read the
810
// entire file into memory before decoding and parsing
9-
const data = await file.readBytes(file.size)
11+
const data = await readBytes(file, file.size)
1012
try {
1113
const text = decoder.decode(data)
1214
if (text.startsWith('\uFEFF')) {
@@ -20,7 +22,7 @@ async function readJSONText(file: BIDSFile): Promise<string> {
2022
}
2123
}
2224

23-
export async function loadJSON(file: BIDSFile): Promise<Record<string, unknown>> {
25+
async function _loadJSON(file: BIDSFile): Promise<Record<string, unknown>> {
2426
const text = await readJSONText(file) // Raise encoding errors
2527
let parsedText
2628
try {
@@ -36,3 +38,5 @@ export async function loadJSON(file: BIDSFile): Promise<Record<string, unknown>>
3638
}
3739
return parsedText
3840
}
41+
42+
export const loadJSON = filememoizeAsync(_loadJSON)

src/files/nifti.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { assert, assertEquals, assertObjectMatch } from '@std/assert'
22
import { FileIgnoreRules } from './ignore.ts'
33
import { BIDSFileDeno } from './deno.ts'
4+
import { testAsyncFileAccess } from './access.test.ts'
45

56
import { axisCodes, loadHeader } from './nifti.ts'
67

@@ -96,3 +97,5 @@ Deno.test('Test extracting axis codes', async (t) => {
9697
assertEquals(axisCodes(affine), ['A', 'S', 'R'])
9798
})
9899
})
100+
101+
testAsyncFileAccess('Test file access errors for loadHeader', loadHeader)

src/files/nifti.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { isCompressed, isNIFTI1, isNIFTI2, NIFTI1, NIFTI2 } from '@mango/nifti'
22
import type { BIDSFile } from '../types/filetree.ts'
33
import { logger } from '../utils/logger.ts'
44
import type { NiftiHeader } from '@bids/schema/context'
5+
import { readBytes } from './access.ts'
56

67
async function extract(buffer: Uint8Array, nbytes: number): Promise<Uint8Array<ArrayBuffer>> {
78
// The fflate decompression that is used in nifti-reader does not like
@@ -32,8 +33,8 @@ async function extract(buffer: Uint8Array, nbytes: number): Promise<Uint8Array<A
3233
}
3334

3435
export async function loadHeader(file: BIDSFile): Promise<NiftiHeader> {
36+
const buf = await readBytes(file, 1024)
3537
try {
36-
const buf = await file.readBytes(1024)
3738
const data = isCompressed(buf.buffer) ? await extract(buf, 540) : buf.slice(0, 540)
3839
let header
3940
if (isNIFTI1(data.buffer)) {

0 commit comments

Comments
 (0)