blob: 88cad4a363cfff97087bf06a9012cb1219ec292d [file] [log] [blame]
// Copyright 2023 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package database
import (
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"golang.org/x/exp/slices"
"golang.org/x/vuln/osv"
"golang.org/x/vulndb/internal/derrors"
"golang.org/x/vulndb/internal/report"
)
// Load loads a database assuming that path contains a full, valid
// database following the v1 specification.
//
// It errors if:
// - any required files are missing or invalid
// - any unexpected files are found in the index/ or ID/ folders
// (with the exception that ID/index.json, from the legacy spec, is ignored)
//
// Any files present in the top level directory are ignored.
func Load(path string) (_ *Database, err error) {
defer derrors.Wrap(&err, "Load(%q)", path)
db, err := RawLoad(filepath.Join(path, idDir))
if err != nil {
return nil, err
}
requireGzip := true
if err := db.validateIndex(filepath.Join(path, indexDir), requireGzip); err != nil {
return nil, err
}
if err := db.validateEntries(filepath.Join(path, idDir), requireGzip); err != nil {
return nil, err
}
return db, nil
}
// RawLoad loads a database assuming that vulnsPath contains ".json" files
// representing OSV entries.
// It errors if any of the files cannot be unmarshaled into osv.Entry.
// It does not require any database indexes or gzipped files to be present,
// Directories and non-JSON files are ignored.
// Also, to accommodate the legacy spec, the file "index.json" is ignored
// if present.
func RawLoad(vulnsPath string) (_ *Database, err error) {
defer derrors.Wrap(&err, "RawLoad(%q)", vulnsPath)
db, err := New()
if err != nil {
return nil, err
}
if err := filepath.WalkDir(vulnsPath, func(path string, f fs.DirEntry, err error) error {
if err != nil {
return err
}
fname := f.Name()
if f.IsDir() ||
fname == "index.json" ||
filepath.Ext(fname) != ".json" {
return nil
}
var entry osv.Entry
if err = report.UnmarshalFromFile(path, &entry); err != nil {
return fmt.Errorf("could not unmarshal %q: %v", path, err)
}
return db.Add(entry)
}); err != nil {
return nil, err
}
return db, nil
}
func (db *Database) validateIndex(indexPath string, requireGzip bool) (err error) {
defer derrors.Wrap(&err, "validateIndex(%q)", indexPath)
// Check that the index files are present and have the correct
// contents.
dbPath := filepath.Join(indexPath, dbEndpoint)
if err := checkFiles(dbPath, db.DB, requireGzip); err != nil {
return err
}
modulesPath := filepath.Join(indexPath, modulesEndpoint)
if err := checkFiles(modulesPath, db.Modules, requireGzip); err != nil {
return err
}
vulnsPath := filepath.Join(indexPath, vulnsEndpoint)
if err := checkFiles(vulnsPath, db.Vulns, requireGzip); err != nil {
return err
}
// Check for unexpected files in the index folder.
expected := []string{
indexDir,
dbEndpoint, dbEndpoint + ".gz",
modulesEndpoint, modulesEndpoint + ".gz",
vulnsEndpoint, vulnsEndpoint + ".gz",
}
return checkNoUnexpectedFiles(indexPath, expected)
}
func (db *Database) validateEntries(idPath string, requireGzip bool) (err error) {
defer derrors.Wrap(&err, "validateEntries(%q)", idPath)
expected := []string{
idDir,
"index.json", // index.json is OK to accommodate legacy spec
}
for _, entry := range db.Entries {
if err = validateEntry(entry); err != nil {
return err
}
path := filepath.Join(idPath, entry.ID+".json")
if err = checkFiles(path, entry, requireGzip); err != nil {
return err
}
expected = append(expected, entry.ID+".json", entry.ID+".json.gz")
}
return checkNoUnexpectedFiles(idPath, expected)
}
func checkNoUnexpectedFiles(path string, expected []string) error {
if err := filepath.WalkDir(path, func(path string, f fs.DirEntry, err error) error {
if err != nil {
return err
}
if !slices.Contains(expected, f.Name()) {
return fmt.Errorf("unexpected file %s", f.Name())
}
return nil
}); err != nil {
return err
}
return nil
}
func validateEntry(entry osv.Entry) error {
if entry.Modified.IsZero() {
return fmt.Errorf("%s: modified time must be non-zero (found %s)", entry.ID, entry.Modified)
}
if entry.Published.After(entry.Modified) {
return fmt.Errorf("%s: published time (%s) cannot be after modified time (%s)", entry.ID, entry.Published, entry.Modified)
}
return nil
}
// checkFiles ensures that filepath and filepath+".gz" exist and
// have contents consistent with v.
// Returns an error if:
// - any expected files don't exist or v cannot be marshaled
// - the contents of filepath do not match the result
// of marshaling v
// - the uncompressed contents of filepath+".gz" do not match the
// contents of filepath
func checkFiles(filepath string, v any, requireGzip bool) (err error) {
defer derrors.Wrap(&err, "checkFiles(%q)", filepath)
contents, err := os.ReadFile(filepath)
if err != nil {
return err
}
marshaled, err := json.Marshal(v)
if err != nil {
return err
}
if string(contents) != string(marshaled) {
return fmt.Errorf("%s: contents do not match marshaled bytes", filepath)
}
if requireGzip {
gzipped, err := readGzipped(filepath + ".gz")
if err != nil {
return err
}
if string(contents) != string(gzipped) {
return fmt.Errorf("%s: contents do not match uncompressed file", filepath+".gz")
}
}
return nil
}