dsh-instruction-memory 1.4.8 → 1.5.1
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/CHANGELOG.md +86 -0
- package/README.md +11 -2
- package/install.mjs +25 -4
- package/lib/client.js +105 -18
- package/lib/index.js +412 -60
- package/lib/mailer.js +50 -9
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* user-chosen backup directory (F18).
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
import { chmod, copyFile, mkdir, readdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
|
|
28
|
+
import { chmod, copyFile, mkdir, open, readdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
|
|
29
29
|
import { readFileSync, realpathSync, statSync, watch } from 'node:fs'
|
|
30
30
|
import { randomUUID } from 'node:crypto'
|
|
31
31
|
import { isIP } from 'node:net'
|
|
@@ -102,6 +102,17 @@ const MAX_HISTORY = 20
|
|
|
102
102
|
* when it alone exceeds the budget.
|
|
103
103
|
*/
|
|
104
104
|
const MAX_HISTORY_BYTES = 32 * 1024 * 1024
|
|
105
|
+
/**
|
|
106
|
+
* The largest single snapshot this build will READ back. Every other read path
|
|
107
|
+
* is bounded (the store by MAX_BODY_CHARS, exports by their own ceilings); the
|
|
108
|
+
* restore path historically was not, so a corrupt or hostile file in the
|
|
109
|
+
* history directory could be pulled whole into memory by one click. Snapshots
|
|
110
|
+
* are serialised stores, so this sits above the store ceiling with room to
|
|
111
|
+
* spare while still refusing an absurd file.
|
|
112
|
+
*/
|
|
113
|
+
const MAX_HISTORY_FILE_BYTES = 8 * 1024 * 1024
|
|
114
|
+
/** The feedback config is a handful of keys; anything near this is not one. */
|
|
115
|
+
const MAX_FEEDBACK_CONFIG_BYTES = 1024 * 1024
|
|
105
116
|
/**
|
|
106
117
|
* F7: the longest single pause. Doubles as the setTimeout guard — 7 days is
|
|
107
118
|
* far below the 2^31-1 ms (≈24.8 days) timer ceiling, so the delay never
|
|
@@ -288,6 +299,14 @@ export function expandDisplayPath(value, home = homedir(), platform = process.pl
|
|
|
288
299
|
* .bak — and only the last two may never be treated as "no memory yet".
|
|
289
300
|
*/
|
|
290
301
|
const EMPTY_STORE_CODE = 'EEMPTYSTORE'
|
|
302
|
+
/**
|
|
303
|
+
* A primary that parses as JSON but is not a store at all. Same role as
|
|
304
|
+
* EMPTY_STORE_CODE, for the state JSON.parse cannot flag: the file is valid
|
|
305
|
+
* JSON, so "it read" says nothing about whether it is a store. Keeping it a
|
|
306
|
+
* distinct code is what lets the .bak rotation refuse to copy it over the last
|
|
307
|
+
* good backup while leaving the file itself untouched for hand repair.
|
|
308
|
+
*/
|
|
309
|
+
const NOT_A_STORE_CODE = 'ENOTASTORE'
|
|
291
310
|
|
|
292
311
|
const DEFAULT_BUDGET = 4000
|
|
293
312
|
const MIN_BUDGET = 800
|
|
@@ -371,8 +390,14 @@ export const LIMITS = Object.freeze({
|
|
|
371
390
|
maxFeedbackContact: FEEDBACK_LIMITS.maxContact,
|
|
372
391
|
})
|
|
373
392
|
|
|
374
|
-
/**
|
|
375
|
-
|
|
393
|
+
/**
|
|
394
|
+
* How long a crashed holder's `memory.json.lock` may block writes before it is
|
|
395
|
+
* stolen. This must exceed the worst-case hold time (tmp write + two copies +
|
|
396
|
+
* rename + a large history snapshot), so a LIVE slow holder is not mistaken for
|
|
397
|
+
* debris and swept mid-write: the reclaim path re-checks stat identity, but a
|
|
398
|
+
* wide margin is cheaper than relying on that.
|
|
399
|
+
*/
|
|
400
|
+
const LOCK_STALE_MS = 30_000
|
|
376
401
|
/** How long a write waits for the cross-process lock before proceeding unlocked. */
|
|
377
402
|
const LOCK_WAIT_MS = 2_000
|
|
378
403
|
/** Coalescing window for store-directory watcher events before a re-read. */
|
|
@@ -775,6 +800,40 @@ const describeValue = (value) => {
|
|
|
775
800
|
* `dropped`: the next save persists the corrected values, so the user must be
|
|
776
801
|
* told their hand edit did not survive verbatim.
|
|
777
802
|
*/
|
|
803
|
+
/**
|
|
804
|
+
* Does this PARSED json carry a store's shape, as opposed to merely being
|
|
805
|
+
* valid JSON?
|
|
806
|
+
*
|
|
807
|
+
* The distinction is load-bearing: a file that parses but is an array, a
|
|
808
|
+
* number, or some other tool's object used to be adopted as "an empty
|
|
809
|
+
* memory" with no warning at all, so the very next save wrote that emptiness
|
|
810
|
+
* back over the user's memory and rotated the result into `.bak`. JSON.parse
|
|
811
|
+
* answering is not the same question as "is this a store", and every place
|
|
812
|
+
* that has to ask the second one now asks it here.
|
|
813
|
+
*
|
|
814
|
+
* @returns 'store' for anything adoptable, 'empty-object' for a truthy but
|
|
815
|
+
* empty store (`{}`, or only option keys — a hand-cleared file), and
|
|
816
|
+
* 'not-object' / 'not-store' for the two states that must never be adopted.
|
|
817
|
+
*/
|
|
818
|
+
export function storeShapeVerdict(parsed) {
|
|
819
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return 'not-object'
|
|
820
|
+
// A declared, supported version is enough on its own: `{"version":2}` is a
|
|
821
|
+
// store that happens to be empty (and its formatNote says so), not a
|
|
822
|
+
// foreign file. An unsupported one is handled before this test by the
|
|
823
|
+
// version gate in normalizeData.
|
|
824
|
+
if (typeof parsed.version === 'number' && SUPPORTED_VERSIONS.includes(parsed.version)) return 'store'
|
|
825
|
+
if (Array.isArray(parsed.entries) || Array.isArray(parsed.sets)) return 'store'
|
|
826
|
+
// Every key is one the loader itself understands: `{}` and `{"rev":3}` are
|
|
827
|
+
// empty stores, not somebody else's document.
|
|
828
|
+
const OPTION_KEYS = new Set([
|
|
829
|
+
'version', 'rev', 'enabled', 'disabledUntil', 'budgetChars',
|
|
830
|
+
'backupDir', 'activeSetId', 'entries', 'sets', 'profiles',
|
|
831
|
+
])
|
|
832
|
+
const keys = Object.keys(parsed)
|
|
833
|
+
if (keys.length === 0 || keys.every((key) => OPTION_KEYS.has(key))) return 'empty-object'
|
|
834
|
+
return 'not-store'
|
|
835
|
+
}
|
|
836
|
+
|
|
778
837
|
function normalizeData(raw) {
|
|
779
838
|
const data = {
|
|
780
839
|
version: FORMAT_VERSION,
|
|
@@ -790,8 +849,18 @@ function normalizeData(raw) {
|
|
|
790
849
|
}
|
|
791
850
|
const loaded = { data, dropped: 0, droppedProfiles: 0, droppedSets: 0, reattributed: 0, optionFixes: [], formatNote: null }
|
|
792
851
|
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return loaded
|
|
793
|
-
|
|
794
|
-
|
|
852
|
+
// A declared version this build does not support is refused whatever its
|
|
853
|
+
// TYPE. The old test was `typeof raw.version === 'number'`, so a file
|
|
854
|
+
// carrying `"2.0"` or `"3"` read as "no version declared" and the next save
|
|
855
|
+
// rewrote it as the current format — the exact downgrade this gate exists
|
|
856
|
+
// to prevent, merely reached through a string.
|
|
857
|
+
if ('version' in raw && raw.version !== null && raw.version !== undefined) {
|
|
858
|
+
if (!(typeof raw.version === 'number' && SUPPORTED_VERSIONS.includes(raw.version))) {
|
|
859
|
+
return {
|
|
860
|
+
data, dropped: 0, droppedProfiles: 0, droppedSets: 0, reattributed: 0, optionFixes: [],
|
|
861
|
+
rejectedVersion: typeof raw.version === 'number' ? raw.version : String(raw.version),
|
|
862
|
+
}
|
|
863
|
+
}
|
|
795
864
|
}
|
|
796
865
|
if (typeof raw.rev === 'number' && Number.isFinite(raw.rev) && raw.rev >= 0) {
|
|
797
866
|
data.rev = Math.round(raw.rev)
|
|
@@ -1443,7 +1512,6 @@ function buildProfilesBlock(profiles, budget) {
|
|
|
1443
1512
|
const blocks = []
|
|
1444
1513
|
const items = []
|
|
1445
1514
|
let used = PERSONA_HEADER.length + 1
|
|
1446
|
-
let omitted = 0
|
|
1447
1515
|
for (const profile of active) {
|
|
1448
1516
|
const name = typeof profile.name === 'string' && profile.name !== ''
|
|
1449
1517
|
? profile.name
|
|
@@ -1475,7 +1543,6 @@ function buildProfilesBlock(profiles, budget) {
|
|
|
1475
1543
|
items.push({ id, name, chars: head.length + note.length, truncated: true })
|
|
1476
1544
|
used += sep + head.length + note.length
|
|
1477
1545
|
}
|
|
1478
|
-
omitted = active.length - blocks.length
|
|
1479
1546
|
break
|
|
1480
1547
|
}
|
|
1481
1548
|
const text = PERSONA_HEADER + '\n' + blocks.join('\n\n') + footerFor(Math.max(0, active.length - blocks.length))
|
|
@@ -1748,6 +1815,15 @@ export function apply(ctx) {
|
|
|
1748
1815
|
*/
|
|
1749
1816
|
versionRejected: false,
|
|
1750
1817
|
rejectedVersion: null,
|
|
1818
|
+
/**
|
|
1819
|
+
* Set while the file on disk parses as JSON but is not a store at all (an
|
|
1820
|
+
* array, a number, another tool's object). Adopting it as "an empty
|
|
1821
|
+
* memory" and then saving over it is precisely the silent-loss chain the
|
|
1822
|
+
* rolling backup exists to survive, so ordinary edits are refused in this
|
|
1823
|
+
* state; the repair doors (history restore, restore-from-backup, import,
|
|
1824
|
+
* source mode) stay open and clear it on the first successful write.
|
|
1825
|
+
*/
|
|
1826
|
+
shapeRejected: false,
|
|
1751
1827
|
sectionRegistered: false,
|
|
1752
1828
|
sectionError: null,
|
|
1753
1829
|
/** Whether a store file is actually present on disk (diagnostics + the panel). */
|
|
@@ -2397,8 +2473,36 @@ export function apply(ctx) {
|
|
|
2397
2473
|
}
|
|
2398
2474
|
/** Where the rolling pre-write copy lives: ALWAYS next to the store (crash recovery). */
|
|
2399
2475
|
const localBackupPath = () => storePath + BACKUP_FILE_SUFFIX
|
|
2476
|
+
/**
|
|
2477
|
+
* The backup directory the WRITE path may use, or '' when the configured one
|
|
2478
|
+
* must be ignored.
|
|
2479
|
+
*
|
|
2480
|
+
* `setOptions` and `exportToDir` each check the subtree rule, but they are
|
|
2481
|
+
* not the only writers: every save also drops a `.bak` and a history
|
|
2482
|
+
* snapshot into this directory. A store hand-edited to point outside
|
|
2483
|
+
* `$DSH_HOME` therefore escaped the fence on the very next save, which
|
|
2484
|
+
* contradicted the promise that every write stays inside the harness home.
|
|
2485
|
+
* The same rule now runs where the writes actually happen; the refusal is
|
|
2486
|
+
* disclosed once per process instead of silently relocated.
|
|
2487
|
+
*/
|
|
2488
|
+
let refusedBackupDir = null
|
|
2489
|
+
const effectiveBackupDir = () => {
|
|
2490
|
+
const configured = backupDirOf()
|
|
2491
|
+
if (configured === '') return ''
|
|
2492
|
+
if (allowExternalBackup() || isInsideDshRoot(configured)) return configured
|
|
2493
|
+
if (refusedBackupDir !== configured) {
|
|
2494
|
+
refusedBackupDir = configured
|
|
2495
|
+
state.storageWarning = '备份位置 ' + displayPath(configured) + ' 不在 DSH 主目录内,已忽略(备份与历史快照一律写回默认位置);'
|
|
2496
|
+
+ '要使用其他磁盘请设置 ' + ALLOW_EXTERNAL_BACKUP_ENV + '=1 后重启宿主'
|
|
2497
|
+
logInfo('ignoring backupDir outside the harness home: ' + configured)
|
|
2498
|
+
}
|
|
2499
|
+
return ''
|
|
2500
|
+
}
|
|
2400
2501
|
/** The user-facing copy under a custom backup location, or null (F18). */
|
|
2401
|
-
const customBackupPath = () =>
|
|
2502
|
+
const customBackupPath = () => {
|
|
2503
|
+
const dir = effectiveBackupDir()
|
|
2504
|
+
return dir !== '' ? join(dir, basename(storePath) + BACKUP_FILE_SUFFIX) : null
|
|
2505
|
+
}
|
|
2402
2506
|
/**
|
|
2403
2507
|
* History snapshots live in a `history/` SUBDIRECTORY of the backup location
|
|
2404
2508
|
* (default: the store directory). Two reasons for the subdirectory:
|
|
@@ -2409,7 +2513,10 @@ export function apply(ctx) {
|
|
|
2409
2513
|
* 2. a custom (e.g. cloud-synced) backup directory stays tidy: one known
|
|
2410
2514
|
* folder holds the history, nothing else is touched.
|
|
2411
2515
|
*/
|
|
2412
|
-
const historyDirPath = () =>
|
|
2516
|
+
const historyDirPath = () => {
|
|
2517
|
+
const dir = effectiveBackupDir()
|
|
2518
|
+
return join(dir !== '' ? dir : dirname(storePath), HISTORY_DIR_NAME)
|
|
2519
|
+
}
|
|
2413
2520
|
|
|
2414
2521
|
/**
|
|
2415
2522
|
* Cross-process write lock: a `memory.json.lock` marker created exclusively
|
|
@@ -2459,12 +2566,22 @@ export function apply(ctx) {
|
|
|
2459
2566
|
if (err.code !== 'EEXIST') throw err
|
|
2460
2567
|
const info = await stat(lockPath).catch(() => null)
|
|
2461
2568
|
if (info !== null && Date.now() - info.mtimeMs > LOCK_STALE_MS) {
|
|
2462
|
-
//
|
|
2463
|
-
//
|
|
2464
|
-
//
|
|
2465
|
-
//
|
|
2466
|
-
//
|
|
2467
|
-
|
|
2569
|
+
// Confirm the marker we are about to delete is STILL the one we just
|
|
2570
|
+
// judged stale. Between the stat above and the unlink a live holder
|
|
2571
|
+
// can release it and a new owner can create a fresh marker; deleting
|
|
2572
|
+
// THAT would unlock a live writer, the same TOCTOU the token closed
|
|
2573
|
+
// on release. The mtime+size re-check makes the sweep ownership-safe
|
|
2574
|
+
// without needing to know the dead holder's token.
|
|
2575
|
+
const after = await stat(lockPath).catch(() => null)
|
|
2576
|
+
if (after !== null && after.mtimeMs === info.mtimeMs && after.size === info.size
|
|
2577
|
+
&& Date.now() - after.mtimeMs > LOCK_STALE_MS) {
|
|
2578
|
+
// Crashed-holder debris. It may be recreated by a live process
|
|
2579
|
+
// between this unlink and the next wx attempt — that case simply
|
|
2580
|
+
// loops and re-evaluates freshness instead of forcing its way in.
|
|
2581
|
+
// A failed unlink must not become a tight loop, so every attempt
|
|
2582
|
+
// still sleeps below.
|
|
2583
|
+
await unlink(lockPath).catch(() => {})
|
|
2584
|
+
}
|
|
2468
2585
|
}
|
|
2469
2586
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
2470
2587
|
}
|
|
@@ -2545,14 +2662,22 @@ export function apply(ctx) {
|
|
|
2545
2662
|
let lastPersistedKey = null
|
|
2546
2663
|
|
|
2547
2664
|
/**
|
|
2548
|
-
* Enforce the
|
|
2549
|
-
* planHistoryPrune(). Grouping by the device tag
|
|
2550
|
-
* deliberate: two machines sharing one backup folder
|
|
2551
|
-
* setup) each keep their OWN budget, so
|
|
2552
|
-
*
|
|
2553
|
-
*
|
|
2554
|
-
*
|
|
2555
|
-
*
|
|
2665
|
+
* Enforce the retention policy: the count and byte ceilings from
|
|
2666
|
+
* planHistoryPrune(), applied PER DEVICE first. Grouping by the device tag
|
|
2667
|
+
* inside the filename is deliberate: two machines sharing one backup folder
|
|
2668
|
+
* (the documented F18 setup) each keep their OWN budget, so a device that
|
|
2669
|
+
* saves a lot cannot prune a quiet device's history.
|
|
2670
|
+
*
|
|
2671
|
+
* The GLOBAL byte ceiling afterwards is the documented exception to that:
|
|
2672
|
+
* per-device budgets multiply by the number of machines (ten machines = ten
|
|
2673
|
+
* × 32 MB), so the folder as a whole is also bounded, and that pass deletes
|
|
2674
|
+
* the oldest snapshots whichever device they came from. It is disclosed
|
|
2675
|
+
* rather than hidden — the name ordering is chronological across devices
|
|
2676
|
+
* because the stamp is fixed-width UTC.
|
|
2677
|
+
*
|
|
2678
|
+
* Files that do not match our naming pattern are never touched. Sizes come
|
|
2679
|
+
* from stat() rather than a maintained index because the directory is shared
|
|
2680
|
+
* with other processes and a stale index would prune the wrong files.
|
|
2556
2681
|
*/
|
|
2557
2682
|
const pruneHistory = async (dir) => {
|
|
2558
2683
|
const names = (await readdir(dir)).filter((name) => HISTORY_FILE_PATTERN.test(name))
|
|
@@ -2652,7 +2777,19 @@ export function apply(ctx) {
|
|
|
2652
2777
|
const source = await readFile(fileURLToPath(new URL('../' + SCHEMA_FILE_NAME, import.meta.url)), 'utf8')
|
|
2653
2778
|
const target = join(dirname(storePath), SCHEMA_FILE_NAME)
|
|
2654
2779
|
const existing = await readFile(target, 'utf8').catch(() => null)
|
|
2655
|
-
if (existing !== source)
|
|
2780
|
+
if (existing !== source) {
|
|
2781
|
+
// Atomic like the store itself: a crash mid-copy must not leave a
|
|
2782
|
+
// truncated schema for the user's editor to validate against, and the
|
|
2783
|
+
// 0600 bit matches every other file this plugin writes.
|
|
2784
|
+
const tmp = target + '.tmp-' + randomUUID()
|
|
2785
|
+
try {
|
|
2786
|
+
await writeFile(tmp, source, { encoding: 'utf8', mode: 0o600 })
|
|
2787
|
+
await rename(tmp, target)
|
|
2788
|
+
} catch (err) {
|
|
2789
|
+
await unlink(tmp).catch(() => {})
|
|
2790
|
+
throw err
|
|
2791
|
+
}
|
|
2792
|
+
}
|
|
2656
2793
|
} catch {
|
|
2657
2794
|
// Best effort by design.
|
|
2658
2795
|
}
|
|
@@ -2673,6 +2810,21 @@ export function apply(ctx) {
|
|
|
2673
2810
|
}
|
|
2674
2811
|
}
|
|
2675
2812
|
|
|
2813
|
+
/**
|
|
2814
|
+
* Best-effort durability fence. `rename` is atomic against a crash but NOT
|
|
2815
|
+
* durable on its own: without an fsync the directory entry can reach disk
|
|
2816
|
+
* after the data does, so power loss may leave the previous file or a 0-byte
|
|
2817
|
+
* one even though the save reported success. A failure here is deliberately
|
|
2818
|
+
* not a save failure (the bytes are already in the page cache, and Windows
|
|
2819
|
+
* refuses to open directories at all), so it degrades durability only.
|
|
2820
|
+
*/
|
|
2821
|
+
const syncPath = async (target) => {
|
|
2822
|
+
try {
|
|
2823
|
+
const fh = await open(target, 'r')
|
|
2824
|
+
try { await fh.sync() } finally { await fh.close() }
|
|
2825
|
+
} catch { /* unsupported here: durability degrades, correctness does not */ }
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2676
2828
|
const writeToDisk = async () => {
|
|
2677
2829
|
// Atomic replace (write a sibling temp file, then rename) with a rolling
|
|
2678
2830
|
// one-version backup: a crash mid-write must never leave a truncated
|
|
@@ -2687,7 +2839,13 @@ export function apply(ctx) {
|
|
|
2687
2839
|
// user's memory while their real data sat untouched in .bak. Copying keeps
|
|
2688
2840
|
// the primary in place until the atomic replace succeeds, so a failure at
|
|
2689
2841
|
// any step leaves the previous store readable at its own path.
|
|
2690
|
-
|
|
2842
|
+
// A UNIQUE tmp name per attempt: two processes sharing a $DSH_HOME must
|
|
2843
|
+
// never interleave their writes into one temp file. The fixed
|
|
2844
|
+
// `storePath + '.tmp'` meant the second writer's rename could publish a
|
|
2845
|
+
// half-written mix of both serialisations. The lock narrows the window; a
|
|
2846
|
+
// per-attempt name removes the shared resource, so the window cannot
|
|
2847
|
+
// corrupt anything even when the lock is unavailable.
|
|
2848
|
+
const tmpPath = storePath + '.tmp-' + process.pid + '-' + randomUUID()
|
|
2691
2849
|
let lock = null
|
|
2692
2850
|
let previousRev = null
|
|
2693
2851
|
try {
|
|
@@ -2730,6 +2888,10 @@ export function apply(ctx) {
|
|
|
2730
2888
|
const contentKey = JSON.stringify({ ...persistableData(), rev: 0 })
|
|
2731
2889
|
if (state.storeExists && lastPersistedKey !== null && contentKey === lastPersistedKey) {
|
|
2732
2890
|
logDebug('save skipped: the store content did not change')
|
|
2891
|
+
// The caller's "上次保存" clock reports the last successful SAVE, and
|
|
2892
|
+
// this call is one, even though the bytes were already correct. Leaving
|
|
2893
|
+
// it untouched made the panel look stalled after a no-op save.
|
|
2894
|
+
state.savedAt = Date.now()
|
|
2733
2895
|
return true
|
|
2734
2896
|
}
|
|
2735
2897
|
// The rev is bumped before serialising so the file on disk and the state
|
|
@@ -2747,7 +2909,15 @@ export function apply(ctx) {
|
|
|
2747
2909
|
// user profile) personal notes, so on a multi-user POSIX host it must not
|
|
2748
2910
|
// land world-readable the way a bare writeFile (0666 & ~umask = 0644)
|
|
2749
2911
|
// would. Windows ignores the bit and inherits the directory ACL instead.
|
|
2750
|
-
await
|
|
2912
|
+
const tmpHandle = await open(tmpPath, 'w', 0o600)
|
|
2913
|
+
try {
|
|
2914
|
+
await tmpHandle.writeFile(text, 'utf8')
|
|
2915
|
+
// Flush the temp file BEFORE the rename publishes it: otherwise a
|
|
2916
|
+
// power cut can leave the new name pointing at unwritten blocks.
|
|
2917
|
+
await tmpHandle.sync()
|
|
2918
|
+
} finally {
|
|
2919
|
+
await tmpHandle.close()
|
|
2920
|
+
}
|
|
2751
2921
|
// Both rolling copies stand for the PREVIOUS good version, so both are
|
|
2752
2922
|
// taken before the atomic replace, and neither may ever be renamed into
|
|
2753
2923
|
// place (see the long note above).
|
|
@@ -2762,8 +2932,22 @@ export function apply(ctx) {
|
|
|
2762
2932
|
// discloses it (the .bak may be stale, and the panel says so).
|
|
2763
2933
|
let hadPrevious = true
|
|
2764
2934
|
try {
|
|
2765
|
-
await readStoreFile(storePath)
|
|
2935
|
+
const previous = await readStoreFile(storePath)
|
|
2936
|
+
// "Reads as a store", not merely "reads as JSON" — this is the test the
|
|
2937
|
+
// comment above promises. A foreign-but-parseable primary (an array, a
|
|
2938
|
+
// scalar, another tool's object) must NOT be copied over the last good
|
|
2939
|
+
// backup: that is exactly what turns a recoverable state into a
|
|
2940
|
+
// permanent one.
|
|
2941
|
+
const previousShape = storeShapeVerdict(previous)
|
|
2942
|
+
if (previousShape === 'not-object' || previousShape === 'not-store') {
|
|
2943
|
+
const shapeError = new Error('memory.json is not a store (' + previousShape + ')')
|
|
2944
|
+
shapeError.code = NOT_A_STORE_CODE
|
|
2945
|
+
throw shapeError
|
|
2946
|
+
}
|
|
2766
2947
|
await copyFile(storePath, localBackupPath())
|
|
2948
|
+
// The recovery copy exists to survive a crash; an unflushed copy may
|
|
2949
|
+
// roll back with the very crash it is meant to answer.
|
|
2950
|
+
await syncPath(localBackupPath())
|
|
2767
2951
|
state.backupError = null
|
|
2768
2952
|
} catch (err) {
|
|
2769
2953
|
if (err !== null && err !== undefined && err.code === 'ENOENT') {
|
|
@@ -2772,6 +2956,12 @@ export function apply(ctx) {
|
|
|
2772
2956
|
// would adopt as an "empty store" — the trap this guard exists for.
|
|
2773
2957
|
hadPrevious = false
|
|
2774
2958
|
state.backupError = null
|
|
2959
|
+
} else if (err !== null && err !== undefined && err.code === NOT_A_STORE_CODE) {
|
|
2960
|
+
hadPrevious = false
|
|
2961
|
+
const previous = await stat(localBackupPath()).catch(() => null)
|
|
2962
|
+
state.backupError = previous !== null && previous.size > 0
|
|
2963
|
+
? 'memory.json 的内容不是记忆存储,已保留上一版备份 ' + localBackupPath() + ' 不被覆盖'
|
|
2964
|
+
: 'memory.json 的内容不是记忆存储,且没有可用的上一版备份'
|
|
2775
2965
|
} else {
|
|
2776
2966
|
hadPrevious = false
|
|
2777
2967
|
const previous = await stat(localBackupPath()).catch(() => null)
|
|
@@ -2800,6 +2990,9 @@ export function apply(ctx) {
|
|
|
2800
2990
|
await unlink(tmpPath).catch(() => {})
|
|
2801
2991
|
throw err
|
|
2802
2992
|
}
|
|
2993
|
+
// Make the directory entry itself durable: without this the rename can be
|
|
2994
|
+
// lost on power failure even though the file contents were flushed.
|
|
2995
|
+
await syncPath(dirname(storePath))
|
|
2803
2996
|
state.storagePath = storePath
|
|
2804
2997
|
state.storageDisplay = storePath
|
|
2805
2998
|
state.storageError = null
|
|
@@ -2816,6 +3009,9 @@ export function apply(ctx) {
|
|
|
2816
3009
|
// committed, and the store is on disk whatever happens next.
|
|
2817
3010
|
await writeHistorySnapshot(text, contentKey)
|
|
2818
3011
|
lastPersistedKey = contentKey
|
|
3012
|
+
// The disk holds a store again, so a "primary is not a store" refusal no
|
|
3013
|
+
// longer describes reality — this is the one place that can prove it.
|
|
3014
|
+
state.shapeRejected = false
|
|
2819
3015
|
await ensureSchemaFile()
|
|
2820
3016
|
logDebug('store written: ' + storePath + ' bytes=' + text.length
|
|
2821
3017
|
+ ' entries=' + state.data.entries.length + ' rev=' + state.data.rev
|
|
@@ -2891,6 +3087,13 @@ export function apply(ctx) {
|
|
|
2891
3087
|
state.storeExists = false
|
|
2892
3088
|
state.versionRejected = false
|
|
2893
3089
|
state.rejectedVersion = null
|
|
3090
|
+
state.shapeRejected = false
|
|
3091
|
+
// A reload replaces what "the last persisted content" means. Leaving the
|
|
3092
|
+
// old identity in place made a later save that happened to match the
|
|
3093
|
+
// PRE-reload content skip the write while reporting saved:true, so the disk
|
|
3094
|
+
// and the panel silently disagreed. Forcing the next save to write is the
|
|
3095
|
+
// cheap side of that trade.
|
|
3096
|
+
lastPersistedKey = null
|
|
2894
3097
|
|
|
2895
3098
|
/**
|
|
2896
3099
|
* A store declaring an unsupported format version: adopt nothing, write
|
|
@@ -2908,12 +3111,66 @@ export function apply(ctx) {
|
|
|
2908
3111
|
const rejectVersion = (version, source) => {
|
|
2909
3112
|
state.versionRejected = true
|
|
2910
3113
|
state.rejectedVersion = version
|
|
2911
|
-
|
|
3114
|
+
// A string that spells a supported number ("2") produced the absurd
|
|
3115
|
+
// "declared version 2, supported versions 1 / 2". The TYPE is the
|
|
3116
|
+
// problem, so say that instead of comparing it to the list.
|
|
3117
|
+
state.storageError = (typeof version === 'number'
|
|
3118
|
+
? '存储文件声明为格式版本 ' + version + ',本插件支持的格式版本为 ' + SUPPORTED_VERSIONS.join(' / ')
|
|
3119
|
+
: '存储文件的 version 字段类型不正确:必须是数字,当前是 ' + JSON.stringify(version === undefined ? null : version)
|
|
3120
|
+
+ ';本插件支持的格式版本为 ' + SUPPORTED_VERSIONS.join(' / '))
|
|
2912
3121
|
+ ';已拒绝载入以防数据损坏,原文件已保留,修改操作已暂停。请升级插件,或手动将文件改回受支持的版本后点「重新载入」'
|
|
2913
3122
|
state.loadedFrom = source + ' (unsupported version ' + version + ')'
|
|
2914
3123
|
state.storeExists = true
|
|
2915
3124
|
}
|
|
2916
3125
|
|
|
3126
|
+
/**
|
|
3127
|
+
* The primary parses as JSON but is not a store (an array, a scalar, or
|
|
3128
|
+
* another tool's object). Adopt NOTHING from it: the in-memory data — a
|
|
3129
|
+
* freshly booted empty store, or whatever this process already had — stays
|
|
3130
|
+
* authoritative, ordinary edits are refused, and both files stay on disk.
|
|
3131
|
+
* The repair doors (history restore, restore-from-backup, import, source
|
|
3132
|
+
* mode) remain open and clear the flag on the first successful write.
|
|
3133
|
+
*/
|
|
3134
|
+
const rejectShape = (describe) => {
|
|
3135
|
+
state.shapeRejected = true
|
|
3136
|
+
state.storageError = 'memory.json 的内容不是本插件的记忆存储:' + describe
|
|
3137
|
+
+ '。已暂停写入以免覆盖原文件(原文件已保留)。请用「历史回滚」或「用备份整体替换」恢复,或修正该文件后点「重新载入」'
|
|
3138
|
+
state.loadedFrom = 'memory.json (not a store; writes paused)'
|
|
3139
|
+
state.storeExists = true
|
|
3140
|
+
}
|
|
3141
|
+
|
|
3142
|
+
/**
|
|
3143
|
+
* A primary that is not a store still leaves the rolling backup intact, so
|
|
3144
|
+
* the last good version is usually one file away. Promote it into memory
|
|
3145
|
+
* (never back onto disk here) and disclose both facts: the primary is
|
|
3146
|
+
* foreign, and the panel is temporarily showing the backup.
|
|
3147
|
+
*/
|
|
3148
|
+
const recoverFromForeignPrimary = async (shape) => {
|
|
3149
|
+
state.storeExists = true
|
|
3150
|
+
const describe = shape === 'not-object'
|
|
3151
|
+
? '顶层不是对象(是数组或标量)'
|
|
3152
|
+
: '顶层对象里没有 entries / sets / version 等记忆字段'
|
|
3153
|
+
try {
|
|
3154
|
+
const backup = await readStoreFile(storePath + '.bak')
|
|
3155
|
+
if (backup !== null && storeShapeVerdict(backup) === 'store') {
|
|
3156
|
+
const loaded = normalizeData(backup)
|
|
3157
|
+
if (loaded.rejectedVersion !== undefined) {
|
|
3158
|
+
rejectVersion(loaded.rejectedVersion, 'memory.json.bak')
|
|
3159
|
+
return
|
|
3160
|
+
}
|
|
3161
|
+
state.data = loaded.data
|
|
3162
|
+
const warnings = loadWarnings(loaded)
|
|
3163
|
+
state.storageWarning = (warnings !== null ? warnings + ';' : '')
|
|
3164
|
+
+ 'memory.json ' + describe + ',不是本插件的记忆存储(原文件已保留);已临时回退到最近备份 memory.json.bak'
|
|
3165
|
+
state.loadedFrom = 'memory.json.bak (primary is not a store)'
|
|
3166
|
+
return
|
|
3167
|
+
}
|
|
3168
|
+
} catch {
|
|
3169
|
+
// No usable backup either; refuse rather than guess.
|
|
3170
|
+
}
|
|
3171
|
+
rejectShape(describe)
|
|
3172
|
+
}
|
|
3173
|
+
|
|
2917
3174
|
// 1. The canonical location under the harness home.
|
|
2918
3175
|
try {
|
|
2919
3176
|
const parsed = await readStoreFile(storePath)
|
|
@@ -2923,6 +3180,14 @@ export function apply(ctx) {
|
|
|
2923
3180
|
rejectVersion(loaded.rejectedVersion, 'memory.json')
|
|
2924
3181
|
return false
|
|
2925
3182
|
}
|
|
3183
|
+
// Parsing is not the same as being a store: an array or a foreign
|
|
3184
|
+
// object used to be adopted as "empty memory" with no warning, and the
|
|
3185
|
+
// next save wrote that emptiness back. Never adopt it.
|
|
3186
|
+
const shape = storeShapeVerdict(parsed)
|
|
3187
|
+
if (shape === 'not-object' || shape === 'not-store') {
|
|
3188
|
+
await recoverFromForeignPrimary(shape)
|
|
3189
|
+
return false
|
|
3190
|
+
}
|
|
2926
3191
|
state.data = loaded.data
|
|
2927
3192
|
state.storageWarning = loadWarnings(loaded)
|
|
2928
3193
|
state.loadedFrom = 'memory.json'
|
|
@@ -2964,7 +3229,7 @@ export function apply(ctx) {
|
|
|
2964
3229
|
: 'memory.json 解析失败(' + describeErrorSafe(err) + ')'
|
|
2965
3230
|
try {
|
|
2966
3231
|
const backup = await readStoreFile(storePath + '.bak')
|
|
2967
|
-
if (backup !== null) {
|
|
3232
|
+
if (backup !== null && storeShapeVerdict(backup) === 'store') {
|
|
2968
3233
|
const loaded = normalizeData(backup)
|
|
2969
3234
|
if (loaded.rejectedVersion !== undefined) {
|
|
2970
3235
|
rejectVersion(loaded.rejectedVersion, 'memory.json.bak')
|
|
@@ -3001,7 +3266,7 @@ export function apply(ctx) {
|
|
|
3001
3266
|
// canonical path so the next boot finds it normally.
|
|
3002
3267
|
try {
|
|
3003
3268
|
const backup = await readStoreFile(storePath + '.bak')
|
|
3004
|
-
if (backup !== null) {
|
|
3269
|
+
if (backup !== null && storeShapeVerdict(backup) === 'store') {
|
|
3005
3270
|
const loaded = normalizeData(backup)
|
|
3006
3271
|
if (loaded.rejectedVersion !== undefined) {
|
|
3007
3272
|
rejectVersion(loaded.rejectedVersion, 'memory.json.bak')
|
|
@@ -3026,22 +3291,29 @@ export function apply(ctx) {
|
|
|
3026
3291
|
try {
|
|
3027
3292
|
const parsed = await readStoreFile(legacyPath)
|
|
3028
3293
|
if (parsed !== null) {
|
|
3029
|
-
const
|
|
3030
|
-
if (
|
|
3031
|
-
//
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
}
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3294
|
+
const legacyShape = storeShapeVerdict(parsed)
|
|
3295
|
+
if (legacyShape === 'not-object' || legacyShape === 'not-store') {
|
|
3296
|
+
// Some other program's JSON beside the old store path is not a store
|
|
3297
|
+
// to migrate, and must never become memory.json.
|
|
3298
|
+
state.storageError = '旧位置文件不是本插件的记忆存储,已忽略'
|
|
3299
|
+
} else {
|
|
3300
|
+
const loaded = normalizeData(parsed)
|
|
3301
|
+
if (loaded.rejectedVersion !== undefined) {
|
|
3302
|
+
// A legacy file in a future format is not ours to migrate or judge.
|
|
3303
|
+
state.storageError = '旧位置文件为不支持的格式版本 ' + loaded.rejectedVersion + ',已忽略'
|
|
3304
|
+
return false
|
|
3305
|
+
}
|
|
3306
|
+
state.data = loaded.data
|
|
3307
|
+
state.storageWarning = loadWarnings(loaded)
|
|
3308
|
+
const migrated = await writeToDisk()
|
|
3309
|
+
if (migrated) {
|
|
3310
|
+
state.migratedFrom = legacyPath
|
|
3311
|
+
// Keep the original as evidence instead of deleting user data.
|
|
3312
|
+
await rename(legacyPath, legacyPath + '.migrated').catch(() => {})
|
|
3313
|
+
}
|
|
3314
|
+
state.loadedFrom = 'legacy cwd file (migrated)'
|
|
3315
|
+
return migrated
|
|
3042
3316
|
}
|
|
3043
|
-
state.loadedFrom = 'legacy cwd file (migrated)'
|
|
3044
|
-
return migrated
|
|
3045
3317
|
}
|
|
3046
3318
|
} catch (err) {
|
|
3047
3319
|
// A missing or unreadable legacy file must never block a fresh store.
|
|
@@ -3243,6 +3515,13 @@ export function apply(ctx) {
|
|
|
3243
3515
|
* to say which of the two situations it is looking at.
|
|
3244
3516
|
*/
|
|
3245
3517
|
authFence: connectionGate !== null,
|
|
3518
|
+
/**
|
|
3519
|
+
* How many hostnames DSH_INSTRUCTION_MEMORY_TRUSTED_HOSTS widened the
|
|
3520
|
+
* Host/Origin check to. The list relaxes a HOSTNAME rule; it is not an
|
|
3521
|
+
* identity check, so the panel must be able to say when a widened
|
|
3522
|
+
* allow-list is the only thing standing in front of a fence-less route.
|
|
3523
|
+
*/
|
|
3524
|
+
trustedHostCount: trustedHosts.length,
|
|
3246
3525
|
},
|
|
3247
3526
|
/**
|
|
3248
3527
|
* F12: load-side diagnostics that used to be log-only. `loadedFrom` says
|
|
@@ -3301,9 +3580,16 @@ export function apply(ctx) {
|
|
|
3301
3580
|
* Persona section detail: rendered profiles (id/name/chars, in order)
|
|
3302
3581
|
* and their total size. `entryChars` is the instruction half alone,
|
|
3303
3582
|
* which is what the budget meter measures; `chars` above stays the
|
|
3304
|
-
* whole section (personas + instructions) for diagnostics.
|
|
3583
|
+
* whole section (personas + instructions) for diagnostics. `truncated`
|
|
3584
|
+
* mirrors the entry list: a persona cut by the injection ceiling has to
|
|
3585
|
+
* be DISCLOSED, or the panel shows full text the model never received.
|
|
3305
3586
|
*/
|
|
3306
|
-
profiles: plan.profiles.map((item) => ({
|
|
3587
|
+
profiles: plan.profiles.map((item) => ({
|
|
3588
|
+
id: item.id,
|
|
3589
|
+
name: item.name,
|
|
3590
|
+
chars: item.chars,
|
|
3591
|
+
truncated: item.truncated === true,
|
|
3592
|
+
})),
|
|
3307
3593
|
profileChars: plan.profileChars,
|
|
3308
3594
|
entryChars: plan.entryChars,
|
|
3309
3595
|
/**
|
|
@@ -3394,6 +3680,20 @@ export function apply(ctx) {
|
|
|
3394
3680
|
snapshot: snapshot(),
|
|
3395
3681
|
})
|
|
3396
3682
|
|
|
3683
|
+
/**
|
|
3684
|
+
* Refusal while memory.json parses as JSON but is not a store at all (an
|
|
3685
|
+
* array, a scalar, another tool's object). Same reason as versionRefusal:
|
|
3686
|
+
* a save would rewrite the file in this build's format over content this
|
|
3687
|
+
* build never understood. The repair doors stay open — that is what
|
|
3688
|
+
* `shapeRejected` is for, and why they deliberately skip this check.
|
|
3689
|
+
*/
|
|
3690
|
+
const shapeRefusal = () => ({
|
|
3691
|
+
ok: false,
|
|
3692
|
+
saved: false,
|
|
3693
|
+
message: 'memory.json 的内容不是本插件的记忆存储,修改已暂停以免覆盖原文件。请用「历史回滚」或「用备份整体替换」恢复,或修正该文件后点「重新载入」',
|
|
3694
|
+
snapshot: snapshot(),
|
|
3695
|
+
})
|
|
3696
|
+
|
|
3397
3697
|
/**
|
|
3398
3698
|
* Adopt a whole parsed store — the shared core of 源码模式 (F16) and the
|
|
3399
3699
|
* 整体替换 restore (F14). Both hand the user the SAME safety story as every
|
|
@@ -3407,17 +3707,23 @@ export function apply(ctx) {
|
|
|
3407
3707
|
* before the path existed — must not silently redirect where backups go.
|
|
3408
3708
|
*/
|
|
3409
3709
|
const adoptParsedStore = (parsed) => {
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3710
|
+
// Version first. `{"version":3}` is a FUTURE store, and the honest answer
|
|
3711
|
+
// is "unsupported version", not "not a store" — an unsupported version is
|
|
3712
|
+
// by definition not a shape this build recognises, so the shape test below
|
|
3713
|
+
// would mislabel it.
|
|
3714
|
+
const loaded = normalizeData(parsed)
|
|
3715
|
+
if (loaded.rejectedVersion !== undefined) return { rejectedVersion: loaded.rejectedVersion }
|
|
3716
|
+
// Then shape, with the loader's own verdict rather than a looser one. A
|
|
3717
|
+
// file carrying a single stray option key (`{"version":2}`,
|
|
3718
|
+
// `{"enabled":false}`) is NOT a store to adopt wholesale: on the
|
|
3719
|
+
// import/restore path that would replace real memory with near-nothing and
|
|
3720
|
+
// report saved:true.
|
|
3721
|
+
if (storeShapeVerdict(parsed) !== 'store') {
|
|
3414
3722
|
return {
|
|
3415
|
-
error: '
|
|
3723
|
+
error: '内容不像本插件的存储文件:顶层需要是对象,且必须包含 entries / sets 数组(或受支持的 version)。'
|
|
3416
3724
|
+ '若要清空记忆,请使用界面上的删除操作,而不是保存一个空对象',
|
|
3417
3725
|
}
|
|
3418
3726
|
}
|
|
3419
|
-
const loaded = normalizeData(parsed)
|
|
3420
|
-
if (loaded.rejectedVersion !== undefined) return { rejectedVersion: loaded.rejectedVersion }
|
|
3421
3727
|
loaded.data.backupDir = backupDirOf()
|
|
3422
3728
|
return { loaded }
|
|
3423
3729
|
}
|
|
@@ -3489,6 +3795,7 @@ export function apply(ctx) {
|
|
|
3489
3795
|
|
|
3490
3796
|
const saveEntry = (raw, baseRev) => enqueue(async () => {
|
|
3491
3797
|
if (state.versionRejected) return versionRefusal()
|
|
3798
|
+
if (state.shapeRejected) return shapeRefusal()
|
|
3492
3799
|
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
3493
3800
|
const entry = sanitizeEntry(raw)
|
|
3494
3801
|
if (entry === null) return { ok: false, saved: false, message: '标题与指令内容不能同时为空', snapshot: snapshot() }
|
|
@@ -3540,6 +3847,7 @@ export function apply(ctx) {
|
|
|
3540
3847
|
|
|
3541
3848
|
const deleteEntry = (id, baseRev) => enqueue(async () => {
|
|
3542
3849
|
if (state.versionRejected) return versionRefusal()
|
|
3850
|
+
if (state.shapeRejected) return shapeRefusal()
|
|
3543
3851
|
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
3544
3852
|
// Deleting an id that is not there changes nothing, so it must not touch
|
|
3545
3853
|
// disk, bump rev, or push an echo frame — otherwise two panels deleting
|
|
@@ -3562,6 +3870,7 @@ export function apply(ctx) {
|
|
|
3562
3870
|
|
|
3563
3871
|
const setOptions = (patch, baseRev) => enqueue(async () => {
|
|
3564
3872
|
if (state.versionRejected) return versionRefusal()
|
|
3873
|
+
if (state.shapeRejected) return shapeRefusal()
|
|
3565
3874
|
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
3566
3875
|
// The custom backup location (F18) is validated BEFORE anything is queued
|
|
3567
3876
|
// into the store: an unusable path must be refused with a reason, not
|
|
@@ -3587,7 +3896,7 @@ export function apply(ctx) {
|
|
|
3587
3896
|
return {
|
|
3588
3897
|
ok: false,
|
|
3589
3898
|
saved: false,
|
|
3590
|
-
message: '备份位置必须位于 DSH 主目录内(' + dshRootPath() + ');要使用其他磁盘请设置环境变量 '
|
|
3899
|
+
message: '备份位置必须位于 DSH 主目录内(' + displayPath(dshRootPath()) + ');要使用其他磁盘请设置环境变量 '
|
|
3591
3900
|
+ ALLOW_EXTERNAL_BACKUP_ENV + '=1 后重启宿主',
|
|
3592
3901
|
snapshot: snapshot(),
|
|
3593
3902
|
}
|
|
@@ -3711,6 +4020,7 @@ export function apply(ctx) {
|
|
|
3711
4020
|
*/
|
|
3712
4021
|
const batchEntries = (op, ids, value, baseRev) => enqueue(async () => {
|
|
3713
4022
|
if (state.versionRejected) return versionRefusal()
|
|
4023
|
+
if (state.shapeRejected) return shapeRefusal()
|
|
3714
4024
|
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
3715
4025
|
if (typeof op !== 'string' || !BATCH_OPS.includes(op)) {
|
|
3716
4026
|
return { ok: false, saved: false, message: '未知的批量操作:' + String(op), snapshot: snapshot() }
|
|
@@ -3818,6 +4128,7 @@ export function apply(ctx) {
|
|
|
3818
4128
|
|
|
3819
4129
|
const saveProfile = (raw, baseRev) => enqueue(async () => {
|
|
3820
4130
|
if (state.versionRejected) return versionRefusal()
|
|
4131
|
+
if (state.shapeRejected) return shapeRefusal()
|
|
3821
4132
|
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
3822
4133
|
const profile = sanitizeProfile(raw)
|
|
3823
4134
|
if (profile === null) return { ok: false, saved: false, message: '形象名称与内容不能同时为空', snapshot: snapshot() }
|
|
@@ -3848,6 +4159,7 @@ export function apply(ctx) {
|
|
|
3848
4159
|
|
|
3849
4160
|
const deleteProfile = (id, baseRev) => enqueue(async () => {
|
|
3850
4161
|
if (state.versionRejected) return versionRefusal()
|
|
4162
|
+
if (state.shapeRejected) return shapeRefusal()
|
|
3851
4163
|
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
3852
4164
|
// Mirror deleteEntry: an absent id changes nothing and must not touch disk,
|
|
3853
4165
|
// and an id belonging to a different set is not this panel's to delete.
|
|
@@ -3875,6 +4187,7 @@ export function apply(ctx) {
|
|
|
3875
4187
|
*/
|
|
3876
4188
|
const saveSet = (raw, baseRev) => enqueue(async () => {
|
|
3877
4189
|
if (state.versionRejected) return versionRefusal()
|
|
4190
|
+
if (state.shapeRejected) return shapeRefusal()
|
|
3878
4191
|
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
3879
4192
|
const wanted = sanitizeSet(raw)
|
|
3880
4193
|
if (wanted === null) {
|
|
@@ -3915,6 +4228,7 @@ export function apply(ctx) {
|
|
|
3915
4228
|
*/
|
|
3916
4229
|
const deleteSet = (id, baseRev) => enqueue(async () => {
|
|
3917
4230
|
if (state.versionRejected) return versionRefusal()
|
|
4231
|
+
if (state.shapeRejected) return shapeRefusal()
|
|
3918
4232
|
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
3919
4233
|
const index = state.data.sets.findIndex((set) => set.id === id)
|
|
3920
4234
|
if (index < 0) {
|
|
@@ -3943,6 +4257,7 @@ export function apply(ctx) {
|
|
|
3943
4257
|
*/
|
|
3944
4258
|
const setActiveSet = (id, baseRev) => enqueue(async () => {
|
|
3945
4259
|
if (state.versionRejected) return versionRefusal()
|
|
4260
|
+
if (state.shapeRejected) return shapeRefusal()
|
|
3946
4261
|
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
3947
4262
|
const target = state.data.sets.find((set) => set.id === id)
|
|
3948
4263
|
if (target === undefined) {
|
|
@@ -3964,6 +4279,7 @@ export function apply(ctx) {
|
|
|
3964
4279
|
*/
|
|
3965
4280
|
const duplicateSet = (id, name, baseRev) => enqueue(async () => {
|
|
3966
4281
|
if (state.versionRejected) return versionRefusal()
|
|
4282
|
+
if (state.shapeRejected) return shapeRefusal()
|
|
3967
4283
|
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
3968
4284
|
const source = state.data.sets.find((set) => set.id === id)
|
|
3969
4285
|
if (source === undefined) {
|
|
@@ -4093,13 +4409,28 @@ export function apply(ctx) {
|
|
|
4093
4409
|
*/
|
|
4094
4410
|
const historyRestore = (name, baseRev) => enqueue(async () => {
|
|
4095
4411
|
if (state.versionRejected) return versionRefusal()
|
|
4412
|
+
// Deliberately NOT gated on shapeRejected: restoring a snapshot is the
|
|
4413
|
+
// repair door for a primary that is not a store.
|
|
4096
4414
|
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
4097
4415
|
if (typeof name !== 'string' || !HISTORY_FILE_PATTERN.test(name)) {
|
|
4098
4416
|
return { ok: false, saved: false, message: '历史快照名称无效', snapshot: snapshot() }
|
|
4099
4417
|
}
|
|
4100
4418
|
let parsed = null
|
|
4419
|
+
const snapshotPath = join(historyDirPath(), name)
|
|
4101
4420
|
try {
|
|
4102
|
-
|
|
4421
|
+
// Bounded read: stat first, so an absurd file in a shared backup folder
|
|
4422
|
+
// (or a corrupted one) is refused instead of pulled into memory whole.
|
|
4423
|
+
const info = await stat(snapshotPath)
|
|
4424
|
+
if (!info.isFile()) throw new Error('不是普通文件')
|
|
4425
|
+
if (info.size > MAX_HISTORY_FILE_BYTES) {
|
|
4426
|
+
return {
|
|
4427
|
+
ok: false,
|
|
4428
|
+
saved: false,
|
|
4429
|
+
message: '该历史快照过大(' + info.size + ' 字节,上限 ' + MAX_HISTORY_FILE_BYTES + ' 字节),已拒绝读取',
|
|
4430
|
+
snapshot: snapshot(),
|
|
4431
|
+
}
|
|
4432
|
+
}
|
|
4433
|
+
parsed = JSON.parse(await readFile(snapshotPath, 'utf8'))
|
|
4103
4434
|
} catch (err) {
|
|
4104
4435
|
return { ok: false, saved: false, message: '无法读取该历史快照:' + describeErrorSafe(err), snapshot: snapshot() }
|
|
4105
4436
|
}
|
|
@@ -4199,7 +4530,7 @@ export function apply(ctx) {
|
|
|
4199
4530
|
return {
|
|
4200
4531
|
ok: false,
|
|
4201
4532
|
saved: false,
|
|
4202
|
-
message: '导出目录必须位于 DSH 主目录内(' + dshRootPath() + ');要写入其他磁盘请设置环境变量 '
|
|
4533
|
+
message: '导出目录必须位于 DSH 主目录内(' + displayPath(dshRootPath()) + ');要写入其他磁盘请设置环境变量 '
|
|
4203
4534
|
+ ALLOW_EXTERNAL_BACKUP_ENV + '=1 后重启宿主',
|
|
4204
4535
|
snapshot: snapshot(),
|
|
4205
4536
|
}
|
|
@@ -4294,6 +4625,8 @@ export function apply(ctx) {
|
|
|
4294
4625
|
|
|
4295
4626
|
const writeSource = (text, baseRev) => enqueue(async () => {
|
|
4296
4627
|
if (state.versionRejected) return versionRefusal()
|
|
4628
|
+
// Deliberately NOT gated on shapeRejected: hand-editing the source is how
|
|
4629
|
+
// a user repairs a primary that is not a store.
|
|
4297
4630
|
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
4298
4631
|
if (typeof text !== 'string') {
|
|
4299
4632
|
return { ok: false, saved: false, message: '源码必须是 JSON 文本', snapshot: snapshot() }
|
|
@@ -4324,6 +4657,8 @@ export function apply(ctx) {
|
|
|
4324
4657
|
*/
|
|
4325
4658
|
const restoreData = (payload, baseRev) => enqueue(async () => {
|
|
4326
4659
|
if (state.versionRejected) return versionRefusal()
|
|
4660
|
+
// Deliberately NOT gated on shapeRejected: wholesale replacement from a
|
|
4661
|
+
// backup is the repair door for a primary that is not a store.
|
|
4327
4662
|
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
4328
4663
|
if (payload === null || typeof payload !== 'object') {
|
|
4329
4664
|
return { ok: false, saved: false, message: '恢复内容不是有效的 JSON 对象', snapshot: snapshot() }
|
|
@@ -4337,6 +4672,8 @@ export function apply(ctx) {
|
|
|
4337
4672
|
|
|
4338
4673
|
const importData = (payload, baseRev) => enqueue(async () => {
|
|
4339
4674
|
if (state.versionRejected) return versionRefusal()
|
|
4675
|
+
// Deliberately NOT gated on shapeRejected: importing a real store is a
|
|
4676
|
+
// repair path for a primary that is not one.
|
|
4340
4677
|
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
4341
4678
|
if (payload === null || typeof payload !== 'object') {
|
|
4342
4679
|
return { ok: false, saved: false, message: '导入内容不是有效的 JSON 对象', snapshot: snapshot() }
|
|
@@ -4624,6 +4961,14 @@ export function apply(ctx) {
|
|
|
4624
4961
|
const readFeedbackConfigFile = async (path) => {
|
|
4625
4962
|
let text
|
|
4626
4963
|
try {
|
|
4964
|
+
// Bounded read: this file is operator-authored, but a wrong path (a log,
|
|
4965
|
+
// a database) must not be dragged into memory just because the send
|
|
4966
|
+
// button was pressed.
|
|
4967
|
+
const info = await stat(path)
|
|
4968
|
+
if (!info.isFile()) throw new Error('不是普通文件')
|
|
4969
|
+
if (info.size > MAX_FEEDBACK_CONFIG_BYTES) {
|
|
4970
|
+
throw new Error('文件过大(' + info.size + ' 字节,上限 ' + MAX_FEEDBACK_CONFIG_BYTES + ' 字节)')
|
|
4971
|
+
}
|
|
4627
4972
|
text = await readFile(path, 'utf8')
|
|
4628
4973
|
} catch (err) {
|
|
4629
4974
|
if (err !== null && typeof err === 'object' && err.code === 'ENOENT') return null
|
|
@@ -5005,9 +5350,16 @@ export function apply(ctx) {
|
|
|
5005
5350
|
logInfo('connection fence threw, refusing the request: ' + describeError(err))
|
|
5006
5351
|
rejection = 401
|
|
5007
5352
|
}
|
|
5008
|
-
|
|
5009
|
-
|
|
5010
|
-
|
|
5353
|
+
// Only the documented "admit" values pass. Anything else — a future
|
|
5354
|
+
// `{status:401}`, an Error, a string — is treated as a refusal, because
|
|
5355
|
+
// an authentication gate that silently opens on an unexpected shape is
|
|
5356
|
+
// not a gate. The numeric status is preserved when it is plausible.
|
|
5357
|
+
const admitted = rejection === undefined || rejection === null || rejection === false
|
|
5358
|
+
if (!admitted) {
|
|
5359
|
+
const status = typeof rejection === 'number' && rejection >= 400 && rejection <= 599 ? rejection : 401
|
|
5360
|
+
logInfo('route refused a request (' + status + ', fence returned '
|
|
5361
|
+
+ (typeof rejection === 'number' ? rejection : typeof rejection) + ')')
|
|
5362
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
5011
5363
|
res.end(JSON.stringify({
|
|
5012
5364
|
ok: false,
|
|
5013
5365
|
message: rejection === 403
|