(raw: string)
| 123 | } |
| 124 | |
| 125 | export function parseQuickAddPackage(raw: string): QuickAddPackage { |
| 126 | let parsed: unknown; |
| 127 | try { |
| 128 | parsed = JSON.parse(raw); |
| 129 | } catch (error) { |
| 130 | throw new Error( |
| 131 | `Package content is not valid JSON: ${(error as Error)?.message ?? error}`, |
| 132 | ); |
| 133 | } |
| 134 | |
| 135 | if (!isQuickAddPackage(parsed)) { |
| 136 | throw new Error("Content is not a valid QuickAdd package."); |
| 137 | } |
| 138 | |
| 139 | if (parsed.schemaVersion > QUICKADD_PACKAGE_SCHEMA_VERSION) { |
| 140 | throw new Error( |
| 141 | `Package schema version ${parsed.schemaVersion} is newer than this plugin supports (${QUICKADD_PACKAGE_SCHEMA_VERSION}).`, |
| 142 | ); |
| 143 | } |
| 144 | |
| 145 | // Reject duplicate asset paths at the untrusted-input boundary. The writer is |
| 146 | // last-write-wins per destination while the review pane resolves the FIRST |
| 147 | // match (decodeAssetPreview), so two assets at one path could show benign bytes |
| 148 | // in review while malicious bytes land on disk — a silent review-gate desync. |
| 149 | // Failing closed here keeps reviewed bytes identical to written bytes. |
| 150 | const duplicateAssetPath = findDuplicateAssetPath(parsed.assets); |
| 151 | if (duplicateAssetPath !== null) { |
| 152 | throw new Error( |
| 153 | `Package contains duplicate asset path "${duplicateAssetPath}". Each asset must have a unique path.`, |
| 154 | ); |
| 155 | } |
| 156 | |
| 157 | // Reject internally-inconsistent choices at the untrusted-input boundary. The |
| 158 | // same choice id can appear in MORE than one place in a package: as a flat |
| 159 | // `pkg.choices` entry AND inline inside a Multi's `choices` array (or as a |
| 160 | // NestedChoice/Conditional-branch embedded choice). The preview and the writer |
| 161 | // each pick ONE of those copies and assume the others are identical: |
| 162 | // buildPackagePreview's walk SKIPS an inline Multi child whose id is also an |
| 163 | // entry (trusting the entry's walk to cover it), while applyPackageImport's |
| 164 | // remapChoiceTree INSTALLS the inline copy and drops the standalone entry. A |
| 165 | // crafted package can make those copies DIVERGE — a benign top-level entry that |
| 166 | // the preview discloses, paired with a malicious inline child (e.g. |
| 167 | // runOnStartup:true) that actually installs — suppressing the capability |
| 168 | // disclosure and acknowledgement gate entirely. The structural validator |
| 169 | // (isQuickAddPackage) never checks this. A legitimately-exported package always |
| 170 | // clones every appearance of an id from one source choice, so all appearances |
| 171 | // are identical; divergence implies tampering. Fail closed so the copy the user |
| 172 | // reviews is provably the copy that installs. |
| 173 | const divergentChoiceId = findDivergentChoiceId(parsed.choices); |
| 174 | if (divergentChoiceId !== null) { |
| 175 | throw new Error( |
| 176 | `Package contains conflicting definitions for choice "${divergentChoiceId}". Each choice id must describe the same choice everywhere it appears.`, |
| 177 | ); |
| 178 | } |
| 179 | |
| 180 | // Reject a parented choice whose declared parent does not actually carry it |
| 181 | // inline. applyPackageImport installs a child SOLELY through its parent Multi's |
| 182 | // inline `choices` (remapChoiceTree keeps only the children listed there) and, |
no test coverage detected