Skip to content

Commit 83a6c04

Browse files
drbergmanclaude
andauthored
Fix substrate csv out-of-bounds reads, revive the headerless path, require well-formed rows (#66)
* Bounds-check substrate IC csv rows before indexing them get_row_from_substrate_initial_condition_csv indexed data[0..2] and data[ci + 3] without consulting data.size(). Both reads are out of bounds for a malformed row: - substrate_csv_to_vector always emits a final field, so a blank line parses to a single 0.0 and data[1]/data[2] read past the end. - substrate_indices is sized from the header's column count, so a row with fewer columns than the header runs data[ci + 3] off the end. Reproduced with libc++ bounds checking on a 4-voxel microenvironment: a trailing blank line, a whitespace-only line, a CRLF blank line, a 2-column row, and a row supplying 1 of 3 header substrates all abort with "vector[] index out of bounds" before this change and are handled cleanly after it. Note these reads are not new here -- development crashes on the same five inputs. Its coverage check ran after the read loop, so it never guarded them. Blank lines are now skipped, matching load_cells_csv_v1 and process_csv_v2_line, and a short row is a hard error rather than a silent partial write. Ordering the guards ahead of the existing warning also stops data.size() - 3 from underflowing on a short row. Also correct that warning: it reported number_of_voxels() where it means number_of_densities(), and the block comment still described the one-row-per-voxel, no-header format this branch replaced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Revive the headerless csv path and require well-formed rows The headerless branch of load_initial_conditions_from_csv could never have worked. Two bugs had to be fixed together to see either one: - "if (i<3) {continue;}" jumped past the "i++" below it, so i never advanced and substrate_indices came out empty. - the reopened "std::ifstream file(filename, ...)" shadowed the enclosing stream, which had just been closed, so the row loop read a closed stream and processed no rows. -Wshadow flags this. A headerless csv therefore loaded nothing. On development that at least failed loudly, because the deleted coverage check saw voxel_set.size() == 0 and exited; without it the run continued silently on the config file's uniform initial conditions. Reading the first row's column count and rewinding the stream fixes both, and the row loop now counts lines so every diagnostic can name the row it rejected. Rows are now held to being well formed rather than parsed as far as they go: - a field must be empty or a complete finite number. strtod's endptr was discarded, so "NA" and "1.5abc" silently became 0 and "inf" became a density. - a row must have exactly the column count the header (or the first row) established. Extra columns were silently dropped. - x, y and z must all be present, so ",,,," no longer resolves to the origin. - a position must lie inside the domain, since nearest_voxel_index clamps and would otherwise snap a typo onto an edge voxel. - a header may not name the same substrate twice, and must name at least one. - an empty file is an error rather than a header sniff on nothing. An omitted entry now travels as NaN instead of 0, which is what lets the row loop tell "the user left this blank" from "the user asked for zero". It still resolves to 0, as before; the distinction only makes the checks above possible. The header sniff also no longer indexes line.c_str()[2] and [4] without knowing they exist -- a first line of "x" read past the end. It splits the row and compares fields instead, which also makes the sniff whitespace tolerant. Verified against real BioFVM on a 2x2x1 microenvironment with libc++ bounds checking: 36 cases covering header/headerless, subsets, reordering, whitespace, CRLF, blank lines, and every rejection above. All 36 behave as intended with no out-of-bounds reads. Headerless files load correctly for the first time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent f6f4d2c commit 83a6c04

4 files changed

Lines changed: 195 additions & 86 deletions

File tree

‎BioFVM/BioFVM_microenvironment.cpp‎

Lines changed: 144 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1450,12 +1450,44 @@ void load_initial_conditions_from_matlab(std::string filename)
14501450
return;
14511451
}
14521452

