@narumitw/pi-sync 0.50.3 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. package/README.md +4 -1
  2. package/dist/chunks/{chunk-6LP3MCR2.ts → chunk-2JZFHNLM.ts} +3 -5
  3. package/dist/chunks/chunk-2JZFHNLM.ts.map +7 -0
  4. package/dist/chunks/{chunk-LCRI3ABW.ts → chunk-2RZSQWBH.ts} +8 -19
  5. package/dist/chunks/chunk-2RZSQWBH.ts.map +7 -0
  6. package/dist/chunks/{chunk-IJ6FJFA5.ts → chunk-4AIHXRKE.ts} +11 -17
  7. package/dist/chunks/chunk-4AIHXRKE.ts.map +7 -0
  8. package/dist/chunks/{chunk-C7XY5NF5.ts → chunk-4MPIH46N.ts} +29 -51
  9. package/dist/chunks/chunk-4MPIH46N.ts.map +7 -0
  10. package/dist/chunks/{chunk-5B3FXBLE.ts → chunk-6546TS54.ts} +16 -20
  11. package/dist/chunks/chunk-6546TS54.ts.map +7 -0
  12. package/dist/chunks/{chunk-Q2TUM5X6.ts → chunk-6NT3YFGY.ts} +2 -2
  13. package/dist/chunks/chunk-6NT3YFGY.ts.map +7 -0
  14. package/dist/chunks/{chunk-2B2ELUF5.ts → chunk-7D5SF7P3.ts} +1 -1
  15. package/dist/chunks/chunk-7D5SF7P3.ts.map +7 -0
  16. package/dist/chunks/{chunk-YDWUZVPN.ts → chunk-7KHKWL3Q.ts} +28 -47
  17. package/dist/chunks/chunk-7KHKWL3Q.ts.map +7 -0
  18. package/dist/chunks/{chunk-ZI5MXMEX.ts → chunk-BVVP33U5.ts} +2 -4
  19. package/dist/chunks/chunk-BVVP33U5.ts.map +7 -0
  20. package/dist/chunks/{chunk-SZWBGPID.ts → chunk-DM4X4FIA.ts} +8 -13
  21. package/dist/chunks/chunk-DM4X4FIA.ts.map +7 -0
  22. package/dist/chunks/{chunk-SL64RLXR.ts → chunk-DZUSTGQV.ts} +8 -16
  23. package/dist/chunks/chunk-DZUSTGQV.ts.map +7 -0
  24. package/dist/chunks/{chunk-YHEMENKV.ts → chunk-E42GXD6D.ts} +13 -19
  25. package/dist/chunks/chunk-E42GXD6D.ts.map +7 -0
  26. package/dist/chunks/{chunk-Z4DQF6L3.ts → chunk-ECYCNZOE.ts} +1 -1
  27. package/dist/chunks/chunk-ECYCNZOE.ts.map +7 -0
  28. package/dist/chunks/{chunk-OEX4ABY6.ts → chunk-EEJPYTWE.ts} +11 -21
  29. package/dist/chunks/chunk-EEJPYTWE.ts.map +7 -0
  30. package/dist/chunks/{chunk-CPEHGXJV.ts → chunk-G4RUXRWZ.ts} +3 -5
  31. package/dist/chunks/chunk-G4RUXRWZ.ts.map +7 -0
  32. package/dist/chunks/{chunk-T2B5IU74.ts → chunk-HGJH56KR.ts} +4 -4
  33. package/dist/chunks/chunk-HGJH56KR.ts.map +7 -0
  34. package/dist/chunks/{chunk-JAAKLL6M.ts → chunk-IVXEDPAD.ts} +10 -34
  35. package/dist/chunks/chunk-IVXEDPAD.ts.map +7 -0
  36. package/dist/chunks/chunk-K3RIEL6J.ts +20 -0
  37. package/dist/chunks/chunk-K3RIEL6J.ts.map +7 -0
  38. package/dist/chunks/{chunk-LTBWGSBV.ts → chunk-MSSW6F3R.ts} +1 -1
  39. package/dist/chunks/chunk-MSSW6F3R.ts.map +7 -0
  40. package/dist/chunks/{chunk-T3HH3UEJ.ts → chunk-OVDTF5CH.ts} +4 -10
  41. package/dist/chunks/chunk-OVDTF5CH.ts.map +7 -0
  42. package/dist/chunks/{chunk-NJFUIVYL.ts → chunk-PWMWOJP4.ts} +1 -1
  43. package/dist/chunks/chunk-PWMWOJP4.ts.map +7 -0
  44. package/dist/chunks/{chunk-HUDQATCL.ts → chunk-Q32CQWPM.ts} +6 -8
  45. package/dist/chunks/chunk-Q32CQWPM.ts.map +7 -0
  46. package/dist/chunks/{chunk-ETTRJDNP.ts → chunk-QLSN7AGX.ts} +6 -13
  47. package/dist/chunks/chunk-QLSN7AGX.ts.map +7 -0
  48. package/dist/chunks/{chunk-THMF4BVL.ts → chunk-SWUXA3MH.ts} +1 -1
  49. package/dist/chunks/chunk-SWUXA3MH.ts.map +7 -0
  50. package/dist/chunks/{chunk-TEN2ZBJV.ts → chunk-T2SJD3N7.ts} +2 -4
  51. package/dist/chunks/chunk-T2SJD3N7.ts.map +7 -0
  52. package/dist/chunks/{chunk-AUSDLD6F.ts → chunk-T56RNRZJ.ts} +10 -26
  53. package/dist/chunks/chunk-T56RNRZJ.ts.map +7 -0
  54. package/dist/chunks/{chunk-DHVFS4QK.ts → chunk-UA7RXOIT.ts} +37 -90
  55. package/dist/chunks/chunk-UA7RXOIT.ts.map +7 -0
  56. package/dist/chunks/{chunk-D5X27LCA.ts → chunk-UETBKTDI.ts} +1 -1
  57. package/dist/chunks/chunk-UETBKTDI.ts.map +7 -0
  58. package/dist/chunks/{chunk-CHH26D4F.ts → chunk-YFADF5ZQ.ts} +7 -6
  59. package/dist/chunks/chunk-YFADF5ZQ.ts.map +7 -0
  60. package/dist/chunks/{chunk-CCSN3F47.ts → chunk-YPXGU5OR.ts} +4 -4
  61. package/dist/chunks/chunk-YPXGU5OR.ts.map +7 -0
  62. package/dist/chunks/{file-selection-FTTKAOYX.ts → file-selection-RAVHRYFG.ts} +14 -26
  63. package/dist/chunks/file-selection-RAVHRYFG.ts.map +7 -0
  64. package/dist/chunks/{git-backend-GMVS4PD4.ts → git-backend-IAANDL3K.ts} +41 -104
  65. package/dist/chunks/git-backend-IAANDL3K.ts.map +7 -0
  66. package/dist/chunks/manager-result-dispatcher-OTUMQM26.ts +23 -0
  67. package/dist/chunks/{manager-ui-IPAOAWZA.ts → manager-ui-763NEKJY.ts} +124 -251
  68. package/dist/chunks/manager-ui-763NEKJY.ts.map +7 -0
  69. package/dist/chunks/remote-selection-ui-TZTJF32Y.ts +22 -0
  70. package/dist/chunks/{s3-backend-66O4FGBH.ts → s3-backend-XGXUOPUN.ts} +23 -54
  71. package/dist/chunks/s3-backend-XGXUOPUN.ts.map +7 -0
  72. package/dist/chunks/{setup-switch-IS2PEZ3J.ts → setup-switch-5G2VNJHL.ts} +9 -9
  73. package/dist/chunks/{setup-wizard-JUJZYNNM.ts → setup-wizard-YIEWGX32.ts} +12 -12
  74. package/dist/chunks/setup-wizard-YIEWGX32.ts.map +7 -0
  75. package/dist/chunks/{snapshot-HKEWCVK6.ts → snapshot-JL5DFSXI.ts} +5 -5
  76. package/dist/chunks/sync-inspection-P3ENJLNF.ts +20 -0
  77. package/dist/chunks/{sync-operations-LBX3GKCM.ts → sync-operations-AR76EB6C.ts} +85 -201
  78. package/dist/chunks/sync-operations-AR76EB6C.ts.map +7 -0
  79. package/dist/chunks/{sync-state-Q2FTXTW6.ts → sync-state-VHJUZ4YG.ts} +6 -6
  80. package/dist/chunks/{webdav-backend-ECIKD7SO.ts → webdav-backend-3EGUONAT.ts} +20 -51
  81. package/dist/chunks/webdav-backend-3EGUONAT.ts.map +7 -0
  82. package/dist/index.ts +49 -74
  83. package/dist/index.ts.map +3 -3
  84. package/docs/settings.md +9 -0
  85. package/package.json +61 -62
  86. package/src/backends/backend-factory.ts +15 -15
  87. package/src/backends/backend-identity.ts +31 -31
  88. package/src/backends/backend-types.ts +42 -42
  89. package/src/backends/git/git-backend.ts +714 -788
  90. package/src/backends/git/git-config.ts +104 -111
  91. package/src/backends/git/git-runner.ts +230 -247
  92. package/src/backends/git/git-storage.ts +228 -245
  93. package/src/backends/s3/s3-backend.ts +408 -441
  94. package/src/backends/s3/s3-client.ts +288 -333
  95. package/src/backends/sync-backend.ts +65 -65
  96. package/src/backends/webdav/webdav-backend.ts +542 -589
  97. package/src/backends/webdav/webdav-client.ts +292 -316
  98. package/src/backends/webdav/webdav-config.ts +57 -68
  99. package/src/commands/command-execution.ts +270 -289
  100. package/src/commands/command-handler.ts +160 -163
  101. package/src/commands/command-types.ts +13 -13
  102. package/src/commands/command.ts +190 -196
  103. package/src/commands/setup-completions.ts +1 -1
  104. package/src/paths.ts +47 -47
  105. package/src/settings/config-errors.ts +4 -5
  106. package/src/settings/config-file.ts +473 -506
  107. package/src/settings/config.ts +171 -174
  108. package/src/settings/settings-management.ts +191 -216
  109. package/src/settings/settings-store.ts +63 -77
  110. package/src/settings/settings-types.ts +78 -75
  111. package/src/settings/settings-validation.ts +328 -364
  112. package/src/snapshot/session-paths.ts +41 -55
  113. package/src/snapshot/snapshot-apply.ts +197 -229
  114. package/src/snapshot/snapshot-codec.ts +29 -31
  115. package/src/snapshot/snapshot-paths.ts +4 -6
  116. package/src/snapshot/snapshot-transaction.ts +161 -170
  117. package/src/snapshot/snapshot-types.ts +24 -24
  118. package/src/snapshot/snapshot.ts +310 -343
  119. package/src/state/json-file.ts +11 -11
  120. package/src/state/lock.ts +226 -242
  121. package/src/state/lockfile-fs.ts +20 -20
  122. package/src/state/operation-availability.ts +36 -41
  123. package/src/state/state-directory.ts +171 -173
  124. package/src/state/state-types.ts +15 -15
  125. package/src/state/sync-state-store.ts +23 -23
  126. package/src/sync/automatic-sync.ts +75 -80
  127. package/src/sync/remote-snapshot.ts +52 -60
  128. package/src/sync/setup-switch.ts +99 -114
  129. package/src/sync/signals.ts +3 -5
  130. package/src/sync/startup-check.ts +97 -111
  131. package/src/sync/sync-decision.ts +76 -80
  132. package/src/sync/sync-error-guidance.ts +41 -48
  133. package/src/sync/sync-errors.ts +10 -10
  134. package/src/sync/sync-inspection.ts +29 -33
  135. package/src/sync/sync-loaders.ts +28 -36
  136. package/src/sync/sync-mutations.ts +510 -622
  137. package/src/sync/sync-operations.ts +7 -7
  138. package/src/sync/sync-policy.ts +296 -326
  139. package/src/sync/sync-queries.ts +175 -194
  140. package/src/sync/sync-state.ts +74 -100
  141. package/src/sync-extension.ts +89 -101
  142. package/src/sync.ts +26 -26
  143. package/src/types.ts +37 -37
  144. package/src/ui/automatic-sync-summary.ts +1 -3
  145. package/src/ui/cancellable-operation.ts +89 -101
  146. package/src/ui/file-selection.ts +250 -274
  147. package/src/ui/manager-attention.ts +51 -63
  148. package/src/ui/manager-recovery.ts +48 -57
  149. package/src/ui/manager-result-dispatcher.ts +62 -76
  150. package/src/ui/manager-state.ts +200 -232
  151. package/src/ui/manager-ui.ts +264 -362
  152. package/src/ui/remote-selection-ui.ts +547 -588
  153. package/src/ui/secret-input.ts +197 -205
  154. package/src/ui/settings-ui.ts +175 -172
  155. package/src/ui/setup/git-ui.ts +232 -261
  156. package/src/ui/setup/s3-credentials-ui.ts +58 -65
  157. package/src/ui/setup/s3-ui.ts +171 -195
  158. package/src/ui/setup/setup-actions.ts +91 -101
  159. package/src/ui/setup/setup-location-ui.ts +121 -137
  160. package/src/ui/setup/setup-name-ui.ts +27 -27
  161. package/src/ui/setup/setup-prompts.ts +65 -65
  162. package/src/ui/setup/setup-review.ts +58 -59
  163. package/src/ui/setup/setup-switcher.ts +156 -160
  164. package/src/ui/setup/setup-wizard.ts +28 -28
  165. package/src/ui/setup/text-input.ts +54 -58
  166. package/src/ui/setup/webdav-ui.ts +246 -293
  167. package/src/ui/storage-connections-ui.ts +317 -369
  168. package/src/ui/sync-attention.ts +148 -164
  169. package/src/ui/sync-format.ts +110 -121
  170. package/src/ui/sync-resolution-ui.ts +147 -153
  171. package/src/ui/sync-setups-ui.ts +218 -238
  172. package/src/ui/sync-status.ts +17 -0
  173. package/src/ui/terminal-text.ts +2 -2
  174. package/dist/chunks/chunk-2B2ELUF5.ts.map +0 -7
  175. package/dist/chunks/chunk-5B3FXBLE.ts.map +0 -7
  176. package/dist/chunks/chunk-6LP3MCR2.ts.map +0 -7
  177. package/dist/chunks/chunk-AUSDLD6F.ts.map +0 -7
  178. package/dist/chunks/chunk-C7XY5NF5.ts.map +0 -7
  179. package/dist/chunks/chunk-CCSN3F47.ts.map +0 -7
  180. package/dist/chunks/chunk-CHH26D4F.ts.map +0 -7
  181. package/dist/chunks/chunk-CPEHGXJV.ts.map +0 -7
  182. package/dist/chunks/chunk-D5X27LCA.ts.map +0 -7
  183. package/dist/chunks/chunk-DHVFS4QK.ts.map +0 -7
  184. package/dist/chunks/chunk-ETTRJDNP.ts.map +0 -7
  185. package/dist/chunks/chunk-HUDQATCL.ts.map +0 -7
  186. package/dist/chunks/chunk-IJ6FJFA5.ts.map +0 -7
  187. package/dist/chunks/chunk-JAAKLL6M.ts.map +0 -7
  188. package/dist/chunks/chunk-LCRI3ABW.ts.map +0 -7
  189. package/dist/chunks/chunk-LTBWGSBV.ts.map +0 -7
  190. package/dist/chunks/chunk-NJFUIVYL.ts.map +0 -7
  191. package/dist/chunks/chunk-OEX4ABY6.ts.map +0 -7
  192. package/dist/chunks/chunk-Q2TUM5X6.ts.map +0 -7
  193. package/dist/chunks/chunk-SL64RLXR.ts.map +0 -7
  194. package/dist/chunks/chunk-SZWBGPID.ts.map +0 -7
  195. package/dist/chunks/chunk-T2B5IU74.ts.map +0 -7
  196. package/dist/chunks/chunk-T3HH3UEJ.ts.map +0 -7
  197. package/dist/chunks/chunk-TEN2ZBJV.ts.map +0 -7
  198. package/dist/chunks/chunk-THMF4BVL.ts.map +0 -7
  199. package/dist/chunks/chunk-YDWUZVPN.ts.map +0 -7
  200. package/dist/chunks/chunk-YHEMENKV.ts.map +0 -7
  201. package/dist/chunks/chunk-Z4DQF6L3.ts.map +0 -7
  202. package/dist/chunks/chunk-ZI5MXMEX.ts.map +0 -7
  203. package/dist/chunks/file-selection-FTTKAOYX.ts.map +0 -7
  204. package/dist/chunks/git-backend-GMVS4PD4.ts.map +0 -7
  205. package/dist/chunks/manager-result-dispatcher-ASKGOSW5.ts +0 -22
  206. package/dist/chunks/manager-ui-IPAOAWZA.ts.map +0 -7
  207. package/dist/chunks/remote-selection-ui-QDNR7WEI.ts +0 -21
  208. package/dist/chunks/s3-backend-66O4FGBH.ts.map +0 -7
  209. package/dist/chunks/setup-wizard-JUJZYNNM.ts.map +0 -7
  210. package/dist/chunks/sync-inspection-CST2KL65.ts +0 -20
  211. package/dist/chunks/sync-operations-LBX3GKCM.ts.map +0 -7
  212. package/dist/chunks/webdav-backend-ECIKD7SO.ts.map +0 -7
  213. /package/dist/chunks/{manager-result-dispatcher-ASKGOSW5.ts.map → manager-result-dispatcher-OTUMQM26.ts.map} +0 -0
  214. /package/dist/chunks/{remote-selection-ui-QDNR7WEI.ts.map → remote-selection-ui-TZTJF32Y.ts.map} +0 -0
  215. /package/dist/chunks/{setup-switch-IS2PEZ3J.ts.map → setup-switch-5G2VNJHL.ts.map} +0 -0
  216. /package/dist/chunks/{snapshot-HKEWCVK6.ts.map → snapshot-JL5DFSXI.ts.map} +0 -0
  217. /package/dist/chunks/{sync-inspection-CST2KL65.ts.map → sync-inspection-P3ENJLNF.ts.map} +0 -0
  218. /package/dist/chunks/{sync-state-Q2FTXTW6.ts.map → sync-state-VHJUZ4YG.ts.map} +0 -0
