Files
larksuite-cli/shortcuts/common/validate.go
evandance b3fcf55611 feat(common): emit typed validation errors from shared shortcut pre-checks (#1242)
Input pre-check failures shared by every shortcut — @file/stdin input
resolution, enum validation, and unsupported --dry-run — now leave the
CLI as typed validation envelopes naming the offending flag, so scripts
and AI agents can branch on `param` instead of parsing prose. Wire type,
exit code, and message text are unchanged; the new fields are additive.

The shared layer also gains typed replacements for its legacy
error-producing helpers, so each business domain can migrate to typed
errors without rebuilding common plumbing, and a path-scoped lint guard
keeps migrated domains from sliding back.

Changes:
- Shared pre-check failures (input flags, enum values, dry-run support)
  return typed validation errors carrying the offending flag as `param`.
- Every legacy error-producing helper in shortcuts/common has a typed
  replacement that preserves the existing message text: validation and
  flag-group checks, chat/user ID validation (callers name the flag so
  `param` is ground truth), "me" open-id resolution, safe-path checks,
  input-stat and save-error wrapping. Legacy helpers stay for
  not-yet-migrated domains, marked deprecated — including the legacy
  API-result classifier, whose typed route is runtime.CallAPITyped.
- A new errscontract rule rejects legacy common-helper calls on migrated
  paths, so a migrated domain cannot silently reintroduce legacy
  envelopes; drive is the first locked path and its last legacy
  ID-helper calls are replaced.
2026-06-03 19:20:19 +08:00

226 lines
6.8 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"fmt"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
)
// FlagErrorf returns a validation error with flag context (exit code 2).
//
// Deprecated: use ValidationErrorf for typed error envelopes.
func FlagErrorf(format string, args ...any) error {
return output.ErrValidation(format, args...)
}
// ValidationErrorf returns a typed validation error with invalid_argument subtype.
func ValidationErrorf(format string, args ...any) *errs.ValidationError {
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...)
}
// MutuallyExclusive checks that at most one of the given flags is set.
//
// Deprecated: use MutuallyExclusiveTyped for typed error envelopes.
func MutuallyExclusive(rt *RuntimeContext, flags ...string) error {
var set []string
for _, f := range flags {
val := rt.Str(f)
if val != "" {
set = append(set, "--"+f)
}
}
if len(set) > 1 {
return FlagErrorf("%s are mutually exclusive", strings.Join(set, " and "))
}
return nil
}
// MutuallyExclusiveTyped checks that at most one of the given flags is set.
func MutuallyExclusiveTyped(rt *RuntimeContext, flags ...string) error {
var set []string
for _, f := range flags {
val := rt.Str(f)
if val != "" {
set = append(set, "--"+f)
}
}
if len(set) > 1 {
return ValidationErrorf("%s are mutually exclusive", strings.Join(set, " and ")).
WithParams(invalidParams(set, "mutually exclusive")...)
}
return nil
}
// AtLeastOne checks that at least one of the given flags is set.
//
// Deprecated: use AtLeastOneTyped for typed error envelopes.
func AtLeastOne(rt *RuntimeContext, flags ...string) error {
for _, f := range flags {
if rt.Str(f) != "" {
return nil
}
}
names := make([]string, len(flags))
for i, f := range flags {
names[i] = "--" + f
}
return FlagErrorf("specify at least one of %s", strings.Join(names, " or "))
}
// AtLeastOneTyped checks that at least one of the given flags is set.
func AtLeastOneTyped(rt *RuntimeContext, flags ...string) error {
for _, f := range flags {
if rt.Str(f) != "" {
return nil
}
}
names := make([]string, len(flags))
for i, f := range flags {
names[i] = "--" + f
}
return ValidationErrorf("specify at least one of %s", strings.Join(names, " or ")).
WithParams(invalidParams(names, "required; specify at least one")...)
}
// ExactlyOne checks that exactly one of the given flags is set.
//
// Deprecated: use ExactlyOneTyped for typed error envelopes.
func ExactlyOne(rt *RuntimeContext, flags ...string) error {
if err := AtLeastOne(rt, flags...); err != nil {
return err
}
return MutuallyExclusive(rt, flags...)
}
// ExactlyOneTyped checks that exactly one of the given flags is set.
func ExactlyOneTyped(rt *RuntimeContext, flags ...string) error {
if err := AtLeastOneTyped(rt, flags...); err != nil {
return err
}
return MutuallyExclusiveTyped(rt, flags...)
}
// ValidatePageSize validates that the named flag (if set) is an integer within [minVal, maxVal].
// It returns the parsed value (or defaultVal if the flag is empty) and any validation error.
//
// Deprecated: use ValidatePageSizeTyped for typed error envelopes.
func ValidatePageSize(rt *RuntimeContext, flagName string, defaultVal, minVal, maxVal int) (int, error) {
s := rt.Str(flagName)
if s == "" {
return defaultVal, nil
}
n, err := strconv.Atoi(s)
if err != nil {
return 0, FlagErrorf("invalid --%s %q: must be an integer", flagName, s)
}
if n < minVal || n > maxVal {
return 0, FlagErrorf("invalid --%s %d: must be between %d and %d", flagName, n, minVal, maxVal)
}
return n, nil
}
// ValidatePageSizeTyped validates that the named flag (if set) is an integer within [minVal, maxVal].
// It returns the parsed value (or defaultVal if the flag is empty) and any validation error.
func ValidatePageSizeTyped(rt *RuntimeContext, flagName string, defaultVal, minVal, maxVal int) (int, error) {
s := rt.Str(flagName)
param := "--" + flagName
if s == "" {
return defaultVal, nil
}
n, err := strconv.Atoi(s)
if err != nil {
return 0, ValidationErrorf("invalid --%s %q: must be an integer", flagName, s).WithParam(param)
}
if n < minVal || n > maxVal {
return 0, ValidationErrorf("invalid --%s %d: must be between %d and %d", flagName, n, minVal, maxVal).
WithParam(param)
}
return n, nil
}
// ParseIntBounded parses an int flag and clamps it to [min, max].
func ParseIntBounded(rt *RuntimeContext, name string, min, max int) int {
v := rt.Int(name)
if v < min {
return min
}
if v > max {
return max
}
return v
}
// ValidateSafePath ensures path is relative and resolves within the current
// working directory. It catches traversal, symlink escape, and control
// characters by delegating to FileIO.ResolvePath. Works for both file and
// directory paths.
//
// Deprecated: use ValidateSafePathTyped for typed error envelopes.
func ValidateSafePath(fio fileio.FileIO, path string) error {
_, err := fio.ResolvePath(path)
return err
}
// ValidateSafePathTyped ensures path resolves within the current working directory.
func ValidateSafePathTyped(fio fileio.FileIO, path string) error {
_, err := fio.ResolvePath(path)
if err != nil {
return ValidationErrorf("%s", err).WithCause(err)
}
return nil
}
// RejectDangerousChars returns an error if value contains ASCII control
// characters or dangerous Unicode code points.
//
// Deprecated: use RejectDangerousCharsTyped for typed error envelopes.
func RejectDangerousChars(paramName, value string) error {
for _, r := range value {
if r < 0x20 && r != '\t' && r != '\n' {
return fmt.Errorf("parameter %q contains control character U+%04X", paramName, r)
}
if r == 0x7F {
return fmt.Errorf("parameter %q contains DEL character", paramName)
}
if IsDangerousUnicode(r) {
return fmt.Errorf("parameter %q contains dangerous Unicode character U+%04X", paramName, r)
}
}
return nil
}
// RejectDangerousCharsTyped returns an error if value contains ASCII control
// characters or dangerous Unicode code points.
func RejectDangerousCharsTyped(paramName, value string) error {
for _, r := range value {
if r < 0x20 && r != '\t' && r != '\n' {
return ValidationErrorf("parameter %q contains control character U+%04X", paramName, r).
WithParam(paramName)
}
if r == 0x7F {
return ValidationErrorf("parameter %q contains DEL character", paramName).
WithParam(paramName)
}
if IsDangerousUnicode(r) {
return ValidationErrorf("parameter %q contains dangerous Unicode character U+%04X", paramName, r).
WithParam(paramName)
}
}
return nil
}
func invalidParams(names []string, reason string) []errs.InvalidParam {
params := make([]errs.InvalidParam, len(names))
for i, name := range names {
params[i] = errs.InvalidParam{Name: name, Reason: reason}
}
return params
}