From 5d3afd30bde569aadf64c86f96e23dd327cc1556 Mon Sep 17 00:00:00 2001 From: "Max P." Date: Fri, 30 May 2025 12:42:00 +0200 Subject: [PATCH 1/5] feat(build): add run permissions to compiled binaries --- deno.jsonc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deno.jsonc b/deno.jsonc index 12c42a8..66e484b 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -5,8 +5,8 @@ "fmt": "deno fmt --check", "lint": "deno lint", "ci": "deno task fmt && deno task lint && deno task test && build:amd64", // For local CI checks - "build:amd64": "deno compile --target x86_64-unknown-linux-gnu --include VERSION --include src/i18n/de.jsonc --include src/i18n/en.jsonc --allow-env --allow-write --allow-read --output dist/systemd-timer-linux-amd64 src/mod.ts", - "build:arm64": "deno compile --target aarch64-unknown-linux-gnu --include VERSION --include src/i18n/de.jsonc --include src/i18n/en.jsonc --allow-env --allow-write --allow-read --output dist/systemd-timer-linux-arm64 src/mod.ts" + "build:amd64": "deno compile --target x86_64-unknown-linux-gnu --include VERSION --include src/i18n/de.jsonc --include src/i18n/en.jsonc --allow-env --allow-write --allow-read --allow-run --output dist/systemd-timer-linux-amd64 src/mod.ts", + "build:arm64": "deno compile --target aarch64-unknown-linux-gnu --include VERSION --include src/i18n/de.jsonc --include src/i18n/en.jsonc --allow-env --allow-write --allow-read --allow-run --output dist/systemd-timer-linux-arm64 src/mod.ts" }, "compilerOptions": {}, "fmt": { -- 2.49.1 From 5510ab74d6dc4cf803ec69c6c9b08c3fc5c1ec2e Mon Sep 17 00:00:00 2001 From: "Max P." Date: Fri, 30 May 2025 12:42:25 +0200 Subject: [PATCH 2/5] feat(validation): add CLI validation helpers for input checks - Introduce functions to validate CLI inputs like paths, identifiers, and environment variables - Add error messages for invalid inputs to support user feedback - Include unit tests to ensure correctness and robustness of validation logic --- src/i18n/de.jsonc | 8 +- src/i18n/en.jsonc | 8 +- .../__tests__/cliValidationHelper.test.ts | 145 +++++++++++++++ src/utils/cliValidationHelper.ts | 176 ++++++++++++++++++ src/utils/mod.ts | 8 + 5 files changed, 343 insertions(+), 2 deletions(-) create mode 100644 src/utils/__tests__/cliValidationHelper.test.ts create mode 100644 src/utils/cliValidationHelper.ts 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'; -- 2.49.1 From 63002a7f21cf4f2d760ba4acc14730c5a8ef6a93 Mon Sep 17 00:00:00 2001 From: "Max P." Date: Fri, 30 May 2025 12:42:37 +0200 Subject: [PATCH 3/5] feat(cli): add validation for command options --- src/cli/create.ts | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/cli/create.ts b/src/cli/create.ts index 1a20e5f..338e288 100644 --- a/src/cli/create.ts +++ b/src/cli/create.ts @@ -2,6 +2,14 @@ import { Command } from '@cliffy/command'; import { generateUnitFiles } from '../templates/unit-generator.ts'; import { TimerOptions } from '../types/options.ts'; import { t } from '../i18n/mod.ts'; +import { + collectAndValidateAfter, + collectAndValidateEnv, + validateIdentifier, + validateNotEmpty, + validatePath, + validateSystemdCalendar, +} from '../utils/mod.ts'; export function createCommand() { return new Command() @@ -9,43 +17,54 @@ export function createCommand() { .option( '--name ', t('option_name'), + { value: (v) => validateIdentifier(v, '--name') }, ) .option( '--exec ', t('option_exec'), - { required: true }, + { + required: true, + value: (v) => validateNotEmpty(v, '--exec'), + }, ) .option('--calendar ', t('option_calendar'), { required: true, + value: validateSystemdCalendar, }) .option('--description ', t('option_description')) .option('--user', t('option_user')) .option( '--run-as ', t('option_run_as'), + { value: (v) => validateNotEmpty(v, '--run-as') }, ) .option( '--home ', t('option_home'), + { value: (v) => validatePath(v, true) }, ) .option( '--cwd ', t('option_cwd'), + { value: (v) => validatePath(v, true) }, ) - .option('--output ', t('option_output')) + .option('--output ', t('option_output'), { + value: (v) => validatePath(v, false), + }) .option( '--after ', t('option_after'), - { collect: true }, + { collect: true, value: collectAndValidateAfter }, ) .option( '--environment ', t('option_environment'), - { collect: true }, + { collect: true, value: collectAndValidateEnv }, ) .option( '--logfile ', t('option_logfile'), + { value: (v) => validatePath(v, false) }, ) .option('--dry-run', t('option_dry_run')) .action(async (options: TimerOptions) => { -- 2.49.1 From 8c5dc166ef5cf6df21ccd3c959e798742c99e659 Mon Sep 17 00:00:00 2001 From: "Max P." Date: Fri, 30 May 2025 12:42:46 +0200 Subject: [PATCH 4/5] chore(lock): update dependencies to latest versions --- deno.lock | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/deno.lock b/deno.lock index b1161da..f7ad2ab 100644 --- a/deno.lock +++ b/deno.lock @@ -53,6 +53,10 @@ } }, "remote": { + "https://deno.land/std@0.192.0/fmt/colors.ts": "d67e3cd9f472535241a8e410d33423980bec45047e343577554d3356e1f0ef4e", + "https://deno.land/std@0.192.0/testing/_diff.ts": "1a3c044aedf77647d6cac86b798c6417603361b66b54c53331b312caeb447aea", + "https://deno.land/std@0.192.0/testing/_format.ts": "a69126e8a469009adf4cf2a50af889aca364c349797e63174884a52ff75cf4c7", + "https://deno.land/std@0.192.0/testing/asserts.ts": "e16d98b4d73ffc4ed498d717307a12500ae4f2cbe668f1a215632d19fcffc22f", "https://deno.land/std@0.224.0/assert/_constants.ts": "a271e8ef5a573f1df8e822a6eb9d09df064ad66a4390f21b3e31f820a38e0975", "https://deno.land/std@0.224.0/assert/assert.ts": "09d30564c09de846855b7b071e62b5974b001bb72a4b797958fe0660e7849834", "https://deno.land/std@0.224.0/assert/assert_almost_equals.ts": "9e416114322012c9a21fa68e187637ce2d7df25bcbdbfd957cd639e65d3cf293", -- 2.49.1 From dfc2e11ae900eeb67b244fb980fc088b92d2d960 Mon Sep 17 00:00:00 2001 From: ghost-bot Date: Fri, 30 May 2025 10:43:05 +0000 Subject: [PATCH 5/5] chore(changelog): update unreleased changelog --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24f1e16..17f4e27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,18 @@ All notable changes to this project will be documented in this file. ### 🚀 Features +- *(cli)* Add validation for command options - ([63002a7](https://git.0xmax42.io/maxp/systemd-timer/commit/63002a7f21cf4f2d760ba4acc14730c5a8ef6a93)) +- *(validation)* Add CLI validation helpers for input checks - ([5510ab7](https://git.0xmax42.io/maxp/systemd-timer/commit/5510ab74d6dc4cf803ec69c6c9b08c3fc5c1ec2e)) +- *(build)* Add run permissions to compiled binaries - ([5d3afd3](https://git.0xmax42.io/maxp/systemd-timer/commit/5d3afd30bde569aadf64c86f96e23dd327cc1556)) +- *(ci)* Add compile steps to CI workflow - ([531a02a](https://git.0xmax42.io/maxp/systemd-timer/commit/531a02a6e11a769f2e05888d49ea2b4808d974e3)) - *(vscode)* Add JSONC formatter configuration - ([c7af1fb](https://git.0xmax42.io/maxp/systemd-timer/commit/c7af1fb6caa46c22b84229745067d05bf60b6f64)) - *(i18n)* Support loading JSONC translation files - ([4ac5dd4](https://git.0xmax42.io/maxp/systemd-timer/commit/4ac5dd4c88324f99cb6827283ad85bb9718abbeb)) - *(config)* Add @std/jsonc dependency - ([8f1cb3f](https://git.0xmax42.io/maxp/systemd-timer/commit/8f1cb3fad71ead365d93087963ddb6c7202a9b4f)) +### 🐛 Bug Fixes + +- *(build)* Update included files to use .jsonc format - ([f3f2c61](https://git.0xmax42.io/maxp/systemd-timer/commit/f3f2c61da0785dce4c6b8c7d8ef0ae9abf098172)) + ### 🎨 Styling - *(i18n)* Add comments for clarity and rename files - ([5226269](https://git.0xmax42.io/maxp/systemd-timer/commit/5226269ec2a0b76dfa30ac8d614c3789ff3a837b)) @@ -19,6 +27,10 @@ All notable changes to this project will be documented in this file. - *(fs)* Update test descriptions and comments to English - ([c4f4614](https://git.0xmax42.io/maxp/systemd-timer/commit/c4f4614a2daee68f9b33b9676106214c65a1a427)) - *(fs)* Add rollback tests for writeUnitFiles errors - ([6039d23](https://git.0xmax42.io/maxp/systemd-timer/commit/6039d236eb7de449ce22b1d9ea718389a3e2261d)) +### ⚙️ Miscellaneous Tasks + +- *(lock)* Update dependencies to latest versions - ([8c5dc16](https://git.0xmax42.io/maxp/systemd-timer/commit/8c5dc166ef5cf6df21ccd3c959e798742c99e659)) + ## [0.4.1](https://git.0xmax42.io/maxp/systemd-timer/compare/v0.4.0..v0.4.1) - 2025-05-28 ### 🐛 Bug Fixes -- 2.49.1