Skip to content
This repository was archived by the owner on Feb 12, 2024. It is now read-only.

Commit 008e872

Browse files
achingbrainhugomrdias
authored andcommitted
feat: support UnixFSv1.5 metadata
* refactor: pass mode and mtime in headers * refactor: refactor clunky test * feat: store mtime as timespec
1 parent 34007f4 commit 008e872

File tree

3 files changed

+148
-65
lines changed

3 files changed

+148
-65
lines changed

package.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,12 @@
2727
},
2828
"dependencies": {
2929
"@hapi/content": "^4.1.0",
30-
"it-multipart": "~0.0.2"
30+
"it-multipart": "^1.0.1"
3131
},
3232
"devDependencies": {
3333
"aegir": "^20.0.0",
3434
"chai": "^4.2.0",
35-
"ipfs-http-client": "^35.1.0",
35+
"ipfs-http-client": "^40.2.0",
3636
"request": "^2.88.0"
3737
},
3838
"engines": {

src/parser.js

+79-42
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,16 @@ const isDirectory = (mediatype) => mediatype === multipartFormdataType || mediat
1212
const parseDisposition = (disposition) => {
1313
const details = {}
1414
details.type = disposition.split(';')[0]
15-
if (details.type === 'file' || details.type === 'form-data') {
16-
const namePattern = / filename="(.[^"]+)"/
17-
const matches = disposition.match(namePattern)
18-
details.name = matches ? matches[1] : ''
19-
}
20-
21-
return details
22-
}
2315

24-
const parseHeader = (header) => {
25-
const type = Content.type(header['content-type'])
26-
const disposition = parseDisposition(header['content-disposition'])
16+
if (details.type === 'file' || details.type === 'form-data') {
17+
const filenamePattern = / filename="(.[^"]+)"/
18+
const filenameMatches = disposition.match(filenamePattern)
19+
details.filename = filenameMatches ? filenameMatches[1] : ''
2720

28-
const details = type
29-
details.name = decodeURIComponent(disposition.name)
30-
details.type = disposition.type
21+
const namePattern = / name="(.[^"]+)"/
22+
const nameMatches = disposition.match(namePattern)
23+
details.name = nameMatches ? nameMatches[1] : ''
24+
}
3125

3226
return details
3327
}
@@ -50,49 +44,92 @@ const ignore = async (stream) => {
5044
}
5145
}
5246

