Skip to content

Commit 62d7219

Browse files
committed
fix: preserve last byte of file entries
The parser was reading `size - 1` bytes of file content and skipping the next byte, assuming it was a trailing NUL. In USTAR the header's `size` field is the exact file length in bytes; NULs between the content and the next 512-byte record boundary are padding, and `roundNextSector` already handles them. Read `size` bytes as-is. This surfaced in cdnjs — see cdnjs/cdnjs#14305 — where font-awesome .woff2 files were served 1 byte short and failed OTS parsing in Chrome. The regression test extracts fa-regular-400.woff2 from a fixture built from @fortawesome/fontawesome-free@7.3.0 and asserts a byte-exact SHA-256 match against upstream. Fixtures hi-sven-1.0.0.tar.expected and random.tar.expected are regenerated: both contained files ending in a trailing newline that the old parser silently ate.
1 parent c03e4bc commit 62d7219

5 files changed

Lines changed: 85 additions & 7 deletions

File tree

‎fixtures/fa-regular-400.tar‎

21 KB
Binary file not shown.
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
"package/package.json"
2-
"{\n \"name\": \"hi-sven\",\n \"version\": \"1.0.0\",\n \"description\": \"Say hi to Sven\",\n \"main\": \"index.js\",\n \"scripts\": {\n \"test\": \"echo \\\"Error: no test specified\\\" && exit 1\"\n },\n \"author\": \"Sven Sauleau <sven@sauleau.com>\",\n \"license\": \"MIT\"\n}"
2+
"{\n \"name\": \"hi-sven\",\n \"version\": \"1.0.0\",\n \"description\": \"Say hi to Sven\",\n \"main\": \"index.js\",\n \"scripts\": {\n \"test\": \"echo \\\"Error: no test specified\\\" && exit 1\"\n },\n \"author\": \"Sven Sauleau <sven@sauleau.com>\",\n \"license\": \"MIT\"\n}\n"
33

44
"package/index.js"
5-
"console.log(\"Hi Sven\");"
5+
"console.log(\"Hi Sven\");\n"
66

77
"package/README.md"
8-
"# hi-sven"
8+
"# hi-sven\n"
99

‎fixtures/random.tar.expected‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"random"
2-
"8387031fe5d75f3ce7260c9ccdd0feaf3501b1eab07d55a2c29bd764f6abd5dff27c7df950431dcbc6381559e9053090417a650e5f416f9ef96c593798c455a48004a39cb9fdc2e2eeefe60b20fbcd0f280ad95814d3d614a8c27fc1cbb7e130d94afcb27a8fe2e9785ab1361a0caa6a0ed81095634f15d0ae3f66444300d920c585509bf4ed0326f80972642d49f99ecf1e774a3b9849cc5cfd7bedd1ca142e7d95c5e67401807ada20f8ac2eff71ff136af2dcc3573ec7021e646d0752299d436804d76edc6cae01754188b1e0ada55bba6dd00c22336b156552680150720585976a154fcbbcb5f2253c30a84c96f3384b259a4d54a16f1e0f6ab3c6bf105ce8a2ca1f33c37ea89271b85623c679b3867e5a4c2279ced7d567bdfbeebfedca586314f4dd9685729f8e0d0e77696ebff3c34f3f1ce12cddd77421b53cc24bf24c07682f4c70877cc7aeeabaf2ad9f5f269c2522b8cf9c7dbd7ce7d8a23460ba918c9a562c04c192c19f44ad8a9867b961986dbb01e397c62c64e87293376a5a099f57978c4fced67a8bd9f80a5f5f565bcf57bf30479301528d5317c1f65116bb94af78cf21f59016b64263e8a42f84e16c6fccf3d5ca068076a9fb1cf105855936b4749e737a50e481efa57bdc19d6c94cedc20c4381b2093e1f618a81e810a6ba63c91b8c01c5f494d0c8b008a2f0b3c662e713ec2b5b8ec42b1c2d08ef2a3b6f2a388573676180d06810c7e8b827bc9ccdd0d89b17edb0388c0bb3176e377cd73c447c349788e744913af7bc67850486021326f71eff263442f1f04f13475d380db5b48a23b7dacd0c955abac1c58b82318f63437658"
2+
"8387031fe5d75f3ce7260c9ccdd0feaf3501b1eab07d55a2c29bd764f6abd5dff27c7df950431dcbc6381559e9053090417a650e5f416f9ef96c593798c455a48004a39cb9fdc2e2eeefe60b20fbcd0f280ad95814d3d614a8c27fc1cbb7e130d94afcb27a8fe2e9785ab1361a0caa6a0ed81095634f15d0ae3f66444300d920c585509bf4ed0326f80972642d49f99ecf1e774a3b9849cc5cfd7bedd1ca142e7d95c5e67401807ada20f8ac2eff71ff136af2dcc3573ec7021e646d0752299d436804d76edc6cae01754188b1e0ada55bba6dd00c22336b156552680150720585976a154fcbbcb5f2253c30a84c96f3384b259a4d54a16f1e0f6ab3c6bf105ce8a2ca1f33c37ea89271b85623c679b3867e5a4c2279ced7d567bdfbeebfedca586314f4dd9685729f8e0d0e77696ebff3c34f3f1ce12cddd77421b53cc24bf24c07682f4c70877cc7aeeabaf2ad9f5f269c2522b8cf9c7dbd7ce7d8a23460ba918c9a562c04c192c19f44ad8a9867b961986dbb01e397c62c64e87293376a5a099f57978c4fced67a8bd9f80a5f5f565bcf57bf30479301528d5317c1f65116bb94af78cf21f59016b64263e8a42f84e16c6fccf3d5ca068076a9fb1cf105855936b4749e737a50e481efa57bdc19d6c94cedc20c4381b2093e1f618a81e810a6ba63c91b8c01c5f494d0c8b008a2f0b3c662e713ec2b5b8ec42b1c2d08ef2a3b6f2a388573676180d06810c7e8b827bc9ccdd0d89b17edb0388c0bb3176e377cd73c447c349788e744913af7bc67850486021326f71eff263442f1f04f13475d380db5b48a23b7dacd0c955abac1c58b82318f63437658\n"
33

