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
18 changes: 18 additions & 0 deletions shortcuts/common/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,24 @@ func WrapOpenError(err error, pathMsg, readMsg string) error {
return fmt.Errorf("%s: %w", readMsg, err)
}

// WrapSaveErrorByCategory maps a FileIO.Save error to structured output errors,
// using standardized messages and the given error category (e.g. "api_error", "io").
// Path validation errors always use ErrValidation (exit code 2).
func WrapSaveErrorByCategory(err error, category string) error {
if err == nil {
return nil
}
var me *fileio.MkdirError
switch {
case errors.Is(err, fileio.ErrPathValidation):
return output.ErrValidation("unsafe output path: %s", err)
case errors.As(err, &me):
return output.Errorf(output.ExitInternal, category, "cannot create parent directory: %s", err)
default:
return output.Errorf(output.ExitInternal, category, "cannot create file: %s", err)
}
}

// 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
Expand Down
38 changes: 20 additions & 18 deletions shortcuts/minutes/minutes_download.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ import (
"strings"
"time"

"github.com/larksuite/cli/internal/vfs"

"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
Expand Down Expand Up @@ -79,7 +78,7 @@ var MinutesDownload = common.Shortcut{

// Batch mode: --output must be a directory, not an existing file.
if !single && outputPath != "" {
if fi, err := vfs.Stat(outputPath); err == nil && !fi.IsDir() {
if fi, err := runtime.FileIO().Stat(outputPath); err == nil && !fi.IsDir() {
return output.ErrValidation("--output %q is a file; batch mode expects a directory path", outputPath)
}
}
Expand Down Expand Up @@ -162,7 +161,7 @@ var MinutesDownload = common.Shortcut{
fmt.Fprintf(errOut, "Downloading media: %s\n", common.MaskToken(token))

// single token: --output is a file path; batch: --output is a directory
opts := downloadOpts{overwrite: overwrite, usedNames: usedNames}
opts := downloadOpts{fio: runtime.FileIO(), overwrite: overwrite, usedNames: usedNames}
if single {
opts.outputPath = outputPath
} else {
Expand Down Expand Up @@ -229,8 +228,9 @@ type downloadResult struct {
}

type downloadOpts struct {
outputPath string // explicit output file path (single mode only)
outputDir string // output directory (batch mode)
fio fileio.FileIO // file I/O abstraction
outputPath string // explicit output file path (single mode only)
outputDir string // output directory (batch mode)
overwrite bool
usedNames map[string]bool // tracks used filenames to deduplicate in batch mode
}
Expand Down Expand Up @@ -275,22 +275,24 @@ func downloadMediaFile(ctx context.Context, client *http.Client, downloadURL, mi
outputPath = filepath.Join(opts.outputDir, filename)
}

safePath, err := validate.SafeOutputPath(outputPath)
if err != nil {
return nil, output.ErrValidation("unsafe output path: %s", err)
}
if err := common.EnsureWritableFile(safePath, opts.overwrite); err != nil {
return nil, err
}
if err := vfs.MkdirAll(filepath.Dir(safePath), 0700); err != nil {
return nil, output.Errorf(output.ExitInternal, "api_error", "cannot create parent directory: %s", err)
if !opts.overwrite {
if _, statErr := opts.fio.Stat(outputPath); statErr == nil {
return nil, output.ErrValidation("output file already exists: %s (use --overwrite to replace)", outputPath)
}
}

sizeBytes, err := validate.AtomicWriteFromReader(safePath, resp.Body, 0600)
result, err := opts.fio.Save(outputPath, fileio.SaveOptions{
ContentType: resp.Header.Get("Content-Type"),
ContentLength: resp.ContentLength,
}, resp.Body)
if err != nil {
return nil, output.Errorf(output.ExitInternal, "api_error", "cannot create file: %s", err)
return nil, common.WrapSaveErrorByCategory(err, "io")
}
resolvedPath, err := opts.fio.ResolvePath(outputPath)
if err != nil || resolvedPath == "" {
resolvedPath = outputPath
}
return &downloadResult{savedPath: safePath, sizeBytes: sizeBytes}, nil
return &downloadResult{savedPath: resolvedPath, sizeBytes: result.Size()}, nil
}

// resolveFilenameFromResponse derives the filename from HTTP response headers.
Expand Down
33 changes: 17 additions & 16 deletions shortcuts/vc/vc_notes.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@
package vc

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
Expand All @@ -22,11 +24,11 @@ import (

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/credential"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/shortcuts/common"
)

Expand Down Expand Up @@ -262,25 +264,16 @@ func downloadTranscriptFile(runtime *common.RuntimeContext, minuteToken string,
base = outDir
}
dirName := filepath.Join(base, sanitizeDirName(title, minuteToken))
transcriptPath := filepath.Join(dirName, "transcript.txt")

// Overwrite check via FileIO.Stat
if !runtime.Bool("overwrite") {
transcriptPath := filepath.Join(dirName, "transcript.txt")
if _, statErr := vfs.Stat(transcriptPath); statErr == nil {
if _, statErr := runtime.FileIO().Stat(transcriptPath); statErr == nil {
fmt.Fprintf(errOut, "%s transcript already exists: %s (use --overwrite to replace)\n", logPrefix, transcriptPath)
return transcriptPath
}
}
Comment thread
tuxedomm marked this conversation as resolved.

transcriptPath := filepath.Join(dirName, "transcript.txt")
safePath, err := validate.SafeOutputPath(transcriptPath)
if err != nil {
fmt.Fprintf(errOut, "%s invalid transcript path: %v\n", logPrefix, err)
return ""
}
if err := vfs.MkdirAll(filepath.Dir(safePath), 0755); err != nil {
fmt.Fprintf(errOut, "%s failed to create directory: %v\n", logPrefix, err)
return ""
}

fmt.Fprintf(errOut, "%s downloading transcript: %s\n", logPrefix, transcriptPath)
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
Expand All @@ -303,8 +296,16 @@ func downloadTranscriptFile(runtime *common.RuntimeContext, minuteToken string,
fmt.Fprintf(errOut, "%s transcript is empty (not available for this minute)\n", logPrefix)
return ""
}
if err := validate.AtomicWrite(safePath, apiResp.RawBody, 0644); err != nil {
fmt.Fprintf(errOut, "%s failed to write transcript: %v\n", logPrefix, err)
if _, err := runtime.FileIO().Save(transcriptPath, fileio.SaveOptions{}, bytes.NewReader(apiResp.RawBody)); err != nil {
var me *fileio.MkdirError
switch {
case errors.Is(err, fileio.ErrPathValidation):
fmt.Fprintf(errOut, "%s invalid transcript path: %v\n", logPrefix, err)
case errors.As(err, &me):
fmt.Fprintf(errOut, "%s failed to create directory: %v\n", logPrefix, err)
default:
fmt.Fprintf(errOut, "%s failed to write transcript: %v\n", logPrefix, err)
}
return ""
}
return transcriptPath
Expand Down
Loading