// Copyright 2025 Versity Software // This file is licensed under the Apache License, Version 2.0 // (the "License"); you may not use this file except in compliance // with the License. You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, // software distributed under the License is distributed on an // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY // KIND, either express or implied. See the License for the // specific language governing permissions and limitations // under the License. package meta import ( "errors" "fmt" "io" "os" "path/filepath" "syscall" "github.com/versity/versitygw/s3err" ) // SideCar is a metadata storer that uses sidecar files to store metadata. type SideCar struct { dir string } const ( sidecarmeta = "meta" ) // NewSideCar creates a new SideCar metadata storer. func NewSideCar(dir string) (SideCar, error) { fi, err := os.Lstat(dir) if err != nil { return SideCar{}, fmt.Errorf("failed to stat directory: %v", err) } if !fi.IsDir() { return SideCar{}, fmt.Errorf("not a directory") } return SideCar{dir: dir}, nil } // RetrieveAttribute retrieves the value of a specific attribute for an object or a bucket. func (s SideCar) RetrieveAttribute(_ *os.File, bucket, object, attribute string) ([]byte, error) { bucket, object = trimVolume(bucket), trimVolume(object) metadir := filepath.Join(s.dir, bucket, object, sidecarmeta) if object == "" { metadir = filepath.Join(s.dir, bucket, sidecarmeta) } attr := filepath.Join(metadir, attribute) value, err := os.ReadFile(attr) if errors.Is(err, os.ErrNotExist) { return nil, ErrNoSuchKey } if err != nil { return nil, fmt.Errorf("failed to read attribute: %v", err) } return value, nil } // StoreAttribute stores the value of a specific attribute for an object or a bucket. func (s SideCar) StoreAttribute(_ *os.File, bucket, object, attribute string, value []byte) error { bucket, object = trimVolume(bucket), trimVolume(object) metadir := filepath.Join(s.dir, bucket, object, sidecarmeta) if object == "" { metadir = filepath.Join(s.dir, bucket, sidecarmeta) } err := os.MkdirAll(metadir, 0777) if err != nil { if errors.Is(err, syscall.ENOSPC) { return s3err.GetAPIError(s3err.ErrNoSpaceLeftOnDevice) } return fmt.Errorf("failed to create metadata directory: %v", err) } attr := filepath.Join(metadir, attribute) tempfile, err := os.CreateTemp(metadir, attribute) if err != nil { if errors.Is(err, syscall.ENOSPC) { return s3err.GetAPIError(s3err.ErrNoSpaceLeftOnDevice) } return fmt.Errorf("failed to create temporary file: %v", err) } defer os.Remove(tempfile.Name()) _, err = tempfile.Write(value) if err != nil { tempfile.Close() if errors.Is(err, syscall.ENOSPC) { return s3err.GetAPIError(s3err.ErrNoSpaceLeftOnDevice) } return fmt.Errorf("failed to write attribute: %v", err) } // Close explicitly before rename to prevent error on Windows: // The process cannot access the file because it is being used by another process. if err = tempfile.Close(); err != nil { return fmt.Errorf("failed to close temporary file: %v", err) } err = os.Rename(tempfile.Name(), attr) if err != nil { return fmt.Errorf("failed to rename temporary file: %v", err) } return nil } // DeleteAttribute removes the value of a specific attribute for an object or a bucket. func (s SideCar) DeleteAttribute(bucket, object, attribute string) error { bucket, object = trimVolume(bucket), trimVolume(object) metadir := filepath.Join(s.dir, bucket, object, sidecarmeta) if object == "" { metadir = filepath.Join(s.dir, bucket, sidecarmeta) } attr := filepath.Join(metadir, attribute) err := os.Remove(attr) if errors.Is(err, os.ErrNotExist) { return ErrNoSuchKey } if err != nil { return fmt.Errorf("failed to remove attribute: %v", err) } s.cleanupEmptyDirs(metadir, bucket, object) return nil } // ListAttributes lists all attributes for an object or a bucket. func (s SideCar) ListAttributes(bucket, object string) ([]string, error) { bucket, object = trimVolume(bucket), trimVolume(object) metadir := filepath.Join(s.dir, bucket, object, sidecarmeta) if object == "" { metadir = filepath.Join(s.dir, bucket, sidecarmeta) } ents, err := os.ReadDir(metadir) if errors.Is(err, os.ErrNotExist) { return []string{}, nil } if err != nil { return nil, fmt.Errorf("failed to list attributes: %v", err) } var attrs []string for _, ent := range ents { attrs = append(attrs, ent.Name()) } return attrs, nil } // DeleteAttributes removes all attributes for an object or a bucket. // When object is empty the entire bucket sidecar directory is removed, // cleaning up any orphaned object or multipart metadata within it. func (s SideCar) DeleteAttributes(bucket, object string) error { bucket, object = trimVolume(bucket), trimVolume(object) if object == "" { // Remove the entire bucket sidecar directory so that orphaned // object/multipart metadata does not accumulate after DeleteBucket. bucketDir := filepath.Join(s.dir, bucket) err := os.RemoveAll(bucketDir) if err != nil && !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("failed to remove bucket attributes: %w", err) } return nil } metadir := filepath.Join(s.dir, bucket, object, sidecarmeta) err := os.RemoveAll(metadir) if err != nil && !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("failed to remove attributes: %w", err) } s.cleanupEmptyDirs(metadir, bucket, object) return nil } // RenameObject renames the sidecar metadata directory from oldObject to // newObject so that path-based lookups continue to work after the data // directory has been renamed. func (s SideCar) RenameObject(bucket, oldObject, newObject string) error { bucket = trimVolume(bucket) oldPath := filepath.Join(s.dir, bucket, trimVolume(oldObject)) newPath := filepath.Join(s.dir, bucket, trimVolume(newObject)) if err := os.MkdirAll(filepath.Dir(newPath), 0777); err != nil { if errors.Is(err, syscall.ENOSPC) { return s3err.GetAPIError(s3err.ErrNoSpaceLeftOnDevice) } return fmt.Errorf("create parent for renamed metadata: %w", err) } err := os.Rename(oldPath, newPath) if errors.Is(err, os.ErrNotExist) { // No metadata stored yet — nothing to rename. return nil } return err } func (s SideCar) cleanupEmptyDirs(metadir, bucket, object string) { removeIfEmpty(metadir) if bucket == "" { return } bucketDir := filepath.Join(s.dir, bucket) if object != "" { removeEmptyParents(filepath.Dir(metadir), bucketDir) } removeIfEmpty(bucketDir) } func removeIfEmpty(dir string) { empty, err := isDirEmpty(dir) if err != nil || !empty { return } _ = os.Remove(dir) } func removeEmptyParents(dir, stopDir string) { for { if dir == stopDir || dir == "." || dir == string(filepath.Separator) { return } empty, err := isDirEmpty(dir) if err != nil || !empty { return } err = os.Remove(dir) if err != nil { return } dir = filepath.Dir(dir) } } func isDirEmpty(dir string) (bool, error) { f, err := os.Open(dir) if err != nil { return false, err } defer f.Close() ents, err := f.Readdirnames(1) if err == io.EOF { return true, nil } if err != nil { return false, err } return len(ents) == 0, nil }