Harden batch-wide recovery identity claims

Scope remote code ownership to normalized hoster and account identities while retaining title-only recovery serialization and canonical Unicode title matching.

Mark post-upload ambiguity and duplicate identities as uncertain so retries, account fallback, and later same-title jobs fail closed instead of reporting unsafe success.

Acquire recovery title leases before hoster and global semaphores, revalidate failed-account overrides before upload, and clear claim state at batch boundaries.

Add deterministic concurrent coverage for same-code rejection, distinct-code parallel success, uncertainty propagation, semaphore fairness, account isolation, Unicode equivalence, and registry lifetime.
This commit is contained in:
Sucukdeluxe
2026-08-13 23:09:03 +02:00
parent dd14381e43
commit c78160a521
7 changed files with 640 additions and 141 deletions
+122 -34
View File
@@ -575,7 +575,40 @@ async function _fetchByseFileList(apiKey, signal, phase = 'recovery-poll') {
}
function _normalizeFileTitle(s) {
return String(s || '').toLowerCase().replace(/\.[a-z0-9]+$/i, '').replace(/[^a-z0-9]+/g, '');
return String(s || '')
.normalize('NFKD')
.toLowerCase()
.replace(/\.[\p{Letter}\p{Number}]+$/u, '')
.replace(/\p{Mark}+/gu, '')
.replace(/[^\p{Letter}\p{Number}]+/gu, '');
}
function _normalizeRecoveryHoster(value) {
return String(value || '').normalize('NFKC').trim().toLowerCase();
}
function _normalizeRecoveryAccount(value) {
return String(value || '').normalize('NFKC').trim();
}
function _createRecoveryUncertainError() {
const error = new Error('Upload-Ergebnis für diesen Titel ist wegen eines möglichen Remote-Commits unsicher');
error.remoteCommitUncertain = true;
error.hosterTransient = true;
return error;
}
function _markRecoveryUncertain(recoveryClaim, error) {
if (!recoveryClaim) return error;
if (typeof recoveryClaim.markUncertain === 'function') {
return recoveryClaim.markUncertain(error);
}
const uncertainError = error && typeof error === 'object'
? error
: _createRecoveryUncertainError();
uncertainError.remoteCommitUncertain = true;
uncertainError.hosterTransient = true;
return uncertainError;
}
function _createAbortError() {
@@ -607,40 +640,69 @@ function _waitForRecoveryTurn(predecessor, signal) {
}
function createRecoveryClaimRegistry() {
const scopes = new Map();
const accounts = new Map();
let nextClaimId = 1;
return {
forUpload(hosterName, apiKey, fileName) {
const identity = crypto.createHash('sha256')
.update(`${String(hosterName || '').toLowerCase()}\0${String(apiKey || '')}\0${_normalizeFileTitle(fileName)}`)
const accountIdentity = crypto.createHash('sha256')
.update(`${_normalizeRecoveryHoster(hosterName)}\0${_normalizeRecoveryAccount(apiKey)}`)
.digest('hex');
let codes = scopes.get(identity);
if (!codes) {
codes = {
values: new Set(),
tail: Promise.resolve()
let account = accounts.get(accountIdentity);
if (!account) {
account = {
codes: new Map(),
titles: new Map()
};
scopes.set(identity, codes);
accounts.set(accountIdentity, account);
}
const titleIdentity = _normalizeFileTitle(fileName);
let title = account.titles.get(titleIdentity);
if (!title) {
title = {
tail: Promise.resolve(),
uncertain: false
};
account.titles.set(titleIdentity, title);
}
const claimId = nextClaimId++;
return {
has(code) {
return codes.values.has(String(code || '').trim());
return account.codes.has(String(code || '').trim());
},
reserve(code) {
const normalized = String(code || '').trim();
if (!normalized || codes.values.has(normalized)) return false;
codes.values.add(normalized);
if (!normalized) return false;
if (account.codes.has(normalized)) {
return account.codes.get(normalized) === claimId;
}
account.codes.set(normalized, claimId);
return true;
},
markUncertain(error) {
title.uncertain = true;
const uncertainError = error && typeof error === 'object'
? error
: _createRecoveryUncertainError();
uncertainError.remoteCommitUncertain = true;
uncertainError.hosterTransient = true;
return uncertainError;
},
isUncertain() {
return title.uncertain;
},
async runExclusive(operation, signal) {
const predecessor = codes.tail;
const predecessor = title.tail;
let release;
const current = new Promise(resolve => {
release = resolve;
});
codes.tail = predecessor.then(() => current);
title.tail = predecessor.then(() => current, () => current);
try {
await _waitForRecoveryTurn(predecessor, signal);
return await operation();
if (title.uncertain) throw _createRecoveryUncertainError();
const result = await operation();
if (title.uncertain) throw _createRecoveryUncertainError();
return result;
} finally {
release();
}
@@ -648,7 +710,7 @@ function createRecoveryClaimRegistry() {
};
},
clear() {
scopes.clear();
accounts.clear();
}
};
}
@@ -805,23 +867,28 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
bodyTimeout: UPLOAD_TIMEOUT
});
} catch (err) {
if (signal && signal.aborted) throw err;
throw createTransportError(`Upload zu ${hosterName} konnte nicht übertragen werden`, {
const error = signal && signal.aborted ? err : createTransportError(`Upload zu ${hosterName} konnte nicht übertragen werden`, {
phase: 'upload-request',
endpoint: targetUrl,
retryable: true,
transientNetwork: true
});
throw _markRecoveryUncertain(opts && opts.recoveryClaim, error);
}
const { body, statusCode, headers } = uploadResponse;
const rawBody = await body.text();
let rawBody;
try {
rawBody = await body.text();
} catch (err) {
throw _markRecoveryUncertain(opts && opts.recoveryClaim, err);
}
let payload = null;
try {
payload = rawBody ? JSON.parse(rawBody) : {};
} catch {
throw createTransportError(`Upload-Antwort von ${hosterName} war kein JSON`, {
throw _markRecoveryUncertain(opts && opts.recoveryClaim, createTransportError(`Upload-Antwort von ${hosterName} war kein JSON`, {
phase: 'upload-response',
endpoint: targetUrl,
httpStatus: statusCode,
@@ -829,7 +896,7 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
body: rawBody,
retryable: statusCode >= 500,
transientNetwork: statusCode >= 500
});
}));
}
// Normalize valid-but-not-object JSON (JSON.parse('null') → null;
// JSON.parse('"foo"') → string; JSON.parse('[1]') → array). Without this
@@ -843,7 +910,7 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
}
if (statusCode < 200 || statusCode >= 300) {
throw createTransportError(`Upload zu ${hosterName} fehlgeschlagen`, {
const error = createTransportError(`Upload zu ${hosterName} fehlgeschlagen`, {
phase: 'upload-response',
endpoint: targetUrl,
httpStatus: statusCode,
@@ -852,10 +919,13 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
retryable: statusCode === 429 || statusCode >= 500,
transientNetwork: statusCode >= 500
});
throw statusCode >= 500
? _markRecoveryUncertain(opts && opts.recoveryClaim, error)
: error;
}
if (payload.status && [401, 403, 429, 500].includes(payload.status)) {
throw createTransportError(`Upload zu ${hosterName} wurde abgelehnt`, {
const error = createTransportError(`Upload zu ${hosterName} wurde abgelehnt`, {
phase: 'upload-response',
endpoint: targetUrl,
httpStatus: Number(payload.status),
@@ -864,6 +934,9 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
retryable: Number(payload.status) === 429 || Number(payload.status) >= 500,
transientNetwork: Number(payload.status) >= 500
});
throw Number(payload.status) >= 500
? _markRecoveryUncertain(opts && opts.recoveryClaim, error)
: error;
}
let result = null;
@@ -885,7 +958,7 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
if (result && (result.file_code || result.download_url || result.embed_url)) {
if (result.file_code && opts && opts.recoveryClaim && typeof opts.recoveryClaim.reserve === 'function') {
if (!opts.recoveryClaim.reserve(result.file_code)) {
throw createTransportError(`Upload zu ${hosterName} lieferte eine bereits zugeordnete file_code-Antwort`, {
const error = createTransportError(`Upload zu ${hosterName} lieferte eine bereits zugeordnete file_code-Antwort`, {
phase: 'upload-result',
endpoint: targetUrl,
httpStatus: statusCode,
@@ -894,6 +967,8 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
retryable: true,
hosterTransient: true
});
error.remoteIdentityClaimed = true;
throw _markRecoveryUncertain(opts.recoveryClaim, error);
}
}
return result;
@@ -921,8 +996,12 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
// even after our uploader gave up.
if (hosterName === 'byse.sx' && byseBaseline && !explicitlyRejected) {
const fileName = path.basename(filePath);
const polled = await _resolveByseUploadByName(apiKey, fileName, byseBaseline, signal, opts && opts.recoveryClaim);
if (polled) return polled;
try {
const polled = await _resolveByseUploadByName(apiKey, fileName, byseBaseline, signal, opts && opts.recoveryClaim);
if (polled) return polled;
} catch (err) {
throw _markRecoveryUncertain(opts && opts.recoveryClaim, err);
}
}
// Doodstream: the doodapi upload POST returned no filecode (the same backend
@@ -930,21 +1009,29 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
// the file did register, claim its code instead of failing the upload.
if (hosterName === 'doodstream.com' && doodBaseline && !explicitlyRejected) {
const fileName = path.basename(filePath);
const polled = await _resolveDoodstreamUploadByName(apiKey, fileName, doodBaseline, signal, opts && opts.recoveryClaim);
if (polled) return polled;
try {
const polled = await _resolveDoodstreamUploadByName(apiKey, fileName, doodBaseline, signal, opts && opts.recoveryClaim);
if (polled) return polled;
} catch (err) {
throw _markRecoveryUncertain(opts && opts.recoveryClaim, err);
}
}
if (hosterName === 'byse.sx' && byseBaselineError && !explicitlyRejected) {
byseBaselineError.hosterTransient = true;
throw byseBaselineError;
throw _markRecoveryUncertain(opts && opts.recoveryClaim, byseBaselineError);
}
if (hosterName === 'doodstream.com' && doodBaselineError && !explicitlyRejected) {
doodBaselineError.hosterTransient = true;
throw doodBaselineError;
throw _markRecoveryUncertain(opts && opts.recoveryClaim, doodBaselineError);
}
if (parseErr) throw parseErr;
if (parseErr) {
throw explicitlyRejected
? parseErr
: _markRecoveryUncertain(opts && opts.recoveryClaim, parseErr);
}
if (payload.success === false) {
throw createTransportError(`Upload zu ${hosterName} wurde vom Server abgelehnt`, {
@@ -970,7 +1057,7 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
// so tag it hosterTransient: the upload-manager then fails this file WITHOUT
// blacklisting the account (same protection the web path got in 3.3.29) and
// the account stays usable for the next retry/batch.
throw createTransportError(`Upload zu ${hosterName} lieferte keine file_code-Antwort`, {
throw _markRecoveryUncertain(opts && opts.recoveryClaim, createTransportError(`Upload zu ${hosterName} lieferte keine file_code-Antwort`, {
phase: 'upload-result',
endpoint: targetUrl,
httpStatus: statusCode,
@@ -978,7 +1065,7 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
body: rawBody,
retryable: true,
hosterTransient: true
});
}));
}
throw createTransportError(`Upload zu ${hosterName} wurde abgelehnt`, {
phase: 'upload-result',
@@ -1007,6 +1094,7 @@ module.exports = {
uploadFile,
prefetchBaseline,
createRecoveryClaimRegistry,
normalizeRecoveryTitle: _normalizeFileTitle,
HOSTER_CONFIGS,
__test: {
extractUploadServerUrl,