package docstore import ( "context" "fmt" "os" "path/filepath" "strings" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/mlogger" "go.uber.org/zap" ) type LocalStore struct { logger mlogger.Logger rootPath string } func NewLocalStore(logger mlogger.Logger, cfg LocalConfig) (*LocalStore, error) { root := strings.TrimSpace(cfg.RootPath) if root == "" { return nil, merrors.InvalidArgument("docstore: local root_path is empty") } store := &LocalStore{ logger: logger.Named("docstore").Named("local"), rootPath: root, } store.logger.Info("Document storage initialised", zap.String("root_path", root)) return store, nil } func (s *LocalStore) Save(ctx context.Context, key string, data []byte) error { if err := ctx.Err(); err != nil { return err } path, err := resolveStoragePath(s.rootPath, key) if err != nil { return err } if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { s.logger.Warn("Failed to create document directory", zap.Error(err), zap.String("path", path)) return err } if err := os.WriteFile(path, data, 0o600); err != nil { s.logger.Warn("Failed to write document file", zap.Error(err), zap.String("path", path)) return err } return nil } func (s *LocalStore) Load(ctx context.Context, key string) ([]byte, error) { if err := ctx.Err(); err != nil { return nil, err } path, err := resolveStoragePath(s.rootPath, key) if err != nil { return nil, err } //nolint:gosec // path is constrained by resolveStoragePath to stay within configured root. data, err := os.ReadFile(path) if err != nil { s.logger.Warn("Failed to read document file", zap.Error(err), zap.String("path", path)) return nil, err } return data, nil } var _ Store = (*LocalStore)(nil) func resolveStoragePath(rootPath string, key string) (string, error) { cleanKey := filepath.Clean(strings.TrimSpace(key)) if cleanKey == "" || cleanKey == "." { return "", merrors.InvalidArgument("docstore: key is required") } if filepath.IsAbs(cleanKey) { return "", merrors.InvalidArgument("docstore: absolute keys are not allowed") } rootAbs, err := filepath.Abs(rootPath) if err != nil { return "", fmt.Errorf("resolve local store root: %w", err) } path := filepath.Join(rootAbs, cleanKey) pathAbs, err := filepath.Abs(path) if err != nil { return "", fmt.Errorf("resolve local store path: %w", err) } prefix := rootAbs + string(filepath.Separator) if pathAbs != rootAbs && !strings.HasPrefix(pathAbs, prefix) { return "", merrors.InvalidArgument("docstore: key escapes root path") } return pathAbs, nil }