53-
async function * parser (stream, options) {
54-
for await (const part of multipart(stream, options.boundary)) {
55-
const partHeader = parseHeader(part.headers)
47+
async function * parseEntry (stream, options) {
48+
for await (const part of stream) {
49+
if (!part.headers['content-type']) {
50+
throw new Error('No content-type in multipart part')
51+
}
5652

57-
if (isDirectory(partHeader.mime)) {
58-
yield {
59-
type: 'directory',
60-
name: partHeader.name
61-
}
53+
const type = Content.type(part.headers['content-type'])
6254

63-
await ignore(part.body)
55+
if (type.boundary) {
56+
// recursively parse nested multiparts
57+
yield * parser(part.body, {
58+
...options,
59+
boundary: type.boundary
60+
})
6461

6562
continue
6663
}
6764

68-
if (partHeader.mime === applicationSymlink) {
69-
const target = await collect(part.body)
65+
if (!part.headers['content-disposition']) {
66+
throw new Error('No content disposition in multipart part')
67+
}
7068

71-
yield {
72-
type: 'symlink',
73-
name: partHeader.name,
74-
target: target.toString('utf8')
69+
const entry = {}
70+
71+
if (part.headers.mtime) {
72+
entry.mtime = {
73+
secs: parseInt(part.headers.mtime, 10)
7574
}
7675

77-
continue
76+
if (part.headers['mtime-nsecs']) {
77+
entry.mtime.nsecs = parseInt(part.headers['mtime-nsecs'], 10)
78+
}
7879
}
7980

80-
if (partHeader.boundary) {
81-
// recursively parse nested multiparts
82-
for await (const entry of parser(part, {
83-
...options,
84-
boundary: partHeader.boundary
85-
})) {
86-
yield entry
81+
if (part.headers.mode) {
82+
entry.mode = parseInt(part.headers.mode, 8)
83+
}
84+
85+
if (isDirectory(type.mime)) {
86+
entry.type = 'directory'
87+
} else if (type.mime === applicationSymlink) {
88+
entry.type = 'symlink'
89+
} else {
90+
entry.type = 'file'
91+
}
92+
93+
const disposition = parseDisposition(part.headers['content-disposition'])
94+
95+
entry.name = decodeURIComponent(disposition.filename)
96+
entry.body = part.body
97+
98+
yield entry
99+
}
100+
}
101+
102+
async function * parser (stream, options) {
103+
for await (const entry of parseEntry(multipart(stream, options.boundary), options)) {
104+
if (entry.type === 'directory') {
105+
yield {
106+
type: 'directory',
107+
name: entry.name,
108+
mtime: entry.mtime,
109+
mode: entry.mode
87110
}
88111

89-
continue
112+
await ignore(entry.body)
113+
}
114+
115+
if (entry.type === 'symlink') {
116+
yield {
117+
type: 'symlink',
118+
name: entry.name,
119+
target: (await collect(entry.body)).toString('utf8'),
120+
mtime: entry.mtime,
121+
mode: entry.mode
122+
}
90123
}
91124

92-
yield {
93-
type: 'file',
94-
name: partHeader.name,
95-
content: part.body
125+
if (entry.type === 'file') {
126+
yield {
127+
type: 'file',
128+
name: entry.name,
129+
content: entry.body,
130+
mtime: entry.mtime,
131+
mode: entry.mode
132+
}
96133
}
97134
}
98135
}

test/parser.spec.js

+67-21
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const os = require('os')
1414

1515
const isWindows = os.platform() === 'win32'
1616

17-
const readDir = (path, prefix, output = []) => {
17+
const readDir = (path, prefix, includeMetadata, output = []) => {
1818
const entries = fs.readdirSync(path)
1919

2020
entries.forEach(entry => {
@@ -23,21 +23,25 @@ const readDir = (path, prefix, output = []) => {
2323
const type = fs.statSync(entryPath)
2424

2525
if (type.isDirectory()) {
26-
readDir(entryPath, `${prefix}/${entry}`, output)
26+
readDir(entryPath, `${prefix}/${entry}`, includeMetadata, output)
27+
28+
output.push({
29+
path: `${prefix}/${entry}`,
30+
mtime: includeMetadata ? new Date(type.mtimeMs) : undefined,
31+
mode: includeMetadata ? type.mode : undefined
32+
})
2733
}
2834

2935
if (type.isFile()) {
3036
output.push({
3137
path: `${prefix}/${entry}`,
32-
content: fs.createReadStream(entryPath)
38+
content: fs.createReadStream(entryPath),
39+
mtime: includeMetadata ? new Date(type.mtimeMs) : undefined,
40+
mode: includeMetadata ? type.mode : undefined
3341
})
3442
}
3543
})
3644

37-
output.push({
38-
path: prefix
39-
})
40-
4145
return output
4246
}
4347

@@ -75,6 +79,8 @@ describe('parser', () => {
7579
describe('single file', () => {
7680
const filePath = path.resolve(__dirname, 'fixtures/config')
7781
const fileContent = fs.readFileSync(filePath, 'utf8')
82+
const fileMtime = parseInt(Date.now() / 1000)
83+
const fileMode = parseInt('0777', 8)
7884

7985
before(() => {
8086
handler = async (req) => {
@@ -84,7 +90,7 @@ describe('parser', () => {
8490

8591
for await (const entry of parser(req)) {
8692
if (entry.type === 'file') {
87-
const file = { name: entry.name, content: '' }
93+
const file = { ...entry, content: '' }
8894

8995
for await (const data of entry.content) {
9096
file.content += data.toString()
@@ -95,13 +101,12 @@ describe('parser', () => {
95101
}
96102

97103
expect(files.length).to.equal(1)
98-
expect(files[0].name).to.equal('config')
99-
expect(files[0].content).to.equal(fileContent)
104+
expect(JSON.parse(files[0].content)).to.deep.equal(JSON.parse(fileContent))
100105
}
101106
})
102107

103108
it('parses ctl.config.replace correctly', async () => {
104-
await ctl.config.replace(filePath)
109+
await ctl.config.replace(JSON.parse(fileContent))
105110
})
106111

107112
it('parses regular multipart requests correctly', (done) => {
@@ -111,6 +116,22 @@ describe('parser', () => {
111116

112117
request.post({ url: `http://localhost:${PORT}`, formData: formData }, (err) => done(err))
113118
})
119+
120+
it('parses multipart requests with metadata correctly', (done) => {
121+
const formData = {
122+
file: {
123+
value: fileContent,
124+
options: {
125+
header: {
126+
mtime: fileMtime,
127+
mode: fileMode
128+
}
129+
}
130+
}
131+
}
132+
133+
request.post({ url: `http://localhost:${PORT}`, formData }, (err) => done(err))
134+
})
114135
})
115136

116137
describe('directory', () => {
@@ -123,15 +144,15 @@ describe('parser', () => {
123144
expect(req.headers['content-type']).to.be.a('string')
124145

125146
for await (const entry of parser(req)) {
126-
if (entry.type === 'file') {
127-
const file = { name: entry.name, content: '' }
147+
const file = { ...entry, content: '' }
128148

149+
if (entry.content) {
129150
for await (const data of entry.content) {
130151
file.content += data.toString()
131152
}
132-
133-
files.push(file)
134153
}
154+
155+
files.push(file)
135156
}
136157
}
137158
})
@@ -149,12 +170,37 @@ describe('parser', () => {
149170
return
150171
}
151172

152-
expect(files.length).to.equal(5)
153-
expect(files[0].name).to.equal('fixtures/config')
154-
expect(files[1].name).to.equal('fixtures/folderlink/deepfile')
155-
expect(files[2].name).to.equal('fixtures/link')
156-
expect(files[3].name).to.equal('fixtures/otherfile')
157-
expect(files[4].name).to.equal('fixtures/subfolder/deepfile')
173+
expect(files).to.have.lengthOf(contents.length)
174+
175+
for (let i = 0; i < contents.length; i++) {
176+
expect(files[i].name).to.equal(contents[i].path)
177+
expect(files[i].mode).to.be.undefined
178+
expect(files[i].mtime).to.be.undefined
179+
}
180+
})
181+
182+
it('parses ctl.add with metadata correctly', async () => {
183+
const contents = readDir(dirPath, 'fixtures', true)
184+
185+
await ctl.add(contents, { recursive: true, followSymlinks: false })
186+
187+
if (isWindows) {
188+
return
189+
}
190+
191+
expect(files).to.have.lengthOf(contents.length)
192+
193+
for (let i = 0; i < contents.length; i++) {
194+
const msecs = contents[i].mtime.getTime()
195+
const secs = Math.floor(msecs / 1000)
196+
197+
expect(files[i].name).to.equal(contents[i].path)
198+
expect(files[i].mode).to.equal(contents[i].mode)
199+
expect(files[i].mtime).to.deep.equal({
200+
secs,
201+
nsecs: (msecs - (secs * 1000)) * 1000
202+
})
203+
}
158204
})
159205
})
160206

0 commit comments

Comments
 (0)