github.com/google/syzkaller@v0.0.0-20240517125934-c0f1611a36d6/pkg/asset/storage_test.go (about)

     1  // Copyright 2022 syzkaller project authors. All rights reserved.
     2  // Use of this source code is governed by Apache 2 LICENSE that can be found in the LICENSE file.
     3  
     4  package asset
     5  
     6  import (
     7  	"bytes"
     8  	"compress/gzip"
     9  	"errors"
    10  	"fmt"
    11  	"io"
    12  	"reflect"
    13  	"strings"
    14  	"testing"
    15  	"time"
    16  
    17  	"github.com/google/syzkaller/dashboard/dashapi"
    18  	"github.com/google/syzkaller/pkg/debugtracer"
    19  	"github.com/stretchr/testify/assert"
    20  	"github.com/ulikunitz/xz"
    21  )
    22  
    23  type addBuildAssetCallback func(obj dashapi.NewAsset) error
    24  
    25  type dashMock struct {
    26  	downloadURLs  map[string]bool
    27  	addBuildAsset addBuildAssetCallback
    28  }
    29  
    30  func newDashMock() *dashMock {
    31  	return &dashMock{downloadURLs: map[string]bool{}}
    32  }
    33  
    34  func (dm *dashMock) AddBuildAssets(req *dashapi.AddBuildAssetsReq) error {
    35  	for _, obj := range req.Assets {
    36  		if dm.addBuildAsset != nil {
    37  			if err := dm.addBuildAsset(obj); err != nil {
    38  				return err
    39  			}
    40  		}
    41  		dm.downloadURLs[obj.DownloadURL] = true
    42  	}
    43  	return nil
    44  }
    45  
    46  func (dm *dashMock) NeededAssetsList() (*dashapi.NeededAssetsResp, error) {
    47  	resp := &dashapi.NeededAssetsResp{}
    48  	for url := range dm.downloadURLs {
    49  		resp.DownloadURLs = append(resp.DownloadURLs, url)
    50  	}
    51  	return resp, nil
    52  }
    53  
    54  func makeStorage(t *testing.T, dash Dashboard) (*Storage, *dummyStorageBackend) {
    55  	be := makeDummyStorageBackend()
    56  	cfg := &Config{
    57  		UploadTo: "dummy://test",
    58  	}
    59  	return &Storage{
    60  		dash:    dash,
    61  		cfg:     cfg,
    62  		backend: be,
    63  		tracer:  &debugtracer.TestTracer{T: t},
    64  	}, be
    65  }
    66  
    67  func validateGzip(res *uploadedFile, expected []byte) error {
    68  	if res == nil {
    69  		return fmt.Errorf("no file was uploaded")
    70  	}
    71  	reader, err := gzip.NewReader(bytes.NewReader(res.bytes))
    72  	if err != nil {
    73  		return fmt.Errorf("gzip.NewReader failed: %w", err)
    74  	}
    75  	defer reader.Close()
    76  	body, err := io.ReadAll(reader)
    77  	if err != nil {
    78  		return fmt.Errorf("read of ungzipped content failed: %w", err)
    79  	}
    80  	if !reflect.DeepEqual(body, expected) {
    81  		return fmt.Errorf("decompressed: %#v, expected: %#v", body, expected)
    82  	}
    83  	return nil
    84  }
    85  
    86  func validateXz(res *uploadedFile, expected []byte) error {
    87  	if res == nil {
    88  		return fmt.Errorf("no file was uploaded")
    89  	}
    90  	xzUsed := strings.HasSuffix(res.req.savePath, ".xz")
    91  	if !xzUsed {
    92  		return fmt.Errorf("xz expected to be used")
    93  	}
    94  	xzReader, err := xz.NewReader(bytes.NewReader(res.bytes))
    95  	if err != nil {
    96  		return fmt.Errorf("xz reader failed: %w", err)
    97  	}
    98  	out, err := io.ReadAll(xzReader)
    99  	if err != nil {
   100  		return fmt.Errorf("xz decompression failed: %w", err)
   101  	}
   102  	if !reflect.DeepEqual(out, expected) {
   103  		return fmt.Errorf("decompressed: %#v, expected: %#v", out, expected)
   104  	}
   105  	return nil
   106  }
   107  
   108  func (storage *Storage) sendBuildAsset(reader io.Reader, fileName string, assetType dashapi.AssetType,
   109  	build *dashapi.Build) error {
   110  	asset, err := storage.UploadBuildAsset(reader, fileName, assetType, build, nil)
   111  	if err != nil {
   112  		return err
   113  	}
   114  	return storage.ReportBuildAssets(build, asset)
   115  }
   116  
   117  func TestUploadBuildAsset(t *testing.T) {
   118  	dashMock := newDashMock()
   119  	storage, be := makeStorage(t, dashMock)
   120  	be.currentTime = time.Now().Add(-2 * deletionEmbargo)
   121  	build := &dashapi.Build{ID: "1234", KernelCommit: "abcdef2134"}
   122  
   123  	// Upload two assets using different means.
   124  	vmLinuxContent := []byte{0xDE, 0xAD, 0xBE, 0xEF}
   125  	dashMock.addBuildAsset = func(newAsset dashapi.NewAsset) error {
   126  		if newAsset.Type != dashapi.KernelObject {
   127  			t.Fatalf("expected KernelObject, got %v", newAsset.Type)
   128  		}
   129  		if !strings.Contains(newAsset.DownloadURL, "vmlinux") {
   130  			t.Fatalf("%#v was expected to mention vmlinux", newAsset.DownloadURL)
   131  		}
   132  		return nil
   133  	}
   134  	var file *uploadedFile
   135  	be.objectUpload = collectBytes(&file)
   136  	err := storage.sendBuildAsset(bytes.NewReader(vmLinuxContent), "vmlinux",
   137  		dashapi.KernelObject, build)
   138  	if err != nil {
   139  		t.Fatalf("file upload failed: %s", err)
   140  	}
   141  	if err := validateXz(file, vmLinuxContent); err != nil {
   142  		t.Fatalf("vmlinux validation failed: %s", err)
   143  	}
   144  	// Upload the same file the second time.
   145  	storage.sendBuildAsset(bytes.NewReader(vmLinuxContent), "vmlinux", dashapi.KernelObject, build)
   146  	// The currently expected behavior is that it will be uploaded twice and will have
   147  	// different names.
   148  	if len(dashMock.downloadURLs) < 2 {
   149  		t.Fatalf("same-file upload was expected to succeed, but it didn't; %#v", dashMock.downloadURLs)
   150  	}
   151  
   152  	diskImageContent := []byte{0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8}
   153  	dashMock.addBuildAsset = func(newAsset dashapi.NewAsset) error {
   154  		if newAsset.Type != dashapi.KernelImage {
   155  			t.Fatalf("expected KernelImage, got %v", newAsset.Type)
   156  		}
   157  		if !strings.Contains(newAsset.DownloadURL, "disk") ||
   158  			!strings.Contains(newAsset.DownloadURL, ".img") {
   159  			t.Fatalf("%#v was expected to mention disk.img", newAsset.DownloadURL)
   160  		}
   161  		if !strings.Contains(newAsset.DownloadURL, build.KernelCommit[:6]) {
   162  			t.Fatalf("%#v was expected to mention build commit", newAsset.DownloadURL)
   163  		}
   164  		return nil
   165  	}
   166  	file = nil
   167  	be.objectUpload = collectBytes(&file)
   168  	storage.sendBuildAsset(bytes.NewReader(diskImageContent), "disk.img", dashapi.KernelImage, build)
   169  	if err := validateXz(file, diskImageContent); err != nil {
   170  		t.Fatalf("disk.img validation failed: %s", err)
   171  	}
   172  
   173  	allUrls := []string{}
   174  	for url := range dashMock.downloadURLs {
   175  		allUrls = append(allUrls, url)
   176  	}
   177  	if len(allUrls) != 3 {
   178  		t.Fatalf("invalid dashMock state: expected 3 assets, got %d", len(allUrls))
   179  	}
   180  	// First try to remove two assets.
   181  	dashMock.downloadURLs = map[string]bool{allUrls[2]: true, "http://download/unrelated.txt": true}
   182  
   183  	// Pretend there's an asset deletion error.
   184  	be.objectRemove = func(string) error { return fmt.Errorf("not now") }
   185  	err = storage.DeprecateAssets()
   186  	if err == nil {
   187  		t.Fatalf("DeprecateAssets should have failed")
   188  	}
   189  
   190  	// Let the deletion be successful.
   191  	be.objectRemove = nil
   192  	err = storage.DeprecateAssets()
   193  	if err != nil {
   194  		t.Fatalf("DeprecateAssets was expected to be successful, got %s", err)
   195  	}
   196  	path, err := be.getPath(allUrls[2])
   197  	if err != nil {
   198  		t.Fatalf("getPath failed: %s", err)
   199  	}
   200  	err = be.hasOnly([]string{path})
   201  	if err != nil {
   202  		t.Fatalf("after first DeprecateAssets: %s", err)
   203  	}
   204  
   205  	// Delete the rest.
   206  	dashMock.downloadURLs = map[string]bool{}
   207  	err = storage.DeprecateAssets()
   208  	if err != nil || len(be.objects) != 0 {
   209  		t.Fatalf("second DeprecateAssets failed: %s, len %d",
   210  			err, len(be.objects))
   211  	}
   212  }
   213  
   214  type uploadedFile struct {
   215  	req   uploadRequest
   216  	bytes []byte
   217  }
   218  
   219  func collectBytes(saveTo **uploadedFile) objectUploadCallback {
   220  	return func(req *uploadRequest) (*uploadResponse, error) {
   221  		buf := &bytes.Buffer{}
   222  		wwc := &wrappedWriteCloser{
   223  			writer: buf,
   224  			closeCallback: func() error {
   225  				*saveTo = &uploadedFile{req: *req, bytes: buf.Bytes()}
   226  				return nil
   227  			},
   228  		}
   229  		return &uploadResponse{path: req.savePath, writer: wwc}, nil
   230  	}
   231  }
   232  
   233  func TestUploadHtmlAsset(t *testing.T) {
   234  	dashMock := newDashMock()
   235  	storage, be := makeStorage(t, dashMock)
   236  	build := &dashapi.Build{ID: "1234", KernelCommit: "abcdef2134"}
   237  	htmlContent := []byte("<html><head><title>Hi!</title></head></html>")
   238  	dashMock.addBuildAsset = func(newAsset dashapi.NewAsset) error {
   239  		if newAsset.Type != dashapi.HTMLCoverageReport {
   240  			t.Fatalf("expected HtmlCoverageReport, got %v", newAsset.Type)
   241  		}
   242  		if !strings.Contains(newAsset.DownloadURL, "cover_report") {
   243  			t.Fatalf("%#v was expected to mention cover_report", newAsset.DownloadURL)
   244  		}
   245  		if !strings.HasSuffix(newAsset.DownloadURL, ".html") {
   246  			t.Fatalf("%#v was expected to have .html extension", newAsset.DownloadURL)
   247  		}
   248  		return nil
   249  	}
   250  	var file *uploadedFile
   251  	be.objectUpload = collectBytes(&file)
   252  	storage.sendBuildAsset(bytes.NewReader(htmlContent), "cover_report.html",
   253  		dashapi.HTMLCoverageReport, build)
   254  	if err := validateGzip(file, htmlContent); err != nil {
   255  		t.Fatalf("cover_report.html validation failed: %s", err)
   256  	}
   257  }
   258  
   259  func TestRecentAssetDeletionProtection(t *testing.T) {
   260  	dashMock := newDashMock()
   261  	storage, be := makeStorage(t, dashMock)
   262  	build := &dashapi.Build{ID: "1234", KernelCommit: "abcdef2134"}
   263  	htmlContent := []byte("<html><head><title>Hi!</title></head></html>")
   264  	be.currentTime = time.Now().Add(-time.Hour * 24 * 6)
   265  	err := storage.sendBuildAsset(bytes.NewReader(htmlContent), "cover_report.html",
   266  		dashapi.HTMLCoverageReport, build)
   267  	if err != nil {
   268  		t.Fatalf("failed to upload a file: %v", err)
   269  	}
   270  
   271  	// Try to delete a recent file.
   272  	dashMock.downloadURLs = map[string]bool{}
   273  	err = storage.DeprecateAssets()
   274  	if err != nil {
   275  		t.Fatalf("DeprecateAssets failed: %v", err)
   276  	} else if len(be.objects) == 0 {
   277  		t.Fatalf("a recent object was deleted: %v", err)
   278  	}
   279  }
   280  
   281  func TestAssetStorageConfiguration(t *testing.T) {
   282  	dashMock := newDashMock()
   283  	cfg := &Config{
   284  		UploadTo: "dummy://",
   285  		Assets: map[dashapi.AssetType]TypeConfig{
   286  			dashapi.HTMLCoverageReport: {Never: true},
   287  			dashapi.KernelObject:       {},
   288  		},
   289  	}
   290  	storage, err := StorageFromConfig(cfg, dashMock)
   291  	if err != nil {
   292  		t.Fatalf("unexpected error from StorageFromConfig: %s", err)
   293  	}
   294  	build := &dashapi.Build{ID: "1234", KernelCommit: "abcdef2134"}
   295  
   296  	// Uploading a file of a disabled asset type.
   297  	htmlContent := []byte("<html><head><title>Hi!</title></head></html>")
   298  	err = storage.sendBuildAsset(bytes.NewReader(htmlContent), "cover_report.html",
   299  		dashapi.HTMLCoverageReport, build)
   300  	if !errors.Is(err, ErrAssetTypeDisabled) {
   301  		t.Fatalf("UploadBuildAssetStream expected to fail with ErrAssetTypeDisabled, but got %v", err)
   302  	}
   303  
   304  	// Uploading a file of an unspecified asset type.
   305  	testContent := []byte{0x1, 0x2, 0x3, 0x4}
   306  	err = storage.sendBuildAsset(bytes.NewReader(testContent), "disk.raw", dashapi.BootableDisk, build)
   307  	if err != nil {
   308  		t.Fatalf("UploadBuildAssetStream of BootableDisk expected to succeed, got %v", err)
   309  	}
   310  
   311  	// Uploading a file of a specified asset type.
   312  	err = storage.sendBuildAsset(bytes.NewReader(testContent), "vmlinux", dashapi.KernelObject, build)
   313  	if err != nil {
   314  		t.Fatalf("UploadBuildAssetStream of BootableDisk expected to succeed, got %v", err)
   315  	}
   316  }
   317  
   318  func TestUploadSameContent(t *testing.T) {
   319  	dashMock := newDashMock()
   320  	storage, be := makeStorage(t, dashMock)
   321  	be.currentTime = time.Now().Add(-2 * deletionEmbargo)
   322  
   323  	build := &dashapi.Build{ID: "1234", KernelCommit: "abcdef2134"}
   324  	extra := &ExtraUploadArg{UniqueTag: "uniquetag", SkipIfExists: true}
   325  	testContent := []byte{0x1, 0x2, 0x3, 0x4}
   326  	asset, err := storage.UploadBuildAsset(bytes.NewReader(testContent), "disk.raw",
   327  		dashapi.BootableDisk, build, extra)
   328  	if err != nil {
   329  		t.Fatalf("UploadBuildAssetexpected to succeed, got %v", err)
   330  	}
   331  	if !strings.Contains(asset.DownloadURL, extra.UniqueTag) {
   332  		t.Fatalf("%#v was expected to contain %#v", asset.DownloadURL, extra.UniqueTag)
   333  	}
   334  	// Upload the same asset again.
   335  	be.objectUpload = func(req *uploadRequest) (*uploadResponse, error) {
   336  		return nil, &FileExistsError{req.savePath}
   337  	}
   338  	assetTwo, err := storage.UploadBuildAsset(bytes.NewReader(testContent), "disk.raw",
   339  		dashapi.BootableDisk, build, extra)
   340  	if err != nil {
   341  		t.Fatalf("UploadBuildAssetexpected to succeed, got %v", err)
   342  	}
   343  	if asset.DownloadURL != assetTwo.DownloadURL {
   344  		t.Fatalf("assets were expected to have same download URL, got %#v %#v",
   345  			asset.DownloadURL, assetTwo.DownloadURL)
   346  	}
   347  }
   348  
   349  // Test that we adequately handle the case when several syz-cis with separate buckets
   350  // are connected to a single dashboard.
   351  // nolint: dupl
   352  func TestTwoBucketDeprecation(t *testing.T) {
   353  	dash := newDashMock()
   354  	storage, dummy := makeStorage(t, dash)
   355  
   356  	// "Upload" an asset from this instance.
   357  	resp, _ := dummy.upload(&uploadRequest{
   358  		savePath: `folder/file.txt`,
   359  	})
   360  	url, _ := dummy.downloadURL(resp.path, true)
   361  
   362  	// Dashboard returns two asset URLs.
   363  	dash.downloadURLs = map[string]bool{
   364  		"http://unknown-bucket/other-folder/other-file.txt": true, // will cause ErrUnknownBucket
   365  		url: true,
   366  	}
   367  	dummy.objectRemove = func(url string) error {
   368  		t.Fatalf("unexpected removal")
   369  		return nil
   370  	}
   371  	err := storage.DeprecateAssets()
   372  	assert.NoError(t, err)
   373  }
   374  
   375  // nolint: dupl
   376  func TestInvalidAssetURLs(t *testing.T) {
   377  	dash := newDashMock()
   378  	storage, dummy := makeStorage(t, dash)
   379  
   380  	// "Upload" an asset from this instance.
   381  	resp, _ := dummy.upload(&uploadRequest{
   382  		savePath: `folder/file.txt`,
   383  	})
   384  	url, _ := dummy.downloadURL(resp.path, true)
   385  
   386  	// Dashboard returns two asset URLs.
   387  	dash.downloadURLs = map[string]bool{
   388  		"http://totally-unknown-bucket/other-folder/other-file.txt": true,
   389  		url: true,
   390  	}
   391  	dummy.objectRemove = func(url string) error {
   392  		t.Fatalf("unexpected removal")
   393  		return nil
   394  	}
   395  	err := storage.DeprecateAssets()
   396  	assert.Error(t, err)
   397  }