@@ -10,295 +10,278 @@ export const MAX_GIT_SNAPSHOT_BYTES = 512 * 1024 * 1024;
10
10
  const SNAPSHOT_VERSION = 1;
11
11
 
12
12
  export interface GitManifestFile {
13
- path: string;
14
- sha256: string;
15
- size: number;
13
+ path: string;
14
+ sha256: string;
15
+ size: number;
16
16
  }
17
17
 
18
18
  export interface GitManifest {
19
- version: number;
20
- snapshotVersion: number;
21
- snapshotId: string;
22
- createdAt: string;
23
- machine: string;
24
- profile: string;
25
- syncSessions: boolean;
26
- snapshotSyncSessions?: boolean;
27
- selection?: SnapshotSelection;
28
- files: GitManifestFile[];
19
+ version: number;
20
+ snapshotVersion: number;
21
+ snapshotId: string;
22
+ createdAt: string;
23
+ machine: string;
24
+ profile: string;
25
+ syncSessions: boolean;
26
+ snapshotSyncSessions?: boolean;
27
+ selection?: SnapshotSelection;
28
+ files: GitManifestFile[];
29
29
  }
30
30
 
31
31
  export interface PreparedGitFile extends GitManifestFile {
32
- content: Buffer;
32
+ content: Buffer;
33
33
  }
