diff --git a/src/i18n/de.jsonc b/src/i18n/de.jsonc index 12e6590..e6526ef 100644 --- a/src/i18n/de.jsonc +++ b/src/i18n/de.jsonc @@ -22,5 +22,11 @@ "hint_header": "\nℹ️ Hinweis:", // Error messages "error_write_units": "Fehler beim Schreiben der Units:", - "rollback_failed": "Rollback fehlgeschlagen:" + "rollback_failed": "Rollback fehlgeschlagen:", + "error_invalid_env": "Ungültiges Environment-Format. Verwende KEY=VALUE.", + "error_path_schold_not_be_empty": "Pfad darf nicht leer sein.", + "error_path_not_found": "Pfad nicht gefunden: {path}", + "error_value_should_not_be_empty": "Wert ({label}) sollte nicht leer sein.", + "error_invalid_identifier": "Ungültiger Bezeichner: '{value}' für {identifier}. Erlaubt sind nur alphanumerische Zeichen, Unterstriche und Bindestriche.", + "error_invalid_calendar": "Ungültiger OnCalendar-Ausdruck: {value}. Bitte überprüfe die Syntax." } diff --git a/src/i18n/en.jsonc b/src/i18n/en.jsonc index 16cca89..0bfd1e2 100644 --- a/src/i18n/en.jsonc +++ b/src/i18n/en.jsonc @@ -22,5 +22,11 @@ "hint_header": "\nℹ️ Note:", // Error messages "error_write_units": "Error while writing unit files:", - "rollback_failed": "Rollback failed:" + "rollback_failed": "Rollback failed:", + "error_invalid_env": "Invalid environment format. Use KEY=VALUE.", + "error_path_schold_not_be_empty": "Path should not be empty.", + "error_path_not_found": "Path not found: {path}", + "error_value_should_not_be_empty": "Value ({label}) should not be empty.", + "error_invalid_identifier": "Invalid identifier: '{value}' for {identifier}. Only alphanumeric characters, underscores, and hyphens are allowed.", + "error_invalid_calendar": "Invalid OnCalendar expression: {value}. Please check the syntax." } diff --git a/src/utils/__tests__/cliValidationHelper.test.ts b/src/utils/__tests__/cliValidationHelper.test.ts new file mode 100644 index 0000000..0b76f23 --- /dev/null +++ b/src/utils/__tests__/cliValidationHelper.test.ts @@ -0,0 +1,145 @@ +import { resolve } from 'https://deno.land/std@0.224.0/path/mod.ts'; +import { ValidationError } from '@cliffy/command'; +import { + collectAndValidateAfter, + collectAndValidateEnv, + validateIdentifier, + validateNotEmpty, + validatePath, + validateSystemdCalendar, +} from '../mod.ts'; +import { t } from '../../i18n/mod.ts'; +import { + assertEquals, + assertThrows, +} from 'https://deno.land/std@0.224.0/assert/mod.ts'; + +Deno.test( + 'collectAndValidateEnv: throws ValidationError for invalid env format', + () => { + const invalidEnv = 'INVALID_ENV'; + assertThrows( + () => collectAndValidateEnv(invalidEnv), + ValidationError, + t('error_invalid_env', { value: invalidEnv }), + ); + }, +); + +Deno.test( + 'collectAndValidateEnv: returns collected array for valid env format', + () => { + const validEnv = 'KEY=value'; + const previous = ['EXISTING=env']; + const result = collectAndValidateEnv(validEnv, previous); + assertEquals(result, ['EXISTING=env', 'KEY=value']); + }, +); + +Deno.test('collectAndValidateEnv: aggregates multiple calls', () => { + const first = collectAndValidateEnv('FOO=bar'); + const second = collectAndValidateEnv('BAZ=qux', first); + assertEquals(second, ['FOO=bar', 'BAZ=qux']); +}); + +Deno.test( + 'validatePath: returns absolute path for valid path without existence check', + () => { + const tmpDir = Deno.makeTempDirSync(); + const relativePath = `${tmpDir}/testfile.txt`; + const result = validatePath(relativePath, false); + assertEquals(result, resolve(relativePath)); + }, +); + +Deno.test( + 'validatePath: throws ValidationError for non‑existent path with existence check', + () => { + const tmpDir = Deno.makeTempDirSync(); + const nonExistentPath = `${tmpDir}/nonexistent.txt`; + assertThrows( + () => validatePath(nonExistentPath, true), + ValidationError, + t('error_path_not_found', { path: resolve(nonExistentPath) }), + ); + }, +); + +Deno.test( + 'validatePath: returns absolute path for existing path with existence check', + () => { + const tmpDir = Deno.makeTempDirSync(); + const existingPath = `${tmpDir}/existing.txt`; + Deno.writeTextFileSync(existingPath, 'test content'); + const result = validatePath(existingPath, true); + assertEquals(result, resolve(existingPath)); + }, +); + +Deno.test('validatePath: throws ValidationError for empty path', () => { + const invalidPath = ''; + assertThrows( + () => validatePath(invalidPath, true), + ValidationError, + t('error_path_schold_not_be_empty'), + ); +}); + +Deno.test('validateNotEmpty: returns value for non‑empty string', () => { + const input = 'some-value'; + const result = validateNotEmpty(input, '--exec'); + assertEquals(result, input); +}); + +Deno.test('validateNotEmpty: throws ValidationError for empty string', () => { + const input = ''; + assertThrows( + () => validateNotEmpty(input, '--exec'), + ValidationError, + t('error_value_should_not_be_empty', { label: '--exec' }), + ); +}); + +Deno.test('collectAndValidateAfter: returns aggregated array', () => { + const first = collectAndValidateAfter('network.target'); + const second = collectAndValidateAfter('postgres.service', first); + assertEquals(second, ['network.target', 'postgres.service']); +}); + +Deno.test('collectAndValidateAfter: throws ValidationError for empty value', () => { + assertThrows( + () => collectAndValidateAfter(''), + ValidationError, + t('error_value_should_not_be_empty', { label: '--after' }), + ); +}); + +Deno.test('validateIdentifier: returns value for valid identifier', () => { + const id = 'backup_job-1'; + const result = validateIdentifier(id, '--name'); + assertEquals(result, id); +}); + +Deno.test('validateIdentifier: throws ValidationError for invalid identifier', () => { + const id = 'invalid$'; + assertThrows( + () => validateIdentifier(id, '--name'), + ValidationError, + t('error_invalid_identifier', { label: '--name', value: id }), + ); +}); + +Deno.test('validateSystemdCalendar: accepts valid expression', async () => { + const valid = 'Mon..Fri 12:00'; + const result = await validateSystemdCalendar(valid); + assertEquals(result, valid); +}); + +Deno.test('validateSystemdCalendar: rejects invalid expression', async () => { + const invalid = 'Mo..Fr 12:00'; + await assertThrows( + () => validateSystemdCalendar(invalid), + ValidationError, + t('error_invalid_calendar', { value: invalid }), + ); +}); diff --git a/src/utils/cliValidationHelper.ts b/src/utils/cliValidationHelper.ts new file mode 100644 index 0000000..f32ad24 --- /dev/null +++ b/src/utils/cliValidationHelper.ts @@ -0,0 +1,176 @@ +import { ValidationError } from '@cliffy/command'; +import { t } from '../i18n/mod.ts'; +import { existsSync } from 'https://deno.land/std@0.224.0/fs/mod.ts'; +import { resolve } from 'https://deno.land/std@0.224.0/path/mod.ts'; + +/** + * Collects repeated occurrences of the `--environment` CLI option and validates + * that every entry adheres to `KEY=value` syntax. + * + * Cliffy calls this handler for *each* `--environment` argument. The previously + * accumulated values are passed in via the `previous` parameter, allowing us to + * build up the final array manually. + * + * @example + * ```ts + * // CLI invocation: + * // mycli --environment FOO=bar --environment BAZ=qux + * const env = collectAndValidateEnv("FOO=bar"); + * const env2 = collectAndValidateEnv("BAZ=qux", env); + * console.log(env2); // => ["FOO=bar", "BAZ=qux"] + * ``` + * + * @param value - Current `KEY=value` string supplied by the user. + * @param previous - Array of values collected so far (defaults to an empty + * array on the first call). + * @returns The updated array containing all validated `KEY=value` pairs. + * + * @throws {ValidationError} If the input does not match the required syntax + * The resulting error is caught by Cliffy, which + * will print a help message and terminate with a non-zero exit code. + */ +export function collectAndValidateEnv( + value: string, + previous: string[] = [], +): string[] { + if (!/^\w+=.+/.test(value)) { + throw new ValidationError(t('error_invalid_env', { value })); + } + previous.push(value); + return previous; +} + +/** + * Normalises a given path to its absolute representation and optionally checks + * whether it exists on the filesystem. + * + * @remarks + * Because `--home`, `--cwd`, `--output`, and similar options may refer to files + * or directories that *must* already exist, this helper performs the common + * validation in a single place. + * + * @param value - Path provided by the user (absolute or relative). + * @param mustExist - When `true`, the function asserts that the resolved path + * exists; otherwise, existence is not verified. + * @returns The absolute path derived via {@linkcode resolve}. + * + * @throws {ValidationError} + * - If `value` is empty or not a string. + * - If `mustExist` is `true` **and** the resolved path cannot be found. + */ +export function validatePath(value: string, mustExist: boolean): string { + if (!value || typeof value !== 'string') { + throw new ValidationError(t('error_path_schold_not_be_empty')); + } + const abs = resolve(value); + if (mustExist && !existsSync(abs)) { + throw new ValidationError(t('error_path_not_found', { path: abs })); + } + return abs; +} + +/** + * Ensures that a mandatory CLI argument is not empty. + * + * @param value - Raw string supplied by the user. + * @param label - Human-readable label identifying the option (used in error + * messages). Example: `"--exec"`. + * @returns The original `value` if the validation passes. + * + * @throws {ValidationError} When `value` is `""`, `null`, `undefined`, or a + * non-string. + */ +export function validateNotEmpty(value: string, label: string): string { + if (!value || typeof value !== 'string') { + throw new ValidationError( + t('error_value_should_not_be_empty', { label }), + ); + } + return value; +} + +/** + * Collects repeated occurrences of the `--after` CLI option, validating each + * target unit name and returning the aggregated list. + * + * The validation performed here is intentionally minimal – it merely checks + * that the argument is a non-empty string. Detailed identifier rules (ASCII + * characters, digits, etc.) are enforced elsewhere by + * {@link validateIdentifier} when appropriate. + * + * @param value - Unit name provided with the current `--after` occurrence. + * @param previous - Accumulated array of unit names (defaults to an empty + * array). + * @returns An array containing all validated `--after` values. + * + * @throws {ValidationError} If `value` is empty. + */ +export function collectAndValidateAfter( + value: string, + previous: string[] = [], +): string[] { + if (!value || typeof value !== 'string') { + throw new ValidationError( + t('error_value_should_not_be_empty', { label: '--after' }), + ); + } + previous.push(value); + return previous; +} + +/** + * Validates identifiers used in generated systemd unit file names (e.g. + * service or timer names) and option flags like `--run-as`. + * + * @example + * ```ts + * validateIdentifier("backup_job", "--name"); // OK + * validateIdentifier("foo.bar-baz", "--after"); // OK + * validateIdentifier("oops$", "--name"); // throws ValidationError + * ``` + * + * @param value - The identifier string to validate. + * @param label - The relevant option flag, forwarded to the error message so + * the user immediately sees which argument is wrong. + * @returns The unchanged `value` when it satisfies the pattern. + * + * @throws {ValidationError} If `value` contains characters outside the allowed + * set of ASCII letters, digits, `.`, `_`, and `-`. + */ +export function validateIdentifier(value: string, label: string): string { + if (!/^[A-Za-z0-9_.-]+$/.test(value)) { + throw new ValidationError( + t('error_invalid_identifier', { identifier: label, value }), + ); + } + return value; +} + +/** + * Validates a systemd OnCalendar expression by invoking `systemd-analyze calendar` + * as a subprocess using a synchronous call. This avoids async overhead. + * + * @remarks + * Requires a Linux environment with systemd available. + * + * @param value - Calendar expression to validate + * @returns The original `value` if valid + * @throws {ValidationError} If the expression is invalid or systemd rejects it + */ +export function validateSystemdCalendar(value: string): string { + const command = new Deno.Command('systemd-analyze', { + args: ['calendar', value], + stdout: 'null', + stderr: 'null', + }); + + const { success } = command.outputSync(); + + if (!success) { + throw new ValidationError( + t('error_invalid_calendar', { value }), + ); + } + + return value; +} diff --git a/src/utils/mod.ts b/src/utils/mod.ts index c68b31f..3ab2277 100644 --- a/src/utils/mod.ts +++ b/src/utils/mod.ts @@ -1,3 +1,11 @@ +export { + collectAndValidateAfter, + collectAndValidateEnv, + validateIdentifier, + validateNotEmpty, + validatePath, + validateSystemdCalendar, +} from './cliValidationHelper.ts'; export { resolveUnitTargetPath, writeUnitFiles } from './fs.ts'; export { deriveNameFromExec } from './misc.ts'; export { getVersion } from './version.ts';