Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions extension/fileio/registry.go
Original file line number Diff line number Diff line change
@@ -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 changes: 71 additions & 0 deletions extension/fileio/types.go
Original file line number Diff line number Diff line change
@@ -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 changes: 45 additions & 0 deletions internal/charcheck/charcheck.go
Original file line number Diff line number Diff line change
@@ -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
}
12 changes: 12 additions & 0 deletions internal/cmdutil/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions internal/cmdutil/factory_default.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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()

Expand Down
33 changes: 27 additions & 6 deletions internal/cmdutil/testing.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Comment thread
tuxedomm marked this conversation as resolved.

type testDefaultToken struct{}

func (t *testDefaultToken) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
Expand Down
66 changes: 6 additions & 60 deletions internal/validate/atomicwrite.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
38 changes: 5 additions & 33 deletions internal/validate/input.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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
}
Loading
Loading