All files / src/utils/intelligence internals.ts

100% Statements 37/37
100% Branches 18/18
100% Functions 8/8
100% Lines 29/29

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150                                        9x                     363x                     442x                                   13x 19x   16x 16x 17x 15x 14x   1x                                     274x 318x   308x 318x 304x                                 36x   36x 36x 36x 36x 36x 36x 5x 5x 24x   5x                   28x                             81x    
// SPDX-FileCopyrightText: 2024-2026 Hack23 AB
// SPDX-License-Identifier: Apache-2.0
 
/**
 * @module Utils/Intelligence/Internals
 * @description Private helpers shared across the intelligence-index
 * submodules: prototype-pollution-safe lookup maps, slug normalisation,
 * and attribute/text escapers used by the related-articles renderer.
 *
 * Not intended for use outside `src/utils/intelligence/`.
 */
 
/**
 * Keys that must never be used as lookup-map indices to prevent
 * prototype-pollution attacks from untrusted article metadata.
 *
 * - `__proto__` — directly sets the prototype chain of plain objects
 * - `constructor` — can be used to reach `Object` and mutate shared prototypes
 * - `prototype` — when combined with `constructor`, enables prototype injection
 */
const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
 
/**
 * Return `true` when `key` is safe to use as a plain-object property.
 * Rejects `__proto__`, `constructor`, and `prototype` to avoid prototype
 * pollution when indexing untrusted topic/actor/procedure strings.
 *
 * @param key - The key to validate
 * @returns `true` if the key is safe to use as an object property
 */
export function isSafeKey(key: string): boolean {
  return !DANGEROUS_KEYS.has(key);
}
 
/**
 * Create a null-prototype object suitable for use as a lookup map.
 * Unlike `{}`, these objects have no inherited keys, providing an extra
 * layer of defence against prototype-pollution.
 *
 * @returns A fresh null-prototype Record for use as a lookup map
 */
export function createNullMap(): Record<string, string[]> {
  return Object.create(null) as Record<string, string[]>;
}
 
/**
 * Remove an article ID from every key's list in a lookup map.
 * Cleans up empty arrays left behind.
 * Skips dangerous keys (`__proto__`, `constructor`, `prototype`) to prevent
 * prototype pollution.
 *
 * @param map - Lookup map (actor/domain/procedure → article IDs)
 * @param keys - Keys to remove the article ID from
 * @param articleId - Article ID to remove
 */
export function removeIdFromMap(
  map: Record<string, string[]>,
  keys: readonly string[],
  articleId: string
): void {
  for (const key of keys) {
    if (!isSafeKey(key)) continue;
 
    const list = map[key];
    if (!list) continue;
    const filtered = list.filter((id) => id !== articleId);
    if (filtered.length === 0) {
      delete map[key];
    } else {
      map[key] = filtered;
    }
  }
}
 
/**
 * Add an article ID to every key's list in a lookup map (deduplicating).
 * Skips dangerous keys (`__proto__`, `constructor`, `prototype`) to prevent
 * prototype pollution.
 *
 * @param map - Lookup map (actor/domain/procedure → article IDs)
 * @param keys - Keys under which to register the article ID
 * @param articleId - Article ID to add
 */
export function addIdToMap(
  map: Record<string, string[]>,
  keys: readonly string[],
  articleId: string
): void {
  for (const key of keys) {
    if (!isSafeKey(key)) continue;
 
    const existing = map[key] ?? [];
    if (!existing.includes(articleId)) {
      map[key] = [...existing, articleId];
    }
  }
}
 
/**
 * Convert a string to a URL-safe slug.
 *
 * Uses Unicode-aware character classes so non-Latin scripts (e.g. Arabic,
 * Hebrew, CJK) produce meaningful slugs instead of collapsing to `""`.
 * When the result would still be empty (e.g. purely punctuation input) a
 * short deterministic hash is returned as a fallback.
 *
 * @param text - Input string
 * @returns Slugified string (never empty)
 */
export function slugify(text: string): string {
  const replaced = text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-');
  // Trim leading/trailing dashes without regex to avoid polynomial backtracking
  let start = 0;
  while (start < replaced.length && replaced[start] === '-') start++;
  let end = replaced.length;
  while (end > start && replaced[end - 1] === '-') end--;
  const slug = replaced.slice(start, end);
  if (slug.length > 0) return slug;
  let hash = 5381;
  for (let i = 0; i < text.length; i++) {
    hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0;
  }
  return `h${Math.abs(hash).toString(36)}`;
}
 
/**
 * Escape HTML attribute special characters.
 *
 * @param text - Raw text
 * @returns Escaped text safe for HTML attributes
 */
export function escapeAttr(text: string): string {
  return text
    .replace(/&/g, '&amp;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;');
}
 
/**
 * Escape HTML text content special characters.
 *
 * @param text - Raw text
 * @returns Escaped text safe for HTML text nodes
 */
export function escapeText(text: string): string {
  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}