1453+
// split a csv row into trimmed fields. A row of n commas always yields n+1 fields, matching how
1454+
// substrate_csv_to_vector counts them, so a header and its data rows are measured the same way.
1455+
static std::vector<std::string> split_substrate_csv_row(const std::string &line)
1456+
{
1457+
std::vector<std::string> fields;
1458+
std::size_t start = 0;
1459+
while (true)
1460+
{
1461+
std::size_t comma = line.find(',', start);
1462+
std::size_t end = (comma == std::string::npos) ? line.size() : comma;
1463+
std::size_t first = line.find_first_not_of(" \t\r", start);
1464+
std::string field; // stays empty when the field is empty or all whitespace
1465+
if (first != std::string::npos && first < end)
1466+
{
1467+
std::size_t last = line.find_last_not_of(" \t\r", end - 1);
1468+
field = line.substr(first, last - first + 1);
1469+
}
1470+
fields.push_back(field);
1471+
if (comma == std::string::npos)
1472+
{ return fields; }
1473+
start = comma + 1;
1474+
}
1475+
}
1476+
1477+
static bool is_csv_label(const std::string &field, const char lower, const char upper)
1478+
{ return field.size() == 1 && (field[0] == lower || field[0] == upper); }
1479+
14531480
void load_initial_conditions_from_csv(std::string filename)
14541481
{
1455-
// The .csv file needs to contain one row per voxel.
1456-
// Each row is a vector of values as follows: [x coord, y coord, z coord, substrate id 0 value, substrate id 1 value, ...]
1457-
// Thus, your table should be of size #voxels x (3 + #densities) (rows x columns)
1458-
// Do not include a header row.
1482+
// Each row locates one voxel and sets substrate densities in it:
1483+
// [x coord, y coord, z coord, value, value, ...]
1484+
// An optional header row "x,y,z,<substrate name>,..." names the substrates being set, which may be
1485+
// any subset of the densities, in any order. Without a header, the columns after x,y,z are taken to
1486+
// be the first n densities in index order.
1487+
// Rows need not cover every voxel: a voxel with no row keeps the initial condition from the config file.
1488+
// Within a row, an entry may be omitted by leaving the field empty (e.g. "0,0,0,,3.5"), which sets 0.
1489+
// Every row must otherwise be well formed -- the same column count as the header (or as the first row),
1490+
// a position inside the domain, and a finite number in every field that is not empty.
14591491

14601492
// open file
14611493
std::ifstream file( filename, std::ios::in );
@@ -1465,102 +1497,168 @@ void load_initial_conditions_from_csv(std::string filename)
14651497
exit(-1);
14661498
}
14671499

