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 }