‎index.js‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,9 @@ class USTarParser {
9696
// Skip any padding after the header
9797
offset = 512;
9898

99-
// The last byte is a NUL byte, we ignore it from the content.
100-
const content = eat(size - 1);
101-
offset += 1;
99+
// `size` is the exact file length; the 512-byte record padding after
100+
// the content is handled by roundNextSector below.
101+
const content = eat(size);
102102

103103
if (typeflag === 0 || typeflag === 48) {
104104
// notify that we have a file

‎test.js‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const { StreamingUSTarParser, USTarParser, str, assertUsTar, roundNextSector, isTarSector } = require("./index.js");
22
const { join } = require("path");
3+
const { createHash } = require("crypto");
34
const { assert } = require("chai");
45
const { readFileSync, writeFileSync, existsSync } = require("fs");
56

@@ -69,6 +70,83 @@ describe('fixtures', () => {
6970
t("random.tar");
7071
});
7172

73+
describe('preserves last byte of file content', () => {
74+
// Build a minimal USTAR archive containing a single file. Field offsets and
75+
// the space-then-sum checksum are per the USTAR spec (https://wiki.osdev.org/USTAR).
76+
function buildUstarArchive(name, content) {
77+
const HEADER_SIZE = 512;
78+
const header = Buffer.alloc(HEADER_SIZE);
79+
Buffer.from(name).copy(header, 0);
80+
Buffer.from("0000644\0").copy(header, 100);
81+
Buffer.from("0000000\0").copy(header, 108);
82+
Buffer.from("0000000\0").copy(header, 116);
83+
Buffer.from(content.length.toString(8).padStart(11, "0") + "\0").copy(header, 124);
84+
Buffer.from("00000000000\0").copy(header, 136);
85+
header[156] = 0x30;
86+
Buffer.from("ustar\0").copy(header, 257);
87+
Buffer.from("00").copy(header, 263);
88+
89+
header.fill(0x20, 148, 148 + 8);
90+
let sum = 0;
91+
for (let i = 0; i < HEADER_SIZE; i++) sum += header[i];
92+
Buffer.from(sum.toString(8).padStart(6, "0") + "\0 ").copy(header, 148);
93+
94+
const padded = Buffer.alloc(Math.ceil(content.length / HEADER_SIZE) * HEADER_SIZE);
95+
Buffer.from(content).copy(padded, 0);
96+
return new Uint8Array(Buffer.concat([header, padded, Buffer.alloc(HEADER_SIZE * 2)]));
97+
}
98+
99+
async function extract(tar) {
100+
const parser = new USTarParser();
101+
let received = null;
102+
parser.on("file", (name, content) => { received = { name, content }; });
103+
await parser.parse(tar);
104+
return received;
105+
}
106+
107+
it("keeps the last byte when it is a printable non-newline character", async () => {
108+
const content = Buffer.from("hello, world}");
109+
const got = await extract(buildUstarArchive("file.txt", content));
110+
111+
assert.equal(got.content.length, content.length);
112+
assert.deepEqual([...got.content], [...content]);
113+
});
114+
115+
it("keeps the last byte when it is 0x00", async () => {
116+
const content = Buffer.from([0x01, 0x02, 0x00]);
117+
const got = await extract(buildUstarArchive("bin", content));
118+
119+
assert.equal(got.content.length, content.length);
120+
assert.deepEqual([...got.content], [...content]);
121+
});
122+
123+
it("keeps the last byte when it is 0x0a", async () => {
124+
const content = Buffer.from("line one\nline two\n");
125+
const got = await extract(buildUstarArchive("multi.txt", content));
126+
127+
assert.equal(got.content.length, content.length);
128+
assert.deepEqual([...got.content], [...content]);
129+
});
130+
131+
// Regression for https://github.com/cdnjs/cdnjs/issues/14305 — real
132+
// fa-regular-400.woff2 extracted from @fortawesome/fontawesome-free@7.3.0.
133+
it("extracts a real font-awesome .woff2 byte-exact against upstream", async () => {
134+
const UPSTREAM_SIZE = 19480;
135+
const UPSTREAM_SHA256 = "f4f5cc8867d30647f0ad918c01599fa8aa9657c39eb55f0fc610120986544385";
136+
137+
const tar = new Uint8Array(readFileSync(join("fixtures", "fa-regular-400.tar")));
138+
const got = await extract(tar);
139+
140+
assert.equal(got.name, "package/webfonts/fa-regular-400.woff2");
141+
assert.equal(got.content.length, UPSTREAM_SIZE);
142+
assert.equal(
143+
createHash("sha256").update(Buffer.from(got.content)).digest("hex"),
144+
UPSTREAM_SHA256
145+
);
146+
assert.deepEqual([...got.content.slice(0, 4)], [0x77, 0x4f, 0x46, 0x32]);
147+
});
148+
});
149+
72150
describe('streaming', () => {
73151
it("should dump in streaming - files between chunks", async () => {
74152
const tar = new Uint8Array(readFileSync("./fixtures/hi-sven-1.0.0.tar", null));

0 commit comments

Comments
 (0)