Skip to content

Commit 5a12654

Browse files
authored
Merge pull request #259 from effigies/fix/file-access-errors
feat: Add file access functions that translate errors into issues
2 parents fb6b0a7 + a87568e commit 5a12654

File tree

15 files changed

+153
-8
lines changed

15 files changed

+153
-8
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+
-->

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: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
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

56
import { loadJSON } from './json.ts'
67

@@ -61,3 +62,5 @@ Deno.test('Test JSON error conditions', async (t) => {
6162
assertObjectMatch(error, { code: 'JSON_INVALID' })
6263
})
6364
})
65+
66+
testAsyncFileAccess('Test file access errors for loadJSON', loadJSON)

src/files/json.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import type { BIDSFile } from '../types/filetree.ts'
2+
import { readBytes } from './access.ts'
23

34
async function readJSONText(file: BIDSFile): Promise<string> {
45
// Read JSON text from a file
56
// JSON must be encoded in UTF-8 without a byte order mark (BOM)
67
const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true })
78
// Streaming TextDecoders are buggy in Deno and Chrome, so read the
89
// entire file into memory before decoding and parsing
9-
const data = await file.readBytes(file.size)
10+
const data = await readBytes(file, file.size)
1011
try {
1112
const text = decoder.decode(data)
1213
if (text.startsWith('\uFEFF')) {

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)) {

src/files/tiff.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 { parseTIFF } from './tiff.ts'
33
import { BIDSFileDeno } from './deno.ts'
4+
import { testAsyncFileAccess } from './access.test.ts'
45

56
Deno.test('parseTIFF', async (t) => {
67
await t.step('parse example file as TIFF', async () => {
@@ -53,3 +54,5 @@ Deno.test('parseTIFF', async (t) => {
5354
})
5455
})
5556
})
57+
58+
testAsyncFileAccess('Test file access errors for parseTIFF', parseTIFF)

0 commit comments

Comments
 (0)