6c676a0e9ed642676008731762141e0c16822787
braney
  Sat Aug 22 09:31:14 2026 -0700
vcf: tighten validation of the genotype allele index, refs #38155

New parseAlleleIx returns missing data for a GT allele index that the record
has no allele for, and for a value that will not fit the field.  It checks the
value as an int, before it reaches the field.  vcfParseGenotypes and
vcfParseGenotypesGtOnly both go through it, and a check after the per-genotype
loop covers the PL and SGT fallbacks, which choose an index without consulting
the allele count.

hapIxA and hapIxB become signed char.  Plain char is unsigned on some
architectures, and the negative missing-data value did not stay negative there.
Size and field offsets are unchanged, and x86 code generation is identical.

Adds a lib/tests case for malformed and boundary genotype indexes.

diff --git src/lib/tests/vcfParseTest.c src/lib/tests/vcfParseTest.c
index 0050e16f9d0..9ba0f25959e 100644
--- src/lib/tests/vcfParseTest.c
+++ src/lib/tests/vcfParseTest.c
@@ -8,60 +8,96 @@
 #include "options.h"
 #include "sqlNum.h"
 #include "vcf.h"
 
 
 void usage()
 /* Explain usage and exit. */
 {
 errAbort(
   "vcfParseTest - Parse VCF header and data lines in given position range.\n"
   "usage:\n"
   "   vcfParseTest fileOrUrl.vcf.gz seqName start end\n"
   "options:\n"
   "   -headerOnly  print header summary only (version, def counts, sample IDs)\n"
   "                and skip the position-range parse.  seqName/start/end are ignored.\n"
+  "   -genotypes   print the parsed allele index of each genotype, and the allele\n"
+  "                counts tallied from them.\n"
   "\n"
   "fileOrUrl.vcf.gz needs to have been compressed by tabix, and index file\n"
   "fileOrUrl.vcf.gz.tbi must exist.\n"
   );
 }
 
 static struct optionSpec options[] = {
    {"headerOnly", OPTION_BOOLEAN},
+   {"genotypes", OPTION_BOOLEAN},
    {NULL, 0},
 };
 
 static void vcfHeaderTest(char *fileOrUrl)
 /* Open via tabix path and dump header summary -- regression-tests that the
  * VCF header is actually read through the tabix iterator code path. */
 {
 struct vcfFile *vcff = vcfTabixFileMayOpen(fileOrUrl, NULL, 0, 0, 100, -1);
 if (vcff == NULL)
     errAbort("Failed to open \"%s\"", fileOrUrl);
 printf("file: %s\n", fileOrUrl);
 printf("version: %d.%d\n", vcff->majorVersion, vcff->minorVersion);
 printf("infoDefs: %d\n", slCount(vcff->infoDefs));
 printf("filterDefs: %d\n", slCount(vcff->filterDefs));
 printf("gtFormatDefs: %d\n", slCount(vcff->gtFormatDefs));
 printf("genotypeCount: %d\n", vcff->genotypeCount);
 int i;
 for (i = 0;  i < vcff->genotypeCount && i < 5;  i++)
     printf("genotypeId[%d]: %s\n", i, vcff->genotypeIds[i]);
 vcfFileFree(&vcff);
 }
 
+static void vcfGenotypeTest(char *fileOrUrl, char *seqName, int start, int end)
+/* Dump the allele index that each genotype parsed to, and the allele counts tallied from
+ * those indexes.  An index that the record has no allele for should come out as missing
+ * data (-1). */
+{
+struct vcfFile *vcff = vcfTabixFileMayOpen(fileOrUrl, seqName, start, end, 100, -1);
+if (vcff == NULL)
+    errAbort("Failed to parse \"%s\" and/or its index file \"%s.tbi\"", fileOrUrl, fileOrUrl);
+struct vcfRecord *rec;
+for (rec = vcff->records;  rec != NULL;  rec = rec->next)
+    {
+    vcfParseGenotypes(rec);
+    printf("%s\t%d\t%s\talleleCount=%d\n", rec->chrom, rec->chromStart, rec->name,
+           rec->alleleCount);
+    int i;
+    for (i = 0;  i < vcff->genotypeCount;  i++)
+        {
+        struct vcfGenotype *gt = &(rec->genotypes[i]);
+        printf("  %s\thapIxA=%d\thapIxB=%d\tisHaploid=%d\tisPhased=%d\n",
+               vcff->genotypeIds[i], gt->hapIxA, gt->hapIxB, gt->isHaploid, gt->isPhased);
+        }
+    int *gtCounts = NULL, *alCounts = NULL, phasedCount = 0, diploidCount = 0;
+    vcfCountGenotypes(rec, &gtCounts, &alCounts, &phasedCount, &diploidCount);
+    printf("  alleleCounts (last is missing data):");
+    for (i = 0;  i <= rec->alleleCount;  i++)
+        printf(" %d", alCounts[i]);
+    printf("\n  phased=%d diploid=%d\n", phasedCount, diploidCount);
+    freeMem(gtCounts);
+    freeMem(alCounts);
+    }
+vcfFileFree(&vcff);
+}
+
 void vcfParseTest(char *fileOrUrl, char *seqName, int start, int end)
 /* vcfParseTest - Parse VCF header and data lines in given position range.. */
 {
 struct vcfFile *vcff = vcfTabixFileMayOpen(fileOrUrl, seqName, start, end, 100, -1);
 if (vcff == NULL)
     errAbort("Failed to parse \"%s\" and/or its index file \"%s.tbi\"", fileOrUrl, fileOrUrl);
 int recCount = slCount(vcff->records);
 printf("Finished parsing \"%s\" items in %s:%d-%d, got %d data rows\n",
        fileOrUrl, seqName, start+1, end, recCount);
 if (recCount > 0)
     printf("First (up to) 100 rows in range:\n");
 int i = 0;
 struct vcfRecord *rec = vcff->records;
 while (rec != NULL && i < 100)
     {
@@ -75,18 +111,21 @@
 }
 
 int main(int argc, char *argv[])
 /* Process command line. */
 {
 optionInit(&argc, argv, options);
 if (optionExists("headerOnly"))
     {
     if (argc != 2)
 	usage();
     vcfHeaderTest(argv[1]);
     return 0;
     }
 if (argc != 5)
     usage();
+if (optionExists("genotypes"))
+    vcfGenotypeTest(argv[1], argv[2], sqlUnsigned(argv[3]), sqlUnsigned(argv[4]));
+else
     vcfParseTest(argv[1], argv[2], sqlUnsigned(argv[3]), sqlUnsigned(argv[4]));
 return 0;
 }