34
34
 
35
35
  export interface GitTreeEntry {
36
- mode: string;
37
- type: string;
38
- object: string;
39
- path: string;
36
+ mode: string;
37
+ type: string;
38
+ object: string;
39
+ path: string;
40
40
  }
41
41
 
42
42
  export function isGitPayloadSizeAllowed(size: number) {
43
- return Number.isSafeInteger(size) && size >= 0 && size <= MAX_GIT_PAYLOAD_BYTES;
43
+ return Number.isSafeInteger(size) && size >= 0 && size <= MAX_GIT_PAYLOAD_BYTES;
44
44
  }
45
45
 
46
46
  export function requireGitManifest(value: unknown): GitManifest {
47
- if (!value || typeof value !== "object" || Array.isArray(value)) {
48
- throw new Error("Git publication manifest is malformed.");
49
- }
50
- const manifest = value as Partial<GitManifest>;
51
- if (manifest.version === 1) {
52
- throw new Error(
53
- "Git publication uses the unsupported pre-release gzip format; recreate this pi-sync-owned test branch.",
54
- );
55
- }
56
- if (
57
- manifest.version !== GIT_MANIFEST_VERSION ||
58
- manifest.snapshotVersion !== SNAPSHOT_VERSION ||
59
- typeof manifest.snapshotId !== "string" ||
60
- manifest.snapshotId.length > 512 ||
61
- !/^[A-Za-z0-9._-]+$/u.test(manifest.snapshotId) ||
62
- typeof manifest.createdAt !== "string" ||
63
- manifest.createdAt.length > 64 ||
64
- hasControlCharacter(manifest.createdAt) ||
65
- Number.isNaN(Date.parse(manifest.createdAt)) ||
66
- typeof manifest.machine !== "string" ||
67
- manifest.machine.length > 256 ||
68
- hasControlCharacter(manifest.machine) ||
69
- typeof manifest.profile !== "string" ||
70
- manifest.profile.length === 0 ||
71
- manifest.profile.length > 256 ||
72
- hasControlCharacter(manifest.profile) ||
73
- typeof manifest.syncSessions !== "boolean" ||
74
- (manifest.snapshotSyncSessions !== undefined &&
75
- typeof manifest.snapshotSyncSessions !== "boolean") ||
76
- !Array.isArray(manifest.files) ||
77
- !hasExactKeys(manifest as Record<string, unknown>, [
78
- "version",
79
- "snapshotVersion",
80
- "snapshotId",
81
- "createdAt",
82
- "machine",
83
- "profile",
84
- "syncSessions",
85
- ...(manifest.snapshotSyncSessions === undefined ? [] : ["snapshotSyncSessions"]),
86
- ...(manifest.selection === undefined ? [] : ["selection"]),
87
- "files",
88
- ])
89
- ) {
90
- throw new Error("Git publication manifest is malformed.");
91
- }
92
- if (manifest.selection !== undefined) portableSnapshotSelection(manifest.selection);
93
- let total = 0;
94
- const paths = new Set<string>();
95
- for (const rawFile of manifest.files) {
96
- if (!rawFile || typeof rawFile !== "object" || Array.isArray(rawFile)) {
97
- throw new Error("Git publication manifest file is malformed.");
98
- }
99
- const file = rawFile as Partial<GitManifestFile>;
100
- if (
101
- !hasExactKeys(file as Record<string, unknown>, ["path", "sha256", "size"]) ||
102
- !isSafeSnapshotPath(file.path) ||
103
- typeof file.sha256 !== "string" ||
104
- !/^[0-9a-f]{64}$/u.test(file.sha256) ||
105
- typeof file.size !== "number" ||
106
- !isGitPayloadSizeAllowed(file.size) ||
107
- paths.has(file.path)
108
- ) {
109
- throw new Error("Git publication manifest file is malformed.");
110
- }
111
- total += file.size;
112
- if (!Number.isSafeInteger(total) || total > MAX_GIT_SNAPSHOT_BYTES) {
113
- throw new Error(`Git snapshot content exceeds the ${MAX_GIT_SNAPSHOT_BYTES}-byte limit.`);
114
- }
115
- paths.add(file.path);
116
- }
117
- assertNoPathConflicts([...paths]);
118
- return manifest as GitManifest;
47
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
48
+ throw new Error("Git publication manifest is malformed.");
49
+ }
50
+ const manifest = value as Partial<GitManifest>;
51
+ if (manifest.version === 1) {
52
+ throw new Error(
53
+ "Git publication uses the unsupported pre-release gzip format; recreate this pi-sync-owned test branch.",
54
+ );
55
+ }
56
+ if (
57
+ manifest.version !== GIT_MANIFEST_VERSION ||
58
+ manifest.snapshotVersion !== SNAPSHOT_VERSION ||
59
+ typeof manifest.snapshotId !== "string" ||
60
+ manifest.snapshotId.length > 512 ||
61
+ !/^[A-Za-z0-9._-]+$/u.test(manifest.snapshotId) ||
62
+ typeof manifest.createdAt !== "string" ||
63
+ manifest.createdAt.length > 64 ||
64
+ hasControlCharacter(manifest.createdAt) ||
65
+ Number.isNaN(Date.parse(manifest.createdAt)) ||
66
+ typeof manifest.machine !== "string" ||
67
+ manifest.machine.length > 256 ||
68
+ hasControlCharacter(manifest.machine) ||
69
+ typeof manifest.profile !== "string" ||
70
+ manifest.profile.length === 0 ||
71
+ manifest.profile.length > 256 ||
72
+ hasControlCharacter(manifest.profile) ||
73
+ typeof manifest.syncSessions !== "boolean" ||
74
+ (manifest.snapshotSyncSessions !== undefined && typeof manifest.snapshotSyncSessions !== "boolean") ||
75
+ !Array.isArray(manifest.files) ||
76
+ !hasExactKeys(manifest as Record<string, unknown>, [
77
+ "version",
78
+ "snapshotVersion",
79
+ "snapshotId",
80
+ "createdAt",
81
+ "machine",
82
+ "profile",
83
+ "syncSessions",
84
+ ...(manifest.snapshotSyncSessions === undefined ? [] : ["snapshotSyncSessions"]),
85
+ ...(manifest.selection === undefined ? [] : ["selection"]),
86
+ "files",
87
+ ])
88
+ ) {
89
+ throw new Error("Git publication manifest is malformed.");
90
+ }
91
+ if (manifest.selection !== undefined) portableSnapshotSelection(manifest.selection);
92
+ let total = 0;
93
+ const paths = new Set<string>();
94
+ for (const rawFile of manifest.files) {
95
+ if (!rawFile || typeof rawFile !== "object" || Array.isArray(rawFile)) {
96
+ throw new Error("Git publication manifest file is malformed.");
97
+ }
98
+ const file = rawFile as Partial<GitManifestFile>;
99
+ if (
100
+ !hasExactKeys(file as Record<string, unknown>, ["path", "sha256", "size"]) ||
101
+ !isSafeSnapshotPath(file.path) ||
102
+ typeof file.sha256 !== "string" ||
103
+ !/^[0-9a-f]{64}$/u.test(file.sha256) ||
104
+ typeof file.size !== "number" ||
105
+ !isGitPayloadSizeAllowed(file.size) ||
106
+ paths.has(file.path)
107
+ ) {
108
+ throw new Error("Git publication manifest file is malformed.");
109
+ }
110
+ total += file.size;
111
+ if (!Number.isSafeInteger(total) || total > MAX_GIT_SNAPSHOT_BYTES) {
112
+ throw new Error(`Git snapshot content exceeds the ${MAX_GIT_SNAPSHOT_BYTES}-byte limit.`);
113
+ }
114
+ paths.add(file.path);
115
+ }
116
+ assertNoPathConflicts([...paths]);
117
+ return manifest as GitManifest;
119
118
  }
