Commit 0d599d96 for tesseract

commit 0d599d9607f3b8a50fbb606b2ec7ea9dcfabd8bb
Author: Stefan Weil <sw@weilnetz.de>
Date:   Tue Aug 25 21:55:54 2026 +0200

    Make IndexMapBiDi index handling bounds-safe

    IndexMapBiDi::DeSerialize used the compact and sparse indices read
    from the file directly as subscripts of sparse_map_, so a crafted or
    corrupt .traineddata with an out-of-range value performed a heap
    out-of-bounds write during deserialization. DeSerialize now validates
    every index before use and rejects such data, including
    remaining_pairs with an odd element count, which previously read one
    element past the end of the vector. It also rejects files in which a
    sparse slot is claimed by more than one compact representative or
    remaining pair: such a file can encode a master cycle (for example
    sparse_map_ = [1, 0]) that would make MasterCompactIndex loop forever.
    Copilot's review of this PR suggested the duplicate-claim check and
    the empty base-map test case.

    The public index accessors had the same unchecked-subscript pattern:
    IndexMapBiDi::SparseToCompact and IndexMap::CompactToSparse read
    outside their vectors for out-of-range indices, Merge subscripted both
    maps with unchecked compact indices, MapFeatures read sparse_map_ with
    unchecked feature indices, and IsCompactDeleted could chase an
    out-of-range master index. They now return the not-mapped / not-merged
    / missed-feature result for out-of-range input instead of invoking
    undefined behavior. The Merge(-1, index) merge-away sentinel used by
    IntFeatureMap is still accepted. IndexMap::SparseToCompact no longer
    reads compact_map_ when the map is empty.

    Key changes:
    - indexmapbidi.cpp: validate compact_map_ entries and remaining_pairs
      in DeSerialize before subscripting sparse_map_, including duplicate
      sparse slot claims that could encode a master cycle; guard Merge and
      MapFeatures; reject an empty compact_map_ in IndexMap::SparseToCompact.
    - indexmapbidi.h: bounds-check IndexMap::CompactToSparse,
      IndexMapBiDi::SparseToCompact and IsCompactDeleted.
    - unittest: add DeSerializeRejectsBadIndices (crafted blobs with
      out-of-range, negative, odd-count, duplicate-claim and cyclic
      indices) and AccessorsRejectBadIndices, which also covers the
      empty-map guards in both SparseToCompact implementations, plus a
      many-to-one serialize/deserialize round trip as a positive control.
      On unpatched code the new tests die on ASan heap-buffer-overflow
      write in DeSerialize and reads in SparseToCompact.

    Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
    Assisted-by: OpenCode / qwen3.8-27b-thinking (Alibaba Cloud)
    Signed-off-by: Stefan Weil <sw@weilnetz.de>

