@logictan/dsh-config-manager 0.1.60
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 +21 -0
- package/README.md +105 -0
- package/cordis.patch.yml +21 -0
- package/lib/adapters/agent-instructions.d.ts +13 -0
- package/lib/adapters/agent-instructions.js +46 -0
- package/lib/adapters/agent-presets.d.ts +13 -0
- package/lib/adapters/agent-presets.js +14 -0
- package/lib/adapters/credentials.d.ts +37 -0
- package/lib/adapters/credentials.js +122 -0
- package/lib/adapters/file-collection.d.ts +15 -0
- package/lib/adapters/file-collection.js +127 -0
- package/lib/adapters/index.d.ts +57 -0
- package/lib/adapters/index.js +55 -0
- package/lib/adapters/link-report.d.ts +16 -0
- package/lib/adapters/link-report.js +47 -0
- package/lib/adapters/mcp.d.ts +28 -0
- package/lib/adapters/mcp.js +178 -0
- package/lib/adapters/plugin-files.d.ts +18 -0
- package/lib/adapters/plugin-files.js +142 -0
- package/lib/adapters/plugins.d.ts +41 -0
- package/lib/adapters/plugins.js +603 -0
- package/lib/adapters/pnpm-workspace.d.ts +26 -0
- package/lib/adapters/pnpm-workspace.js +147 -0
- package/lib/adapters/prompts.d.ts +30 -0
- package/lib/adapters/prompts.js +231 -0
- package/lib/adapters/providers.d.ts +34 -0
- package/lib/adapters/providers.js +155 -0
- package/lib/adapters/self.d.ts +17 -0
- package/lib/adapters/self.js +71 -0
- package/lib/adapters/sessions.d.ts +14 -0
- package/lib/adapters/sessions.js +15 -0
- package/lib/adapters/settings.d.ts +28 -0
- package/lib/adapters/settings.js +165 -0
- package/lib/adapters/skills.d.ts +13 -0
- package/lib/adapters/skills.js +14 -0
- package/lib/adapters/test-helpers.d.ts +125 -0
- package/lib/adapters/test-helpers.js +236 -0
- package/lib/adapters/ui.d.ts +27 -0
- package/lib/adapters/ui.js +89 -0
- package/lib/adapters/workspaces.d.ts +13 -0
- package/lib/adapters/workspaces.js +106 -0
- package/lib/client.d.ts +1026 -0
- package/lib/client.js +10047 -0
- package/lib/core/analyzer.d.ts +99 -0
- package/lib/core/analyzer.js +934 -0
- package/lib/core/backup.d.ts +138 -0
- package/lib/core/backup.js +513 -0
- package/lib/core/boot-rescue.d.ts +146 -0
- package/lib/core/boot-rescue.js +618 -0
- package/lib/core/cache-cleaner.d.ts +31 -0
- package/lib/core/cache-cleaner.js +95 -0
- package/lib/core/config-lifecycle.d.ts +180 -0
- package/lib/core/config-lifecycle.js +481 -0
- package/lib/core/config-snapshot.d.ts +135 -0
- package/lib/core/config-snapshot.js +424 -0
- package/lib/core/config-state.d.ts +70 -0
- package/lib/core/config-state.js +177 -0
- package/lib/core/consult-source.d.ts +58 -0
- package/lib/core/consult-source.js +260 -0
- package/lib/core/crash-report.d.ts +84 -0
- package/lib/core/crash-report.js +317 -0
- package/lib/core/exporter.d.ts +68 -0
- package/lib/core/exporter.js +375 -0
- package/lib/core/ghost-sweep.d.ts +19 -0
- package/lib/core/ghost-sweep.js +52 -0
- package/lib/core/importer.d.ts +60 -0
- package/lib/core/importer.js +29 -0
- package/lib/core/index.d.ts +14 -0
- package/lib/core/index.js +14 -0
- package/lib/core/journal.d.ts +197 -0
- package/lib/core/journal.js +418 -0
- package/lib/core/local-plugin-host.d.ts +35 -0
- package/lib/core/local-plugin-host.js +120 -0
- package/lib/core/local-plugin-pack.d.ts +135 -0
- package/lib/core/local-plugin-pack.js +278 -0
- package/lib/core/messages.d.ts +310 -0
- package/lib/core/messages.js +621 -0
- package/lib/core/migration-consult.d.ts +117 -0
- package/lib/core/migration-consult.js +363 -0
- package/lib/core/migration-history.d.ts +153 -0
- package/lib/core/migration-history.js +411 -0
- package/lib/core/model-tools.d.ts +50 -0
- package/lib/core/model-tools.js +148 -0
- package/lib/core/msg-types.d.ts +8 -0
- package/lib/core/msg-types.js +6 -0
- package/lib/core/phase3-child-crash.d.ts +1 -0
- package/lib/core/phase3-child-crash.js +87 -0
- package/lib/core/phase3-host.d.ts +138 -0
- package/lib/core/phase3-host.js +359 -0
- package/lib/core/phase3-prod-child.d.ts +1 -0
- package/lib/core/phase3-prod-child.js +35 -0
- package/lib/core/phase4-crash-child.d.ts +1 -0
- package/lib/core/phase4-crash-child.js +103 -0
- package/lib/core/plugin-cli.d.ts +150 -0
- package/lib/core/plugin-cli.js +497 -0
- package/lib/core/reconcile.d.ts +120 -0
- package/lib/core/reconcile.js +389 -0
- package/lib/core/recovery-orchestrator.d.ts +94 -0
- package/lib/core/recovery-orchestrator.js +327 -0
- package/lib/core/restore.d.ts +162 -0
- package/lib/core/restore.js +703 -0
- package/lib/core/rollback.d.ts +12 -0
- package/lib/core/rollback.js +145 -0
- package/lib/core/run-registry.d.ts +85 -0
- package/lib/core/run-registry.js +157 -0
- package/lib/core/startup-barrier.d.ts +53 -0
- package/lib/core/startup-barrier.js +77 -0
- package/lib/core/types.d.ts +515 -0
- package/lib/core/types.js +27 -0
- package/lib/core/undo.d.ts +73 -0
- package/lib/core/undo.js +87 -0
- package/lib/core/validator.d.ts +18 -0
- package/lib/core/validator.js +77 -0
- package/lib/core/verify-recovery.d.ts +21 -0
- package/lib/core/verify-recovery.js +256 -0
- package/lib/core/watcher.d.ts +101 -0
- package/lib/core/watcher.js +219 -0
- package/lib/index.d.ts +238 -0
- package/lib/index.js +2764 -0
- package/lib/migrations/index.d.ts +29 -0
- package/lib/migrations/index.js +56 -0
- package/lib/migrations/v1-to-v2.d.ts +9 -0
- package/lib/migrations/v1-to-v2.js +10 -0
- package/lib/profiles/index.d.ts +4 -0
- package/lib/profiles/index.js +5 -0
- package/lib/profiles/profile-manager.d.ts +106 -0
- package/lib/profiles/profile-manager.js +543 -0
- package/lib/schema/config.d.ts +28 -0
- package/lib/schema/config.js +165 -0
- package/lib/schema/index.d.ts +24 -0
- package/lib/schema/index.js +26 -0
- package/lib/schema/manifest.d.ts +28 -0
- package/lib/schema/manifest.js +109 -0
- package/lib/schema/tombstones.d.ts +46 -0
- package/lib/schema/tombstones.js +64 -0
- package/lib/schema/types.d.ts +232 -0
- package/lib/schema/types.js +8 -0
- package/lib/schema/versions.d.ts +27 -0
- package/lib/schema/versions.js +50 -0
- package/lib/security/encryption.d.ts +85 -0
- package/lib/security/encryption.js +279 -0
- package/lib/security/index.d.ts +16 -0
- package/lib/security/index.js +17 -0
- package/lib/security/integrity.d.ts +30 -0
- package/lib/security/integrity.js +78 -0
- package/lib/security/redaction.d.ts +26 -0
- package/lib/security/redaction.js +95 -0
- package/lib/security/secret-scanner.d.ts +86 -0
- package/lib/security/secret-scanner.js +404 -0
- package/lib/security/vault.d.ts +59 -0
- package/lib/security/vault.js +151 -0
- package/lib/security/zip-security.d.ts +27 -0
- package/lib/security/zip-security.js +154 -0
- package/lib/sync/autosync-config.d.ts +39 -0
- package/lib/sync/autosync-config.js +151 -0
- package/lib/sync/autosync-scheduler.d.ts +139 -0
- package/lib/sync/autosync-scheduler.js +564 -0
- package/lib/sync/fs.d.ts +18 -0
- package/lib/sync/fs.js +49 -0
- package/lib/sync/git/git-transport.d.ts +100 -0
- package/lib/sync/git/git-transport.js +437 -0
- package/lib/sync/github-auth.d.ts +131 -0
- package/lib/sync/github-auth.js +264 -0
- package/lib/sync/layout.d.ts +43 -0
- package/lib/sync/layout.js +203 -0
- package/lib/sync/snapshot-json.d.ts +9 -0
- package/lib/sync/snapshot-json.js +107 -0
- package/lib/sync/sync-config.d.ts +81 -0
- package/lib/sync/sync-config.js +281 -0
- package/lib/sync/sync-engine.d.ts +269 -0
- package/lib/sync/sync-engine.js +591 -0
- package/lib/sync/sync-history.d.ts +42 -0
- package/lib/sync/sync-history.js +78 -0
- package/lib/sync/sync-selection.d.ts +29 -0
- package/lib/sync/sync-selection.js +127 -0
- package/lib/sync/sync-session.d.ts +40 -0
- package/lib/sync/sync-session.js +54 -0
- package/lib/sync/sync-state.d.ts +37 -0
- package/lib/sync/sync-state.js +108 -0
- package/lib/sync/transport.d.ts +75 -0
- package/lib/sync/transport.js +55 -0
- package/lib/sync/ui-prefs.d.ts +34 -0
- package/lib/sync/ui-prefs.js +109 -0
- package/lib/sync/webdav/webdav-transport.d.ts +77 -0
- package/lib/sync/webdav/webdav-transport.js +451 -0
- package/lib/ui/errors.d.ts +19 -0
- package/lib/ui/errors.js +81 -0
- package/lib/ui/export-flow.d.ts +82 -0
- package/lib/ui/export-flow.js +98 -0
- package/lib/ui/i18n.d.ts +289 -0
- package/lib/ui/i18n.js +581 -0
- package/lib/ui/migration-consult-view.d.ts +91 -0
- package/lib/ui/migration-consult-view.js +115 -0
- package/lib/ui/progress.d.ts +38 -0
- package/lib/ui/progress.js +87 -0
- package/lib/ui/report.d.ts +23 -0
- package/lib/ui/report.js +153 -0
- package/lib/ui/test-helpers.d.ts +69 -0
- package/lib/ui/test-helpers.js +154 -0
- package/lib/ui/types.d.ts +257 -0
- package/lib/ui/types.js +21 -0
- package/lib/utils/atomic-write.d.ts +88 -0
- package/lib/utils/atomic-write.js +399 -0
- package/lib/utils/env-lock.d.ts +393 -0
- package/lib/utils/env-lock.js +930 -0
- package/lib/utils/hashing.d.ts +17 -0
- package/lib/utils/hashing.js +41 -0
- package/lib/utils/json.d.ts +24 -0
- package/lib/utils/json.js +76 -0
- package/lib/utils/logger.d.ts +39 -0
- package/lib/utils/logger.js +82 -0
- package/lib/utils/paths.d.ts +42 -0
- package/lib/utils/paths.js +189 -0
- package/lib/utils/proxy.d.ts +69 -0
- package/lib/utils/proxy.js +455 -0
- package/lib/utils/recursive-walk.d.ts +24 -0
- package/lib/utils/recursive-walk.js +134 -0
- package/lib/utils/zip.d.ts +54 -0
- package/lib/utils/zip.js +285 -0
- package/package.json +188 -0
- package/src/adapters/agent-instructions.ts +48 -0
- package/src/adapters/agent-presets.ts +14 -0
- package/src/adapters/credentials.test.ts +90 -0
- package/src/adapters/credentials.ts +143 -0
- package/src/adapters/file-collection.ts +137 -0
- package/src/adapters/files.test.ts +353 -0
- package/src/adapters/index.ts +94 -0
- package/src/adapters/link-report.test.ts +65 -0
- package/src/adapters/link-report.ts +61 -0
- package/src/adapters/mcp.test.ts +96 -0
- package/src/adapters/mcp.ts +185 -0
- package/src/adapters/plugin-files.ts +146 -0
- package/src/adapters/plugins.test.ts +326 -0
- package/src/adapters/plugins.ts +615 -0
- package/src/adapters/pnpm-workspace.test.ts +87 -0
- package/src/adapters/pnpm-workspace.ts +150 -0
- package/src/adapters/prompts.test.ts +110 -0
- package/src/adapters/prompts.ts +230 -0
- package/src/adapters/providers.test.ts +126 -0
- package/src/adapters/providers.ts +173 -0
- package/src/adapters/roundtrip.test.ts +190 -0
- package/src/adapters/self.test.ts +92 -0
- package/src/adapters/self.ts +75 -0
- package/src/adapters/sessions.ts +15 -0
- package/src/adapters/settings.test.ts +100 -0
- package/src/adapters/settings.ts +187 -0
- package/src/adapters/skills.ts +14 -0
- package/src/adapters/test-helpers.ts +248 -0
- package/src/adapters/ui.test.ts +57 -0
- package/src/adapters/ui.ts +108 -0
- package/src/adapters/workspaces.test.ts +106 -0
- package/src/adapters/workspaces.ts +109 -0
- package/src/client/ConfigManagerSection.tsx +90 -0
- package/src/client/api.ts +66 -0
- package/src/client/client-types.ts +18 -0
- package/src/client/common/ErrorBanner.tsx +73 -0
- package/src/client/common/Icon.tsx +129 -0
- package/src/client/common/Modal.tsx +173 -0
- package/src/client/common/ToastViewport.tsx +73 -0
- package/src/client/common/toast-store.test.ts +192 -0
- package/src/client/common/toast-store.ts +181 -0
- package/src/client/common/toast-types.ts +7 -0
- package/src/client/common/ui.tsx +342 -0
- package/src/client/config-manager.module.css +2971 -0
- package/src/client/css-modules.d.ts +8 -0
- package/src/client/index.ts +65 -0
- package/src/client/locales.ts +56 -0
- package/src/client/lucide-icons.d.ts +38 -0
- package/src/client/run-store.test.ts +223 -0
- package/src/client/run-store.ts +372 -0
- package/src/client/sync/SyncConfirmView.tsx +379 -0
- package/src/client/sync/SyncHistoryView.test.ts +40 -0
- package/src/client/sync/SyncHistoryView.tsx +220 -0
- package/src/client/sync/SyncSettingsView.tsx +1535 -0
- package/src/client/sync/history-model.test.ts +194 -0
- package/src/client/sync/history-model.ts +195 -0
- package/src/client/sync/sync-api.test.ts +585 -0
- package/src/client/sync/sync-api.ts +543 -0
- package/src/client/sync/sync-locales.ts +416 -0
- package/src/client/sync/sync-push-preview.test.ts +52 -0
- package/src/client/sync/sync-view-v2.test.ts +213 -0
- package/src/client/sync/sync-view.test.ts +487 -0
- package/src/client/sync/sync-view.ts +759 -0
- package/src/core/analyzer.ts +1068 -0
- package/src/core/backup.ts +605 -0
- package/src/core/boot-rescue.test.ts +847 -0
- package/src/core/boot-rescue.ts +737 -0
- package/src/core/cache-cleaner.test.ts +169 -0
- package/src/core/cache-cleaner.ts +121 -0
- package/src/core/config-lifecycle.test.ts +727 -0
- package/src/core/config-lifecycle.ts +573 -0
- package/src/core/config-snapshot.test.ts +369 -0
- package/src/core/config-snapshot.ts +522 -0
- package/src/core/config-state.test.ts +381 -0
- package/src/core/config-state.ts +207 -0
- package/src/core/consult-source.test.ts +199 -0
- package/src/core/consult-source.ts +293 -0
- package/src/core/crash-report.test.ts +380 -0
- package/src/core/crash-report.ts +369 -0
- package/src/core/exporter.ts +417 -0
- package/src/core/ghost-sweep.test.ts +94 -0
- package/src/core/ghost-sweep.ts +67 -0
- package/src/core/importer.ts +83 -0
- package/src/core/index.ts +56 -0
- package/src/core/journal.test.ts +257 -0
- package/src/core/journal.ts +518 -0
- package/src/core/local-plugin-host.ts +152 -0
- package/src/core/local-plugin-pack.test.ts +359 -0
- package/src/core/local-plugin-pack.ts +363 -0
- package/src/core/messages.test.ts +54 -0
- package/src/core/messages.ts +664 -0
- package/src/core/migration-consult.test.ts +315 -0
- package/src/core/migration-consult.ts +494 -0
- package/src/core/migration-history.test.ts +319 -0
- package/src/core/migration-history.ts +484 -0
- package/src/core/model-tools.test.ts +190 -0
- package/src/core/model-tools.ts +208 -0
- package/src/core/msg-types.ts +10 -0
- package/src/core/phase3-child-crash.ts +95 -0
- package/src/core/phase3-host.ts +411 -0
- package/src/core/phase3-p1.test.ts +212 -0
- package/src/core/phase3-p2b-fingerprint.test.ts +189 -0
- package/src/core/phase3-prod-child.ts +36 -0
- package/src/core/phase3-production-integration.test.ts +286 -0
- package/src/core/phase4-crash-child.ts +101 -0
- package/src/core/phase4-crash-injection.test.ts +166 -0
- package/src/core/plugin-cli.fs.test.ts +184 -0
- package/src/core/plugin-cli.test.ts +137 -0
- package/src/core/plugin-cli.ts +563 -0
- package/src/core/reconcile.test.ts +392 -0
- package/src/core/reconcile.ts +498 -0
- package/src/core/recovery-orchestrator.test.ts +145 -0
- package/src/core/recovery-orchestrator.ts +366 -0
- package/src/core/restore-manage.test.ts +94 -0
- package/src/core/restore.ts +849 -0
- package/src/core/rollback.ts +151 -0
- package/src/core/run-progress.test.ts +237 -0
- package/src/core/run-registry.test.ts +232 -0
- package/src/core/run-registry.ts +209 -0
- package/src/core/smoke.test.ts +842 -0
- package/src/core/startup-barrier.ts +99 -0
- package/src/core/types.ts +542 -0
- package/src/core/undo.ts +116 -0
- package/src/core/validator.ts +84 -0
- package/src/core/verify-recovery.ts +265 -0
- package/src/core/watcher.test.ts +326 -0
- package/src/core/watcher.ts +263 -0
- package/src/index.facade.test.ts +265 -0
- package/src/index.sync.test.ts +217 -0
- package/src/index.ts +2975 -0
- package/src/migrations/index.ts +71 -0
- package/src/migrations/v1-to-v2.ts +17 -0
- package/src/profiles/index.ts +8 -0
- package/src/profiles/profile-manager.test.ts +282 -0
- package/src/profiles/profile-manager.ts +630 -0
- package/src/schema/config.ts +165 -0
- package/src/schema/index.ts +71 -0
- package/src/schema/manifest-schema.test.ts +519 -0
- package/src/schema/manifest.ts +124 -0
- package/src/schema/tombstones.test.ts +131 -0
- package/src/schema/tombstones.ts +89 -0
- package/src/schema/types.ts +245 -0
- package/src/schema/versions.ts +55 -0
- package/src/security/encryption.ts +335 -0
- package/src/security/index.ts +16 -0
- package/src/security/integrity.ts +102 -0
- package/src/security/redaction.ts +101 -0
- package/src/security/secret-scanner.ts +445 -0
- package/src/security/security.test.ts +1215 -0
- package/src/security/vault.test.ts +164 -0
- package/src/security/vault.ts +198 -0
- package/src/security/zip-security.ts +180 -0
- package/src/sync/autosync-config.test.ts +147 -0
- package/src/sync/autosync-config.ts +179 -0
- package/src/sync/autosync-scheduler.test.ts +490 -0
- package/src/sync/autosync-scheduler.ts +646 -0
- package/src/sync/fs.ts +48 -0
- package/src/sync/git/git-transport.test.ts +673 -0
- package/src/sync/git/git-transport.ts +491 -0
- package/src/sync/github-auth.test.ts +280 -0
- package/src/sync/github-auth.ts +356 -0
- package/src/sync/layout.test.ts +270 -0
- package/src/sync/layout.ts +222 -0
- package/src/sync/snapshot-json.test.ts +119 -0
- package/src/sync/snapshot-json.ts +128 -0
- package/src/sync/sync-config.test.ts +310 -0
- package/src/sync/sync-config.ts +299 -0
- package/src/sync/sync-engine.test.ts +1074 -0
- package/src/sync/sync-engine.ts +754 -0
- package/src/sync/sync-history.test.ts +116 -0
- package/src/sync/sync-history.ts +120 -0
- package/src/sync/sync-selection.test.ts +186 -0
- package/src/sync/sync-selection.ts +143 -0
- package/src/sync/sync-session.test.ts +143 -0
- package/src/sync/sync-session.ts +77 -0
- package/src/sync/sync-state.test.ts +164 -0
- package/src/sync/sync-state.ts +131 -0
- package/src/sync/transport.test.ts +111 -0
- package/src/sync/transport.ts +118 -0
- package/src/sync/ui-prefs.test.ts +152 -0
- package/src/sync/ui-prefs.ts +135 -0
- package/src/sync/webdav/webdav-transport.test.ts +871 -0
- package/src/sync/webdav/webdav-transport.ts +535 -0
- package/src/ui/errors.test.ts +47 -0
- package/src/ui/errors.ts +100 -0
- package/src/ui/export-flow.test.ts +87 -0
- package/src/ui/export-flow.ts +156 -0
- package/src/ui/i18n.ts +590 -0
- package/src/ui/migration-consult-view.test.ts +151 -0
- package/src/ui/migration-consult-view.ts +178 -0
- package/src/ui/progress.test.ts +47 -0
- package/src/ui/progress.ts +102 -0
- package/src/ui/report.test.ts +101 -0
- package/src/ui/report.ts +146 -0
- package/src/ui/test-helpers.ts +184 -0
- package/src/ui/types.ts +325 -0
- package/src/utils/atomic-write.test.ts +312 -0
- package/src/utils/atomic-write.ts +422 -0
- package/src/utils/env-lock.test.ts +999 -0
- package/src/utils/env-lock.ts +1086 -0
- package/src/utils/hashing.ts +53 -0
- package/src/utils/json.ts +77 -0
- package/src/utils/logger.ts +114 -0
- package/src/utils/paths.ts +176 -0
- package/src/utils/proxy.test.ts +465 -0
- package/src/utils/proxy.ts +493 -0
- package/src/utils/recursive-walk.test.ts +148 -0
- package/src/utils/recursive-walk.ts +145 -0
- package/src/utils/zip.ts +332 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 递归遍历(跟随 junction / 符号链接)—— 文件类分区(skills / agentPresets / sessions / pluginFiles)
|
|
3
|
+
* 的收集内核(issue #37)。
|
|
4
|
+
*
|
|
5
|
+
* 背景:`fs.readdir(withFileTypes)` 对 **目录 junction / 符号链接** 返回的是
|
|
6
|
+
* `isSymbolicLink() === true`、`isDirectory() === false`。此前调用方只处理
|
|
7
|
+
* `isDirectory() / isFile()` 两个分支,于是链接目录及其**全部真实内容**被静默排除,
|
|
8
|
+
* 备份照样报成功(issue #37:Windows 下 8 个链接目录约 12 MB 内容丢失,且无任何提示)。
|
|
9
|
+
*
|
|
10
|
+
* 本模块的语义:
|
|
11
|
+
* - **跟随**目录链接(内容进备份),并用 realpath 去重防环(自引用 / 重复链接 / 深度爆炸);
|
|
12
|
+
* - 链接目标**越出 homeDir** 或**损坏**时跳过,但**记入 skippedLinks** —— 让「没进备份」
|
|
13
|
+
* 变成备份报告里可见的一行,而不是无声缺失;
|
|
14
|
+
* - 产出与 `FileSystemFacade.listRecursive` 一致的 **homeDir 相对** 斜杠路径,且已排序。
|
|
15
|
+
*/
|
|
16
|
+
import fs from 'node:fs/promises';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import type { Dirent } from 'node:fs';
|
|
19
|
+
|
|
20
|
+
/** 遍历中跳过的链接(备份必须能说明「哪些内容没进来」) */
|
|
21
|
+
export interface SkippedLink {
|
|
22
|
+
/** homeDir 相对路径(链接本身,不是目标) */
|
|
23
|
+
path: string;
|
|
24
|
+
/** loop=指向已遍历目录(自引用/重复);outside-home=目标越出 homeDir;
|
|
25
|
+
* broken=断链;unreadable=目标既非文件也非目录(设备/FIFO 等);too-deep=超过深度上限 */
|
|
26
|
+
reason: 'loop' | 'outside-home' | 'broken' | 'unreadable' | 'too-deep';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface RecursiveListing {
|
|
30
|
+
/** homeDir 相对、斜杠分隔、已排序的文件路径 */
|
|
31
|
+
paths: string[];
|
|
32
|
+
/** 被跳过的链接(含原因);空 = 本次遍历无内容缺失 */
|
|
33
|
+
skippedLinks: SkippedLink[];
|
|
34
|
+
/** 成功跟随(内容已收集)的链接数;用于「链接结构不会被还原」的提示 */
|
|
35
|
+
followedLinks: number;
|
|
36
|
+
/** 读取失败的目录(ACL / 竞态删除等)——这些目录下的内容同样没进备份,必须留痕 */
|
|
37
|
+
unreadableDirs: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 深度上限:防御病态目录树 / realpath 去重失效时的栈爆炸 */
|
|
41
|
+
const MAX_DEPTH = 64;
|
|
42
|
+
|
|
43
|
+
function normalizeSlashes(p: string): string {
|
|
44
|
+
return p.split(path.sep).join('/');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** current 是否等于 base 或位于其下(win32 折叠大小写) */
|
|
48
|
+
function isSameOrChild(current: string, base: string): boolean {
|
|
49
|
+
const c = path.resolve(current);
|
|
50
|
+
const b = path.resolve(base);
|
|
51
|
+
const cl = process.platform === 'win32' ? c.toLowerCase() : c;
|
|
52
|
+
const bl = process.platform === 'win32' ? b.toLowerCase() : b;
|
|
53
|
+
return cl === bl || cl.startsWith(bl.endsWith(path.sep) ? bl : bl + path.sep);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function realpathSafe(p: string): Promise<string | null> {
|
|
57
|
+
try { return await fs.realpath(p) } catch { return null }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 遍历 baseDir 下的所有文件(跟随目录链接)。
|
|
62
|
+
* @param baseDir 绝对路径;不在 homeDir 内则返回空清单(与既有语义一致)
|
|
63
|
+
* @param homeDir 绝对路径;产出相对它,跟随边界也是它
|
|
64
|
+
*/
|
|
65
|
+
export async function listRecursiveFollowingLinks(baseDir: string, homeDir: string): Promise<RecursiveListing> {
|
|
66
|
+
const base = path.resolve(baseDir);
|
|
67
|
+
const home = path.resolve(homeDir);
|
|
68
|
+
const paths: string[] = [];
|
|
69
|
+
const skippedLinks: SkippedLink[] = [];
|
|
70
|
+
const unreadableDirs: string[] = [];
|
|
71
|
+
let followedLinks = 0;
|
|
72
|
+
/** 已进入过的目录 realpath(防环 / 防同一目标被两条链接重复收集) */
|
|
73
|
+
const visited = new Set<string>();
|
|
74
|
+
|
|
75
|
+
const rel = (abs: string): string => normalizeSlashes(path.relative(home, abs));
|
|
76
|
+
|
|
77
|
+
/** @returns 是否真的进入了该目录(false = 被边界/去重/IO 挡下) */
|
|
78
|
+
const walk = async (dir: string, depth: number, viaLink: string | null): Promise<boolean> => {
|
|
79
|
+
if (!isSameOrChild(dir, home)) return false;
|
|
80
|
+
if (depth > MAX_DEPTH) {
|
|
81
|
+
if (viaLink !== null) skippedLinks.push({ path: viaLink, reason: 'too-deep' });
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
const real = await realpathSafe(dir);
|
|
85
|
+
// 去重只针对**链接**:链接目标已经进过(自引用 / 两条链接指向同一处)→ 跳过。
|
|
86
|
+
// 普通目录永远遍历(否则「真实目录 + 指向它的链接」会有一边被静默吞掉)。
|
|
87
|
+
if (viaLink !== null && real !== null && visited.has(real)) {
|
|
88
|
+
skippedLinks.push({ path: viaLink, reason: 'loop' });
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
if (real !== null) visited.add(real);
|
|
92
|
+
let entries: Dirent[];
|
|
93
|
+
try {
|
|
94
|
+
// 排序:让「链接 vs 真实目录」的遍历顺序与去重结果可复现(readdir 顺序依 FS 而异)
|
|
95
|
+
entries = (await fs.readdir(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name));
|
|
96
|
+
} catch (err) {
|
|
97
|
+
// 目录不可读(ACL / 竞态删除):不中断整次遍历,但**必须留痕** ——
|
|
98
|
+
// 静默吞掉同样会造成「备份成功但内容缺失」(issue #37 的同类症状)。
|
|
99
|
+
// 根目录不存在(ENOENT)例外:调用方语义是「目录不存在视为空」,不告警。
|
|
100
|
+
if (viaLink !== null) skippedLinks.push({ path: viaLink, reason: 'unreadable' });
|
|
101
|
+
else if ((err as { code?: string } | null)?.code !== 'ENOENT') unreadableDirs.push(rel(dir));
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
for (const entry of entries) {
|
|
105
|
+
const abs = path.join(dir, entry.name);
|
|
106
|
+
const entryRel = rel(abs);
|
|
107
|
+
if (entry.isDirectory()) {
|
|
108
|
+
await walk(abs, depth + 1, null);
|
|
109
|
+
} else if (entry.isFile()) {
|
|
110
|
+
paths.push(entryRel);
|
|
111
|
+
} else if (entry.isSymbolicLink()) {
|
|
112
|
+
// 目录 junction / 符号链接:跟随(issue #37 的核心修复)。
|
|
113
|
+
// 注意:Dirent 对链接一律 isDirectory()===false,必须显式 stat 目标才知道类型。
|
|
114
|
+
let st;
|
|
115
|
+
try {
|
|
116
|
+
st = await fs.stat(abs); // 跟随链接
|
|
117
|
+
} catch {
|
|
118
|
+
skippedLinks.push({ path: entryRel, reason: 'broken' });
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
// 链接目标必须落在 homeDir 内——**文件链接同样如此**。
|
|
122
|
+
// 只对目录链接做这个检查会漏掉「skills/link.md → ~/外部文件」:
|
|
123
|
+
// 内容会被读进备份(T2-P3 实测就是这种文件链接,属真实越界读取)。
|
|
124
|
+
const target = await realpathSafe(abs);
|
|
125
|
+
if (target === null || !isSameOrChild(target, home)) {
|
|
126
|
+
skippedLinks.push({ path: entryRel, reason: 'outside-home' });
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (st.isDirectory()) {
|
|
130
|
+
if (await walk(abs, depth + 1, entryRel)) followedLinks += 1;
|
|
131
|
+
} else if (st.isFile()) {
|
|
132
|
+
paths.push(entryRel);
|
|
133
|
+
followedLinks += 1;
|
|
134
|
+
} else {
|
|
135
|
+
skippedLinks.push({ path: entryRel, reason: 'unreadable' });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// 其余类型(socket / FIFO / 设备):与既有实现一致,静默忽略
|
|
139
|
+
}
|
|
140
|
+
return true;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
await walk(base, 0, null);
|
|
144
|
+
return { paths: paths.sort(), skippedLinks, followedLinks, unreadableDirs };
|
|
145
|
+
}
|
package/src/utils/zip.ts
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ZIP 读写封装:node:zlib(deflate raw)+ 自实现 CRC32,零依赖。
|
|
3
|
+
* 同时是 ZIP 安全的第一道防线(规范 §19,m4 的 zip-security 可在此基础上强化):
|
|
4
|
+
* - 条目名 isPathSafe(拒绝 ../、绝对路径、盘符、UNC、NUL)
|
|
5
|
+
* - 条目数 / 压缩体积 / 解压体积 / 单条 / 压缩比(zip bomb)上限
|
|
6
|
+
* - 解压时逐条 CRC32 与尺寸校验,损坏即整体拒绝
|
|
7
|
+
* 解压只允许写入受控目标目录(safeExtract),绝不落任意路径。
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs/promises';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import zlib from 'node:zlib';
|
|
12
|
+
import { parseJsonSafe } from './json.ts';
|
|
13
|
+
import { isPathSafe, isSameOrChild } from './paths.ts';
|
|
14
|
+
import { atomicWriteFile } from './atomic-write.ts';
|
|
15
|
+
|
|
16
|
+
export interface ZipEntryMeta {
|
|
17
|
+
name: string;
|
|
18
|
+
method: number; // 0=store, 8=deflate
|
|
19
|
+
compressedSize: number;
|
|
20
|
+
uncompressedSize: number;
|
|
21
|
+
crc32: number;
|
|
22
|
+
isDirectory: boolean;
|
|
23
|
+
/** 本地文件头在 ZIP 字节流中的偏移 */
|
|
24
|
+
localOffset: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ZipSafetyLimits {
|
|
28
|
+
maxEntries?: number;
|
|
29
|
+
maxTotalBytes?: number; // 解压后累计字节上限
|
|
30
|
+
maxCompressedBytes?: number; // ZIP 内压缩数据累计上限
|
|
31
|
+
maxSingleBytes?: number; // 单条目解压后上限
|
|
32
|
+
maxRatio?: number; // 单条目解压/压缩比上限(zip bomb 检测)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const DEFAULT_ZIP_SAFETY_LIMITS: Required<ZipSafetyLimits> = {
|
|
36
|
+
maxEntries: 10_000,
|
|
37
|
+
maxTotalBytes: 500 * 1024 * 1024,
|
|
38
|
+
maxCompressedBytes: 200 * 1024 * 1024,
|
|
39
|
+
maxSingleBytes: 100 * 1024 * 1024,
|
|
40
|
+
maxRatio: 200,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export class ZipSafetyError extends Error {
|
|
44
|
+
constructor(message: string) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.name = 'ZipSafetyError';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* ---------------- CRC32(表驱动) ---------------- */
|
|
51
|
+
|
|
52
|
+
let crcTable: Uint32Array | null = null;
|
|
53
|
+
function getCrcTable(): Uint32Array {
|
|
54
|
+
if (crcTable) return crcTable;
|
|
55
|
+
const table = new Uint32Array(256);
|
|
56
|
+
for (let n = 0; n < 256; n++) {
|
|
57
|
+
let c = n;
|
|
58
|
+
for (let k = 0; k < 8; k++) {
|
|
59
|
+
c = (c & 1) !== 0 ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
|
60
|
+
}
|
|
61
|
+
table[n] = c >>> 0;
|
|
62
|
+
}
|
|
63
|
+
crcTable = table;
|
|
64
|
+
return table;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function crc32(data: Uint8Array): number {
|
|
68
|
+
const table = getCrcTable();
|
|
69
|
+
let crc = 0xffffffff;
|
|
70
|
+
for (let i = 0; i < data.length; i++) {
|
|
71
|
+
crc = table[(crc ^ data[i]!) & 0xff]! ^ (crc >>> 8);
|
|
72
|
+
}
|
|
73
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/* ---------------- 写入 ---------------- */
|
|
77
|
+
|
|
78
|
+
function dosDateTime(): { time: number; date: number } {
|
|
79
|
+
const now = new Date();
|
|
80
|
+
const time = ((now.getHours() & 0x1f) << 11) | ((now.getMinutes() & 0x3f) << 5) | ((now.getSeconds() >> 1) & 0x1f);
|
|
81
|
+
const date = (((now.getFullYear() - 1980) & 0x7f) << 9) | (((now.getMonth() + 1) & 0x0f) << 5) | (now.getDate() & 0x1f);
|
|
82
|
+
return { time, date };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface ZipWriteEntry { name: string; data: Uint8Array; }
|
|
86
|
+
|
|
87
|
+
/** 组装 ZIP 字节(全部条目 method=8 deflate,UTF-8 文件名,含中央目录与 EOCD) */
|
|
88
|
+
export function zipToBuffer(entries: ZipWriteEntry[]): Uint8Array {
|
|
89
|
+
const chunks: Buffer[] = [];
|
|
90
|
+
const central: Buffer[] = [];
|
|
91
|
+
let offset = 0;
|
|
92
|
+
const { time, date } = dosDateTime();
|
|
93
|
+
|
|
94
|
+
for (const entry of entries) {
|
|
95
|
+
if (!isPathSafe(entry.name)) {
|
|
96
|
+
throw new ZipSafetyError(`拒绝写入不安全的条目名: ${entry.name}`);
|
|
97
|
+
}
|
|
98
|
+
const nameBuf = Buffer.from(entry.name, 'utf8');
|
|
99
|
+
const data = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(entry.data);
|
|
100
|
+
const compressed = zlib.deflateRawSync(data);
|
|
101
|
+
const crc = crc32(data);
|
|
102
|
+
|
|
103
|
+
const local = Buffer.alloc(30);
|
|
104
|
+
local.writeUInt32LE(0x04034b50, 0);
|
|
105
|
+
local.writeUInt16LE(20, 4);
|
|
106
|
+
local.writeUInt16LE(0x0800, 6); // UTF-8 文件名
|
|
107
|
+
local.writeUInt16LE(8, 8); // deflate
|
|
108
|
+
local.writeUInt16LE(time, 10);
|
|
109
|
+
local.writeUInt16LE(date, 12);
|
|
110
|
+
local.writeUInt32LE(crc, 14);
|
|
111
|
+
local.writeUInt32LE(compressed.length, 18);
|
|
112
|
+
local.writeUInt32LE(data.length, 22);
|
|
113
|
+
local.writeUInt16LE(nameBuf.length, 26);
|
|
114
|
+
local.writeUInt16LE(0, 28);
|
|
115
|
+
chunks.push(local, nameBuf, compressed);
|
|
116
|
+
|
|
117
|
+
const cen = Buffer.alloc(46);
|
|
118
|
+
cen.writeUInt32LE(0x02014b50, 0);
|
|
119
|
+
cen.writeUInt16LE(20, 4);
|
|
120
|
+
cen.writeUInt16LE(20, 6);
|
|
121
|
+
cen.writeUInt16LE(0x0800, 8);
|
|
122
|
+
cen.writeUInt16LE(8, 10);
|
|
123
|
+
cen.writeUInt16LE(time, 12);
|
|
124
|
+
cen.writeUInt16LE(date, 14);
|
|
125
|
+
cen.writeUInt32LE(crc, 16);
|
|
126
|
+
cen.writeUInt32LE(compressed.length, 20);
|
|
127
|
+
cen.writeUInt32LE(data.length, 24);
|
|
128
|
+
cen.writeUInt16LE(nameBuf.length, 28);
|
|
129
|
+
cen.writeUInt16LE(0, 30);
|
|
130
|
+
cen.writeUInt16LE(0, 32);
|
|
131
|
+
cen.writeUInt16LE(0, 34);
|
|
132
|
+
cen.writeUInt16LE(0, 36);
|
|
133
|
+
cen.writeUInt32LE(0, 38);
|
|
134
|
+
cen.writeUInt32LE(offset, 42);
|
|
135
|
+
central.push(cen, nameBuf);
|
|
136
|
+
|
|
137
|
+
offset += local.length + nameBuf.length + compressed.length;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const cd = Buffer.concat(central);
|
|
141
|
+
const eocd = Buffer.alloc(22);
|
|
142
|
+
eocd.writeUInt32LE(0x06054b50, 0);
|
|
143
|
+
eocd.writeUInt16LE(0, 4);
|
|
144
|
+
eocd.writeUInt16LE(0, 6);
|
|
145
|
+
eocd.writeUInt16LE(entries.length, 8);
|
|
146
|
+
eocd.writeUInt16LE(entries.length, 10);
|
|
147
|
+
eocd.writeUInt32LE(cd.length, 12);
|
|
148
|
+
eocd.writeUInt32LE(offset, 16);
|
|
149
|
+
eocd.writeUInt16LE(0, 20);
|
|
150
|
+
|
|
151
|
+
return Buffer.concat([...chunks, cd, eocd]);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 写出 ZIP 文件(原子写,避免半写 zip) */
|
|
155
|
+
export async function writeZip(zipPath: string, entries: ZipWriteEntry[]): Promise<void> {
|
|
156
|
+
const buf = zipToBuffer(entries);
|
|
157
|
+
await atomicWriteFile(zipPath, buf);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/* ---------------- 读取 ---------------- */
|
|
161
|
+
|
|
162
|
+
const EOCD_SIG = Buffer.from([0x50, 0x4b, 0x05, 0x06]);
|
|
163
|
+
const LOCAL_SIG = 0x04034b50;
|
|
164
|
+
const CENTRAL_SIG = 0x02014b50;
|
|
165
|
+
|
|
166
|
+
/** 内存 ZIP 归档:构造时校验全部条目名与上限;读取时逐条解压 + CRC/尺寸/比预算校验 */
|
|
167
|
+
export class ZipArchive {
|
|
168
|
+
private readonly buf: Buffer;
|
|
169
|
+
private readonly metas: ZipEntryMeta[];
|
|
170
|
+
readonly limits: Required<ZipSafetyLimits>;
|
|
171
|
+
private totalUncompressed = 0;
|
|
172
|
+
|
|
173
|
+
constructor(buf: Uint8Array, metas: ZipEntryMeta[], limits: Required<ZipSafetyLimits>) {
|
|
174
|
+
this.buf = Buffer.isBuffer(buf) ? buf : Buffer.from(buf);
|
|
175
|
+
this.metas = metas;
|
|
176
|
+
this.limits = limits;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
names(): string[] {
|
|
180
|
+
return this.metas.map((m) => m.name);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
entries(): ZipEntryMeta[] {
|
|
184
|
+
return this.metas.map((m) => ({ ...m }));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
has(name: string): boolean {
|
|
188
|
+
return this.metas.some((m) => m.name === name);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 读取并解压单条目(带 CRC32 / 尺寸 / 体积预算 / 压缩比校验) */
|
|
192
|
+
readEntry(name: string): Uint8Array {
|
|
193
|
+
const meta = this.metas.find((m) => m.name === name);
|
|
194
|
+
if (!meta) throw new ZipSafetyError(`ZIP 中不存在条目: ${name}`);
|
|
195
|
+
if (meta.isDirectory) return Buffer.alloc(0);
|
|
196
|
+
|
|
197
|
+
const lhOffset = meta.localOffset;
|
|
198
|
+
const lh = this.buf.subarray(lhOffset, lhOffset + 30);
|
|
199
|
+
if (lh.length < 30 || lh.readUInt32LE(0) !== LOCAL_SIG) {
|
|
200
|
+
throw new ZipSafetyError(`条目 "${name}" 的本地文件头损坏`);
|
|
201
|
+
}
|
|
202
|
+
const nameLen = lh.readUInt16LE(26);
|
|
203
|
+
const extraLen = lh.readUInt16LE(28);
|
|
204
|
+
const dataStart = lhOffset + 30 + nameLen + extraLen;
|
|
205
|
+
if (dataStart + meta.compressedSize > this.buf.length) {
|
|
206
|
+
throw new ZipSafetyError(`条目 "${name}" 数据越界`);
|
|
207
|
+
}
|
|
208
|
+
const raw = this.buf.subarray(dataStart, dataStart + meta.compressedSize);
|
|
209
|
+
|
|
210
|
+
let out: Buffer;
|
|
211
|
+
if (meta.method === 0) {
|
|
212
|
+
out = raw;
|
|
213
|
+
} else if (meta.method === 8) {
|
|
214
|
+
try {
|
|
215
|
+
out = zlib.inflateRawSync(raw, { maxOutputLength: this.limits.maxSingleBytes });
|
|
216
|
+
} catch (err) {
|
|
217
|
+
throw new ZipSafetyError(`条目 "${name}" 解压失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
218
|
+
}
|
|
219
|
+
} else {
|
|
220
|
+
throw new ZipSafetyError(`条目 "${name}" 使用未知压缩方法 ${meta.method}`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (out.length !== meta.uncompressedSize) {
|
|
224
|
+
throw new ZipSafetyError(`条目 "${name}" 解压尺寸不符(${out.length} ≠ ${meta.uncompressedSize})`);
|
|
225
|
+
}
|
|
226
|
+
if (crc32(out) !== meta.crc32) {
|
|
227
|
+
throw new ZipSafetyError(`条目 "${name}" CRC32 校验失败(ZIP 已损坏)`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
this.totalUncompressed += out.length;
|
|
231
|
+
if (this.totalUncompressed > this.limits.maxTotalBytes) {
|
|
232
|
+
throw new ZipSafetyError('ZIP 解压总字节数超过上限');
|
|
233
|
+
}
|
|
234
|
+
if (meta.compressedSize > 0) {
|
|
235
|
+
const ratio = out.length / meta.compressedSize;
|
|
236
|
+
if (ratio > this.limits.maxRatio) {
|
|
237
|
+
throw new ZipSafetyError(`条目 "${name}" 压缩比 ${ratio.toFixed(1)} 超过上限(疑似 zip bomb)`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
readEntryText(name: string): string {
|
|
244
|
+
return Buffer.from(this.readEntry(name)).toString('utf8');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** 读取并深度保护解析 JSON 条目 */
|
|
248
|
+
readEntryJson(name: string): unknown {
|
|
249
|
+
return parseJsonSafe(this.readEntryText(name));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** 解析 ZIP(含安全校验:条目名 / 条目数 / 压缩体积) */
|
|
254
|
+
export function parseZip(buf: Uint8Array, limits: ZipSafetyLimits = {}): ZipArchive {
|
|
255
|
+
const merged: Required<ZipSafetyLimits> = { ...DEFAULT_ZIP_SAFETY_LIMITS, ...limits };
|
|
256
|
+
const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf);
|
|
257
|
+
if (b.length < 22) throw new ZipSafetyError('不是合法的 ZIP 文件(体积过小)');
|
|
258
|
+
|
|
259
|
+
const eocdIdx = b.lastIndexOf(EOCD_SIG);
|
|
260
|
+
if (eocdIdx < 0 || eocdIdx + 22 > b.length) {
|
|
261
|
+
throw new ZipSafetyError('不是合法的 ZIP 文件(缺少中央目录结束记录)');
|
|
262
|
+
}
|
|
263
|
+
const totalEntries = b.readUInt16LE(eocdIdx + 10);
|
|
264
|
+
const cdSize = b.readUInt32LE(eocdIdx + 12);
|
|
265
|
+
const cdOffset = b.readUInt32LE(eocdIdx + 16);
|
|
266
|
+
if (cdOffset + cdSize > b.length) {
|
|
267
|
+
throw new ZipSafetyError('中央目录越界(ZIP 损坏)');
|
|
268
|
+
}
|
|
269
|
+
if (totalEntries > merged.maxEntries) {
|
|
270
|
+
throw new ZipSafetyError(`ZIP 条目数 ${totalEntries} 超过上限 ${merged.maxEntries}`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const metas: ZipEntryMeta[] = [];
|
|
274
|
+
let pos = cdOffset;
|
|
275
|
+
let compressedTotal = 0;
|
|
276
|
+
for (let i = 0; i < totalEntries; i++) {
|
|
277
|
+
if (pos + 46 > cdOffset + cdSize) throw new ZipSafetyError('中央目录条目越界(ZIP 损坏)');
|
|
278
|
+
if (b.readUInt32LE(pos) !== CENTRAL_SIG) throw new ZipSafetyError('中央目录签名损坏');
|
|
279
|
+
const method = b.readUInt16LE(pos + 10);
|
|
280
|
+
const crc = b.readUInt32LE(pos + 16);
|
|
281
|
+
const compSize = b.readUInt32LE(pos + 20);
|
|
282
|
+
const uncompSize = b.readUInt32LE(pos + 24);
|
|
283
|
+
const nameLen = b.readUInt16LE(pos + 28);
|
|
284
|
+
const extraLen = b.readUInt16LE(pos + 30);
|
|
285
|
+
const commentLen = b.readUInt16LE(pos + 32);
|
|
286
|
+
const localOffset = b.readUInt32LE(pos + 42);
|
|
287
|
+
const name = b.subarray(pos + 46, pos + 46 + nameLen).toString('utf8');
|
|
288
|
+
|
|
289
|
+
if (!isPathSafe(name)) throw new ZipSafetyError(`ZIP 条目名不安全: ${name}`);
|
|
290
|
+
compressedTotal += compSize;
|
|
291
|
+
if (compressedTotal > merged.maxCompressedBytes) {
|
|
292
|
+
throw new ZipSafetyError('ZIP 压缩数据总量超过上限');
|
|
293
|
+
}
|
|
294
|
+
metas.push({
|
|
295
|
+
name,
|
|
296
|
+
method,
|
|
297
|
+
compressedSize: compSize,
|
|
298
|
+
uncompressedSize: uncompSize,
|
|
299
|
+
crc32: crc,
|
|
300
|
+
isDirectory: name.endsWith('/'),
|
|
301
|
+
localOffset,
|
|
302
|
+
});
|
|
303
|
+
pos += 46 + nameLen + extraLen + commentLen;
|
|
304
|
+
}
|
|
305
|
+
return new ZipArchive(b, metas, merged);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* 安全解压到受控目录(对齐设计 §13.4 safeExtract 签名)。
|
|
310
|
+
* 返回条目相对路径列表;任何越界/损坏即整体拒绝(不部分落盘)。
|
|
311
|
+
* m4 的 zip-security 可替换/强化本实现。
|
|
312
|
+
*/
|
|
313
|
+
export async function safeExtract(
|
|
314
|
+
zipPath: string,
|
|
315
|
+
destDir: string,
|
|
316
|
+
limits: ZipSafetyLimits = {},
|
|
317
|
+
): Promise<string[]> {
|
|
318
|
+
const data = await fs.readFile(zipPath);
|
|
319
|
+
const archive = parseZip(data, limits);
|
|
320
|
+
const extracted: string[] = [];
|
|
321
|
+
for (const meta of archive.entries()) {
|
|
322
|
+
if (meta.isDirectory) continue;
|
|
323
|
+
const target = path.join(destDir, ...meta.name.split('/'));
|
|
324
|
+
if (!isSameOrChild(target, destDir)) {
|
|
325
|
+
throw new ZipSafetyError(`解压目标越界: ${meta.name}`);
|
|
326
|
+
}
|
|
327
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
328
|
+
await fs.writeFile(target, archive.readEntry(meta.name));
|
|
329
|
+
extracted.push(meta.name);
|
|
330
|
+
}
|
|
331
|
+
return extracted;
|
|
332
|
+
}
|