120
119
 
121
120
  export function validateGitSnapshot(snapshot: Snapshot, manifest: GitManifest, namespace: string) {
122
- const prepared = prepareGitSnapshot(snapshot, namespace);
123
- const syncSessions =
124
- snapshot.syncSessions === true ||
125
- snapshot.files.some((file) => file.path.startsWith("sessions/"));
126
- if (
127
- snapshot.id !== manifest.snapshotId ||
128
- snapshot.createdAt !== manifest.createdAt ||
129
- snapshot.machine !== manifest.machine ||
130
- snapshot.profile !== manifest.profile ||
131
- snapshot.syncSessions !== manifest.snapshotSyncSessions ||
132
- syncSessions !== manifest.syncSessions ||
133
- !sameOptionalInclude(
134
- snapshotSelectionInclude(snapshot),
135
- manifest.selection === undefined
136
- ? undefined
137
- : portableSnapshotSelection(manifest.selection).include,
138
- ) ||
139
- prepared.length !== manifest.files.length ||
140
- prepared.some((file, index) => {
141
- const expected = manifest.files[index];
142
- return (
143
- !expected ||
144
- file.path !== expected.path ||
145
- file.sha256 !== expected.sha256 ||
146
- file.size !== expected.size
147
- );
148
- })
149
- ) {
150
- throw new Error("Git snapshot identity does not match its publication manifest.");
151
- }
121
+ const prepared = prepareGitSnapshot(snapshot, namespace);
122
+ const syncSessions =
123
+ snapshot.syncSessions === true || snapshot.files.some((file) => file.path.startsWith("sessions/"));
124
+ if (
125
+ snapshot.id !== manifest.snapshotId ||
126
+ snapshot.createdAt !== manifest.createdAt ||
127
+ snapshot.machine !== manifest.machine ||
128
+ snapshot.profile !== manifest.profile ||
129
+ snapshot.syncSessions !== manifest.snapshotSyncSessions ||
130
+ syncSessions !== manifest.syncSessions ||
131
+ !sameOptionalInclude(
132
+ snapshotSelectionInclude(snapshot),
133
+ manifest.selection === undefined ? undefined : portableSnapshotSelection(manifest.selection).include,
134
+ ) ||
135
+ prepared.length !== manifest.files.length ||
136
+ prepared.some((file, index) => {
137
+ const expected = manifest.files[index];
138
+ return !expected || file.path !== expected.path || file.sha256 !== expected.sha256 || file.size !== expected.size;
139
+ })
140
+ ) {
141
+ throw new Error("Git snapshot identity does not match its publication manifest.");
142
+ }
152
143
  }
