Skip to content

Commit

Permalink
Improve validation, handle empty options.phrases (#3)
Browse files Browse the repository at this point in the history
* fix: improve `options.charSets` validation, change behavior when `options.phrases` empty `[]`
* fix: add validation for `options.phrases`
  • Loading branch information
fityannugroho authored Nov 18, 2023
1 parent d2c3f55 commit 474de8f
Show file tree
Hide file tree
Showing 3 changed files with 110 additions and 15 deletions.
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,17 @@ The text to be obscured.
- Type: `string[]`
- Required: `false`

The specific phrases to be obscured. If not specified, the whole text will be obscured.
The specific phrases to be obscured. If not specified or empty, the entire text will be obscured.

Each phrase must be **less than or equal to 30 characters** and only contain the following characters:

- Alphabets (`a-z`, `A-Z`)
- Numbers (`0-9`)
- Spaces (` `)
- Hyphens (`-`)
- Underscores (`_`)
- Apostrophes (`'`)
- Forward slashes (`/`)

##### caseSensitive

Expand All @@ -92,7 +102,12 @@ Whether to obscure in a case-sensitive manner.

The character set that will be used for obfuscation. Put the **name of the** [**built-in character sets**](#character-sets) or a **custom character set objects**.

The valid custom character set object must be an object that **each key is a single character** and **each value is an array of single characters** that will be used to replace the key. See the example below.
The valid custom character set must be an object that contains key-value pairs where:

- The **key** is the character to be replaced. It must be a **single alphabet character** (`a-z`, `A-Z`).
- The **value** is an array of characters that will be used to replace the key. It must be an array of **any single characters** other than [control characters](https://unicodeplus.com/category/Cc).

See the example below.

```js
const customCharSet = {
Expand Down
79 changes: 70 additions & 9 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,35 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

/**
* The name of built-in charsets.
*/
export const CharSets = {
LATIN: 'latin',
LATIN_1: 'latin-1',
} as const;

export type CharSetNames = typeof CharSets[keyof typeof CharSets];
export type CharSet = Record<string, string[] | undefined>;

const dirname = path.dirname(fileURLToPath(import.meta.url));

export class ValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'ValidationError';
}
}

/**
* Get a built-in charset.
* @param name The name of the charset.
* @throws {ValidationError} If the given charset name is invalid.
*/
function getCharSet(name: CharSetNames = 'latin'): CharSet {
// Validating the name
if (!Object.values(CharSets).includes(name)) {
throw new Error(`Invalid charSet name: ${name}`);
throw new ValidationError(`Invalid charSet name: ${name}`);
}

const strJson = fs.readFileSync(
Expand All @@ -25,15 +41,43 @@ function getCharSet(name: CharSetNames = 'latin'): CharSet {
return JSON.parse(strJson) as CharSet;
}

export function isCharSetValid(charSet: CharSet): boolean {
/**
* Check if the given charset is valid.
* @param charSet The charset to check.
*/
export function isCharSetValid(charSet: object): boolean {
return typeof charSet === 'object'
&& Object.keys(charSet).every((key) => key.length === 1)
&& Object.keys(charSet).every((key) => (
key.length === 1
&& /^[a-zA-Z]$/.test(key)
))
&& Object.values(charSet).every((replacements) => (
Array.isArray(replacements)
&& replacements.every((char) => char.length === 1)
Array.isArray(replacements) && replacements.every((char) => (
typeof char === 'string'
&& char.length === 1
// eslint-disable-next-line no-control-regex
&& /[^\u0000-\u001f\u007f-\u009f]/.test(char)
))
));
}

/**
* Check if the given phrase is valid.
* @param phrase The phrase to check.
*/
export function isPhraseValid(phrase: string): boolean {
return typeof phrase === 'string'
&& /^[a-zA-Z0-9 \-_'/]+$/.test(phrase)
&& phrase.trim().length > 0 && phrase.length <= 30;
}

/**
* Merge multiple charsets.
* @param charSets The names of built-in charset or custom charsets to merge.
* @returns The merged charset.
* @throws {ValidationError} If the given built-in charset name is invalid
* or if the given custom charset is invalid.
*/
export function mergeCharSets(...charSets: (CharSetNames | CharSet)[]): CharSet {
const res: CharSet = {};

Expand All @@ -42,7 +86,7 @@ export function mergeCharSets(...charSets: (CharSetNames | CharSet)[]): CharSet

// Validate the charSet
if (!isCharSetValid(charSetObj)) {
throw new Error('Invalid charSet: each key and value must be a single character');
throw new ValidationError('Invalid charSet: each key and value must be a single character');
}

for (const [key, replacements] of Object.entries(charSetObj)) {
Expand All @@ -56,6 +100,13 @@ export function mergeCharSets(...charSets: (CharSetNames | CharSet)[]): CharSet
return res;
}

/**
* Get a random replacement for the given character.
* @param char The character to replace.
* @param charSet The charset to use.
* @param caseSensitive Whether to use case sensitive replacements.
* @returns The replacement character.
*/
function getChar(char: string, charSet: CharSet, caseSensitive?: boolean) {
const replacements = caseSensitive ? charSet[char] ?? []
: Array.from(new Set([
Expand All @@ -77,20 +128,30 @@ export type Options = {
charSets?: (CharSetNames | CharSet)[];
};

/**
* @param options The options.
* @throws {ValidationError} If the given built-in charset name,
* the given custom charset, or if the given phrases are invalid.
*/
export default function wisely(options: Options): string {
const charSet = mergeCharSets(...(options.charSets ?? ['latin']));

const censor = (phrase: string): string => phrase.split('')
.map((char) => getChar(char, charSet, options.caseSensitive))
.join('');

if (!options.phrases) {
if (!options.phrases?.length) {
return censor(options.text);
}

let res = options.text;
for (const t of options.phrases) {
const regex = new RegExp(`\\b${t}\\b`, options.caseSensitive ? 'g' : 'gi');
for (const phrase of options.phrases) {
// Validating the phrase
if (!isPhraseValid(phrase)) {
throw new ValidationError(`Invalid phrase: ${phrase}`);
}

const regex = new RegExp(`\\b${phrase.trim()}\\b`, options.caseSensitive ? 'g' : 'gi');

for (const m of options.text.matchAll(regex)) {
const [match] = m;
Expand Down
27 changes: 23 additions & 4 deletions test/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,16 @@ describe('mergeCharSets', () => {
});

test('invalid custom charSets', () => {
expect(() => mergeCharSets({ aa: ['b', 'c', 'd'] })).toThrow();
expect(() => mergeCharSets({ aa: ['b'] })).toThrow();
expect(() => mergeCharSets({ a: ['bc'] })).toThrow();
expect(() => mergeCharSets({ a: ['b', 'c', ''] })).toThrow();
expect(() => mergeCharSets({ a: ['b', 'c', 'd', ''] })).toThrow();
expect(() => mergeCharSets({ a: [''] })).toThrow();
expect(() => mergeCharSets({ a: ['b', ''] })).toThrow();
expect(() => mergeCharSets({ 1: ['a'] })).toThrow();
expect(() => mergeCharSets({ '!': ['a'] })).toThrow();
// Not contains control characters
expect(() => mergeCharSets({
a: ['\u0000', '\u0001', '\u001f', '\u007f', '\u0080', '\u009f'],
})).toThrow();
});
});

Expand Down Expand Up @@ -132,7 +138,20 @@ describe('wisely', () => {
});

test('empty phrases', () => {
expect(wisely({ text, phrases: [] })).toEqual(text);
expect(wisely({ text, phrases: [] })).not.toEqual(text);
});

test('with invalid phrases', () => {
expect(() => wisely({ text, phrases: [''] })).toThrow();
expect(() => wisely({ text, phrases: [' '] })).toThrow();
expect(() => wisely({ text, phrases: [' '.repeat(10)] })).toThrow();
expect(() => wisely({ text, phrases: ['\n'] })).toThrow();
expect(() => wisely({ text, phrases: ['a\n'] })).toThrow();
expect(() => wisely({ text, phrases: ['\t'] })).toThrow();
expect(() => wisely({ text, phrases: ['a\t'] })).toThrow();
expect(() => wisely({ text, phrases: ['a'.repeat(31)] })).toThrow();
expect(() => wisely({ text, phrases: ['th!s symbo|'] })).toThrow();
expect(() => wisely({ text, phrases: ['\\'] })).toThrow();
});

test.each<{ testText: string, charSets: Options['charSets'], contains: string, notContains: string }>([
Expand Down

0 comments on commit 474de8f

Please sign in to comment.