@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.
Files changed (3) hide show
  1. package/index.js +44 -14
  2. package/package.json +1 -1
  3. 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
- plan.push({ component, relative, destination: destinationFor(home, relative), bytes: content.length })
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
- * Where one archive path lands in the Mnemon home.
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
- * `payload/` is dropped and the component directories are the same names the
600
- * store itself uses, so a restored tree is byte-identical in layout to a live
601
- * one. The path is resolved and checked so a malformed archive cannot walk out
602
- * of the home directory.
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
- const stripped = relative.replace(/^payload\//u, '')
606
- const destination = resolve(home, stripped)
607
- const root = resolve(home)
608
- if (destination !== root && !destination.startsWith(`${root}/`)) {
609
- throw new Error(`archive entry escapes the Mnemon home: ${relative}`)
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.8",
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
- const url = new URL(s3Url(target, ''))
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
- const response = await fetch(url, {
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,