diff --git a/extension/fileio/registry.go b/extension/fileio/registry.go new file mode 100644 index 000000000..a9e1fe368 --- /dev/null +++ b/extension/fileio/registry.go @@ -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 +} diff --git a/extension/fileio/types.go b/extension/fileio/types.go new file mode 100644 index 000000000..7fd47506e --- /dev/null +++ b/extension/fileio/types.go @@ -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 +} diff --git a/internal/charcheck/charcheck.go b/internal/charcheck/charcheck.go new file mode 100644 index 000000000..872f3f1cc --- /dev/null +++ b/internal/charcheck/charcheck.go @@ -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 +} diff --git a/internal/cmdutil/factory.go b/internal/cmdutil/factory.go index 8845f1dc6..3f4759838 100644 --- a/internal/cmdutil/factory.go +++ b/internal/cmdutil/factory.go @@ -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. diff --git a/internal/cmdutil/factory_default.go b/internal/cmdutil/factory_default.go index 8c8ea4f88..c9b4e92cf 100644 --- a/internal/cmdutil/factory_default.go +++ b/internal/cmdutil/factory_default.go @@ -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() diff --git a/internal/cmdutil/testing.go b/internal/cmdutil/testing.go index 7a70ed2b0..8d8be452b 100644 --- a/internal/cmdutil/testing.go +++ b/internal/cmdutil/testing.go @@ -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) { diff --git a/internal/validate/atomicwrite.go b/internal/validate/atomicwrite.go index 5c1ec9c92..456bc9e60 100644 --- a/internal/validate/atomicwrite.go +++ b/internal/validate/atomicwrite.go @@ -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) } diff --git a/internal/validate/input.go b/internal/validate/input.go index 0213fa67b..9890bd47a 100644 --- a/internal/validate/input.go +++ b/internal/validate/input.go @@ -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 -} diff --git a/internal/validate/path.go b/internal/validate/path.go index d46f3571d..59d21c2c0 100644 --- a/internal/validate/path.go +++ b/internal/validate/path.go @@ -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) } diff --git a/internal/validate/resource.go b/internal/validate/resource.go index 63e132106..cf3bbad67 100644 --- a/internal/validate/resource.go +++ b/internal/validate/resource.go @@ -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) } } diff --git a/internal/validate/sanitize.go b/internal/validate/sanitize.go index 1e9bd027e..aafe574f8 100644 --- a/internal/validate/sanitize.go +++ b/internal/validate/sanitize.go @@ -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) diff --git a/internal/validate/sanitize_test.go b/internal/validate/sanitize_test.go index be353bdf0..ffd10436e 100644 --- a/internal/validate/sanitize_test.go +++ b/internal/validate/sanitize_test.go @@ -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) } } } diff --git a/internal/vfs/localfileio/atomicwrite.go b/internal/vfs/localfileio/atomicwrite.go new file mode 100644 index 000000000..00fc6f91a --- /dev/null +++ b/internal/vfs/localfileio/atomicwrite.go @@ -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 +} diff --git a/internal/vfs/localfileio/localfileio.go b/internal/vfs/localfileio/localfileio.go new file mode 100644 index 000000000..5c712c231 --- /dev/null +++ b/internal/vfs/localfileio/localfileio.go @@ -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 +} diff --git a/internal/vfs/localfileio/path.go b/internal/vfs/localfileio/path.go new file mode 100644 index 000000000..7a4b52ab8 --- /dev/null +++ b/internal/vfs/localfileio/path.go @@ -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 diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 1141b0bc5..6eef2b455 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -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.