@summerscar/dsh-mnemon-sync 0.1.8 → 0.1.10
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/index.js +44 -14
- package/package.json +1 -1
- package/targets.js +11 -2
package/index.js
CHANGED
|
@@ -123,7 +123,6 @@ export function archiveKey(at = new Date(), device = 'device') {
|
|
|
123
123
|
const stamp = at.toISOString().replace(/:/gu, '-')
|
|
124
124
|
return `${ARCHIVE_PREFIX}${device}-${stamp}.zip`
|
|
125
125
|
}
|
|
126
|
-
|
|
127
126
|
export function apply(ctx, config = {}) {
|
|
128
127
|
const dataRoot = resolveDataRoot(config)
|
|
129
128
|
// `<home>/data` → `<home>`: settings and work files sit beside the store.
|
|
@@ -487,14 +486,21 @@ export function apply(ctx, config = {}) {
|
|
|
487
486
|
}))
|
|
488
487
|
const restorable = requested.filter((component) => present.includes(component))
|
|
489
488
|
|
|
490
|
-
// Plan first, so a dry run and a real run describe the same work.
|
|
489
|
+
// Plan first, so a dry run and a real run describe the same work. Entries
|
|
490
|
+
// whose path does not resolve into a component directory (a foreign file
|
|
491
|
+
// smuggled into the archive) are rejected outright rather than skipped:
|
|
492
|
+
// a listing that silently drops entries would hide what a restore does.
|
|
491
493
|
const plan = []
|
|
492
494
|
for (const component of restorable) {
|
|
493
495
|
for (const [relative, content] of files.entries()) {
|
|
494
496
|
if (relative === 'manifest.json') continue
|
|
495
497
|
const director = componentDirectoryOf(relative)
|
|
496
498
|
if (director !== component) continue
|
|
497
|
-
|
|
499
|
+
const destination = destinationFor(home, relative)
|
|
500
|
+
if (destination === undefined) {
|
|
501
|
+
throw new Error(`archive entry cannot be restored safely: ${relative}`)
|
|
502
|
+
}
|
|
503
|
+
plan.push({ component, relative, destination, bytes: content.length })
|
|
498
504
|
}
|
|
499
505
|
}
|
|
500
506
|
|
|
@@ -594,20 +600,44 @@ function componentDirectoryOf(relative) {
|
|
|
594
600
|
}
|
|
595
601
|
|
|
596
602
|
/**
|
|
597
|
-
*
|
|
603
|
+
* Archive payload directories that a restore may write, mapped to the live
|
|
604
|
+
* directories they land in. `state/` is deliberately absent: it holds this
|
|
605
|
+
* plugin's credentials and dsh-mnemon's machine identity, and no backup
|
|
606
|
+
* component backs it up.
|
|
607
|
+
*/
|
|
608
|
+
const RESTORE_DIRECTORIES = {
|
|
609
|
+
data: 'data',
|
|
610
|
+
documents: 'documents',
|
|
611
|
+
runtime: 'runtime',
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Where one archive path lands in the Mnemon home, or `undefined` when the
|
|
616
|
+
* path is not part of a restorable component.
|
|
598
617
|
*
|
|
599
|
-
*
|
|
600
|
-
*
|
|
601
|
-
*
|
|
602
|
-
*
|
|
618
|
+
* The check is deliberately stricter than "stays inside the home": a restore
|
|
619
|
+
* must only ever touch the three component directories (`data/`, `documents/`,
|
|
620
|
+
* `runtime/`). Everything else in the home — `state/` with this plugin's own
|
|
621
|
+
* credentials and dsh-mnemon's machine identity, `skills/`, the archive
|
|
622
|
+
* workroot — must be unreachable from an archive, because the archive is
|
|
623
|
+
* untrusted data. A zip entry like `payload/data/../state/mnemon-sync.json`
|
|
624
|
+
* resolves inside the home yet would overwrite the stored credentials, so the
|
|
625
|
+
* first path segment after `payload/` is matched exactly against the three
|
|
626
|
+
* component names and no `..` or nested separator survives.
|
|
603
627
|
*/
|
|
604
628
|
function destinationFor(home, relative) {
|
|
605
|
-
|
|
606
|
-
const
|
|
607
|
-
const
|
|
608
|
-
if (
|
|
609
|
-
|
|
610
|
-
|
|
629
|
+
if (!relative.startsWith('payload/')) return undefined
|
|
630
|
+
const stripped = relative.slice('payload/'.length)
|
|
631
|
+
const separator = stripped.indexOf('/')
|
|
632
|
+
if (separator <= 0) return undefined
|
|
633
|
+
const topDirectory = stripped.slice(0, separator)
|
|
634
|
+
const componentDirectory = RESTORE_DIRECTORIES[topDirectory]
|
|
635
|
+
if (componentDirectory === undefined) return undefined
|
|
636
|
+
const rest = stripped.slice(separator + 1)
|
|
637
|
+
if (rest === '' || rest.includes('\0') || rest.split('/').includes('..')) return undefined
|
|
638
|
+
const destination = resolve(home, componentDirectory, ...rest.split('/'))
|
|
639
|
+
const root = resolve(home, componentDirectory)
|
|
640
|
+
if (!destination.startsWith(`${root}/`)) return undefined
|
|
611
641
|
return destination
|
|
612
642
|
}
|
|
613
643
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@summerscar/dsh-mnemon-sync",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "Back up and restore dsh-mnemon memory components (memory spaces, project documents, runtime memory) to S3-compatible storage or WebDAV.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
package/targets.js
CHANGED
|
@@ -467,7 +467,10 @@ function encodeRfc3986(value) {
|
|
|
467
467
|
* misconfigured, and paging the whole bucket would cost more than it explains.
|
|
468
468
|
*/
|
|
469
469
|
async function listS3(target, signal) {
|
|
470
|
-
|
|
470
|
+
// ListObjectsV2 operates on the bucket: the path is the bucket alone and
|
|
471
|
+
// the prefix carries the folder. s3Url(target, '') would put remotePath in
|
|
472
|
+
// the path, and the store then answers 404 NoSuchKey for the missing object.
|
|
473
|
+
const url = new URL(s3Endpoint(target))
|
|
471
474
|
url.searchParams.set('list-type', '2')
|
|
472
475
|
url.searchParams.set('prefix', `${target.remotePath}/`)
|
|
473
476
|
const { response } = await signedS3UrlRequest(target, 'GET', url, signal)
|
|
@@ -519,7 +522,13 @@ async function signedS3UrlRequest(target, method, url, signal) {
|
|
|
519
522
|
const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256Hex(canonicalRequest)].join('\n')
|
|
520
523
|
const signingKey = signatureKey(target.secretAccessKey, dateStamp, target.region)
|
|
521
524
|
const signature = createHmac('sha256', signingKey).update(stringToSign).digest('hex')
|
|
522
|
-
|
|
525
|
+
// The signature covers the canonical (RFC 3986) query, but URLSearchParams
|
|
526
|
+
// re-encodes spaces as `+` when the URL stringifies, which would not match
|
|
527
|
+
// what was signed. Serialize the query for the wire with the same encoding.
|
|
528
|
+
const wireQuery = [...url.searchParams.entries()]
|
|
529
|
+
.map(([name, value]) => `${encodeRfc3986(name)}=${encodeRfc3986(value)}`)
|
|
530
|
+
.join('&')
|
|
531
|
+
const response = await fetch(`${url.origin}${url.pathname}?${wireQuery}`, {
|
|
523
532
|
method,
|
|
524
533
|
headers: {
|
|
525
534
|
...headers,
|