feat: add guarded resume recovery comparison tooling (#3460)

* feat: add synthetic resume recovery procedure

* fix: harden resume recovery comparison

* fix: validate recovery objects before serialization

* fix: require serialized recovery requests

* fix: reject ambiguous recovery requests

* [autofix.ci] apply automated fixes

* fix: reject format characters in recovery IDs

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Amruth Pillai
2026-09-05 18:32:51 -07:00
committed by GitHub
co-authored by autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
parent 8c5804ed05
commit 549135bb36
4 changed files with 815 additions and 20 deletions
+34 -16
View File
@@ -3,24 +3,24 @@ title: "Accessing the previous version"
description: "Access the previous version (v4) of Reactive Resume to retrieve old resumes, and export them for import into the latest version."
---
## The previous version is still available
## Check whether the previous version is available
If you've used Reactive Resume for a while, you may have resumes saved in the previous version. Version 4 (v4) is still fully accessible and will remain online for the foreseeable future.
If you've used Reactive Resume for a while, you may have resumes saved in version 4 (v4). Access depends on whether
your self-hosted instance or the hosted previous-version service is currently available.
<Info>The previous version of Reactive Resume is available at [https://v4.rxresu.me](https://v4.rxresu.me).</Info>
<Info>
When the hosted previous version is available, its address is
[https://v4.rxresu.me](https://v4.rxresu.me). Availability is not guaranteed.
</Info>
## Why keep the old version running?
Self-hosted operators control their own v4 instance and backups. The [v4 to v5 migration
guide](/self-hosting/migration) applies only to infrastructure they are authorized to operate. It does not authorize
access to hosted databases or backups.
Anyone who created resumes in v4 can still open, edit, and export them there. Some people also prefer the interface they already know, and moving to a new version takes time.
## When v4 is accessible
## How long will v4 be available?
The previous version will keep running for as long as possible, until the maintainer runs out of breath or funds to keep the server active. There are no immediate plans to shut it down.
<Warning>
v4 will stay accessible for the foreseeable future, but I recommend moving to the latest version when you can. That
is where new features and ongoing support land.
</Warning>
Open, export, and securely back up each resume you need. Import the export into v5 as a new resume; keep the v5 version
until you have compared both copies.
## Accessing your v4 resumes
@@ -39,7 +39,7 @@ The previous version will keep running for as long as possible, until the mainta
</Step>
<Step title="Access your resumes">
Once signed in, you'll find all your previously created resumes in your dashboard, exactly as you left them.
If the dashboard contains your resumes, export each one as JSON before making more changes.
</Step>
</Steps>
@@ -53,10 +53,28 @@ If you'd like to move your resumes to the latest version of Reactive Resume, you
4. Use the import feature to upload your JSON file (select the "Reactive Resume v4 (JSON)" option)
<Info>
Your existing resumes should already be available in the new version. If you don't see them, you can manually import
them using the steps above.
Import creates a separate resume. It should not be used to replace a newer v5 copy until you have compared both
versions.
</Info>
## When hosted v4 or a resume is unavailable
Only an authorized hosted service operator can determine whether a source snapshot exists. Open a GitHub issue without
including resume contents, account credentials, reset links, or other private data. A useful request identifies the
approximate time of the missing edits, the sign-in method, and whether the resume is missing or merely not visible.
Recovery is handled per owner. Before accessing content, the operator must record a private case with source snapshot
time, owner verification, source-to-target mapping, target resume ID, content hashes, and proposed outcome. A matching
email address, username, or resume title alone is not proof of ownership.
Default recovery result is a private JSON export delivered through an approved channel to a verified recipient. Old-only
or divergent content must remain a separate copy; it must not overwrite a current v5 resume. If no source snapshot is
available, the factual outcome is that the records cannot be recovered from the service. Local tooling cannot recreate
missing source data.
An empty workspace with successful create responses or name conflicts can instead be a listing or account-mapping
problem. That requires a separate session, create, list, and reload diagnosis; a v4 recovery export does not resolve it.
## Questions or issues?
If you run into problems accessing v4, or have questions about migrating your resumes, open an issue on [GitHub](https://github.com/amruthpillai/reactive-resume/issues).
+64 -4
View File
@@ -12,6 +12,12 @@ To move a Reactive Resume installation from **v4 to v5**, you set up a new v5 in
Docker](/self-hosting/docker) guide. v5 schema migrations run automatically on app startup.
</Info>
<Warning>
This guide applies only to infrastructure and backups you are authorized to operate. It does not grant access to
hosted Reactive Resume data. Only the hosted service operator can verify whether a hosted snapshot exists and
authorize access to it.
</Warning>
<Warning>
**Keep your v4 instance running** until you have migrated all data to v5 and checked that everything works. That way
you have a fallback if the migration goes wrong.
@@ -48,6 +54,58 @@ The best migration approach depends on the size of your instance:
</Card>
</CardGroup>
## Recover one owner's resumes without overwriting v5
Use a recovery case when an owner changed resumes after an earlier migration. Keep the case record private and outside
Git because it may connect account and resume identifiers. Record these fields before inspecting resume content:
- Recovery case ID
- Source snapshot capture time
- Owner verification status
- Source resume ID and mapped target resume ID, if one exists
- Source and target content hashes
- Proposed outcome: `no-op`, `export-copy`, or `blocked`
Case IDs, source resume IDs, and non-null target resume IDs must contain a non-whitespace character after trimming and
must not contain Unicode control or format characters. Valid identifiers are preserved verbatim.
These hashes prove content equality only; they never prove ownership, source authenticity, or recipient identity.
An authorized operator should follow this order:
<Steps>
<Step title="Verify source availability">
Confirm that a source snapshot exists and record when it was captured. If no source snapshot exists, report that
factual limit. Recovery tooling cannot reconstruct records that are absent from every available source.
</Step>
<Step title="Verify owner and mapping">
Verify the requester using the operator's approved account-ownership process. Confirm the old-to-new owner mapping;
a matching email address, resume title, or username alone is not ownership proof. Stop if either check is incomplete.
</Step>
<Step title="Compare without writing">
Build one serialized JSON comparison request containing the case IDs, safety flags, and source and target values. The
comparator accepts only this request string, not an object argument. Source and target data must already conform
exactly to the current v5 resume-data schema before content hashes are calculated. Raw v4 exports are unsupported,
and the comparator performs no format conversion. Identical content is a `no-op`. Source-only or divergent content
is an `export-copy`. Missing identity evidence, mapping, valid current-v5 data, or a source snapshot is `blocked`.
</Step>
<Step title="Deliver a separate private export">
Default to a private JSON export. Never overwrite the current v5 resume. After the recipient and delivery channel are
approved, deliver the recovered JSON privately so the owner can import it as a separate resume. Record source and
delivered hashes outside Git and confirm they match.
</Step>
</Steps>
Repository contributors can rehearse this decision with the pure comparator in
`tooling/recovery/compare-resume.ts`. It accepts one serialized JSON comparison request and rejects object arguments.
The request's source and target values must already conform exactly to the current v5 resume-data schema. It does not
accept raw v4 exports or perform legacy conversion; review the historical converter at tag `v5.0.20` separately before
processing legacy-format data. The comparator produces a deterministic dry-run manifest and has no database, network,
or write path. Use synthetic IDs and content only; keep any operational manifest outside the repository.
## Manual migration (small instances)
If you have only a few resumes to migrate, the simplest approach is to use the **Import Dialog** feature in v5.
@@ -213,8 +271,9 @@ Both migration scripts support graceful shutdown and resume:
- **Resume migration**: Run the script again to continue from where you left off
<Tip>
If you need to restart the migration from scratch, delete the progress files and the user ID mapping file before
running the scripts again.
Preserve progress files and the user ID mapping as recovery evidence. Do not delete them or replay the historical
scripts against a populated target as a recovery shortcut. Review the `v5.0.20` scripts, source backup, mapping, and
target state before any rerun.
</Tip>
## Post-migration steps
@@ -290,8 +349,9 @@ After completing the migration:
</Accordion>
<Accordion title="Resume data parsing fails">
If a resume can't be parsed from v4 format, it will be created with default empty data. Check the console output for
warnings about specific resumes, and consider manually importing those using the Import Dialog.
Historical scripts can create default empty data when a v4 resume cannot be parsed. Treat that result as a failed
conversion, not a recovered resume. Preserve the source export, review the `v5.0.20` converter, and use the Import
Dialog only after valid source data is confirmed.
</Accordion>
<Accordion title="Migration is slow">
+411
View File
@@ -0,0 +1,411 @@
import { describe, expect, it } from "vitest";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { compareResumeRecovery } from "./compare-resume";
const SYNTHETIC_SOURCE_HASH = "68cbff28a704f3859c7f5385e9e82a3517521374d99ec2f65ca15887f81310cc";
const RECOVERED_COPY_HASH = "56d3e7d3ecd336b6d910224e2ecb64c7a3c010ff980782f3bea985682018e3a6";
const CURRENT_COPY_HASH = "40cb0aba1e7b3d0950314c3ad20a74b30785545658b296fea97885d6364c9b2f";
const DEFAULT_RESUME_HASH = "15c8a97e15f248c630a6e1c16e5e257a5b02959ef749acbc18c41cd150e853a4";
const FORMAT_CHARACTERS = [
["zero-width space (U+200B)", "\u200B"],
["left-to-right isolate (U+2066)", "\u2066"],
["right-to-left override (U+202E)", "\u202E"],
["byte-order mark (U+FEFF)", "\uFEFF"],
] as const;
const INVALID_INPUT_MANIFEST = {
caseId: "invalid-input",
sourceResumeId: "invalid-input",
targetResumeId: null,
sourceHash: null,
targetHash: null,
outcome: "blocked",
blockedReason: "invalid-input",
};
type RecoveryRequest = {
caseId: unknown;
sourceResumeId: unknown;
targetResumeId: unknown;
ownerVerified: unknown;
ownerMappingPresent: unknown;
sourceAvailable: unknown;
source: unknown;
target: unknown;
};
function resumeWithName(name: string) {
const resume = structuredClone(defaultResumeData);
resume.basics.name = name;
return resume;
}
function resumeWithTemplate(template: string): unknown {
const resume = structuredClone(defaultResumeData);
return { ...resume, metadata: { ...resume.metadata, template } };
}
function validRequestObject(overrides: Partial<RecoveryRequest> = {}): RecoveryRequest {
return {
caseId: "case-synthetic-001",
sourceResumeId: "resume-v4-synthetic-001",
targetResumeId: "resume-v5-synthetic-001",
ownerVerified: true,
ownerMappingPresent: true,
sourceAvailable: true,
source: resumeWithName("Synthetic source"),
target: resumeWithName("Synthetic source"),
...overrides,
};
}
function validRequest(overrides: Partial<RecoveryRequest> = {}): string {
return JSON.stringify(validRequestObject(overrides));
}
function validRequestWithRawValue(field: "source" | "target", rawValue: string): string {
const marker = `raw-${field}-value`;
return validRequest({ [field]: marker }).replace(JSON.stringify(marker), rawValue);
}
describe("compareResumeRecovery", () => {
it("returns no-op with hand-checked hashes when serialized source and target are identical", () => {
expect(compareResumeRecovery(validRequest())).toEqual({
caseId: "case-synthetic-001",
sourceResumeId: "resume-v4-synthetic-001",
targetResumeId: "resume-v5-synthetic-001",
sourceHash: SYNTHETIC_SOURCE_HASH,
targetHash: SYNTHETIC_SOURCE_HASH,
outcome: "no-op",
blockedReason: null,
});
});
it("returns export-copy when serialized request has no target resume", () => {
expect(compareResumeRecovery(validRequest({ targetResumeId: null, target: null }))).toEqual({
caseId: "case-synthetic-001",
sourceResumeId: "resume-v4-synthetic-001",
targetResumeId: null,
sourceHash: SYNTHETIC_SOURCE_HASH,
targetHash: null,
outcome: "export-copy",
blockedReason: null,
});
});
it("returns export-copy with both hashes when serialized source and target diverge", () => {
expect(
compareResumeRecovery(
validRequest({ source: resumeWithName("Recovered copy"), target: resumeWithName("Current copy") }),
),
).toEqual({
caseId: "case-synthetic-001",
sourceResumeId: "resume-v4-synthetic-001",
targetResumeId: "resume-v5-synthetic-001",
sourceHash: RECOVERED_COPY_HASH,
targetHash: CURRENT_COPY_HASH,
outcome: "export-copy",
blockedReason: null,
});
});
it.each([
[{ ownerVerified: false }, "owner-unverified"],
[{ ownerMappingPresent: false }, "owner-mapping-missing"],
] as const)("blocks before hashing when serialized identity gate fails with %s", (overrides, blockedReason) => {
expect(compareResumeRecovery(validRequest(overrides))).toMatchObject({
sourceHash: null,
targetHash: null,
outcome: "blocked",
blockedReason,
});
});
it("blocks when serialized request says source snapshot is unavailable", () => {
expect(compareResumeRecovery(validRequest({ sourceAvailable: false, source: null }))).toMatchObject({
sourceHash: null,
targetHash: null,
outcome: "blocked",
blockedReason: "source-unavailable",
});
});
it.each(["ownerVerified", "ownerMappingPresent", "sourceAvailable"] as const)(
"rejects string false for %s as invalid input",
(flag) => {
expect(compareResumeRecovery(validRequest({ [flag]: "false" }))).toEqual(INVALID_INPUT_MANIFEST);
},
);
it.each([
["caseId", 42],
["caseId", ""],
["sourceResumeId", 42],
["sourceResumeId", ""],
["targetResumeId", 42],
["targetResumeId", ""],
] as const)("rejects invalid %s value %s", (field, value) => {
expect(compareResumeRecovery(validRequest({ [field]: value }))).toEqual(INVALID_INPUT_MANIFEST);
});
describe.each(["caseId", "sourceResumeId", "targetResumeId"] as const)("safe %s validation", (field) => {
it.each([
["spaces", " "],
["tab", "\t"],
["newline", "\n"],
["NUL", "\u0000"],
["embedded control character", "valid\u001fid"],
] as const)("rejects %s", (_description, value) => {
expect(compareResumeRecovery(validRequest({ [field]: value }))).toEqual(INVALID_INPUT_MANIFEST);
});
it.each(FORMAT_CHARACTERS)("rejects embedded %s", (_description, character) => {
expect(compareResumeRecovery(validRequest({ [field]: `valid${character}id` }))).toEqual(INVALID_INPUT_MANIFEST);
});
it.each(FORMAT_CHARACTERS)("rejects format-only %s", (_description, character) => {
expect(compareResumeRecovery(validRequest({ [field]: character }))).toEqual(INVALID_INPUT_MANIFEST);
});
});
it("preserves valid manifest identifiers", () => {
expect(
compareResumeRecovery(
validRequest({
caseId: " case-synthetic-001 ",
sourceResumeId: "résumé/source 001",
targetResumeId: "target 001",
}),
),
).toMatchObject({
caseId: " case-synthetic-001 ",
sourceResumeId: "résumé/source 001",
targetResumeId: "target 001",
outcome: "no-op",
blockedReason: null,
});
});
it.each([
['"ownerVerified":false,"ownerVerified":true', "false before true"],
['"ownerVerified":true,"ownerVerified":false', "true before false"],
['"ownerVerified":false,"\\u006fwnerVerified":true', "escaped duplicate name"],
] as const)("rejects duplicate top-level safety members with %s (%s)", (duplicateMembers, _order) => {
const request = validRequest().replace('"ownerVerified":true', duplicateMembers);
expect(compareResumeRecovery(request)).toEqual(INVALID_INPUT_MANIFEST);
});
it.each(["source", "target"] as const)("rejects duplicate members within nested %s data", (field) => {
const resume = JSON.stringify(resumeWithName("Synthetic source")).replace(
'"name":"Synthetic source"',
'"name":"first value","name":"second value"',
);
const request = validRequestWithRawValue(field, resume);
expect(compareResumeRecovery(request)).toEqual(INVALID_INPUT_MANIFEST);
});
it.each(["source", "target"] as const)("rejects duplicate members within serialized nested %s data", (field) => {
const resume = JSON.stringify(resumeWithName("Synthetic source")).replace(
'"name":"Synthetic source"',
'"name":"first value","name":"second value"',
);
expect(compareResumeRecovery(validRequest({ [field]: resume }))).toEqual(INVALID_INPUT_MANIFEST);
});
it("rejects unknown envelope keys", () => {
const request = { ...validRequestObject(), unexpected: true };
expect(compareResumeRecovery(JSON.stringify(request))).toEqual(INVALID_INPUT_MANIFEST);
});
it.each([
["source", { source: undefined }],
["target", { target: undefined }],
] as const)("rejects missing required %s value", (_field, overrides) => {
expect(compareResumeRecovery(validRequest(overrides))).toEqual(INVALID_INPUT_MANIFEST);
});
it.each([
["malformed JSON", "{"],
[
"NaN",
'{"caseId":"case","sourceResumeId":"source","targetResumeId":null,"ownerVerified":true,"ownerMappingPresent":true,"sourceAvailable":true,"source":NaN,"target":null}',
],
[
"Infinity",
'{"caseId":"case","sourceResumeId":"source","targetResumeId":null,"ownerVerified":true,"ownerMappingPresent":true,"sourceAvailable":true,"source":Infinity,"target":null}',
],
[
"a number that overflows to Infinity",
'{"caseId":"case","sourceResumeId":"source","targetResumeId":null,"ownerVerified":true,"ownerMappingPresent":true,"sourceAvailable":true,"source":1e400,"target":null}',
],
] as const)("rejects request containing %s", (_description, request) => {
expect(compareResumeRecovery(request)).toEqual(INVALID_INPUT_MANIFEST);
});
it("returns a fresh stable manifest for each invalid request", () => {
const first = compareResumeRecovery("{");
first.caseId = "mutated-by-caller";
expect(compareResumeRecovery("{")).toEqual(INVALID_INPUT_MANIFEST);
});
it("rejects an object argument before reading a top-level accessor", () => {
let getterCalls = 0;
const request = Object.defineProperty({}, "caseId", {
enumerable: true,
get() {
getterCalls += 1;
return "case";
},
});
expect(compareResumeRecovery(request as never)).toEqual(INVALID_INPUT_MANIFEST);
expect(getterCalls).toBe(0);
});
it("rejects a proxy argument without triggering any traps", () => {
let trapCalls = 0;
const request = new Proxy(
{},
{
get() {
trapCalls += 1;
return undefined;
},
getOwnPropertyDescriptor() {
trapCalls += 1;
return undefined;
},
ownKeys() {
trapCalls += 1;
return [];
},
},
);
expect(compareResumeRecovery(request as never)).toEqual(INVALID_INPUT_MANIFEST);
expect(trapCalls).toBe(0);
});
it("rejects an object argument before reading a schema-valid changing getter", () => {
let getterCalls = 0;
const source = structuredClone(defaultResumeData);
Object.defineProperty(source.basics, "name", {
enumerable: true,
get() {
getterCalls += 1;
return getterCalls % 2 === 0 ? "Second" : "First";
},
});
expect(compareResumeRecovery(validRequestObject({ source }) as never)).toEqual(INVALID_INPUT_MANIFEST);
expect(getterCalls).toBe(0);
});
it("rejects an object envelope containing a boxed string before serialization", () => {
const source = {
...structuredClone(defaultResumeData),
basics: {
...structuredClone(defaultResumeData.basics),
name: new String(""),
},
};
expect(compareResumeRecovery(validRequestObject({ source }) as never)).toEqual(INVALID_INPUT_MANIFEST);
});
it("rejects an object envelope without executing a nested toJSON method", () => {
let toJSONCalls = 0;
const source = {
...structuredClone(defaultResumeData),
toJSON() {
toJSONCalls += 1;
return structuredClone(defaultResumeData);
},
};
expect(compareResumeRecovery(validRequestObject({ source }) as never)).toEqual(INVALID_INPUT_MANIFEST);
expect(toJSONCalls).toBe(0);
});
it("keeps malformed non-JSON source distinct from serialized null source", () => {
const nonJsonManifest = compareResumeRecovery(validRequestObject({ source: Number.NaN }) as never);
const nullManifest = compareResumeRecovery(validRequest({ source: null }));
expect(nonJsonManifest).toEqual(INVALID_INPUT_MANIFEST);
expect(nullManifest).toMatchObject({
caseId: "case-synthetic-001",
sourceResumeId: "resume-v4-synthetic-001",
blockedReason: "invalid-source-json",
});
expect(nonJsonManifest).not.toEqual(nullManifest);
});
it("blocks malformed source data instead of treating it as an empty resume", () => {
expect(compareResumeRecovery(validRequest({ source: "{" }))).toMatchObject({
sourceHash: null,
targetHash: null,
outcome: "blocked",
blockedReason: "invalid-source-json",
});
});
it("blocks malformed target data instead of replacing it", () => {
expect(compareResumeRecovery(validRequest({ target: "{" }))).toMatchObject({
sourceHash: SYNTHETIC_SOURCE_HASH,
targetHash: null,
outcome: "blocked",
blockedReason: "invalid-target-json",
});
});
it("blocks schema-invalid source data instead of treating normalized content as identical", () => {
expect(
compareResumeRecovery(
validRequest({ source: resumeWithTemplate("not-a-template"), target: structuredClone(defaultResumeData) }),
),
).toMatchObject({
sourceHash: null,
targetHash: null,
outcome: "blocked",
blockedReason: "invalid-source-json",
});
});
it("blocks schema-invalid target data instead of treating normalized content as identical", () => {
expect(
compareResumeRecovery(
validRequest({ source: structuredClone(defaultResumeData), target: resumeWithTemplate("not-a-template") }),
),
).toMatchObject({
sourceHash: DEFAULT_RESUME_HASH,
targetHash: null,
outcome: "blocked",
blockedReason: "invalid-target-json",
});
});
it.each([
[{ targetResumeId: null }, null],
[{ target: null }, "resume-v5-synthetic-001"],
] as const)("blocks contradictory serialized target presence for %s", (overrides, targetResumeId) => {
expect(compareResumeRecovery(validRequest(overrides))).toEqual({
caseId: "case-synthetic-001",
sourceResumeId: "resume-v4-synthetic-001",
targetResumeId,
sourceHash: null,
targetHash: null,
outcome: "blocked",
blockedReason: "target-presence-mismatch",
});
});
it("returns the same manifest for repeated serialized dry runs", () => {
const request = validRequest({ source: resumeWithName("Recovered copy"), targetResumeId: null, target: null });
expect(compareResumeRecovery(request)).toEqual(compareResumeRecovery(request));
});
});
+306
View File
@@ -0,0 +1,306 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { createHash } from "node:crypto";
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
type RecoveryOutcome = "no-op" | "export-copy" | "blocked";
type RecoveryBlockReason =
| "invalid-input"
| "source-unavailable"
| "owner-unverified"
| "owner-mapping-missing"
| "invalid-source-json"
| "invalid-target-json"
| "target-presence-mismatch";
type RecoveryComparisonRequest = {
caseId: string;
sourceResumeId: string;
targetResumeId: string | null;
ownerVerified: boolean;
ownerMappingPresent: boolean;
sourceAvailable: boolean;
source: unknown;
target: unknown;
};
export type RecoveryComparisonInput = string;
export type RecoveryManifest = {
caseId: string;
sourceResumeId: string;
targetResumeId: string | null;
sourceHash: string | null;
targetHash: string | null;
outcome: RecoveryOutcome;
blockedReason: RecoveryBlockReason | null;
};
const REQUEST_KEYS = [
"caseId",
"ownerMappingPresent",
"ownerVerified",
"source",
"sourceAvailable",
"sourceResumeId",
"target",
"targetResumeId",
] as const;
const INVALID_INPUT_MANIFEST = {
caseId: "invalid-input",
sourceResumeId: "invalid-input",
targetResumeId: null,
sourceHash: null,
targetHash: null,
outcome: "blocked",
blockedReason: "invalid-input",
} satisfies RecoveryManifest;
const UNICODE_CONTROL_OR_FORMAT_CHARACTER = /[\p{Cc}\p{Cf}]/u;
function hasDuplicateJsonMembers(input: string): boolean {
let position = 0;
const numberPattern = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
function fail(): never {
throw new SyntaxError("Invalid JSON");
}
function skipWhitespace(): void {
while (
input[position] === " " ||
input[position] === "\t" ||
input[position] === "\n" ||
input[position] === "\r"
) {
position += 1;
}
}
function scanString(): string {
const start = position;
if (input[position] !== '"') fail();
position += 1;
while (position < input.length) {
const character = input[position] ?? fail();
position += 1;
if (character === '"') return JSON.parse(input.slice(start, position));
if (character === "\\") {
if (position >= input.length) fail();
position += 1;
} else if (character.charCodeAt(0) <= 0x1f) {
fail();
}
}
return fail();
}
function scanObject(): boolean {
position += 1;
skipWhitespace();
if (input[position] === "}") {
position += 1;
return false;
}
const keys = new Set<string>();
while (position < input.length) {
const key = scanString();
if (keys.has(key)) return true;
keys.add(key);
skipWhitespace();
if (input[position] !== ":") fail();
position += 1;
if (scanValue()) return true;
skipWhitespace();
if (input[position] === "}") {
position += 1;
return false;
}
if (input[position] !== ",") fail();
position += 1;
skipWhitespace();
}
return fail();
}
function scanArray(): boolean {
position += 1;
skipWhitespace();
if (input[position] === "]") {
position += 1;
return false;
}
while (position < input.length) {
if (scanValue()) return true;
skipWhitespace();
if (input[position] === "]") {
position += 1;
return false;
}
if (input[position] !== ",") fail();
position += 1;
skipWhitespace();
}
return fail();
}
function scanValue(): boolean {
skipWhitespace();
const character = input[position];
if (character === "{") return scanObject();
if (character === "[") return scanArray();
if (character === '"') {
scanString();
return false;
}
for (const literal of ["true", "false", "null"]) {
if (input.startsWith(literal, position)) {
position += literal.length;
return false;
}
}
numberPattern.lastIndex = position;
const number = numberPattern.exec(input);
if (!number) fail();
position = numberPattern.lastIndex;
return false;
}
const hasDuplicate = scanValue();
if (hasDuplicate) return true;
skipWhitespace();
if (position !== input.length) fail();
return false;
}
function isManifestId(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0 && !UNICODE_CONTROL_OR_FORMAT_CHARACTER.test(value);
}
function hasDuplicateSerializedResumeMembers(value: unknown): boolean {
if (typeof value !== "string") return false;
try {
return hasDuplicateJsonMembers(value);
} catch {
return false;
}
}
function isJsonValue(value: unknown): boolean {
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
if (typeof value === "number") return Number.isFinite(value);
if (Array.isArray(value)) return value.every(isJsonValue);
if (typeof value === "object") return Object.values(value).every(isJsonValue);
return false;
}
function parseRequest(input: string): RecoveryComparisonRequest | null {
try {
if (hasDuplicateJsonMembers(input)) return null;
const value: unknown = JSON.parse(input);
if (value === null || Array.isArray(value) || typeof value !== "object" || !isJsonValue(value)) return null;
const keys = Object.keys(value).sort();
if (keys.length !== REQUEST_KEYS.length || !keys.every((key, index) => key === REQUEST_KEYS[index])) return null;
const request = value as Record<(typeof REQUEST_KEYS)[number], unknown>;
if (hasDuplicateSerializedResumeMembers(request.source) || hasDuplicateSerializedResumeMembers(request.target)) {
return null;
}
if (!isManifestId(request.caseId)) return null;
if (!isManifestId(request.sourceResumeId)) return null;
if (request.targetResumeId !== null && !isManifestId(request.targetResumeId)) return null;
if (
typeof request.ownerVerified !== "boolean" ||
typeof request.ownerMappingPresent !== "boolean" ||
typeof request.sourceAvailable !== "boolean"
) {
return null;
}
return request as RecoveryComparisonRequest;
} catch {
return null;
}
}
function parseResume(value: unknown): ResumeData | null {
try {
const json = typeof value === "string" ? JSON.parse(value) : value;
const result = resumeDataSchema.safeParse(json);
if (!result.success || canonicalize(json) !== canonicalize(result.data)) return null;
return result.data;
} catch {
return null;
}
}
function canonicalize(value: unknown): string {
if (value === null) return "null";
if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`;
if (typeof value === "object") {
const entries = Object.entries(value)
.filter(([, item]) => item !== undefined)
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonicalize(item)}`).join(",")}}`;
}
return JSON.stringify(value);
}
function hashResume(resume: ResumeData): string {
return createHash("sha256").update(canonicalize(resume)).digest("hex");
}
export function compareResumeRecovery(input: RecoveryComparisonInput): RecoveryManifest {
if (typeof input !== "string") return { ...INVALID_INPUT_MANIFEST };
const request = parseRequest(input);
if (!request) return { ...INVALID_INPUT_MANIFEST };
const manifest = {
caseId: request.caseId,
sourceResumeId: request.sourceResumeId,
targetResumeId: request.targetResumeId,
sourceHash: null,
targetHash: null,
outcome: "blocked",
blockedReason: null,
} satisfies RecoveryManifest;
if (!request.sourceAvailable) return { ...manifest, blockedReason: "source-unavailable" };
if (!request.ownerVerified) return { ...manifest, blockedReason: "owner-unverified" };
if (!request.ownerMappingPresent) return { ...manifest, blockedReason: "owner-mapping-missing" };
if ((request.targetResumeId === null) !== (request.target === null)) {
return { ...manifest, blockedReason: "target-presence-mismatch" };
}
const source = parseResume(request.source);
if (!source) return { ...manifest, blockedReason: "invalid-source-json" };
const sourceHash = hashResume(source);
if (request.target === null) {
return { ...manifest, sourceHash, outcome: "export-copy", blockedReason: null };
}
const target = parseResume(request.target);
if (!target) return { ...manifest, sourceHash, blockedReason: "invalid-target-json" };
const targetHash = hashResume(target);
return {
...manifest,
sourceHash,
targetHash,
outcome: sourceHash === targetHash ? "no-op" : "export-copy",
blockedReason: null,
};
}