From 909f20a097a1ae93a6a5f34f228eca5ba5e7e306 Mon Sep 17 00:00:00 2001 From: Sven Slootweg Date: Sun, 26 Mar 2017 01:06:31 +0100 Subject: [PATCH] Further work --- app.js | 18 +- bin/iso-tool.js | 58 +++++ lib/bit-parser.js | 21 ++ lib/buffer-reader/index.js | 36 +++ lib/deeply-unique.js | 17 ++ lib/iso9660/buffer-reader.js | 207 +++++++++++++++--- lib/iso9660/decode.js | 19 ++ lib/iso9660/index.js | 84 ++++--- lib/iso9660/object/directory.js | 67 +++--- .../parse/descriptors/primary-volume.js | 27 ++- .../parse/descriptors/supplementary-volume.js | 35 ++- lib/iso9660/parse/directory-record.js | 47 ++-- lib/iso9660/parse/escape-sequences.js | 41 ++++ .../parse/extended-attribute-record.js | 80 +++++++ lib/iso9660/read/directory-extent.js | 40 ++++ lib/iso9660/read/extended-attribute-record.js | 13 ++ lib/iso9660/read/path-table.js | 20 ++ lib/iso9660/read/volume-descriptors.js | 34 +++ lib/iso9660/types.js | 130 ----------- lib/remove-right-padding.js | 4 +- lib/render/bytes.js | 5 + lib/render/filesystem-tree.js | 24 ++ lib/render/volume-table.js | 72 ++++++ package.json | 11 +- table-testcase.js | 13 ++ 25 files changed, 840 insertions(+), 283 deletions(-) create mode 100644 bin/iso-tool.js create mode 100644 lib/bit-parser.js create mode 100644 lib/buffer-reader/index.js create mode 100644 lib/deeply-unique.js create mode 100644 lib/iso9660/decode.js create mode 100644 lib/iso9660/parse/escape-sequences.js create mode 100644 lib/iso9660/parse/extended-attribute-record.js create mode 100644 lib/iso9660/read/directory-extent.js create mode 100644 lib/iso9660/read/extended-attribute-record.js create mode 100644 lib/iso9660/read/path-table.js create mode 100644 lib/iso9660/read/volume-descriptors.js delete mode 100644 lib/iso9660/types.js create mode 100644 lib/render/bytes.js create mode 100644 lib/render/filesystem-tree.js create mode 100644 lib/render/volume-table.js create mode 100644 table-testcase.js diff --git a/app.js b/app.js index c49d1a0..0f5fc25 100644 --- a/app.js +++ b/app.js @@ -27,23 +27,7 @@ Promise.try(() => { }).then((rootDirectory) => { return rootDirectory.getTree(); }).then((tree) => { - function makeArchy(node) { - if (node.type === "directory") { - return { - label: node.filename, - nodes: node.children.map(makeArchy) - } - } else { - return node.filename - } - } - - let archyNodes = { - label: "(root)", - nodes: tree.map(makeArchy) - } - - console.log(archy(archyNodes)); + }); /* diff --git a/bin/iso-tool.js b/bin/iso-tool.js new file mode 100644 index 0000000..4f651cb --- /dev/null +++ b/bin/iso-tool.js @@ -0,0 +1,58 @@ +'use strict'; + +const Promise = require("bluebird"); +const isoParser = require("../lib/iso9660"); +const yargs = require("yargs"); + +const fsSeekable = require("../lib/seekable/fs"); +const httpSeekable = require("../lib/seekable/http"); +const renderVolumeTable = require("../lib/render/volume-table"); +const renderFilesystemTree = require("../lib/render/filesystem-tree"); + +let argv = yargs.argv; +let command, target, subCommands; + +if (argv._.length === 1) { + command = "volumes"; + target = argv._[0]; +} else if (argv._.length > 1) { + [command, target, ...subCommands] = argv._; +} else { + throw new Error("You must specify at least a target file or URL"); +} + +Promise.try(() => { + if (target.match(/^https?:\/\//)) { + return httpSeekable(target); + } else { + return fsSeekable(target); + } +}).then((seekable) => { + let parser = isoParser(seekable); + + if (command === "volumes") { + return Promise.try(() => { + return parser.getUniqueVolumeDescriptors(); + }).then((volumeDescriptors) => { + console.log(renderVolumeTable(volumeDescriptors)); + }); + } else if (command === "tree") { + return Promise.try(() => { + if (argv.volume != null) { + let volumeId = parseInt(argv.volume); + + if (isNaN(volumeId)) { + throw new Error("--volume argument must be numeric"); + } + + return parser.getRootDirectory(volumeId); + } else { + return parser.getRootDirectory(); + } + }).then((rootDirectory) => { + return rootDirectory.getTree(); + }).then((tree) => { + console.log(renderFilesystemTree(tree)); + }) + } +}); diff --git a/lib/bit-parser.js b/lib/bit-parser.js new file mode 100644 index 0000000..632513f --- /dev/null +++ b/lib/bit-parser.js @@ -0,0 +1,21 @@ +'use strict'; + +module.exports = function(rules) { + let fields = rules.map((field, i) => { + if (field != null) { + return { + field: field, + value: Math.pow(2, i) + } + } else { + return null; + } + }).filter((item) => item != null); + + return function parseBits(value) { + return fields.reduce((flags, item) => { + flags[item.field] = !!(value & item.value); + return flags; + }, {}); + } +} diff --git a/lib/buffer-reader/index.js b/lib/buffer-reader/index.js new file mode 100644 index 0000000..9a21f6d --- /dev/null +++ b/lib/buffer-reader/index.js @@ -0,0 +1,36 @@ +'use strict'; + +module.exports = function createBufferReaderFactory(rules) { + return function createBufferReader(buffer) { + let defaultMethods = { + slice: function readSlice(start, length) { + return buffer.slice(start, start + length); + } + } + + let customMethods = Object.keys(rules).map((ruleName) => { + let rule = rules[ruleName]; + let readerFunction; + + return { + key: ruleName, + value: function(start, length) { + let actualLength; + + if (rule.length != null) { + actualLength = rule.length; + } else { + actualLength = length; + } + + return rule.decode(buffer.slice(start, start + actualLength)); + } + } + }).reduce((methods, item) => { + methods[item.key] = item.value; + return methods; + }, {}); + + return Object.assign(defaultMethods, customMethods); + } +}; diff --git a/lib/deeply-unique.js b/lib/deeply-unique.js new file mode 100644 index 0000000..b58ec55 --- /dev/null +++ b/lib/deeply-unique.js @@ -0,0 +1,17 @@ +'use strict'; + +const deepEql = require("deep-eql"); + +module.exports = function deeplyUnique(items) { + return items.reduce((uniques, item) => { + let index = uniques.findIndex((uniqueItem) => { + return (uniqueItem.type === item.type && deepEql(uniqueItem.data, item.data)); + }); + + if (index === -1) { + return uniques.concat([item]); + } else { + return uniques; + } + }, []); +} diff --git a/lib/iso9660/buffer-reader.js b/lib/iso9660/buffer-reader.js index 73e9020..7e4bff8 100644 --- a/lib/iso9660/buffer-reader.js +++ b/lib/iso9660/buffer-reader.js @@ -1,43 +1,182 @@ 'use strict'; -const types = require("./types"); - -module.exports = function createBufferReader(buffer) { - let typeSpecs = { - strA: {}, - strD: {}, - strFilename: {}, - int8: { length: 1 }, - sint8: { length: 1 }, - int16_LSB: { length: 2 }, - int16_MSB: { length: 2 }, - int16_LSB_MSB: { length: 4 }, - sint16_LSB: { length: 2 }, - sint16_MSB: { length: 2 }, - sint16_LSB_MSB: { length: 4 }, - int32_LSB: { length: 4 }, - int32_MSB: { length: 4 }, - int32_LSB_MSB: { length: 8 }, - sint32_LSB: { length: 4 }, - sint32_MSB: { length: 4 }, - sint32_LSB_MSB: { length: 8 }, - decDatetime: { length: 17 }, - directoryDatetime: { length: 7 } - } +const moment = require("moment"); + +const bufferReaderFactory = require("../buffer-reader"); +const decode = require("./decode"); +const removeRightPadding = require("../remove-right-padding"); + +function offsetInMinutes(value) { + /* "Time zone offset from GMT in 15 minute intervals, starting at interval -48 (west) and running up to interval 52 (east). + * So value 0 indicates interval -48 which equals GMT-12 hours, and value 100 indicates interval 52 which equals GMT+13 hours." + * + * ref: http://wiki.osdev.org/ISO_9660#Date.2Ftime_format + */ + + return (value - 48) * 15; +} + +module.exports = function createReader(buffer, {encoding} = {encoding: "ascii"}) { + let bufferReaderRules = { + /* String types */ + strA: { + decode: function parseStrA(buffer) { + return removeRightPadding(decode(buffer, "ascii")); + } + }, + strD: { + decode: function parseStrD(buffer) { + return bufferReaderRules.strA.decode(buffer); + } + }, + strA1: { + decode: function parseStrA(buffer) { + return removeRightPadding(decode(buffer, encoding)); + } + }, + strD1: { + decode: function parseStrD(buffer) { + return bufferReaderRules.strA1.decode(buffer); + } + }, + strFilename: { + decode: function parseStrFilename(buffer) { + let decodedString = decode(buffer, encoding); + let [filename, version] = decodedString.split(";"); + + return {filename, version}; + } + }, - return Object.keys(typeSpecs).reduce((methods, type) => { - let options = typeSpecs[type]; + /* 8-bit integers */ + int8: { + length: 1, + decode: function parseInt8(buffer) { + return buffer.readUInt8(0); + } + }, + sint8: { + length: 1, + decode: function parseSInt8(buffer) { + return buffer.readInt8(0); + } + }, - if (options.length == null) { - methods[type] = function(offset, length) { - return types[type](buffer.slice(offset, offset + length)); + /* 16-bit integers */ + int16_LSB: { + length: 2, + decode: function parseInt16_LSB(buffer) { + return buffer.readUInt16LE(0); + } + }, + int16_MSB: { + length: 2, + decode: function parseInt16_LSB(buffer) { + return buffer.readUInt16BE(0); + } + }, + int16_LSB_MSB: { + length: 4, + decode: function parseInt16_LSB(buffer) { + return bufferReaderRules.int16_LSB.decode(buffer.slice(0, 2)); + } + }, + sint16_LSB: { + length: 2, + decode: function parseSInt16_LSB(buffer) { + return buffer.readInt16LE(0); } - } else { - methods[type] = function(offset) { - return types[type](buffer.slice(offset, offset + options.length)); + }, + sint16_MSB: { + length: 2, + decode: function parseSInt16_LSB(buffer) { + return buffer.readInt16BE(0); + } + }, + sint16_LSB_MSB: { + length: 4, + decode: function parseSInt16_LSB(buffer) { + return bufferReaderRules.sint16_LSB.decode(buffer.slice(0, 2)); + } + }, + + /* 32-bit integers */ + int32_LSB: { + length: 4, + decode: function parseInt32_LSB(buffer) { + return buffer.readUInt32LE(0); + } + }, + int32_MSB: { + length: 4, + decode: function parseInt32_LSB(buffer) { + return buffer.readUInt32BE(0); + } + }, + int32_LSB_MSB: { + length: 8, + decode: function parseInt32_LSB(buffer) { + return bufferReaderRules.int32_LSB.decode(buffer.slice(0, 4)); + } + }, + sint32_LSB: { + length: 4, + decode: function parseSInt32_LSB(buffer) { + return buffer.readInt32LE(0); + } + }, + sint32_MSB: { + length: 4, + decode: function parseSInt32_LSB(buffer) { + return buffer.readInt32BE(0); + } + }, + sint32_LSB_MSB: { + length: 8, + decode: function parseSInt32_LSB(buffer) { + return bufferReaderRules.sint32_LSB.decode(buffer.slice(0, 4)); + } + }, + + /* Date/time */ + decDatetime: { + length: 17, + decode: function parseDecDatetime(buffer) { + let year = parseInt(bufferReaderRules.strD.decode(buffer.slice(0, 4))); + let month = parseInt(bufferReaderRules.strD.decode(buffer.slice(4, 6))) - 1; // "Note that like moment(Array) and new Date(year, month, date), months are 0 indexed." + let day = parseInt(bufferReaderRules.strD.decode(buffer.slice(6, 8))); + + let hour = parseInt(bufferReaderRules.strD.decode(buffer.slice(8, 10))); + let minute = parseInt(bufferReaderRules.strD.decode(buffer.slice(10, 12))); + let second = parseInt(bufferReaderRules.strD.decode(buffer.slice(12, 14))); + let centisecond = parseInt(bufferReaderRules.strD.decode(buffer.slice(14, 16))); + + let timezoneOffset = bufferReaderRules.int8.decode(buffer.slice(16, 17)); + + if (year === 0 && month === 0 && day === 0 && hour === 0 && minute === 0 && second === 0 && centisecond === 0 && timezoneOffset === 0) { + return null; + } else { + return moment({ + year, month, day, hour, minute, second, + millisecond: centisecond * 10, + }).utcOffset(offsetInMinutes(timezoneOffset)); + } + } + }, + directoryDatetime: { + length: 7, + decode: function parseDirectoryDatetime(buffer) { + return moment({ + year: 1900 + bufferReaderRules.int8.decode(buffer.slice(0, 0 + 1)), + month: bufferReaderRules.int8.decode(buffer.slice(1, 1 + 1)) - 1, + day: bufferReaderRules.int8.decode(buffer.slice(2, 2 + 1)), + hour: bufferReaderRules.int8.decode(buffer.slice(3, 3 + 1)), + minute: bufferReaderRules.int8.decode(buffer.slice(4, 4 + 1)), + second: bufferReaderRules.int8.decode(buffer.slice(5, 5 + 1)), + }).utcOffset(offsetInMinutes(bufferReaderRules.int8.decode(buffer.slice(6, 6 + 1)))); } } + } - return methods; - }, {}); + return bufferReaderFactory(bufferReaderRules)(buffer); }; diff --git a/lib/iso9660/decode.js b/lib/iso9660/decode.js new file mode 100644 index 0000000..5c6e270 --- /dev/null +++ b/lib/iso9660/decode.js @@ -0,0 +1,19 @@ +'use strict'; + +const iconvLite = require("iconv-lite"); + +let encodingMap = { + "ascii": "ascii", + /* The following are strictly not the same thing, but there's backwards compatibility. */ + "ucs2-level1": "utf16-be", + "ucs2-level2": "utf16-be", + "ucs2-level3": "utf16-be" +} + +module.exports = function decode(buffer, encoding) { + if (encodingMap[encoding] == null) { + throw new Error(`No such encoding: ${encoding}`); + } + + return iconvLite.decode(buffer, encodingMap[encoding]); +}; diff --git a/lib/iso9660/index.js b/lib/iso9660/index.js index f17c74d..1d58c40 100644 --- a/lib/iso9660/index.js +++ b/lib/iso9660/index.js @@ -3,9 +3,13 @@ const Promise = require("bluebird"); const streamToPromise = require("stream-to-promise"); const memoizee = require("memoizee"); +const promiseWhile = require("promise-while-loop"); -const parseVolumeDescriptor = require("./parse/volume-descriptor"); const parsePathTable = require("./parse/path-table"); +const deeplyUnique = require("../deeply-unique"); + +const readVolumeDescriptors = require("./read/volume-descriptors"); +const readPathTable = require("./read/path-table"); let imageOffset = 32 * 1024; // first 32KB are system area, ref http://wiki.osdev.org/ISO_9660#System_Area @@ -23,47 +27,75 @@ module.exports = function createImage(seekable) { return streamToPromise(stream); }); }, + readSectors: function readSectors(firstSector, sectorCount) { + return Promise.try(() => { + return this.getSectorSize(); + }).then((sectorSize) => { + return this.readRange(firstSector * sectorSize, (firstSector + sectorCount) * sectorSize - 1); + }); + }, - getPrimaryVolumeDescriptorBuffer: memoizee(function getPrimaryVolumeDescriptorBuffer() { - return this.readRange(imageOffset, imageOffset + 2047); + getVolumeDescriptors: memoizee(function getVolumeDescriptors() { + return readVolumeDescriptors(this); }), - getPrimaryVolumeDescriptor: function getPrimaryVolumeDescriptor() { + getUniqueVolumeDescriptors: function getUniqueVolumeDescriptors() { return Promise.try(() => { - return this.getPrimaryVolumeDescriptorBuffer(); - }).then((buffer) => { - return parseVolumeDescriptor(buffer); + return this.getVolumeDescriptors(); + }).then((descriptors) => { + return deeplyUnique(descriptors); }); }, + getPrimaryVolumeDescriptor: function getPrimaryVolumeDescriptor() { + return Promise.try(() => { + return this.getVolumeDescriptors(); + }).then((descriptors) => { + let primaryVolumeDescriptor = descriptors.find((descriptor) => descriptor.type === "primary"); - getSectorOffset: function getSectorOffset(sector) { + if (primaryVolumeDescriptor != null) { + return primaryVolumeDescriptor; + } else { + throw new Error("No primary volume descriptor could be found"); + } + }) + }, + + getSectorSize: function getSectorSize() { return Promise.try(() => { return this.getPrimaryVolumeDescriptor(); }).then((primaryVolumeDescriptor) => { - return primaryVolumeDescriptor.data.sectorSize * sector; + return primaryVolumeDescriptor.data.sectorSize; }); }, - - getPathTable: function getPathTable() { + getSectorOffset: function getSectorOffset(sector) { // FIXME: Remove? return Promise.try(() => { - return this.getPrimaryVolumeDescriptor(); - }).then((primaryVolumeDescriptor) => { - return Promise.try(() => { - let start = primaryVolumeDescriptor.data.pathTableLocationL * primaryVolumeDescriptor.data.sectorSize; - let end = start + primaryVolumeDescriptor.data.pathTableSize - 1; - - return this.readRange(start, end); - }).then((buffer) => { - return parsePathTable(buffer, primaryVolumeDescriptor.data.sectorSize); - }); + return this.getSectorSize(); + }).then((sectorSize) => { + return sectorSize * sector; }); }, - getRootDirectory: function getRootDirectory() { + + getPathTable: function getPathTable() { + return readPathTable(this); + }, + getRootDirectory: memoizee(function getRootDirectory(volumeIndex) { return Promise.try(() => { - return this.getPrimaryVolumeDescriptor(); - }).then((primaryVolumeDescriptor) => { - return createDirectory(primaryVolumeDescriptor.data.rootDirectory); + if (volumeIndex == null) { + return this.getPrimaryVolumeDescriptor(); + } else { + return Promise.try(() => { + return this.getVolumeDescriptors(); + }).then((descriptors) => { + if (descriptors[volumeIndex] == null) { + throw new Error("No volume descriptor with that ID exists"); + } else { + return descriptors[volumeIndex]; + } + }); + } + }).then((volumeDescriptor) => { + return createDirectory(volumeDescriptor.data.rootDirectory); }); - } + }) } const createDirectory = require("./object/directory")(image); diff --git a/lib/iso9660/object/directory.js b/lib/iso9660/object/directory.js index d9fc6cf..0c0aa94 100644 --- a/lib/iso9660/object/directory.js +++ b/lib/iso9660/object/directory.js @@ -4,11 +4,20 @@ const Promise = require("bluebird"); const memoizee = require("memoizee"); const debug = require("debug")("iso9660:object:directory"); -const parseDirectoryRecord = require("../parse/directory-record"); +const readExtendedAttributeRecord = require("../read/extended-attribute-record"); +const readDirectoryExtent = require("../read/directory-extent"); module.exports = function(image) { const createFile = require("./file")(image); + function getExtendedAttributeRecord(extentLocation, extendedAttributeRecordLength) { + if (extendedAttributeRecordLength === 0) { + return {}; + } else { + return readExtendedAttributeRecord(extentLocation, extendedAttributeRecordLength); + } + } + return function createDirectory(directoryRecord, parent) { debug(`Creating new directory object for ${directoryRecord.identifier.filename}`); @@ -18,52 +27,32 @@ module.exports = function(image) { version: directoryRecord.identifier.version, parent: parent, recordingDate: directoryRecord.recordingDate, + encoding: directoryRecord.encoding, + record: directoryRecord, getChildren: memoizee(function getChildren() { return Promise.try(() => { - function getDirectoryContents() { - return Promise.try(() => { - return image.getSectorOffset(directoryRecord.extentLocation); - }).then((sectorOffset) => { - return image.readRange(sectorOffset, sectorOffset + directoryRecord.extentSize - 1); - }); - } - - return Promise.all([ - image.getPrimaryVolumeDescriptor(), - getDirectoryContents() - ]); - }).spread((primaryVolumeDescriptor, directoryContents) => { - const roundToNextSector = require("../round-to-next-sector")(primaryVolumeDescriptor.data.sectorSize); - - let pos = 0; - let records = []; + return readDirectoryExtent(image, directoryRecord.extentLocation, directoryRecord.extentSize, directoryRecord.encoding); + }).then((directoryRecords) => { + /* The first two records are . and .. respectively, so we'll strip these off. */ + let realDirectoryRecords = directoryRecords.slice(2); - while (pos < directoryContents.length) { - let recordLength = directoryContents.readUInt8(pos); - - if (recordLength === 0) { - /* We ran out of records for this sector, skip to the next. */ - pos = roundToNextSector(pos); + return Promise.map(realDirectoryRecords, (directoryRecord) => { + if (directoryRecord.fileFlags.directory) { + debug(`Found directory: ${directoryRecord.identifier.filename}`); + return createDirectory(directoryRecord, this); } else { - let directoryRecord = parseDirectoryRecord(directoryContents.slice(pos, pos + recordLength)); - records.push(directoryRecord); - pos += recordLength; - } - } - - return records.slice(2).map((record) => { - if (record.fileFlags.directory) { - debug(`Found directory: ${record.identifier.filename}`); + debug(`Found file: ${directoryRecord.identifier.filename}`); - return createDirectory(record, this); - } else { - debug(`Found file: ${record.identifier.filename}`); - - return createFile(record, this); + return Promise.try(() => { + return getExtendedAttributeRecord(directoryRecord.extentLocation, directoryRecord.extendedAttributeRecordLength); + }).then((extendedAttributeRecord) => { + directoryRecord.extendedAttributeRecord = extendedAttributeRecord; + return createFile(directoryRecord, this); + }); } }); - }); + }) }), getTree: function getTree() { return Promise.try(() => { diff --git a/lib/iso9660/parse/descriptors/primary-volume.js b/lib/iso9660/parse/descriptors/primary-volume.js index f1e8086..d745d3d 100644 --- a/lib/iso9660/parse/descriptors/primary-volume.js +++ b/lib/iso9660/parse/descriptors/primary-volume.js @@ -3,12 +3,16 @@ const createBufferReader = require("../../buffer-reader"); const parseDirectoryRecord = require("../directory-record"); -module.exports = function parsePrimaryVolumeDescriptor(data) { - let bufferReader = createBufferReader(data); +module.exports = function parsePrimaryVolumeDescriptor(data, encoding = "ascii") { + let bufferReader = createBufferReader(data, { + encoding: encoding + }); + + /* NOTE: Technically, the spec states that we should use strA and strD in a primary volume descriptor, and not strA1 and strD1. However, using the encoding-specific versions makes it easier to base the supplementary volume descriptor parser on this one, and since this parser defaults to an 'ascii' encoding anyway, it doesn't matter from a functional perspective. */ return { - systemIdentifier: bufferReader.strA(1, 32), - volumeIdentifier: bufferReader.strD(33, 32), + systemIdentifier: bufferReader.strA1(1, 32), + volumeIdentifier: bufferReader.strD1(33, 32), sectorCount: bufferReader.int32_LSB_MSB(73), setSize: bufferReader.int16_LSB_MSB(113), sequenceNumber: bufferReader.int16_LSB_MSB(117), @@ -18,13 +22,14 @@ module.exports = function parsePrimaryVolumeDescriptor(data) { optionalPathTableLocationL: bufferReader.int32_LSB(137), // FIXME: Pointer? (location is expressed in 'sectors') pathTableLocationM: bufferReader.int32_MSB(141), // FIXME: Pointer? (location is expressed in 'sectors') optionalPathTableLocationM: bufferReader.int32_MSB(145), // FIXME: Pointer? (location is expressed in 'sectors') - rootDirectory: parseDirectoryRecord(data.slice(149, 149 + 34)), - publisherIdentifier: bufferReader.strA(311, 128), // FIXME: null for unspecified & extended publisher information - dataPreparerIdentifier: bufferReader.strA(439, 128), // FIXME: null for unspecified & extended publisher information - applicationIdentifier: bufferReader.strA(567, 128), // FIXME: null for unspecified & extended publisher information - copyrightFile: bufferReader.strD(695, 38), // FIXME: seek for file, optionally? - abstractFile: bufferReader.strD(733, 36), // FIXME: seek for file, optionally? - bibliographicFile: bufferReader.strD(769, 37), // FIXME: seek for file, optionally? + rootDirectory: parseDirectoryRecord(bufferReader.slice(149, 34), {encoding: encoding}), + volumeSetIdentifier: bufferReader.strD1(183, 128), + publisherIdentifier: bufferReader.strA1(311, 128), // FIXME: null for unspecified & extended publisher information + dataPreparerIdentifier: bufferReader.strA1(439, 128), // FIXME: null for unspecified & extended publisher information + applicationIdentifier: bufferReader.strA1(567, 128), // FIXME: null for unspecified & extended publisher information + copyrightFile: bufferReader.strD1(695, 36), // FIXME: seek for file, optionally? + abstractFile: bufferReader.strD1(732, 36), // FIXME: seek for file, optionally? + bibliographicFile: bufferReader.strD1(769, 36), // FIXME: seek for file, optionally? creationDate: bufferReader.decDatetime(806), modificationDate: bufferReader.decDatetime(823), expirationDate: bufferReader.decDatetime(840), diff --git a/lib/iso9660/parse/descriptors/supplementary-volume.js b/lib/iso9660/parse/descriptors/supplementary-volume.js index f22feb1..f458bef 100644 --- a/lib/iso9660/parse/descriptors/supplementary-volume.js +++ b/lib/iso9660/parse/descriptors/supplementary-volume.js @@ -1,5 +1,38 @@ 'use strict'; +const createBufferReader = require("../../buffer-reader"); +const createBitParser = require("../../../bit-parser"); +const parsePrimaryVolumeDescriptor = require("./primary-volume"); +const parseEscapeSequences = require("../../parse/escape-sequences"); + +/* Supplementary volume flags: (ref http://www.ecma-international.org/publications/files/ECMA-ST/Ecma-119.pdf) + * Bit Description + * 0 If set, the Escape Sequences field specifies at least one escape sequence not registered according to ISO 2375. + * 1 - 7 Reserved + */ + +let parseVolumeFlags = createBitParser([ + "hasNonISO2375EscapeSequences", + null, + null, + null, + null, + null, + null, + null +]); + module.exports = function parseSupplementaryVolumeDescriptor(data) { - throw new Error("Not implemented yet"); + let bufferReader = createBufferReader(data); + + let escapeSequences = data.slice(81, 113); + let encodings = parseEscapeSequences(escapeSequences); + + /* The format of a primary and supplementary volume descriptor are mostly the same - the only real difference is that a supplementary volume descriptor will also have a volumeFlags and escapeSequences field. We can therefore just parse this volume descriptor like a primary volume descriptor, and then merge in the additional fields. */ + + return Object.assign(parsePrimaryVolumeDescriptor(data, encodings[0]), { + volumeFlags: parseVolumeFlags(bufferReader.int8(0)), + escapeSequences: escapeSequences, + encodings: encodings + }); }; diff --git a/lib/iso9660/parse/directory-record.js b/lib/iso9660/parse/directory-record.js index fbf4e9b..cd8e4fb 100644 --- a/lib/iso9660/parse/directory-record.js +++ b/lib/iso9660/parse/directory-record.js @@ -2,36 +2,39 @@ const createBufferReader = require("../buffer-reader"); const roundEven = require("../../round-even"); +const createBitParser = require("../../bit-parser"); +const decode = require("../decode"); -function parseFileFlags(value) { - /* File flags: (ref http://wiki.osdev.org/ISO_9660#Directories) - * Bit Description - * 0 If set, the existence of this file need not be made known to the user (basically a 'hidden' flag. - * 1 If set, this record describes a directory (in other words, it is a subdirectory extent). - * 2 If set, this file is an "Associated File". - * 3 If set, the extended attribute record contains information about the format of this file. - * 4 If set, owner and group permissions are set in the extended attribute record. - * 5 & 6 Reserved - * 7 If set, this is not the final directory record for this file (for files spanning several extents, for example files over 4GiB long. - */ +/* File flags: (ref http://wiki.osdev.org/ISO_9660#Directories) + * Bit Description + * 0 If set, the existence of this file need not be made known to the user (basically a 'hidden' flag. + * 1 If set, this record describes a directory (in other words, it is a subdirectory extent). + * 2 If set, this file is an "Associated File". + * 3 If set, the extended attribute record contains information about the format of this file. + * 4 If set, owner and group permissions are set in the extended attribute record. + * 5 & 6 Reserved + * 7 If set, this is not the final directory record for this file (for files spanning several extents, for example files over 4GiB long. + */ - return { - hidden: !!(value & 1), - directory: !!(value & 2), - associated: !!(value & 4), - inEAR: !!(value & 8), - permissionsInEAR: !!(value & 16), - moreRecordsFollowing: !!(value & 128) - } -} +let parseFileFlags = createBitParser([ + "hidden", + "directory", + "associated", + "inEAR", + "permissionsInEAR", + null, + null, + "moreRecordsFollowing" +]); -module.exports = function parseDirectoryRecord(data) { - let bufferReader = createBufferReader(data); +module.exports = function parseDirectoryRecord(data, {encoding} = {encoding: "ascii"}) { + let bufferReader = createBufferReader(data, {encoding: encoding}); let identifierLength = bufferReader.int8(32); let identifierEnd = roundEven(32 + identifierLength); return { + encoding: encoding, recordLength: bufferReader.int8(0), extendedAttributeRecordLength: bufferReader.int8(1), extentLocation: bufferReader.int32_LSB_MSB(2), diff --git a/lib/iso9660/parse/escape-sequences.js b/lib/iso9660/parse/escape-sequences.js new file mode 100644 index 0000000..d1b0f6b --- /dev/null +++ b/lib/iso9660/parse/escape-sequences.js @@ -0,0 +1,41 @@ +'use strict'; + +const renderBytes = require("../../render/bytes"); + +let sequenceMap = { + "ucs2-level1": Buffer.from([0x25, 0x2F, 0x40]), + "ucs2-level2": Buffer.from([0x25, 0x2F, 0x43]), + "ucs2-level3": Buffer.from([0x25, 0x2F, 0x45]) +} + +module.exports = function parseEscapeSequences(escapeSequences) { + let pos = 0; + let finished = false; + let encodings = []; + + while (finished === false) { + if (escapeSequences.readUInt8(pos) === 0) { + /* We've run out of escape sequences to parse */ + break; + } + + /* We only check for 3-byte-long escape sequences, for now... */ + let slice3 = escapeSequences.slice(pos, pos + 3); + + let encoding = Object.keys(sequenceMap).find((encoding) => sequenceMap[encoding].equals(slice3)); + + if (encoding == null) { + throw new Error(`Encountered unrecognized escape sequence; remaining bytes to parse: ${renderBytes(escapeSequences.slice(pos))}`); + } else { + pos += sequenceMap[encoding].length; + encodings.push(encoding); + } + } + + + if (encodings.length === 0) { + return ["ascii"]; + } else { + return encodings; + } +}; diff --git a/lib/iso9660/parse/extended-attribute-record.js b/lib/iso9660/parse/extended-attribute-record.js new file mode 100644 index 0000000..600028b --- /dev/null +++ b/lib/iso9660/parse/extended-attribute-record.js @@ -0,0 +1,80 @@ +'use strict'; + +const createBufferReader = require("../buffer-reader"); +const createBitParser = require("../../bit-parser"); +const decode = require("../decode"); +const parseEscapeSequences = require("./escape-sequences"); + +let parsePermissions = createBitParser([ + "groupOwnerReadable", + null, + "groupOwnerExecutable", + null, + "ownerReadable", + null, + "ownerExecutable", + null, + "groupReadable", + null, + "groupExecutable", + null, + "worldReadable", + null, + "worldExecutable", + null +], {inverse: [0, 2, 4, 6, 8, 10, 12, 14]}) + +function parseRecordFormat(value) { + switch (value) { + case 0: + return "unspecified"; + case 1: + return "fixedLength"; + case 2: + return "variableLengthLSB"; + case 3: + return "variableLengthMSB"; + default: + throw new Error(`Unknown record format encountered: ${value}`); + } +} + +function parseRecordAttributes(value) { + switch (value) { + case 0: + return "lineFeedCarriageReturn"; + case 1: + return "iso1539"; + case 2: + return "containedInRecord"; + default: + throw new Error(`Unknown record attributes encountered: ${value}`); + } +} + +module.exports = function parseExtendedAttributeRecord(data, {encoding} = {encoding: "ascii"}) { + let bufferReader = createBufferReader(data, {encoding: encoding}); + + let applicationUseLength = bufferReader.int16_LSB_MSB(246); + let escapeSequenceLength = bufferReader.int8(181); + + return { + ownerIdentification: bufferReader.int16_LSB_MSB(0), + groupIdentification: bufferReader.int16_LSB_MSB(4), + permissions: parsePermissions(bufferReader.int16_MSB(8)), + creationDate: bufferReader.decDatetime(10), + modificationDate: bufferReader.decDatetime(27), + expirationDate: bufferReader.decDatetime(44), + effectiveDate: bufferReader.decDatetime(61), + recordFormat: parseRecordFormat(bufferReader.int8(78)), + recordAttributes: parseRecordAttributes(bufferReader.int8(79)), + recordLength: bufferReader.int16_LSB_MSB(80), + systemIdentifier: bufferReader.strA1(84, 32), + systemUse: bufferReader.slice(116, 64), + earVersion: bufferReader.int8(180), + escapeSequenceLength: escapeSequenceLength, + applicationUseLength: applicationUseLength, + applicationUse: bufferReader.slice(250, applicationUseLength), + escapeSequences: parseEscapeSequences(bufferReader.slice(250 + applicationUseLength, escapeSequenceLength)) + } +}; diff --git a/lib/iso9660/read/directory-extent.js b/lib/iso9660/read/directory-extent.js new file mode 100644 index 0000000..8802d08 --- /dev/null +++ b/lib/iso9660/read/directory-extent.js @@ -0,0 +1,40 @@ +'use strict'; + +const Promise = require("bluebird"); + +const parseDirectoryRecord = require("../parse/directory-record"); + +module.exports = function readDirectoryExtent(image, sector, length, encoding) { + return Promise.try(() => { + return image.getSectorSize(); + }).then((sectorSize) => { + const roundToNextSector = require("../round-to-next-sector")(sectorSize); + + function splitRecordBuffers(buffer) { + let pos = 0; + let recordBuffers = []; + + while (pos < buffer.length) { + let recordLength = buffer.readUInt8(pos); + + if (recordLength === 0) { + /* We ran out of records for this sector, skip to the next. */ + pos = roundToNextSector(pos); + } else { + let directoryRecord = buffer.slice(pos, pos + recordLength); + recordBuffers.push(directoryRecord); + pos += recordLength; + } + } + + return recordBuffers; + } + + return Promise.try(() => { + let sectorOffset = sector * sectorSize; + return image.readRange(sectorOffset, sectorOffset + length - 1); + }).then((buffer) => { + let recordBuffers = splitRecordBuffers(buffer); + }) + }) +}; diff --git a/lib/iso9660/read/extended-attribute-record.js b/lib/iso9660/read/extended-attribute-record.js new file mode 100644 index 0000000..6af914e --- /dev/null +++ b/lib/iso9660/read/extended-attribute-record.js @@ -0,0 +1,13 @@ +'use strict'; + +const Promise = require("bluebird"); + +const parseExtendedAttributeRecord = require("../parse/extended-attribute-record"); + +module.exports = function readExtendedAttributeRecord(image, sector, sectorCount) { + return Promise.try(() => { + return image.readSectors(sector, sectorCount); + }).then((buffer) => { + return parseExtendedAttributeRecord(buffer); + }); +}; diff --git a/lib/iso9660/read/path-table.js b/lib/iso9660/read/path-table.js new file mode 100644 index 0000000..28857f5 --- /dev/null +++ b/lib/iso9660/read/path-table.js @@ -0,0 +1,20 @@ +'use strict'; + +const Promise = require("bluebird"); + +const parsePathTable = require("../parse/path-table"); + +module.exports = function readPathTable(image) { + return Promise.try(() => { + return image.getPrimaryVolumeDescriptor(); + }).then((primaryVolumeDescriptor) => { + return Promise.try(() => { + let start = primaryVolumeDescriptor.data.pathTableLocationL * primaryVolumeDescriptor.data.sectorSize; + let end = start + primaryVolumeDescriptor.data.pathTableSize - 1; + + return image.readRange(start, end); + }).then((buffer) => { + return parsePathTable(buffer, primaryVolumeDescriptor.data.sectorSize); + }); + }); +} diff --git a/lib/iso9660/read/volume-descriptors.js b/lib/iso9660/read/volume-descriptors.js new file mode 100644 index 0000000..c1011a8 --- /dev/null +++ b/lib/iso9660/read/volume-descriptors.js @@ -0,0 +1,34 @@ +'use strict'; + +const Promise = require("bluebird"); +const promiseWhile = require("promise-while-loop"); + +const parseVolumeDescriptor = require("../parse/volume-descriptor"); + +module.exports = function readVolumeDescriptors(image) { + return Promise.try(() => { + let encounteredTerminator = false; + let currentSector = 16; + + return promiseWhile(() => encounteredTerminator === false, (lastResult) => { + return Promise.try(() => { + let offset = currentSector * 2048; // Always assume a sector size of 2048 for volume descriptors + + return image.readRange(offset, offset + 2047); + }).then((buffer) => { + let descriptor = parseVolumeDescriptor(buffer); + + if (descriptor.type === "terminator") { + encounteredTerminator = true; + } + + currentSector += 1; + return descriptor; + }); + }); + }).map((volumeDescriptor, i) => { + return Object.assign({ + volumeDescriptorIndex: i + }, volumeDescriptor); + }); +}; diff --git a/lib/iso9660/types.js b/lib/iso9660/types.js deleted file mode 100644 index f620b6a..0000000 --- a/lib/iso9660/types.js +++ /dev/null @@ -1,130 +0,0 @@ -'use strict'; - -const moment = require("moment"); -const removeRightPadding = require("../remove-right-padding"); - -/* Basic ISO 9660 types: (ref http://wiki.osdev.org/ISO_9660#Numerical_formats) - * int8 Unsigned 8-bit integer. - * sint8 Signed 8-bit integer. - * int16_LSB Little-endian encoded unsigned 16-bit integer. - * int16_MSB Big-endian encoded unsigned 16-bit integer. - * int16_LSB-MSB Little-endian followed by big-endian encoded unsigned 16-bit integer. - * sint16_LSB Little-endian encoded signed 16-bit integer. - * sint16_MSB Big-endian encoded signed 16-bit integer. - * sint16_LSB-MSB Little-endian followed by big-endian encoded signed 16-bit integer. - * int32_LSB Little-endian encoded unsigned 32-bit integer. - * int32_MSB Big-endian encoded unsigned 32-bit integer. - * int32_LSB-MSB Little-endian followed by big-endian encoded unsigned 32-bit integer. - * sint32_LSB Little-endian encoded signed 32-bit integer. - * sint32_MSB Big-endian encoded signed 32-bit integer. - * sint32_LSB-MSB Little-endian followed by big-endian encoded signed 32-bit integer. - * - * "Where a both-endian format is present, the x86 architecture makes use of the first little-endian sequence and ignores the big-endian sequence." - */ - - function offsetInMinutes(value) { - /* "Time zone offset from GMT in 15 minute intervals, starting at interval -48 (west) and running up to interval 52 (east). - * So value 0 indicates interval -48 which equals GMT-12 hours, and value 100 indicates interval 52 which equals GMT+13 hours." - * - * ref: http://wiki.osdev.org/ISO_9660#Date.2Ftime_format - */ - - return (value - 48) * 15; - } - -module.exports = { - /* String types */ - strA: function parseStrA(buffer) { - return removeRightPadding(buffer.toString("ascii")); - }, - strD: function parseStrD(buffer) { - return this.strA(buffer); - }, - strFilename: function parseStrFilename(buffer) { - let str = this.strA(buffer); - let [filename, version] = str.split(";"); - - return {filename, version}; - }, - - /* 8-bit integers */ - int8: function parseInt8(buffer) { - return buffer.readUInt8(0); - }, - sint8: function parseSInt8(buffer) { - return buffer.readInt8(0); - }, - - /* 16-bit integers */ - int16_LSB: function parseInt16_LSB(buffer) { - return buffer.readUInt16LE(0); - }, - int16_MSB: function parseInt16_LSB(buffer) { - return buffer.readUInt16BE(0); - }, - int16_LSB_MSB: function parseInt16_LSB(buffer) { - return this.int16_LSB(buffer.slice(0, 2)); - }, - sint16_LSB: function parseSInt16_LSB(buffer) { - return buffer.readInt16LE(0); - }, - sint16_MSB: function parseSInt16_LSB(buffer) { - return buffer.readInt16BE(0); - }, - sint16_LSB_MSB: function parseSInt16_LSB(buffer) { - return this.sint16_LSB(buffer.slice(0, 2)); - }, - - /* 32-bit integers */ - int32_LSB: function parseInt32_LSB(buffer) { - return buffer.readUInt32LE(0); - }, - int32_MSB: function parseInt32_LSB(buffer) { - return buffer.readUInt32BE(0); - }, - int32_LSB_MSB: function parseInt32_LSB(buffer) { - return this.int32_LSB(buffer.slice(0, 4)); - }, - sint32_LSB: function parseSInt32_LSB(buffer) { - return buffer.readInt32LE(0); - }, - sint32_MSB: function parseSInt32_LSB(buffer) { - return buffer.readInt32BE(0); - }, - sint32_LSB_MSB: function parseSInt32_LSB(buffer) { - return this.sint32_LSB(buffer.slice(0, 4)); - }, - - /* Date/time */ - decDatetime: function parseDecDatetime(buffer) { - let year = parseInt(this.strD(buffer.slice(0, 4))); - let month = parseInt(this.strD(buffer.slice(4, 6))) - 1; // "Note that like moment(Array) and new Date(year, month, date), months are 0 indexed." - let day = parseInt(this.strD(buffer.slice(6, 8))); - - let hour = parseInt(this.strD(buffer.slice(8, 10))); - let minute = parseInt(this.strD(buffer.slice(10, 12))); - let second = parseInt(this.strD(buffer.slice(12, 14))); - let centisecond = parseInt(this.strD(buffer.slice(14, 16))); - - let timezoneOffset = this.int8(buffer.slice(16, 17)); - - if (year === 0 && month === 0 && day === 0 && hour === 0 && minute === 0 && second === 0 && centisecond === 0 && timezoneOffset === 0) { - return null; - } else { - return moment({ - year, month, day, hour, minute, second, - millisecond: centisecond * 10, - }).utcOffset(offsetInMinutes(timezoneOffset)); - } - }, - directoryDatetime: function parseDirectoryDatetime(buffer) { - return moment({ - year: 1900 + this.int8(buffer.slice(0, 0 + 1)), - month: this.int8(buffer.slice(1, 1 + 1)) - 1, - day: this.int8(buffer.slice(2, 2 + 1)), - hour: this.int8(buffer.slice(3, 3 + 1)), - minute: this.int8(buffer.slice(4, 4 + 1)), - second: this.int8(buffer.slice(5, 5 + 1)), - }).utcOffset(offsetInMinutes(this.int8(buffer.slice(6, 6 + 1)))); - } -}; diff --git a/lib/remove-right-padding.js b/lib/remove-right-padding.js index 7d45ee5..9cd5b3c 100644 --- a/lib/remove-right-padding.js +++ b/lib/remove-right-padding.js @@ -1,5 +1,5 @@ 'use strict'; module.exports = function removeRightPadding(string) { - return string.replace(/[ ]+$/, ""); -}; + return string.replace(/[ \u0000]+$/, ""); +} diff --git a/lib/render/bytes.js b/lib/render/bytes.js new file mode 100644 index 0000000..7c6f5f1 --- /dev/null +++ b/lib/render/bytes.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = function renderBytes(buffer) { + return Array.from(buffer.values()).map((byte) => byte.toString(16)).join(" "); +}; diff --git a/lib/render/filesystem-tree.js b/lib/render/filesystem-tree.js new file mode 100644 index 0000000..3f37ef3 --- /dev/null +++ b/lib/render/filesystem-tree.js @@ -0,0 +1,24 @@ +'use strict'; + +const archy = require("archy"); +const util = require("util"); + +function makeArchyNode(node) { + if (node.type === "directory") { + return { + label: node.filename, + nodes: node.children.map(makeArchyNode) + } + } else { + return node.filename + } +} + +module.exports = function renderFilesystemTree(rootDirectory) { + let archyTree = { + label: "(root)", + nodes: rootDirectory.map(makeArchyNode) + } + + return archy(archyTree); +}; diff --git a/lib/render/volume-table.js b/lib/render/volume-table.js new file mode 100644 index 0000000..564a7f5 --- /dev/null +++ b/lib/render/volume-table.js @@ -0,0 +1,72 @@ +'use strict'; + +const dotty = require("dotty"); +const asciiDataTable = require("ascii-data-table"); +const defaultValue = require("default-value"); +const buffertrim = require("buffertrim"); + +const renderBytes = require("./bytes"); + +function formatCreationDate(creationDate) { + if (creationDate != null) { + return creationDate.format("llll"); + } else { + return ""; + } +} + +function formatVolumePosition(sequenceNumber, setSize) { + if (sequenceNumber != null) { + return `${sequenceNumber}/${setSize}`; + } else { + return ""; + } +} + +function formatEncodings(encodings) { + if (encodings != null) { + return encodings.join(", "); + } else { + return ""; + } +} + +let headers = [ + "ID", + "Type", + "System", + "Volume", + "Volume identifier", + "Encoding", + "Extent", + "Creation date" +]; + +module.exports = function renderVolumeTable(volumeDescriptors) { + let tableData = volumeDescriptors.map((descriptor, i) => { + + let volumeIndex = descriptor.volumeDescriptorIndex; + let volumeType = descriptor.type; + let systemIdentifier = defaultValue(descriptor.data.systemIdentifier, ""); + let volumeIdentifier = defaultValue(descriptor.data.volumeIdentifier, ""); + let extentSector = defaultValue(dotty.get(descriptor, "data.rootDirectory.extentLocation"), ""); + let creationDate = formatCreationDate(descriptor.data.creationDate); + let volumePosition = formatVolumePosition(descriptor.data.sequenceNumber, descriptor.data.setSize); + let encodings = formatEncodings(descriptor.data.encodings); + + return [ + volumeIndex, + volumeType, + systemIdentifier, + volumePosition, + volumeIdentifier, + encodings, + extentSector, + creationDate + ]; + }); + + let tableDataWithHeaders = [headers].concat(tableData); + + return asciiDataTable.default.run(tableDataWithHeaders); +}; diff --git a/package.json b/package.json index 0974ca7..44e3e5c 100644 --- a/package.json +++ b/package.json @@ -14,12 +14,21 @@ "license": "WTFPL", "dependencies": { "archy": "^1.0.0", + "ascii-data-table": "^1.3.3", "bhttp": "^1.2.4", "bluebird": "^3.4.6", + "buffertrim": "^1.0.0", "debug": "^2.2.0", + "deep-eql": "^1.0.3", + "default-value": "^1.0.0", + "dotty": "0.0.2", + "iconv-lite": "^0.4.13", "memoizee": "^0.4.1", "moment": "^2.15.1", + "promise-while-loop": "^1.0.1", "safe-buffer": "^5.0.1", - "stream-to-promise": "^2.2.0" + "stream-to-promise": "^2.2.0", + "table": "^3.8.3", + "yargs": "^6.3.0" } } diff --git a/table-testcase.js b/table-testcase.js new file mode 100644 index 0000000..3c9e52c --- /dev/null +++ b/table-testcase.js @@ -0,0 +1,13 @@ +'use strict'; + +const table = require("table"); + +table.default([ [ 0, 'PC', 'SC2KSEPC', '1/1', 24, 'Mon, Apr 13, 1998 8:28 PM' ], + [ 1, 'PC', 'SC2KSEPC', '1/1', 24, 'Mon, Apr 13, 1998 8:28 PM' ], + [ 2, + '\u0000P\u0000C\u0000 \u0000 \u0000 \u0000 \u0000 \u0000 \u0000 \u0000 \u0000 \u0000 \u0000 \u0000 \u0000 \u0000', + '\u0000S\u0000C\u00002\u0000K\u0000S\u0000E\u0000P\u0000C\u0000 \u0000 \u0000 \u0000 \u0000 \u0000 \u0000 \u0000', + '1/1', + 25, + 'Mon, Apr 13, 1998 8:28 PM' ], + [ 3, '', '', 'undefined/undefined', '', '' ] ]);