153
144
 
154
145
  export function prepareGitSnapshot(snapshot: Snapshot, namespace: string) {
155
- snapshotSelectionInclude(snapshot);
156
- if (
157
- snapshot.version !== SNAPSHOT_VERSION ||
158
- typeof snapshot.id !== "string" ||
159
- !snapshot.id ||
160
- snapshot.id.length > 512 ||
161
- !/^[A-Za-z0-9._-]+$/u.test(snapshot.id) ||
162
- snapshot.profile !== namespace ||
163
- !Array.isArray(snapshot.files) ||
164
- typeof snapshot.createdAt !== "string" ||
165
- !snapshot.createdAt ||
166
- snapshot.createdAt.length > 64 ||
167
- hasControlCharacter(snapshot.createdAt) ||
168
- Number.isNaN(Date.parse(snapshot.createdAt)) ||
169
- typeof snapshot.machine !== "string" ||
170
- snapshot.machine.length > 256 ||
171
- hasControlCharacter(snapshot.machine)
172
- ) {
173
- throw new Error("Invalid Git snapshot publication.");
174
- }
175
- const paths = new Set<string>();
176
- const prepared: PreparedGitFile[] = [];
177
- let total = 0;
178
- for (const file of snapshot.files) {
179
- if (
180
- !isSafeSnapshotPath(file.path) ||
181
- typeof file.contentBase64 !== "string" ||
182
- typeof file.sha256 !== "string" ||
183
- !/^[0-9a-f]{64}$/u.test(file.sha256) ||
184
- paths.has(file.path)
185
- ) {
186
- throw new Error("Invalid Git snapshot file.");
187
- }
188
- const content = Buffer.from(file.contentBase64, "base64");
189
- if (content.toString("base64") !== file.contentBase64 || sha256(content) !== file.sha256) {
190
- throw new Error("Git snapshot file checksum mismatch.");
191
- }
192
- if (!isGitPayloadSizeAllowed(content.byteLength)) {
193
- throw new Error(
194
- `Git snapshot file exceeds GitHub's ${MAX_GIT_PAYLOAD_BYTES}-byte regular-Git limit: ${file.path}`,
195
- );
196
- }
197
- total += content.byteLength;
198
- if (!Number.isSafeInteger(total) || total > MAX_GIT_SNAPSHOT_BYTES) {
199
- throw new Error(`Git snapshot content exceeds the ${MAX_GIT_SNAPSHOT_BYTES}-byte limit.`);
200
- }
201
- paths.add(file.path);
202
- prepared.push({ path: file.path, sha256: file.sha256, size: content.byteLength, content });
203
- }
204
- assertNoPathConflicts([...paths]);
205
- return prepared;
146
+ snapshotSelectionInclude(snapshot);
147
+ if (
148
+ snapshot.version !== SNAPSHOT_VERSION ||
149
+ typeof snapshot.id !== "string" ||
150
+ !snapshot.id ||
151
+ snapshot.id.length > 512 ||
152
+ !/^[A-Za-z0-9._-]+$/u.test(snapshot.id) ||
153
+ snapshot.profile !== namespace ||
154
+ !Array.isArray(snapshot.files) ||
155
+ typeof snapshot.createdAt !== "string" ||
156
+ !snapshot.createdAt ||
157
+ snapshot.createdAt.length > 64 ||
158
+ hasControlCharacter(snapshot.createdAt) ||
159
+ Number.isNaN(Date.parse(snapshot.createdAt)) ||
160
+ typeof snapshot.machine !== "string" ||
161
+ snapshot.machine.length > 256 ||
162
+ hasControlCharacter(snapshot.machine)
163
+ ) {
164
+ throw new Error("Invalid Git snapshot publication.");
165
+ }
166
+ const paths = new Set<string>();
167
+ const prepared: PreparedGitFile[] = [];
168
+ let total = 0;
169
+ for (const file of snapshot.files) {
170
+ if (
171
+ !isSafeSnapshotPath(file.path) ||
172
+ typeof file.contentBase64 !== "string" ||
173
+ typeof file.sha256 !== "string" ||
174
+ !/^[0-9a-f]{64}$/u.test(file.sha256) ||
175
+ paths.has(file.path)
176
+ ) {
177
+ throw new Error("Invalid Git snapshot file.");
178
+ }
179
+ const content = Buffer.from(file.contentBase64, "base64");
180
+ if (content.toString("base64") !== file.contentBase64 || sha256(content) !== file.sha256) {
181
+ throw new Error("Git snapshot file checksum mismatch.");
182
+ }
183
+ if (!isGitPayloadSizeAllowed(content.byteLength)) {
184
+ throw new Error(
185
+ `Git snapshot file exceeds GitHub's ${MAX_GIT_PAYLOAD_BYTES}-byte regular-Git limit: ${file.path}`,
186
+ );
187
+ }
188
+ total += content.byteLength;
189
+ if (!Number.isSafeInteger(total) || total > MAX_GIT_SNAPSHOT_BYTES) {
190
+ throw new Error(`Git snapshot content exceeds the ${MAX_GIT_SNAPSHOT_BYTES}-byte limit.`);
191
+ }
192
+ paths.add(file.path);
193
+ prepared.push({ path: file.path, sha256: file.sha256, size: content.byteLength, content });
194
+ }
195
+ assertNoPathConflicts([...paths]);
196
+ return prepared;
206
197
  }
