mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 08:12:36 +08:00
134 lines
4.3 KiB
Go
134 lines
4.3 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package apps
|
|
|
|
import (
|
|
"errors"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/validate"
|
|
"github.com/larksuite/cli/internal/vfs" //nolint:depguard // existing apps storage persists CLI config-dir state; it is not user file I/O.
|
|
)
|
|
|
|
// storageRoot is the per-domain local-storage directory name under the config dir.
|
|
const storageRoot = "spark"
|
|
|
|
// checkSeg validates a value used as a single path segment (appID or key).
|
|
// It rejects empty, "..", "." , URL metacharacters, control and dangerous
|
|
// Unicode via validate.ResourceName — defense-in-depth alongside the
|
|
// EncodePathSegment escaping applied when building the path, so neither value
|
|
// can traverse out of the storage directory.
|
|
func checkSeg(name, what string) error {
|
|
if err := validate.ResourceName(name, what); err != nil {
|
|
return appsStorageError(err, "apps storage: %v", err)
|
|
}
|
|
if name == "." {
|
|
return errs.NewInternalError(errs.SubtypeStorage, "apps storage: %s must not be \".\"", what)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// appDir returns the storage directory for one app: ~/.lark-cli/spark/<esc(appID)>/
|
|
// (workspace-aware).
|
|
func appDir(appID string) string {
|
|
return filepath.Join(core.GetConfigDir(), storageRoot, validate.EncodePathSegment(appID))
|
|
}
|
|
|
|
// appKeyPath returns the file path for one (appID, key).
|
|
func appKeyPath(appID, key string) string {
|
|
return filepath.Join(appDir(appID), validate.EncodePathSegment(key))
|
|
}
|
|
|
|
// Read returns the bytes stored under (appID, key). A missing file returns
|
|
// (nil, nil). Content is opaque — callers own the format. Note: an empty stored
|
|
// value is indistinguishable from a missing key (both yield nil), so this store
|
|
// is unsuitable as an existence flag.
|
|
func Read(appID, key string) ([]byte, error) {
|
|
if err := checkSeg(appID, "appID"); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := checkSeg(key, "key"); err != nil {
|
|
return nil, err
|
|
}
|
|
data, err := vfs.ReadFile(appKeyPath(appID, key))
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, nil
|
|
}
|
|
return nil, appsStorageError(err, "apps storage: read: %v", err)
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
// Write atomically stores data under (appID, key): file 0600, dir 0700. It is a
|
|
// create-or-replace upsert for that key; content is written verbatim in
|
|
// plaintext. 0600 only guards against other local OS users — it does not protect
|
|
// against this user's processes, backups, or synced folders. appID and key are
|
|
// opaque strings: any "/" is escaped into a single path segment, never treated
|
|
// as a directory separator.
|
|
func Write(appID, key string, data []byte) error {
|
|
if err := checkSeg(appID, "appID"); err != nil {
|
|
return err
|
|
}
|
|
if err := checkSeg(key, "key"); err != nil {
|
|
return err
|
|
}
|
|
if err := vfs.MkdirAll(appDir(appID), 0700); err != nil {
|
|
return appsStorageError(err, "apps storage: create dir: %v", err)
|
|
}
|
|
if err := validate.AtomicWrite(appKeyPath(appID, key), data, 0600); err != nil {
|
|
return appsStorageError(err, "apps storage: write: %v", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Delete removes the file under (appID, key). A missing file is not an error.
|
|
func Delete(appID, key string) error {
|
|
if err := checkSeg(appID, "appID"); err != nil {
|
|
return err
|
|
}
|
|
if err := checkSeg(key, "key"); err != nil {
|
|
return err
|
|
}
|
|
if err := vfs.Remove(appKeyPath(appID, key)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return appsStorageError(err, "apps storage: delete: %v", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// List returns the keys stored under appID, skipping subdirectories and names
|
|
// that fail to unescape or validate after decoding. A missing app directory
|
|
// yields an empty list.
|
|
func List(appID string) ([]string, error) {
|
|
if err := checkSeg(appID, "appID"); err != nil {
|
|
return nil, err
|
|
}
|
|
entries, err := vfs.ReadDir(appDir(appID))
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return []string{}, nil
|
|
}
|
|
return nil, appsStorageError(err, "apps storage: read dir: %v", err)
|
|
}
|
|
keys := make([]string, 0, len(entries))
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
key, err := url.PathUnescape(e.Name())
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if err := checkSeg(key, "key"); err != nil {
|
|
continue
|
|
}
|
|
keys = append(keys, key)
|
|
}
|
|
return keys, nil
|
|
}
|