Hi,

On Fri, 2 Oct 2020 22:04:32 +0200 Sylvain Beucler <[email protected]> wrote:
It seems a bit difficult to make a big switch, probably because it's not
easy to know and test all the various involved scripts.

Considering a more progressive approach, is there something preventing
us from switching to the rewritten repository and split/merging the
file

As discussed at #debian-security in July, I refined that approach.

There's an on-going effort to tackle the issue through multiple means, including a different splitting approach, and/or regular repacks (https://www.freexian.com/blog/debian-contributions-08-2026/), so my refinement isn't likely to be used as-is.

For posterity, in case this helps similar issues, I'm attaching the scripts, as well as some design notes.

Cheers!
Sylvain
# Copyright (C) 2026  Sylvain Beucler

import glob, itertools, os, sys

def main():
    # Group splits by year
    split_files = sorted(glob.glob('data/CVE/*.list'))
    years = { year : [open(f, 'rb') for f in volumes]
              for year,volumes in itertools.groupby(split_files,
                key=lambda x: x.lstrip('data/CVE/main-')[0:4]) }
    # 1-line read-ahead, as we can't push back readline() when reaching a new CVE entry
    years_readahead = { year : years[year][-1].readline() for year in years }
    
    # Read next year entry, from newest to oldest
    def copy_year_cve(year, out):
        volumes = years[year]
        if years_readahead[year]:
            out.write(years_readahead[year])
            years_readahead[year] = None
        else:
            raise EOFError(f"No more content from '{year}'")
        while len(volumes) > 0:
            line = volumes[-1].readline()
            if line.startswith(b'CVE-'):
                # stop here and store for next call
                years_readahead[year] = line
                break
            elif line == b'':
                # next file
                volumes.pop()
                continue
            else:
                out.write(line)
    
    # Merge file
    with (open('data/CVE/order', 'r') as order,
          open('data/CVE/list', 'wb') as out):
        for cur_range in order:
            year,nb = cur_range.split(':')
            for i in range(int(nb)):
                copy_year_cve(year, out)

    # Safety check
    for year in years:
        if len(years[year]) > 0:
            raise Exception(f"Lost content from '{year}'")

# Put main code inside a function to avoid slow globals
main()
# Copyright (C) 2026  Sylvain Beucler

import os, sys

# 'order' file to rebuild data/CVE/list in the same order
class OrderFile:
    def __init__(self):
        self.lines = []

    def add_range(self, year, length):
        line = b"%s:%d\n" % (year, length)
        self.lines.append(line)

    def write(self):
        with open('data/CVE/order', 'wb') as f:
            f.write(b''.join(self.lines))

# Year-based, then volume-based split
class YearSplit:
    #SIZE_LIMIT = 5*1024*1024
    #MAX_ENTRIES = 30000  # <5MB, 1 file/year until 2020, growable/stable first slot
    MAX_ENTRIES = 10000  # generate a different git-filter-branch rewrite for analysis

    def __init__(self, year):
        self.entries = []
        self.year = year

    def write(self):
        """
        write a yearly split, multi-volume, never split a CVE entry
        """
        # MAX_ENTRIES per volume
        range_starts = range(0, len(self.entries), self.MAX_ENTRIES)
        ranges = [(i,i+self.MAX_ENTRIES) for i in range_starts]
        # Oldest entries in first volumes for Git blob and filename stability
        entries = list(reversed(self.entries))
        file_count = 0
        for start,end in ranges:
            file_count += 1
            # Use a "main" prefix to help Git, which packs better with unique filenames
            fd = open(b"data/CVE/main-%s-%d.list" % (self.year, file_count), 'wb')
            volume = entries[start:end]
            # Prepend newer entries on top to mimic data/CVE/list order
            # (optional, appending new content may make sense too)
            volume = reversed(volume)
            for entry in volume:
                fd.write(entry)
            fd.close()

# Split, in memory
# (two-pass, as we read newest entries first but write oldest entries first,
#  for content stability; also group random writes to yearly files)
def main():
    years = {}
    with open('data/CVE/list', 'rb') as f:  # 'b' for robustness against encoding errors
        cur_year = b'0000'  # handle invalid first entry for robustness and data integrity
        years[cur_year] = YearSplit(cur_year)
        cur_range_len = 0
        cur_entry = b''
        index = OrderFile()
        for line in f:
            if line.startswith(b'CVE-'):
                # Store last entry (inlined code for efficiency)
                if len(cur_entry) > 0:  # handle invalid first line
                    years[cur_year].entries.append(cur_entry)
                    cur_range_len += 1
                new_year = line[4:8]
                if not new_year.isdigit():
                    new_year = b'0000'  # store entries with invalid years there
                if new_year != cur_year:
                    if cur_range_len > 0:
                        index.add_range(cur_year, cur_range_len)
                    cur_range_len = 0
                    cur_year = new_year
                    if not cur_year in years:
                        years[cur_year] = YearSplit(cur_year)
                cur_entry = line
            else:
                # Everything else is the CVE content (this isn't a syntax checker).
                # If there's an syntax error, store too under the previous entry.
                # (No data loss, especially during the Git full rewrite.)
                cur_entry += line
        # End Of File, store last entry
        if len(cur_entry) > 0:
            years[cur_year].entries.append(cur_entry)
            cur_range_len += 1
        index.add_range(cur_year, cur_range_len)
    
    # Write to disk
    for year in years:
        years[year].write()
    index.write()

