diff options
| -rw-r--r-- | main.go | 125 | 
1 files changed, 122 insertions, 3 deletions
| @@ -22,6 +22,7 @@ import (  	"path/filepath"  	"regexp"  	"runtime" +	"slices"  	"strconv"  	"strings"  	"sync" @@ -853,7 +854,74 @@ func cmdTag(args []string) error {  // --- Check ------------------------------------------------------------------- -// cmdCheck checks if all files tracked in the DB are accessible. +func isValidSHA1(hash string) bool { +	if len(hash) != sha1.Size*2 || strings.ToLower(hash) != hash { +		return false +	} +	if _, err := hex.DecodeString(hash); err != nil { +		return false +	} +	return true +} + +func hashesToFileListing(root, suffix string, hashes []string) []string { +	// Note that we're semi-duplicating {image,thumb}Path(). +	paths := []string{root} +	for _, hash := range hashes { +		dir := filepath.Join(root, hash[:2]) +		paths = append(paths, dir, filepath.Join(dir, hash+suffix)) +	} +	slices.Sort(paths) +	return slices.Compact(paths) +} + +func collectFileListing(root string) (paths []string, err error) { +	err = filepath.WalkDir(root, +		func(path string, d fs.DirEntry, err error) error { +			paths = append(paths, path) +			return err +		}) + +	// Even though it should already be sorted somehow. +	slices.Sort(paths) +	return +} + +func checkFiles(root, suffix string, hashes []string) (bool, []string, error) { +	db := hashesToFileListing(root, suffix, hashes) +	fs, err := collectFileListing(root) +	if err != nil { +		return false, nil, err +	} + +	iDB, iFS, ok, intersection := 0, 0, true, []string{} +	for iDB < len(db) && iFS < len(fs) { +		if db[iDB] == fs[iFS] { +			intersection = append(intersection, db[iDB]) +			iDB++ +			iFS++ +		} else if db[iDB] < fs[iFS] { +			ok = false +			fmt.Printf("only in DB: %s\n", db[iDB]) +			iDB++ +		} else { +			ok = false +			fmt.Printf("only in FS: %s\n", fs[iFS]) +			iFS++ +		} +	} +	for _, path := range db[iDB:] { +		ok = false +		fmt.Printf("only in DB: %s\n", path) +	} +	for _, path := range fs[iFS:] { +		ok = false +		fmt.Printf("only in FS: %s\n", path) +	} +	return ok, intersection, nil +} + +// cmdCheck carries out various database consistency checks.  func cmdCheck(args []string) error {  	if len(args) != 1 {  		return errors.New("usage: GD") @@ -862,8 +930,59 @@ func cmdCheck(args []string) error {  		return err  	} -	// TODO: Check if all hashes of DB entries have a statable image file, -	// and that all images with thumb{w,h} have a thumbnail file. Perhaps. +	// Check if hashes are in the right format. +	log.Println("checking image hashes") + +	allSHA1, err := dbCollectStrings(`SELECT sha1 FROM image`) +	if err != nil { +		return err +	} + +	ok := true +	for _, hash := range allSHA1 { +		if !isValidSHA1(hash) { +			ok = false +			fmt.Printf("invalid image SHA1: %s\n", hash) +		} +	} + +	// This is, rather obviously, just a strict subset. +	// Although it doesn't run in the same transaction. +	thumbSHA1, err := dbCollectStrings(`SELECT sha1 FROM image +		WHERE thumbw IS NOT NULL OR thumbh IS NOT NULL`) +	if err != nil { +		return err +	} + +	// This somewhat duplicates {image,thumb}Path(). +	log.Println("checking SQL against filesystem") +	okImages, intersection, err := checkFiles( +		filepath.Join(galleryDirectory, "images"), "", allSHA1) +	if err != nil { +		return err +	} + +	okThumbs, _, err := checkFiles( +		filepath.Join(galleryDirectory, "thumbs"), ".webp", thumbSHA1) +	if err != nil { +		return err +	} +	if !okImages || !okThumbs { +		ok = false +	} + +	// NOTE: We could also compare mtime, and on mismatch the current SHA1, +	// though that's more of a "sync" job. +	log.Println("checking for dead symlinks") +	for _, path := range intersection { +		if _, err := os.Stat(path); err != nil { +			ok = false +			fmt.Printf("%s: %s\n", path, err) +		} +	} +	if !ok { +		return errors.New("detected inconsistencies") +	}  	return nil  } | 
