inibase 1.6.5 → 1.7.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.
package/dist/cli.js CHANGED
@@ -180,7 +180,7 @@ rl.on("line", async (input) => {
180
180
  break;
181
181
  }
182
182
  if (!isSafeName(splitedInput[1]))
183
- console.log(`${textRed(" Err:")} Invalid table name, only alphanumeric characters, underscores and hyphens are allowed`);
183
+ console.log(`${textRed(" Err:")} Invalid table name`);
184
184
  else if (!(await isExists(join(path, splitedInput[1]))))
185
185
  console.log(`${textRed(" Err:")} Table doesn't exist`);
186
186
  else {
package/dist/file.js CHANGED
@@ -6,8 +6,8 @@ import { pipeline } from "node:stream/promises";
6
6
  import { createGunzip, createGzip } from "node:zlib";
7
7
  import Inison from "inison";
8
8
  import { globalConfig, } from "./index.js";
9
- import { detectFieldType, isArrayOfObjects, isNumber, isObject, isStringified, } from "./utils.js";
10
- import { compare, encodeID, exec, gunzip, gzip } from "./utils.server.js";
9
+ import { detectFieldType, isArrayOfObjects, isNumber, isObject, isStringified, isValidID, } from "./utils.js";
10
+ import { compare, decodeID, encodeID, exec, gunzip, gzip, } from "./utils.server.js";
11
11
  // Locks older than this are assumed abandoned by a crashed/killed process, not a slow operation.
12
12
  const STALE_LOCK_MS = 30_000;
13
13
  export const lock = async (folderPath, prefix) => {
@@ -506,6 +506,205 @@ export const remove = async (filePath, linesToDelete) => {
506
506
  return [fileTempPath, null];
507
507
  }
508
508
  };
509
+ /**
510
+ * Field types whose on-disk bytes equal the encoded query value (identity or
511
+ * canonical numeric/id transforms), making a native whole-line equality search safe.
512
+ */
513
+ const EQUALS_FAST_TYPES = new Set([
514
+ "string",
515
+ "text",
516
+ "textarea",
517
+ "html",
518
+ "url",
519
+ "email",
520
+ "number",
521
+ "date",
522
+ "timestamp",
523
+ "time",
524
+ "id",
525
+ "table",
526
+ ]);
527
+ /**
528
+ * True when the query value is, or looks like, a number. Such values alias across
529
+ * several raw byte forms under `decode` (e.g. "123", "0123", "1e3"), so the exact
530
+ * native match would silently drop legitimate lines -> fall back to the JS reader.
531
+ */
532
+ const looksNumeric = (value) => typeof value === "number" ||
533
+ (typeof value === "string" && value !== "" && isNumber(value));
534
+ const shellQuote = (str) => `'${String(str).replace(/'/g, `'\\''`)}'`;
535
+ /**
536
+ * Computes the exact on-disk byte strings a native `grep -x -F` must match for a
537
+ * `=` (equality) search so that every matched line decodes to one of the compared
538
+ * values. Returns the pattern array, or null when the search cannot be handled by
539
+ * the fast path (caller then falls back to the JS readline scan).
540
+ */
541
+ function buildEqualsPatterns(comparedAtValue, field) {
542
+ const values = Array.isArray(comparedAtValue)
543
+ ? comparedAtValue
544
+ : [comparedAtValue];
545
+ if (!values.length)
546
+ return null;
547
+ const patterns = [];
548
+ for (const value of values) {
549
+ if (value === null || value === undefined || value === "")
550
+ return null; // null-like values -> readline path
551
+ const type = field.type;
552
+ if (type === "id" || type === "table") {
553
+ let forms;
554
+ if (isValidID(value))
555
+ forms = [value, String(decodeID(value) ?? value)];
556
+ else if (typeof value === "number" ||
557
+ (typeof value === "string" && isNumber(value)))
558
+ forms = [String(Number(value))];
559
+ else
560
+ forms = [value];
561
+ for (const form of [...new Set(forms)]) {
562
+ let valid = false;
563
+ try {
564
+ valid = compare("=", decode(form, field), value, type);
565
+ }
566
+ catch {
567
+ valid = false;
568
+ }
569
+ if (!valid)
570
+ return null;
571
+ patterns.push(form);
572
+ }
573
+ }
574
+ else if (type === "number" ||
575
+ type === "date" ||
576
+ type === "timestamp" ||
577
+ type === "time") {
578
+ const form = String(Number(value));
579
+ let valid = false;
580
+ try {
581
+ valid = compare("=", decode(form, field), value, type);
582
+ }
583
+ catch {
584
+ valid = false;
585
+ }
586
+ if (!valid)
587
+ return null;
588
+ patterns.push(form);
589
+ }
590
+ else {
591
+ // scalar string-like field types
592
+ if (looksNumeric(value))
593
+ return null; // numeric aliasing -> readline path
594
+ const raw = String(value);
595
+ if (raw === "null" ||
596
+ raw === "undefined" ||
597
+ raw.startsWith("{") ||
598
+ raw.startsWith("["))
599
+ return null;
600
+ let encoded;
601
+ try {
602
+ encoded = encode(value);
603
+ }
604
+ catch {
605
+ return null;
606
+ }
607
+ const form = String(encoded);
608
+ let valid = false;
609
+ try {
610
+ valid = compare("=", decode(form, field), value, type);
611
+ }
612
+ catch {
613
+ valid = false;
614
+ }
615
+ if (!valid)
616
+ return null;
617
+ patterns.push(form);
618
+ }
619
+ }
620
+ return [...new Set(patterns)];
621
+ }
622
+ /**
623
+ * Native whole-line equality search (`grep -a -n -x -F`). Returns the same tuple
624
+ * shape as `search`, or null when the fast path could not be used/verified safely
625
+ * (in which case the caller should fall back to the JS readline scan).
626
+ */
627
+ async function searchEqualsNative(filePath, patterns, field, searchIn, comparedAtValue, limit, offset, readWholeFile) {
628
+ const totalPatternSize = patterns.reduce((acc, pattern) => acc + String(pattern).length, 0) +
629
+ patterns.length * 8;
630
+ if (totalPatternSize > 32_768 || patterns.length > 1024)
631
+ return null;
632
+ if (searchIn?.size) {
633
+ for (const lineNumber of searchIn) {
634
+ if (lineNumber < 0)
635
+ return null; // exclusion ranges -> readline path
636
+ }
637
+ }
638
+ const source = filePath.endsWith(".gz")
639
+ ? `gunzip -c ${escapeShellPath(filePath)}`
640
+ : `cat ${escapeShellPath(filePath)}`;
641
+ // Column files whose lines start with "[" or "{" are array/object-encoded
642
+ // (decode() eagerly unstringifies them), so a native whole-line grep could
643
+ // silently miss matches that live inside those containers. Detect such lines
644
+ // up front; when any are present, fall back to the JS reader.
645
+ let probe;
646
+ try {
647
+ ({ stdout: probe } = await exec(`LC_ALL=C ${source} | LC_ALL=C grep -a -m 1 -E '^[[{]'`, { maxBuffer: 1024 * 1024 * 4 }));
648
+ }
649
+ catch (err) {
650
+ if (Number(err?.code) !== 1)
651
+ return null; // probe failure -> readline fallback
652
+ }
653
+ if (probe)
654
+ return null;
655
+ const command = `LC_ALL=C ${source} | LC_ALL=C grep -a -n -x -F ${patterns
656
+ .map((pattern) => `-e ${shellQuote(pattern)}`)
657
+ .join(" ")}`;
658
+ let stdout;
659
+ try {
660
+ ({ stdout } = await exec(command, { maxBuffer: 1024 * 1024 * 256 }));
661
+ }
662
+ catch (err) {
663
+ if (Number(err?.code) === 1)
664
+ return [null, 0, null]; // grep: no matches
665
+ return null; // native failure -> readline fallback
666
+ }
667
+ const rawLines = stdout ? stdout.trimEnd().split("\n") : [];
668
+ let matched = [];
669
+ for (const line of rawLines) {
670
+ const colonIndex = line.indexOf(":");
671
+ if (colonIndex === -1)
672
+ continue;
673
+ const lineNumber = Number(line.slice(0, colonIndex));
674
+ if (!Number.isInteger(lineNumber) || lineNumber < 1)
675
+ continue;
676
+ matched.push([lineNumber, line.slice(colonIndex + 1)]);
677
+ }
678
+ if (searchIn?.size)
679
+ matched = matched.filter(([lineNumber]) => searchIn.has(lineNumber));
680
+ const linesNumbers = new Set();
681
+ const matchingLines = {};
682
+ let processed = 0;
683
+ let finalTotal = null;
684
+ for (const [lineNumber, raw] of matched) {
685
+ processed++;
686
+ linesNumbers.add(lineNumber);
687
+ if (offset && processed < offset)
688
+ continue;
689
+ if (limit && processed > limit + (offset ? offset - 1 : 0)) {
690
+ if (readWholeFile)
691
+ continue;
692
+ finalTotal = processed;
693
+ break;
694
+ }
695
+ const decodedLine = decode(raw, field);
696
+ // Defensive verification: the raw line must decode to one of the compared
697
+ // values (guaranteed by construction, kept as a safety net).
698
+ const verifies = Array.isArray(comparedAtValue)
699
+ ? comparedAtValue.some((value) => compare("=", decodedLine, value, field.type))
700
+ : compare("=", decodedLine, comparedAtValue, field.type);
701
+ if (!verifies)
702
+ return null;
703
+ matchingLines[lineNumber] = decodedLine;
704
+ }
705
+ const total = finalTotal ?? processed;
706
+ return total ? [matchingLines, total, linesNumbers] : [null, 0, null];
707
+ }
509
708
  /**
510
709
  * Asynchronously searches a file for lines matching specified criteria, using comparison and logical operators.
511
710
  *
@@ -524,6 +723,22 @@ export const remove = async (filePath, linesToDelete) => {
524
723
  * Note: Decodes each line for comparison and can handle complex queries with multiple conditions.
525
724
  */
526
725
  export const search = async (filePath, operator, comparedAtValue, logicalOperator, searchIn, field, limit, offset, readWholeFile) => {
726
+ // Native fast path for exact-equality searches (whole-line `grep -x -F`).
727
+ const fieldType = field?.type;
728
+ if (operator === "=" &&
729
+ !Array.isArray(operator) &&
730
+ !logicalOperator &&
731
+ comparedAtValue !== null &&
732
+ comparedAtValue !== undefined &&
733
+ typeof fieldType === "string" &&
734
+ EQUALS_FAST_TYPES.has(fieldType)) {
735
+ const patterns = buildEqualsPatterns(comparedAtValue, field);
736
+ if (patterns) {
737
+ const fastResult = await searchEqualsNative(filePath, patterns, field, searchIn, comparedAtValue, limit, offset, readWholeFile);
738
+ if (fastResult)
739
+ return fastResult;
740
+ }
741
+ }
527
742
  // Initialize a Map to store the matching lines with their line numbers.
528
743
  const matchingLines = {};
529
744
  // Initialize counters for line number, found items, and processed items.
package/dist/utils.d.ts CHANGED
@@ -236,13 +236,17 @@ export declare const createError: (language: ErrorLang, name: ErrorCode, variabl
236
236
  /**
237
237
  * Validates that a string is a safe name for a table, database or column.
238
238
  *
239
- * Names must start with an alphanumeric character (Latin or Arabic), followed
240
- * by alphanumerics (Latin or Arabic), underscores, hyphens or spaces, and be
241
- * at most 255 characters long. Leading or trailing spaces are not allowed.
239
+ * Instead of whitelisting allowed characters, this uses a blacklist that blocks
240
+ * only characters known to cause problems with shell commands, filesystem
241
+ * paths, or injection attacks. All Unicode letters (Latin, Arabic, Chinese,
242
+ * etc.), digits, underscores, hyphens, spaces and forward slashes are allowed.
242
243
  *
243
- * This prevents path traversal (`../`, `..\\`), null-byte injection (`\0`),
244
- * shell injection (`;`, `|`, backticks, `$()`, tabs, newlines, slashes) and
245
- * other reserved-name issues.
244
+ * Blocked characters: null bytes, control characters, `\`, `;`, `|`, `&`,
245
+ * `<`, `>`, `$`, backtick, `!`, `'`, `"`, `{`, `}`, `(`, `)`, `~`, and `.`
246
+ * (reserved as column-path separator). Forward slash (`/`) is intentionally
247
+ * allowed so that nested table names like `"user/logs"` work.
248
+ *
249
+ * Leading or trailing spaces are not allowed.
246
250
  *
247
251
  * @param input - The value to be checked.
248
252
  * @returns boolean - True if the name is safe to use, false otherwise.
package/dist/utils.js CHANGED
@@ -726,13 +726,17 @@ export const createError = (language, name, variable) => {
726
726
  /**
727
727
  * Validates that a string is a safe name for a table, database or column.
728
728
  *
729
- * Names must start with an alphanumeric character (Latin or Arabic), followed
730
- * by alphanumerics (Latin or Arabic), underscores, hyphens or spaces, and be
731
- * at most 255 characters long. Leading or trailing spaces are not allowed.
729
+ * Instead of whitelisting allowed characters, this uses a blacklist that blocks
730
+ * only characters known to cause problems with shell commands, filesystem
731
+ * paths, or injection attacks. All Unicode letters (Latin, Arabic, Chinese,
732
+ * etc.), digits, underscores, hyphens, spaces and forward slashes are allowed.
732
733
  *
733
- * This prevents path traversal (`../`, `..\\`), null-byte injection (`\0`),
734
- * shell injection (`;`, `|`, backticks, `$()`, tabs, newlines, slashes) and
735
- * other reserved-name issues.
734
+ * Blocked characters: null bytes, control characters, `\`, `;`, `|`, `&`,
735
+ * `<`, `>`, `$`, backtick, `!`, `'`, `"`, `{`, `}`, `(`, `)`, `~`, and `.`
736
+ * (reserved as column-path separator). Forward slash (`/`) is intentionally
737
+ * allowed so that nested table names like `"user/logs"` work.
738
+ *
739
+ * Leading or trailing spaces are not allowed.
736
740
  *
737
741
  * @param input - The value to be checked.
738
742
  * @returns boolean - True if the name is safe to use, false otherwise.
@@ -741,12 +745,14 @@ export const isValidName = (input) => typeof input === "string" &&
741
745
  input.length > 0 &&
742
746
  input.length <= 255 &&
743
747
  validNamePattern.test(input);
744
- // Word characters: Latin alphanumerics, underscores, hyphens and Arabic blocks.
745
- const nameWordCharClass = "a-zA-Z0-9_\\u0600-\\u06FF\\u0750-\\u077F\\u08A0-\\u08FF-";
746
- // Leading characters: same as word chars minus underscore and hyphen.
747
- const nameFirstCharClass = "a-zA-Z0-9\\u0600-\\u06FF\\u0750-\\u077F\\u08A0-\\u08FF";
748
- // eslint-disable-next-line no-misleading-character-class
749
- const validNamePattern = new RegExp(`^[${nameFirstCharClass}](?:[${nameWordCharClass} ]*[${nameWordCharClass}])?$`);
748
+ // Forbidden characters: control chars, path separators, shell metacharacters,
749
+ // quoting, grouping, history expansion, home-dir expansion and dot (reserved
750
+ // as column-path separator). The forward slash (/) is intentionally allowed
751
+ // so that nested table names like "user/logs" work.
752
+ const nameForbiddenChars = "\\x00-\\x1F\\x7F.\\\\;|&<>$`!'\\" + "{}()~";
753
+ // Names must be 1-255 chars, start/end with a non-forbidden non-whitespace
754
+ // character, and contain no forbidden characters in between.
755
+ const validNamePattern = new RegExp(`^[^${nameForbiddenChars}\\s\\p{Z}\\p{Cf}][^${nameForbiddenChars}\\p{Cf}]*[^${nameForbiddenChars}\\s\\p{Z}\\p{Cf}]$|^[^${nameForbiddenChars}\\s\\p{Z}\\p{Cf}]$`, "u");
750
756
  /**
751
757
  * Validates that a string is a safe name for a table, database or column and
752
758
  * throws a translated `INVALID_NAME` error if it is not.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "inibase",
3
- "version": "1.6.5",
3
+ "version": "1.7.0",
4
4
  "type": "module",
5
5
  "author": {
6
6
  "name": "Karim Amahtil",