@reddb-io/sdk 1.23.2 → 1.23.4

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/README.md CHANGED
@@ -608,3 +608,11 @@ remote URIs.
608
608
 
609
609
  _Status legend: ✅ supported · ⚠️ partial (known gaps) · ❌ unsupported._
610
610
  <!-- contract-matrix:end -->
611
+
612
+ ## SQL quotation compatibility
613
+
614
+ SQL double quotes delimit identifiers; single quotes delimit text. For example,
615
+ `SELECT "title" FROM articles WHERE author = 'alice'` reads the `title` column.
616
+ Bind application values as parameters. JSON object/array strings keep double
617
+ quotes. See the [SQL quotation migration guide](../../docs/query/sql-quoting.md)
618
+ before upgrading applications that used double-quoted SQL text literals.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reddb-io/sdk",
3
- "version": "1.23.2",
3
+ "version": "1.23.4",
4
4
  "description": "Official embedded RedDB SDK — launches a local red binary over stdio JSON-RPC. Use @reddb-io/client for remote HTTP, gRPC, and RedWire.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/config.js CHANGED
@@ -62,5 +62,5 @@ function keyedValueLiteral(value) {
62
62
  }
63
63
 
64
64
  function keyedStringLiteral(value) {
65
- return `'${String(value).replace(/'/g, "''")}'`
65
+ return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`
66
66
  }
package/src/db-helpers.js CHANGED
@@ -91,5 +91,5 @@ function sqlIdentifier(value) {
91
91
  }
92
92
 
93
93
  function sqlString(value) {
94
- return `'${String(value).replace(/'/g, "''")}'`
94
+ return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`
95
95
  }
package/src/documents.js CHANGED
@@ -127,5 +127,5 @@ function sqlValueLiteral(value) {
127
127
  }
128
128
 
129
129
  function sqlString(value) {
130
- return `'${String(value).replace(/'/g, "''")}'`
130
+ return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`
131
131
  }
@@ -3,6 +3,71 @@ import { request as httpRequest } from 'node:http'
3
3
 
4
4
  const MAX_REDIRECTS = 5
5
5
 
6
+ /**
7
+ * Hosts a release download may be served from. The postinstall script writes
8
+ * the response to disk and then executes it, so an unrestricted redirect
9
+ * chain (or a plain-http hop) turns any network position into code
10
+ * execution on the installing machine. GitHub serves release assets from
11
+ * `github.com` and redirects to its object storage.
12
+ */
13
+ const ALLOWED_HOSTS = new Set([
14
+ 'github.com',
15
+ 'objects.githubusercontent.com',
16
+ 'release-assets.githubusercontent.com',
17
+ 'raw.githubusercontent.com',
18
+ ])
19
+
20
+ /**
21
+ * Ceiling on a downloaded asset. The `red` binary is tens of megabytes; a
22
+ * response without this cap is buffered in full whatever its size.
23
+ */
24
+ const MAX_BODY_BYTES = 512 * 1024 * 1024
25
+
26
+ export class DisallowedHostError extends Error {
27
+ constructor(url) {
28
+ super(`refusing to download from a non-allowlisted host: ${url}`)
29
+ this.name = 'DisallowedHostError'
30
+ this.code = 'DISALLOWED_HOST'
31
+ this.url = url
32
+ }
33
+ }
34
+
35
+ export class ResponseTooLargeError extends Error {
36
+ constructor(url, limit) {
37
+ super(`response from ${url} exceeds the ${limit}-byte limit`)
38
+ this.name = 'ResponseTooLargeError'
39
+ this.code = 'RESPONSE_TOO_LARGE'
40
+ this.url = url
41
+ this.limit = limit
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Reject anything that is not https on an allowlisted host. Applied to the
47
+ * initial URL and to every redirect target, since a redirect is exactly how
48
+ * an attacker would leave the allowlist.
49
+ *
50
+ * `allowedHosts` exists so the test suite can point the transport at a local
51
+ * server; production callers never pass it and get {@link ALLOWED_HOSTS}
52
+ * plus the https requirement.
53
+ */
54
+ export function assertDownloadableUrl(url, allowedHosts = ALLOWED_HOSTS) {
55
+ let parsed
56
+ try {
57
+ parsed = new URL(url)
58
+ } catch {
59
+ throw new DisallowedHostError(url)
60
+ }
61
+ const isDefaultPolicy = allowedHosts === ALLOWED_HOSTS
62
+ if (isDefaultPolicy && parsed.protocol !== 'https:') {
63
+ throw new DisallowedHostError(url)
64
+ }
65
+ if (!allowedHosts.has(parsed.hostname)) {
66
+ throw new DisallowedHostError(url)
67
+ }
68
+ return parsed
69
+ }
70
+
6
71
  export class AssetNotFoundError extends Error {
7
72
  constructor(url) {
8
73
  super(`asset not found (HTTP 404) at ${url}`)
@@ -31,21 +96,27 @@ export class TooManyRedirectsError extends Error {
31
96
  }
32
97
  }
33
98
 
34
- function pickRequest(url) {
35
- return url.startsWith('http://') ? httpRequest : httpsRequest
36
- }
37
-
38
99
  function resolveLocation(currentUrl, location) {
39
100
  if (/^https?:\/\//i.test(location)) return location
40
101
  return new URL(location, currentUrl).toString()
41
102
  }
42
103
 
43
- export function downloadFollowingRedirects(url, { userAgent, originalUrl } = {}, depth = 0) {
104
+ export function downloadFollowingRedirects(
105
+ url,
106
+ { userAgent, originalUrl, allowedHosts } = {},
107
+ depth = 0,
108
+ ) {
44
109
  const startUrl = originalUrl || url
45
110
  if (depth > MAX_REDIRECTS) {
46
111
  return Promise.reject(new TooManyRedirectsError(startUrl))
47
112
  }
48
- const request = pickRequest(url)
113
+ let parsed
114
+ try {
115
+ parsed = assertDownloadableUrl(url, allowedHosts)
116
+ } catch (err) {
117
+ return Promise.reject(err)
118
+ }
119
+ const request = parsed.protocol === 'http:' ? httpRequest : httpsRequest
49
120
  return new Promise((resolve, reject) => {
50
121
  const req = request(
51
122
  url,
@@ -61,10 +132,11 @@ export function downloadFollowingRedirects(url, { userAgent, originalUrl } = {},
61
132
  if (status >= 300 && status < 400 && res.headers.location) {
62
133
  res.resume()
63
134
  const next = resolveLocation(url, res.headers.location)
64
- downloadFollowingRedirects(next, { userAgent, originalUrl: startUrl }, depth + 1).then(
65
- resolve,
66
- reject,
67
- )
135
+ downloadFollowingRedirects(
136
+ next,
137
+ { userAgent, originalUrl: startUrl, allowedHosts },
138
+ depth + 1,
139
+ ).then(resolve, reject)
68
140
  return
69
141
  }
70
142
  if (status === 404) {
@@ -78,7 +150,16 @@ export function downloadFollowingRedirects(url, { userAgent, originalUrl } = {},
78
150
  return
79
151
  }
80
152
  const chunks = []
81
- res.on('data', (chunk) => chunks.push(chunk))
153
+ let received = 0
154
+ res.on('data', (chunk) => {
155
+ received += chunk.length
156
+ if (received > MAX_BODY_BYTES) {
157
+ res.destroy()
158
+ reject(new ResponseTooLargeError(startUrl, MAX_BODY_BYTES))
159
+ return
160
+ }
161
+ chunks.push(chunk)
162
+ })
82
163
  res.on('end', () => resolve(Buffer.concat(chunks)))
83
164
  res.on('error', reject)
84
165
  },
@@ -26,7 +26,44 @@
26
26
 
27
27
  import { composeAssetName } from './asset-name.js'
28
28
  import { downloadFollowingRedirects } from './download.js'
29
- import { verifySha256 } from './checksum.js'
29
+ import { sha256Hex, verifySha256, ChecksumMismatchError } from './checksum.js'
30
+
31
+ /**
32
+ * A GitHub `owner/name` pair. `repo` reaches this function from
33
+ * `REDDB_POSTINSTALL_REPO` / `REDDB_MCP_REPO`, and is interpolated into the
34
+ * download URL, so a value containing a path or authority would point the
35
+ * download somewhere else entirely.
36
+ */
37
+ const REPO_PATTERN = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/
38
+
39
+ /** A release tag, as it appears in the download URL path. */
40
+ const TAG_PATTERN = /^[A-Za-z0-9._+-]+$/
41
+
42
+ export class ChecksumUnavailableError extends Error {
43
+ constructor(assetName) {
44
+ super(
45
+ `no SHA256SUMS entry for ${assetName}; refusing to install an unverified binary ` +
46
+ `(set REDDB_POSTINSTALL_ALLOW_UNVERIFIED=1 to override)`,
47
+ )
48
+ this.name = 'ChecksumUnavailableError'
49
+ this.code = 'CHECKSUM_UNAVAILABLE'
50
+ this.assetName = assetName
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Parse a `SHA256SUMS` file (`<hex> <filename>` per line) and return the
56
+ * digest recorded for `assetName`, or `null` when it is not listed.
57
+ */
58
+ export function sha256FromSumsFile(text, assetName) {
59
+ for (const line of String(text).split('\n')) {
60
+ const match = line.trim().match(/^([0-9a-fA-F]{64})\s+\*?(.+)$/)
61
+ if (match && match[2].trim() === assetName) {
62
+ return match[1].toLowerCase()
63
+ }
64
+ }
65
+ return null
66
+ }
30
67
 
31
68
  export async function fetchReleaseAsset({ repo, tag, platform, arch, binName, sha256 } = {}) {
32
69
  if (typeof repo !== 'string' || repo === '') {
@@ -42,11 +79,52 @@ export async function fetchReleaseAsset({ repo, tag, platform, arch, binName, sh
42
79
  throw new TypeError('fetchReleaseAsset: `arch` must be a non-empty string')
43
80
  }
44
81
 
82
+ if (!REPO_PATTERN.test(repo)) {
83
+ throw new TypeError(`fetchReleaseAsset: \`repo\` must be "owner/name", got ${JSON.stringify(repo)}`)
84
+ }
85
+ if (!TAG_PATTERN.test(tag)) {
86
+ throw new TypeError(`fetchReleaseAsset: \`tag\` contains unsupported characters: ${JSON.stringify(tag)}`)
87
+ }
88
+
45
89
  const assetName = composeAssetName({ platform, arch, binName })
