@reddb-io/client 1.23.2-rc.362 → 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 +8 -0
- package/package.json +1 -1
- package/src/config.js +1 -1
- package/src/db-helpers.js +1 -1
- package/src/documents.js +1 -1
- package/src/internal/asset-fetcher/download.js +92 -11
- package/src/internal/asset-fetcher/index.js +82 -4
- package/src/kv.js +4 -4
- package/src/queue.js +1 -1
- package/src/vault.js +1 -1
package/README.md
CHANGED
|
@@ -225,3 +225,11 @@ MIT.
|
|
|
225
225
|
|
|
226
226
|
_Status legend: ✅ supported · ⚠️ partial (known gaps) · ❌ unsupported._
|
|
227
227
|
<!-- contract-matrix:end -->
|
|
228
|
+
|
|
229
|
+
## SQL quotation compatibility
|
|
230
|
+
|
|
231
|
+
SQL double quotes delimit identifiers; single quotes delimit text. For example,
|
|
232
|
+
`SELECT "title" FROM articles WHERE author = 'alice'` reads the `title` column.
|
|
233
|
+
Bind application values as parameters. JSON object/array strings keep double
|
|
234
|
+
quotes. See the [SQL quotation migration guide](../../docs/query/sql-quoting.md)
|
|
235
|
+
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/client",
|
|
3
|
-
"version": "1.23.
|
|
3
|
+
"version": "1.23.4",
|
|
4
4
|
"description": "Thin remote-only RedDB driver. Downloads the `red_client` binary on install. Speaks RedWire/gRPC/HTTP. Embedded URIs (memory://, file://, red:///path) are rejected — use @reddb-io/sdk for those.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/config.js
CHANGED
package/src/db-helpers.js
CHANGED
package/src/documents.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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(
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
|
47
|
-
const body = await downloadFollowingRedirects(
|
|
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
|
-
|
|
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
|
@@ -113,16 +113,16 @@ function kvIdentifier(value) {
|
|
|
113
113
|
function kvKeySegment(value) {
|
|
114
114
|
const key = String(value)
|
|
115
115
|
if (/^[A-Za-z0-9_]+$/.test(key)) return key
|
|
116
|
-
return `'${key.replace(/'/g, "''")}'`
|
|
116
|
+
return `'${key.replace(/\\/g, '\\\\').replace(/'/g, "''")}'`
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
function kvValueLiteral(value) {
|
|
120
120
|
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
|
121
121
|
if (value == null) return 'NULL'
|
|
122
|
-
if (typeof value === 'object') return `'${JSON.stringify(value).replace(/'/g, "''")}'`
|
|
123
|
-
return `'${String(value).replace(/'/g, "''")}'`
|
|
122
|
+
if (typeof value === 'object') return `'${JSON.stringify(value).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`
|
|
123
|
+
return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
function kvTagLiteral(value) {
|
|
127
|
-
return `'${String(value).replace(/'/g, "''")}'`
|
|
127
|
+
return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`
|
|
128
128
|
}
|
package/src/queue.js
CHANGED
|
@@ -114,7 +114,7 @@ function queueValueLiteral(value) {
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
function queueStringLiteral(value) {
|
|
117
|
-
return `'${String(value).replace(/'/g, "''")}'`
|
|
117
|
+
return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
function queuePayloads(result) {
|
package/src/vault.js
CHANGED