diff --git a/.fusa-reqs.json b/.fusa-reqs.json index c3823cc..d733e64 100644 --- a/.fusa-reqs.json +++ b/.fusa-reqs.json @@ -265,6 +265,9 @@ {"id":"REQ-FIX005","title":"cfusa fix provides remediation guidance for CFUSA-CY006 (free-without-NULL, CWE-416/CERT-C MEM30-C): set the pointer to NULL immediately after free()","standard":"c-FuSa CLI","level":"ASIL-A"}, {"id":"REQ-FIX006","title":"cfusa fix --dry-run/--apply auto-fixes CFUSA-CY006 by inserting ' = NULL;' after a bare free(); call, skipping insertion when already NULL'd on the next line, and never writing files under --dry-run","standard":"c-FuSa CLI","level":"ASIL-A"}, {"id":"REQ-FIX007","title":"cfusa fix provides remediation guidance for every registered lint/analyze/cyber rule that has a well-defined recommended fix","standard":"c-FuSa CLI","level":"ASIL-A"}, + {"id":"REQ-EXPLAIN001","title":"cfusa explain prints the rule's name, category, standard/clause citation, description, and fix guidance (or a clear note when none exists)","standard":"c-FuSa CLI","level":"ASIL-A"}, + {"id":"REQ-EXPLAIN002","title":"cfusa explain returns a usage error (2) with no RULE-ID and a not-found error (1) for an unknown rule id","standard":"c-FuSa CLI","level":"ASIL-A"}, + {"id":"REQ-EXPLAIN003","title":"cfusa explain accepts a rule id case-insensitively and without requiring the 'CFUSA-' prefix","standard":"c-FuSa CLI","level":"ASIL-A"}, {"id":"REQ-FMEA003","title":"cfusa fmea --help returns zero","standard":"IEC 60812","level":"ASIL-A"}, {"id":"REQ-FMEA004","title":"cfusa fmea --format json writes fmea.json","standard":"IEC 60812","level":"ASIL-A"}, {"id":"REQ-FMEA005","title":"cfusa fmea --format csv writes fmea.csv","standard":"IEC 60812","level":"ASIL-A"}, diff --git a/CMakeLists.txt b/CMakeLists.txt index 7def122..a7e682f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -73,6 +73,7 @@ add_library(cfusa_cmds STATIC cmd/cfusa/cmd_badge.c cmd/cfusa/cmd_req.c cmd/cfusa/cmd_fix.c + cmd/cfusa/cmd_explain.c cmd/cfusa/cmd_hooks.c cmd/cfusa/cmd_sign.c cmd/cfusa/cmd_do178.c @@ -172,6 +173,7 @@ cfusa_test(test_xfusa_audit_20260728) cfusa_test(test_severity) cfusa_test(test_coverage_asil) cfusa_test(test_disposition_enforcement) +cfusa_test(test_explain) # ---- Installation ---- include(GNUInstallDirs) diff --git a/cmd/cfusa/cmd_capabilities.c b/cmd/cfusa/cmd_capabilities.c index b3f9020..c1c9b41 100644 --- a/cmd/cfusa/cmd_capabilities.c +++ b/cmd/cfusa/cmd_capabilities.c @@ -60,7 +60,7 @@ int cmd_capabilities(int argc, char **argv) " \"coupling\", \"cyber\", \"vuln\", \"boundary\", \"coverage\", \"diff\", \"badge\",\n" " \"iso26262\", \"iec61508\", \"iec62443\", \"slsa\", \"do178\", \"iso21434\", \"unece\",\n" " \"lint\", \"analyze\", \"disposition\", \"baseline\", \"sign\", \"sas\", \"sci\", \"pr\", \"req\",\n" - " \"fix\", \"hooks\", \"impact\", \"metrics\", \"comp\", \"template\", \"misra\"\n" + " \"fix\", \"explain\", \"hooks\", \"impact\", \"metrics\", \"comp\", \"template\", \"misra\"\n" " ],\n" " \"formats\": {\n" " \"check\": [\"text\", \"json\", \"sarif\", \"html\", \"md\"],\n" diff --git a/cmd/cfusa/cmd_dispatch.c b/cmd/cfusa/cmd_dispatch.c index 055c396..5be6167 100644 --- a/cmd/cfusa/cmd_dispatch.c +++ b/cmd/cfusa/cmd_dispatch.c @@ -33,6 +33,7 @@ const cfusa_command_t CFUSA_COMMANDS[] = { {"badge", "SVG status badge from report", cmd_badge}, {"req", "Show source locations for a requirement ID", cmd_req}, {"fix", "Apply mechanical auto-fixes", cmd_fix}, + {"explain", "Show a rule's description, standard, and fix guidance", cmd_explain}, {"hooks", "Install / remove git pre-commit hook", cmd_hooks}, {"sign", "HMAC-SHA256 file signing and verification", cmd_sign}, {"do178", "DO-178C Annex A objective gap report", cmd_do178}, diff --git a/cmd/cfusa/cmd_explain.c b/cmd/cfusa/cmd_explain.c new file mode 100644 index 0000000..e3818b1 --- /dev/null +++ b/cmd/cfusa/cmd_explain.c @@ -0,0 +1,168 @@ +//cfusa:req REQ-EXPLAIN001 REQ-EXPLAIN002 REQ-EXPLAIN003 + +/* + * cfusa explain — issue #212: onboarding aid. Prints a rule's + * full metadata (name, standard/clause citation, description) plus its + * remediation guidance from `cfusa fix`'s table, in one place, so a team + * new to the tool doesn't have to go read the rule's source to understand + * a finding. + * + * Deliberately does NOT introduce a second, separately-authored writeup + * table: cfusa_rule_t already carries id/category/name/description/ + * standard_id/clause for every registered rule (both whole-file rules and + * the line-rule metadata stubs cfusa_engine_register_line_rule() creates + * — see engine.h), and cfusa_fix_lookup() (cfusa/fix.h) already carries + * remediation guidance for every rule that has one. This command is a + * thin, hand-authoring-free presentation layer over both. + */ +#if defined(__linux__) || defined(__unix__) +# define _GNU_SOURCE +#endif +#include +#include +#include +#include "cfusa/engine.h" +#include "cfusa/fix.h" + +/* Registers every rule set so the full metadata table is populated, + * regardless of which category the requested rule id belongs to. Mirrors + * cmd_check.c/cmd_fix.c's own registration calls. */ +static void register_all_rules(void) +{ + cfusa_engine_reset(); + cfusa_lint_register_rules(); + cfusa_analyze_register_rules(); + cfusa_cyber_register_rules(); + cfusa_safety_register_rules(); +} + +/* Skips a leading "CFUSA-"/"CFUSA_" prefix (case-insensitive), if present. + * Rule ids outside the lint/analyze/cyber families (COMP001, HARA001, ...) + * never carry the prefix to begin with, so this is a no-op for them. */ +static const char *skip_cfusa_prefix(const char *id) +{ + if ((id[0] == 'c' || id[0] == 'C') && (id[1] == 'f' || id[1] == 'F') && + (id[2] == 'u' || id[2] == 'U') && (id[3] == 's' || id[3] == 'S') && + (id[4] == 'a' || id[4] == 'A') && (id[5] == '-' || id[5] == '_')) + return id + 6; + return id; +} + +/* Findings/rule ids are case-sensitive elsewhere in the tool, but a human + * typing `cfusa explain cy006` at a shell prompt shouldn't have to + * remember the exact "CFUSA-" prefix and casing — strip that prefix from + * both sides, then compare case-insensitively (also ignoring any stray + * '-'/'_' separator so "cy-006" and "cy_006" match too). */ +static int ids_match_loosely(const char *a, const char *b) +{ + a = skip_cfusa_prefix(a); + b = skip_cfusa_prefix(b); + while (*a || *b) { + while (*a == '-' || *a == '_') a++; + while (*b == '-' || *b == '_') b++; + if (!*a || !*b) break; + if (tolower((unsigned char)*a) != tolower((unsigned char)*b)) return 0; + a++; b++; + } + return *a == '\0' && *b == '\0'; +} + +static const cfusa_rule_t *find_rule_loosely(const char *want) +{ + int n = cfusa_engine_rule_count(); + /* Exact match first, so an id that happens to be a substring/loose + * match of a different rule's id never shadows the real one. */ + for (int i = 0; i < n; i++) { + const cfusa_rule_t *r = cfusa_engine_get_rule(i); + if (!strcmp(r->id, want)) return r; + } + for (int i = 0; i < n; i++) { + const cfusa_rule_t *r = cfusa_engine_get_rule(i); + if (ids_match_loosely(r->id, want)) return r; + } + return NULL; +} + +static void print_wrapped_guidance(const char *g) +{ + char line_buf[256]; + while (*g) { + const char *nl = strchr(g, '\n'); + size_t len = nl ? (size_t)(nl - g) : strlen(g); + if (len >= sizeof(line_buf)) len = sizeof(line_buf) - 1; + memcpy(line_buf, g, len); line_buf[len] = '\0'; + printf(" %s\n", line_buf); + if (!nl) break; + g = nl + 1; + } +} + +static void print_rule_ids(void) +{ + int n = cfusa_engine_rule_count(); + const char *last_category = ""; + for (int i = 0; i < n; i++) { + const cfusa_rule_t *r = cfusa_engine_get_rule(i); + if (strcmp(r->category, last_category) != 0) { + printf("\n%s:\n", r->category); + last_category = r->category; + } + printf(" %-16s %s\n", r->id, r->name); + } +} + +int cmd_explain(int argc, char **argv) +{ + if (argc >= 2 && (!strcmp(argv[1], "--help") || !strcmp(argv[1], "-h"))) { + printf("Usage: cfusa explain \n" + " cfusa explain --list\n\n" + "Prints a rule's full description, standard/clause citation, and\n" + "remediation guidance (when available) in one place.\n" + " --list List every registered rule id, grouped by category\n"); + return 0; + } + + register_all_rules(); + + if (argc >= 2 && (!strcmp(argv[1], "--list") || !strcmp(argv[1], "-l"))) { + print_rule_ids(); + return 0; + } + + if (argc < 2) { + fprintf(stderr, + "cfusa explain: missing RULE-ID\n" + "Usage: cfusa explain (or: cfusa explain --list)\n"); + return 2; + } + + const char *want = argv[1]; + const cfusa_rule_t *r = find_rule_loosely(want); + if (!r) { + fprintf(stderr, "cfusa explain: unknown rule '%s'\n", want); + fprintf(stderr, "Run 'cfusa explain --list' to see every registered rule id.\n"); + return 1; + } + + printf("%s — %s\n", r->id, r->name); + printf("Category: %s\n", r->category); + if (r->standard_id && r->standard_id[0]) { + if (r->clause && r->clause[0]) + printf("Standard: %s %s\n", r->standard_id, r->clause); + else + printf("Standard: %s\n", r->standard_id); + } + if (r->description && r->description[0]) + printf("\n%s\n", r->description); + + const cfusa_fix_entry_t *fix = cfusa_fix_lookup(r->id); + if (fix) { + printf("\nFix: %s\n", fix->summary); + printf("Guidance:\n"); + print_wrapped_guidance(fix->guidance); + } else { + printf("\nNo automated remediation guidance for this rule yet —\n" + "see the description above and the standard clause it maps to.\n"); + } + return 0; +} diff --git a/cmd/cfusa/cmd_fix.c b/cmd/cfusa/cmd_fix.c index 16e072a..bc1e357 100644 --- a/cmd/cfusa/cmd_fix.c +++ b/cmd/cfusa/cmd_fix.c @@ -18,12 +18,9 @@ #include "cfusa/engine.h" #include "cfusa/report.h" #include "cfusa/utils.h" +#include "cfusa/fix.h" -typedef struct { - const char *rule_id; - const char *summary; - const char *guidance; -} fix_entry_t; +typedef cfusa_fix_entry_t fix_entry_t; static const fix_entry_t FIXES[] = { /* ---- LINT ---- */ @@ -241,6 +238,13 @@ static const fix_entry_t *lookup_fix(const char *rule_id) return NULL; } +/* Public accessor (cfusa/fix.h) — issue #212: lets `cfusa explain` show + * the same guidance without a second copy of FIXES[]. */ +const cfusa_fix_entry_t *cfusa_fix_lookup(const char *rule_id) +{ + return lookup_fix(rule_id); +} + /* ---- Real autofix: CFUSA-CY006 (free() without a following NULL-out) ---- * * issue #210: the only rewrite in this file that is genuinely mechanical diff --git a/include/cfusa/commands.h b/include/cfusa/commands.h index 7a9c4a6..0729cbe 100644 --- a/include/cfusa/commands.h +++ b/include/cfusa/commands.h @@ -31,6 +31,7 @@ int cmd_diff(int argc, char **argv); int cmd_badge(int argc, char **argv); int cmd_req(int argc, char **argv); int cmd_fix(int argc, char **argv); +int cmd_explain(int argc, char **argv); int cmd_hooks(int argc, char **argv); int cmd_sign(int argc, char **argv); int cmd_do178(int argc, char **argv); diff --git a/include/cfusa/fix.h b/include/cfusa/fix.h new file mode 100644 index 0000000..e5c08f3 --- /dev/null +++ b/include/cfusa/fix.h @@ -0,0 +1,20 @@ +#ifndef CFUSA_FIX_H +#define CFUSA_FIX_H + +/* issue #212: cmd_fix.c's remediation-guidance table is the same data + * `cfusa explain ` wants to show alongside a rule's description + * and standard citation -- expose a read-only lookup instead of a second, + * inevitably-drifting copy of the same ~39 entries. */ + +typedef struct { + const char *rule_id; + const char *summary; + const char *guidance; +} cfusa_fix_entry_t; + +/* Returns the FIXES[] entry for `rule_id`, or NULL if that rule has no + * remediation guidance (yet). Owned by cmd_fix.c; the returned pointer is + * valid for the process lifetime (points into a static const table). */ +const cfusa_fix_entry_t *cfusa_fix_lookup(const char *rule_id); + +#endif /* CFUSA_FIX_H */ diff --git a/tests/test_explain.c b/tests/test_explain.c new file mode 100644 index 0000000..6a05a89 --- /dev/null +++ b/tests/test_explain.c @@ -0,0 +1,146 @@ +/* + * Tests for `cfusa explain ` (issue #212). + */ +#include +#include +#include +#include "../vendor/unity/unity.h" + +extern int cmd_explain(int argc, char **argv); + +void setUp(void) {} +void tearDown(void) {} + +/* Runs cmd_explain(argc, argv), capturing stdout into `buf` (NUL- + * terminated). Mirrors the redirect pattern used throughout + * tests/test_commands2.c. */ +static int run_capture(int argc, char **argv, char *buf, size_t bufcap) +{ + char capture_path[] = "/tmp/cfusa_explain_test_stdout.XXXXXX"; + int fd = mkstemp(capture_path); + TEST_ASSERT_TRUE(fd >= 0); + if (fd >= 0) close(fd); + + fflush(stdout); + int saved_fd = dup(STDOUT_FILENO); + FILE *redirected = freopen(capture_path, "w", stdout); + TEST_ASSERT_NOT_NULL(redirected); + + int rc = cmd_explain(argc, argv); + + fflush(stdout); + dup2(saved_fd, STDOUT_FILENO); + close(saved_fd); + + buf[0] = '\0'; + FILE *f = fopen(capture_path, "r"); + if (f) { + size_t n = fread(buf, 1, bufcap - 1, f); + buf[n] = '\0'; + fclose(f); + } + remove(capture_path); + return rc; +} + +//cfusa:req REQ-EXPLAIN001 +//cfusa:test REQ-EXPLAIN001 +void test_explain_help_returns_zero(void) +{ + char *argv[] = {"cfusa", "--help", NULL}; + char buf[4096]; + int rc = run_capture(2, argv, buf, sizeof(buf)); + TEST_ASSERT_EQUAL_INT(0, rc); + TEST_ASSERT_NOT_NULL(strstr(buf, "Usage: cfusa explain")); +} + +//cfusa:req REQ-EXPLAIN002 +//cfusa:test REQ-EXPLAIN002 +void test_explain_missing_arg_returns_2(void) +{ + char *argv[] = {"cfusa", NULL}; + char buf[256]; + int rc = run_capture(1, argv, buf, sizeof(buf)); + TEST_ASSERT_EQUAL_INT(2, rc); +} + +//cfusa:req REQ-EXPLAIN002 +//cfusa:test REQ-EXPLAIN002 +void test_explain_unknown_rule_returns_1(void) +{ + char *argv[] = {"cfusa", "CFUSA-NOPE999", NULL}; + char buf[256]; + int rc = run_capture(2, argv, buf, sizeof(buf)); + TEST_ASSERT_EQUAL_INT(1, rc); +} + +//cfusa:req REQ-EXPLAIN001 +//cfusa:test REQ-EXPLAIN001 +void test_explain_known_rule_prints_description_standard_and_fix(void) +{ + char *argv[] = {"cfusa", "CFUSA-CY006", NULL}; + char buf[4096]; + int rc = run_capture(2, argv, buf, sizeof(buf)); + TEST_ASSERT_EQUAL_INT(0, rc); + TEST_ASSERT_NOT_NULL(strstr(buf, "CFUSA-CY006")); + TEST_ASSERT_NOT_NULL(strstr(buf, "cert-c")); + TEST_ASSERT_NOT_NULL(strstr(buf, "MEM30-C")); + TEST_ASSERT_NOT_NULL(strstr(buf, "Fix:")); + TEST_ASSERT_NOT_NULL(strstr(buf, "Null the pointer immediately after free")); +} + +/* issue #212's whole point: a human at a shell prompt shouldn't need the + * exact "CFUSA-" prefix or casing. */ +//cfusa:req REQ-EXPLAIN003 +//cfusa:test REQ-EXPLAIN003 +void test_explain_case_and_prefix_insensitive_lookup(void) +{ + char *argv[] = {"cfusa", "cy006", NULL}; + char buf[4096]; + int rc = run_capture(2, argv, buf, sizeof(buf)); + TEST_ASSERT_EQUAL_INT(0, rc); + TEST_ASSERT_NOT_NULL(strstr(buf, "CFUSA-CY006")); +} + +//cfusa:req REQ-EXPLAIN001 +//cfusa:test REQ-EXPLAIN001 +void test_explain_rule_without_fix_guidance_says_so(void) +{ + /* COMP001 (cyclomatic complexity) has no FIXES[] entry -- it needs + * judgment, not a mechanical fix (cmd_fix.c's own doc comment on + * L001 makes the same point). */ + char *argv[] = {"cfusa", "COMP001", NULL}; + char buf[4096]; + int rc = run_capture(2, argv, buf, sizeof(buf)); + TEST_ASSERT_EQUAL_INT(0, rc); + TEST_ASSERT_NOT_NULL(strstr(buf, "COMP001")); + TEST_ASSERT_NOT_NULL(strstr(buf, "No automated remediation guidance")); +} + +//cfusa:req REQ-EXPLAIN001 +//cfusa:test REQ-EXPLAIN001 +void test_explain_list_prints_every_category(void) +{ + char *argv[] = {"cfusa", "--list", NULL}; + char buf[16384]; + int rc = run_capture(2, argv, buf, sizeof(buf)); + TEST_ASSERT_EQUAL_INT(0, rc); + TEST_ASSERT_NOT_NULL(strstr(buf, "CFUSA-L001")); + TEST_ASSERT_NOT_NULL(strstr(buf, "CFUSA-A001")); + TEST_ASSERT_NOT_NULL(strstr(buf, "CFUSA-CY001")); + TEST_ASSERT_NOT_NULL(strstr(buf, "lint:")); + TEST_ASSERT_NOT_NULL(strstr(buf, "cyber:")); +} + +int main(void) +{ + UNITY_BEGIN(); + RUN_TEST(test_explain_help_returns_zero); + RUN_TEST(test_explain_missing_arg_returns_2); + RUN_TEST(test_explain_unknown_rule_returns_1); + RUN_TEST(test_explain_known_rule_prints_description_standard_and_fix); + RUN_TEST(test_explain_case_and_prefix_insensitive_lookup); + RUN_TEST(test_explain_rule_without_fix_guidance_says_so); + RUN_TEST(test_explain_list_prints_every_category); + return UNITY_END(); +}