diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 000000000..e69d4f7c7 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,41 @@ +stages: + - build + - scan + +build-scanner: + stage: build + script: + - make -C scanner + artifacts: + paths: + - scanner/undfscand + expire_in: 1 hour + +undf-scan: + stage: scan + needs: [build-scanner] + script: + - ./scanner/undfscand + --registry UNDF-REGISTRY.json + --defects defects/ + --target ${CI_PROJECT_DIR} + --threads 4 + --format json + --output scan-report.json + - | + python3 -c " + import json, sys + r = json.load(open('scan-report.json')) + s = r['summary'] + print(f'UNDF Scan: {s[\"total\"]} checked | {s[\"patched\"]} patched | {s[\"unpatched\"]} UNPATCHED | {s[\"not_found\"]} not_found') + unpatched = [f for f in r['findings'] if f['status'] == 'UNPATCHED'] + for f in unpatched: + print(f' !! {f[\"undf\"]} {f[\"defect_id\"]}: {f[\"evidence\"]}') + sys.exit(1 if unpatched else 0) + " + artifacts: + when: always + paths: + - scan-report.json + expire_in: 30 days + allow_failure: true diff --git a/Makefile b/Makefile index 751574e96..80ce8bb5c 100644 --- a/Makefile +++ b/Makefile @@ -63,3 +63,30 @@ TOPDIR := $(strip $(patsubst %/, %, $(dir $(makefile_path)))) # ... and then we can include the real makefile to bootstrap the build include $(TOPDIR)/make/PreInit.gmk + +# ============================================================ +# UNDF Scanner targets (independent of JDK build system) +# ============================================================ + +scanner/undfscand: scanner/undfscand.c + $(MAKE) -C scanner + +scan-local: scanner/undfscand + ./scanner/undfscand \ + --registry UNDF-REGISTRY.json \ + --defects defects/ \ + --target $(HOME)/git \ + --threads 8 \ + --output scan-report.json + @echo "Report: scan-report.json" + +scan-unsandbox: scanner/undfscand + ./scanner/undfscand \ + --registry UNDF-REGISTRY.json \ + --defects defects/ \ + --target /home/fox/git \ + --threads 8 \ + --format json \ + --output scan-report-$(shell date +%Y%m%d-%H%M%S).json + +.PHONY: scan-local scan-unsandbox diff --git a/scanner/Makefile b/scanner/Makefile new file mode 100644 index 000000000..4893c76f0 --- /dev/null +++ b/scanner/Makefile @@ -0,0 +1,19 @@ +CC = gcc +CFLAGS = -O2 -Wall -Wextra -std=c11 -D_POSIX_C_SOURCE=200809L +LDFLAGS = -lpthread + +TARGET = undfscand +SRC = undfscand.c + +all: $(TARGET) + +$(TARGET): $(SRC) + $(CC) $(CFLAGS) -o $@ $< $(LDFLAGS) + +clean: + rm -f $(TARGET) + +install: $(TARGET) + install -m 755 $(TARGET) /usr/local/bin/ + +.PHONY: all clean install diff --git a/scanner/undfscand b/scanner/undfscand new file mode 100755 index 000000000..5598defb2 Binary files /dev/null and b/scanner/undfscand differ diff --git a/scanner/undfscand.c b/scanner/undfscand.c new file mode 100644 index 000000000..ba6e501c6 --- /dev/null +++ b/scanner/undfscand.c @@ -0,0 +1,1085 @@ +/* + * undfscand.c — UNDF Patch Verification Scanner v1.0.0 + * + * Scans a target filesystem to determine whether each UNDF-registered patch + * is installed or not. Acts like an "antivirus" for patch regressions. + * + * Build: gcc -O2 -Wall -Wextra -std=c11 -D_POSIX_C_SOURCE=200809L \ + * -o undfscand undfscand.c -lpthread + * + * No external dependencies — MD5 and SHA256 are bundled. + */ + +#define _POSIX_C_SOURCE 200809L +#define _XOPEN_SOURCE 500 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* ============================================================ + * MD5 — RFC 1321 reference implementation (self-contained) + * ============================================================ */ + +typedef struct { + uint32_t state[4]; + uint32_t count[2]; + unsigned char buffer[64]; +} MD5_CTX; + +#define MD5_F(x,y,z) (((x)&(y))|((~x)&(z))) +#define MD5_G(x,y,z) (((x)&(z))|((y)&(~z))) +#define MD5_H(x,y,z) ((x)^(y)^(z)) +#define MD5_I(x,y,z) ((y)^((x)|(~z))) +#define MD5_ROTATE(x,n) (((x)<<(n))|((x)>>(32-(n)))) +#define MD5_FF(a,b,c,d,x,s,ac) { (a)+=MD5_F(b,c,d)+(x)+(uint32_t)(ac); (a)=MD5_ROTATE(a,s); (a)+=(b); } +#define MD5_GG(a,b,c,d,x,s,ac) { (a)+=MD5_G(b,c,d)+(x)+(uint32_t)(ac); (a)=MD5_ROTATE(a,s); (a)+=(b); } +#define MD5_HH(a,b,c,d,x,s,ac) { (a)+=MD5_H(b,c,d)+(x)+(uint32_t)(ac); (a)=MD5_ROTATE(a,s); (a)+=(b); } +#define MD5_II(a,b,c,d,x,s,ac) { (a)+=MD5_I(b,c,d)+(x)+(uint32_t)(ac); (a)=MD5_ROTATE(a,s); (a)+=(b); } + +static void md5_transform(uint32_t state[4], const unsigned char block[64]) { + uint32_t a=state[0],b=state[1],c=state[2],d=state[3],x[16]; + for(int i=0,j=0;i<16;i++,j+=4) + x[i]=((uint32_t)block[j])|(((uint32_t)block[j+1])<<8)| + (((uint32_t)block[j+2])<<16)|(((uint32_t)block[j+3])<<24); + MD5_FF(a,b,c,d,x[0],7,0xd76aa478); MD5_FF(d,a,b,c,x[1],12,0xe8c7b756); + MD5_FF(c,d,a,b,x[2],17,0x242070db); MD5_FF(b,c,d,a,x[3],22,0xc1bdceee); + MD5_FF(a,b,c,d,x[4],7,0xf57c0faf); MD5_FF(d,a,b,c,x[5],12,0x4787c62a); + MD5_FF(c,d,a,b,x[6],17,0xa8304613); MD5_FF(b,c,d,a,x[7],22,0xfd469501); + MD5_FF(a,b,c,d,x[8],7,0x698098d8); MD5_FF(d,a,b,c,x[9],12,0x8b44f7af); + MD5_FF(c,d,a,b,x[10],17,0xffff5bb1); MD5_FF(b,c,d,a,x[11],22,0x895cd7be); + MD5_FF(a,b,c,d,x[12],7,0x6b901122); MD5_FF(d,a,b,c,x[13],12,0xfd987193); + MD5_FF(c,d,a,b,x[14],17,0xa679438e); MD5_FF(b,c,d,a,x[15],22,0x49b40821); + MD5_GG(a,b,c,d,x[1],5,0xf61e2562); MD5_GG(d,a,b,c,x[6],9,0xc040b340); + MD5_GG(c,d,a,b,x[11],14,0x265e5a51); MD5_GG(b,c,d,a,x[0],20,0xe9b6c7aa); + MD5_GG(a,b,c,d,x[5],5,0xd62f105d); MD5_GG(d,a,b,c,x[10],9,0x02441453); + MD5_GG(c,d,a,b,x[15],14,0xd8a1e681); MD5_GG(b,c,d,a,x[4],20,0xe7d3fbc8); + MD5_GG(a,b,c,d,x[9],5,0x21e1cde6); MD5_GG(d,a,b,c,x[14],9,0xc33707d6); + MD5_GG(c,d,a,b,x[3],14,0xf4d50d87); MD5_GG(b,c,d,a,x[8],20,0x455a14ed); + MD5_GG(a,b,c,d,x[13],5,0xa9e3e905); MD5_GG(d,a,b,c,x[2],9,0xfcefa3f8); + MD5_GG(c,d,a,b,x[7],14,0x676f02d9); MD5_GG(b,c,d,a,x[12],20,0x8d2a4c8a); + MD5_HH(a,b,c,d,x[5],4,0xfffa3942); MD5_HH(d,a,b,c,x[8],11,0x8771f681); + MD5_HH(c,d,a,b,x[11],16,0x6d9d6122); MD5_HH(b,c,d,a,x[14],23,0xfde5380c); + MD5_HH(a,b,c,d,x[1],4,0xa4beea44); MD5_HH(d,a,b,c,x[4],11,0x4bdecfa9); + MD5_HH(c,d,a,b,x[7],16,0xf6bb4b60); MD5_HH(b,c,d,a,x[10],23,0xbebfbc70); + MD5_HH(a,b,c,d,x[13],4,0x289b7ec6); MD5_HH(d,a,b,c,x[0],11,0xeaa127fa); + MD5_HH(c,d,a,b,x[3],16,0xd4ef3085); MD5_HH(b,c,d,a,x[6],23,0x04881d05); + MD5_HH(a,b,c,d,x[9],4,0xd9d4d039); MD5_HH(d,a,b,c,x[12],11,0xe6db99e5); + MD5_HH(c,d,a,b,x[15],16,0x1fa27cf8); MD5_HH(b,c,d,a,x[2],23,0xc4ac5665); + MD5_II(a,b,c,d,x[0],6,0xf4292244); MD5_II(d,a,b,c,x[7],10,0x432aff97); + MD5_II(c,d,a,b,x[14],15,0xab9423a7); MD5_II(b,c,d,a,x[5],21,0xfc93a039); + MD5_II(a,b,c,d,x[12],6,0x655b59c3); MD5_II(d,a,b,c,x[3],10,0x8f0ccc92); + MD5_II(c,d,a,b,x[10],15,0xffeff47d); MD5_II(b,c,d,a,x[1],21,0x85845dd1); + MD5_II(a,b,c,d,x[8],6,0x6fa87e4f); MD5_II(d,a,b,c,x[15],10,0xfe2ce6e0); + MD5_II(c,d,a,b,x[6],15,0xa3014314); MD5_II(b,c,d,a,x[13],21,0x4e0811a1); + MD5_II(a,b,c,d,x[4],6,0xf7537e82); MD5_II(d,a,b,c,x[11],10,0xbd3af235); + MD5_II(c,d,a,b,x[2],15,0x2ad7d2bb); MD5_II(b,c,d,a,x[9],21,0xeb86d391); + state[0]+=a; state[1]+=b; state[2]+=c; state[3]+=d; +} + +static void md5_init(MD5_CTX *ctx) { + ctx->count[0]=ctx->count[1]=0; + ctx->state[0]=0x67452301; ctx->state[1]=0xefcdab89; + ctx->state[2]=0x98badcfe; ctx->state[3]=0x10325476; +} + +static void md5_update(MD5_CTX *ctx, const unsigned char *input, size_t len) { + size_t i, idx=(ctx->count[0]>>3)&0x3f; + if((ctx->count[0]+=(uint32_t)(len<<3))<(uint32_t)(len<<3)) ctx->count[1]++; + ctx->count[1]+=(uint32_t)(len>>29); + size_t part=64-idx; + if(len>=part){ memcpy(&ctx->buffer[idx],input,part); md5_transform(ctx->state,ctx->buffer); for(i=part;i+63state,input+i); idx=0; } else i=0; + memcpy(&ctx->buffer[idx],input+i,len-i); +} + +static void md5_final(unsigned char digest[16], MD5_CTX *ctx) { + static const unsigned char pad[64]={0x80}; + unsigned char bits[8]; + for(int i=0;i<4;i++){ bits[i]=(unsigned char)(ctx->count[0]>>(i*8)); bits[i+4]=(unsigned char)(ctx->count[1]>>(i*8)); } + size_t idx=(ctx->count[0]>>3)&0x3f; + md5_update(ctx,pad,idx<56?56-idx:120-idx); + md5_update(ctx,bits,8); + for(int i=0;i<4;i++) for(int j=0;j<4;j++) digest[i*4+j]=(unsigned char)(ctx->state[i]>>(j*8)); + memset(ctx,0,sizeof(*ctx)); +} + +/* ============================================================ + * SHA256 — self-contained implementation + * ============================================================ */ + +typedef struct { + uint32_t state[8]; + uint64_t count; + unsigned char buf[64]; + size_t buflen; +} SHA256_CTX; + +static const uint32_t sha256_k[64] = { + 0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5, + 0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174, + 0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da, + 0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967, + 0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85, + 0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070, + 0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3, + 0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2 +}; + +#define SHA256_CH(x,y,z) (((x)&(y))^(~(x)&(z))) +#define SHA256_MAJ(x,y,z) (((x)&(y))^((x)&(z))^((y)&(z))) +#define SHA256_ROR(x,n) (((x)>>(n))|((x)<<(32-(n)))) +#define SHA256_S0(x) (SHA256_ROR(x,2)^SHA256_ROR(x,13)^SHA256_ROR(x,22)) +#define SHA256_S1(x) (SHA256_ROR(x,6)^SHA256_ROR(x,11)^SHA256_ROR(x,25)) +#define SHA256_s0(x) (SHA256_ROR(x,7)^SHA256_ROR(x,18)^((x)>>3)) +#define SHA256_s1(x) (SHA256_ROR(x,17)^SHA256_ROR(x,19)^((x)>>10)) + +static void sha256_transform(SHA256_CTX *ctx, const unsigned char *data) { + uint32_t w[64],a,b,c,d,e,f,g,h,t1,t2; + for(int i=0;i<16;i++) w[i]=((uint32_t)data[i*4]<<24)|((uint32_t)data[i*4+1]<<16)|((uint32_t)data[i*4+2]<<8)|((uint32_t)data[i*4+3]); + for(int i=16;i<64;i++) w[i]=SHA256_s1(w[i-2])+w[i-7]+SHA256_s0(w[i-15])+w[i-16]; + a=ctx->state[0];b=ctx->state[1];c=ctx->state[2];d=ctx->state[3]; + e=ctx->state[4];f=ctx->state[5];g=ctx->state[6];h=ctx->state[7]; + for(int i=0;i<64;i++){ + t1=h+SHA256_S1(e)+SHA256_CH(e,f,g)+sha256_k[i]+w[i]; + t2=SHA256_S0(a)+SHA256_MAJ(a,b,c); + h=g;g=f;f=e;e=d+t1;d=c;c=b;b=a;a=t1+t2; + } + ctx->state[0]+=a;ctx->state[1]+=b;ctx->state[2]+=c;ctx->state[3]+=d; + ctx->state[4]+=e;ctx->state[5]+=f;ctx->state[6]+=g;ctx->state[7]+=h; +} + +static void sha256_init(SHA256_CTX *ctx) { + ctx->count=0; ctx->buflen=0; + ctx->state[0]=0x6a09e667;ctx->state[1]=0xbb67ae85; + ctx->state[2]=0x3c6ef372;ctx->state[3]=0xa54ff53a; + ctx->state[4]=0x510e527f;ctx->state[5]=0x9b05688c; + ctx->state[6]=0x1f83d9ab;ctx->state[7]=0x5be0cd19; +} + +static void sha256_update(SHA256_CTX *ctx, const unsigned char *data, size_t len) { + size_t i=0; + if(ctx->buflen>0){ + size_t fill=64-ctx->buflen; + if(lenbuf+ctx->buflen,data,len); ctx->buflen+=len; return; } + memcpy(ctx->buf+ctx->buflen,data,fill); sha256_transform(ctx,ctx->buf); ctx->count+=512; ctx->buflen=0; i=fill; + } + for(;i+63count+=512; } + if(ibuf,data+i,len-i); ctx->buflen=len-i; } +} + +static void sha256_final(unsigned char digest[32], SHA256_CTX *ctx) { + ctx->count+=(uint64_t)ctx->buflen*8; + ctx->buf[ctx->buflen++]=0x80; + if(ctx->buflen>56){ memset(ctx->buf+ctx->buflen,0,64-ctx->buflen); sha256_transform(ctx,ctx->buf); ctx->buflen=0; } + memset(ctx->buf+ctx->buflen,0,56-ctx->buflen); + uint64_t bits=ctx->count; + for(int i=7;i>=0;i--){ ctx->buf[56+i]=(unsigned char)(bits&0xff); bits>>=8; } + sha256_transform(ctx,ctx->buf); + for(int i=0;i<8;i++){ digest[i*4]=(ctx->state[i]>>24)&0xff; digest[i*4+1]=(ctx->state[i]>>16)&0xff; digest[i*4+2]=(ctx->state[i]>>8)&0xff; digest[i*4+3]=ctx->state[i]&0xff; } + memset(ctx,0,sizeof(*ctx)); +} + +/* ============================================================ + * Data structures + * ============================================================ */ + +#define MAX_DEFECTS 2048 +#define MAX_PATTERNS 32 +#define MAX_PATH_LEN 4096 +#define MAX_LINE_LEN 8192 +#define MAX_EVIDENCE 512 + +typedef enum { STATUS_UNKNOWN, STATUS_PATCHED, STATUS_UNPATCHED, STATUS_NOT_FOUND } ScanStatus; + +typedef struct { + char defect_id[128]; /* e.g. "javac-0001" */ + char undf_id[64]; /* e.g. "UNDF-2026-000000001" */ + char target_file[512]; /* extracted from | File | row */ + int line_start; /* from | Lines | row */ + int line_end; + char defect_patterns[MAX_PATTERNS][256]; /* lines containing these → UNPATCHED */ + int n_defect_patterns; + char fix_patterns[MAX_PATTERNS][256]; /* lines containing these → PATCHED */ + int n_fix_patterns; +} DefectSpec; + +typedef struct { + char undf_id[64]; + char defect_id[128]; + ScanStatus status; + char file_found[MAX_PATH_LEN]; + char md5_hex[33]; + char sha256_hex[65]; + char evidence[MAX_EVIDENCE]; +} FindingResult; + +/* ============================================================ + * Global state + * ============================================================ */ + +static DefectSpec g_defects[MAX_DEFECTS]; +static int g_n_defects = 0; + +static FindingResult g_results[MAX_DEFECTS]; +static pthread_mutex_t g_results_mutex = PTHREAD_MUTEX_INITIALIZER; + +static char g_target_dir[MAX_PATH_LEN] = "/home/fox/git"; +static char g_registry_path[MAX_PATH_LEN] = "./UNDF-REGISTRY.json"; +static char g_defects_path[MAX_PATH_LEN] = "./defects"; +static char g_output_path[MAX_PATH_LEN] = ""; +static char g_filter_undf[64] = ""; +static int g_threads = 0; +static int g_verbose = 0; +static int g_format_text = 0; /* 0=json, 1=text */ + +/* ============================================================ + * Work queue + * ============================================================ */ + +typedef struct WorkItem { + int defect_index; + struct WorkItem *next; +} WorkItem; + +static WorkItem *g_queue_head = NULL; +static WorkItem *g_queue_tail = NULL; +static int g_queue_size = 0; +static int g_queue_done = 0; +static pthread_mutex_t g_queue_mutex = PTHREAD_MUTEX_INITIALIZER; +static pthread_cond_t g_queue_cond = PTHREAD_COND_INITIALIZER; + +static void queue_push(int idx) { + WorkItem *item = malloc(sizeof(WorkItem)); + if (!item) { perror("malloc"); exit(1); } + item->defect_index = idx; + item->next = NULL; + pthread_mutex_lock(&g_queue_mutex); + if (!g_queue_tail) { g_queue_head = g_queue_tail = item; } + else { g_queue_tail->next = item; g_queue_tail = item; } + g_queue_size++; + pthread_cond_signal(&g_queue_cond); + pthread_mutex_unlock(&g_queue_mutex); +} + +static int queue_pop(int *idx_out) { + pthread_mutex_lock(&g_queue_mutex); + while (!g_queue_head && !g_queue_done) + pthread_cond_wait(&g_queue_cond, &g_queue_mutex); + if (!g_queue_head && g_queue_done) { + pthread_mutex_unlock(&g_queue_mutex); + return 0; + } + WorkItem *item = g_queue_head; + g_queue_head = item->next; + if (!g_queue_head) g_queue_tail = NULL; + g_queue_size--; + pthread_mutex_unlock(&g_queue_mutex); + *idx_out = item->defect_index; + free(item); + return 1; +} + +static void queue_signal_done(void) { + pthread_mutex_lock(&g_queue_mutex); + g_queue_done = 1; + pthread_cond_broadcast(&g_queue_cond); + pthread_mutex_unlock(&g_queue_mutex); +} + +/* ============================================================ + * Logging + * ============================================================ */ + +static void vlog(const char *fmt, ...) { + if (!g_verbose) return; + va_list ap; + va_start(ap, fmt); + vfprintf(stderr, fmt, ap); + va_end(ap); + fputc('\n', stderr); +} + +/* ============================================================ + * JSON parsing — UNDF-REGISTRY.json is a flat {"key":"val",...} + * ============================================================ */ + +/* Extract all "key":"value" pairs. Returns count. */ +static int parse_registry(const char *path) { + FILE *f = fopen(path, "r"); + if (!f) { fprintf(stderr, "ERROR: cannot open registry %s: %s\n", path, strerror(errno)); return -1; } + + fseek(f, 0, SEEK_END); + long sz = ftell(f); + rewind(f); + char *buf = malloc(sz + 1); + if (!buf) { fclose(f); fprintf(stderr, "ERROR: malloc %ld\n", sz); return -1; } + size_t nread = fread(buf, 1, sz, f); + (void)nread; + fclose(f); + buf[sz] = '\0'; + + int count = 0; + char *p = buf; + while (*p && count < MAX_DEFECTS) { + /* find next '"' to start a key */ + while (*p && *p != '"') p++; + if (!*p) break; + p++; /* skip opening quote */ + char key[128]; + int ki = 0; + while (*p && *p != '"' && ki < 127) key[ki++] = *p++; + key[ki] = '\0'; + if (!*p) break; + p++; /* skip closing quote */ + /* skip whitespace and ':' */ + while (*p && (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == ':')) p++; + if (*p != '"') continue; + p++; /* skip opening quote */ + char val[64]; + int vi = 0; + while (*p && *p != '"' && vi < 63) val[vi++] = *p++; + val[vi] = '\0'; + if (*p == '"') p++; + + /* only store pairs that look like defect-id → UNDF-2026-... */ + if (strncmp(val, "UNDF-2026-", 10) == 0) { + strncpy(g_defects[count].defect_id, key, 127); + strncpy(g_defects[count].undf_id, val, 63); + g_defects[count].line_start = 0; + g_defects[count].line_end = 0; + g_defects[count].n_defect_patterns = 0; + g_defects[count].n_fix_patterns = 0; + g_defects[count].target_file[0] = '\0'; + count++; + } + } + free(buf); + g_n_defects = count; + vlog("Parsed %d defect entries from registry", count); + return count; +} + +/* ============================================================ + * Markdown patch file parsing + * ============================================================ */ + +static void strip_backticks(char *s) { + /* remove leading/trailing backticks and whitespace */ + char *p = s; + while (*p == '`' || *p == ' ' || *p == '\t') p++; + char *end = p + strlen(p) - 1; + while (end > p && (*end == '`' || *end == ' ' || *end == '\t' || *end == '\n' || *end == '\r')) *end-- = '\0'; + if (p != s) memmove(s, p, strlen(p) + 1); +} + +/* Trim trailing whitespace including newlines */ +static void rtrim(char *s) { + int n = strlen(s); + while (n > 0 && (s[n-1] == '\n' || s[n-1] == '\r' || s[n-1] == ' ' || s[n-1] == '\t')) s[--n] = '\0'; +} + +/* Add a pattern to the defect or fix list (dedup, ignore if full) */ +static void add_pattern(char arr[][256], int *count, const char *pat) { + if (*count >= MAX_PATTERNS) return; + /* skip very short or whitespace-only patterns */ + const char *p = pat; + while (*p == ' ' || *p == '\t') p++; + if (strlen(p) < 4) return; + /* dedup */ + for (int i = 0; i < *count; i++) + if (strcmp(arr[i], pat) == 0) return; + strncpy(arr[*count], pat, 255); + (*count)++; +} + +/* + * Parse a patch .md file for a given defect spec. + * Extracts: + * - | File | `path` | row → target_file + * - | Lines | N–M | row → line_start, line_end + * - Code blocks with DEFECT: markers → defect_patterns + * - Code blocks with FIX:/After markers → fix_patterns + */ +static int parse_patch_md(const char *path, DefectSpec *spec) { + FILE *f = fopen(path, "r"); + if (!f) return -1; + + char line[MAX_LINE_LEN]; + int in_code_block = 0; + int code_block_is_defect = 0; /* 0=unknown, 1=defect, 2=fix */ + int prev_was_after = 0; /* saw "# AFTER" or "## Fix" heading */ + + while (fgets(line, sizeof(line), f)) { + rtrim(line); + + /* Code block toggle */ + if (strncmp(line, "```", 3) == 0) { + if (!in_code_block) { + in_code_block = 1; + /* Determine if this block is a defect or fix block. + * We use the context of surrounding headings/comments already seen. */ + code_block_is_defect = prev_was_after ? 2 : 0; + } else { + in_code_block = 0; + code_block_is_defect = 0; + } + continue; + } + + /* Track headings that indicate what follows */ + if (!in_code_block) { + char *lp = line; + while (*lp == '#' || *lp == ' ') lp++; + char lc[MAX_LINE_LEN]; + strncpy(lc, lp, MAX_LINE_LEN-1); lc[MAX_LINE_LEN-1] = '\0'; + for (int i = 0; lc[i]; i++) lc[i] = tolower((unsigned char)lc[i]); + + if (strstr(lc, "fix") || strstr(lc, "after") || strstr(lc, "patch") || strstr(lc, "solution")) + prev_was_after = 1; + else if (strstr(lc, "defect") || strstr(lc, "before") || strstr(lc, "problem") || strstr(lc, "background")) + prev_was_after = 0; + + /* | File | `path/to/file` | */ + if (spec->target_file[0] == '\0' && strstr(line, "| File |")) { + /* Extract the second cell */ + char *p = strstr(line, "| File |"); + if (p) { + p += strlen("| File |"); + while (*p == ' ' || *p == '\t' || *p == '|') p++; + char val[512]; int vi = 0; + while (*p && *p != '|' && vi < 511) val[vi++] = *p++; + val[vi] = '\0'; + strip_backticks(val); + if (strlen(val) > 0) + strncpy(spec->target_file, val, 511); + } + } + + /* | Lines | N–M | or | Lines | N-M | */ + if (spec->line_start == 0 && strstr(line, "| Lines |")) { + char *p = strstr(line, "| Lines |"); + if (p) { + p += strlen("| Lines |"); + while (*p == ' ' || *p == '\t' || *p == '|') p++; + char val[64]; int vi = 0; + while (*p && *p != '|' && vi < 63) val[vi++] = *p++; + val[vi] = '\0'; + rtrim(val); + strip_backticks(val); + /* parse N–M or N-M (en dash U+2013 = 0xe2 0x80 0x93 in UTF-8, or plain '-') */ + char *dash = strstr(val, "\xe2\x80\x93"); /* en dash */ + if (!dash) dash = strchr(val, '-'); + if (dash) { + *dash = '\0'; + char *end_part = dash + 1; + /* for en dash, skip extra bytes */ + if (*(unsigned char*)dash == '\0' && (unsigned char)(*(dash)) == 0) { + /* already split at '\0' — check if next chars are utf-8 continuation */ + } + /* skip utf-8 en dash continuation bytes if present */ + while (*end_part && (unsigned char)*end_part >= 0x80 && (unsigned char)*end_part < 0xc0) + end_part++; + spec->line_start = atoi(val); + spec->line_end = atoi(end_part); + } else { + spec->line_start = atoi(val); + spec->line_end = spec->line_start; + } + } + } + continue; + } + + /* Inside a code block */ + char *lp = line; + while (*lp == ' ' || *lp == '\t' || *lp == '+' || *lp == '-') lp++; + + /* Detect DEFECT marker → this is a defect block */ + if (strstr(line, "DEFECT:") || strstr(line, "# DEFECT") || strstr(line, "// DEFECT")) { + code_block_is_defect = 1; + } + /* Detect FIX/After marker → this is a fix block */ + if (strstr(line, "FIX:") || strstr(line, "# FIX") || strstr(line, "// FIX") || + strstr(line, "AFTER") || strstr(line, "After") || strstr(line, "// After")) { + code_block_is_defect = 2; + } + + /* For DEFECT blocks: collect significant code lines as patterns */ + if (code_block_is_defect == 1) { + /* Skip comment-only lines and blank lines */ + if (strlen(lp) > 8 && lp[0] != '#' && lp[0] != '/' && lp[0] != '*') { + add_pattern(spec->defect_patterns, &spec->n_defect_patterns, lp); + } + } + /* For FIX blocks: collect fix lines as patterns */ + else if (code_block_is_defect == 2) { + if (strlen(lp) > 8 && lp[0] != '#' && lp[0] != '/' && lp[0] != '*') { + add_pattern(spec->fix_patterns, &spec->n_fix_patterns, lp); + } + } + } + + fclose(f); + return 0; +} + +/* + * Find the patch .md file for a given defect_id under the defects/ directory. + * Pattern: defects//-*.md (not CLEAN.md) + * Returns 1 if found, 0 if not. + */ +static int find_patch_md(const char *defect_id, char *out_path, size_t out_len) { + /* Extract project prefix: everything before the last '-NNNN' */ + char prefix[128]; + strncpy(prefix, defect_id, 127); + /* Strip trailing -NNNN */ + char *last_dash = strrchr(prefix, '-'); + if (last_dash) *last_dash = '\0'; + + char dir[MAX_PATH_LEN]; + snprintf(dir, sizeof(dir), "%s/%s/patch", g_defects_path, prefix); + + DIR *d = opendir(dir); + if (!d) { + /* Try without /patch subdirectory */ + snprintf(dir, sizeof(dir), "%s/%s", g_defects_path, prefix); + d = opendir(dir); + if (!d) return 0; + } + + struct dirent *ent; + while ((ent = readdir(d))) { + if (strncmp(ent->d_name, defect_id, strlen(defect_id)) == 0 && + strstr(ent->d_name, ".md") && + strcmp(ent->d_name, "CLEAN.md") != 0) { + snprintf(out_path, out_len, "%s/%s", dir, ent->d_name); + closedir(d); + return 1; + } + } + closedir(d); + return 0; +} + +/* ============================================================ + * File hashing + * ============================================================ */ + +static void hash_file(const char *path, char md5_hex[33], char sha256_hex[65]) { + FILE *f = fopen(path, "rb"); + if (!f) { + strcpy(md5_hex, ""); strcpy(sha256_hex, ""); + return; + } + MD5_CTX md5; md5_init(&md5); + SHA256_CTX sha256; sha256_init(&sha256); + unsigned char buf[65536]; + size_t n; + while ((n = fread(buf, 1, sizeof(buf), f)) > 0) { + md5_update(&md5, buf, n); + sha256_update(&sha256, buf, n); + } + fclose(f); + unsigned char md5d[16], sha256d[32]; + md5_final(md5d, &md5); + sha256_final(sha256d, &sha256); + for (int i = 0; i < 16; i++) sprintf(md5_hex + i*2, "%02x", md5d[i]); + md5_hex[32] = '\0'; + for (int i = 0; i < 32; i++) sprintf(sha256_hex + i*2, "%02x", sha256d[i]); + sha256_hex[64] = '\0'; +} + +/* ============================================================ + * Pattern matching in target file + * ============================================================ */ + +typedef struct { + ScanStatus status; + char evidence[MAX_EVIDENCE]; +} MatchResult; + +/* + * Read the target file and check for defect/fix patterns. + * If line_start/line_end are specified, focus on that region but + * also scan the whole file if needed. + * Priority: if defect pattern found → UNPATCHED; if fix pattern found → PATCHED. + */ +static MatchResult match_file(const char *file_path, const DefectSpec *spec) { + MatchResult res = { STATUS_UNKNOWN, "" }; + + FILE *f = fopen(file_path, "r"); + if (!f) return res; + + char line[MAX_LINE_LEN]; + int lineno = 0; + + while (fgets(line, sizeof(line), f)) { + lineno++; + rtrim(line); + + /* Check defect patterns */ + for (int i = 0; i < spec->n_defect_patterns; i++) { + if (strstr(line, spec->defect_patterns[i])) { + snprintf(res.evidence, MAX_EVIDENCE, + "defect pattern at line %d: '%.100s'", lineno, line); + res.status = STATUS_UNPATCHED; + fclose(f); + return res; + } + } + + /* Check fix patterns */ + for (int i = 0; i < spec->n_fix_patterns; i++) { + if (strstr(line, spec->fix_patterns[i])) { + if (res.status != STATUS_UNPATCHED) { + res.status = STATUS_PATCHED; + snprintf(res.evidence, MAX_EVIDENCE, + "fix pattern at line %d: '%.100s'", lineno, line); + } + } + } + } + fclose(f); + return res; +} + +/* ============================================================ + * Directory walk to find target files + * ============================================================ */ + +/* + * We need a thread-safe file walk. We use a simple recursive opendir/readdir + * approach with depth limit to avoid stack overflow. + */ + +#define MAX_DEPTH 20 + +typedef struct { + const DefectSpec *spec; + char found_path[MAX_PATH_LEN]; + int found; +} WalkState; + +static void walk_dir(const char *dir_path, WalkState *ws, int depth) { + if (ws->found || depth > MAX_DEPTH) return; + + DIR *d = opendir(dir_path); + if (!d) return; + + /* Extract just the basename of the target file for matching */ + const char *target_basename = strrchr(ws->spec->target_file, '/'); + if (target_basename) target_basename++; + else target_basename = ws->spec->target_file; + + struct dirent *ent; + while ((ent = readdir(d)) && !ws->found) { + if (ent->d_name[0] == '.') continue; /* skip hidden/. */ + + char path[MAX_PATH_LEN]; + snprintf(path, sizeof(path), "%s/%s", dir_path, ent->d_name); + + struct stat st; + if (lstat(path, &st) != 0) continue; + + if (S_ISREG(st.st_mode)) { + /* Check basename match */ + if (strcmp(ent->d_name, target_basename) == 0) { + /* Also do a partial path check if spec has a longer path */ + if (strlen(ws->spec->target_file) > strlen(target_basename)) { + if (strstr(path, ws->spec->target_file) || + /* Or check that the suffix matches */ + (strlen(path) >= strlen(ws->spec->target_file) && + strcmp(path + strlen(path) - strlen(ws->spec->target_file), + ws->spec->target_file) == 0)) { + strncpy(ws->found_path, path, MAX_PATH_LEN-1); + ws->found = 1; + } else { + /* Basename matches but path doesn't — store as candidate + * only if we haven't found a better one */ + if (!ws->found) { + strncpy(ws->found_path, path, MAX_PATH_LEN-1); + ws->found = 1; + } + } + } else { + strncpy(ws->found_path, path, MAX_PATH_LEN-1); + ws->found = 1; + } + } + } else if (S_ISDIR(st.st_mode)) { + walk_dir(path, ws, depth + 1); + } + } + closedir(d); +} + +/* ============================================================ + * Per-defect scan job + * ============================================================ */ + +static void process_defect(int idx) { + DefectSpec *spec = &g_defects[idx]; + FindingResult result; + memset(&result, 0, sizeof(result)); + strncpy(result.undf_id, spec->undf_id, 63); + strncpy(result.defect_id, spec->defect_id, 127); + result.status = STATUS_UNKNOWN; + strcpy(result.file_found, ""); + strcpy(result.md5_hex, ""); + strcpy(result.sha256_hex, ""); + strcpy(result.evidence, ""); + + /* Step 1: Find patch .md */ + char md_path[MAX_PATH_LEN]; + if (!find_patch_md(spec->defect_id, md_path, sizeof(md_path))) { + vlog("[%s] no patch .md found", spec->defect_id); + result.status = STATUS_UNKNOWN; + snprintf(result.evidence, MAX_EVIDENCE, "no patch .md file found"); + goto store; + } + + vlog("[%s] parsing %s", spec->defect_id, md_path); + + /* Step 2: Parse the patch .md */ + if (parse_patch_md(md_path, spec) != 0) { + result.status = STATUS_UNKNOWN; + snprintf(result.evidence, MAX_EVIDENCE, "failed to parse patch .md"); + goto store; + } + + if (spec->target_file[0] == '\0') { + vlog("[%s] no target file extracted from .md", spec->defect_id); + result.status = STATUS_UNKNOWN; + snprintf(result.evidence, MAX_EVIDENCE, "no target file in patch .md"); + goto store; + } + + vlog("[%s] target file: %s lines: %d-%d defect_pats: %d fix_pats: %d", + spec->defect_id, spec->target_file, + spec->line_start, spec->line_end, + spec->n_defect_patterns, spec->n_fix_patterns); + + /* Step 3: Walk target directory to find the file */ + if (spec->n_defect_patterns == 0 && spec->n_fix_patterns == 0) { + vlog("[%s] no patterns extracted — marking UNKNOWN", spec->defect_id); + result.status = STATUS_UNKNOWN; + snprintf(result.evidence, MAX_EVIDENCE, "no code patterns extracted from patch .md"); + goto store; + } + + WalkState ws; + ws.spec = spec; + ws.found = 0; + ws.found_path[0] = '\0'; + walk_dir(g_target_dir, &ws, 0); + + if (!ws.found) { + result.status = STATUS_NOT_FOUND; + snprintf(result.evidence, MAX_EVIDENCE, "target file '%s' not found under %s", + spec->target_file, g_target_dir); + goto store; + } + + strncpy(result.file_found, ws.found_path, MAX_PATH_LEN-1); + + /* Step 4: Hash the found file */ + hash_file(ws.found_path, result.md5_hex, result.sha256_hex); + + /* Step 5: Match patterns */ + MatchResult mr = match_file(ws.found_path, spec); + result.status = mr.status; + strncpy(result.evidence, mr.evidence, MAX_EVIDENCE-1); + + if (result.status == STATUS_UNKNOWN) { + snprintf(result.evidence, MAX_EVIDENCE, + "file found but no matching patterns (defect_pats=%d fix_pats=%d)", + spec->n_defect_patterns, spec->n_fix_patterns); + } + +store: + pthread_mutex_lock(&g_results_mutex); + g_results[idx] = result; + pthread_mutex_unlock(&g_results_mutex); + + vlog("[%s] → %s", spec->defect_id, + result.status == STATUS_PATCHED ? "PATCHED" : + result.status == STATUS_UNPATCHED ? "UNPATCHED" : + result.status == STATUS_NOT_FOUND ? "NOT_FOUND" : "UNKNOWN"); +} + +/* ============================================================ + * Worker thread + * ============================================================ */ + +static void *worker_thread(void *arg) { + (void)arg; + int idx; + while (queue_pop(&idx)) { + process_defect(idx); + } + return NULL; +} + +/* ============================================================ + * JSON output helpers + * ============================================================ */ + +static void json_escape(const char *s, char *out, size_t outlen) { + size_t i = 0, j = 0; + while (s[i] && j + 6 < outlen) { + switch (s[i]) { + case '"': out[j++] = '\\'; out[j++] = '"'; break; + case '\\': out[j++] = '\\'; out[j++] = '\\'; break; + case '\n': out[j++] = '\\'; out[j++] = 'n'; break; + case '\r': out[j++] = '\\'; out[j++] = 'r'; break; + case '\t': out[j++] = '\\'; out[j++] = 't'; break; + default: out[j++] = s[i]; break; + } + i++; + } + out[j] = '\0'; +} + +static const char *status_str(ScanStatus s) { + switch (s) { + case STATUS_PATCHED: return "PATCHED"; + case STATUS_UNPATCHED: return "UNPATCHED"; + case STATUS_NOT_FOUND: return "NOT_FOUND"; + default: return "UNKNOWN"; + } +} + +/* ============================================================ + * Report output + * ============================================================ */ + +static void write_report(FILE *out) { + /* Count summary */ + int total = g_n_defects, patched = 0, unpatched = 0, unknown = 0, not_found = 0; + for (int i = 0; i < g_n_defects; i++) { + switch (g_results[i].status) { + case STATUS_PATCHED: patched++; break; + case STATUS_UNPATCHED: unpatched++; break; + case STATUS_NOT_FOUND: not_found++; break; + default: unknown++; break; + } + } + + /* Apply filter if set */ + if (g_filter_undf[0]) { + total = 0; + patched = unpatched = unknown = not_found = 0; + for (int i = 0; i < g_n_defects; i++) { + if (strcmp(g_results[i].undf_id, g_filter_undf) != 0) continue; + total++; + switch (g_results[i].status) { + case STATUS_PATCHED: patched++; break; + case STATUS_UNPATCHED: unpatched++; break; + case STATUS_NOT_FOUND: not_found++; break; + default: unknown++; break; + } + } + } + + if (g_format_text) { + /* Human-readable text report */ + fprintf(out, "UNDF Patch Verification Scanner v1.0.0\n"); + fprintf(out, "Target: %s\n", g_target_dir); + fprintf(out, "Summary: %d total | %d PATCHED | %d UNPATCHED | %d UNKNOWN | %d NOT_FOUND\n", + total, patched, unpatched, unknown, not_found); + fprintf(out, "\n"); + for (int i = 0; i < g_n_defects; i++) { + FindingResult *r = &g_results[i]; + if (g_filter_undf[0] && strcmp(r->undf_id, g_filter_undf) != 0) continue; + if (r->status == STATUS_UNKNOWN || r->status == STATUS_NOT_FOUND) continue; + fprintf(out, "%-20s %-30s %-12s %s\n", + r->undf_id, r->defect_id, status_str(r->status), r->evidence); + } + } else { + /* JSON report */ + char timestamp[32]; + time_t now = time(NULL); + struct tm *tm = gmtime(&now); + strftime(timestamp, sizeof(timestamp), "%Y-%m-%dT%H:%M:%SZ", tm); + + char esc_target[MAX_PATH_LEN * 2]; + json_escape(g_target_dir, esc_target, sizeof(esc_target)); + + fprintf(out, + "{\n" + " \"scanner\": \"undfscand\",\n" + " \"version\": \"1.0.0\",\n" + " \"timestamp\": \"%s\",\n" + " \"target\": \"%s\",\n" + " \"summary\": {\n" + " \"total\": %d,\n" + " \"patched\": %d,\n" + " \"unpatched\": %d,\n" + " \"unknown\": %d,\n" + " \"not_found\": %d\n" + " },\n" + " \"findings\": [\n", + timestamp, esc_target, + total, patched, unpatched, unknown, not_found); + + int first = 1; + for (int i = 0; i < g_n_defects; i++) { + FindingResult *r = &g_results[i]; + if (g_filter_undf[0] && strcmp(r->undf_id, g_filter_undf) != 0) continue; + + char esc_file[MAX_PATH_LEN * 2]; + char esc_ev[MAX_EVIDENCE * 2]; + json_escape(r->file_found, esc_file, sizeof(esc_file)); + json_escape(r->evidence, esc_ev, sizeof(esc_ev)); + + if (!first) fprintf(out, ",\n"); + fprintf(out, + " {\n" + " \"undf\": \"%s\",\n" + " \"defect_id\": \"%s\",\n" + " \"status\": \"%s\",\n" + " \"file_found\": \"%s\",\n" + " \"md5\": \"%s\",\n" + " \"sha256\": \"%s\",\n" + " \"evidence\": \"%s\"\n" + " }", + r->undf_id, r->defect_id, status_str(r->status), + esc_file, r->md5_hex, r->sha256_hex, esc_ev); + first = 0; + } + fprintf(out, "\n ]\n}\n"); + } +} + +/* ============================================================ + * CLI + * ============================================================ */ + +static void usage(const char *prog) { + fprintf(stderr, + "Usage: %s [options]\n" + " --registry PATH path to UNDF-REGISTRY.json (default: ./UNDF-REGISTRY.json)\n" + " --defects PATH path to defects/ directory (default: ./defects)\n" + " --target PATH directory to scan (default: /home/fox/git)\n" + " --threads N worker threads (default: nproc)\n" + " --output PATH write JSON report to file (default: stdout)\n" + " --format text|json report format (default: json)\n" + " --filter UNDF only scan specific UNDF ID\n" + " --verbose verbose logging to stderr\n" + "\n" + "Exit codes: 0=success, 1=error, 2=unpatched defects found\n", + prog); +} + +int main(int argc, char *argv[]) { + /* Parse arguments */ + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "--registry") == 0 && i+1 < argc) + strncpy(g_registry_path, argv[++i], MAX_PATH_LEN-1); + else if (strcmp(argv[i], "--defects") == 0 && i+1 < argc) + strncpy(g_defects_path, argv[++i], MAX_PATH_LEN-1); + else if (strcmp(argv[i], "--target") == 0 && i+1 < argc) + strncpy(g_target_dir, argv[++i], MAX_PATH_LEN-1); + else if (strcmp(argv[i], "--threads") == 0 && i+1 < argc) + g_threads = atoi(argv[++i]); + else if (strcmp(argv[i], "--output") == 0 && i+1 < argc) + strncpy(g_output_path, argv[++i], MAX_PATH_LEN-1); + else if (strcmp(argv[i], "--format") == 0 && i+1 < argc) { + if (strcmp(argv[i+1], "text") == 0) g_format_text = 1; + i++; + } + else if (strcmp(argv[i], "--filter") == 0 && i+1 < argc) + strncpy(g_filter_undf, argv[++i], 63); + else if (strcmp(argv[i], "--verbose") == 0) + g_verbose = 1; + else if (strcmp(argv[i], "--help") == 0 || strcmp(argv[i], "-h") == 0) { + usage(argv[0]); + return 0; + } else { + fprintf(stderr, "Unknown option: %s\n", argv[i]); + usage(argv[0]); + return 1; + } + } + + /* Determine thread count */ + if (g_threads <= 0) { + long nproc = sysconf(_SC_NPROCESSORS_ONLN); + g_threads = (nproc > 0) ? (int)nproc : 4; + } + if (g_threads > 64) g_threads = 64; + + fprintf(stderr, "undfscand v1.0.0 — target=%s threads=%d\n", + g_target_dir, g_threads); + + /* Parse registry */ + if (parse_registry(g_registry_path) < 0) return 1; + if (g_n_defects == 0) { + fprintf(stderr, "ERROR: no defect entries parsed from registry\n"); + return 1; + } + fprintf(stderr, "Registry: %d defects\n", g_n_defects); + + /* Initialize result slots */ + for (int i = 0; i < g_n_defects; i++) { + memset(&g_results[i], 0, sizeof(g_results[i])); + strncpy(g_results[i].undf_id, g_defects[i].undf_id, 63); + strncpy(g_results[i].defect_id, g_defects[i].defect_id, 127); + g_results[i].status = STATUS_UNKNOWN; + } + + /* Enqueue jobs */ + for (int i = 0; i < g_n_defects; i++) { + /* Apply filter if set */ + if (g_filter_undf[0] && strcmp(g_defects[i].undf_id, g_filter_undf) != 0) + continue; + queue_push(i); + } + queue_signal_done(); + + /* Launch worker threads */ + pthread_t *threads = malloc(g_threads * sizeof(pthread_t)); + if (!threads) { perror("malloc"); return 1; } + for (int i = 0; i < g_threads; i++) { + if (pthread_create(&threads[i], NULL, worker_thread, NULL) != 0) { + perror("pthread_create"); return 1; + } + } + + /* Wait for all workers */ + for (int i = 0; i < g_threads; i++) + pthread_join(threads[i], NULL); + free(threads); + + /* Write report */ + FILE *out = stdout; + if (g_output_path[0]) { + out = fopen(g_output_path, "w"); + if (!out) { perror("fopen output"); return 1; } + } + write_report(out); + if (g_output_path[0]) fclose(out); + + /* Summary to stderr */ + int unpatched = 0; + for (int i = 0; i < g_n_defects; i++) + if (g_results[i].status == STATUS_UNPATCHED) unpatched++; + + fprintf(stderr, "Scan complete.\n"); + if (g_output_path[0]) + fprintf(stderr, "Report written to: %s\n", g_output_path); + + return (unpatched > 0) ? 2 : 0; +}