check / check (push) Waiting to run
Source and variant files are synced to disk before the rename into place, and their directory after. Lookup and LookupSource compare the file's size with the size its row records: a file that is empty or of another size is removed with its rows and a warning naming it, and the request converts the image or fetches the source again. A variant whose row cannot be read is judged by its file alone. Storing a source over a file of another size replaces it. A source that fails to decode is not removed for that alone. #252 Model: opus-5-5
666 lines
17 KiB
Go
666 lines
17 KiB
Go
package imgcache
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
)
|
|
|
|
// Storage constants.
|
|
const (
|
|
// StorageDirPerm is the permission mode for storage directories.
|
|
StorageDirPerm = 0750
|
|
// StorageFilePerm is the permission mode for storage files.
|
|
StorageFilePerm = 0600
|
|
// MinHashLength is the minimum hash length for path splitting.
|
|
MinHashLength = 4
|
|
)
|
|
|
|
// Storage errors.
|
|
var (
|
|
ErrNotFound = errors.New("content not found")
|
|
)
|
|
|
|
// ContentHash is a SHA256 hash of file content (hex-encoded).
|
|
type ContentHash string
|
|
|
|
// VariantKey is a SHA256 hash identifying a specific image variant (hex-encoded).
|
|
type VariantKey string
|
|
|
|
// PathHash is a SHA256 hash of a URL path (hex-encoded).
|
|
type PathHash string
|
|
|
|
// ContentStorage handles content-addressable file storage.
|
|
// Files are stored at: <basedir>/<ab>/<cd>/<abcdef...sha256>
|
|
type ContentStorage struct {
|
|
baseDir string
|
|
}
|
|
|
|
// NewContentStorage creates a new content storage at the given base directory.
|
|
func NewContentStorage(baseDir string) (*ContentStorage, error) {
|
|
err := os.MkdirAll(baseDir, StorageDirPerm)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create storage directory: %w", err)
|
|
}
|
|
|
|
return &ContentStorage{baseDir: baseDir}, nil
|
|
}
|
|
|
|
// Store writes content to storage and returns its SHA256 hash.
|
|
// The content is read fully into memory to compute the hash before writing.
|
|
func (s *ContentStorage) Store(r io.Reader) (ContentHash, int64, error) {
|
|
// Read all content to compute hash
|
|
data, err := io.ReadAll(r)
|
|
if err != nil {
|
|
return "", 0, fmt.Errorf("failed to read content: %w", err)
|
|
}
|
|
|
|
// Compute hash
|
|
h := sha256.Sum256(data)
|
|
hash := ContentHash(hex.EncodeToString(h[:]))
|
|
size := int64(len(data))
|
|
|
|
err = s.writeIfAbsent(hash, data)
|
|
if err != nil {
|
|
return "", 0, err
|
|
}
|
|
|
|
return hash, size, nil
|
|
}
|
|
|
|
// StoreHashed writes pre-hashed content to storage at the path derived
|
|
// from hash, without recomputing it. Callers that already know the
|
|
// hash before writing (e.g. because they must hold a hash-keyed lock
|
|
// across the whole store operation) use this instead of Store. Like
|
|
// Store, it is idempotent: a file of the content's size already at that
|
|
// path is left untouched, and a file of another size is replaced.
|
|
func (s *ContentStorage) StoreHashed(
|
|
hash ContentHash, data []byte,
|
|
) (int64, error) {
|
|
err := s.writeIfAbsent(hash, data)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return int64(len(data)), nil
|
|
}
|
|
|
|
// Load returns a reader for the content with the given hash.
|
|
func (s *ContentStorage) Load(hash ContentHash) (io.ReadCloser, error) {
|
|
path := s.hashToPath(hash)
|
|
|
|
f, err := os.Open(path) //nolint:gosec // path derived from content hash
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, ErrNotFound
|
|
}
|
|
|
|
return nil, fmt.Errorf("failed to open content: %w", err)
|
|
}
|
|
|
|
return f, nil
|
|
}
|
|
|
|
// LoadWithSize returns a reader and file size for the content with the given hash.
|
|
func (s *ContentStorage) LoadWithSize(hash ContentHash) (io.ReadCloser, int64, error) {
|
|
path := s.hashToPath(hash)
|
|
|
|
f, err := os.Open(path) //nolint:gosec // path derived from content hash
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, 0, ErrNotFound
|
|
}
|
|
|
|
return nil, 0, fmt.Errorf("failed to open content: %w", err)
|
|
}
|
|
|
|
stat, err := f.Stat()
|
|
if err != nil {
|
|
_ = f.Close()
|
|
|
|
return nil, 0, fmt.Errorf("failed to stat content: %w", err)
|
|
}
|
|
|
|
return f, stat.Size(), nil
|
|
}
|
|
|
|
// Delete removes content with the given hash.
|
|
func (s *ContentStorage) Delete(hash ContentHash) error {
|
|
path := s.hashToPath(hash)
|
|
|
|
err := os.Remove(path)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("failed to delete content: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Exists checks if content with the given hash exists.
|
|
func (s *ContentStorage) Exists(hash ContentHash) bool {
|
|
path := s.hashToPath(hash)
|
|
_, err := os.Stat(path)
|
|
|
|
return err == nil
|
|
}
|
|
|
|
// Size returns the size in bytes of the content with the given hash.
|
|
func (s *ContentStorage) Size(hash ContentHash) (int64, error) {
|
|
info, err := os.Stat(s.hashToPath(hash))
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return 0, ErrNotFound
|
|
}
|
|
|
|
return 0, fmt.Errorf("failed to stat content: %w", err)
|
|
}
|
|
|
|
return info.Size(), nil
|
|
}
|
|
|
|
// writeIfAbsent writes data to the path derived from hash, unless a
|
|
// file of the data's size is already there. It writes a temp file,
|
|
// syncs it to disk and renames it into place, so neither a concurrent
|
|
// reader nor a crash finds a partial file under that path.
|
|
func (s *ContentStorage) writeIfAbsent(hash ContentHash, data []byte) error {
|
|
// Build path: <basedir>/<ab>/<cd>/<hash>
|
|
path := s.hashToPath(hash)
|
|
|
|
// A file of another size there is damaged, and is replaced
|
|
info, statErr := os.Stat(path)
|
|
if statErr == nil && info.Size() == int64(len(data)) {
|
|
return nil
|
|
}
|
|
|
|
// Create directory structure
|
|
dir := filepath.Dir(path)
|
|
|
|
err := os.MkdirAll(dir, StorageDirPerm)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create directory: %w", err)
|
|
}
|
|
|
|
// Write to temp file first, then rename for atomicity
|
|
tmpFile, err := os.CreateTemp(dir, ".tmp-*")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create temp file: %w", err)
|
|
}
|
|
|
|
tmpPath := tmpFile.Name()
|
|
|
|
// Each failure path below unlinks the temp file explicitly. This
|
|
// replaces a deferred cleanup that read a named result, which the
|
|
// canonical config does not permit; the set of paths that remove
|
|
// tmpPath, and the order relative to Close, is unchanged. This
|
|
// mirrors how MetadataStorage.Store and VariantStorage.Store below
|
|
// already express the same cleanup.
|
|
_, err = tmpFile.Write(data)
|
|
if err != nil {
|
|
_ = tmpFile.Close()
|
|
_ = os.Remove(tmpPath)
|
|
|
|
return fmt.Errorf("failed to write content: %w", err)
|
|
}
|
|
|
|
err = tmpFile.Sync()
|
|
if err != nil {
|
|
_ = tmpFile.Close()
|
|
_ = os.Remove(tmpPath)
|
|
|
|
return fmt.Errorf("failed to sync temp file: %w", err)
|
|
}
|
|
|
|
err = tmpFile.Close()
|
|
if err != nil {
|
|
_ = os.Remove(tmpPath)
|
|
|
|
return fmt.Errorf("failed to close temp file: %w", err)
|
|
}
|
|
|
|
// Atomic rename
|
|
err = os.Rename(filepath.Clean(tmpPath), filepath.Clean(path))
|
|
if err != nil {
|
|
_ = os.Remove(tmpPath)
|
|
|
|
return fmt.Errorf("failed to rename temp file: %w", err)
|
|
}
|
|
|
|
return syncDirectory(dir)
|
|
}
|
|
|
|
// syncDirectory syncs the directory dir to disk, so that a file just
|
|
// renamed into it is still there after a crash.
|
|
func syncDirectory(dir string) error {
|
|
d, err := os.Open(filepath.Clean(dir))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open directory: %w", err)
|
|
}
|
|
|
|
err = d.Sync()
|
|
_ = d.Close()
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("failed to sync directory: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// hashToPath converts a hash to a file path: <basedir>/<ab>/<cd>/<hash>
|
|
func (s *ContentStorage) hashToPath(hash ContentHash) string {
|
|
h := string(hash)
|
|
if len(h) < MinHashLength {
|
|
return filepath.Clean(filepath.Join(s.baseDir, h))
|
|
}
|
|
|
|
return filepath.Clean(filepath.Join(s.baseDir, h[0:2], h[2:4], h))
|
|
}
|
|
|
|
// MetadataStorage handles JSON metadata file storage.
|
|
// Files are stored at: <basedir>/<hostname>/<path_hash>.json
|
|
type MetadataStorage struct {
|
|
baseDir string
|
|
}
|
|
|
|
// NewMetadataStorage creates a new metadata storage at the given base directory.
|
|
func NewMetadataStorage(baseDir string) (*MetadataStorage, error) {
|
|
err := os.MkdirAll(baseDir, StorageDirPerm)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create metadata directory: %w", err)
|
|
}
|
|
|
|
return &MetadataStorage{baseDir: baseDir}, nil
|
|
}
|
|
|
|
// SourceMetadata represents cached metadata about a source URL.
|
|
//
|
|
//nolint:tagliatelle // stored metadata format uses snake_case
|
|
type SourceMetadata struct {
|
|
Host string `json:"host"`
|
|
Path string `json:"path"`
|
|
Query string `json:"query,omitempty"`
|
|
ContentHash string `json:"content_hash,omitempty"`
|
|
StatusCode int `json:"status_code"`
|
|
ContentType string `json:"content_type,omitempty"`
|
|
ContentLength int64 `json:"content_length,omitempty"`
|
|
ResponseHeaders map[string][]string `json:"response_headers,omitempty"`
|
|
FetchedAt int64 `json:"fetched_at"`
|
|
FetchDurationMs int64 `json:"fetch_duration_ms,omitempty"`
|
|
ExpiresAt int64 `json:"expires_at,omitempty"`
|
|
ETag string `json:"etag,omitempty"`
|
|
LastModified string `json:"last_modified,omitempty"`
|
|
RemoteAddr string `json:"remote_addr,omitempty"`
|
|
}
|
|
|
|
// Store writes metadata to storage.
|
|
func (s *MetadataStorage) Store(
|
|
host string, pathHash PathHash, meta *SourceMetadata,
|
|
) error {
|
|
path := s.metaPath(host, pathHash)
|
|
|
|
// Create directory structure
|
|
dir := filepath.Dir(path)
|
|
|
|
err := os.MkdirAll(dir, StorageDirPerm)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create directory: %w", err)
|
|
}
|
|
|
|
// Marshal to JSON
|
|
data, err := json.MarshalIndent(meta, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal metadata: %w", err)
|
|
}
|
|
|
|
// Write to temp file first, then rename for atomicity
|
|
tmpFile, err := os.CreateTemp(dir, ".tmp-*.json")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create temp file: %w", err)
|
|
}
|
|
|
|
tmpPath := tmpFile.Name()
|
|
|
|
_, err = tmpFile.Write(data)
|
|
if err != nil {
|
|
_ = tmpFile.Close()
|
|
_ = os.Remove(tmpPath)
|
|
|
|
return fmt.Errorf("failed to write metadata: %w", err)
|
|
}
|
|
|
|
err = tmpFile.Close()
|
|
if err != nil {
|
|
_ = os.Remove(tmpPath)
|
|
|
|
return fmt.Errorf("failed to close temp file: %w", err)
|
|
}
|
|
|
|
// Atomic rename
|
|
err = os.Rename(filepath.Clean(tmpPath), filepath.Clean(path))
|
|
if err != nil {
|
|
_ = os.Remove(tmpPath)
|
|
|
|
return fmt.Errorf("failed to rename temp file: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Load reads metadata from storage.
|
|
func (s *MetadataStorage) Load(
|
|
host string, pathHash PathHash,
|
|
) (*SourceMetadata, error) {
|
|
path := s.metaPath(host, pathHash)
|
|
|
|
data, err := os.ReadFile(path) //nolint:gosec // path derived from host+hash
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, ErrNotFound
|
|
}
|
|
|
|
return nil, fmt.Errorf("failed to read metadata: %w", err)
|
|
}
|
|
|
|
var meta SourceMetadata
|
|
|
|
err = json.Unmarshal(data, &meta)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to unmarshal metadata: %w", err)
|
|
}
|
|
|
|
return &meta, nil
|
|
}
|
|
|
|
// Delete removes metadata for the given host and path hash.
|
|
func (s *MetadataStorage) Delete(host string, pathHash PathHash) error {
|
|
path := s.metaPath(host, pathHash)
|
|
|
|
err := os.Remove(path)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("failed to delete metadata: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Exists checks if metadata exists for the given host and path hash.
|
|
func (s *MetadataStorage) Exists(host string, pathHash PathHash) bool {
|
|
path := s.metaPath(host, pathHash)
|
|
_, err := os.Stat(path)
|
|
|
|
return err == nil
|
|
}
|
|
|
|
// metaPath returns the file path for metadata: <basedir>/<host>/<path_hash>.json
|
|
func (s *MetadataStorage) metaPath(host string, pathHash PathHash) string {
|
|
return filepath.Clean(filepath.Join(s.baseDir, host, string(pathHash)+".json"))
|
|
}
|
|
|
|
// HashPath computes the SHA256 hash of a path string.
|
|
func HashPath(path string) PathHash {
|
|
h := sha256.Sum256([]byte(path))
|
|
|
|
return PathHash(hex.EncodeToString(h[:]))
|
|
}
|
|
|
|
// CacheKey generates a unique key for a request variant.
|
|
// Format: sha256(host:path:query:width:height:format:quality:fit_mode)
|
|
func CacheKey(req *ImageRequest) VariantKey {
|
|
data := fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d:%s",
|
|
req.SourceHost,
|
|
req.SourcePath,
|
|
req.SourceQuery,
|
|
req.Size.Width,
|
|
req.Size.Height,
|
|
req.Format,
|
|
req.Quality,
|
|
req.FitMode,
|
|
)
|
|
h := sha256.Sum256([]byte(data))
|
|
|
|
return VariantKey(hex.EncodeToString(h[:]))
|
|
}
|
|
|
|
// VariantStorage handles key-based file storage for processed image variants.
|
|
// Files are stored at: <basedir>/<ab>/<cd>/<cache_key>
|
|
// Metadata is stored at: <basedir>/<ab>/<cd>/<cache_key>.meta
|
|
// Unlike ContentStorage, the key is provided by the caller (not computed from content).
|
|
type VariantStorage struct {
|
|
baseDir string
|
|
log *slog.Logger
|
|
}
|
|
|
|
// VariantMeta contains metadata about a cached variant.
|
|
//
|
|
//nolint:tagliatelle // stored metadata format uses snake_case
|
|
type VariantMeta struct {
|
|
ContentType string `json:"content_type"`
|
|
Size int64 `json:"size"`
|
|
CreatedAt int64 `json:"created_at"`
|
|
}
|
|
|
|
// NewVariantStorage creates a new variant storage at the given base directory.
|
|
// A failed .meta write is logged to log.
|
|
func NewVariantStorage(baseDir string, log *slog.Logger) (*VariantStorage, error) {
|
|
err := os.MkdirAll(baseDir, StorageDirPerm)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create variant storage directory: %w", err)
|
|
}
|
|
|
|
return &VariantStorage{baseDir: baseDir, log: log}, nil
|
|
}
|
|
|
|
// Store writes content and metadata to storage at the given key.
|
|
func (s *VariantStorage) Store(
|
|
key VariantKey, r io.Reader, contentType string,
|
|
) (int64, error) {
|
|
data, err := io.ReadAll(r)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to read content: %w", err)
|
|
}
|
|
|
|
size := int64(len(data))
|
|
path := s.keyToPath(key)
|
|
metaPath := path + ".meta"
|
|
|
|
// Create directory structure
|
|
dir := filepath.Dir(path)
|
|
|
|
err = os.MkdirAll(dir, StorageDirPerm)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to create directory: %w", err)
|
|
}
|
|
|
|
// Write content to temp file first, sync it to disk, then rename for
|
|
// atomicity, so neither a concurrent reader nor a crash finds a
|
|
// partial file under the key's path
|
|
tmpFile, err := os.CreateTemp(dir, ".tmp-*")
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to create temp file: %w", err)
|
|
}
|
|
|
|
tmpPath := tmpFile.Name()
|
|
|
|
_, err = tmpFile.Write(data)
|
|
if err != nil {
|
|
_ = tmpFile.Close()
|
|
_ = os.Remove(tmpPath)
|
|
|
|
return 0, fmt.Errorf("failed to write content: %w", err)
|
|
}
|
|
|
|
err = tmpFile.Sync()
|
|
if err != nil {
|
|
_ = tmpFile.Close()
|
|
_ = os.Remove(tmpPath)
|
|
|
|
return 0, fmt.Errorf("failed to sync temp file: %w", err)
|
|
}
|
|
|
|
err = tmpFile.Close()
|
|
if err != nil {
|
|
_ = os.Remove(tmpPath)
|
|
|
|
return 0, fmt.Errorf("failed to close temp file: %w", err)
|
|
}
|
|
|
|
// Atomic rename content
|
|
err = os.Rename(filepath.Clean(tmpPath), filepath.Clean(path))
|
|
if err != nil {
|
|
_ = os.Remove(tmpPath)
|
|
|
|
return 0, fmt.Errorf("failed to rename temp file: %w", err)
|
|
}
|
|
|
|
err = syncDirectory(dir)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
// Write metadata
|
|
meta := VariantMeta{
|
|
ContentType: contentType,
|
|
Size: size,
|
|
CreatedAt: time.Now().UTC().Unix(),
|
|
}
|
|
|
|
metaData, err := json.Marshal(meta)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to marshal metadata: %w", err)
|
|
}
|
|
|
|
// Metadata write failure is non-fatal; content is already stored.
|
|
err = os.WriteFile(metaPath, metaData, StorageFilePerm)
|
|
if err != nil {
|
|
s.log.Warn("failed to write variant metadata sidecar",
|
|
"path", metaPath, "error", err)
|
|
}
|
|
|
|
return size, nil
|
|
}
|
|
|
|
// Load returns a reader for the content at the given key.
|
|
func (s *VariantStorage) Load(key VariantKey) (io.ReadCloser, error) {
|
|
path := s.keyToPath(key)
|
|
|
|
f, err := os.Open(path) //nolint:gosec // path derived from cache key
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, ErrNotFound
|
|
}
|
|
|
|
return nil, fmt.Errorf("failed to open content: %w", err)
|
|
}
|
|
|
|
return f, nil
|
|
}
|
|
|
|
// LoadWithSize returns a reader and file size for the content at the
|
|
// given key.
|
|
func (s *VariantStorage) LoadWithSize(key VariantKey) (io.ReadCloser, int64, error) {
|
|
path := s.keyToPath(key)
|
|
|
|
f, err := os.Open(path) //nolint:gosec // path derived from cache key
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, 0, ErrNotFound
|
|
}
|
|
|
|
return nil, 0, fmt.Errorf("failed to open content: %w", err)
|
|
}
|
|
|
|
stat, err := f.Stat()
|
|
if err != nil {
|
|
_ = f.Close()
|
|
|
|
return nil, 0, fmt.Errorf("failed to stat content: %w", err)
|
|
}
|
|
|
|
return f, stat.Size(), nil
|
|
}
|
|
|
|
// LoadWithMeta returns a reader, size, and content type for the content at
|
|
// the given key. The content type is read from the .meta file, and is
|
|
// empty when that file is missing or unreadable.
|
|
func (s *VariantStorage) LoadWithMeta(
|
|
key VariantKey,
|
|
) (io.ReadCloser, int64, string, error) {
|
|
f, size, err := s.LoadWithSize(key)
|
|
if err != nil {
|
|
return nil, 0, "", err
|
|
}
|
|
|
|
var contentType string
|
|
|
|
metaPath := s.keyToPath(key) + ".meta"
|
|
|
|
metaData, err := os.ReadFile(metaPath) //nolint:gosec // path derived from cache key
|
|
if err == nil {
|
|
var meta VariantMeta
|
|
if json.Unmarshal(metaData, &meta) == nil && meta.ContentType != "" {
|
|
contentType = meta.ContentType
|
|
}
|
|
}
|
|
|
|
return f, size, contentType, nil
|
|
}
|
|
|
|
// Exists checks if content exists at the given key.
|
|
func (s *VariantStorage) Exists(key VariantKey) bool {
|
|
path := s.keyToPath(key)
|
|
_, err := os.Stat(path)
|
|
|
|
return err == nil
|
|
}
|
|
|
|
// Size returns the size in bytes of the content at the given key.
|
|
func (s *VariantStorage) Size(key VariantKey) (int64, error) {
|
|
info, err := os.Stat(s.keyToPath(key))
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return 0, ErrNotFound
|
|
}
|
|
|
|
return 0, fmt.Errorf("failed to stat content: %w", err)
|
|
}
|
|
|
|
return info.Size(), nil
|
|
}
|
|
|
|
// Delete removes the content at the given key together with its .meta
|
|
// sidecar file. A missing file is not an error.
|
|
func (s *VariantStorage) Delete(key VariantKey) error {
|
|
path := s.keyToPath(key)
|
|
|
|
err := os.Remove(path)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("failed to delete content: %w", err)
|
|
}
|
|
|
|
metaPath := path + ".meta"
|
|
|
|
err = os.Remove(metaPath)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("failed to delete variant metadata: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// keyToPath converts a key to a file path: <basedir>/<ab>/<cd>/<key>
|
|
func (s *VariantStorage) keyToPath(key VariantKey) string {
|
|
k := string(key)
|
|
if len(k) < MinHashLength {
|
|
return filepath.Join(s.baseDir, k)
|
|
}
|
|
|
|
return filepath.Join(s.baseDir, k[0:2], k[2:4], k)
|
|
}
|