Commit 3a7a69ae for libheif

commit 3a7a69ae325f652e48c026b8241ab25bedf44d9b
Author: Dirk Farin <dirk.farin@gmail.com>
Date:   Tue Aug 25 00:29:57 2026 +0200

    fix sequence timing-table DoS and unbounded memory (GHSA-xw34-mjcp-jqh8)

    A crafted HEIF sequence with an edit list in repeat mode plus the movie
    header duration could amplify a single physical sample into an
    astronomical logical output count, and several per-track structures were
    built without going through the memory limits.

    - Clamp the repeat-amplified output count. init_sample_timing_table()
      set m_num_output_samples = multiplier * timeline_size (uint64), but the
      decode/raw-output loops and end_of_sequence_reached() count with a
      uint32 counter, so a value above UINT32_MAX could never be reached and
      decoding never terminated. Compute the product saturating and clamp to
      max_sequence_frames (or UINT32_MAX when the limit is disabled).
      m_num_repetitions still reports UINT32_MAX ("infinite") so a caller can
      loop the media itself via ignore_sequence_editlist.

    - Make Box_stts::get_sample_duration() O(log entries). It scanned the
      run-length table from the start on every call, once per sample while
      building the timing table (O(entries*samples), a file-open CPU DoS) and
      once per output sample on the decode/raw paths. Add a
      cumulative_sample_count prefix sum to TimeToSample (maintained by
      parse() and append_sample_duration()) and binary-search it.

    - Account Track::m_presentation_timeline and Chunk::m_sample_ranges
      against max_total_memory via MemoryHandle. A small track can declare
      millions of samples, so these ~48 B/sample and ~16 B/sample tables were
      ~1 GB/track of untracked memory. The media timeline is now moved (not
      copied) into m_presentation_timeline, removing the transient double
      allocation.

    - Give the raw-sample path a way to honor decoding options. The raw C API
      heif_track_get_next_raw_sequence_sample() could not set
      ignore_sequence_editlist. Move the implementation into an internal
      static heif_track_get_next_raw_sequence_sample2() taking
      heif_decoding_options; the public function forwards with null options.
      Promotion to public API is left for the next major version (TODO).

    Add tests/sequence_timing_overflow.cc: builds minimal in-memory
    metadata-track sequence files and checks that repeat/indefinite edit
    lists terminate, that stts durations resolve correctly across entries,
    and that the per-track tables are bounded by max_total_memory.

diff --git a/libheif/api/libheif/heif_sequences.cc b/libheif/api/libheif/heif_sequences.cc
index 9b822242..bc2373e9 100644
--- a/libheif/api/libheif/heif_sequences.cc
+++ b/libheif/api/libheif/heif_sequences.cc
@@ -258,21 +258,48 @@ heif_error heif_track_get_urim_sample_entry_uri_of_first_cluster(const heif_trac
 }


