@mblaney/holster 1.0.6 → 1.0.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mblaney/holster",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "description": "Holster is a service that allows synchronising data between devices using Node, Deno, Bun or the browser.",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/src/array.js CHANGED
@@ -5,29 +5,40 @@ if (typeof btoa === "undefined") {
5
5
 
6
6
  // This is Array extended to have .toString(["utf8"|"hex"|"base64"])
7
7
  function SeaArray() {}
8
-
9
8
  Object.assign(SeaArray, {from: Array.from})
10
9
  SeaArray.prototype = Object.create(Array.prototype)
10
+
11
11
  SeaArray.prototype.toString = function (enc, start, end) {
12
12
  if (!enc) enc = "utf8"
13
13
  if (!start) start = 0
14
14
 
15
- const length = this.length
15
+ end = end ? Math.min(Math.max(end, start), this.length) : this.length
16
+
16
17
  if (enc === "hex") {
17
- const buf = new Uint8Array(this)
18
- return [...Array(((end && end + 1) || length) - start).keys()]
19
- .map(i => buf[i + start].toString(16).padStart(2, "0"))
20
- .join("")
18
+ const buf = new Uint8Array(this.slice(start, end))
19
+ return Array.from(buf, byte => byte.toString(16).padStart(2, "0")).join("")
21
20
  }
22
21
 
23
22
  if (enc === "utf8") {
24
- return Array.from({length: (end || length) - start}, (_, i) =>
25
- String.fromCharCode(this[i + start]),
26
- ).join("")
23
+ return Array.from({length: end - start}, (_, i) => {
24
+ const charCode = this[i + start]
25
+ // Use unicode replacement character for invalid character codes.
26
+ if (charCode < 0 || charCode > 0x10ffff) {
27
+ return String.fromCharCode(0xfffd)
28
+ }
29
+ return String.fromCharCode(charCode)
30
+ }).join("")
27
31
  }
28
32
 
29
33
  if (enc === "base64") {
30
- return btoa(this)
34
+ const utf8String = Array.from({length: end - start}, (_, i) => {
35
+ const charCode = this[i + start]
36
+ if (charCode < 0 || charCode > 255) {
37
+ return String.fromCharCode(0xfffd)
38
+ }
39
+ return String.fromCharCode(charCode)
40
+ }).join("")
41
+ return btoa(utf8String)
31
42
  }
32
43
  }
33
44
 
package/src/buffer.js CHANGED
@@ -1,83 +1,416 @@
1
1
  import SeaArray from "./array.js"
2
2
 
3
- // This is Buffer implementation used in SEA. Functionality is mostly
4
- // compatible with NodeJS "safe-buffer" and is used for encoding conversions
5
- // between binary and "hex" | "utf8" | "base64"
6
- // See documentation and validation for safe implementation in:
7
- // https://github.com/feross/safe-buffer#update
3
+ // Security constants
4
+ const SECURITY_LIMITS = {
5
+ MAX_BUFFER_SIZE: 10 * 1024 * 1024, // 10MB limit
6
+ MAX_STRING_LENGTH: 1024 * 1024, // 1MB string limit
7
+ }
8
+
9
+ // Secure Buffer implementation for SEA.js
10
+ // Compatible with NodeJS safe-buffer but with enhanced security
8
11
  function SafeBuffer(...props) {
9
12
  console.warn("new SafeBuffer() is deprecated, please use SafeBuffer.from()")
10
13
  return SafeBuffer.from(...props)
11
14
  }
12
15
 
13
16
  SafeBuffer.prototype = Object.create(Array.prototype)
14
- Object.assign(SafeBuffer, {
15
- // (data, enc) where typeof data === "string" then
16
- // enc === "utf8"|"hex"|"base64"
17
- from() {
18
- if (!Object.keys(arguments).length || arguments[0] == null) {
17
+
18
+ // Input validation helpers
19
+ const validateStringInput = (input, encoding) => {
20
+ if (typeof input !== "string") {
21
+ throw new TypeError("Input must be a string for encoding: " + encoding)
22
+ }
23
+
24
+ if (input.length > SECURITY_LIMITS.MAX_STRING_LENGTH) {
25
+ throw new RangeError(
26
+ `String too large: ${input.length} > ${SECURITY_LIMITS.MAX_STRING_LENGTH}`,
27
+ )
28
+ }
29
+ }
30
+
31
+ const validateBufferSize = (size, operation = "buffer operation") => {
32
+ if (size > SECURITY_LIMITS.MAX_BUFFER_SIZE) {
33
+ throw new RangeError(
34
+ `Buffer size too large for ${operation}: ${size} > ${SECURITY_LIMITS.MAX_BUFFER_SIZE}`,
35
+ )
36
+ }
37
+ }
38
+
39
+ // Secure hex parsing
40
+ const parseHexString = hexString => {
41
+ validateStringInput(hexString, "hex")
42
+
43
+ // Remove any whitespace and convert to lowercase
44
+ const cleanHex = hexString.replace(/\s/g, "").toLowerCase()
45
+
46
+ // Validate hex format: only 0-9, a-f characters
47
+ if (!/^[0-9a-f]*$/.test(cleanHex)) {
48
+ throw new TypeError("Invalid hex string: contains non-hex characters")
49
+ }
50
+
51
+ // Must be even length
52
+ if (cleanHex.length % 2 !== 0) {
53
+ throw new TypeError("Invalid hex string: must have even length")
54
+ }
55
+
56
+ if (cleanHex.length === 0) {
57
+ return new Uint8Array(0)
58
+ }
59
+
60
+ validateBufferSize(cleanHex.length / 2, "hex parsing")
61
+
62
+ const bytes = new Uint8Array(cleanHex.length / 2)
63
+ for (let i = 0; i < cleanHex.length; i += 2) {
64
+ const hexByte = cleanHex.substr(i, 2)
65
+ bytes[i / 2] = parseInt(hexByte, 16)
66
+ }
67
+ return bytes
68
+ }
69
+
70
+ const encodeUtf8String = str => {
71
+ validateStringInput(str, "utf8")
72
+
73
+ try {
74
+ // Use standard TextEncoder for proper UTF-8 encoding
75
+ const encoder = new TextEncoder()
76
+ const bytes = encoder.encode(str)
77
+
78
+ validateBufferSize(bytes.length, "UTF-8 encoding")
79
+ return bytes
80
+ } catch (err) {
81
+ throw new TypeError("Failed to encode UTF-8 string: " + err.message)
82
+ }
83
+ }
84
+
85
+ const encodeBinaryString = str => {
86
+ validateStringInput(str, "binary")
87
+
88
+ validateBufferSize(str.length, "binary encoding")
89
+
90
+ const bytes = new Uint8Array(str.length)
91
+ for (let i = 0; i < str.length; i++) {
92
+ const charCode = str.charCodeAt(i)
93
+
94
+ // Binary strings should only contain bytes (0-255)
95
+ if (charCode > 255) {
19
96
  throw new TypeError(
20
- "First argument must be a string, Buffer, ArrayBuffer, Array, or array-like object.",
97
+ `Invalid binary string: character at position ${i} exceeds byte range (${charCode} > 255)`,
21
98
  )
22
99
  }
100
+
101
+ bytes[i] = charCode
102
+ }
103
+ return bytes
104
+ }
105
+
106
+ // Secure base64 decoding
107
+ const parseBase64String = base64String => {
108
+ validateStringInput(base64String, "base64")
109
+
110
+ // Remove any whitespace
111
+ const cleanBase64 = base64String.replace(/\s/g, "")
112
+
113
+ // Validate base64 format
114
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(cleanBase64)) {
115
+ throw new TypeError("Invalid base64 string: contains invalid characters")
116
+ }
117
+
118
+ try {
119
+ const decodedString = atob(cleanBase64)
120
+ validateBufferSize(decodedString.length, "base64 decoding")
121
+
122
+ const bytes = new Uint8Array(decodedString.length)
123
+ for (let i = 0; i < decodedString.length; i++) {
124
+ bytes[i] = decodedString.charCodeAt(i)
125
+ }
126
+
127
+ return bytes
128
+ } catch (err) {
129
+ throw new TypeError("Failed to decode base64 string: " + err.message)
130
+ }
131
+ }
132
+
133
+ // Secure array-like input processing
134
+ const processArrayLikeInput = input => {
135
+ let bytes
136
+
137
+ if (input instanceof ArrayBuffer) {
138
+ validateBufferSize(input.byteLength, "ArrayBuffer processing")
139
+ bytes = new Uint8Array(input)
140
+ } else if (ArrayBuffer.isView(input)) {
141
+ validateBufferSize(
142
+ input.byteLength || input.length,
143
+ "TypedArray processing",
144
+ )
145
+ bytes = new Uint8Array(input.buffer, input.byteOffset, input.byteLength)
146
+ } else if (Array.isArray(input)) {
147
+ validateBufferSize(input.length, "Array processing")
148
+
149
+ // Validate all array elements are valid byte values
150
+ for (let i = 0; i < input.length; i++) {
151
+ const val = input[i]
152
+ if (
153
+ typeof val !== "number" ||
154
+ val < 0 ||
155
+ val > 255 ||
156
+ !Number.isInteger(val)
157
+ ) {
158
+ throw new TypeError(
159
+ `Invalid byte value at index ${i}: ${val} (must be integer 0-255)`,
160
+ )
161
+ }
162
+ }
163
+
164
+ bytes = new Uint8Array(input)
165
+ } else if (
166
+ typeof input === "object" &&
167
+ input !== null &&
168
+ typeof input.length === "number"
169
+ ) {
170
+ // Handle array-like objects
171
+ validateBufferSize(input.length, "array-like processing")
172
+
173
+ const arr = Array.from(input)
174
+ for (let i = 0; i < arr.length; i++) {
175
+ const val = arr[i]
176
+ if (
177
+ typeof val !== "number" ||
178
+ val < 0 ||
179
+ val > 255 ||
180
+ !Number.isInteger(val)
181
+ ) {
182
+ throw new TypeError(
183
+ `Invalid byte value at index ${i}: ${val} (must be integer 0-255)`,
184
+ )
185
+ }
186
+ }
187
+
188
+ bytes = new Uint8Array(arr)
189
+ } else {
190
+ throw new TypeError("Unsupported input type: " + typeof input)
191
+ }
192
+
193
+ return bytes
194
+ }
195
+
196
+ Object.assign(SafeBuffer, {
197
+ from() {
198
+ const argCount = arguments.length
199
+
200
+ if (argCount === 0) {
201
+ throw new TypeError("SafeBuffer.from() requires at least one argument")
202
+ }
203
+
23
204
  const input = arguments[0]
24
- let buf
205
+
206
+ if (input === null || input === undefined) {
207
+ throw new TypeError("First argument must not be null or undefined")
208
+ }
209
+
210
+ let bytes
211
+
25
212
  if (typeof input === "string") {
26
- const enc = arguments[1] || "utf8"
27
- if (enc === "hex") {
28
- const bytes = input
29
- .match(/([\da-fA-F]{2})/g)
30
- .map(byte => parseInt(byte, 16))
31
- if (!bytes || !bytes.length) {
32
- throw new TypeError("Invalid first argument for type 'hex'.")
33
- }
34
- buf = SeaArray.from(bytes)
35
- } else if (enc === "utf8" || enc === "binary") {
36
- const length = input.length
37
- const words = new Uint16Array(length)
38
- Array.from({length: length}, (_, i) => (words[i] = input.charCodeAt(i)))
39
- buf = SeaArray.from(words)
40
- } else if (enc === "base64") {
41
- const dec = atob(input)
42
- const length = dec.length
43
- const bytes = new Uint8Array(length)
44
- Array.from({length: length}, (_, i) => (bytes[i] = dec.charCodeAt(i)))
45
- buf = SeaArray.from(bytes)
46
- } else {
47
- console.info("SafeBuffer.from unknown encoding: " + enc)
213
+ const encoding = (arguments[1] || "utf8").toLowerCase()
214
+
215
+ switch (encoding) {
216
+ case "hex":
217
+ bytes = parseHexString(input)
218
+ break
219
+
220
+ case "utf8":
221
+ case "utf-8":
222
+ bytes = encodeUtf8String(input)
223
+ break
224
+
225
+ case "binary":
226
+ case "latin1":
227
+ bytes = encodeBinaryString(input)
228
+ break
229
+
230
+ case "base64":
231
+ bytes = parseBase64String(input)
232
+ break
233
+
234
+ case "ascii":
235
+ // ASCII is subset of UTF-8, but validate range
236
+ validateStringInput(input, "ascii")
237
+ for (let i = 0; i < input.length; i++) {
238
+ if (input.charCodeAt(i) > 127) {
239
+ throw new TypeError(
240
+ `Invalid ASCII character at position ${i}: ${input.charCodeAt(i)} > 127`,
241
+ )
242
+ }
243
+ }
244
+ bytes = encodeUtf8String(input) // Safe since ASCII is UTF-8 subset
245
+ break
246
+
247
+ default:
248
+ throw new TypeError("Unknown encoding: " + encoding)
48
249
  }
49
- return buf
250
+ } else {
251
+ bytes = processArrayLikeInput(input)
50
252
  }
51
- const length = input.byteLength ? input.byteLength : input.length
52
- if (length) {
53
- let buf
54
- if (input instanceof ArrayBuffer) {
55
- buf = new Uint8Array(input)
56
- }
57
- const check = SeaArray.from(buf || input)
58
- return check
253
+
254
+ if (!bytes || bytes.length === 0) {
255
+ return SeaArray.from(new Uint8Array(0))
256
+ }
257
+
258
+ try {
259
+ return SeaArray.from(bytes)
260
+ } catch (err) {
261
+ throw new Error("Failed to create SafeBuffer: " + err.message)
59
262
  }
60
263
  },
61
- // This is "safe-buffer.alloc" sans encoding support
264
+
62
265
  alloc(length, fill = 0) {
63
- return SeaArray.from(
64
- new Uint8Array(Array.from({length: length}, () => fill)),
65
- )
266
+ if (typeof length !== "number" || !Number.isInteger(length) || length < 0) {
267
+ throw new TypeError("Length must be a non-negative integer")
268
+ }
269
+
270
+ validateBufferSize(length, "buffer allocation")
271
+
272
+ // Validate fill value
273
+ if (typeof fill === "number") {
274
+ if (fill < 0 || fill > 255 || !Number.isInteger(fill)) {
275
+ throw new TypeError("Fill value must be an integer between 0 and 255")
276
+ }
277
+ } else if (typeof fill === "string") {
278
+ if (fill.length !== 1) {
279
+ throw new TypeError("Fill string must be exactly one character")
280
+ }
281
+ fill = fill.charCodeAt(0)
282
+ if (fill > 255) {
283
+ throw new TypeError("Fill character code must be <= 255")
284
+ }
285
+ } else {
286
+ throw new TypeError("Fill must be a number or single character string")
287
+ }
288
+
289
+ try {
290
+ const buffer = new Uint8Array(length)
291
+ if (fill !== 0) {
292
+ buffer.fill(fill)
293
+ }
294
+ return SeaArray.from(buffer)
295
+ } catch (err) {
296
+ throw new Error("Failed to allocate buffer: " + err.message)
297
+ }
66
298
  },
67
- // This puts together array of array like members
299
+
68
300
  concat(arr) {
69
- // octet array
70
301
  if (!Array.isArray(arr)) {
71
- throw new TypeError(
72
- "First argument must be Array containing ArrayBuffer or Uint8Array instances.",
73
- )
302
+ throw new TypeError("First argument must be an array")
303
+ }
304
+
305
+ if (arr.length === 0) {
306
+ return SeaArray.from(new Uint8Array(0))
307
+ }
308
+
309
+ // Validate all array elements and calculate total size
310
+ let totalLength = 0
311
+ const validatedBuffers = []
312
+
313
+ for (let i = 0; i < arr.length; i++) {
314
+ const item = arr[i]
315
+
316
+ if (!item) {
317
+ throw new TypeError(`Array element at index ${i} is null or undefined`)
318
+ }
319
+
320
+ let bytes
321
+ try {
322
+ bytes = processArrayLikeInput(item)
323
+ } catch (err) {
324
+ throw new TypeError(
325
+ `Invalid array element at index ${i}: ${err.message}`,
326
+ )
327
+ }
328
+
329
+ totalLength += bytes.length
330
+ validateBufferSize(totalLength, "buffer concatenation")
331
+
332
+ validatedBuffers.push(bytes)
74
333
  }
75
- return SeaArray.from(
76
- arr.reduce((ret, item) => ret.concat(Array.from(item)), []),
334
+
335
+ try {
336
+ const result = new Uint8Array(totalLength)
337
+ let offset = 0
338
+
339
+ for (const buffer of validatedBuffers) {
340
+ result.set(buffer, offset)
341
+ offset += buffer.length
342
+ }
343
+
344
+ return SeaArray.from(result)
345
+ } catch (err) {
346
+ throw new Error("Failed to concatenate buffers: " + err.message)
347
+ }
348
+ },
349
+
350
+ // Helper method to check if object is SafeBuffer
351
+ isBuffer(obj) {
352
+ return (
353
+ obj instanceof SeaArray ||
354
+ (obj && typeof obj === "object" && obj.constructor === SafeBuffer)
77
355
  )
78
356
  },
357
+
358
+ // Get byte length of string in specific encoding
359
+ byteLength(string, encoding = "utf8") {
360
+ if (typeof string !== "string") {
361
+ throw new TypeError("First argument must be a string")
362
+ }
363
+
364
+ switch (encoding.toLowerCase()) {
365
+ case "utf8":
366
+ case "utf-8":
367
+ return new TextEncoder().encode(string).length
368
+
369
+ case "ascii":
370
+ case "binary":
371
+ case "latin1":
372
+ return string.length
373
+
374
+ case "base64":
375
+ const clean = string.replace(/\s/g, "")
376
+ return (
377
+ Math.floor((clean.length * 3) / 4) - (clean.match(/=/g) || []).length
378
+ )
379
+
380
+ case "hex":
381
+ return Math.floor(string.replace(/\s/g, "").length / 2)
382
+
383
+ default:
384
+ throw new TypeError("Unknown encoding: " + encoding)
385
+ }
386
+ },
79
387
  })
388
+
80
389
  SafeBuffer.prototype.from = SafeBuffer.from
81
- SafeBuffer.prototype.toString = SeaArray.prototype.toString
390
+ SafeBuffer.prototype.toString = function (
391
+ encoding = "utf8",
392
+ start = 0,
393
+ end = this.length,
394
+ ) {
395
+ // Input validation
396
+ if (typeof encoding !== "string") {
397
+ throw new TypeError("Encoding must be a string")
398
+ }
399
+
400
+ if (typeof start !== "number" || !Number.isInteger(start) || start < 0) {
401
+ throw new TypeError("Start must be a non-negative integer")
402
+ }
403
+
404
+ if (typeof end !== "number" || !Number.isInteger(end) || end < start) {
405
+ throw new TypeError("End must be an integer >= start")
406
+ }
407
+
408
+ // Delegate to SeaArray with validation
409
+ try {
410
+ return SeaArray.prototype.toString.call(this, encoding, start, end)
411
+ } catch (err) {
412
+ throw new Error("Failed to convert buffer to string: " + err.message)
413
+ }
414
+ }
82
415
 
83
416
  export default SafeBuffer
package/src/dup.js CHANGED
@@ -8,6 +8,8 @@ const Dup = maxAge => {
8
8
  dup.store[id] = Date.now()
9
9
  if (!dup.expiry) {
10
10
  dup.expiry = setTimeout(() => {
11
+ if (dup.expiry) return
12
+
11
13
  const now = Date.now()
12
14
  Object.keys(dup.store).forEach(id => {
13
15
  if (now - dup.store[id] > maxAge) delete dup.store[id]
package/src/get.js CHANGED
@@ -1,7 +1,18 @@
1
1
  import {match} from "./utils.js"
2
2
 
3
3
  const Get = (lex, graph) => {
4
+ if (!lex || typeof lex !== "object") {
5
+ throw new TypeError("lex must be an object")
6
+ }
7
+ if (!graph || typeof graph !== "object") {
8
+ throw new TypeError("graph must be an object")
9
+ }
10
+
4
11
  const soul = lex["#"]
12
+ if (!soul || typeof soul !== "string") {
13
+ throw new TypeError("soul must be a string")
14
+ }
15
+
5
16
  if (!graph[soul]) return
6
17
 
7
18
  const node = {_: {"#": soul, ">": {}}}
package/src/ham.js CHANGED
@@ -1,6 +1,10 @@
1
1
  import * as utils from "./utils.js"
2
2
  import SEA from "./sea.js"
3
3
 
4
+ const LISTENER_DELAY = 100
5
+ // Maximum number of souls to keep in memory
6
+ const MAX_GRAPH_SIZE = 10000
7
+
4
8
  // state and value are the incoming changes.
5
9
  // currentState and currentValue are the current graph data.
6
10
  const Ham = (state, currentState, value, currentValue) => {
@@ -26,6 +30,16 @@ const Ham = (state, currentState, value, currentValue) => {
26
30
  }
27
31
 
28
32
  Ham.mix = async (change, graph, secure, listen) => {
33
+ if (!change || typeof change !== "object") {
34
+ throw new TypeError("change must be an object")
35
+ }
36
+ if (!graph || typeof graph !== "object") {
37
+ throw new TypeError("graph must be an object")
38
+ }
39
+ if (!listen || typeof listen !== "object") {
40
+ throw new TypeError("listen must be an object")
41
+ }
42
+
29
43
  const machine = Date.now()
30
44
  const now = {}
31
45
  const defer = {}
@@ -103,9 +117,6 @@ Ham.mix = async (change, graph, secure, listen) => {
103
117
  if (!now[soul]) {
104
118
  now[soul] = {_: {"#": soul, ">": {}}}
105
119
  }
106
- // TODO: graph should not just grow indefintitely in memory.
107
- // Need to have a max size after which start dropping the oldest state
108
- // Do something similar to Dup which can handle deletes?
109
120
  if (!graph[soul]) {
110
121
  graph[soul] = {_: {"#": soul, ">": {}}}
111
122
  }
@@ -121,7 +132,7 @@ Ham.mix = async (change, graph, secure, listen) => {
121
132
  .filter(l => utils.match(l["."], key))
122
133
  .forEach(l => l.cb())
123
134
  }
124
- }, 100)
135
+ }, LISTENER_DELAY)
125
136
  }
126
137
  updated = true
127
138
  }
@@ -139,9 +150,24 @@ Ham.mix = async (change, graph, secure, listen) => {
139
150
  if (listen[soul]) {
140
151
  listen[soul].filter(l => utils.match(l["."])).forEach(l => l.cb())
141
152
  }
142
- }, 100)
153
+ }, LISTENER_DELAY)
143
154
  }
144
155
  }
156
+
157
+ const souls = Object.keys(graph)
158
+ if (souls.length > MAX_GRAPH_SIZE) {
159
+ // Sort by oldest state timestamp and remove oldest entries
160
+ const soulsByAge = souls
161
+ .map(soul => {
162
+ const states = graph[soul]._ && graph[soul]._[">"]
163
+ const maxState = states ? Math.max(...Object.values(states)) : 0
164
+ return {soul, maxState}
165
+ })
166
+ .sort((a, b) => a.maxState - b.maxState)
167
+ const remove = soulsByAge.slice(0, souls.length - maxSize)
168
+ remove.forEach(({soul}) => delete graph[soul])
169
+ }
170
+
145
171
  return {now: now, defer: defer, wait: wait}
146
172
  }
147
173
 
package/src/radisk.js CHANGED
@@ -197,11 +197,14 @@ const Radisk = opt => {
197
197
  ) {
198
198
  const end = k.indexOf(enq)
199
199
  write.limit = end === -1 ? k : k.substring(0, end)
200
- write.done = true
201
- write.sub = Radix()
202
- Radix.map(rad, write.slice)
203
- radisk.write(write.limit, write.sub, cb)
204
- return
200
+ // Cannot split if they key is the same as the current file name.
201
+ if (write.limit !== file) {
202
+ write.done = true
203
+ write.sub = Radix()
204
+ Radix.map(rad, write.slice)
205
+ radisk.write(write.limit, write.sub, cb)
206
+ return
207
+ }
205
208
  }
206
209
 
207
210
  write.text += enc