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