Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 146
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
DiffHistoryBlob
0.00% covered (danger)
0.00%
0 / 146
0.00% covered (danger)
0.00%
0 / 13
1980
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 addItem
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getItem
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setText
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getText
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 compress
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
132
 diff
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 patch
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
90
 xdiffAdler32
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 uncompress
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 __sleep
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
30
 __wakeup
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
30
 isHappy
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * Efficient concatenated text storage.
4 *
5 * @license GPL-2.0-or-later
6 * @file
7 */
8
9// NO_NAMESPACE Compatibility with serialized objects in permanent storage
10
11/**
12 * Diff-based history compression
13 * Requires xdiff and zlib
14 *
15 * WARNING: Objects of this class are serialized and permanently stored in the DB.
16 * Do not change the name or visibility of any property!
17 */
18class DiffHistoryBlob implements HistoryBlob {
19    /** @var string[] Uncompressed item cache */
20    public $mItems = [];
21
22    /** @var int Total uncompressed size */
23    public $mSize = 0;
24
25    /**
26     * @var array|null Array of diffs. If a diff D from A to B is notated D = B - A,
27     * and Z is an empty string:
28     *
29     *              { item[map[i]] - item[map[i-1]]   where i > 0
30     *    diff[i] = {
31     *              { item[map[i]] - Z                where i = 0
32     */
33    public $mDiffs;
34
35    /** @var array The diff map, see above */
36    public $mDiffMap;
37
38    /** @var string|null The key for getText()
39     */
40    public $mDefaultKey;
41
42    /** @var string|null Compressed storage */
43    public $mCompressed;
44
45    /** @var bool True if the object is locked against further writes */
46    public $mFrozen = false;
47
48    /**
49     * @var int The maximum uncompressed size before the object becomes sad
50     * Should be less than max_allowed_packet
51     */
52    public $mMaxSize = 10_000_000;
53
54    /** @var int The maximum number of text items before the object becomes sad */
55    public $mMaxCount = 100;
56
57    /** Constants from xdiff.h */
58    private const XDL_BDOP_INS = 1;
59    private const XDL_BDOP_CPY = 2;
60    private const XDL_BDOP_INSB = 3;
61
62    public function __construct() {
63        if ( !function_exists( 'gzdeflate' ) ) {
64            throw new RuntimeException( "Need zlib support to read or write DiffHistoryBlob\n" );
65        }
66    }
67
68    /**
69     * @param string $text
70     * @return string
71     */
72    public function addItem( $text ) {
73        if ( $this->mFrozen ) {
74            throw new BadMethodCallException( __METHOD__ . ": Cannot add more items after sleep/wakeup" );
75        }
76
77        $this->mItems[] = $text;
78        $this->mSize += strlen( $text );
79        $this->mDiffs = null; // later
80        return (string)( count( $this->mItems ) - 1 );
81    }
82
83    /**
84     * @param string $key
85     * @return string
86     */
87    public function getItem( $key ) {
88        return $this->mItems[(int)$key];
89    }
90
91    /**
92     * @param string $text
93     */
94    public function setText( $text ) {
95        $this->mDefaultKey = $this->addItem( $text );
96    }
97
98    /**
99     * @return string
100     */
101    public function getText() {
102        return $this->getItem( $this->mDefaultKey );
103    }
104
105    private function compress() {
106        if ( !function_exists( 'xdiff_string_rabdiff' ) ) {
107            throw new RuntimeException( "Need xdiff support to write DiffHistoryBlob\n" );
108        }
109        if ( $this->mDiffs !== null ) {
110            // Already compressed
111            return;
112        }
113        if ( $this->mItems === [] ) {
114            return;
115        }
116
117        // Create two diff sequences: one for main text and one for small text
118        $sequences = [
119            'small' => [
120                'tail' => '',
121                'diffs' => [],
122                'map' => [],
123            ],
124            'main' => [
125                'tail' => '',
126                'diffs' => [],
127                'map' => [],
128            ],
129        ];
130        $smallFactor = 0.5;
131
132        $mItemsCount = count( $this->mItems );
133        for ( $i = 0; $i < $mItemsCount; $i++ ) {
134            $text = $this->mItems[$i];
135            if ( $i == 0 ) {
136                $seqName = 'main';
137            } else {
138                $mainTail = $sequences['main']['tail'];
139                if ( strlen( $text ) < strlen( $mainTail ) * $smallFactor ) {
140                    $seqName = 'small';
141                } else {
142                    $seqName = 'main';
143                }
144            }
145
146            $tail = $sequences[$seqName]['tail'];
147            $diff = $this->diff( $tail, $text );
148            $sequences[$seqName]['diffs'][] = $diff;
149            $sequences[$seqName]['map'][] = $i;
150            $sequences[$seqName]['tail'] = $text;
151        }
152
153        // Knit the sequences together
154        $tail = '';
155        $this->mDiffs = [];
156        $this->mDiffMap = [];
157        foreach ( $sequences as $seq ) {
158            if ( $seq['diffs'] === [] ) {
159                continue;
160            }
161            if ( $tail === '' ) {
162                $this->mDiffs[] = $seq['diffs'][0];
163            } else {
164                $head = $this->patch( '', $seq['diffs'][0] );
165                $this->mDiffs[] = $this->diff( $tail, $head );
166            }
167            $this->mDiffMap[] = $seq['map'][0];
168            $diffsCount = count( $seq['diffs'] );
169            for ( $i = 1; $i < $diffsCount; $i++ ) {
170                $this->mDiffs[] = $seq['diffs'][$i];
171                $this->mDiffMap[] = $seq['map'][$i];
172            }
173            $tail = $seq['tail'];
174        }
175    }
176
177    /**
178     * @param string $t1
179     * @param string $t2
180     * @return string
181     */
182    private function diff( $t1, $t2 ) {
183        return xdiff_string_rabdiff( $t1, $t2 );
184    }
185
186    /**
187     * @param string $base
188     * @param string $diff
189     * @return bool|string
190     */
191    private function patch( $base, $diff ) {
192        if ( function_exists( 'xdiff_string_bpatch' ) ) {
193            return xdiff_string_bpatch( $base, $diff );
194        }
195
196        # Pure PHP implementation
197
198        $header = unpack( 'Vofp/Vcsize', substr( $diff, 0, 8 ) );
199
200        # Check the checksum
201        $ofp = $this->xdiffAdler32( $base );
202        if ( $ofp !== substr( $diff, 0, 4 ) ) {
203            wfDebug( __METHOD__ . ": incorrect base checksum" );
204            return false;
205        }
206        if ( $header['csize'] != strlen( $base ) ) {
207            wfDebug( __METHOD__ . ": incorrect base length" );
208            return false;
209        }
210
211        $p = 8;
212        $out = '';
213        while ( $p < strlen( $diff ) ) {
214            $x = unpack( 'Cop', substr( $diff, $p, 1 ) );
215            $op = $x['op'];
216            ++$p;
217            switch ( $op ) {
218                case self::XDL_BDOP_INS:
219                    $x = unpack( 'Csize', substr( $diff, $p, 1 ) );
220                    $p++;
221                    $out .= substr( $diff, $p, $x['size'] );
222                    $p += $x['size'];
223                    break;
224                case self::XDL_BDOP_INSB:
225                    $x = unpack( 'Vcsize', substr( $diff, $p, 4 ) );
226                    $p += 4;
227                    $out .= substr( $diff, $p, $x['csize'] );
228                    $p += $x['csize'];
229                    break;
230                case self::XDL_BDOP_CPY:
231                    $x = unpack( 'Voff/Vcsize', substr( $diff, $p, 8 ) );
232                    $p += 8;
233                    $out .= substr( $base, $x['off'], $x['csize'] );
234                    break;
235                default:
236                    wfDebug( __METHOD__ . ": invalid op" );
237                    return false;
238            }
239        }
240        return $out;
241    }
242
243    /**
244     * Compute a binary "Adler-32" checksum as defined by LibXDiff, i.e. with
245     * the bytes backwards and initialised with 0 instead of 1. See T36428.
246     *
247     * @param string $s
248     * @return string
249     */
250    public function xdiffAdler32( $s ) {
251        static $init = null;
252        $init ??= str_repeat( "\xf0", 205 ) . "\xee" . str_repeat( "\xf0", 67 ) . "\x02";
253
254        // The real Adler-32 checksum of $init is zero, so it initialises the
255        // state to zero, as it is at the start of LibXDiff's checksum
256        // algorithm. Appending the subject string then simulates LibXDiff.
257        return strrev( hash( 'adler32', $init . $s, true ) );
258    }
259
260    private function uncompress() {
261        if ( !$this->mDiffs ) {
262            return;
263        }
264        $tail = '';
265        $mDiffsCount = count( $this->mDiffs );
266        for ( $diffKey = 0; $diffKey < $mDiffsCount; $diffKey++ ) {
267            $textKey = $this->mDiffMap[$diffKey];
268            $text = $this->patch( $tail, $this->mDiffs[$diffKey] );
269            $this->mItems[$textKey] = $text;
270            $tail = $text;
271        }
272    }
273
274    /**
275     * @return array
276     */
277    public function __sleep() {
278        $this->compress();
279        if ( $this->mItems === [] ) {
280            $info = false;
281        } else {
282            // Take forward differences to improve the compression ratio for sequences
283            $map = '';
284            $prev = 0;
285            foreach ( $this->mDiffMap as $i ) {
286                if ( $map !== '' ) {
287                    $map .= ',';
288                }
289                $map .= $i - $prev;
290                $prev = $i;
291            }
292            $info = [
293                'diffs' => $this->mDiffs,
294                'map' => $map
295            ];
296        }
297        if ( $this->mDefaultKey !== null ) {
298            $info['default'] = $this->mDefaultKey;
299        }
300        $this->mCompressed = gzdeflate( serialize( $info ) );
301        return [ 'mCompressed' ];
302    }
303
304    public function __wakeup() {
305        // addItem() doesn't work if mItems is partially filled from mDiffs
306        $this->mFrozen = true;
307        $info = HistoryBlobUtils::unserializeArray( gzinflate( $this->mCompressed ) );
308        $this->mCompressed = null;
309
310        if ( !$info ) {
311            // Empty object
312            return;
313        }
314
315        if ( isset( $info['default'] ) ) {
316            $this->mDefaultKey = $info['default'];
317        }
318        $this->mDiffs = $info['diffs'];
319        if ( isset( $info['base'] ) ) {
320            // Old format
321            $this->mDiffMap = range( 0, count( $this->mDiffs ) - 1 );
322            array_unshift( $this->mDiffs,
323                pack( 'VVCV', 0, 0, self::XDL_BDOP_INSB, strlen( $info['base'] ) ) .
324                $info['base'] );
325        } else {
326            // New format
327            $map = explode( ',', $info['map'] );
328            $cur = 0;
329            $this->mDiffMap = [];
330            foreach ( $map as $i ) {
331                $cur += $i;
332                $this->mDiffMap[] = $cur;
333            }
334        }
335        $this->uncompress();
336    }
337
338    /** @inheritDoc */
339    public function isHappy(): bool {
340        return $this->mSize < $this->mMaxSize
341            && count( $this->mItems ) < $this->mMaxCount;
342    }
343
344}