# Put main code inside a function to avoid slow globals
main()
Context: man git: "git - the stupid content tracker"

git is a content-indexed tracker. At heart it blindly stores each and every 
file revision in full, gzip'd. Its limitations from day 1 are: not handling 
large files well (hence git-lfs, etc.), and not handling renames natively.

In a second step, git can search for diffs ("deltas") and generate packs 
(git-gc, gitlab "maintenance"), which is costly.

We're storing an ever-growing data/CVE/list in Git, so this triggered the 
follow issues:
- git-blame has long stopped being usable.
- Repacking is now taking half a day, even optimized, because diff-ing 50MB 
remains costly no matter what.
- Reaching 50MB triggered all kinds of safe limits in Salsa, resulting in 
inefficient packs and high network&disk usage.
- We need a work-around for diffs in merge requests.
- We no longer can host the security-tracker (or forks/mirrors) at other forges 
(e.g. GitHub).
- New clones are timing out.


Guiding principles:

- Remain compatible with secteam's workflow

I proposed splitting the file before committing (pre-commit hook), and merging 
it when working on it (post-merge). We keep the same local data/CVE/list, only 
it's stored more efficiently in Git.

We also keep the file order to recreate data/CVE/list exactly as-is (new CVEs 
on top by order of addition, no sort), if secteam wants it.

All tools should keep working.
(Except maybe for those that specifically 'git add data/CVE/list', and even 
then the fix is trivial.)

- Split data/CVE/list in smaller parts

Split it in smaller files, which Git/Salsa/etc. handles better.

Git operations will be fast again (diff, pull, merge, blame, rebase, computing 
deltas, etc.), without the current work-arounds.

This allows for other optimization:
* the lengthy 'make check-syntax' now could only re-check the modified 
sub-parts,
* the web tracker import could perform a multi-step, cached yearly import, then 
only reimport the last modified years,
* etc.

- Minimize Git objects (blobs, trees)

In https://salsa.debian.org/lts-team/cvehist I split 1 file per-CVE, but I was 
focusing on optimizing git-log / git-blame specifically. This creates large 
tree objects (Git file listing).
$ git ls-tree --object-only HEAD^{tree} 20* | xargs -n1 git cat-file -s
=> 300k for 2026/, 200k gzip'd; new object for *every* content change

It also creates many files (400k CVEs). cvehist is optimized and drops from 
400k CVEs to 70k CVEs with interesting history, but that's not applicable here.

Splitting by year is a good bargain, but even then, each year is growing 
exponentially. So I suggest splitting by year, and then by year volumes 
(2026-1.list, 2026-2.list), with a limit per volume (see below).

Prior art: Daniel Lange ran a naive yearly split up to 2022:
https://salsa.debian.org/dlange/debian_security_security-tracker_split_files_v2/
It can be repacked to 120MB in 30mn.
(compared to, current Git repacked to 1-2GB in 10-20h)

The CVE order/index should also be small. A trivial CVE list takes 4MB. I 
propose listing a series of year ranges (e.g. 112 "2026" entries, then 10 
"2025" entries, then 7 "2026" entries, etc.). We're down to 60k.

- Minimize new blobs, i.e. keep file content stable

No new blob means no new content, diff/deltas to compute, data to download, 
etc. This is the ideal Git situation.

Old CVE years are rarely touched, so once e.g. 2020-1.list is written, it 
should stay unmodified (no new blob), even with new 2020 CVEs. So -1.list gets 
the oldest CVEs (by addition), -2.list get newer ones, etc.

2026-2.list should also stay stable when 2026-1.list entries are triaged and 
expanded. Splitting with a hard file size limit means entries will be moved 
from -1 to -2 as -1 grows, and so on, modifying all year volumes. Instead, I 
suggest capping the number of CVE entries per volume (30000 is a good bargain, 
<5MB/volume, single-volume until 2020; 10000 packs even better).

- Cons

This is a full Git rewrite. All local clones need to be re-cloned. All 
forks/rewrites need to be rewritten (ELTS, cvehist).

Also I didn't fully test how to work in a rewritten repo. 'git commit' will 
work, but 'git diff' may need another hook/wrapper to split before diffing, for 
instance.


A sample rewrite is available at:
https://salsa.debian.org/beuc/sc-split-v2-10k

Reply via email to