-heif_error heif_track_get_next_raw_sequence_sample(heif_track* track_ptr,
-                                                   heif_raw_sequence_sample** out_sample)
-{
-  auto track = track_ptr->track;
-
-  // --- reached end of sequence ?
-
-  if (track->end_of_sequence_reached()) {
-    return {heif_error_End_of_sequence, heif_suberror_Unspecified, "End of sequence"};
+// TODO(next major API version): promote this to public API — remove 'static', add
+// the LIBHEIF_API attribute, and add its declaration to
+// libheif/api/libheif/heif_sequences.h. It is the options-taking counterpart of
+// heif_track_get_next_raw_sequence_sample() and the fix for GHSA-xw34-mjcp-jqh8
+// variant V7 (the raw-sample path otherwise cannot honor
+// heif_decoding_options::ignore_sequence_editlist). It is kept 'static' for now
+// because adding a new exported symbol / public declaration is not permitted in a
+// stable-API bugfix release. (The non-terminating-loop aspect of V7 is already
+// fixed by the clamp in Track::init_sample_timing_table(); this only closes the
+// remaining functional gap.)
+//
+// Proposed header declaration:
+//
+//   /**
+//    * Like heif_track_get_next_raw_sequence_sample(), but takes decoding options.
+//    * Set heif_decoding_options::ignore_sequence_editlist to iterate the raw media
+//    * timeline once, ignoring edit-list repetition. `options` may be NULL, which
+//    * behaves like heif_track_get_next_raw_sequence_sample().
+//    */
+//   LIBHEIF_API
+//   heif_error heif_track_get_next_raw_sequence_sample2(heif_track*,
+//                                                       heif_raw_sequence_sample** out_sample,
+//                                                       const heif_decoding_options* options);
+static heif_error heif_track_get_next_raw_sequence_sample2(heif_track* track_ptr,
+                                                           heif_raw_sequence_sample** out_sample,
+                                                           const heif_decoding_options* options)
+{
+  if (out_sample == nullptr) {
+    return heif_error_null_pointer_argument;
   }

-  // --- get next raw sample
+  auto track = track_ptr->track;

-  // TODO: pass decoding options. We currently have no way to ignore the edit-list.
-  auto decodingResult = track->get_next_sample_raw_data(nullptr);
+  // `options` may be null (the no-options public wrapper below passes nullptr).
+  // get_next_sample_raw_data() treats null as "apply the edit list" and only reads
+  // options->ignore_sequence_editlist when options is non-null.
+  //
+  // We intentionally do not pre-check end_of_sequence_reached() here: that helper
+  // compares against the edit-list-applied output count, whereas
+  // get_next_sample_raw_data() applies ignore_sequence_editlist itself and signals
+  // heif_error_End_of_sequence at the correct (option-dependent) boundary.
+  auto decodingResult = track->get_next_sample_raw_data(options);
   if (!decodingResult) {
     return decodingResult.error_struct(track_ptr->context.get());
   }
@@ -283,6 +310,16 @@ heif_error heif_track_get_next_raw_sequence_sample(heif_track* track_ptr,
 }


+heif_error heif_track_get_next_raw_sequence_sample(heif_track* track_ptr,
+                                                   heif_raw_sequence_sample** out_sample)
+{
+  // Thin wrapper over the (currently internal) options-taking implementation, with
+  // no decoding options. Once heif_track_get_next_raw_sequence_sample2() is promoted
+  // to public API (see the TODO above), this stays as a convenience wrapper.
+  return heif_track_get_next_raw_sequence_sample2(track_ptr, out_sample, nullptr);
+}
+
+
 void heif_raw_sequence_sample_release(heif_raw_sequence_sample* sample)
 {
   delete sample;
diff --git a/libheif/sequences/chunk.cc b/libheif/sequences/chunk.cc
index 48d3c05e..a4683e95 100644
--- a/libheif/sequences/chunk.cc
+++ b/libheif/sequences/chunk.cc
@@ -102,6 +102,12 @@ Chunk::Chunk(HeifContext* ctx, uint32_t track_id,

   m_next_sample_to_be_decoded = first_sample;

+  // Reserve the exact final size so the running offset check below is the only
+  // growth concern (no geometric reallocation). The aggregate over all chunks is
+  // accounted against max_total_memory by the owning Track before any chunk is
+  // built (GHSA-xw34-mjcp-jqh8, variants V2/V3), so no per-chunk limit check here.
+  m_sample_ranges.reserve(num_samples);
+
   for (uint32_t i=0;i<num_samples;i++) {
     SampleFileRange range;
     range.offset = file_offset;
@@ -130,6 +136,12 @@ Chunk::Chunk(HeifContext* ctx, uint32_t track_id,
 }


+size_t Chunk::sample_range_entry_size()
+{
+  return sizeof(SampleFileRange);
+}
+
+
 DataExtent Chunk::get_data_extent_for_sample(uint32_t n) const
 {
   assert(n>= m_first_sample);
diff --git a/libheif/sequences/chunk.h b/libheif/sequences/chunk.h
index 66f472f2..b788d861 100644
--- a/libheif/sequences/chunk.h
+++ b/libheif/sequences/chunk.h
@@ -45,6 +45,11 @@ public:

   virtual ~Chunk() = default;

+  // Size in bytes of one entry in the internal sample-range table. Used by the
+  // owning Track to reserve the aggregate memory for all chunks up front
+  // (GHSA-xw34-mjcp-jqh8, variants V2/V3).
+  static size_t sample_range_entry_size();
+
   heif_compression_format get_compression_format() const { return m_compression_format; }

   virtual std::shared_ptr<class Decoder> get_decoder() const { return m_decoder; }
diff --git a/libheif/sequences/seq_boxes.cc b/libheif/sequences/seq_boxes.cc
index 6dc8cf21..8861a00a 100644
--- a/libheif/sequences/seq_boxes.cc
+++ b/libheif/sequences/seq_boxes.cc
@@ -610,6 +610,17 @@ Error Box_stts::parse(BitstreamRange& range, const heif_security_limits* limits)
     }
   }

+  // Precompute the prefix sum of sample_count for O(log entries) lookups in
+  // get_sample_duration(). Safe as uint32 because the total was just checked to
+  // be <= max_samples <= 0xFFFFFFFF.
+  {
+    uint32_t running = 0;
+    for (auto& entry : m_entries) {
+      running += entry.sample_count;
+      entry.cumulative_sample_count = running;
+    }
+  }
+
   return range.get_error();
 }

@@ -644,14 +655,22 @@ Error Box_stts::write(StreamWriter& writer) const

 uint32_t Box_stts::get_sample_duration(uint32_t sample_idx)
 {
-  for (const auto& entry : m_entries) {
-    if (sample_idx < entry.sample_count) {
-      return entry.sample_delta;
-    }
-    sample_idx -= entry.sample_count;
+  // The entries are contiguous and cumulative_sample_count is the non-decreasing
+  // index one past the last sample of each entry, so the entry covering sample_idx
+  // is the first one whose cumulative_sample_count is strictly greater than
+  // sample_idx. Binary search keeps this O(log entries); a linear scan would make
+  // the decode/raw output paths O(entries) per sample (GHSA-xw34-mjcp-jqh8, V1).
+  auto it = std::upper_bound(m_entries.begin(), m_entries.end(), sample_idx,
+                             [](uint32_t idx, const TimeToSample& entry) {
+                               return idx < entry.cumulative_sample_count;
+                             });
+
+  if (it == m_entries.end()) {
+    // sample_idx is not covered by any entry.
+    return 0;
   }

-  return 0;
+  return it->sample_delta;
 }


@@ -661,11 +680,13 @@ void Box_stts::append_sample_duration(uint32_t duration)
     TimeToSample entry{};
     entry.sample_delta = duration;
     entry.sample_count = 1;
+    entry.cumulative_sample_count = (m_entries.empty() ? 0 : m_entries.back().cumulative_sample_count) + 1;
     m_entries.push_back(entry);
     return;
   }

   m_entries.back().sample_count++;
+  m_entries.back().cumulative_sample_count++;
 }


diff --git a/libheif/sequences/seq_boxes.h b/libheif/sequences/seq_boxes.h
index 2da1cec5..bd593c92 100644
--- a/libheif/sequences/seq_boxes.h
+++ b/libheif/sequences/seq_boxes.h
@@ -356,8 +356,18 @@ public:
   struct TimeToSample {
     uint32_t sample_count;
     uint32_t sample_delta;
+
+    // Index one past the last sample described by this entry, i.e. the prefix sum
+    // of sample_count over the entries up to and including this one. Derived (not
+    // stored in the file); kept in sync by parse() and append_sample_duration() so
+    // get_sample_duration() can binary-search instead of scanning linearly.
+    uint32_t cumulative_sample_count = 0;
   };

+  // O(log entries) lookup of the sample duration (delta) for a given sample index.
+  // Called once per output sample on the decode/raw paths, so a linear scan would
+  // be O(entries) per sample, i.e. O(entries * samples) overall (GHSA-xw34-mjcp-jqh8,
+  // variant V1).
   uint32_t get_sample_duration(uint32_t sample_idx);

   void append_sample_duration(uint32_t duration);
diff --git a/libheif/sequences/track.cc b/libheif/sequences/track.cc
index 9a7aabc1..97755659 100644
--- a/libheif/sequences/track.cc
+++ b/libheif/sequences/track.cc
@@ -26,6 +26,7 @@
 #include "sequences/track_visual.h"
 #include "sequences/track_metadata.h"
 #include "api_structs.h"
+#include <algorithm>
 #include <limits>


@@ -357,6 +358,17 @@ Error Track::load(const std::shared_ptr<Box_trak>& trak_box)
   const std::vector<uint32_t>& chunk_offsets = m_stco->get_offsets();
   assert(chunk_offsets.size() <= (size_t) std::numeric_limits<uint32_t>::max()); // There cannot be more than uint32_t chunks.

+  // Account the per-chunk sample-range tables (Chunk::m_sample_ranges) against
+  // max_total_memory before building any chunk. Their combined size across all
+  // chunks is num_samples * sizeof(SampleFileRange); a small track can declare
+  // millions of samples in one chunk, so without this the tables are untracked
+  // and multiple tracks can exhaust memory undetected (GHSA-xw34-mjcp-jqh8, V2/V3).
+  if (auto err = m_chunk_sample_ranges_memory.alloc(m_stsz->num_samples(),
+                                                    Chunk::sample_range_entry_size(),
+                                                    limits, "the sequence chunk sample-range tables")) {
+    return err;
+  }
+
   uint32_t current_sample_idx = 0;
   int32_t previous_sample_description_index = -1;

@@ -1045,9 +1057,23 @@ Error Track::init_sample_timing_table()
 {
   m_num_samples = m_stsz->num_samples();

+  const auto* limits = m_heif_context->get_security_limits();
+
+  // Account the presentation timeline against max_total_memory before allocating
+  // it. m_num_samples is bounded by max_sequence_frames, but a single small track
+  // can still declare millions of samples, so this ~48-bytes/sample vector must be
+  // tracked to bound multi-track accumulation (GHSA-xw34-mjcp-jqh8, V2/V3). The
+  // media timeline built below is moved into m_presentation_timeline (not copied),
+  // so only one such buffer ever exists and this single reservation covers it.
+  if (auto err = m_presentation_timeline_memory.alloc(m_num_samples, sizeof(SampleTiming),
+                                                      limits, "the sequence presentation timeline")) {
+    return err;
+  }
+
   // --- build media timeline

   std::vector<SampleTiming> media_timeline;
+  media_timeline.reserve(m_num_samples);

   uint64_t current_decoding_time = 0;
   uint32_t current_chunk = 0;
@@ -1058,6 +1084,9 @@ Error Track::init_sample_timing_table()
     timing.sampleIdx = i;
     timing.sampleInChunkIdx = current_sample_in_chunk_idx;
     timing.media_decoding_time = current_decoding_time;
+    // O(log entries) per lookup (Box_stts uses a prefix-sum binary search), so
+    // building the whole table is O(samples * log entries) rather than the former
+    // O(samples * entries) file-open CPU-DoS (GHSA-xw34-mjcp-jqh8, variant V1).
     timing.sample_duration_media_time = m_stts->get_sample_duration(i);
     current_decoding_time += timing.sample_duration_media_time;
     current_sample_in_chunk_idx++;
@@ -1093,7 +1122,6 @@ Error Track::init_sample_timing_table()
            m_elst->get_entry(0).media_time == 0 &&
            m_elst->get_entry(0).segment_duration == m_mdhd->get_duration() &&
            m_elst->is_repeat_mode()) {
-    m_presentation_timeline = media_timeline;

     uint64_t duration_media_units = get_duration_in_media_units();
     if (duration_media_units == 0) {
@@ -1104,8 +1132,38 @@ Error Track::init_sample_timing_table()
       };
     }

-    uint64_t multiplier = m_heif_context->get_sequence_duration() / get_duration_in_media_units();
-    m_num_output_samples = multiplier * media_timeline.size();
+    uint64_t multiplier = m_heif_context->get_sequence_duration() / duration_media_units;
+
+    // Logical number of output samples = physical samples x repeat multiplier.
+    // Compute this saturating instead of wrapping: with the indefinite-duration
+    // sentinel (mvhd.duration = UINT64_MAX) the multiplier is enormous, and a plain
+    // multiply could overflow uint64_t and wrap to a small, misleading value.
+    uint64_t timeline_size = media_timeline.size();
+    uint64_t total_output_samples;
+    if (timeline_size != 0 && multiplier > std::numeric_limits<uint64_t>::max() / timeline_size) {
+      total_output_samples = std::numeric_limits<uint64_t>::max();
+    }
+    else {
+      total_output_samples = multiplier * timeline_size;
+    }
+
+    // The decode loop (Track_Visual::decode_next_image_sample), the raw-data path
+    // (get_next_sample_raw_data) and end_of_sequence_reached() all count emitted
+    // samples with a uint32_t (m_next_sample_to_be_output). If m_num_output_samples
+    // exceeds UINT32_MAX, that counter can never reach it and decoding never
+    // terminates (GHSA-xw34-mjcp-jqh8, variants V4/V5). The physical-sample limit
+    // (max_sequence_frames) is also never applied to this repeat-amplified logical
+    // count (variant V6). Bound the number of samples the loop will emit by the
+    // sequence-frame limit (or UINT32_MAX when the limit is disabled), so a
+    // repeat/indefinite edit list cannot inflate a single physical sample into an
+    // effectively infinite decode. The reported repetition count below still signals
+    // "infinite", so a caller wanting true unbounded playback can loop the media
+    // itself via heif_decoding_options::ignore_sequence_editlist.
+    uint64_t output_sample_cap = (limits->max_sequence_frames > 0)
+                                 ? limits->max_sequence_frames
+                                 : std::numeric_limits<uint32_t>::max();
+
+    m_num_output_samples = std::min(total_output_samples, output_sample_cap);

     if (m_heif_context->is_sequence_duration_indefinite()) {
       // mvhd carries the all-1s sentinel -> editlist repeats forever.
@@ -1125,7 +1183,6 @@ Error Track::init_sample_timing_table()

   // Fallback: just play the media timeline
   if (fallback) {
-    m_presentation_timeline = media_timeline;
     m_num_output_samples = media_timeline.size();
     // No editlist box at all: the media plays exactly once.
     // Editlist box present but its pattern isn't one libheif interprets: report
@@ -1133,6 +1190,11 @@ Error Track::init_sample_timing_table()
     m_num_repetitions = m_elst ? 0 : 1;
   }

+  // Both branches above use media_timeline unchanged as the presentation timeline.
+  // Move (do not copy) it in so only one such buffer is ever allocated, matching
+  // the single reservation made at the top of this function.
+  m_presentation_timeline = std::move(media_timeline);
+
   return {};
 }

diff --git a/libheif/sequences/track.h b/libheif/sequences/track.h
index e7ec2beb..5da1fb94 100644
--- a/libheif/sequences/track.h
+++ b/libheif/sequences/track.h
@@ -23,6 +23,7 @@

 #include "error.h"
 #include "api_structs.h"
+#include "security_limits.h"
 #include "libheif/heif_plugin.h"
 #include "libheif/heif_sequences.h"
 #include <string>
@@ -220,6 +221,10 @@ protected:
     uint32_t sample_duration_presentation_time = 0; // TODO
   };
   std::vector<SampleTiming> m_presentation_timeline;
+  // Accounts m_presentation_timeline against max_total_memory. A small track can
+  // declare millions of samples, so this vector (~48 bytes/sample) must be tracked
+  // to bound multi-track accumulation (GHSA-xw34-mjcp-jqh8, variants V2/V3).
+  MemoryHandle m_presentation_timeline_memory;
   uint64_t m_num_output_samples = 0; // Can be larger than the vector. It then repeats the playback.

   // How many times the media timeline is repeated.
@@ -241,6 +246,10 @@ protected:
   Error init_sample_timing_table();

   std::vector<std::shared_ptr<Chunk>> m_chunks;
+  // Accounts the per-chunk Chunk::m_sample_ranges tables against max_total_memory.
+  // Their combined size over all chunks is num_samples * sizeof(SampleFileRange),
+  // so a single reservation here bounds them all (GHSA-xw34-mjcp-jqh8, V2/V3).
+  MemoryHandle m_chunk_sample_ranges_memory;
   std::vector<uint8_t> m_chunk_data;

   std::shared_ptr<Box_moov> m_moov;
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 7d6c596f..0e162dfa 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -78,6 +78,7 @@ add_libheif_test(grid_tile_missing)
 add_libheif_test(iden_declared_size)
 add_libheif_test(region)
 add_libheif_test(sequence_no_track)
+add_libheif_test(sequence_timing_overflow)
 add_libheif_test(tai)
 add_libheif_test(text)
 add_libheif_test(cxx_wrapper)
diff --git a/tests/sequence_timing_overflow.cc b/tests/sequence_timing_overflow.cc
new file mode 100644
index 00000000..ddcbaecc
--- /dev/null
+++ b/tests/sequence_timing_overflow.cc
@@ -0,0 +1,497 @@
+/*
+  libheif integration tests for sequence sample-timing overflow.
+
+  MIT License
+
+  Copyright (c) 2026 Dirk Farin <dirk.farin@gmail.com>
+
+  Permission is hereby granted, free of charge, to any person obtaining a copy
+  of this software and associated documentation files (the "Software"), to deal
+  in the Software without restriction, including without limitation the rights
+  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+  copies of the Software, and to permit persons to whom the Software is
+  furnished to do so, subject to the following conditions:
+
+  The above copyright notice and this permission notice shall be included in all
+  copies or substantial portions of the Software.
+
+  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+  SOFTWARE.
+*/
+
+// Regression tests for GHSA-xw34-mjcp-jqh8: an ISOBMFF edit list in "repeat"
+// mode combined with the movie-header duration amplifies a single physical
+// sample into an astronomically large *logical* output count. Before the fix,
+// Track::init_sample_timing_table() left m_num_output_samples (uint64_t) at that
+// value while the decode/raw-output loops count with a uint32_t, so
+// end_of_sequence_reached() could never become true and decoding never
+// terminated (variants V4/V5). The physical-sample security limit
+// (max_sequence_frames) was also never applied to the amplified logical count
+// (variant V6).
+//
+// These tests build minimal HEIF sequence files by hand. A 'urim' metadata
+// track is used so the raw-sample API path exercises the timing table without
+// needing any codec plugin.
+
+#include "catch_amalgamated.hpp"
+#include "libheif/heif.h"
+#include "libheif/heif_sequences.h"
+
+#include <cstdint>
+#include <limits>
+#include <vector>
+
+namespace {
+
+void put16(std::vector<uint8_t>& v, uint16_t x)
+{
+  v.push_back(static_cast<uint8_t>(x >> 8));
+  v.push_back(static_cast<uint8_t>(x));
+}
+
+void put32(std::vector<uint8_t>& v, uint32_t x)
+{
+  v.push_back(static_cast<uint8_t>(x >> 24));
+  v.push_back(static_cast<uint8_t>(x >> 16));
+  v.push_back(static_cast<uint8_t>(x >> 8));
+  v.push_back(static_cast<uint8_t>(x));
+}
+
+void put64(std::vector<uint8_t>& v, uint64_t x)
+{
+  for (int i = 7; i >= 0; i--) {
+    v.push_back(static_cast<uint8_t>(x >> (i * 8)));
+  }
+}
+
+void put_fourcc(std::vector<uint8_t>& v, const char* s)
+{
+  v.push_back(static_cast<uint8_t>(s[0]));
+  v.push_back(static_cast<uint8_t>(s[1]));
+  v.push_back(static_cast<uint8_t>(s[2]));
+  v.push_back(static_cast<uint8_t>(s[3]));
+}
+
+// Wrap a payload in a box: [uint32 size][fourcc type][payload].
+std::vector<uint8_t> box(const char* type, const std::vector<uint8_t>& payload)
+{
+  std::vector<uint8_t> b;
+  put32(b, static_cast<uint32_t>(8 + payload.size()));
+  put_fourcc(b, type);
+  b.insert(b.end(), payload.begin(), payload.end());
+  return b;
+}
+
+std::vector<uint8_t> concat(std::initializer_list<std::vector<uint8_t>> parts)
+{
+  std::vector<uint8_t> out;
+  for (const auto& p : parts) {
+    out.insert(out.end(), p.begin(), p.end());
+  }
+  return out;
+}
+
+struct Stts_entry
+{
+  uint32_t sample_count;
+  uint32_t sample_delta;
+};
+
+struct SequenceFileParams
+{
+  bool     mvhd_v1 = true;
+  uint64_t mvhd_duration = std::numeric_limits<uint64_t>::max(); // indefinite sentinel
+  uint32_t timescale = 1000;
+
+  uint64_t mdhd_duration = 1;
+
+  bool     with_editlist = true;
+  uint64_t elst_segment_duration = 1; // must equal mdhd_duration to match the repeat pattern
+
+  std::vector<Stts_entry> stts = {{1, 1}};
+
+  uint32_t num_samples = 1;
+  uint32_t sample_size = 1;
+};
+
+// Build a minimal, structurally valid HEIF sequence file containing a single
+// 'urim' metadata track, following the box tree that Track::load() requires.
+std::vector<uint8_t> build_sequence_file(const SequenceFileParams& p)
+{
+  // --- ftyp
+  std::vector<uint8_t> ftyp_payload;
+  put_fourcc(ftyp_payload, "msf1"); // major brand: HEIF image sequence
+  put32(ftyp_payload, 0);           // minor version
+  put_fourcc(ftyp_payload, "msf1");
+  put_fourcc(ftyp_payload, "isom");
+  std::vector<uint8_t> ftyp = box("ftyp", ftyp_payload);
+
+  // --- mvhd
+  std::vector<uint8_t> mvhd_payload;
+  if (p.mvhd_v1) {
+    put32(mvhd_payload, 0x01000000); // version 1, flags 0
+    put64(mvhd_payload, 0);          // creation_time
+    put64(mvhd_payload, 0);          // modification_time
+    put32(mvhd_payload, p.timescale);
+    put64(mvhd_payload, p.mvhd_duration);
+  }
+  else {
+    put32(mvhd_payload, 0x00000000); // version 0, flags 0
+    put32(mvhd_payload, 0);          // creation_time
+    put32(mvhd_payload, 0);          // modification_time
+    put32(mvhd_payload, p.timescale);
+    put32(mvhd_payload, static_cast<uint32_t>(p.mvhd_duration));
+  }
+  put32(mvhd_payload, 0x00010000); // rate 1.0
+  put16(mvhd_payload, 0x0100);     // volume
+  put16(mvhd_payload, 0);          // reserved
+  put32(mvhd_payload, 0);          // reserved
+  put32(mvhd_payload, 0);          // reserved
+  for (int i = 0; i < 9; i++) put32(mvhd_payload, 0); // matrix
+  for (int i = 0; i < 6; i++) put32(mvhd_payload, 0); // pre_defined
+  put32(mvhd_payload, 2);          // next_track_ID
+  std::vector<uint8_t> mvhd = box("mvhd", mvhd_payload);
+
+  // --- tkhd (version 1)
+  std::vector<uint8_t> tkhd_payload;
+  put32(tkhd_payload, 0x01000007); // version 1, flags = enabled|in_movie|in_preview
+  put64(tkhd_payload, 0);          // creation_time
+  put64(tkhd_payload, 0);          // modification_time
+  put32(tkhd_payload, 1);          // track_ID
+  put32(tkhd_payload, 0);          // reserved
+  put64(tkhd_payload, 0);          // duration
+  put64(tkhd_payload, 0);          // reserved
+  put16(tkhd_payload, 0);          // layer
+  put16(tkhd_payload, 0);          // alternate_group
+  put16(tkhd_payload, 0);          // volume
+  put16(tkhd_payload, 0);          // reserved
+  for (int i = 0; i < 9; i++) put32(tkhd_payload, 0); // matrix
+  put32(tkhd_payload, 0);          // width
+  put32(tkhd_payload, 0);          // height
+  std::vector<uint8_t> tkhd = box("tkhd", tkhd_payload);
+
+  // --- edts / elst (version 1, flags = repeat)
+  std::vector<uint8_t> edts;
+  if (p.with_editlist) {
+    std::vector<uint8_t> elst_payload;
+    put32(elst_payload, 0x01000001); // version 1, flags = Repeat_EditList
+    put32(elst_payload, 1);          // entry_count
+    put64(elst_payload, p.elst_segment_duration);
+    put64(elst_payload, 0);          // media_time
+    put16(elst_payload, 1);          // media_rate_integer
+    put16(elst_payload, 0);          // media_rate_fraction
+    edts = box("edts", box("elst", elst_payload));
+  }
+
+  // --- mdhd (version 0)
+  std::vector<uint8_t> mdhd_payload;
+  put32(mdhd_payload, 0x00000000); // version 0, flags 0
+  put32(mdhd_payload, 0);          // creation_time
+  put32(mdhd_payload, 0);          // modification_time
+  put32(mdhd_payload, p.timescale);
+  put32(mdhd_payload, static_cast<uint32_t>(p.mdhd_duration));
+  put16(mdhd_payload, 0x55c4);     // language ('und')
+  put16(mdhd_payload, 0);          // pre_defined
+  std::vector<uint8_t> mdhd = box("mdhd", mdhd_payload);
+
+  // --- hdlr (handler type 'meta')
+  std::vector<uint8_t> hdlr_payload;
+  put32(hdlr_payload, 0x00000000); // version 0, flags 0
+  put32(hdlr_payload, 0);          // pre_defined
+  put_fourcc(hdlr_payload, "meta");
+  put32(hdlr_payload, 0);          // reserved
+  put32(hdlr_payload, 0);          // reserved
+  put32(hdlr_payload, 0);          // reserved
+  hdlr_payload.push_back(0);       // name (empty, null-terminated)
+  std::vector<uint8_t> hdlr = box("hdlr", hdlr_payload);
+
+  // --- nmhd (null media header for metadata tracks)
+  std::vector<uint8_t> nmhd_payload;
+  put32(nmhd_payload, 0x00000000); // version 0, flags 0
+  std::vector<uint8_t> nmhd = box("nmhd", nmhd_payload);
+
+  // --- stsd with a single 'urim' (URI meta) sample entry
+  std::vector<uint8_t> urim_payload;
+  for (int i = 0; i < 6; i++) urim_payload.push_back(0); // SampleEntry reserved
+  put16(urim_payload, 1);                                // data_reference_index
+  std::vector<uint8_t> urim = box("urim", urim_payload);
+
+  std::vector<uint8_t> stsd_payload;
+  put32(stsd_payload, 0x00000000); // version 0, flags 0
+  put32(stsd_payload, 1);          // entry_count
+  stsd_payload.insert(stsd_payload.end(), urim.begin(), urim.end());
+  std::vector<uint8_t> stsd = box("stsd", stsd_payload);
+
+  // --- stts
+  std::vector<uint8_t> stts_payload;
+  put32(stts_payload, 0x00000000); // version 0, flags 0
+  put32(stts_payload, static_cast<uint32_t>(p.stts.size()));
+  for (const auto& e : p.stts) {
+    put32(stts_payload, e.sample_count);
+    put32(stts_payload, e.sample_delta);
+  }
+  std::vector<uint8_t> stts = box("stts", stts_payload);
+
+  // --- stsc: all samples in a single chunk
+  std::vector<uint8_t> stsc_payload;
+  put32(stsc_payload, 0x00000000); // version 0, flags 0
+  put32(stsc_payload, 1);          // entry_count
+  put32(stsc_payload, 1);          // first_chunk
+  put32(stsc_payload, p.num_samples); // samples_per_chunk
+  put32(stsc_payload, 1);          // sample_description_index
+  std::vector<uint8_t> stsc = box("stsc", stsc_payload);
+
+  // --- stsz: fixed sample size
+  std::vector<uint8_t> stsz_payload;
+  put32(stsz_payload, 0x00000000);  // version 0, flags 0
+  put32(stsz_payload, p.sample_size); // fixed sample size (non-zero -> no per-sample array)
+  put32(stsz_payload, p.num_samples); // sample_count
+  std::vector<uint8_t> stsz = box("stsz", stsz_payload);
+
+  // --- stco: single chunk offset, patched below to point at the mdat payload
+  auto make_stco = [](uint32_t offset) {
+    std::vector<uint8_t> stco_payload;
+    put32(stco_payload, 0x00000000); // version 0, flags 0
+    put32(stco_payload, 1);          // entry_count
+    put32(stco_payload, offset);     // chunk offset
+    return box("stco", stco_payload);
+  };
+
+  auto assemble = [&](const std::vector<uint8_t>& stco) {
+    std::vector<uint8_t> stbl = box("stbl", concat({stsd, stts, stsc, stsz, stco}));
+    std::vector<uint8_t> minf = box("minf", concat({nmhd, stbl}));
+    std::vector<uint8_t> mdia = box("mdia", concat({mdhd, hdlr, minf}));
+    std::vector<uint8_t> trak = box("trak", concat({tkhd, edts, mdia}));
+    std::vector<uint8_t> moov = box("moov", concat({mvhd, trak}));
+    return moov;
+  };
+
+  // Box sizes are independent of the offset value, so we can size the file with a
+  // placeholder, then patch the real offset in one pass.
+  std::vector<uint8_t> moov0 = assemble(make_stco(0));
+  uint32_t mdat_payload_offset = static_cast<uint32_t>(ftyp.size() + moov0.size() + 8 /* mdat header */);
+  std::vector<uint8_t> moov = assemble(make_stco(mdat_payload_offset));
+
+  // --- mdat: num_samples * sample_size bytes of arbitrary content
+  std::vector<uint8_t> mdat_payload(static_cast<size_t>(p.num_samples) * p.sample_size);
+  for (size_t i = 0; i < mdat_payload.size(); i++) {
+    mdat_payload[i] = static_cast<uint8_t>(0xA0 + (i & 0x0f));
+  }
+  std::vector<uint8_t> mdat = box("mdat", mdat_payload);
+
+  return concat({ftyp, moov, mdat});
+}
+
+} // namespace
+
+
+TEST_CASE("repeat edit list with indefinite duration terminates")
+{
+  // A single physical sample plus an indefinite-duration repeat edit list.
+  // Before the fix this inflated m_num_output_samples to ~UINT64_MAX, and the
+  // uint32_t output counter could never reach it -> non-terminating loop.
+  SequenceFileParams p;
+  p.mvhd_v1 = true;
+  p.mvhd_duration = std::numeric_limits<uint64_t>::max(); // indefinite sentinel
+  p.mdhd_duration = 1;
+  p.with_editlist = true;
+  p.elst_segment_duration = 1;
+  p.stts = {{1, 1}};
+  p.num_samples = 1;
+
+  std::vector<uint8_t> file = build_sequence_file(p);
+
+  heif_context* ctx = heif_context_alloc();
+  REQUIRE(ctx != nullptr);
+
+  // Lower the frame limit so the (now bounded) output count is small and the test
+  // runs quickly. This is exactly the limit that must cap the repeat-amplified
+  // logical sample count.
+  heif_security_limits* limits = heif_context_get_security_limits(ctx);
+  const uint32_t kFrameLimit = 100;
+  limits->max_sequence_frames = kFrameLimit;
+
+  heif_error err = heif_context_read_from_memory(ctx, file.data(), file.size(), nullptr);
+  REQUIRE(err.code == heif_error_Ok);
+  REQUIRE(heif_context_has_sequence(ctx) == 1);
+
+  heif_track* track = heif_context_get_track(ctx, 0);
+  REQUIRE(track != nullptr);
+
+  // The file still advertises "infinite" repetition to the caller...
+  REQUIRE(heif_track_get_number_of_repetitions(track) ==
+          heif_sequence_track_number_of_repetitions_infinite);
+
+  // ...but iterating the raw samples must terminate. It is bounded by the
+  // frame limit. Cap the loop well above that so a regression fails (rather than
+  // hanging): with the bug, End_of_sequence is never reached.
+  const int kIterationCap = 100000;
+  int count = 0;
+  bool reached_end = false;
+  for (; count < kIterationCap; count++) {
+    heif_raw_sequence_sample* sample = nullptr;
+    heif_error serr = heif_track_get_next_raw_sequence_sample(track, &sample);
+    if (serr.code == heif_error_End_of_sequence) {
+      reached_end = true;
+      break;
+    }
+    REQUIRE(serr.code == heif_error_Ok);
+    REQUIRE(sample != nullptr);
+    heif_raw_sequence_sample_release(sample);
+  }
+
+  REQUIRE(reached_end);
+  REQUIRE(count == static_cast<int>(kFrameLimit));
+
+  heif_track_release(track);
+  heif_context_free(ctx);
+}
+
+
+TEST_CASE("repeat edit list with finite over-uint32 multiplier terminates")
+{
+  // Non-sentinel duration whose multiplier exceeds UINT32_MAX (variant V5): the
+  // repeat is finite but the logical count overflows the uint32_t output counter.
+  SequenceFileParams p;
+  p.mvhd_v1 = true;
+  p.mvhd_duration = (uint64_t{1} << 32) + 1; // 0x100000001, > UINT32_MAX, not the sentinel
+  p.mdhd_duration = 1;
+  p.with_editlist = true;
+  p.elst_segment_duration = 1;
+  p.stts = {{1, 1}};
+  p.num_samples = 1;
+
+  std::vector<uint8_t> file = build_sequence_file(p);
+
+  heif_context* ctx = heif_context_alloc();
+  REQUIRE(ctx != nullptr);
+
+  heif_security_limits* limits = heif_context_get_security_limits(ctx);
+  const uint32_t kFrameLimit = 50;
+  limits->max_sequence_frames = kFrameLimit;
+
+  heif_error err = heif_context_read_from_memory(ctx, file.data(), file.size(), nullptr);
+  REQUIRE(err.code == heif_error_Ok);
+
+  heif_track* track = heif_context_get_track(ctx, 0);
+  REQUIRE(track != nullptr);
+
+  const int kIterationCap = 100000;
+  int count = 0;
+  bool reached_end = false;
+  for (; count < kIterationCap; count++) {
+    heif_raw_sequence_sample* sample = nullptr;
+    heif_error serr = heif_track_get_next_raw_sequence_sample(track, &sample);
+    if (serr.code == heif_error_End_of_sequence) {
+      reached_end = true;
+      break;
+    }
+    REQUIRE(serr.code == heif_error_Ok);
+    heif_raw_sequence_sample_release(sample);
+  }
+
+  REQUIRE(reached_end);
+  REQUIRE(count == static_cast<int>(kFrameLimit));
+
+  heif_track_release(track);
+  heif_context_free(ctx);
+}
+
+
+TEST_CASE("multi-entry stts yields correct per-sample durations")
+{
+  // Guards the prefix-sum + binary-search rewrite of Box_stts::get_sample_duration():
+  // the run-length coded 'stts' must resolve to the same per-sample durations as the
+  // original linear scan, including across entry boundaries. Uses a plain track (no
+  // edit list -> single playback).
+  SequenceFileParams p;
+  p.mvhd_v1 = false;
+  p.mvhd_duration = 6;
+  p.mdhd_duration = 6;
+  p.with_editlist = false;
+  p.stts = {{2, 10}, {1, 20}, {3, 30}}; // durations: 10, 10, 20, 30, 30, 30
+  p.num_samples = 6;
+
+  std::vector<uint8_t> file = build_sequence_file(p);
+
+  heif_context* ctx = heif_context_alloc();
+  REQUIRE(ctx != nullptr);
+
+  heif_error err = heif_context_read_from_memory(ctx, file.data(), file.size(), nullptr);
+  REQUIRE(err.code == heif_error_Ok);
+
+  heif_track* track = heif_context_get_track(ctx, 0);
+  REQUIRE(track != nullptr);
+
+  std::vector<uint32_t> expected_durations = {10, 10, 20, 30, 30, 30};
+  std::vector<uint32_t> got_durations;
+
+  for (int i = 0; i < 20; i++) {
+    heif_raw_sequence_sample* sample = nullptr;
+    heif_error serr = heif_track_get_next_raw_sequence_sample(track, &sample);
+    if (serr.code == heif_error_End_of_sequence) {
+      break;
+    }
+    REQUIRE(serr.code == heif_error_Ok);
+    got_durations.push_back(heif_raw_sequence_sample_get_duration(sample));
+    heif_raw_sequence_sample_release(sample);
+  }
+
+  REQUIRE(got_durations == expected_durations);
+
+  heif_track_release(track);
+  heif_context_free(ctx);
+}
+
+
+TEST_CASE("per-track timeline/chunk memory is bounded by max_total_memory")
+{
+  // A tiny file that declares a large sample count via a single run-length 'stts'
+  // entry plus a fixed_sample_size 'stsz'. The per-track presentation timeline
+  // (~48 B/sample) and the chunk sample-range table (~16 B/sample) must be accounted
+  // against max_total_memory. Before the fix they bypassed MemoryHandle, so a small
+  // file could force hundreds of MB per track undetected (GHSA-xw34-mjcp-jqh8, V2/V3).
+  const uint32_t N = 500000; // ~24 MB timeline + ~8 MB chunk ranges; file stays tiny
+
+  SequenceFileParams p;
+  p.mvhd_v1 = false;
+  p.mvhd_duration = N;
+  p.mdhd_duration = N;
+  p.with_editlist = false;
+  p.stts = {{N, 1}}; // one run-length entry describes all N samples
+  p.num_samples = N;
+  p.sample_size = 1;
+
+  std::vector<uint8_t> file = build_sequence_file(p);
+
+  // With ample memory the file is structurally valid and reads fine.
+  {
+    heif_context* ctx = heif_context_alloc();
+    REQUIRE(ctx != nullptr);
+    heif_error err = heif_context_read_from_memory(ctx, file.data(), file.size(), nullptr);
+    REQUIRE(err.code == heif_error_Ok);
+    heif_context_free(ctx);
+  }
+
+  // With a small max_total_memory the per-track tables must trip the limit. The
+  // 'stts'/'stsz'/'stsc'/'stco' tables are all tiny here, so the only allocation
+  // large enough to exceed 4 MB is one of the newly-tracked per-track tables.
+  {
+    heif_context* ctx = heif_context_alloc();
+    REQUIRE(ctx != nullptr);
+
+    heif_security_limits* limits = heif_context_get_security_limits(ctx);
+    limits->max_total_memory = 4 * 1024 * 1024; // 4 MB, far below the ~32 MB needed
+
+    heif_error err = heif_context_read_from_memory(ctx, file.data(), file.size(), nullptr);
+    REQUIRE(err.code == heif_error_Memory_allocation_error);
+    REQUIRE(err.subcode == heif_suberror_Security_limit_exceeded);
+
+    heif_context_free(ctx);
+  }
+}