@anionex/dsh-turn-rewind 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +28 -0
- package/README.md +182 -0
- package/README.zh.md +180 -0
- package/SECURITY.md +53 -0
- package/cordis.patch.yml +4 -0
- package/docs/FORMAT.md +101 -0
- package/docs/IMPLEMENTATION_PLAN.md +56 -0
- package/docs/assets/turn-rewind-action.png +0 -0
- package/docs/assets/turn-rewind-dialog.png +0 -0
- package/lib/client.js +473 -0
- package/lib/client.js.map +1 -0
- package/lib/engine.js +616 -0
- package/lib/errors.js +20 -0
- package/lib/git.js +155 -0
- package/lib/index.js +71 -0
- package/lib/path-utils.js +262 -0
- package/lib/rewind-host.js +421 -0
- package/lib/snapshot.js +183 -0
- package/lib/store.js +391 -0
- package/lib/types/client/index.d.ts +81 -0
- package/lib/types/engine.d.ts +90 -0
- package/lib/types/errors.d.ts +15 -0
- package/lib/types/git.d.ts +14 -0
- package/lib/types/index.d.ts +43 -0
- package/lib/types/path-utils.d.ts +34 -0
- package/lib/types/rewind-host.d.ts +137 -0
- package/lib/types/snapshot.d.ts +35 -0
- package/lib/types/store.d.ts +39 -0
- package/lib/types/types.d.ts +177 -0
- package/lib/types/validate.d.ts +11 -0
- package/lib/types.js +2 -0
- package/lib/validate.js +242 -0
- package/package.json +91 -0
- package/src/client/index.tsx +648 -0
- package/src/client/platform.d.ts +27 -0
- package/src/engine.ts +770 -0
- package/src/errors.ts +23 -0
- package/src/git.ts +178 -0
- package/src/index.ts +100 -0
- package/src/path-utils.ts +255 -0
- package/src/rewind-host.ts +584 -0
- package/src/snapshot.ts +229 -0
- package/src/store.ts +383 -0
- package/src/types.ts +195 -0
- package/src/validate.ts +245 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/** Durable format version. Readers reject every other version. */
|
|
2
|
+
export const LEDGER_FORMAT_VERSION = 1 as const
|
|
3
|
+
|
|
4
|
+
/** A persisted restore-point identifier. */
|
|
5
|
+
export type RestorePointId = string
|
|
6
|
+
|
|
7
|
+
/** A persisted restore-operation identifier. */
|
|
8
|
+
export type RestoreOperationId = string
|
|
9
|
+
|
|
10
|
+
/** An in-memory, expiring restore-plan identifier. */
|
|
11
|
+
export type RestorePlanId = string
|
|
12
|
+
|
|
13
|
+
/** One regular-file snapshot. */
|
|
14
|
+
export interface FileSnapshotEntry {
|
|
15
|
+
readonly kind: 'file'
|
|
16
|
+
readonly blob: string
|
|
17
|
+
readonly size: number
|
|
18
|
+
readonly mode: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** One symbolic-link snapshot. */
|
|
22
|
+
export interface SymlinkSnapshotEntry {
|
|
23
|
+
readonly kind: 'symlink'
|
|
24
|
+
readonly target: string
|
|
25
|
+
readonly mode: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One path captured by a restore point. */
|
|
29
|
+
export type SnapshotEntry = FileSnapshotEntry | SymlinkSnapshotEntry
|
|
30
|
+
|
|
31
|
+
/** Git facts that fence a restore point to the repository state it observed. */
|
|
32
|
+
export interface RepositoryState {
|
|
33
|
+
readonly root: string
|
|
34
|
+
readonly commonDir: string
|
|
35
|
+
readonly head?: string
|
|
36
|
+
readonly branch?: string
|
|
37
|
+
readonly operation?: string
|
|
38
|
+
readonly stagedPaths: readonly string[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Why a restore point exists. */
|
|
42
|
+
export type RestorePointKind = 'user' | 'rescue' | 'turn'
|
|
43
|
+
|
|
44
|
+
/** Durable workspace restore point. */
|
|
45
|
+
export interface RestorePointManifest {
|
|
46
|
+
readonly version: typeof LEDGER_FORMAT_VERSION
|
|
47
|
+
readonly id: RestorePointId
|
|
48
|
+
readonly kind: RestorePointKind
|
|
49
|
+
readonly workspace: string
|
|
50
|
+
readonly repository: RepositoryState
|
|
51
|
+
readonly sessionId?: string
|
|
52
|
+
readonly label?: string
|
|
53
|
+
readonly parentRestorePoint?: RestorePointId
|
|
54
|
+
/** DSH turn whose opening user request owns this automatic Web rewind checkpoint. */
|
|
55
|
+
readonly turn?: number
|
|
56
|
+
/** Inclusive `turn/start` event sequence; the snapshot was captured before the turn entered its first step. */
|
|
57
|
+
readonly turnStartSeq?: number
|
|
58
|
+
/** Legacy inclusive `turn/end` event sequence used by checkpoints created before prompt-anchored rewind. */
|
|
59
|
+
readonly turnEndSeq?: number
|
|
60
|
+
readonly createdAt: number
|
|
61
|
+
readonly treeHash: string
|
|
62
|
+
readonly fileCount: number
|
|
63
|
+
readonly totalBytes: number
|
|
64
|
+
readonly entries: Readonly<Record<string, SnapshotEntry>>
|
|
65
|
+
readonly restoreCount: number
|
|
66
|
+
readonly lastRestoredAt?: number
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Observable difference between a restore point and the current workspace. */
|
|
70
|
+
export interface WorkspaceChange {
|
|
71
|
+
readonly path: string
|
|
72
|
+
readonly kind: 'added' | 'deleted' | 'modified' | 'mode-changed' | 'type-changed'
|
|
73
|
+
readonly before?: SnapshotEntry
|
|
74
|
+
readonly after?: SnapshotEntry
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Result of comparing one restore point with its workspace. */
|
|
78
|
+
export interface RestorePointInspection {
|
|
79
|
+
readonly restorePoint: RestorePointSummary
|
|
80
|
+
readonly currentTreeHash: string
|
|
81
|
+
readonly currentRepository: RepositoryState
|
|
82
|
+
readonly currentHead?: string
|
|
83
|
+
readonly currentBranch?: string
|
|
84
|
+
readonly currentOperation?: string
|
|
85
|
+
readonly headChanged: boolean
|
|
86
|
+
readonly operationChanged: boolean
|
|
87
|
+
readonly changes: readonly WorkspaceChange[]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Compact restore-point metadata for lists and tool results. */
|
|
91
|
+
export interface RestorePointSummary {
|
|
92
|
+
readonly id: RestorePointId
|
|
93
|
+
readonly kind: RestorePointKind
|
|
94
|
+
readonly workspace: string
|
|
95
|
+
readonly sessionId?: string
|
|
96
|
+
readonly label?: string
|
|
97
|
+
readonly parentRestorePoint?: RestorePointId
|
|
98
|
+
readonly turn?: number
|
|
99
|
+
readonly turnStartSeq?: number
|
|
100
|
+
readonly turnEndSeq?: number
|
|
101
|
+
readonly createdAt: number
|
|
102
|
+
readonly treeHash: string
|
|
103
|
+
readonly fileCount: number
|
|
104
|
+
readonly totalBytes: number
|
|
105
|
+
readonly restoreCount: number
|
|
106
|
+
readonly lastRestoredAt?: number
|
|
107
|
+
readonly head?: string
|
|
108
|
+
readonly branch?: string
|
|
109
|
+
readonly operation?: string
|
|
110
|
+
readonly stagedPathCount: number
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** An expiring restore plan whose confirmation must be echoed exactly. */
|
|
114
|
+
export interface RestorePlan {
|
|
115
|
+
readonly id: RestorePlanId
|
|
116
|
+
readonly restorePointId: RestorePointId
|
|
117
|
+
readonly workspace: string
|
|
118
|
+
readonly repository: RepositoryState
|
|
119
|
+
readonly sessionId?: string
|
|
120
|
+
readonly createdAt: number
|
|
121
|
+
readonly expiresAt: number
|
|
122
|
+
readonly confirmation: string
|
|
123
|
+
readonly allowHeadChange: boolean
|
|
124
|
+
readonly paths: readonly string[]
|
|
125
|
+
readonly changes: readonly WorkspaceChange[]
|
|
126
|
+
readonly expected: Readonly<Record<string, SnapshotEntry | null>>
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Durable journal for one attempted restore. */
|
|
130
|
+
export interface RestoreOperation {
|
|
131
|
+
readonly version: typeof LEDGER_FORMAT_VERSION
|
|
132
|
+
readonly id: RestoreOperationId
|
|
133
|
+
readonly workspace: string
|
|
134
|
+
readonly restorePointId: RestorePointId
|
|
135
|
+
readonly rescuePointId: RestorePointId
|
|
136
|
+
readonly sessionId?: string
|
|
137
|
+
readonly paths: readonly string[]
|
|
138
|
+
readonly startedAt: number
|
|
139
|
+
readonly finishedAt?: number
|
|
140
|
+
readonly state: 'running' | 'rollback-running' | 'completed' | 'rolled-back' | 'interrupted' | 'recovery-required'
|
|
141
|
+
readonly error?: string
|
|
142
|
+
readonly rollbackError?: string
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** A completed restore result. */
|
|
146
|
+
export interface RestoreResult {
|
|
147
|
+
readonly operationId: RestoreOperationId
|
|
148
|
+
readonly restorePointId: RestorePointId
|
|
149
|
+
readonly rescuePointId: RestorePointId
|
|
150
|
+
readonly restoredPaths: readonly string[]
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Public plugin configuration. */
|
|
154
|
+
export interface ChangeLedgerConfig {
|
|
155
|
+
/** State directory. Must live outside every managed workspace. */
|
|
156
|
+
readonly storageDir?: string
|
|
157
|
+
/** Maximum user and rescue restore points retained per workspace. */
|
|
158
|
+
readonly maxRestorePoints?: number
|
|
159
|
+
/** Maximum automatic turn checkpoints retained per session. */
|
|
160
|
+
readonly maxTurnCheckpointsPerSession?: number
|
|
161
|
+
/** Maximum number of files in one restore point. */
|
|
162
|
+
readonly maxFiles?: number
|
|
163
|
+
/** Maximum bytes read from one regular file. */
|
|
164
|
+
readonly maxFileBytes?: number
|
|
165
|
+
/** Maximum aggregate regular-file bytes in one restore point. */
|
|
166
|
+
readonly maxSnapshotBytes?: number
|
|
167
|
+
/** Restore-plan lifetime in milliseconds. */
|
|
168
|
+
readonly planTtlMs?: number
|
|
169
|
+
/** Age after which a lock whose owner is gone may be reclaimed. */
|
|
170
|
+
readonly staleLockMs?: number
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Fully resolved plugin configuration. */
|
|
174
|
+
export interface ResolvedChangeLedgerConfig {
|
|
175
|
+
readonly storageDir: string
|
|
176
|
+
readonly maxRestorePoints: number
|
|
177
|
+
readonly maxTurnCheckpointsPerSession: number
|
|
178
|
+
readonly maxFiles: number
|
|
179
|
+
readonly maxFileBytes: number
|
|
180
|
+
readonly maxSnapshotBytes: number
|
|
181
|
+
readonly planTtlMs: number
|
|
182
|
+
readonly staleLockMs: number
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Current incomplete restore operation. */
|
|
186
|
+
export interface RecoverySummary {
|
|
187
|
+
readonly operationId: RestoreOperationId
|
|
188
|
+
readonly restorePointId: RestorePointId
|
|
189
|
+
readonly rescuePointId: RestorePointId
|
|
190
|
+
readonly state: 'interrupted' | 'recovery-required'
|
|
191
|
+
readonly paths: readonly string[]
|
|
192
|
+
readonly startedAt: number
|
|
193
|
+
readonly error?: string
|
|
194
|
+
readonly rollbackError?: string
|
|
195
|
+
}
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { ChangeLedgerError } from './errors.js'
|
|
2
|
+
import { isAbsolute } from 'node:path'
|
|
3
|
+
import { validateRelativePath } from './path-utils.js'
|
|
4
|
+
import { hashTree } from './snapshot.js'
|
|
5
|
+
import {
|
|
6
|
+
LEDGER_FORMAT_VERSION,
|
|
7
|
+
type RepositoryState,
|
|
8
|
+
type RestoreOperation,
|
|
9
|
+
type RestorePointManifest,
|
|
10
|
+
type SnapshotEntry,
|
|
11
|
+
} from './types.js'
|
|
12
|
+
|
|
13
|
+
const HASH_PATTERN = /^[0-9a-f]{64}$/
|
|
14
|
+
const GIT_OBJECT_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/
|
|
15
|
+
const ID_PATTERN = /^(?:rp|op)_[0-9a-z]+_[0-9a-f]{12}$/
|
|
16
|
+
|
|
17
|
+
/** Validate a restore-point id before using it in a filesystem path. */
|
|
18
|
+
export function validateRestorePointId(value: string): string {
|
|
19
|
+
if (!ID_PATTERN.test(value) || !value.startsWith('rp_')) {
|
|
20
|
+
throw new ChangeLedgerError('INVALID_RESTORE_POINT_ID', `invalid restore-point id ${JSON.stringify(value)}`)
|
|
21
|
+
}
|
|
22
|
+
return value
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Validate a restore-operation id before using it in a filesystem path. */
|
|
26
|
+
export function validateOperationId(value: string): string {
|
|
27
|
+
if (!ID_PATTERN.test(value) || !value.startsWith('op_')) {
|
|
28
|
+
throw new ChangeLedgerError('INVALID_OPERATION_ID', `invalid restore-operation id ${JSON.stringify(value)}`)
|
|
29
|
+
}
|
|
30
|
+
return value
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Parse an untrusted durable restore-point manifest. */
|
|
34
|
+
export function parseManifest(value: unknown): RestorePointManifest {
|
|
35
|
+
const record = objectRecord(value, 'restore-point manifest')
|
|
36
|
+
if (record.version !== LEDGER_FORMAT_VERSION) corrupt(`unsupported restore-point version ${String(record.version)}`)
|
|
37
|
+
const id = stringField(record, 'id')
|
|
38
|
+
validateRestorePointId(id)
|
|
39
|
+
const kind = record.kind
|
|
40
|
+
if (kind !== 'user' && kind !== 'rescue' && kind !== 'turn') {
|
|
41
|
+
corrupt('restore-point kind must be "user", "rescue", or "turn"')
|
|
42
|
+
}
|
|
43
|
+
const workspace = absoluteString(record, 'workspace')
|
|
44
|
+
const repository = parseRepository(record.repository)
|
|
45
|
+
if (repository.root !== workspace) corrupt('repository.root must equal workspace')
|
|
46
|
+
const entriesRecord = objectRecord(record.entries, 'restore-point entries')
|
|
47
|
+
const entries: Record<string, SnapshotEntry> = Object.create(null) as Record<string, SnapshotEntry>
|
|
48
|
+
let totalBytes = 0
|
|
49
|
+
for (const [path, entryValue] of Object.entries(entriesRecord)) {
|
|
50
|
+
validateRelativePath(path)
|
|
51
|
+
const entry = parseEntry(entryValue, path)
|
|
52
|
+
entries[path] = entry
|
|
53
|
+
if (entry.kind === 'file') totalBytes += entry.size
|
|
54
|
+
}
|
|
55
|
+
const fileCount = nonNegativeInteger(record, 'fileCount')
|
|
56
|
+
if (fileCount !== Object.keys(entries).length) corrupt('restore-point fileCount does not match entries')
|
|
57
|
+
const storedTotal = nonNegativeInteger(record, 'totalBytes')
|
|
58
|
+
if (storedTotal !== totalBytes) corrupt('restore-point totalBytes does not match entries')
|
|
59
|
+
const treeHash = hashField(record, 'treeHash')
|
|
60
|
+
if (treeHash !== hashTree(entries)) corrupt('restore-point treeHash does not match entries')
|
|
61
|
+
const createdAt = nonNegativeInteger(record, 'createdAt')
|
|
62
|
+
const restoreCount = nonNegativeInteger(record, 'restoreCount')
|
|
63
|
+
const sessionId = optionalString(record, 'sessionId')
|
|
64
|
+
const label = optionalString(record, 'label')
|
|
65
|
+
const parentRestorePoint = optionalString(record, 'parentRestorePoint')
|
|
66
|
+
if (parentRestorePoint !== undefined) validateRestorePointId(parentRestorePoint)
|
|
67
|
+
const turn = optionalNonNegativeInteger(record, 'turn')
|
|
68
|
+
const turnStartSeq = optionalNonNegativeInteger(record, 'turnStartSeq')
|
|
69
|
+
const turnEndSeq = optionalNonNegativeInteger(record, 'turnEndSeq')
|
|
70
|
+
if (kind === 'turn') {
|
|
71
|
+
if (sessionId === undefined || turn === undefined || (turnStartSeq === undefined) === (turnEndSeq === undefined)) {
|
|
72
|
+
corrupt('turn restore points require sessionId, turn, and exactly one turn boundary')
|
|
73
|
+
}
|
|
74
|
+
} else if (turn !== undefined || turnStartSeq !== undefined || turnEndSeq !== undefined) {
|
|
75
|
+
corrupt('only turn restore points may carry turn metadata')
|
|
76
|
+
}
|
|
77
|
+
const lastRestoredAt = optionalNonNegativeInteger(record, 'lastRestoredAt')
|
|
78
|
+
return {
|
|
79
|
+
version: LEDGER_FORMAT_VERSION,
|
|
80
|
+
id,
|
|
81
|
+
kind,
|
|
82
|
+
workspace,
|
|
83
|
+
repository,
|
|
84
|
+
...(sessionId === undefined ? {} : { sessionId }),
|
|
85
|
+
...(label === undefined ? {} : { label }),
|
|
86
|
+
...(parentRestorePoint === undefined ? {} : { parentRestorePoint }),
|
|
87
|
+
...(turn === undefined ? {} : { turn }),
|
|
88
|
+
...(turnStartSeq === undefined ? {} : { turnStartSeq }),
|
|
89
|
+
...(turnEndSeq === undefined ? {} : { turnEndSeq }),
|
|
90
|
+
createdAt,
|
|
91
|
+
treeHash,
|
|
92
|
+
fileCount,
|
|
93
|
+
totalBytes,
|
|
94
|
+
entries,
|
|
95
|
+
restoreCount,
|
|
96
|
+
...(lastRestoredAt === undefined ? {} : { lastRestoredAt }),
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Parse an untrusted durable restore-operation journal. */
|
|
101
|
+
export function parseOperation(value: unknown): RestoreOperation {
|
|
102
|
+
const record = objectRecord(value, 'restore operation')
|
|
103
|
+
if (record.version !== LEDGER_FORMAT_VERSION) corrupt(`unsupported restore-operation version ${String(record.version)}`)
|
|
104
|
+
const id = stringField(record, 'id')
|
|
105
|
+
validateOperationId(id)
|
|
106
|
+
const restorePointId = stringField(record, 'restorePointId')
|
|
107
|
+
const rescuePointId = stringField(record, 'rescuePointId')
|
|
108
|
+
validateRestorePointId(restorePointId)
|
|
109
|
+
validateRestorePointId(rescuePointId)
|
|
110
|
+
const workspace = absoluteString(record, 'workspace')
|
|
111
|
+
const pathsValue = record.paths
|
|
112
|
+
if (!Array.isArray(pathsValue)) corrupt('restore-operation paths must be an array')
|
|
113
|
+
const paths = pathsValue.map((path) => validateRelativePath(requireString(path, 'restore-operation path')))
|
|
114
|
+
if (new Set(paths).size !== paths.length) corrupt('restore-operation paths contain duplicates')
|
|
115
|
+
const state = record.state
|
|
116
|
+
if (state !== 'running'
|
|
117
|
+
&& state !== 'rollback-running'
|
|
118
|
+
&& state !== 'completed'
|
|
119
|
+
&& state !== 'rolled-back'
|
|
120
|
+
&& state !== 'interrupted'
|
|
121
|
+
&& state !== 'recovery-required') {
|
|
122
|
+
corrupt(`invalid restore-operation state ${JSON.stringify(state)}`)
|
|
123
|
+
}
|
|
124
|
+
const sessionId = optionalString(record, 'sessionId')
|
|
125
|
+
const startedAt = nonNegativeInteger(record, 'startedAt')
|
|
126
|
+
const finishedAt = optionalNonNegativeInteger(record, 'finishedAt')
|
|
127
|
+
const error = optionalString(record, 'error')
|
|
128
|
+
const rollbackError = optionalString(record, 'rollbackError')
|
|
129
|
+
return {
|
|
130
|
+
version: LEDGER_FORMAT_VERSION,
|
|
131
|
+
id,
|
|
132
|
+
workspace,
|
|
133
|
+
restorePointId,
|
|
134
|
+
rescuePointId,
|
|
135
|
+
...(sessionId === undefined ? {} : { sessionId }),
|
|
136
|
+
paths,
|
|
137
|
+
startedAt,
|
|
138
|
+
...(finishedAt === undefined ? {} : { finishedAt }),
|
|
139
|
+
state,
|
|
140
|
+
...(error === undefined ? {} : { error }),
|
|
141
|
+
...(rollbackError === undefined ? {} : { rollbackError }),
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Validate one SHA-256 blob name. */
|
|
146
|
+
export function validateBlobHash(value: string): string {
|
|
147
|
+
if (!HASH_PATTERN.test(value)) {
|
|
148
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `invalid blob hash ${JSON.stringify(value)}`)
|
|
149
|
+
}
|
|
150
|
+
return value
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseRepository(value: unknown): RepositoryState {
|
|
154
|
+
const record = objectRecord(value, 'repository state')
|
|
155
|
+
const root = absoluteString(record, 'root')
|
|
156
|
+
const commonDir = absoluteString(record, 'commonDir')
|
|
157
|
+
const head = optionalHash(record, 'head')
|
|
158
|
+
const branch = optionalString(record, 'branch')
|
|
159
|
+
const operation = optionalString(record, 'operation')
|
|
160
|
+
const stagedValue = record.stagedPaths
|
|
161
|
+
if (!Array.isArray(stagedValue)) corrupt('repository stagedPaths must be an array')
|
|
162
|
+
const stagedPaths = stagedValue.map((path) => validateRelativePath(requireString(path, 'staged path')))
|
|
163
|
+
return {
|
|
164
|
+
root,
|
|
165
|
+
commonDir,
|
|
166
|
+
...(head === undefined ? {} : { head }),
|
|
167
|
+
...(branch === undefined ? {} : { branch }),
|
|
168
|
+
...(operation === undefined ? {} : { operation }),
|
|
169
|
+
stagedPaths,
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function parseEntry(value: unknown, path: string): SnapshotEntry {
|
|
174
|
+
const record = objectRecord(value, `snapshot entry ${JSON.stringify(path)}`)
|
|
175
|
+
const kind = record.kind
|
|
176
|
+
const mode = nonNegativeInteger(record, 'mode')
|
|
177
|
+
if (mode > 0o777) corrupt(`snapshot mode is out of range for ${JSON.stringify(path)}`)
|
|
178
|
+
if (kind === 'file') {
|
|
179
|
+
return {
|
|
180
|
+
kind,
|
|
181
|
+
blob: validateBlobHash(stringField(record, 'blob')),
|
|
182
|
+
size: nonNegativeInteger(record, 'size'),
|
|
183
|
+
mode,
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (kind === 'symlink') {
|
|
187
|
+
const target = stringField(record, 'target')
|
|
188
|
+
if (target.includes('\0')) corrupt(`snapshot symlink target contains a NUL byte for ${JSON.stringify(path)}`)
|
|
189
|
+
return { kind, target, mode }
|
|
190
|
+
}
|
|
191
|
+
corrupt(`invalid snapshot entry kind for ${JSON.stringify(path)}`)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function objectRecord(value: unknown, label: string): Record<string, unknown> {
|
|
195
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) corrupt(`${label} must be an object`)
|
|
196
|
+
const prototype = Object.getPrototypeOf(value)
|
|
197
|
+
if (prototype !== Object.prototype && prototype !== null) corrupt(`${label} must be a plain object`)
|
|
198
|
+
return value as Record<string, unknown>
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function stringField(record: Record<string, unknown>, key: string): string {
|
|
202
|
+
return requireString(record[key], key)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function requireString(value: unknown, label: string): string {
|
|
206
|
+
if (typeof value !== 'string' || value === '') corrupt(`${label} must be a non-empty string`)
|
|
207
|
+
return value
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function optionalString(record: Record<string, unknown>, key: string): string | undefined {
|
|
211
|
+
const value = record[key]
|
|
212
|
+
return value === undefined ? undefined : requireString(value, key)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function absoluteString(record: Record<string, unknown>, key: string): string {
|
|
216
|
+
const value = stringField(record, key)
|
|
217
|
+
if (!isAbsolute(value)) corrupt(`${key} must be an absolute path`)
|
|
218
|
+
return value
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function hashField(record: Record<string, unknown>, key: string): string {
|
|
222
|
+
return validateBlobHash(stringField(record, key))
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function optionalHash(record: Record<string, unknown>, key: string): string | undefined {
|
|
226
|
+
const value = record[key]
|
|
227
|
+
if (value === undefined) return undefined
|
|
228
|
+
const hash = requireString(value, key)
|
|
229
|
+
if (!GIT_OBJECT_PATTERN.test(hash)) corrupt(`${key} must be a Git object id`)
|
|
230
|
+
return hash
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function nonNegativeInteger(record: Record<string, unknown>, key: string): number {
|
|
234
|
+
const value = record[key]
|
|
235
|
+
if (!Number.isSafeInteger(value) || (value as number) < 0) corrupt(`${key} must be a non-negative safe integer`)
|
|
236
|
+
return value as number
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function optionalNonNegativeInteger(record: Record<string, unknown>, key: string): number | undefined {
|
|
240
|
+
return record[key] === undefined ? undefined : nonNegativeInteger(record, key)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function corrupt(message: string): never {
|
|
244
|
+
throw new ChangeLedgerError('STATE_CORRUPT', message)
|
|
245
|
+
}
|