207
198
 
208
199
  export function parseGitTree(output: Buffer): GitTreeEntry[] {
209
- if (output.byteLength === 0) return [];
210
- if (output.at(-1) !== 0) throw new Error("Git publication tree response is malformed.");
211
- return output
212
- .subarray(0, -1)
213
- .toString("utf8")
214
- .split("\0")
215
- .map((line) => {
216
- const match =
217
- /^(?<mode>[0-9]{6}) (?<type>blob|tree|commit) (?<object>[0-9a-f]{40})\t(?<path>.+)$/u.exec(
218
- line,
219
- );
220
- if (!match?.groups || hasControlCharacter(match.groups.path)) {
221
- throw new Error("Git publication tree response is malformed.");
222
- }
223
- return {
224
- mode: match.groups.mode,
225
- type: match.groups.type,
226
- object: match.groups.object,
227
- path: match.groups.path,
228
- };
229
- });
200
+ if (output.byteLength === 0) return [];
201
+ if (output.at(-1) !== 0) throw new Error("Git publication tree response is malformed.");
202
+ return output
203
+ .subarray(0, -1)
204
+ .toString("utf8")
205
+ .split("\0")
206
+ .map((line) => {
207
+ const match = /^(?<mode>[0-9]{6}) (?<type>blob|tree|commit) (?<object>[0-9a-f]{40})\t(?<path>.+)$/u.exec(line);
208
+ if (!match?.groups || hasControlCharacter(match.groups.path)) {
209
+ throw new Error("Git publication tree response is malformed.");
210
+ }
211
+ return {
212
+ mode: match.groups.mode,
213
+ type: match.groups.type,
214
+ object: match.groups.object,
215
+ path: match.groups.path,
216
+ };
217
+ });
230
218
  }