46
- const url = `https://github.com/${repo}/releases/download/${tag}/${assetName}`
47
- const body = await downloadFollowingRedirects(url)
90
+ const base = `https://github.com/${repo}/releases/download/${tag}`
91
+ const body = await downloadFollowingRedirects(`${base}/${assetName}`)
92
+
93
+ // The caller may pin a digest; otherwise fall back to the `SHA256SUMS`
94
+ // file the release publishes beside the assets. The downloaded bytes are
95
+ // written to disk and executed, so installing them unverified is the one
96
+ // outcome worth failing the install over — `install.sh` has verified since
97
+ // it shipped, and this path had not.
48
98
  if (sha256) {
49
99
  verifySha256(body, sha256)
100
+ return body
101
+ }
102
+
103
+ let expected = null
104
+ try {
105
+ const sums = await downloadFollowingRedirects(`${base}/SHA256SUMS`)
106
+ expected = sha256FromSumsFile(sums.toString('utf8'), assetName)
107
+ } catch {
108
+ expected = null
50
109
  }
51
- return body
110
+ if (expected) {
111
+ if (sha256Hex(body) !== expected) {
112
+ throw new ChecksumMismatchError(expected, sha256Hex(body))
113
+ }
114
+ return body
115
+ }
116
+ if (allowUnverified()) {
117
+ return body
118
+ }
119
+ throw new ChecksumUnavailableError(assetName)
120
+ }
121
+
122
+ /**
123
+ * Escape hatch for installing from a release that predates `SHA256SUMS`, or
124
+ * from a fork that does not publish one. Opt-in and loud rather than the
125
+ * silent default it used to be.
126
+ */
127
+ function allowUnverified() {
128
+ const raw = process.env.REDDB_POSTINSTALL_ALLOW_UNVERIFIED
129
+ return raw === '1' || raw === 'true' || raw === 'yes'
52
130
  }
