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 }