1468-
// determine if header row exists
14691500
std::string line;
1470-
std::getline( file , line );
1501+
if( !std::getline( file , line ) )
1502+
{
1503+
std::cout << "ERROR: " << filename << " is empty." << std::endl
1504+
<< "\tIt must contain at least one row of substrate initial conditions." << std::endl;
1505+
file.close();
1506+
exit(-1);
1507+
}
14711508
trim_cr(line);
1472-
char c = line.c_str()[0];
1509+
1510+
// determine if header row exists
1511+
std::vector<std::string> first_row = split_substrate_csv_row(line);
1512+
bool header_provided = is_csv_label(first_row[0], 'x', 'X'); // split always returns at least one field
14731513
std::vector<int> substrate_indices;
1474-
bool header_provided = false;
1475-
if( c == 'X' || c == 'x' )
1514+
1515+
if( header_provided )
14761516
{
1477-
if ((line.c_str()[2] != 'Y' && line.c_str()[2] != 'y') || (line.c_str()[4] != 'Z' && line.c_str()[4] != 'z'))
1517+
if( first_row.size() < 3 || !is_csv_label(first_row[1], 'y', 'Y') || !is_csv_label(first_row[2], 'z', 'Z') )
14781518
{
14791519
std::cout << "ERROR: Header row starts with x but then not y,z? What is this? Exiting now." << std::endl;
14801520
file.close();
14811521
exit(-1);
14821522
}
1483-
std::vector< std::string> column_names; // this will include x,y,z (so make sure to skip those below)
1484-
std::stringstream stream(line);
1485-
std::string field;
1486-
1487-
while (std::getline(stream, field, ','))
1523+
if( first_row.size() < 4 )
14881524
{
1489-
column_names.push_back(field);
1525+
std::cout << "ERROR: The header row of " << filename << " names no substrates." << std::endl
1526+
<< "\tExpected a row like \"x,y,z,[substrate_i0,substrate_i1]\"" << std::endl;
1527+
file.close();
1528+
exit(-1);
14901529
}
1491-
for (int i = 3; i<column_names.size(); i++) // skip x,y,z by starting at 3, not 0
1530+
for (unsigned int i = 3; i<first_row.size(); i++) // skip x,y,z by starting at 3, not 0
14921531
{
1493-
int substrate_index = microenvironment.find_density_index(column_names[i]);
1532+
int substrate_index = microenvironment.find_density_index(first_row[i]);
14941533
if (substrate_index == -1)
14951534
{
1496-
std::cout << "ERROR: Substrate " << column_names[i] << " not found in the BioFVM microenvironment. Exiting now." << std::endl;
1535+
std::cout << "ERROR: Substrate " << first_row[i] << " not found in the BioFVM microenvironment. Exiting now." << std::endl;
14971536
file.close();
14981537
exit(-1);
14991538
}
1500-
substrate_indices.push_back(microenvironment.find_density_index(column_names[i]));
1539+
for (unsigned int j = 0; j < substrate_indices.size(); j++)
1540+
{
1541+
if (substrate_indices[j] == substrate_index) // two columns writing one density would silently race
1542+
{
1543+
std::cout << "ERROR: Substrate " << first_row[i] << " appears more than once in the header row of " << filename << ". Exiting now." << std::endl;
1544+
file.close();
1545+
exit(-1);
1546+
}
1547+
}
1548+
substrate_indices.push_back(substrate_index);
15011549
}
1502-
header_provided = true;
15031550
}
15041551
else // no column labels given; just assume that the first n substrates are supplied (n = # columns after x,y,z)
15051552
{
1506-
std::stringstream stream(line);
1507-
std::string field;
1508-
int i = 0;
1509-
while (std::getline(stream, field, ','))
1553+
if( first_row.size() < 4 )
15101554
{
1511-
if (i<3) {continue;} // skip (x,y,z)
1512-
substrate_indices.push_back(i-3); // the substrate index is the column index - 3 (since x,y,z are the first 3 columns)
1513-
i++;
1555+
std::cout << "ERROR: The first row of " << filename << " has " << first_row.size() << " column(s)." << std::endl
1556+
<< "\tExpected at least 4: x, y, z and one substrate value." << std::endl;
1557+
file.close();
1558+
exit(-1);
15141559
}
1515-
// in this case, we want to read this first line, so close the file and re-open so that we start with this line
1516-
file.close();
1517-
std::ifstream file(filename, std::ios::in);
1518-
std::getline(file, line);
1519-
trim_cr(line);
1560+
unsigned int number_supplied = first_row.size() - 3; // the substrate index is the column index - 3
1561+
if( number_supplied > microenvironment.number_of_densities() )
1562+
{
1563+
std::cout << "ERROR: The first row of " << filename << " supplies " << number_supplied << " density values," << std::endl
1564+
<< "\tbut the BioFVM microenvironment has only " << microenvironment.number_of_densities() << "." << std::endl
1565+
<< "\tRemember, save your csv with columns as: x, y, z, substrate_0, substrate_1,...." << std::endl;
1566+
file.close();
1567+
exit(-1);
1568+
}
1569+
if( number_supplied != microenvironment.number_of_densities() )
1570+
{
1571+
std::cout << "WARNING: " << filename << " supplies " << number_supplied << " of the "
1572+
<< microenvironment.number_of_densities() << " substrate densities," << std::endl
1573+
<< "\tso the first " << number_supplied << " are assumed." << std::endl
1574+
<< "\tThis could be resolved by including a header row \"x,y,z,[substrate_i0,substrate_i1]\"" << std::endl;
1575+
}
1576+
for( unsigned int i = 0; i < number_supplied; i++ )
1577+
{ substrate_indices.push_back(i); }
1578+
1579+
// the first row is data, not labels, so rewind and let the loop below read it again
1580+
file.clear();
1581+
file.seekg(0, std::ios::beg);
15201582
}
15211583

15221584
std::cout << "Loading substrate initial conditions from CSV file " << filename << " ... " << std::endl;
15231585
std::vector<bool> voxel_is_set(microenvironment.number_of_voxels(), false); // set to check that no voxel value is set twice
15241586

1587+
unsigned int line_number = header_provided ? 1 : 0; // only a header leaves the first line already consumed
15251588
while (std::getline(file, line))
15261589
{
1590+
line_number++;
15271591
trim_cr(line);
1528-
get_row_from_substrate_initial_condition_csv(voxel_is_set, line, substrate_indices, header_provided);
1592+
get_row_from_substrate_initial_condition_csv(voxel_is_set, line, substrate_indices, line_number);
15291593
}
15301594

15311595
file.close();
15321596

15331597
return;
15341598
}
15351599

