mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 10:08:02 +08:00
feat: add FileIO extension for file transfer abstraction (#314)
* feat: add FileIO extension for file transfer abstraction Introduce extension/fileio package with Provider/FileIO/File interfaces and a global registry, following the same pattern as extension/credential. - Add LocalFileIO default implementation with path validation and atomic writes - Wire FileIOProvider into Factory and resolve at runtime via RuntimeContext.FileIO() - Factory holds Provider (not resolved instance), deferring resolution to execution time
This commit is contained in:
31
extension/fileio/registry.go
Normal file
31
extension/fileio/registry.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package fileio
|
||||
|
||||
import "sync"
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
provider Provider
|
||||
)
|
||||
|
||||
// Register registers a FileIO Provider.
|
||||
// Later registrations override earlier ones (last-write-wins).
|
||||
// Unlike credential.Register which appends to a chain (multiple credential
|
||||
// sources are tried in order), FileIO uses a single active provider because
|
||||
// only one file I/O backend is active at a time (local vs server mode).
|
||||
// Typically called from init() via blank import.
|
||||
func Register(p Provider) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
provider = p
|
||||
}
|
||||
|
||||
// GetProvider returns the currently registered Provider.
|
||||
// Returns nil if no provider has been registered.
|
||||
func GetProvider() Provider {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return provider
|
||||
}
|
||||
71
extension/fileio/types.go
Normal file
71
extension/fileio/types.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package fileio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Provider creates FileIO instances.
|
||||
// Follows the same API style as extension/credential.Provider.
|
||||
type Provider interface {
|
||||
Name() string
|
||||
ResolveFileIO(ctx context.Context) FileIO
|
||||
}
|
||||
|
||||
// FileIO abstracts file transfer operations for CLI commands.
|
||||
// The default implementation operates on the local filesystem with
|
||||
// path validation, directory creation, and atomic writes.
|
||||
// Inject a custom implementation via Factory.FileIOProvider to replace
|
||||
// file transfer behavior (e.g. streaming in server mode).
|
||||
type FileIO interface {
|
||||
// Open opens a file for reading (upload, attachment, template scenarios).
|
||||
// The default implementation validates the path via SafeInputPath.
|
||||
Open(name string) (File, error)
|
||||
|
||||
// Stat returns file metadata (size validation, existence checks).
|
||||
// The default implementation validates the path via SafeInputPath.
|
||||
// Use os.IsNotExist(err) to distinguish "file not found" from "invalid path".
|
||||
Stat(name string) (FileInfo, error)
|
||||
|
||||
// ResolvePath returns the validated, absolute path for the given output path.
|
||||
// The default implementation delegates to SafeOutputPath.
|
||||
// Use this to obtain the canonical saved path for user-facing output.
|
||||
ResolvePath(path string) (string, error)
|
||||
|
||||
// Save writes content to the target path and returns a SaveResult.
|
||||
// The default implementation validates via SafeOutputPath, creates
|
||||
// parent directories, and writes atomically.
|
||||
Save(path string, opts SaveOptions, body io.Reader) (SaveResult, error)
|
||||
}
|
||||
|
||||
// FileInfo is a minimal subset of os.FileInfo covering actual CLI usage.
|
||||
// os.FileInfo satisfies this interface.
|
||||
type FileInfo interface {
|
||||
Size() int64
|
||||
IsDir() bool
|
||||
}
|
||||
|
||||
// File is the interface returned by FileIO.Open.
|
||||
// It covers the subset of *os.File methods actually used by CLI commands.
|
||||
// *os.File satisfies this interface without adaptation.
|
||||
type File interface {
|
||||
io.Reader
|
||||
io.ReaderAt
|
||||
io.Closer
|
||||
}
|
||||
|
||||
// SaveResult holds the outcome of a Save operation.
|
||||
type SaveResult interface {
|
||||
Size() int64 // actual bytes written
|
||||
}
|
||||
|
||||
// SaveOptions carries metadata for Save.
|
||||
// The default (local) implementation ignores these fields;
|
||||
// server-mode implementations use them to construct streaming response frames.
|
||||
type SaveOptions struct {
|
||||
ContentType string // MIME type
|
||||
ContentLength int64 // content length; -1 if unknown
|
||||
}
|
||||
45
internal/charcheck/charcheck.go
Normal file
45
internal/charcheck/charcheck.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package charcheck provides character-level security checks shared across
|
||||
// path validation (localfileio) and input validation (validate) packages.
|
||||
// Keeping these checks in one place ensures consistent detection of dangerous
|
||||
// Unicode and control characters throughout the codebase.
|
||||
package charcheck
|
||||
|
||||
import "fmt"
|
||||
|
||||
// RejectControlChars rejects C0 control characters (except \t and \n) and
|
||||
// dangerous Unicode characters (Bidi overrides, zero-width, line/paragraph
|
||||
// separators) that enable visual spoofing attacks.
|
||||
func RejectControlChars(value, flagName string) error {
|
||||
for _, r := range value {
|
||||
if r != '\t' && r != '\n' && (r < 0x20 || r == 0x7f) {
|
||||
return fmt.Errorf("%s contains invalid control characters", flagName)
|
||||
}
|
||||
if IsDangerousUnicode(r) {
|
||||
return fmt.Errorf("%s contains dangerous Unicode characters", flagName)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsDangerousUnicode identifies Unicode code points used for visual spoofing
|
||||
// attacks. These characters are invisible or alter text direction, allowing
|
||||
// attackers to make "report.exe" display as "report.txt" (Bidi override) or
|
||||
// insert hidden content (zero-width characters).
|
||||
func IsDangerousUnicode(r rune) bool {
|
||||
switch {
|
||||
case r >= 0x200B && r <= 0x200D: // zero-width space/non-joiner/joiner
|
||||
return true
|
||||
case r == 0xFEFF: // BOM / ZWNBSP
|
||||
return true
|
||||
case r >= 0x202A && r <= 0x202E: // Bidi: LRE/RLE/PDF/LRO/RLO
|
||||
return true
|
||||
case r >= 0x2028 && r <= 0x2029: // line/paragraph separator
|
||||
return true
|
||||
case r >= 0x2066 && r <= 0x2069: // Bidi isolates: LRI/RLI/FSI/PDI
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
@@ -40,6 +41,17 @@ type Factory struct {
|
||||
ResolvedIdentity core.Identity // identity resolved by the last ResolveAs call
|
||||
|
||||
Credential *credential.CredentialProvider
|
||||
|
||||
FileIOProvider fileio.Provider // file transfer provider (default: local filesystem)
|
||||
}
|
||||
|
||||
// ResolveFileIO resolves a FileIO instance using the current execution context.
|
||||
// The provider controls whether the returned instance is fresh or cached.
|
||||
func (f *Factory) ResolveFileIO(ctx context.Context) fileio.FileIO {
|
||||
if f == nil || f.FileIOProvider == nil {
|
||||
return nil
|
||||
}
|
||||
return f.FileIOProvider.ResolveFileIO(ctx)
|
||||
}
|
||||
|
||||
// ResolveAs returns the effective identity type.
|
||||
|
||||
@@ -17,12 +17,14 @@ import (
|
||||
"golang.org/x/term"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
|
||||
)
|
||||
|
||||
// NewDefault creates a production Factory with cached closures.
|
||||
@@ -44,6 +46,9 @@ func NewDefault(inv InvocationContext) *Factory {
|
||||
IsTerminal: term.IsTerminal(int(os.Stdin.Fd())),
|
||||
}
|
||||
|
||||
// Phase 0: FileIO provider (no dependency)
|
||||
f.FileIOProvider = fileio.GetProvider()
|
||||
|
||||
// Phase 1: HttpClient (no credential dependency)
|
||||
f.HttpClient = cachedHttpClientFunc()
|
||||
|
||||
|
||||
@@ -7,14 +7,17 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// noopKeychain is a no-op KeychainAccess for tests that don't need keychain.
|
||||
@@ -62,12 +65,13 @@ func TestFactory(t *testing.T, config *core.CliConfig) (*Factory, *bytes.Buffer,
|
||||
)
|
||||
|
||||
f := &Factory{
|
||||
Config: func() (*core.CliConfig, error) { return config, nil },
|
||||
HttpClient: func() (*http.Client, error) { return mockClient, nil },
|
||||
LarkClient: func() (*lark.Client, error) { return testLarkClient, nil },
|
||||
IOStreams: &IOStreams{In: nil, Out: stdoutBuf, ErrOut: stderrBuf},
|
||||
Keychain: &noopKeychain{},
|
||||
Credential: testCred,
|
||||
Config: func() (*core.CliConfig, error) { return config, nil },
|
||||
HttpClient: func() (*http.Client, error) { return mockClient, nil },
|
||||
LarkClient: func() (*lark.Client, error) { return testLarkClient, nil },
|
||||
IOStreams: &IOStreams{In: nil, Out: stdoutBuf, ErrOut: stderrBuf},
|
||||
Keychain: &noopKeychain{},
|
||||
Credential: testCred,
|
||||
FileIOProvider: fileio.GetProvider(),
|
||||
}
|
||||
return f, stdoutBuf, stderrBuf, reg
|
||||
}
|
||||
@@ -83,6 +87,23 @@ func (a *testDefaultAcct) ResolveAccount(ctx context.Context) (*credential.Accou
|
||||
return credential.AccountFromCliConfig(a.config), nil
|
||||
}
|
||||
|
||||
// TestChdir changes the working directory to dir for the duration of the test.
|
||||
// The original directory is restored via t.Cleanup.
|
||||
// This enables tests to use LocalFileIO (which resolves relative paths under cwd)
|
||||
// with temporary directories, keeping test artifacts out of the source tree.
|
||||
// Not compatible with t.Parallel() — os.Chdir is process-wide.
|
||||
func TestChdir(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
orig, err := vfs.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Getwd: %v", err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil { //nolint:forbidigo // no vfs.Chdir yet; test-only, process-wide chdir
|
||||
t.Fatalf("Chdir(%s): %v", dir, err)
|
||||
}
|
||||
t.Cleanup(func() { os.Chdir(orig) }) //nolint:forbidigo // matching restore
|
||||
}
|
||||
|
||||
type testDefaultToken struct{}
|
||||
|
||||
func (t *testDefaultToken) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
|
||||
@@ -4,74 +4,20 @@
|
||||
package validate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
"github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
)
|
||||
|
||||
// AtomicWrite writes data to path atomically by creating a temp file in the
|
||||
// same directory, writing and fsyncing the data, then renaming over the target.
|
||||
// It replaces os.WriteFile for all config and download file writes.
|
||||
//
|
||||
// os.WriteFile truncates the target before writing, so a process kill (CI timeout,
|
||||
// OOM, Ctrl+C) between truncate and completion leaves the file empty or partial.
|
||||
// AtomicWrite avoids this: on any failure the temp file is cleaned up and the
|
||||
// original file remains untouched.
|
||||
// AtomicWrite writes data to path atomically.
|
||||
// Delegates to localfileio.AtomicWrite.
|
||||
func AtomicWrite(path string, data []byte, perm os.FileMode) error {
|
||||
return atomicWrite(path, perm, func(tmp *os.File) error {
|
||||
_, err := tmp.Write(data)
|
||||
return err
|
||||
})
|
||||
return localfileio.AtomicWrite(path, data, perm)
|
||||
}
|
||||
|
||||
// AtomicWriteFromReader atomically copies reader contents into path.
|
||||
// Delegates to localfileio.AtomicWriteFromReader.
|
||||
func AtomicWriteFromReader(path string, reader io.Reader, perm os.FileMode) (int64, error) {
|
||||
var copied int64
|
||||
err := atomicWrite(path, perm, func(tmp *os.File) error {
|
||||
n, err := io.Copy(tmp, reader)
|
||||
copied = n
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return copied, nil
|
||||
}
|
||||
|
||||
func atomicWrite(path string, perm os.FileMode, writeFn func(tmp *os.File) error) error {
|
||||
dir := filepath.Dir(path)
|
||||
tmp, err := vfs.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
|
||||
success := false
|
||||
defer func() {
|
||||
if !success {
|
||||
tmp.Close()
|
||||
vfs.Remove(tmpName)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := tmp.Chmod(perm); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFn(tmp); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vfs.Rename(tmpName, path); err != nil {
|
||||
return err
|
||||
}
|
||||
success = true
|
||||
return nil
|
||||
return localfileio.AtomicWriteFromReader(path, reader, perm)
|
||||
}
|
||||
|
||||
@@ -6,25 +6,17 @@ package validate
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/charcheck"
|
||||
)
|
||||
|
||||
// RejectControlChars rejects C0 control characters (except \t and \n) and
|
||||
// dangerous Unicode characters from user input.
|
||||
//
|
||||
// Control characters cause subtle security issues: null bytes truncate strings
|
||||
// at the C layer, \r\n enables HTTP header injection
|
||||
// Unicode characters allow visual spoofing (e.g. making "report.exe" display
|
||||
// as "report.txt").
|
||||
// Delegates to charcheck.RejectControlChars — the single source of truth
|
||||
// for character-level security checks.
|
||||
func RejectControlChars(value, flagName string) error {
|
||||
for _, r := range value {
|
||||
if r != '\t' && r != '\n' && (r < 0x20 || r == 0x7f) {
|
||||
return fmt.Errorf("%s contains invalid control characters", flagName)
|
||||
}
|
||||
if isDangerousUnicode(r) {
|
||||
return fmt.Errorf("%s contains dangerous Unicode characters", flagName)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return charcheck.RejectControlChars(value, flagName)
|
||||
}
|
||||
|
||||
// RejectCRLF rejects strings containing carriage return (\r) or line feed (\n).
|
||||
@@ -48,23 +40,3 @@ func StripQueryFragment(path string) string {
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// isDangerousUnicode identifies Unicode code points used for visual spoofing attacks.
|
||||
// These characters are invisible or alter text direction, allowing attackers to make
|
||||
// "report.exe" display as "report.txt" (Bidi override) or insert hidden content
|
||||
// (zero-width characters).
|
||||
func isDangerousUnicode(r rune) bool {
|
||||
switch {
|
||||
case r >= 0x200B && r <= 0x200D: // zero-width space/non-joiner/joiner
|
||||
return true
|
||||
case r == 0xFEFF: // BOM / ZWNBSP
|
||||
return true
|
||||
case r >= 0x202A && r <= 0x202E: // Bidi: LRE/RLE/PDF/LRO/RLO
|
||||
return true
|
||||
case r >= 0x2028 && r <= 0x2029: // line/paragraph separator
|
||||
return true
|
||||
case r >= 0x2066 && r <= 0x2069: // Bidi isolates: LRI/RLI/FSI/PDI
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -3,148 +3,28 @@
|
||||
|
||||
package validate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
import "github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// SafeOutputPath validates a download/export target path for --output flags.
|
||||
// It rejects absolute paths, resolves symlinks to their real location, and
|
||||
// verifies the canonical result is still under the current working directory.
|
||||
// This prevents an AI Agent from being tricked into writing files outside the
|
||||
// working directory (e.g. "../../.ssh/authorized_keys") or following symlinks
|
||||
// to sensitive locations.
|
||||
//
|
||||
// The returned absolute path MUST be used for all subsequent I/O to prevent
|
||||
// time-of-check-to-time-of-use (TOCTOU) race conditions.
|
||||
// SafeOutputPath validates a download/export target path.
|
||||
// Delegates to localfileio.SafeOutputPath.
|
||||
func SafeOutputPath(path string) (string, error) {
|
||||
return safePath(path, "--output")
|
||||
return localfileio.SafeOutputPath(path)
|
||||
}
|
||||
|
||||
// SafeInputPath validates an upload/read source path for --file flags.
|
||||
// It applies the same rules as SafeOutputPath — rejecting absolute paths,
|
||||
// resolving symlinks, and enforcing working directory containment — to prevent an AI Agent
|
||||
// from being tricked into reading sensitive files like /etc/passwd.
|
||||
// SafeInputPath validates an upload/read source path.
|
||||
// Delegates to localfileio.SafeInputPath.
|
||||
func SafeInputPath(path string) (string, error) {
|
||||
return safePath(path, "--file")
|
||||
return localfileio.SafeInputPath(path)
|
||||
}
|
||||
|
||||
// SafeEnvDirPath validates an environment-provided application directory path.
|
||||
// It requires an absolute path, rejects control characters, normalizes the
|
||||
// input, and resolves symlinks through the nearest existing ancestor so callers
|
||||
// receive a canonical path for subsequent filesystem operations.
|
||||
// Delegates to localfileio.SafeEnvDirPath.
|
||||
func SafeEnvDirPath(path, envName string) (string, error) {
|
||||
if err := RejectControlChars(path, envName); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
path = filepath.Clean(path)
|
||||
if !filepath.IsAbs(path) {
|
||||
return "", fmt.Errorf("%s must be an absolute path, got %q", envName, path)
|
||||
}
|
||||
|
||||
resolved, err := resolveNearestAncestor(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot resolve symlinks: %w", err)
|
||||
}
|
||||
return resolved, nil
|
||||
return localfileio.SafeEnvDirPath(path, envName)
|
||||
}
|
||||
|
||||
// SafeLocalFlagPath validates a flag value as a local file path.
|
||||
// Empty values and http/https URLs are returned unchanged without validation,
|
||||
// allowing the caller to handle non-path inputs (e.g. API keys, URLs) upstream.
|
||||
// For all other values, SafeInputPath rules apply.
|
||||
// The original relative path is returned unchanged (not resolved to absolute) so
|
||||
// upload helpers can re-validate at the actual I/O point via SafeUploadPath.
|
||||
// Delegates to localfileio.SafeLocalFlagPath.
|
||||
func SafeLocalFlagPath(flagName, value string) (string, error) {
|
||||
if value == "" || strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") {
|
||||
return value, nil
|
||||
}
|
||||
if _, err := SafeInputPath(value); err != nil {
|
||||
return "", fmt.Errorf("%s: %v", flagName, err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// safePath is the shared implementation for SafeOutputPath and SafeInputPath.
|
||||
func safePath(raw, flagName string) (string, error) {
|
||||
if err := RejectControlChars(raw, flagName); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
path := filepath.Clean(raw)
|
||||
|
||||
if filepath.IsAbs(path) {
|
||||
return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: cd to the target directory first, or use a relative path like ./filename)", flagName, raw)
|
||||
}
|
||||
|
||||
cwd, err := vfs.Getwd()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot determine working directory: %w", err)
|
||||
}
|
||||
resolved := filepath.Join(cwd, path)
|
||||
|
||||
// Resolve symlinks: for existing paths, follow to real location;
|
||||
// for non-existing paths, walk up to the nearest existing ancestor,
|
||||
// resolve its symlinks, and re-attach the remaining tail segments.
|
||||
// This prevents TOCTOU attacks where a non-existent intermediate
|
||||
// directory is replaced with a symlink between check and use.
|
||||
if _, err := vfs.Lstat(resolved); err == nil {
|
||||
resolved, err = filepath.EvalSymlinks(resolved)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot resolve symlinks: %w", err)
|
||||
}
|
||||
} else {
|
||||
resolved, err = resolveNearestAncestor(resolved)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot resolve symlinks: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
canonicalCwd, _ := filepath.EvalSymlinks(cwd)
|
||||
if !isUnderDir(resolved, canonicalCwd) {
|
||||
return "", fmt.Errorf("%s %q resolves outside the current working directory (hint: the path must stay within the working directory after resolving .. and symlinks)", flagName, raw)
|
||||
}
|
||||
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// resolveNearestAncestor walks up from path until it finds an existing
|
||||
// ancestor, resolves that ancestor's symlinks, and re-joins the tail.
|
||||
// This ensures even deeply nested non-existent paths are anchored to a
|
||||
// real filesystem location, closing the TOCTOU symlink gap.
|
||||
func resolveNearestAncestor(path string) (string, error) {
|
||||
var tail []string
|
||||
cur := path
|
||||
for {
|
||||
if _, err := vfs.Lstat(cur); err == nil {
|
||||
real, err := filepath.EvalSymlinks(cur)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
parts := append([]string{real}, tail...)
|
||||
return filepath.Join(parts...), nil
|
||||
}
|
||||
parent := filepath.Dir(cur)
|
||||
if parent == cur {
|
||||
// Reached filesystem root without finding an existing ancestor;
|
||||
// return path as-is and let the containment check reject it.
|
||||
parts := append([]string{cur}, tail...)
|
||||
return filepath.Join(parts...), nil
|
||||
}
|
||||
tail = append([]string{filepath.Base(cur)}, tail...)
|
||||
cur = parent
|
||||
}
|
||||
}
|
||||
|
||||
// isUnderDir checks whether child is under parent directory.
|
||||
func isUnderDir(child, parent string) bool {
|
||||
rel, err := filepath.Rel(parent, child)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != ".."
|
||||
return localfileio.SafeLocalFlagPath(flagName, value)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/charcheck"
|
||||
)
|
||||
|
||||
// unsafeResourceChars matches URL-special characters, control characters,
|
||||
@@ -35,7 +37,7 @@ func ResourceName(name, flagName string) error {
|
||||
return fmt.Errorf("%s contains invalid characters", flagName)
|
||||
}
|
||||
for _, r := range name {
|
||||
if isDangerousUnicode(r) {
|
||||
if charcheck.IsDangerousUnicode(r) {
|
||||
return fmt.Errorf("%s contains dangerous Unicode characters", flagName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ package validate
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/charcheck"
|
||||
)
|
||||
|
||||
// ansiEscape matches ANSI CSI sequences (ESC[ ... letter) and OSC sequences (ESC] ... BEL).
|
||||
@@ -34,7 +36,7 @@ func SanitizeForTerminal(text string) string {
|
||||
b.WriteRune(r)
|
||||
case r < 0x20 || r == 0x7f:
|
||||
continue
|
||||
case isDangerousUnicode(r):
|
||||
case charcheck.IsDangerousUnicode(r):
|
||||
continue
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
|
||||
@@ -5,6 +5,8 @@ package validate
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/charcheck"
|
||||
)
|
||||
|
||||
func TestSanitizeForTerminal_StripsEscapesAndDangerousChars(t *testing.T) {
|
||||
@@ -74,16 +76,16 @@ func TestIsDangerousUnicode_IdentifiesAllDangerousRanges(t *testing.T) {
|
||||
0x2066, 0x2067, 0x2068, 0x2069, // isolates
|
||||
}
|
||||
for _, r := range dangerous {
|
||||
if !isDangerousUnicode(r) {
|
||||
t.Errorf("isDangerousUnicode(%U) = false, want true", r)
|
||||
if !charcheck.IsDangerousUnicode(r) {
|
||||
t.Errorf("charcheck.IsDangerousUnicode(%U) = false, want true", r)
|
||||
}
|
||||
}
|
||||
|
||||
// ── GIVEN: safe Unicode code points → THEN: returns false ──
|
||||
safe := []rune{'A', '中', '!', ' ', '\t', '\n', 0x200A, 0x2070}
|
||||
for _, r := range safe {
|
||||
if isDangerousUnicode(r) {
|
||||
t.Errorf("isDangerousUnicode(%U) = true, want false", r)
|
||||
if charcheck.IsDangerousUnicode(r) {
|
||||
t.Errorf("charcheck.IsDangerousUnicode(%U) = true, want false", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
74
internal/vfs/localfileio/atomicwrite.go
Normal file
74
internal/vfs/localfileio/atomicwrite.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package localfileio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// AtomicWrite writes data to path atomically via temp file + rename.
|
||||
func AtomicWrite(path string, data []byte, perm os.FileMode) error {
|
||||
return atomicWrite(path, perm, func(tmp *os.File) error {
|
||||
_, err := tmp.Write(data)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// AtomicWriteFromReader atomically copies reader contents into path.
|
||||
func AtomicWriteFromReader(path string, reader io.Reader, perm os.FileMode) (int64, error) {
|
||||
var copied int64
|
||||
err := atomicWrite(path, perm, func(tmp *os.File) error {
|
||||
n, err := io.Copy(tmp, reader)
|
||||
copied = n
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return copied, nil
|
||||
}
|
||||
|
||||
func atomicWrite(path string, perm os.FileMode, writeFn func(tmp *os.File) error) error {
|
||||
dir := filepath.Dir(path)
|
||||
tmp, err := vfs.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
|
||||
closed := false
|
||||
success := false
|
||||
defer func() {
|
||||
if !success {
|
||||
if !closed {
|
||||
tmp.Close()
|
||||
}
|
||||
vfs.Remove(tmpName)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := tmp.Chmod(perm); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFn(tmp); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
closed = true
|
||||
if err := vfs.Rename(tmpName, path); err != nil {
|
||||
return err
|
||||
}
|
||||
success = true
|
||||
return nil
|
||||
}
|
||||
77
internal/vfs/localfileio/localfileio.go
Normal file
77
internal/vfs/localfileio/localfileio.go
Normal file
@@ -0,0 +1,77 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package localfileio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// Provider is the default fileio.Provider backed by the local filesystem.
|
||||
type Provider struct{}
|
||||
|
||||
func (p *Provider) Name() string { return "local" }
|
||||
|
||||
func (p *Provider) ResolveFileIO(_ context.Context) fileio.FileIO {
|
||||
return &LocalFileIO{}
|
||||
}
|
||||
|
||||
func init() {
|
||||
fileio.Register(&Provider{})
|
||||
}
|
||||
|
||||
// LocalFileIO implements fileio.FileIO using the local filesystem.
|
||||
// Path validation (SafeInputPath/SafeOutputPath), directory creation,
|
||||
// and atomic writes are handled internally.
|
||||
type LocalFileIO struct{}
|
||||
|
||||
// Open opens a local file for reading after validating the path.
|
||||
func (l *LocalFileIO) Open(name string) (fileio.File, error) {
|
||||
safePath, err := SafeInputPath(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return vfs.Open(safePath)
|
||||
}
|
||||
|
||||
// Stat returns file metadata after validating the path.
|
||||
func (l *LocalFileIO) Stat(name string) (fileio.FileInfo, error) {
|
||||
safePath, err := SafeInputPath(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return vfs.Stat(safePath)
|
||||
}
|
||||
|
||||
// saveResult implements fileio.SaveResult.
|
||||
type saveResult struct{ size int64 }
|
||||
|
||||
func (r *saveResult) Size() int64 { return r.size }
|
||||
|
||||
// ResolvePath returns the validated absolute path for the given output path.
|
||||
func (l *LocalFileIO) ResolvePath(path string) (string, error) {
|
||||
return SafeOutputPath(path)
|
||||
}
|
||||
|
||||
// Save writes body to path atomically after validating the output path.
|
||||
// Parent directories are created as needed. The body is streamed directly
|
||||
// to a temp file and renamed, avoiding full in-memory buffering.
|
||||
func (l *LocalFileIO) Save(path string, _ fileio.SaveOptions, body io.Reader) (fileio.SaveResult, error) {
|
||||
safePath, err := SafeOutputPath(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := vfs.MkdirAll(filepath.Dir(safePath), 0700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n, err := AtomicWriteFromReader(safePath, body, 0600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &saveResult{size: n}, nil
|
||||
}
|
||||
131
internal/vfs/localfileio/path.go
Normal file
131
internal/vfs/localfileio/path.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package localfileio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/charcheck"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// SafeOutputPath validates a download/export target path for --output flags.
|
||||
func SafeOutputPath(path string) (string, error) {
|
||||
return safePath(path, "--output")
|
||||
}
|
||||
|
||||
// SafeInputPath validates an upload/read source path for --file flags.
|
||||
func SafeInputPath(path string) (string, error) {
|
||||
return safePath(path, "--file")
|
||||
}
|
||||
|
||||
// SafeLocalFlagPath validates a flag value as a local file path.
|
||||
// Empty values and http/https URLs are returned unchanged without validation.
|
||||
func SafeLocalFlagPath(flagName, value string) (string, error) {
|
||||
if value == "" || strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") {
|
||||
return value, nil
|
||||
}
|
||||
if _, err := SafeInputPath(value); err != nil {
|
||||
return "", fmt.Errorf("%s: %v", flagName, err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// SafeEnvDirPath validates an environment-provided application directory path.
|
||||
// It requires an absolute path, rejects control characters, normalizes the
|
||||
// input, and resolves symlinks through the nearest existing ancestor.
|
||||
func SafeEnvDirPath(path, envName string) (string, error) {
|
||||
if err := charcheck.RejectControlChars(path, envName); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
path = filepath.Clean(path)
|
||||
if !filepath.IsAbs(path) {
|
||||
return "", fmt.Errorf("%s must be an absolute path, got %q", envName, path)
|
||||
}
|
||||
|
||||
resolved, err := resolveNearestAncestor(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot resolve symlinks: %w", err)
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// safePath is the shared implementation for SafeOutputPath and SafeInputPath.
|
||||
func safePath(raw, flagName string) (string, error) {
|
||||
if err := charcheck.RejectControlChars(raw, flagName); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
path := filepath.Clean(raw)
|
||||
|
||||
if filepath.IsAbs(path) {
|
||||
return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: cd to the target directory first, or use a relative path like ./filename)", flagName, raw)
|
||||
}
|
||||
|
||||
cwd, err := vfs.Getwd()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot determine working directory: %w", err)
|
||||
}
|
||||
resolved := filepath.Join(cwd, path)
|
||||
|
||||
if _, err := vfs.Lstat(resolved); err == nil {
|
||||
resolved, err = filepath.EvalSymlinks(resolved)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot resolve symlinks: %w", err)
|
||||
}
|
||||
} else {
|
||||
resolved, err = resolveNearestAncestor(resolved)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot resolve symlinks: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
canonicalCwd, _ := filepath.EvalSymlinks(cwd)
|
||||
if !isUnderDir(resolved, canonicalCwd) {
|
||||
return "", fmt.Errorf("%s %q resolves outside the current working directory (hint: the path must stay within the working directory after resolving .. and symlinks)", flagName, raw)
|
||||
}
|
||||
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func resolveNearestAncestor(path string) (string, error) {
|
||||
var tail []string
|
||||
cur := path
|
||||
for {
|
||||
if _, err := vfs.Lstat(cur); err == nil {
|
||||
real, err := filepath.EvalSymlinks(cur)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
parts := append([]string{real}, tail...)
|
||||
return filepath.Join(parts...), nil
|
||||
}
|
||||
parent := filepath.Dir(cur)
|
||||
if parent == cur {
|
||||
parts := append([]string{cur}, tail...)
|
||||
return filepath.Join(parts...), nil
|
||||
}
|
||||
tail = append([]string{filepath.Base(cur)}, tail...)
|
||||
cur = parent
|
||||
}
|
||||
}
|
||||
|
||||
func isUnderDir(child, parent string) bool {
|
||||
rel, err := filepath.Rel(parent, child)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != ".."
|
||||
}
|
||||
|
||||
// RejectControlChars delegates to charcheck.RejectControlChars.
|
||||
// Kept as a package-level alias for backward compatibility with callers
|
||||
// that import localfileio directly.
|
||||
var RejectControlChars = charcheck.RejectControlChars
|
||||
|
||||
// IsDangerousUnicode delegates to charcheck.IsDangerousUnicode.
|
||||
var IsDangerousUnicode = charcheck.IsDangerousUnicode
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -296,6 +298,62 @@ func (ctx *RuntimeContext) IO() *cmdutil.IOStreams {
|
||||
return ctx.Factory.IOStreams
|
||||
}
|
||||
|
||||
// FileIO resolves the FileIO using the current execution context.
|
||||
// Falls back to the globally registered provider when Factory or its
|
||||
// FileIOProvider is nil (e.g. in lightweight test helpers).
|
||||
func (ctx *RuntimeContext) FileIO() fileio.FileIO {
|
||||
if ctx != nil && ctx.Factory != nil {
|
||||
if fio := ctx.Factory.ResolveFileIO(ctx.ctx); fio != nil {
|
||||
return fio
|
||||
}
|
||||
}
|
||||
if p := fileio.GetProvider(); p != nil {
|
||||
c := context.Background()
|
||||
if ctx != nil {
|
||||
c = ctx.ctx
|
||||
}
|
||||
return p.ResolveFileIO(c)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveSavePath resolves a relative path to a validated absolute path via
|
||||
// FileIO.ResolvePath. It returns an error if no FileIO provider is registered
|
||||
// or if the path fails validation (e.g. traversal, symlink escape).
|
||||
func (ctx *RuntimeContext) ResolveSavePath(path string) (string, error) {
|
||||
fio := ctx.FileIO()
|
||||
if fio == nil {
|
||||
return "", fmt.Errorf("no file I/O provider registered")
|
||||
}
|
||||
resolved, err := fio.ResolvePath(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve save path: %w", err)
|
||||
}
|
||||
if resolved == "" {
|
||||
return "", fmt.Errorf("resolve save path: empty result for %q", path)
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// ValidatePath checks that path is a valid relative input path within the
|
||||
// working directory by delegating to FileIO.Stat. Returns nil if the path is
|
||||
// valid or does not exist yet; returns an error only for illegal paths
|
||||
// (absolute, traversal, symlink escape, control chars).
|
||||
//
|
||||
// NOTE: This validates input (read) paths via SafeInputPath semantics inside
|
||||
// the FileIO implementation. For output (write) path validation, use
|
||||
// ResolveSavePath instead.
|
||||
func (ctx *RuntimeContext) ValidatePath(path string) error {
|
||||
fio := ctx.FileIO()
|
||||
if fio == nil {
|
||||
return fmt.Errorf("no file I/O provider registered")
|
||||
}
|
||||
if _, err := fio.Stat(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Output helpers ──
|
||||
|
||||
// Out prints a success JSON envelope to stdout.
|
||||
|
||||
Reference in New Issue
Block a user