231
219
 
232
220
  export function validateGitPublicationTree(
233
- entries: GitTreeEntry[],
234
- manifest: GitManifest,
235
- manifestPath: string,
236
- filePath: (path: string) => string,
221
+ entries: GitTreeEntry[],
222
+ manifest: GitManifest,
223
+ manifestPath: string,
224
+ filePath: (path: string) => string,
237
225
  ) {
238
- const byPath = new Map<string, GitTreeEntry>();
239
- for (const entry of entries) {
240
- if (byPath.has(entry.path)) throw new Error("Git publication tree contains duplicate paths.");
241
- byPath.set(entry.path, entry);
242
- }
243
- const expectedPaths = [manifestPath, ...manifest.files.map((file) => filePath(file.path))];
244
- if (entries.length !== expectedPaths.length || expectedPaths.some((path) => !byPath.has(path))) {
245
- throw new Error("Git publication tree has missing or extra files.");
246
- }
247
- for (const expectedPath of expectedPaths) {
248
- const entry = byPath.get(expectedPath);
249
- if (entry?.mode !== "100644" || entry.type !== "blob") {
250
- throw new Error(`Git publication tree contains a non-regular file: ${expectedPath}`);
251
- }
252
- }
253
- return manifest.files.map((file) => byPath.get(filePath(file.path)) as GitTreeEntry);
226
+ const byPath = new Map<string, GitTreeEntry>();
227
+ for (const entry of entries) {
228
+ if (byPath.has(entry.path)) throw new Error("Git publication tree contains duplicate paths.");
229
+ byPath.set(entry.path, entry);
230
+ }
231
+ const expectedPaths = [manifestPath, ...manifest.files.map((file) => filePath(file.path))];
232
+ if (entries.length !== expectedPaths.length || expectedPaths.some((path) => !byPath.has(path))) {
233
+ throw new Error("Git publication tree has missing or extra files.");
234
+ }
235
+ for (const expectedPath of expectedPaths) {
236
+ const entry = byPath.get(expectedPath);
237
+ if (entry?.mode !== "100644" || entry.type !== "blob") {
238
+ throw new Error(`Git publication tree contains a non-regular file: ${expectedPath}`);
239
+ }
240
+ }
241
+ return manifest.files.map((file) => byPath.get(filePath(file.path)) as GitTreeEntry);
254
242
  }