1536-
void get_row_from_substrate_initial_condition_csv(std::vector<bool> &voxel_is_set, const std::string line, const std::vector<int> substrate_indices, const bool header_provided)
1600+
void get_row_from_substrate_initial_condition_csv(std::vector<bool> &voxel_is_set, const std::string &line, const std::vector<int> &substrate_indices, const unsigned int line_number)
15371601
{
1538-
static bool warning_issued = false;
1602+
if (line.find_first_not_of(" \t\r") == std::string::npos)
1603+
{ return; } // skip blank lines
1604+
15391605
std::vector<double> data;
1540-
substrate_csv_to_vector(line.c_str(), data);
1606+
unsigned int bad_field = substrate_csv_to_vector(line.c_str(), data);
1607+
if (bad_field != 0)
1608+
{
1609+
std::cout << "ERROR : Column " << bad_field << " of line " << line_number << " of the .csv file specifying BioFVM initial conditions is not a number." << std::endl
1610+
<< "\tEvery column must hold a finite number, or nothing at all to omit a substrate value." << std::endl
1611+
<< "\tOffending row: " << line << std::endl;
1612+
exit(-1);
1613+
}
15411614

1542-
if (!(warning_issued) && !(header_provided) && (data.size() != (microenvironment.number_of_densities() + 3)))
1615+
// holding every row to the expected column count is also what keeps every data[...] access below in bounds
1616+
if (data.size() != substrate_indices.size() + 3)
15431617
{
1544-
std::cout << "WARNING: Wrong number of density values supplied in the .csv file specifying BioFVM initial conditions." << std::endl
1545-
<< "\tExpected: " << microenvironment.number_of_voxels() << std::endl
1546-
<< "\tFound: " << data.size() - 3 << std::endl
1547-
<< "\tRemember, save your csv with columns as: x, y, z, substrate_0, substrate_1,...." << std::endl
1548-
<< "\tThis could also be resolved by including a header row \"x,y,z,[substrate_i0,substrate_i1]\"" << std::endl;
1549-
warning_issued = true;
1618+
std::cout << "ERROR : Line " << line_number << " of the .csv file specifying BioFVM initial conditions has the wrong number of columns." << std::endl
1619+
<< "\tExpected: " << substrate_indices.size() + 3 << " (x, y, z and " << substrate_indices.size() << " substrate value(s))" << std::endl
1620+
<< "\tFound: " << data.size() << std::endl
1621+
<< "\tTo omit a value, leave the field empty but keep the comma (e.g. x,y,z,,3.5)." << std::endl
1622+
<< "\tOffending row: " << line << std::endl;
1623+
exit(-1);
1624+
}
1625+
1626+
for (unsigned int i = 0; i < 3; i++) // a position cannot be omitted the way a density value can
1627+
{
1628+
if (std::isnan(data[i]))
1629+
{
1630+
std::cout << "ERROR : Line " << line_number << " of the .csv file specifying BioFVM initial conditions omits an x, y or z coordinate." << std::endl
1631+
<< "\tOffending row: " << line << std::endl;
1632+
exit(-1);
1633+
}
15501634
}
15511635

15521636
std::vector<double> position = {data[0], data[1], data[2]};
1637+
if (!microenvironment.mesh.is_position_valid(position[0], position[1], position[2])) // otherwise it would silently snap to an edge voxel
1638+
{
1639+
std::cout << "ERROR : Line " << line_number << " of the .csv file specifying BioFVM initial conditions lies outside the microenvironment domain." << std::endl
1640+
<< "\tPosition: " << position << std::endl
1641+
<< "\tDomain: x in [" << microenvironment.mesh.bounding_box[0] << ", " << microenvironment.mesh.bounding_box[3] << "]"
1642+
<< ", y in [" << microenvironment.mesh.bounding_box[1] << ", " << microenvironment.mesh.bounding_box[4] << "]"
1643+
<< ", z in [" << microenvironment.mesh.bounding_box[2] << ", " << microenvironment.mesh.bounding_box[5] << "]" << std::endl;
1644+
exit(-1);
1645+
}
1646+
15531647
int voxel_ind = microenvironment.mesh.nearest_voxel_index(position);
15541648
if (voxel_is_set[voxel_ind])
15551649
{
15561650
std::cout << "ERROR : the csv-supplied initial conditions for BioFVM repeat the same voxel. Fix the .csv file and try again." << std::endl
1557-
<< "\tPosition that was repeated: " << position << std::endl;
1651+
<< "\tPosition that was repeated: " << position << std::endl
1652+
<< "\tRepeated on line: " << line_number << std::endl;
15581653
exit(-1);
15591654
}
15601655
voxel_is_set[voxel_ind] = true;
15611656
for (unsigned int ci = 0; ci < substrate_indices.size(); ci++) // column index, counting from the first substrate (or just the index of the vector substrate_indices)
15621657
{
1563-
microenvironment.density_vector(voxel_ind)[substrate_indices[ci]] = data[ci + 3];
1658+
double value = data[ci + 3];
1659+
if (std::isnan(value))
1660+
{ value = 0.0; } // an entry the row omitted
1661+
microenvironment.density_vector(voxel_ind)[substrate_indices[ci]] = value;
15641662
}
15651663
}
15661664

‎BioFVM/BioFVM_microenvironment.h‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -367,7 +367,7 @@ void set_microenvironment_initial_condition( void );
367367

