Skip to content

Commit 85f6f78

Browse files
committed
CLDSRV-863: add ChecksumTransform to calculate and verify stream checksums
1 parent a8a6cec commit 85f6f78

File tree

2 files changed

+274
-0
lines changed

2 files changed

+274
-0
lines changed
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
const { errors } = require('arsenal');
2+
const { algorithms, ChecksumError } = require('../../api/apiUtils/integrity/validateChecksums');
3+
const { Transform } = require('stream');
4+
5+
class ChecksumTransform extends Transform {
6+
constructor(algoName, expectedDigest, isTrailer, log) {
7+
super({});
8+
this.log = log;
9+
this.algoName = algoName;
10+
this.algo = algorithms[algoName];
11+
this.hash = this.algo.createHash();
12+
this.digest = undefined;
13+
this.expectedDigest = expectedDigest;
14+
this.isTrailer = isTrailer;
15+
this.trailerChecksumName = undefined;
16+
this.trailerChecksumValue = undefined;
17+
}
18+
19+
setExpectedChecksum(name, value) {
20+
this.trailerChecksumName = name;
21+
this.trailerChecksumValue = value;
22+
}
23+
24+
validateChecksum() {
25+
if (this.isTrailer) {
26+
// x-amz-trailer in headers but no trailer in body.
27+
if (this.trailerChecksumValue === undefined) {
28+
return {
29+
error: ChecksumError.TrailerMissing,
30+
details: { expectedTrailer: `x-amz-checksum-${this.algoName}` },
31+
};
32+
}
33+
34+
if (this.trailerChecksumName !== `x-amz-checksum-${this.algoName}`) {
35+
return { error: ChecksumError.TrailerAlgoMismatch, details: { algorithm: this.algoName } };
36+
}
37+
38+
const expected = this.trailerChecksumValue;
39+
if (!this.algo.isValidDigest(expected)) {
40+
return {
41+
error: ChecksumError.TrailerChecksumMalformed,
42+
details: { algorithm: this.algoName, expected },
43+
};
44+
}
45+
46+
if (this.digest !== this.trailerChecksumValue) {
47+
return {
48+
error: ChecksumError.XAmzMismatch,
49+
details: { algorithm: this.algoName, calculated: this.digest, expected },
50+
};
51+
}
52+
53+
return null;
54+
}
55+
56+
if (this.trailerChecksumValue) {
57+
// Trailer found in the body but no x-amz-trailer in the headers.
58+
return {
59+
error: ChecksumError.TrailerUnexpected,
60+
details: { name: this.trailerChecksumName, val: this.trailerChecksumValue },
61+
};
62+
}
63+
64+
if (this.expectedDigest) {
65+
if (this.digest !== this.expectedDigest) {
66+
return {
67+
error: ChecksumError.XAmzMismatch,
68+
details: { algorithm: this.algoName, calculated: this.digest, expected: this.expectedDigest },
69+
};
70+
}
71+
}
72+
73+
return null;
74+
}
75+
76+
_flush(callback) {
77+
Promise.resolve(this.algo.digestFromHash(this.hash))
78+
.then(digest => { this.digest = digest; })
79+
.then(() => callback(), err => {
80+
this.log.error('failed to compute checksum digest', { error: err, algorithm: this.algoName });
81+
callback(errors.InternalError);
82+
});
83+
}
84+
85+
_transform(chunk, encoding, callback) {
86+
const input = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
87+
this.hash.update(input, encoding);
88+
callback(null, input);
89+
}
90+
}
91+
92+
module.exports = ChecksumTransform;
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
const assert = require('assert');
2+
const { errors } = require('arsenal');
3+
4+
const { algorithms, ChecksumError } = require('../../../lib/api/apiUtils/integrity/validateChecksums');
5+
const ChecksumTransform = require('../../../lib/auth/streamingV4/ChecksumTransform');
6+
const { DummyRequestLogger } = require('../helpers');
7+
8+
const log = new DummyRequestLogger();
9+
const testData = Buffer.from('hello world');
10+
const algos = ['crc32', 'crc32c', 'sha1', 'sha256', 'crc64nvme'];
11+
12+
// Helper: pipe chunks into a ChecksumTransform, collect output, resolve on finish
13+
function runTransform(stream, chunks) {
14+
return new Promise((resolve, reject) => {
15+
const output = [];
16+
stream.on('data', chunk => output.push(chunk));
17+
stream.on('end', () => resolve(Buffer.concat(output)));
18+
stream.on('error', reject);
19+
for (const chunk of chunks) {
20+
stream.write(chunk);
21+
}
22+
stream.end();
23+
});
24+
}
25+
26+
// Helper: pipe data through and wait for finish without collecting output
27+
function drainTransform(stream, chunks) {
28+
return new Promise((resolve, reject) => {
29+
stream.resume();
30+
stream.on('finish', resolve);
31+
stream.on('error', reject);
32+
for (const chunk of chunks) {
33+
stream.write(chunk);
34+
}
35+
stream.end();
36+
});
37+
}
38+
39+
describe('ChecksumTransform basic behaviour', () => {
40+
let expectedDigests;
41+
42+
before(async () => {
43+
expectedDigests = {};
44+
for (const algo of algos) {
45+
expectedDigests[algo] = await Promise.resolve(algorithms[algo].digest(testData));
46+
}
47+
});
48+
49+
for (const algo of algos) {
50+
it(`[${algo}] passes data through unchanged`, async () => {
51+
const stream = new ChecksumTransform(algo, undefined, false, log);
52+
const output = await runTransform(stream, [testData]);
53+
assert.deepStrictEqual(output, testData);
54+
});
55+
56+
it(`[${algo}] computes digest correctly after stream ends`, async () => {
57+
const stream = new ChecksumTransform(algo, undefined, false, log);
58+
await drainTransform(stream, [testData]);
59+
assert.strictEqual(stream.digest, expectedDigests[algo]);
60+
});
61+
62+
it(`[${algo}] handles multi-chunk input: digest matches single-chunk equivalent`, async () => {
63+
const half = Math.floor(testData.length / 2);
64+
const stream = new ChecksumTransform(algo, undefined, false, log);
65+
await drainTransform(stream, [testData.subarray(0, half), testData.subarray(half)]);
66+
assert.strictEqual(stream.digest, expectedDigests[algo]);
67+
});
68+
69+
it(`[${algo}] handles Buffer and string chunks equally`, async () => {
70+
const streamBuf = new ChecksumTransform(algo, undefined, false, log);
71+
const streamStr = new ChecksumTransform(algo, undefined, false, log);
72+
await drainTransform(streamBuf, [testData]);
73+
await drainTransform(streamStr, [testData.toString()]);
74+
assert.strictEqual(streamBuf.digest, streamStr.digest);
75+
});
76+
}
77+
78+
it('emits error via stream error event if digestFromHash fails', done => {
79+
const stream = new ChecksumTransform('crc32', undefined, false, log);
80+
// Replace digestFromHash to return a rejected Promise
81+
stream.algo = Object.assign({}, stream.algo, {
82+
digestFromHash: () => Promise.reject(new Error('simulated digest failure')),
83+
});
84+
stream.on('error', err => {
85+
assert.deepStrictEqual(err, errors.InternalError);
86+
done();
87+
});
88+
stream.write(testData);
89+
stream.end();
90+
stream.resume();
91+
});
92+
});
93+
94+
describe('ChecksumTransform validateChecksum — non-trailer mode (isTrailer=false)', () => {
95+
let crc32Digest;
96+
97+
before(async () => {
98+
crc32Digest = await Promise.resolve(algorithms.crc32.digest(testData));
99+
});
100+
101+
it('returns null when no expectedDigest and no trailer received', async () => {
102+
const stream = new ChecksumTransform('crc32', undefined, false, log);
103+
await drainTransform(stream, [testData]);
104+
assert.strictEqual(stream.validateChecksum(), null);
105+
});
106+
107+
it('returns null when expectedDigest matches computed digest', async () => {
108+
const stream = new ChecksumTransform('crc32', crc32Digest, false, log);
109+
await drainTransform(stream, [testData]);
110+
assert.strictEqual(stream.validateChecksum(), null);
111+
});
112+
113+
it('returns XAmzMismatch when expectedDigest does not match computed digest', async () => {
114+
const stream = new ChecksumTransform('crc32', 'AAAAAA==', false, log);
115+
await drainTransform(stream, [testData]);
116+
const result = stream.validateChecksum();
117+
assert.strictEqual(result.error, ChecksumError.XAmzMismatch);
118+
assert.strictEqual(result.details.algorithm, 'crc32');
119+
assert.strictEqual(result.details.calculated, crc32Digest);
120+
assert.strictEqual(result.details.expected, 'AAAAAA==');
121+
});
122+
123+
it('returns TrailerUnexpected when setExpectedChecksum was called but isTrailer=false', async () => {
124+
const stream = new ChecksumTransform('crc32', undefined, false, log);
125+
stream.setExpectedChecksum('x-amz-checksum-crc32', crc32Digest);
126+
await drainTransform(stream, [testData]);
127+
const result = stream.validateChecksum();
128+
assert.strictEqual(result.error, ChecksumError.TrailerUnexpected);
129+
});
130+
});
131+
132+
describe('ChecksumTransform validateChecksum — trailer mode (isTrailer=true)', () => {
133+
let crc32Digest;
134+
135+
before(async () => {
136+
crc32Digest = await Promise.resolve(algorithms.crc32.digest(testData));
137+
});
138+
139+
it('returns TrailerMissing when setExpectedChecksum was never called', async () => {
140+
const stream = new ChecksumTransform('crc32', undefined, true, log);
141+
await drainTransform(stream, [testData]);
142+
const result = stream.validateChecksum();
143+
assert.strictEqual(result.error, ChecksumError.TrailerMissing);
144+
assert.strictEqual(result.details.expectedTrailer, 'x-amz-checksum-crc32');
145+
});
146+
147+
it('returns TrailerAlgoMismatch when trailer name does not match algo', async () => {
148+
const stream = new ChecksumTransform('crc32', undefined, true, log);
149+
stream.setExpectedChecksum('x-amz-checksum-sha256', 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=');
150+
await drainTransform(stream, [testData]);
151+
const result = stream.validateChecksum();
152+
assert.strictEqual(result.error, ChecksumError.TrailerAlgoMismatch);
153+
assert.strictEqual(result.details.algorithm, 'crc32');
154+
});
155+
156+
it('returns TrailerChecksumMalformed when trailer value is not a valid digest for the algo', async () => {
157+
const stream = new ChecksumTransform('crc32', undefined, true, log);
158+
stream.setExpectedChecksum('x-amz-checksum-crc32', 'not-valid!');
159+
await drainTransform(stream, [testData]);
160+
const result = stream.validateChecksum();
161+
assert.strictEqual(result.error, ChecksumError.TrailerChecksumMalformed);
162+
assert.strictEqual(result.details.algorithm, 'crc32');
163+
});
164+
165+
it('returns XAmzMismatch when trailer value is valid but does not match computed digest', async () => {
166+
const stream = new ChecksumTransform('crc32', undefined, true, log);
167+
stream.setExpectedChecksum('x-amz-checksum-crc32', 'AAAAAA==');
168+
await drainTransform(stream, [testData]);
169+
const result = stream.validateChecksum();
170+
assert.strictEqual(result.error, ChecksumError.XAmzMismatch);
171+
assert.strictEqual(result.details.algorithm, 'crc32');
172+
assert.strictEqual(result.details.calculated, crc32Digest);
173+
assert.strictEqual(result.details.expected, 'AAAAAA==');
174+
});
175+
176+
it('returns null when trailer name and value match computed digest', async () => {
177+
const stream = new ChecksumTransform('crc32', undefined, true, log);
178+
stream.setExpectedChecksum('x-amz-checksum-crc32', crc32Digest);
179+
await drainTransform(stream, [testData]);
180+
assert.strictEqual(stream.validateChecksum(), null);
181+
});
182+
});

0 commit comments

Comments
 (0)