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:
+122
-34
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user