diff --git a/.gitignore b/.gitignore index c3c6b061..3da8ee83 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ docs package-lock.json yarn.lock +.nyc_output # Logs logs diff --git a/README.md b/README.md index 5e7a445a..0b111b4f 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ The UnixFS spec can be found inside the [ipfs/specs repository](http://github.co - [get total fileSize](#get-total-filesize) - [marshal and unmarshal](#marshal-and-unmarshal) - [is this UnixFS entry a directory?](#is-this-unixfs-entry-a-directory) + - [has an mtime been set?](#has-an-mtime-been-set) - [Contribute](#contribute) - [License](#license) @@ -116,7 +117,12 @@ message Data { optional uint64 hashType = 5; optional uint64 fanout = 6; optional uint32 mode = 7; - optional int64 mtime = 8; + optional UnixTime mtime = 8; +} + +message UnixTime { + required int64 Seconds = 1; + optional fixed32 FractionalNanoseconds = 2; } message Metadata { @@ -142,7 +148,7 @@ const data = new UnixFS([options]) - data (Buffer): The optional data field for this node - blockSizes (Array, default: `[]`): If this is a `file` node that is made up of multiple blocks, `blockSizes` is a list numbers that represent the size of the file chunks stored in each child node. It is used to calculate the total file size. - mode (Number, default `0644` for files, `0755` for directories/hamt-sharded-directories) file mode -- mtime (Date, default `0`): The modification time of this node +- mtime (Date, { secs, nsecs }, { Seconds, FractionalNanoseconds }, [ secs, nsecs ], default { secs: 0 }): The modification time of this node #### add and remove a block size to the block size list @@ -177,6 +183,20 @@ const file = new Data({ type: 'file' }) file.isDirectory() // false ``` +#### has an mtime been set? + +If no modification time has been set, no `mtime` property will be present on the `Data` instance: + +```JavaScript +const file = new Data({ type: 'file' }) +file.mtime // undefined + +Object.prototype.hasOwnProperty.call(file, 'mtime') // false + +const dir = new Data({ type: 'dir', mtime: new Date() }) +dir.mtime // { secs: Number, nsecs: Number } +``` + ## Contribute Feel free to join in. All welcome. Open an [issue](https://github.com/ipfs/js-ipfs-unixfs/issues)! diff --git a/package.json b/package.json index f00d3084..c9c3551e 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "release": "aegir release", "release-minor": "aegir release --type minor", "release-major": "aegir release --type major", - "coverage": "aegir coverage" + "coverage": "nyc -s aegir test -t node && nyc report --reporter=html" }, "repository": { "type": "git", @@ -38,9 +38,11 @@ "devDependencies": { "aegir": "^20.4.1", "chai": "^4.2.0", - "dirty-chai": "^2.0.1" + "dirty-chai": "^2.0.1", + "nyc": "^15.0.0" }, "dependencies": { + "err-code": "^2.0.0", "protons": "^1.1.0" }, "contributors": [ diff --git a/src/index.js b/src/index.js index 6331bdc1..5f77a9c1 100644 --- a/src/index.js +++ b/src/index.js @@ -3,6 +3,7 @@ const protons = require('protons') const pb = protons(require('./unixfs.proto')) const unixfsData = pb.Data +const errcode = require('err-code') const types = [ 'raw', @@ -45,6 +46,84 @@ function parseArgs (args) { return args[0] } +function parseMtime (mtime) { + if (mtime == null) { + return undefined + } + + // { secs, nsecs } + if (Object.prototype.hasOwnProperty.call(mtime, 'secs')) { + mtime = { + secs: mtime.secs, + nsecs: mtime.nsecs + } + } + + // UnixFS TimeSpec + if (Object.prototype.hasOwnProperty.call(mtime, 'Seconds')) { + mtime = { + secs: mtime.Seconds, + nsecs: mtime.FractionalNanoseconds + } + } + + // process.hrtime() + if (Array.isArray(mtime)) { + mtime = { + secs: mtime[0], + nsecs: mtime[1] + } + } + + // Javascript Date + if (mtime instanceof Date) { + const ms = mtime.getTime() + const secs = Math.floor(ms / 1000) + + mtime = { + secs: secs, + nsecs: (ms - (secs * 1000)) * 1000 + } + } + + /* + TODO: https://github.com/ipfs/aegir/issues/487 + + // process.hrtime.bigint() + if (typeof mtime === 'bigint') { + const secs = mtime / BigInt(1e9) + const nsecs = mtime - (secs * BigInt(1e9)) + + mtime = { + secs: parseInt(secs), + nsecs: parseInt(nsecs) + } + } + */ + + if (!Object.prototype.hasOwnProperty.call(mtime, 'secs')) { + return undefined + } + + if (mtime.nsecs < 0 || mtime.nsecs > 999999999) { + throw errcode(new Error('mtime-nsecs must be within the range [0,999999999]'), 'ERR_INVALID_MTIME_NSECS') + } + + return mtime +} + +function parseMode (mode) { + if (mode == null) { + return undefined + } + + if (typeof mode === 'string' || mode instanceof String) { + mode = parseInt(mode, 8) + } + + return mode & 0xFFF +} + class Data { // decode from protobuf https://github.com/ipfs/specs/blob/master/UNIXFS.md static unmarshal (marshaled) { @@ -55,7 +134,7 @@ class Data { data: decoded.hasData() ? decoded.Data : undefined, blockSizes: decoded.blocksizes, mode: decoded.hasMode() ? decoded.mode : undefined, - mtime: decoded.hasMtime() ? new Date(decoded.mtime * 1000) : undefined + mtime: decoded.hasMtime() ? decoded.mtime : undefined }) } @@ -71,7 +150,7 @@ class Data { } = parseArgs(args) if (!types.includes(type)) { - throw new Error('Type: ' + type + ' is not valid') + throw errcode(new Error('Type: ' + type + ' is not valid'), 'ERR_INVALID_TYPE') } this.type = type @@ -79,10 +158,14 @@ class Data { this.hashType = hashType this.fanout = fanout this.blockSizes = blockSizes || [] - this.mtime = mtime || new Date(0) - this.mode = mode || mode === 0 ? (mode & 0xFFF) : undefined this._originalMode = mode + const parsedMode = parseMode(mode) + + if (parsedMode !== undefined) { + this.mode = parsedMode + } + if (this.mode === undefined && type === 'file') { this.mode = DEFAULT_FILE_MODE } @@ -90,6 +173,12 @@ class Data { if (this.mode === undefined && this.isDirectory()) { this.mode = DEFAULT_DIRECTORY_MODE } + + const parsedMtime = parseMtime(mtime) + + if (parsedMtime) { + this.mtime = parsedMtime + } } isDirectory () { @@ -135,7 +224,7 @@ class Data { case 'symlink': type = unixfsData.DataType.Symlink; break case 'hamt-sharded-directory': type = unixfsData.DataType.HAMTShard; break default: - throw new Error(`Unkown type: "${this.type}"`) + throw errcode(new Error('Type: ' + type + ' is not valid'), 'ERR_INVALID_TYPE') } let data = this.data @@ -152,8 +241,8 @@ class Data { let mode - if (this.mode || this.mode === 0) { - mode = (this._originalMode & 0xFFFFF000) | (this.mode & 0xFFF) + if (this.mode != null) { + mode = (this._originalMode & 0xFFFFF000) | parseMode(this.mode) if (mode === DEFAULT_FILE_MODE && this.type === 'file') { mode = undefined @@ -166,11 +255,18 @@ class Data { let mtime - if (this.mtime) { - mtime = Math.round(this.mtime.getTime() / 1000) + if (this.mtime != null) { + const parsed = parseMtime(this.mtime) + + if (parsed) { + mtime = { + Seconds: parsed.secs, + FractionalNanoseconds: parsed.nsecs + } - if (mtime === 0) { - mtime = undefined + if (mtime.FractionalNanoseconds === 0) { + delete mtime.FractionalNanoseconds + } } } diff --git a/src/unixfs.proto.js b/src/unixfs.proto.js index fcc8931d..5b6b3181 100644 --- a/src/unixfs.proto.js +++ b/src/unixfs.proto.js @@ -20,7 +20,12 @@ message Data { optional uint64 hashType = 5; optional uint64 fanout = 6; optional uint32 mode = 7; - optional int64 mtime = 8; + optional UnixTime mtime = 8; +} + +message UnixTime { + required int64 Seconds = 1; + optional fixed32 FractionalNanoseconds = 2; } message Metadata { diff --git a/test/unixfs-format.spec.js b/test/unixfs-format.spec.js index 6190fbf4..e8bf137d 100644 --- a/test/unixfs-format.spec.js +++ b/test/unixfs-format.spec.js @@ -17,6 +17,20 @@ const protons = require('protons') const unixfsData = protons(require('../src/unixfs.proto')).Data describe('unixfs-format', () => { + it('old style constructor', () => { + const buf = Buffer.from('hello world') + const entry = new UnixFS('file', buf) + + expect(entry.type).to.equal('file') + expect(entry.data).to.deep.equal(buf) + }) + + it('old style constructor with single argument', () => { + const entry = new UnixFS('file') + + expect(entry.type).to.equal('file') + }) + it('defaults to file', () => { const data = new UnixFS() expect(data.type).to.equal('file') @@ -116,6 +130,30 @@ describe('unixfs-format', () => { expect(UnixFS.unmarshal(data.marshal())).to.have.property('mode', mode) }) + it('default mode for files', () => { + const data = new UnixFS({ + type: 'file' + }) + + expect(UnixFS.unmarshal(data.marshal())).to.have.property('mode', parseInt('0644', 8)) + }) + + it('default mode for directories', () => { + const data = new UnixFS({ + type: 'directory' + }) + + expect(UnixFS.unmarshal(data.marshal())).to.have.property('mode', parseInt('0755', 8)) + }) + + it('default mode for hamt shards', () => { + const data = new UnixFS({ + type: 'hamt-sharded-directory' + }) + + expect(UnixFS.unmarshal(data.marshal())).to.have.property('mode', parseInt('0755', 8)) + }) + it('sets mode to 0', () => { const mode = 0 const data = new UnixFS({ @@ -126,8 +164,29 @@ describe('unixfs-format', () => { expect(UnixFS.unmarshal(data.marshal())).to.have.property('mode', mode) }) + it('mode as string', () => { + const data = new UnixFS({ + type: 'file', + mode: '0555' + }) + + expect(UnixFS.unmarshal(data.marshal())).to.have.property('mode', parseInt('0555', 8)) + }) + + it('mode as string set outside constructor', () => { + const data = new UnixFS({ + type: 'file' + }) + data.mode = '0555' + + expect(UnixFS.unmarshal(data.marshal())).to.have.property('mode', parseInt('0555', 8)) + }) + it('mtime', () => { - const mtime = new Date() + const mtime = { + secs: 5, + nsecs: 0 + } const data = new UnixFS({ type: 'file', mtime @@ -135,16 +194,127 @@ describe('unixfs-format', () => { const marshaled = data.marshal() const unmarshaled = UnixFS.unmarshal(marshaled) - expect(unmarshaled.mtime).to.deep.equal(new Date(Math.round(mtime.getTime() / 1000) * 1000)) + expect(unmarshaled).to.have.deep.property('mtime', mtime) + }) + + it('default mtime', () => { + const data = new UnixFS({ + type: 'file' + }) + + const marshaled = data.marshal() + const unmarshaled = UnixFS.unmarshal(marshaled) + expect(unmarshaled).to.not.have.property('mtime') }) + it('mtime as date', () => { + const mtime = { + secs: 5, + nsecs: 0 + } + const data = new UnixFS({ + type: 'file', + mtime: new Date(5000) + }) + + const marshaled = data.marshal() + const unmarshaled = UnixFS.unmarshal(marshaled) + expect(unmarshaled).to.have.deep.property('mtime', mtime) + }) + + it('mtime as hrtime', () => { + const mtime = { + secs: 5, + nsecs: 0 + } + const data = new UnixFS({ + type: 'file', + mtime: [5, 0] + }) + + const marshaled = data.marshal() + const unmarshaled = UnixFS.unmarshal(marshaled) + expect(unmarshaled).to.have.deep.property('mtime', mtime) + }) + /* + TODO: https://github.com/ipfs/aegir/issues/487 + + it('mtime as bigint', () => { + const mtime = { + secs: 5, + nsecs: 0 + } + const data = new UnixFS({ + type: 'file', + mtime: BigInt(5 * 1e9) + }) + + const marshaled = data.marshal() + const unmarshaled = UnixFS.unmarshal(marshaled) + expect(unmarshaled).to.have.deep.property('mtime', mtime) + }) + */ it('sets mtime to 0', () => { - const mtime = new Date(0) + const mtime = { + secs: 0, + nsecs: 0 + } const data = new UnixFS({ type: 'file', mtime }) - expect(UnixFS.unmarshal(data.marshal())).to.have.deep.property('mtime', new Date(Math.round(mtime.getTime() / 1000) * 1000)) + + const marshaled = data.marshal() + const unmarshaled = UnixFS.unmarshal(marshaled) + expect(unmarshaled).to.have.deep.property('mtime', mtime) + }) + + it('mtime as date set outside constructor', () => { + const mtime = { + secs: 5, + nsecs: 0 + } + const data = new UnixFS({ + type: 'file' + }) + data.mtime = new Date(5000) + + const marshaled = data.marshal() + const unmarshaled = UnixFS.unmarshal(marshaled) + expect(unmarshaled).to.have.deep.property('mtime', mtime) + }) + + it('ignores invalid mtime', () => { + const data = new UnixFS({ + type: 'file', + mtime: 'what is this' + }) + + const marshaled = data.marshal() + const unmarshaled = UnixFS.unmarshal(marshaled) + expect(unmarshaled).to.not.have.property('mtime') + }) + + it('ignores invalid mtime set outside of constructor', () => { + const entry = new UnixFS({ + type: 'file' + }) + entry.mtime = 'what is this' + + const marshaled = entry.marshal() + const unmarshaled = UnixFS.unmarshal(marshaled) + expect(unmarshaled).to.not.have.property('mtime') + }) + + it('survives null mtime', () => { + const entry = new UnixFS({ + type: 'file' + }) + entry.mtime = null + + const marshaled = entry.marshal() + const unmarshaled = UnixFS.unmarshal(marshaled) + expect(unmarshaled).to.not.have.property('mtime') }) it('does not overwrite unknown mode bits', () => { @@ -162,7 +332,16 @@ describe('unixfs-format', () => { }) // figuring out what is this metadata for https://github.com/ipfs/js-ipfs-data-importing/issues/3#issuecomment-182336526 - it.skip('metadata', () => {}) + it('metadata', () => { + const entry = new UnixFS({ + type: 'metadata' + }) + + const marshaled = entry.marshal() + const unmarshaled = UnixFS.unmarshal(marshaled) + + expect(unmarshaled).to.have.property('type', 'metadata') + }) it('symlink', () => { const data = new UnixFS({ @@ -175,15 +354,37 @@ describe('unixfs-format', () => { expect(data.blockSizes).to.deep.equal(unmarshaled.blockSizes) expect(data.fileSize()).to.deep.equal(unmarshaled.fileSize()) }) - it('wrong type', (done) => { - let data + + it('invalid type', (done) => { try { - data = new UnixFS({ + // eslint-disable-next-line no-new + new UnixFS({ type: 'bananas' }) } catch (err) { - expect(err).to.exist() - expect(data).to.not.exist() + expect(err).to.have.property('code', 'ERR_INVALID_TYPE') + done() + } + }) + + it('invalid type with old style constructor', (done) => { + try { + // eslint-disable-next-line no-new + new UnixFS('bananas') + } catch (err) { + expect(err).to.have.property('code', 'ERR_INVALID_TYPE') + done() + } + }) + + it('invalid type set outside constructor', (done) => { + const entry = new UnixFS() + entry.type = 'bananas' + + try { + entry.marshal() + } catch (err) { + expect(err).to.have.property('code', 'ERR_INVALID_TYPE') done() } })