Skip to content

Commit 7129a18

Browse files
fix putData : detect collision on specific provided version id
Issue: CLDSRV-953
1 parent d52ba09 commit 7129a18

2 files changed

Lines changed: 106 additions & 39 deletions

File tree

lib/routes/routeBackbeat.js

Lines changed: 57 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const backbeatProxy = httpProxy.createProxyServer({
1212
});
1313
const { auth, errors, errorInstances, s3middleware, s3routes, models, storage, versioning } = require('arsenal');
1414
const { decode, encode } = versioning.VersionID;
15+
const { ExternalNullVersionId } = versioning.VersioningConstants;
1516
const {
1617
VersionIdCollisionException,
1718
StaleMicroVersionIdException,
@@ -434,35 +435,38 @@ function putData(request, response, bucketInfo, objMd, log, callback) {
434435

435436
const incomingVersionIdEncoded = request.headers['x-scal-version-id'];
436437
if (incomingVersionIdEncoded !== undefined) {
437-
const incomingVersionIdDecoded =
438-
incomingVersionIdEncoded !== 'null' ? decode(incomingVersionIdEncoded) : 'null';
439-
if (incomingVersionIdDecoded instanceof Error) {
440-
log.error('crr putData: failed to decode x-scal-version-id header', {
441-
method: 'putData',
442-
error: incomingVersionIdDecoded.message,
443-
});
444-
return callback(
445-
errorInstances.BadRequest.customizeDescription('bad request: invalid x-scal-version-id header'),
446-
);
447-
}
448-
if (objMd && objMd.versionId === incomingVersionIdDecoded) {
449-
// Data already at destination for this version; return 409 with the existing
450-
// microVersionId so backbeat can decide if putMetadata is still needed.
451-
log.debug('crr putData: version already at destination', {
452-
method: 'putData',
453-
bucketName: request.bucketName,
454-
objectKey: request.objectKey,
455-
hasMicroVersionId: !!objMd.microVersionId,
456-
});
457-
request.resume();
458-
return _respondWithHeaderCrrConflict(
459-
response,
460-
log,
461-
callback,
462-
VersionIdCollisionException.name,
463-
'version id already at destination',
464-
objMd.microVersionId,
465-
);
438+
// ExternalNullVersionId means a null version, which does not need decoding
439+
if (incomingVersionIdEncoded !== ExternalNullVersionId) {
440+
const decoded = decode(incomingVersionIdEncoded);
441+
if (decoded instanceof Error) {
442+
log.error('crr putData: failed to decode x-scal-version-id header', {
443+
method: 'putData',
444+
error: decoded.message,
445+
});
446+
return callback(
447+
errorInstances.BadRequest.customizeDescription('bad request: invalid x-scal-version-id header'),
448+
);
449+
}
450+
if (objMd) {
451+
// objMd is the specific version requested by the header x-scal-version-id.
452+
// Its existence means the data is already at the destination. Return 409 with the
453+
// existing microVersionId so backbeat can decide if putMetadata is still needed.
454+
log.debug('crr putData: version already at destination', {
455+
method: 'putData',
456+
bucketName: request.bucketName,
457+
objectKey: request.objectKey,
458+
hasMicroVersionId: !!objMd.microVersionId,
459+
});
460+
request.resume();
461+
return _respondWithHeaderCrrConflict(
462+
response,
463+
log,
464+
callback,
465+
VersionIdCollisionException.name,
466+
'version id already at destination',
467+
objMd.microVersionId,
468+
);
469+
}
466470
}
467471
}
468472

@@ -2039,15 +2043,31 @@ function routeBackbeat(clientIP, request, response, log) {
20392043
if (!isObjectRequest) {
20402044
return routeNonObjectRequest(request, response, userInfo, log, next);
20412045
}
2042-
const decodedVidResult = decodeVersionId(request.query);
2043-
if (decodedVidResult instanceof Error) {
2044-
log.trace('invalid versionId query', {
2045-
versionId: request.query.versionId,
2046-
error: decodedVidResult,
2047-
});
2048-
return next(errors.InvalidArgument);
2046+
const versionIdHeader = request.headers['x-scal-version-id'];
2047+
let versionId;
2048+
if (versionIdHeader !== undefined) {
2049+
if (versionIdHeader !== ExternalNullVersionId) {
2050+
const decoded = decode(versionIdHeader);
2051+
if (decoded instanceof Error) {
2052+
return next(
2053+
errorInstances.BadRequest.customizeDescription(
2054+
'bad request: invalid x-scal-version-id header',
2055+
),
2056+
);
2057+
}
2058+
versionId = decoded;
2059+
}
2060+
} else {
2061+
const decodedVidResult = decodeVersionId(request.query);
2062+
if (decodedVidResult instanceof Error) {
2063+
log.trace('invalid versionId query', {
2064+
versionId: request.query.versionId,
2065+
error: decodedVidResult,
2066+
});
2067+
return next(errors.InvalidArgument);
2068+
}
2069+
versionId = decodedVidResult;
20492070
}
2050-
const versionId = decodedVidResult;
20512071
if (useMultipleBackend) {
20522072
if (request.resourceType === 'multiplebackendmetadata') {
20532073
return backbeatRoutes[request.method][request.resourceType](request, response, log, next);

tests/functional/backbeat/putData.js

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,17 +106,64 @@ describe('putData : VersionId collision detection', () => {
106106
const output = await putData(key, { versionId: differentVersionId });
107107
assert.ok(output.Location, 'should return a Location when data is written normally');
108108
});
109+
110+
it('should throw VersionIdCollisionException when a non-current version already exists', async () => {
111+
const key = 'putdata-non-current-collision';
112+
113+
const v1Result = await s3.send(
114+
new PutObjectCommand({
115+
Bucket: TEST_BUCKET,
116+
Key: key,
117+
Body: Buffer.from(OBJECT_BODY),
118+
ContentType: 'text/plain',
119+
}),
120+
);
121+
const v1VersionId = v1Result.VersionId;
122+
assert.ok(v1VersionId, 'first PutObject should return a VersionId');
123+
124+
const v2Result = await s3.send(
125+
new PutObjectCommand({
126+
Bucket: TEST_BUCKET,
127+
Key: key,
128+
Body: Buffer.from(OBJECT_BODY),
129+
ContentType: 'text/plain',
130+
}),
131+
);
132+
assert.ok(v2Result.VersionId, 'second PutObject should return a VersionId');
133+
134+
// putData on v1 (non-current) must detect the collision, not just compare against master
135+
try {
136+
await putData(key, { versionId: v1VersionId });
137+
assert.fail('expected VersionIdCollisionException');
138+
} catch (err) {
139+
assert.ok(
140+
err instanceof VersionIdCollisionException,
141+
`expected VersionIdCollisionException, got ${err.constructor.name}`,
142+
);
143+
assert.strictEqual(err.microVersionId, '', 'microVersionId should be empty for original write state');
144+
}
145+
});
109146
});
110147

111148
describe('putData : null-version objects (ExternalNullVersionId)', () => {
112149
// Null-version objects created before versioning was enabled use Arsenal constant ExternalNullVersionId = 'null'
113150
// getEncodedVersionId() returns 'null' as-is (no base62 encoding), and objMd.versionId is
114151
// undefined in metadata : collision detection is not possible, so putData must write normally.
115-
it('should write normally when VersionId is "null" (ExternalNullVersionId)', async () => {
152+
it('should write normally when VersionId is "null" even when a master already exists', async () => {
153+
const key = 'putdata-null-version';
154+
await s3.send(
155+
new PutObjectCommand({
156+
Bucket: TEST_BUCKET,
157+
Key: key,
158+
Body: Buffer.from(OBJECT_BODY),
159+
ContentType: 'text/plain',
160+
}),
161+
);
162+
116163
const output = await backbeatClient.send(
117164
new PutDataCommand({
118165
Bucket: TEST_BUCKET,
119-
Key: 'putdata-null-version',
166+
Key: key,
120167
ContentMD5: OBJECT_MD5_HEX,
121168
CanonicalID: CANONICAL_ID,
122169
VersioningRequired: true,

0 commit comments

Comments
 (0)