@@ -4,12 +4,14 @@ import (
44 "bytes"
55 "crypto/rand"
66 "fmt"
7+ "io"
78 "math"
89 "os"
910 "syscall"
1011 "testing"
1112 "unsafe"
1213
14+ "github.com/google/uuid"
1315 "github.com/stretchr/testify/require"
1416 "golang.org/x/sys/unix"
1517
@@ -218,6 +220,183 @@ func TestEmptyRanges(t *testing.T) {
218220 })
219221}
220222
223+ func TestCacheExportToDiff_ZeroDirtyBlockEmittedAsDirtyPayload (t * testing.T ) {
224+ t .Parallel ()
225+
226+ const blockSize = header .RootfsBlockSize
227+ cache , err := NewCache (blockSize , blockSize , t .TempDir ()+ "/cache" , false )
228+ require .NoError (t , err )
229+
230+ t .Cleanup (func () {
231+ require .NoError (t , cache .Close ())
232+ })
233+
234+ zeroBlock := make ([]byte , blockSize )
235+ n , err := cache .WriteAt (zeroBlock , 0 )
236+ require .NoError (t , err )
237+ require .Equal (t , int (blockSize ), n )
238+
239+ out , err := os .CreateTemp (t .TempDir (), "diff-*" )
240+ require .NoError (t , err )
241+ defer out .Close ()
242+
243+ diffMetadata , err := cache .ExportToDiff (t .Context (), out )
244+ require .NoError (t , err )
245+
246+ require .EqualValues (t , 1 , diffMetadata .Dirty .Count (), "zero-filled dirty block should be emitted as dirty payload" )
247+ require .EqualValues (t , 0 , diffMetadata .Empty .Count (), "zero-filled dirty block should not be tracked in empty metadata" )
248+
249+ stat , err := out .Stat ()
250+ require .NoError (t , err )
251+ require .EqualValues (t , blockSize , stat .Size (), "zero-filled dirty block should write block payload bytes" )
252+ }
253+
254+ func TestCacheExportToDiff_ZeroDirtyBlockMapsToSnapshotBuild (t * testing.T ) {
255+ t .Parallel ()
256+
257+ const blockSize = header .RootfsBlockSize
258+ cache , err := NewCache (blockSize , blockSize , t .TempDir ()+ "/cache" , false )
259+ require .NoError (t , err )
260+
261+ t .Cleanup (func () {
262+ require .NoError (t , cache .Close ())
263+ })
264+
265+ zeroBlock := make ([]byte , blockSize )
266+ n , err := cache .WriteAt (zeroBlock , 0 )
267+ require .NoError (t , err )
268+ require .Equal (t , int (blockSize ), n )
269+
270+ out , err := os .CreateTemp (t .TempDir (), "diff-*" )
271+ require .NoError (t , err )
272+ defer out .Close ()
273+
274+ diffMetadata , err := cache .ExportToDiff (t .Context (), out )
275+ require .NoError (t , err )
276+
277+ baseBuildID := uuid .New ()
278+ originalHeader , err := header .NewHeader (
279+ header .NewTemplateMetadata (baseBuildID , uint64 (blockSize ), uint64 (blockSize )),
280+ nil ,
281+ )
282+ require .NoError (t , err )
283+
284+ snapshotBuildID := uuid .New ()
285+ diffHeader , err := diffMetadata .ToDiffHeader (t .Context (), originalHeader , snapshotBuildID )
286+ require .NoError (t , err )
287+
288+ _ , _ , mappedBuildID , err := diffHeader .GetShiftedMapping (t .Context (), 0 )
289+ require .NoError (t , err )
290+
291+ require .NotNil (t , mappedBuildID )
292+ require .Equal (t , snapshotBuildID , * mappedBuildID , "zero-filled dirty block should map to the snapshot diff when empty detection is skipped" )
293+ require .NotEqual (t , uuid .Nil , * mappedBuildID , "zero-filled dirty block should no longer be represented as an empty mapping" )
294+ }
295+
296+ func TestCacheExportToDiff_MixedDirtyBlocksKeepsZeroBlockInDiff (t * testing.T ) {
297+ t .Parallel ()
298+
299+ const blockSize = header .RootfsBlockSize
300+ const size = blockSize * 3
301+
302+ cache , err := NewCache (size , blockSize , t .TempDir ()+ "/cache" , false )
303+ require .NoError (t , err )
304+ t .Cleanup (func () {
305+ require .NoError (t , cache .Close ())
306+ })
307+
308+ zeroBlock := make ([]byte , blockSize )
309+ nonZeroBlock := bytes .Repeat ([]byte {0xAB }, int (blockSize ))
310+
311+ _ , err = cache .WriteAt (zeroBlock , 0 )
312+ require .NoError (t , err )
313+
314+ _ , err = cache .WriteAt (nonZeroBlock , blockSize )
315+ require .NoError (t , err )
316+
317+ out , err := os .CreateTemp (t .TempDir (), "diff-*" )
318+ require .NoError (t , err )
319+ defer out .Close ()
320+
321+ diffMetadata , err := cache .ExportToDiff (t .Context (), out )
322+ require .NoError (t , err )
323+
324+ require .EqualValues (t , 2 , diffMetadata .Dirty .Count ())
325+ require .EqualValues (t , 0 , diffMetadata .Empty .Count (), "mixed export should still skip empty tracking for zero-filled dirty blocks" )
326+
327+ _ , err = out .Seek (0 , io .SeekStart )
328+ require .NoError (t , err )
329+ exported , err := io .ReadAll (out )
330+ require .NoError (t , err )
331+ expected := make ([]byte , 0 , len (zeroBlock )+ len (nonZeroBlock ))
332+ expected = append (expected , zeroBlock ... )
333+ expected = append (expected , nonZeroBlock ... )
334+ require .Equal (t , expected , exported )
335+
336+ baseBuildID := uuid .New ()
337+ originalHeader , err := header .NewHeader (
338+ header .NewTemplateMetadata (baseBuildID , uint64 (blockSize ), uint64 (size )),
339+ nil ,
340+ )
341+ require .NoError (t , err )
342+
343+ snapshotBuildID := uuid .New ()
344+ diffHeader , err := diffMetadata .ToDiffHeader (t .Context (), originalHeader , snapshotBuildID )
345+ require .NoError (t , err )
346+
347+ _ , _ , firstBlockBuildID , err := diffHeader .GetShiftedMapping (t .Context (), 0 )
348+ require .NoError (t , err )
349+ require .Equal (t , snapshotBuildID , * firstBlockBuildID , "zero-filled dirty block should still map to the snapshot diff" )
350+
351+ _ , _ , secondBlockBuildID , err := diffHeader .GetShiftedMapping (t .Context (), blockSize )
352+ require .NoError (t , err )
353+ require .Equal (t , snapshotBuildID , * secondBlockBuildID )
354+
355+ _ , _ , thirdBlockBuildID , err := diffHeader .GetShiftedMapping (t .Context (), 2 * blockSize )
356+ require .NoError (t , err )
357+ require .Equal (t , baseBuildID , * thirdBlockBuildID , "clean blocks should keep the base mapping" )
358+ }
359+
360+ func TestCacheExportToDiff_NonContiguousDirtyBlocksPreserveRangeOrder (t * testing.T ) {
361+ t .Parallel ()
362+
363+ const blockSize = header .RootfsBlockSize
364+ const size = blockSize * 5
365+
366+ cache , err := NewCache (size , blockSize , t .TempDir ()+ "/cache" , false )
367+ require .NoError (t , err )
368+ t .Cleanup (func () {
369+ require .NoError (t , cache .Close ())
370+ })
371+
372+ firstBlock := bytes .Repeat ([]byte {0x11 }, int (blockSize ))
373+ secondBlock := bytes .Repeat ([]byte {0x22 }, int (blockSize ))
374+
375+ _ , err = cache .WriteAt (firstBlock , 0 )
376+ require .NoError (t , err )
377+
378+ _ , err = cache .WriteAt (secondBlock , 3 * blockSize )
379+ require .NoError (t , err )
380+
381+ out , err := os .CreateTemp (t .TempDir (), "diff-*" )
382+ require .NoError (t , err )
383+ defer out .Close ()
384+
385+ diffMetadata , err := cache .ExportToDiff (t .Context (), out )
386+ require .NoError (t , err )
387+
388+ require .EqualValues (t , 2 , diffMetadata .Dirty .Count ())
389+ require .True (t , diffMetadata .Dirty .Test (0 ))
390+ require .True (t , diffMetadata .Dirty .Test (3 ))
391+ require .EqualValues (t , 0 , diffMetadata .Empty .Count ())
392+
393+ _ , err = out .Seek (0 , io .SeekStart )
394+ require .NoError (t , err )
395+ exported , err := io .ReadAll (out )
396+ require .NoError (t , err )
397+ require .Equal (t , append (firstBlock , secondBlock ... ), exported )
398+ }
399+
221400func compareData (readBytes []byte , expectedBytes []byte ) error {
222401 // The bytes.Equal is the first place in this flow that actually touches the uffd managed memory and triggers the pagefault, so any deadlocks will manifest here.
223402 if ! bytes .Equal (readBytes , expectedBytes ) {
0 commit comments