github.com/google/syzkaller@v0.0.0-20251211124644-a066d2bc4b02/pkg/asset/storage.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  	"compress/gzip"
     8  	"crypto/sha256"
     9  	"errors"
    10  	"fmt"
    11  	"io"
    12  	"os"
    13  	"os/exec"
    14  	"path/filepath"
    15  	"strings"
    16  	"time"
    17  
    18  	"github.com/google/syzkaller/pkg/gcs"
    19  	"github.com/ulikunitz/xz"
    20  
    21  	"github.com/google/syzkaller/dashboard/dashapi"
    22  	"github.com/google/syzkaller/pkg/debugtracer"
    23  )
    24  
    25  type Storage struct {
    26  	cfg     *Config
    27  	backend StorageBackend
    28  	dash    Dashboard
    29  	tracer  debugtracer.DebugTracer
    30  }
    31  
    32  type Dashboard interface {
    33  	AddBuildAssets(req *dashapi.AddBuildAssetsReq) error
    34  	NeededAssetsList() (*dashapi.NeededAssetsResp, error)
    35  }
    36  
    37  func StorageFromConfig(cfg *Config, dash Dashboard) (*Storage, error) {
    38  	if dash == nil {
    39  		return nil, fmt.Errorf("dashboard api instance is necessary")
    40  	}
    41  	tracer := debugtracer.DebugTracer(&debugtracer.NullTracer{})
    42  	if cfg.Debug {
    43  		tracer = &debugtracer.GenericTracer{
    44  			WithTime:    true,
    45  			TraceWriter: os.Stdout,
    46  		}
    47  	}
    48  	var backend StorageBackend
    49  	if strings.HasPrefix(cfg.UploadTo, "gs://") {
    50  		var err error
    51  		backend, err = makeCloudStorageBackend(strings.TrimPrefix(cfg.UploadTo, "gs://"), tracer)
    52  		if err != nil {
    53  			return nil, fmt.Errorf("the call to MakeCloudStorageBackend failed: %w", err)
    54  		}
    55  	} else if strings.HasPrefix(cfg.UploadTo, "dummy://") {
    56  		backend = makeDummyStorageBackend()
    57  	} else {
    58  		return nil, fmt.Errorf("unknown UploadTo during StorageFromConfig(): %#v", cfg.UploadTo)
    59  	}
    60  	return &Storage{
    61  		cfg:     cfg,
    62  		backend: backend,
    63  		dash:    dash,
    64  		tracer:  tracer,
    65  	}, nil
    66  }
    67  
    68  func (storage *Storage) AssetTypeEnabled(assetType dashapi.AssetType) bool {
    69  	return storage.cfg.IsEnabled(assetType)
    70  }
    71  
    72  func (storage *Storage) getDefaultCompressor() Compressor {
    73  	return xzCompressor
    74  }
    75  
    76  type ExtraUploadArg struct {
    77  	// It is assumed that paths constructed with same UniqueTag values
    78  	// always correspond to an asset having the same content.
    79  	UniqueTag string
    80  	// If the asset being uploaded already exists (see above), don't return
    81  	// an error, abort uploading and return the download URL.
    82  	SkipIfExists bool
    83  }
    84  
    85  var ErrAssetTypeDisabled = errors.New("uploading assets of this type is disabled")
    86  
    87  func (storage *Storage) assetPath(name string, extra *ExtraUploadArg) string {
    88  	folderName := ""
    89  	if extra != nil && extra.UniqueTag != "" {
    90  		folderName = extra.UniqueTag
    91  	} else {
    92  		// The idea is to make a file name useful and yet unique.
    93  		// So we put a file to a pseudo-unique "folder".
    94  		folderNameBytes := sha256.Sum256([]byte(fmt.Sprintf("%v", time.Now().UnixNano())))
    95  		folderName = fmt.Sprintf("%x", folderNameBytes)
    96  	}
    97  	const folderPrefix = 12
    98  	if len(folderName) > folderPrefix {
    99  		folderName = folderName[0:folderPrefix]
   100  	}
   101  	return fmt.Sprintf("%s/%s", folderName, name)
   102  }
   103  
   104  func (storage *Storage) uploadFileStream(reader io.Reader, assetType dashapi.AssetType,
   105  	name string, extra *ExtraUploadArg) (string, error) {
   106  	if name == "" {
   107  		return "", fmt.Errorf("file name is not specified")
   108  	}
   109  	typeDescr := GetTypeDescription(assetType)
   110  	if typeDescr == nil {
   111  		return "", fmt.Errorf("asset type %s is unknown", assetType)
   112  	}
   113  	if !storage.AssetTypeEnabled(assetType) {
   114  		return "", fmt.Errorf("not allowed to upload an asset of type %s: %w",
   115  			assetType, ErrAssetTypeDisabled)
   116  	}
   117  	path := storage.assetPath(name, extra)
   118  	req := &uploadRequest{
   119  		savePath:          path,
   120  		contentType:       typeDescr.ContentType,
   121  		contentEncoding:   typeDescr.ContentEncoding,
   122  		preserveExtension: typeDescr.preserveExtension,
   123  	}
   124  	if req.contentType == "" {
   125  		req.contentType = "application/octet-stream"
   126  	}
   127  	compressor := storage.getDefaultCompressor()
   128  	if typeDescr.customCompressor != nil {
   129  		compressor = typeDescr.customCompressor
   130  	}
   131  	res, err := compressor(req, storage.backend.upload)
   132  	var existsErr *FileExistsError
   133  	if errors.As(err, &existsErr) {
   134  		storage.tracer.Log("asset %s already exists", path)
   135  		if extra == nil || !extra.SkipIfExists {
   136  			return "", err
   137  		}
   138  		// Let's just return the download URL.
   139  		return storage.backend.downloadURL(existsErr.Path, storage.cfg.PublicAccess)
   140  	} else if err != nil {
   141  		return "", fmt.Errorf("failed to query writer: %w", err)
   142  	} else {
   143  		written, err := io.Copy(res.writer, reader)
   144  		if err != nil {
   145  			more := ""
   146  			closeErr := res.writer.Close()
   147  			var exiterr *exec.ExitError
   148  			if errors.As(closeErr, &exiterr) {
   149  				more = fmt.Sprintf(", process state '%s'", exiterr.ProcessState)
   150  			}
   151  			return "", fmt.Errorf("failed to redirect byte stream: copied %d bytes, error %w%s",
   152  				written, err, more)
   153  		}
   154  		err = res.writer.Close()
   155  		if err != nil {
   156  			return "", fmt.Errorf("failed to close writer: %w", err)
   157  		}
   158  	}
   159  	return storage.backend.downloadURL(res.path, storage.cfg.PublicAccess)
   160  }
   161  
   162  func (storage *Storage) UploadBuildAsset(reader io.Reader, fileName string, assetType dashapi.AssetType,
   163  	build *dashapi.Build, extra *ExtraUploadArg) (dashapi.NewAsset, error) {
   164  	const commitPrefix = 8
   165  	commit := build.KernelCommit
   166  	if len(commit) > commitPrefix {
   167  		commit = commit[:commitPrefix]
   168  	}
   169  	baseName := filepath.Base(fileName)
   170  	fileExt := filepath.Ext(baseName)
   171  	name := fmt.Sprintf("%s-%s%s",
   172  		strings.TrimSuffix(baseName, fileExt),
   173  		commit,
   174  		fileExt)
   175  	url, err := storage.uploadFileStream(reader, assetType, name, extra)
   176  	if err != nil {
   177  		return dashapi.NewAsset{}, err
   178  	}
   179  	return dashapi.NewAsset{
   180  		Type:        assetType,
   181  		DownloadURL: url,
   182  	}, nil
   183  }
   184  func (storage *Storage) ReportBuildAssets(build *dashapi.Build, assets ...dashapi.NewAsset) error {
   185  	// If the server denies the reques, we'll delete the orphaned file during deprecated files
   186  	// deletion later.
   187  	return storage.dash.AddBuildAssets(&dashapi.AddBuildAssetsReq{
   188  		BuildID: build.ID,
   189  		Assets:  assets,
   190  	})
   191  }
   192  
   193  func (storage *Storage) UploadCrashAsset(reader io.Reader, fileName string, assetType dashapi.AssetType,
   194  	extra *ExtraUploadArg) (dashapi.NewAsset, error) {
   195  	url, err := storage.uploadFileStream(reader, assetType, fileName, extra)
   196  	if err != nil {
   197  		return dashapi.NewAsset{}, err
   198  	}
   199  	return dashapi.NewAsset{
   200  		Type:        assetType,
   201  		DownloadURL: url,
   202  	}, nil
   203  }
   204  
   205  var ErrAssetDoesNotExist = errors.New("the asset did not exist")
   206  
   207  type FileExistsError struct {
   208  	// The path gets changed by wrappers, so we need to return it back.
   209  	Path string
   210  }
   211  
   212  func (e *FileExistsError) Error() string {
   213  	return fmt.Sprintf("asset exists: %s", e.Path)
   214  }
   215  
   216  var ErrUnknownBucket = errors.New("the asset is not in the currently managed bucket")
   217  
   218  const deletionEmbargo = time.Hour * 24 * 7
   219  
   220  type DeprecateStats struct {
   221  	Needed   int // The count of assets currently needed in the dashboard.
   222  	Existing int // The number of assets currently stored.
   223  	Deleted  int // How many were deleted during DeprecateAssets().
   224  }
   225  
   226  // Best way: convert download URLs to paths.
   227  // We don't want to risk killing all assets after a slight domain change.
   228  func (storage *Storage) DeprecateAssets() (DeprecateStats, error) {
   229  	var stats DeprecateStats
   230  	resp, err := storage.dash.NeededAssetsList()
   231  	if err != nil {
   232  		return stats, fmt.Errorf("failed to query needed assets: %w", err)
   233  	}
   234  	needed := map[string]bool{}
   235  	for _, url := range resp.DownloadURLs {
   236  		path, err := storage.backend.getPath(url)
   237  		if err == ErrUnknownBucket {
   238  			// The asset is not managed by the particular instance.
   239  			continue
   240  		} else if err != nil {
   241  			// If we failed to parse just one URL, let's stop the entire process.
   242  			// Otherwise we'll start deleting still needed files we couldn't recognize.
   243  			return stats, fmt.Errorf("failed to parse '%s': %w", url, err)
   244  		}
   245  		needed[path] = true
   246  	}
   247  	stats.Needed = len(needed)
   248  	storage.tracer.Log("queried needed assets: %#v", needed)
   249  
   250  	existing, err := storage.backend.list()
   251  	if err != nil {
   252  		return stats, fmt.Errorf("failed to query object list: %w", err)
   253  	}
   254  	stats.Existing = len(existing)
   255  	toDelete := []string{}
   256  	intersection := 0
   257  	for _, obj := range existing {
   258  		keep := false
   259  		if time.Since(obj.CreatedAt) < deletionEmbargo {
   260  			// To avoid races between object upload and object deletion, we don't delete
   261  			// newly uploaded files for a while after they're uploaded.
   262  			keep = true
   263  		}
   264  		if val, ok := needed[obj.Path]; ok && val {
   265  			keep = true
   266  			intersection++
   267  		}
   268  		storage.tracer.Log("-- object %v, %v: keep %t", obj.Path, obj.CreatedAt, keep)
   269  		if !keep {
   270  			toDelete = append(toDelete, obj.Path)
   271  		}
   272  	}
   273  	const intersectionCheckCutOff = 4
   274  	if len(existing) > intersectionCheckCutOff && intersection == 0 {
   275  		// This is a last-resort protection against possible dashboard bugs.
   276  		// If the needed assets have no intersection with the existing assets,
   277  		// don't delete anything. Otherwise, if it was a bug, we will lose all files.
   278  		return stats, fmt.Errorf("needed assets have almost no intersection with the existing ones")
   279  	}
   280  	for _, path := range toDelete {
   281  		err := storage.backend.remove(path)
   282  		storage.tracer.Log("-- deleted %v: %v", path, err)
   283  		// Several syz-ci's might be sharing the same storage. So let's tolerate
   284  		// races during file deletion.
   285  		if err != nil && err != ErrAssetDoesNotExist {
   286  			return stats, fmt.Errorf("asset deletion failure: %w", err)
   287  		}
   288  	}
   289  	stats.Deleted = len(toDelete)
   290  	return stats, nil
   291  }
   292  
   293  type uploadRequest struct {
   294  	savePath          string
   295  	contentEncoding   string
   296  	contentType       string
   297  	preserveExtension bool
   298  }
   299  
   300  type uploadResponse struct {
   301  	path   string
   302  	writer io.WriteCloser
   303  }
   304  
   305  type StorageBackend interface {
   306  	upload(req *uploadRequest) (*uploadResponse, error)
   307  	list() ([]*gcs.Object, error)
   308  	remove(path string) error
   309  	downloadURL(path string, publicURL bool) (string, error)
   310  	getPath(url string) (string, error)
   311  }
   312  
   313  type Compressor func(req *uploadRequest,
   314  	next func(req *uploadRequest) (*uploadResponse, error)) (*uploadResponse, error)
   315  
   316  func xzCompressor(req *uploadRequest,
   317  	next func(req *uploadRequest) (*uploadResponse, error)) (*uploadResponse, error) {
   318  	newReq := *req
   319  	if !req.preserveExtension {
   320  		newReq.savePath = fmt.Sprintf("%s.xz", newReq.savePath)
   321  	}
   322  	resp, err := next(&newReq)
   323  	if err != nil {
   324  		return nil, err
   325  	}
   326  	xzWriter, err := xz.NewWriter(resp.writer)
   327  	if err != nil {
   328  		return nil, fmt.Errorf("failed to create xz writer: %w", err)
   329  	}
   330  	return &uploadResponse{
   331  		path: resp.path,
   332  		writer: &wrappedWriteCloser{
   333  			writer:        xzWriter,
   334  			closeCallback: resp.writer.Close,
   335  		},
   336  	}, nil
   337  }
   338  
   339  const gzipCompressionRatio = 4
   340  
   341  // This struct allows to attach a callback on the Close() method invocation of
   342  // an existing io.WriteCloser. Also, it can convert an io.Writer to an io.WriteCloser.
   343  type wrappedWriteCloser struct {
   344  	writer        io.Writer
   345  	closeCallback func() error
   346  }
   347  
   348  func (wwc *wrappedWriteCloser) Write(p []byte) (int, error) {
   349  	return wwc.writer.Write(p)
   350  }
   351  
   352  func (wwc *wrappedWriteCloser) Close() error {
   353  	var err error
   354  	closer, ok := wwc.writer.(io.Closer)
   355  	if ok {
   356  		err = closer.Close()
   357  	}
   358  	err2 := wwc.closeCallback()
   359  	if err != nil {
   360  		return err
   361  	} else if err2 != nil {
   362  		return err2
   363  	}
   364  	return nil
   365  }
   366  
   367  func gzipCompressor(req *uploadRequest,
   368  	next func(req *uploadRequest) (*uploadResponse, error)) (*uploadResponse, error) {
   369  	newReq := *req
   370  	if !req.preserveExtension {
   371  		newReq.savePath = fmt.Sprintf("%s.gz", newReq.savePath)
   372  	}
   373  	resp, err := next(&newReq)
   374  	if err != nil {
   375  		return nil, err
   376  	}
   377  	gzip, err := gzip.NewWriterLevel(resp.writer, gzipCompressionRatio)
   378  	if err != nil {
   379  		resp.writer.Close()
   380  		return nil, err
   381  	}
   382  	return &uploadResponse{
   383  		path: resp.path,
   384  		writer: &wrappedWriteCloser{
   385  			writer: gzip,
   386  			closeCallback: func() error {
   387  				return resp.writer.Close()
   388  			},
   389  		},
   390  	}, nil
   391  }