Skip to content

Commit 3184006

Browse files
committed
fix: tighten path validation and batch-mode --output rejection
1 parent f06121c commit 3184006

3 files changed

Lines changed: 52 additions & 18 deletions

File tree

shortcuts/minutes/minutes_download.go

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ package minutes
55

66
import (
77
"context"
8+
"errors"
89
"fmt"
910
"io"
11+
"io/fs"
1012
"mime"
1113
"net/http"
1214
"path/filepath"
@@ -61,6 +63,20 @@ var MinutesDownload = common.Shortcut{
6163
return output.ErrValidation("invalid minute token %q: must contain only lowercase alphanumeric characters (e.g. obcnq3b9jl72l83w4f149w9c)", token)
6264
}
6365
}
66+
// fail-fast: user-supplied path safety (traversal / cwd-escape / control chars)
67+
if out := runtime.Str("output"); out != "" {
68+
if err := common.ValidateSafeOutputDir(runtime.FileIO(), out); err != nil {
69+
return err
70+
}
71+
}
72+
if outDir := runtime.Str("output-dir"); outDir != "" {
73+
if err := common.ValidateSafeOutputDir(runtime.FileIO(), outDir); err != nil {
74+
return err
75+
}
76+
}
77+
if runtime.Str("output") != "" && runtime.Str("output-dir") != "" {
78+
return output.ErrValidation("--output and --output-dir cannot both be set")
79+
}
6480
return nil
6581
},
6682
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -78,28 +94,37 @@ var MinutesDownload = common.Shortcut{
7894
errOut := runtime.IO().ErrOut
7995
single := len(tokens) == 1
8096

81-
if rawOutput != "" && rawOutputDir != "" {
82-
return output.ErrValidation("--output and --output-dir cannot both be set")
83-
}
84-
85-
// --output 指向已存在目录:降级为 --output-dir(cp 语义),修复单 token 模式
86-
// 下传目录路径报 "cannot create parent directory" 的问题。
97+
// --output 的语义分情况:
98+
// - 已存在目录 → 降级为 --output-dir(cp 语义,修复单 token 传目录报 mkdir err 的 bug)
99+
// - 已存在文件 → 单 token 覆写;批量模式明确拒绝(dir 语义不兼容)
100+
// - 不存在 → 单 token 作为新文件路径;批量作为待创建目录
101+
// - 路径校验失败 / 其他 FS 错误 → 立即抛出(避免延迟到 Save 才暴露)
87102
explicitOutputPath := rawOutput
88103
explicitOutputDir := rawOutputDir
89104
if explicitOutputPath != "" {
90-
if fi, err := runtime.FileIO().Stat(explicitOutputPath); err == nil && fi.IsDir() {
105+
fi, statErr := runtime.FileIO().Stat(explicitOutputPath)
106+
switch {
107+
case statErr == nil && fi.IsDir():
91108
explicitOutputDir = explicitOutputPath
92109
explicitOutputPath = ""
110+
case statErr == nil && !fi.IsDir():
111+
if !single {
112+
return output.ErrValidation("--output %q is a file; batch mode expects a directory (use --output-dir)", explicitOutputPath)
113+
}
114+
// single mode: keep as explicit file path
115+
case errors.Is(statErr, fs.ErrNotExist):
116+
if !single {
117+
// batch: treat non-existent path as directory to be created
118+
explicitOutputDir = explicitOutputPath
119+
explicitOutputPath = ""
120+
}
121+
// single mode: keep as new file path
122+
default:
123+
return output.ErrValidation("cannot access --output %q: %s", explicitOutputPath, statErr)
93124
}
94125
}
95126

96-
// 批量模式下 --output 仍然保留"目录"语义以兼容既有用法。
97-
if !single && explicitOutputPath != "" {
98-
explicitOutputDir = explicitOutputPath
99-
explicitOutputPath = ""
100-
}
101-
102-
// 用户完全未传路径:落到 ./minutes/{minute_token}/recording.{ext}
127+
// 用户完全未传路径:落到 ./minutes/{minute_token}/(文件名沿用服务端)
103128
useDefaultLayout := explicitOutputPath == "" && explicitOutputDir == ""
104129

105130
if !single {

shortcuts/minutes/minutes_download_test.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -200,13 +200,22 @@ func TestDownload_Validation_InvalidToken(t *testing.T) {
200200
}
201201
}
202202

203-
func TestDownload_Validation_OutputWithBatch(t *testing.T) {
203+
func TestDownload_Validation_OutputIsFileInBatchMode(t *testing.T) {
204+
// 批量模式下 --output 指向已存在的文件 → 明确拒绝(dir 语义不兼容)。
205+
// 非存在路径在批量模式下会被当作待创建目录(见 TestDownload_Batch_OutputNewDir)。
206+
chdir(t, t.TempDir())
207+
if err := os.WriteFile("already.mp4", []byte("x"), 0644); err != nil {
208+
t.Fatalf("setup: %v", err)
209+
}
204210
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
205211
err := mountAndRun(t, MinutesDownload, []string{
206-
"+download", "--minute-tokens", "t1,t2", "--output", "file.mp4", "--as", "user",
212+
"+download", "--minute-tokens", "t1,t2", "--output", "already.mp4", "--as", "user",
207213
}, f, nil)
208214
if err == nil {
209-
t.Fatal("expected validation error for --output with --minute-tokens")
215+
t.Fatal("expected error for --output pointing at an existing file in batch mode")
216+
}
217+
if !strings.Contains(err.Error(), "batch mode expects a directory") {
218+
t.Errorf("error should mention batch-mode directory expectation, got: %v", err)
210219
}
211220
}
212221

shortcuts/vc/vc_notes.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -502,7 +502,7 @@ var VCNotes = common.Shortcut{
502502
{Name: "meeting-ids", Desc: "meeting IDs, comma-separated for batch"},
503503
{Name: "minute-tokens", Desc: "minute tokens, comma-separated for batch"},
504504
{Name: "calendar-event-ids", Desc: "calendar event instance IDs, comma-separated for batch"},
505-
{Name: "output-dir", Desc: "output directory for artifact files (default: current dir)"},
505+
{Name: "output-dir", Desc: "output directory for artifact files. When omitted, transcript lands to ./minutes/{minute_token}/transcript.txt; when set, legacy layout ./{output-dir}/artifact-{title}-{token}/transcript.txt is used."},
506506
{Name: "overwrite", Type: "bool", Desc: "overwrite existing artifact files"},
507507
},
508508
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {

0 commit comments

Comments
 (0)