package/src/kv.js CHANGED
@@ -120,16 +120,16 @@ function kvIdentifier(value) {
120
120
  function kvKeySegment(value) {
121
121
  const key = String(value)
122
122
  if (/^[A-Za-z0-9_]+$/.test(key)) return key
123
- return `'${key.replace(/'/g, "''")}'`
123
+ return `'${key.replace(/\\/g, '\\\\').replace(/'/g, "''")}'`
124
124
  }
125
125
 
126
126
  function kvValueLiteral(value) {
127
127
  if (typeof value === 'number' || typeof value === 'boolean') return String(value)
128
128
  if (value == null) return 'NULL'
129
- if (typeof value === 'object') return `'${JSON.stringify(value).replace(/'/g, "''")}'`
130
- return `'${String(value).replace(/'/g, "''")}'`
129
+ if (typeof value === 'object') return `'${JSON.stringify(value).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`
130
+ return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`
131
131
  }
132
132
 
133
133
  function kvTagLiteral(value) {
134
- return `'${String(value).replace(/'/g, "''")}'`
134
+ return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`
135
135
  }
package/src/queue.js CHANGED
@@ -127,7 +127,7 @@ function queueValueLiteral(value) {
127
127
  }
128
128
 
129
129
  function queueStringLiteral(value) {
130
- return `'${String(value).replace(/'/g, "''")}'`
130
+ return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`
131
131
  }
132
132
 
133
133
  function queuePayloads(result) {
package/src/vault.js CHANGED
@@ -54,5 +54,5 @@ function keyedValueLiteral(value) {
54
54
  }
55
55
 
56
56
  function keyedStringLiteral(value) {
57
- return `'${String(value).replace(/'/g, "''")}'`
57
+ return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`
58
58
  }