diff --git a/src/ccutil/indexmapbidi.cpp b/src/ccutil/indexmapbidi.cpp
index fb5d20e6..d5a8e2f3 100644
--- a/src/ccutil/indexmapbidi.cpp
+++ b/src/ccutil/indexmapbidi.cpp
@@ -31,6 +31,9 @@ IndexMap::~IndexMap() = default;
 // Uses a binary search to find the result. For faster speed use
 // IndexMapBiDi, but that takes more memory.
 int IndexMap::SparseToCompact(int sparse_index) const {
+  if (compact_map_.empty()) {
+    return -1;
+  }
   auto pos = std::upper_bound(compact_map_.begin(), compact_map_.end(), sparse_index);
   if (pos > compact_map_.begin()) {
     --pos;
@@ -142,6 +145,16 @@ void IndexMapBiDi::CopyFrom(const IndexMapBiDi &src) {
 // the merges must be concluded by a call to CompleteMerges.
 // Returns true if a merge was actually performed.
 bool IndexMapBiDi::Merge(int compact_index1, int compact_index2) {
+  // A compact index may be -1 (meaning "merge away") or in [0, compact size).
+  const bool index1_ok =
+      compact_index1 == -1 ||
+      (compact_index1 >= 0 && static_cast<size_t>(compact_index1) < compact_map_.size());
+  const bool index2_ok =
+      compact_index2 == -1 ||
+      (compact_index2 >= 0 && static_cast<size_t>(compact_index2) < compact_map_.size());
+  if (!index1_ok || !index2_ok) {
+    return false;
+  }
   // Find the current master index for index1 and index2.
   compact_index1 = MasterCompactIndex(compact_index1);
   compact_index2 = MasterCompactIndex(compact_index2);
@@ -241,14 +254,42 @@ bool IndexMapBiDi::DeSerialize(bool swap, FILE *fp) {
   if (!tesseract::DeSerialize(swap, fp, remaining_pairs)) {
     return false;
   }
+  // The indices in the file are untrusted. Validate them before using them
+  // as subscripts, so that corrupt or crafted data is rejected instead of
+  // writing outside sparse_map_ (or leaving invalid compact indices behind).
+  // Each sparse slot may be claimed by at most one compact representative
+  // or one remaining pair, as in any map produced by Setup/CompleteMerges;
+  // duplicate claims would let a crafted file encode a master cycle that
+  // makes MasterCompactIndex loop forever.
+  const size_t sparse_size = static_cast<size_t>(sparse_size_);
+  std::vector<uint8_t> claimed(sparse_size, 0);
+  for (int32_t sparse_index : compact_map_) {
+    if (sparse_index < 0 || static_cast<size_t>(sparse_index) >= sparse_size ||
+        claimed[sparse_index]) {
+      return false;
+    }
+    claimed[sparse_index] = 1;
+  }
+  if (remaining_pairs.size() % 2 != 0) {
+    return false;
+  }
+  for (size_t i = 0; i < remaining_pairs.size(); i += 2) {
+    const int32_t sparse_index = remaining_pairs[i];
+    const int32_t compact_index = remaining_pairs[i + 1];
+    if (sparse_index < 0 || static_cast<size_t>(sparse_index) >= sparse_size ||
+        compact_index < 0 || static_cast<size_t>(compact_index) >= compact_map_.size() ||
+        claimed[sparse_index]) {
+      return false;
+    }
+    claimed[sparse_index] = 1;
+  }
   sparse_map_.clear();
-  sparse_map_.resize(sparse_size_, -1);
+  sparse_map_.resize(sparse_size, -1);
   for (unsigned i = 0; i < compact_map_.size(); ++i) {
     sparse_map_[compact_map_[i]] = i;
   }
-  for (size_t i = 0; i < remaining_pairs.size(); ++i) {
-    int sparse_index = remaining_pairs[i++];
-    sparse_map_[sparse_index] = remaining_pairs[i];
+  for (size_t i = 0; i < remaining_pairs.size(); i += 2) {
+    sparse_map_[remaining_pairs[i]] = remaining_pairs[i + 1];
   }
   return true;
 }
@@ -264,7 +305,13 @@ int IndexMapBiDi::MapFeatures(const std::vector<int> &sparse, std::vector<int> *
   int missed_features = 0;
   int prev_good_feature = -1;
   for (int f = 0; f < num_features; ++f) {
-    int feature = sparse_map_[sparse[f]];
+    const int sparse_index = sparse[f];
+    if (sparse_index < 0 || static_cast<size_t>(sparse_index) >= sparse_map_.size()) {
+      // A feature outside the sparse space cannot map to the compact space.
+      ++missed_features;
+      continue;
+    }
+    int feature = sparse_map_[sparse_index];
     if (feature >= 0) {
       if (feature != prev_good_feature) {
         compact->push_back(feature);
diff --git a/src/ccutil/indexmapbidi.h b/src/ccutil/indexmapbidi.h
index e14ad12c..2526b4bc 100644
--- a/src/ccutil/indexmapbidi.h
+++ b/src/ccutil/indexmapbidi.h
@@ -53,6 +53,9 @@ public:
   // CompactToSparse takes a compact index to the corresponding index in the
   // sparse space.
   int CompactToSparse(int compact_index) const {
+    if (compact_index < 0 || static_cast<size_t>(compact_index) >= compact_map_.size()) {
+      return -1;
+    }
     return compact_map_[compact_index];
   }
   // The size of the sparse space.
@@ -130,6 +133,10 @@ public:
   bool Merge(int compact_index1, int compact_index2);
   // Returns true if the given compact index has been deleted.
   bool IsCompactDeleted(int index) const {
+    // An index outside the compact space is not a live compact index.
+    if (index < 0 || static_cast<size_t>(index) >= compact_map_.size()) {
+      return true;
+    }
     return MasterCompactIndex(index) < 0;
   }
   // Completes one or more Merge operations by further compacting the
@@ -138,6 +145,9 @@ public:

   // SparseToCompact takes a sparse index to an index in the compact space.
   int SparseToCompact(int sparse_index) const override {
+    if (sparse_index < 0 || static_cast<size_t>(sparse_index) >= sparse_map_.size()) {
+      return -1;
+    }
     return sparse_map_[sparse_index];
   }
   // The size of the sparse space.
diff --git a/unittest/indexmapbidi_test.cc b/unittest/indexmapbidi_test.cc
index a2502f77..60f1bd9b 100644
--- a/unittest/indexmapbidi_test.cc
+++ b/unittest/indexmapbidi_test.cc
@@ -13,7 +13,9 @@
 #include <cstdio>
 #include <string>

+#include "helpers.h"
 #include "indexmapbidi.h"
+#include "serialis.h"

 #include "include_gunit.h"

@@ -118,4 +120,154 @@ TEST_F(IndexMapBiDiTest, ManyToOne) {
   EXPECT_EQ(1, map.SparseToCompact(11));
 }

+// Writes a raw IndexMapBiDi serialization (sparse size, compact map,
+// remaining pairs) so crafted/invalid data can be fed to DeSerialize.
+static void WriteIndexMapBiDiBlob(const std::string &path, int32_t sparse_size,
+                                  const std::vector<int32_t> &compact_map,
+                                  const std::vector<int32_t> &remaining_pairs) {
+  FILE *fp = fopen(path.c_str(), "wb");
+  ASSERT_TRUE(fp != nullptr);
+  ASSERT_TRUE(tesseract::Serialize(fp, &sparse_size));
+  ASSERT_TRUE(tesseract::Serialize(fp, compact_map));
+  ASSERT_TRUE(tesseract::Serialize(fp, remaining_pairs));
+  fclose(fp);
+}
+
+// Indices read from a serialized map are untrusted. Out-of-range values must
+// be rejected instead of writing outside sparse_map_.
+TEST_F(IndexMapBiDiTest, DeSerializeRejectsBadIndices) {
+  // Positive control: a valid many-to-one map round-trips.
+  IndexMapBiDi valid;
+  valid.Init(13, false);
+  valid.SetMap(2, true);
+  valid.SetMap(4, true);
+  valid.SetMap(7, true);
+  valid.SetMap(9, true);
+  valid.SetMap(11, true);
+  valid.Setup();
+  valid.Merge(valid.SparseToCompact(2), valid.SparseToCompact(9));
+  valid.Merge(valid.SparseToCompact(4), valid.SparseToCompact(11));
+  valid.CompleteMerges();
+  const std::string good = OutputNameToPath("good.indexmap");
+  {
+    FILE *fp = fopen(good.c_str(), "wb");
+    ASSERT_TRUE(fp != nullptr);
+    ASSERT_TRUE(valid.Serialize(fp));
+    fclose(fp);
+  }
+  {
+    FILE *fp = fopen(good.c_str(), "rb");
+    ASSERT_TRUE(fp != nullptr);
+    IndexMapBiDi m;
+    ASSERT_TRUE(m.DeSerialize(false, fp));
+    fclose(fp);
+    EXPECT_EQ(13, m.SparseSize());
+    EXPECT_EQ(3, m.CompactSize());
+    EXPECT_EQ(0, m.SparseToCompact(2));
+    EXPECT_EQ(0, m.SparseToCompact(9));
+  }
+
+  auto reject = [this](const std::string &name, int32_t sparse_size,
+                       const std::vector<int32_t> &compact_map,
+                       const std::vector<int32_t> &remaining_pairs) {
+    const std::string path = OutputNameToPath(name);
+    WriteIndexMapBiDiBlob(path, sparse_size, compact_map, remaining_pairs);
+    FILE *fp = fopen(path.c_str(), "rb");
+    ASSERT_TRUE(fp != nullptr);
+    IndexMapBiDi m;
+    EXPECT_FALSE(m.DeSerialize(false, fp));
+    fclose(fp);
+  };
+
+  // compact_map_ entry outside the sparse space.
+  reject("bad1.indexmap", 2, {5}, {});
+  // Negative compact_map_ entry.
+  reject("bad2.indexmap", 2, {-1}, {});
+  // Remaining pair with a sparse index outside the sparse space.
+  reject("bad3.indexmap", 2, {0}, {5, 0});
+  // Remaining pair with a negative sparse index.
+  reject("bad4.indexmap", 2, {0}, {-1, 0});
+  // Remaining pair with a compact index outside the compact space.
+  reject("bad5.indexmap", 2, {0}, {0, 5});
+  // Remaining pair with a negative compact index.
+  reject("bad6.indexmap", 2, {0}, {0, -1});
+  // Odd number of remaining pairs.
+  reject("bad7.indexmap", 2, {0}, {0});
+  // Cyclic master mapping (0 <-> 1) that would make MasterCompactIndex
+  // loop forever.
+  reject("bad8.indexmap", 2, {0, 1}, {0, 1, 1, 0});
+  // Two compact representatives claiming the same sparse slot.
+  reject("bad9.indexmap", 2, {0, 0}, {});
+}
+
+// Public accessors must not read outside their maps when given bad indices.
+TEST_F(IndexMapBiDiTest, AccessorsRejectBadIndices) {
+  IndexMapBiDi map;
+  map.Init(4, false);
+  map.SetMap(1, true);
+  map.SetMap(3, true);
+  map.Setup();
+  // Sparse space is [0,4); compact space is [0,2) with sparse 1->0, 3->1.
+  EXPECT_EQ(4, map.SparseSize());
+  EXPECT_EQ(2, map.CompactSize());
+
+  // Out-of-range sparse index reports unmapped instead of reading OOB.
+  EXPECT_EQ(-1, map.SparseToCompact(-1));
+  EXPECT_EQ(-1, map.SparseToCompact(4));
+  EXPECT_EQ(-1, map.SparseToCompact(1324324));
+  // In-range behavior is unchanged.
+  EXPECT_EQ(0, map.SparseToCompact(1));
+  EXPECT_EQ(-1, map.SparseToCompact(0)); // unmapped.
+  EXPECT_EQ(1, map.SparseToCompact(3));
+
+  // Out-of-range compact index reports unmapped instead of reading OOB.
+  EXPECT_EQ(-1, map.CompactToSparse(-1));
+  EXPECT_EQ(-1, map.CompactToSparse(2));
+  EXPECT_EQ(1, map.CompactToSparse(0));
+  EXPECT_EQ(3, map.CompactToSparse(1));
+
+  // Out-of-range compact indices are not merged.
+  EXPECT_FALSE(map.Merge(2, 0));
+  EXPECT_FALSE(map.Merge(0, 2));
+  EXPECT_FALSE(map.Merge(0, 1324324));
+  EXPECT_FALSE(map.Merge(-5, 0));
+  // The map is unchanged by the rejected merges.
+  EXPECT_EQ(2, map.CompactSize());
+  EXPECT_EQ(0, map.SparseToCompact(1));
+  EXPECT_EQ(1, map.SparseToCompact(3));
+
+  // Out-of-range compact index is reported as deleted.
+  EXPECT_TRUE(map.IsCompactDeleted(-1));
+  EXPECT_TRUE(map.IsCompactDeleted(2));
+  EXPECT_TRUE(map.IsCompactDeleted(1324324));
+  EXPECT_FALSE(map.IsCompactDeleted(0));
+
+  // Out-of-range features count as missed; valid ones still map.
+  std::vector<int> compact;
+  const int missed = map.MapFeatures({-1, 0, 1, 3, 4}, &compact);
+  EXPECT_EQ(3, missed); // -1 (OOB), 0 (unmapped), 4 (OOB).
+  ASSERT_EQ(2, compact.size());
+  EXPECT_EQ(0, compact[0]);
+  EXPECT_EQ(1, compact[1]);
+
+  // The -1 sentinel (delete a compact index) is still permitted.
+  EXPECT_TRUE(map.Merge(-1, 1));
+  EXPECT_TRUE(map.IsCompactDeleted(1));
+
+  // Empty maps built via the public API must report unmapped for any
+  // index instead of reading past the end of their vectors. The plain
+  // IndexMap is the only way to reach the base-class binary-search
+  // implementation, since the IndexMapBiDi override handles its own
+  // empty check.
+  IndexMapBiDi empty_bidi;
+  empty_bidi.Init(0, false);
+  empty_bidi.Setup();
+  EXPECT_EQ(-1, empty_bidi.SparseToCompact(0));
+  EXPECT_EQ(-1, empty_bidi.CompactToSparse(0));
+  IndexMap empty_base;
+  empty_base.CopyFrom(empty_bidi);
+  EXPECT_EQ(-1, empty_base.SparseToCompact(0));
+  EXPECT_EQ(-1, empty_base.CompactToSparse(0));
+}
+
 } // namespace tesseract