368368
void load_initial_conditions_from_matlab( std::string filename );
369369
void load_initial_conditions_from_csv( std::string filename );
370-
void get_row_from_substrate_initial_condition_csv(std::vector<bool> &voxel_is_set, const std::string line, const std::vector<int> substrate_indices, const bool header_provided);
370+
void get_row_from_substrate_initial_condition_csv(std::vector<bool> &voxel_is_set, const std::string &line, const std::vector<int> &substrate_indices, const unsigned int line_number);
371371
};
372372

373373
#endif

‎BioFVM/BioFVM_vector.cpp‎

Lines changed: 47 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@
4747
*/
4848

4949
#include "BioFVM_vector.h"
50+
#include <cctype>
51+
#include <limits>
5052

5153
/* some global BioFVM strings */
5254

@@ -376,44 +378,51 @@ void csv_to_vector( const char* buffer , std::vector<double>& vect )
376378
return;
377379
}
378380

379-
void substrate_csv_to_vector(const char* buffer, std::vector<double>& vect)
380-
{
381-
vect.clear();
382-
383-
const char* start = buffer;
384-
const char* end = buffer;
385-
386-
while (*end != '\0')
387-
{
388-
if (*end == ',')
389-
{
390-
if (start == end)
391-
{
392-
// Empty field: treat as 0
393-
vect.push_back(0.0);
394-
}
395-
else
396-
{
397-
vect.push_back(strtod(start, nullptr));
398-
}
399-
end++;
400-
start = end;
401-
}
402-
else
403-
{
404-
end++;
405-
}
406-
}
407-
408-
// Handle the last field (could be empty at end of line)
409-
if (start == end)
410-
{
411-
vect.push_back(0.0);
412-
}
413-
else
414-
{
415-
vect.push_back(strtod(start, nullptr));
416-
}
381+
// Parse one row of a substrate initial-condition csv into vect. A row of n commas always yields
382+
// n+1 values, so that a caller can hold every row to the same column count.
383+
//
384+
// A field that is empty or all whitespace yields NaN, which lets a caller tell an entry the row
385+
// omitted from one it set to zero. Every other field must parse completely as a finite number, so
386+
// that a typo cannot pass silently as a value: "1.5abc", "NA" and "inf" are all rejected.
387+
//
388+
// Returns the 1-based index of the first field that is neither, or 0 if the whole row is well formed.
389+
unsigned int substrate_csv_to_vector(const char* buffer, std::vector<double>& vect)
390+
{
391+
vect.clear();
392+
393+
const char* start = buffer;
394+
unsigned int field_number = 0;
395+
396+
while (true)
397+
{
398+
const char* end = start;
399+
while (*end != '\0' && *end != ',')
400+
{ end++; }
401+
field_number++;
402+
403+
// trim the field, so that " 1.5 " reads as 1.5 and " " reads as omitted
404+
const char* first = start;
405+
const char* last = end;
406+
while (first < last && isspace((unsigned char) *first))
407+
{ first++; }
408+
while (last > first && isspace((unsigned char) *(last - 1)))
409+
{ last--; }
410+
411+
if (first == last) // the row omitted this entry
412+
{ vect.push_back(std::numeric_limits<double>::quiet_NaN()); }
413+
else
414+
{
415+
char* parse_end;
416+
double value = strtod(first, &parse_end);
417+
if (parse_end != last || !std::isfinite(value)) // trailing garbage, not a number, or not finite
418+
{ return field_number; }
419+
vect.push_back(value);
420+
}
421+
422+
if (*end == '\0')
423+
{ return 0; }
424+
start = end + 1;
425+
}
417426
}
418427

419428
char* vector_to_csv( const std::vector<double>& vect )

‎BioFVM/BioFVM_vector.h‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,9 @@ void double_axpy_div( std::vector<double>* y, std::vector<double>& a1 , std::vec
130130
// turn a delimited character array (e.g., csv) into a vector of doubles
131131

132132
void csv_to_vector( const char* buffer , std::vector<double>& vect );
133-
void substrate_csv_to_vector(const char* buffer, std::vector<double>& vect);
133+
// returns the 1-based index of the first malformed field, or 0 if the row is well formed;
134+
// an empty field yields NaN so that an omitted entry is distinguishable from a zero
135+
unsigned int substrate_csv_to_vector(const char* buffer, std::vector<double>& vect);
134136

135137
char* vector_to_csv( const std::vector<double>& vect );
136138
void vector_to_csv_safe( const std::vector<double>& vect , char*& buffer );

0 commit comments

Comments
 (0)