diff --git a/lib/index.ts b/lib/index.ts index 34134f0..4eba200 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -203,3 +203,80 @@ export async function interact( throw new Error('image must be a String (file path) or a Disk instance'); } } + +/** + * @summary Return the label encoded in the filesystem for a given partition, + * or an empty string if one can't be found. + * + * @example + * + * await filedisk.withOpenFile('/foo/bar.img', 'r', async (handle) => { + * const disk = new filedisk.FileDisk(handle); + * const info = partitioninfo.getPartitions(disk); + * for (const partition of info.partitions) { + * const label = await getLabel(disk, partition); + * console.log(`${partition.index}: ${label}`); + * } + * } + */ +export async function getLabel( + disk: Disk, + partition: partitioninfo.GPTPartition | partitioninfo.MBRPartition, +): Promise { + // If GPT, can we just read the protective MBR? + // Is there a more Typescript native way to determine partition table type? + // const isGpt = 'guid' in partition; + + // Linux native FS, expecting ext2+, so skip to superblock for metadata. + let metadataOffset = 0; + if (partition.type === 0x83) { + metadataOffset += 0x400; + } + + // Read filesystem metadata + let buf = Buffer.alloc(0x100); + await disk.read(buf, 0, buf.length, partition.offset + metadataOffset); + + let labelOffset = 0; + let maxLength = 0; + // First verify magic signature to determine metadata layout for label offset. + const fatTypes = [0xB, 0xC, 0xE]; + if (fatTypes.some(ptype => partition.type === ptype)) { + maxLength = 11; + // FAT16 + if (buf.readUInt8(0x26) === 0x29) { + labelOffset = 0x2B; + // FAT32 + } else if (buf.readUInt8(0x42) === 0x29) { + labelOffset = 0x47; + } else { + return ''; + } + } else if (partition.type === 0x83) { + maxLength = 16; + if (buf.readUInt16LE(0x38) === 0xEF53) { + labelOffset = 0x78; + } else { + return ''; + } + // Unexpected partition type + } else { + return ''; + } + + // Identify and exclude trailing /0 bytes to stringify. + let i = 0; + for (; i <= maxLength; i++) { + // If label fills available space, no need to test. We just need i + // to have the expected value for Buffer.toString() call below. + if (i == maxLength) { + break; + } + if (buf.readUInt8(labelOffset + i) == 0) { + break; + } + } + + const label = buf.toString('utf8', labelOffset, labelOffset + i).trim(); + return label; +} diff --git a/package.json b/package.json index c1bb05b..51ca3af 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "lint-fix": "balena-lint --fix --typescript lib tests", "clean": "rm -rf build", "build": "npm run clean && tsc", - "test": "npm run lint && mocha -r ts-node/register tests/e2e.ts", + "test": "npm run lint && mocha -r ts-node/register tests/*.ts", "readme": "jsdoc2md --template doc/README.hbs build/index.js > README.md", "prepublish": "npm run test && npm run build && npm run readme" }, diff --git a/tests/fsLabel.ts b/tests/fsLabel.ts new file mode 100644 index 0000000..ea4f6af --- /dev/null +++ b/tests/fsLabel.ts @@ -0,0 +1,117 @@ +import { deepEqual } from 'assert'; +import { FileDisk, withOpenFile } from 'file-disk'; +import * as Fs from 'fs'; +import * as Path from 'path'; +import * as tmp from 'tmp'; +import * as partitioninfo from 'partitioninfo'; + +import * as imagefs from '../lib'; + +const RASPBERRYPI = Path.join(__dirname, 'images', 'raspberrypi.img'); +const MBR_FAT32 = Path.join(__dirname, 'images', 'mbr-fat32.img'); + +async function tmpFile(): Promise<{ path: string; cleanup: () => void }> { + return await new Promise((resolve, reject) => { + tmp.file( + { discardDescriptor: true }, + (error: Error | null, path: string, _fd: number, cleanup: () => void) => { + if (error != null) { + reject(error); + } else { + resolve({ path, cleanup }); + } + }, + ); + }); +} + +async function withFileCopy( + filePath: string, + fn: (tmpFilePath: string) => Promise, +): Promise { + const { path, cleanup } = await tmpFile(); + await Fs.promises.copyFile(filePath, path); + try { + return await fn(path); + } finally { + cleanup(); + } +} + +function testWithFileCopy( + title: string, + file: string, + fn: (fileCopy: string) => Promise, +) { + it(title, async () => { + await withFileCopy(file, async (fileCopy: string) => { + await fn(fileCopy); + }); + }); +} + +function testFileDisk( + title: string, + image: { image: string; partition: number }, + fn: ( + disk: FileDisk, + partition: partitioninfo.GPTPartition | partitioninfo.MBRPartition, + ) => Promise, +) { + testWithFileCopy( + `${title} (filedisk)`, + image.image, + async (fileCopy: string) => { + await withOpenFile(fileCopy, 'r+', async (handle) => { + const disk = new FileDisk(handle); + const partition = await partitioninfo.get(disk, image.partition); + await fn(disk, partition); + }); + }, + ); +} + +testFileDisk( + 'should find label in MBR with FAT16 (0xB) partition', + { + image: RASPBERRYPI, + partition: 1, + }, + async ( + disk: FileDisk, + partition: partitioninfo.GPTPartition | partitioninfo.MBRPartition, + ) => { + const label = await imagefs.getLabel(disk, partition); + deepEqual(label, 'RESIN-BOOT'); + }, +); + +testFileDisk( + 'should find label in MBR with FAT32 (0xC) partition', + { + image: MBR_FAT32, + partition: 1, + }, + async ( + disk: FileDisk, + partition: partitioninfo.GPTPartition | partitioninfo.MBRPartition, + ) => { + const label = await imagefs.getLabel(disk, partition); + deepEqual(label, 'resin-boot'); + }, +); + +testFileDisk( + 'should find label in MBR with ext4 (0x83) partition', + { + image: MBR_FAT32, + partition: 6, + }, + async ( + disk: FileDisk, + partition: partitioninfo.GPTPartition | partitioninfo.MBRPartition, + ) => { + const label = await imagefs.getLabel(disk, partition); + deepEqual(label, 'resin-data'); + }, +); diff --git a/tests/images/mbr-fat32.img b/tests/images/mbr-fat32.img new file mode 100644 index 0000000..56eb403 Binary files /dev/null and b/tests/images/mbr-fat32.img differ