255
243
 
256
244
  function sameOptionalInclude(left: string[] | undefined, right: string[] | undefined) {
257
- if (!left || !right) return left === right;
258
- return left.length === right.length && left.every((item, index) => item === right[index]);
245
+ if (!left || !right) return left === right;
246
+ return left.length === right.length && left.every((item, index) => item === right[index]);
259
247
  }
260
248
 
261
249
  function isSafeSnapshotPath(value: unknown): value is string {
262
- return (
263
- typeof value === "string" &&
264
- value.length > 0 &&
265
- value.length <= 4096 &&
266
- !value.startsWith("/") &&
267
- !value.includes("\\") &&
268
- !hasControlCharacter(value) &&
269
- value
270
- .split("/")
271
- .every(
272
- (segment) =>
273
- segment && segment !== "." && segment !== ".." && segment.toLowerCase() !== ".git",
274
- )
275
- );
250
+ return (
251
+ typeof value === "string" &&
252
+ value.length > 0 &&
253
+ value.length <= 4096 &&
254
+ !value.startsWith("/") &&
255
+ !value.includes("\\") &&
256
+ !hasControlCharacter(value) &&
257
+ value
258
+ .split("/")
259
+ .every((segment) => segment && segment !== "." && segment !== ".." && segment.toLowerCase() !== ".git")
260
+ );
276
261
  }
277
262
 
278
263
  function hasExactKeys(value: Record<string, unknown>, expected: string[]) {
279
- const keys = Object.keys(value).sort();
280
- const expectedKeys = [...expected].sort();
281
- return (
282
- keys.length === expectedKeys.length && keys.every((key, index) => key === expectedKeys[index])
283
- );
264
+ const keys = Object.keys(value).sort();
265
+ const expectedKeys = [...expected].sort();
266
+ return keys.length === expectedKeys.length && keys.every((key, index) => key === expectedKeys[index]);
284
267
  }
285
268
 
286
269
  function assertNoPathConflicts(paths: string[]) {
287
- const sorted = [...paths].sort();
288
- for (let index = 1; index < sorted.length; index += 1) {
289
- const parent = sorted[index - 1];
290
- const child = sorted[index];
291
- if (parent && child?.startsWith(`${parent}/`)) {
292
- throw new Error(`Git snapshot file path conflict: ${parent} and ${child}`);
293
- }
294
- }
270
+ const sorted = [...paths].sort();
271
+ for (let index = 1; index < sorted.length; index += 1) {
272
+ const parent = sorted[index - 1];
273
+ const child = sorted[index];
274
+ if (parent && child?.startsWith(`${parent}/`)) {
275
+ throw new Error(`Git snapshot file path conflict: ${parent} and ${child}`);
276
+ }
277
+ }
295
278
  }
296
279
 
297
280
  function sha256(value: Buffer) {
298
- return createHash("sha256").update(value).digest("hex");
281
+ return createHash("sha256").update(value).digest("hex");
299
282
  }
300
283
 
301
284
  function hasControlCharacter(value: string) {
302
- // biome-ignore lint/suspicious/noControlCharactersInRegex: Reject untrusted terminal/ref controls.
303
- return /[\u0000-\u001f\u007f-\u009f]/u.test(value);
285
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: Reject untrusted terminal/ref controls.
286
+ return /[\u0000-\u001f\u007f-\u009f]/u.test(value);
304
287
  }