Skip to content

Commit c7ced37

Browse files
authored
feat: unify minute artifacts output to ./minutes/{minute_token}/ (#604)
* feat: unify minute artifacts output to ./minutes/{minute_token}/ * fix: tighten path validation and batch-mode --output rejection * style: translate comments to english and trim historical context * style: translate leftover chinese comments in vc_notes * refactor: address review findings across validate ordering, error types, JSON, tests * fix: sanitize server-provided filename to prevent escape from artifact dir * style: tighten flag help text for minutes/vc output flags * docs: update minutes/vc skill docs for unified artifact layout
1 parent 81d22c6 commit c7ced37

11 files changed

Lines changed: 569 additions & 77 deletions

File tree

shortcuts/common/artifact_path.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
// This file defines artifact-path conventions shared between
5+
// `minutes +download` and `vc +notes`. Callers outside those two shortcuts
6+
// should not take a dependency on these symbols.
7+
8+
package common
9+
10+
import "path/filepath"
11+
12+
// DefaultMinuteArtifactSubdir is the top-level directory for minute-scoped
13+
// artifacts under the default layout.
14+
const DefaultMinuteArtifactSubdir = "minutes"
15+
16+
// DefaultTranscriptFileName is the fixed transcript filename under the
17+
// default layout. Recording files keep the server-provided name.
18+
const DefaultTranscriptFileName = "transcript.txt"
19+
20+
// ArtifactTypeRecording is the artifact_type value emitted by
21+
// `minutes +download` so that callers can index results by kind without
22+
// parsing saved_path.
23+
const ArtifactTypeRecording = "recording"
24+
25+
// DefaultMinuteArtifactDir returns the default output directory for an
26+
// artifact keyed by minuteToken. The same path is shared across commands so
27+
// that related artifacts of one meeting land together.
28+
func DefaultMinuteArtifactDir(minuteToken string) string {
29+
return filepath.Join(DefaultMinuteArtifactSubdir, minuteToken)
30+
}

shortcuts/common/validate.go

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -83,13 +83,12 @@ func ParseIntBounded(rt *RuntimeContext, name string, min, max int) int {
8383
return v
8484
}
8585

86-
// ValidateSafeOutputDir ensures outputDir is a relative path that resolves
87-
// within the current working directory, preventing path traversal attacks
88-
// (including symlink-based escape).
89-
// It delegates all validation to FileIO.ResolvePath which already performs
90-
// cwd-boundary checks, symlink resolution, and control-character rejection.
91-
func ValidateSafeOutputDir(fio fileio.FileIO, outputDir string) error {
92-
_, err := fio.ResolvePath(outputDir)
86+
// ValidateSafePath ensures path is relative and resolves within the current
87+
// working directory. It catches traversal, symlink escape, and control
88+
// characters by delegating to FileIO.ResolvePath. Works for both file and
89+
// directory paths.
90+
func ValidateSafePath(fio fileio.FileIO, path string) error {
91+
_, err := fio.ResolvePath(path)
9392
return err
9493
}
9594

shortcuts/common/validate_test.go

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ func TestParseIntBounded(t *testing.T) {
172172
}
173173

174174
// ---------------------------------------------------------------------------
175-
// ValidateSafeOutputDir — symlink escape prevention
175+
// ValidateSafePath — symlink escape prevention
176176
// ---------------------------------------------------------------------------
177177

178178
// chdirForTest changes CWD to dir and restores the original CWD on cleanup.
@@ -188,9 +188,9 @@ func chdirForTest(t *testing.T, dir string) {
188188
t.Cleanup(func() { os.Chdir(orig) })
189189
}
190190

191-
// TestValidateSafeOutputDir_RejectsSymlinkEscape verifies that a relative path
191+
// TestValidateSafePath_RejectsSymlinkEscape verifies that a relative path
192192
// that resolves to a symlink pointing outside CWD is rejected.
193-
func TestValidateSafeOutputDir_RejectsSymlinkEscape(t *testing.T) {
193+
func TestValidateSafePath_RejectsSymlinkEscape(t *testing.T) {
194194
outside := t.TempDir() // target outside CWD
195195
workDir := t.TempDir()
196196
chdirForTest(t, workDir)
@@ -200,29 +200,29 @@ func TestValidateSafeOutputDir_RejectsSymlinkEscape(t *testing.T) {
200200
t.Fatalf("Symlink: %v", err)
201201
}
202202

203-
if err := ValidateSafeOutputDir(&localfileio.LocalFileIO{}, "evil_out"); err == nil {
203+
if err := ValidateSafePath(&localfileio.LocalFileIO{}, "evil_out"); err == nil {
204204
t.Fatal("expected error for symlink pointing outside CWD, got nil")
205205
}
206206
}
207207

208-
// TestValidateSafeOutputDir_RejectsDanglingSymlink verifies that a dangling
208+
// TestValidateSafePath_RejectsDanglingSymlink verifies that a dangling
209209
// symlink (target does not exist) is rejected to prevent future escapes.
210-
func TestValidateSafeOutputDir_RejectsDanglingSymlink(t *testing.T) {
210+
func TestValidateSafePath_RejectsDanglingSymlink(t *testing.T) {
211211
workDir := t.TempDir()
212212
chdirForTest(t, workDir)
213213

214214
if err := os.Symlink("/nonexistent/outside/target", filepath.Join(workDir, "dangling")); err != nil {
215215
t.Fatalf("Symlink: %v", err)
216216
}
217217

218-
if err := ValidateSafeOutputDir(&localfileio.LocalFileIO{}, "dangling"); err == nil {
218+
if err := ValidateSafePath(&localfileio.LocalFileIO{}, "dangling"); err == nil {
219219
t.Fatal("expected error for dangling symlink, got nil")
220220
}
221221
}
222222

223-
// TestValidateSafeOutputDir_AllowsNormalSubdir verifies that an existing real
223+
// TestValidateSafePath_AllowsNormalSubdir verifies that an existing real
224224
// subdirectory within CWD is accepted.
225-
func TestValidateSafeOutputDir_AllowsNormalSubdir(t *testing.T) {
225+
func TestValidateSafePath_AllowsNormalSubdir(t *testing.T) {
226226
workDir := t.TempDir()
227227
chdirForTest(t, workDir)
228228

@@ -231,18 +231,18 @@ func TestValidateSafeOutputDir_AllowsNormalSubdir(t *testing.T) {
231231
t.Fatalf("Mkdir: %v", err)
232232
}
233233

234-
if err := ValidateSafeOutputDir(&localfileio.LocalFileIO{}, "output"); err != nil {
234+
if err := ValidateSafePath(&localfileio.LocalFileIO{}, "output"); err != nil {
235235
t.Fatalf("expected no error for real subdir, got: %v", err)
236236
}
237237
}
238238

239-
// TestValidateSafeOutputDir_AllowsNonExistentPath verifies that a path that
239+
// TestValidateSafePath_AllowsNonExistentPath verifies that a path that
240240
// does not yet exist (new output directory) is accepted.
241-
func TestValidateSafeOutputDir_AllowsNonExistentPath(t *testing.T) {
241+
func TestValidateSafePath_AllowsNonExistentPath(t *testing.T) {
242242
workDir := t.TempDir()
243243
chdirForTest(t, workDir)
244244

245-
if err := ValidateSafeOutputDir(&localfileio.LocalFileIO{}, "new_output_dir"); err != nil {
245+
if err := ValidateSafePath(&localfileio.LocalFileIO{}, "new_output_dir"); err != nil {
246246
t.Fatalf("expected no error for non-existent path, got: %v", err)
247247
}
248248
}

shortcuts/minutes/minutes_download.go

Lines changed: 99 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@ package minutes
55

66
import (
77
"context"
8+
"errors"
89
"fmt"
910
"io"
11+
"io/fs"
1012
"mime"
1113
"net/http"
14+
"path"
1215
"path/filepath"
1316
"regexp"
1417
"strings"
@@ -43,7 +46,8 @@ var MinutesDownload = common.Shortcut{
4346
HasFormat: true,
4447
Flags: []common.Flag{
4548
{Name: "minute-tokens", Desc: "minute tokens, comma-separated for batch download (max 50)", Required: true},
46-
{Name: "output", Desc: "output path: file path for single token, directory for batch (default: current dir)"},
49+
{Name: "output", Desc: "output file path (single token)"},
50+
{Name: "output-dir", Desc: "output directory (default: ./minutes/{minute_token}/)"},
4751
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
4852
{Name: "url-only", Type: "bool", Desc: "only print the download URL(s) without downloading"},
4953
},
@@ -60,6 +64,22 @@ var MinutesDownload = common.Shortcut{
6064
return output.ErrValidation("invalid minute token %q: must contain only lowercase alphanumeric characters (e.g. obcnq3b9jl72l83w4f149w9c)", token)
6165
}
6266
}
67+
// Cheap checks first, then path-safety resolution.
68+
out := runtime.Str("output")
69+
outDir := runtime.Str("output-dir")
70+
if out != "" && outDir != "" {
71+
return output.ErrValidation("--output and --output-dir cannot both be set")
72+
}
73+
if out != "" {
74+
if err := common.ValidateSafePath(runtime.FileIO(), out); err != nil {
75+
return err
76+
}
77+
}
78+
if outDir != "" {
79+
if err := common.ValidateSafePath(runtime.FileIO(), outDir); err != nil {
80+
return err
81+
}
82+
}
6383
return nil
6484
},
6585
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -70,29 +90,53 @@ var MinutesDownload = common.Shortcut{
7090
},
7191
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
7292
tokens := common.SplitCSV(runtime.Str("minute-tokens"))
73-
outputPath := runtime.Str("output")
93+
rawOutput := runtime.Str("output")
94+
rawOutputDir := runtime.Str("output-dir")
7495
overwrite := runtime.Bool("overwrite")
7596
urlOnly := runtime.Bool("url-only")
7697
errOut := runtime.IO().ErrOut
7798
single := len(tokens) == 1
7899

79-
// Batch mode: --output must be a directory, not an existing file.
80-
if !single && outputPath != "" {
81-
if fi, err := runtime.FileIO().Stat(outputPath); err == nil && !fi.IsDir() {
82-
return output.ErrValidation("--output %q is a file; batch mode expects a directory path", outputPath)
100+
// Re-interpret --output based on what the path points to. An existing
101+
// directory is promoted to --output-dir so single-token cp semantics
102+
// work. An existing file is rejected in batch mode (the flag carries
103+
// directory semantics there). Unknown filesystem errors are surfaced
104+
// eagerly rather than deferred to Save.
105+
explicitOutputPath := rawOutput
106+
explicitOutputDir := rawOutputDir
107+
if explicitOutputPath != "" {
108+
fi, statErr := runtime.FileIO().Stat(explicitOutputPath)
109+
switch {
110+
case statErr == nil && fi.IsDir():
111+
explicitOutputDir = explicitOutputPath
112+
explicitOutputPath = ""
113+
case statErr == nil && !fi.IsDir():
114+
if !single {
115+
return output.ErrValidation("--output %q is a file; batch mode expects a directory (use --output-dir)", explicitOutputPath)
116+
}
117+
case errors.Is(statErr, fs.ErrNotExist):
118+
if !single {
119+
explicitOutputDir = explicitOutputPath
120+
explicitOutputPath = ""
121+
}
122+
default:
123+
return output.Errorf(output.ExitAPI, "io_error", "cannot access --output %q: %s", explicitOutputPath, statErr)
83124
}
84125
}
85126

127+
useDefaultLayout := explicitOutputPath == "" && explicitOutputDir == ""
128+
86129
if !single {
87130
fmt.Fprintf(errOut, "[minutes +download] batch: %d token(s)\n", len(tokens))
88131
}
89132

90133
type result struct {
91-
MinuteToken string `json:"minute_token"`
92-
SavedPath string `json:"saved_path,omitempty"`
93-
SizeBytes int64 `json:"size_bytes,omitempty"`
94-
DownloadURL string `json:"download_url,omitempty"`
95-
Error string `json:"error,omitempty"`
134+
MinuteToken string `json:"minute_token"`
135+
ArtifactType string `json:"artifact_type,omitempty"`
136+
SavedPath string `json:"saved_path,omitempty"`
137+
SizeBytes int64 `json:"size_bytes,omitempty"`
138+
DownloadURL string `json:"download_url,omitempty"`
139+
Error string `json:"error,omitempty"`
96140
}
97141

98142
results := make([]result, len(tokens))
@@ -160,20 +204,31 @@ var MinutesDownload = common.Shortcut{
160204

161205
fmt.Fprintf(errOut, "Downloading media: %s\n", common.MaskToken(token))
162206

163-
// single token: --output is a file path; batch: --output is a directory
164-
opts := downloadOpts{fio: runtime.FileIO(), overwrite: overwrite, usedNames: usedNames}
165-
if single {
166-
opts.outputPath = outputPath
167-
} else {
168-
opts.outputDir = outputPath
207+
opts := downloadOpts{fio: runtime.FileIO(), overwrite: overwrite}
208+
switch {
209+
case useDefaultLayout:
210+
// Per-token subdirectory guarantees unique paths, so no dedup map.
211+
opts.outputDir = common.DefaultMinuteArtifactDir(token)
212+
case explicitOutputPath != "" && single:
213+
opts.outputPath = explicitOutputPath
214+
default:
215+
opts.outputDir = explicitOutputDir
216+
if !single {
217+
opts.usedNames = usedNames
218+
}
169219
}
170220

171221
dl, err := downloadMediaFile(ctx, dlClient, downloadURL, token, opts)
172222
if err != nil {
173223
results[i] = result{MinuteToken: token, Error: err.Error()}
174224
continue
175225
}
176-
results[i] = result{MinuteToken: token, SavedPath: dl.savedPath, SizeBytes: dl.sizeBytes}
226+
results[i] = result{
227+
MinuteToken: token,
228+
ArtifactType: common.ArtifactTypeRecording,
229+
SavedPath: dl.savedPath,
230+
SizeBytes: dl.sizeBytes,
231+
}
177232
}
178233

179234
// output
@@ -183,9 +238,17 @@ var MinutesDownload = common.Shortcut{
183238
return output.ErrAPI(0, r.Error, nil)
184239
}
185240
if urlOnly {
186-
runtime.Out(map[string]interface{}{"download_url": r.DownloadURL}, nil)
241+
runtime.Out(map[string]interface{}{
242+
"minute_token": r.MinuteToken,
243+
"download_url": r.DownloadURL,
244+
}, nil)
187245
} else {
188-
runtime.Out(map[string]interface{}{"saved_path": r.SavedPath, "size_bytes": r.SizeBytes}, nil)
246+
runtime.Out(map[string]interface{}{
247+
"minute_token": r.MinuteToken,
248+
"artifact_type": r.ArtifactType,
249+
"saved_path": r.SavedPath,
250+
"size_bytes": r.SizeBytes,
251+
}, nil)
189252
}
190253
return nil
191254
}
@@ -230,7 +293,7 @@ type downloadResult struct {
230293
type downloadOpts struct {
231294
fio fileio.FileIO // file I/O abstraction
232295
outputPath string // explicit output file path (single mode only)
233-
outputDir string // output directory (batch mode)
296+
outputDir string // output directory (single or batch)
234297
overwrite bool
235298
usedNames map[string]bool // tracks used filenames to deduplicate in batch mode
236299
}
@@ -300,7 +363,7 @@ func downloadMediaFile(ctx context.Context, client *http.Client, downloadURL, mi
300363
func resolveFilenameFromResponse(resp *http.Response, minuteToken string) string {
301364
if cd := resp.Header.Get("Content-Disposition"); cd != "" {
302365
if _, params, err := mime.ParseMediaType(cd); err == nil {
303-
if filename := params["filename"]; filename != "" {
366+
if filename := sanitizeServerFilename(params["filename"]); filename != "" {
304367
return filename
305368
}
306369
}
@@ -311,6 +374,20 @@ func resolveFilenameFromResponse(resp *http.Response, minuteToken string) string
311374
return minuteToken + ".media"
312375
}
313376

377+
// sanitizeServerFilename reduces a server-provided filename to its basename,
378+
// defending against Content-Disposition payloads that embed directory
379+
// separators (e.g. "../other.mp4") and would otherwise escape the intended
380+
// artifact directory after filepath.Join. Empty or dot-only names return ""
381+
// so the caller can fall back to the next naming strategy.
382+
func sanitizeServerFilename(filename string) string {
383+
filename = strings.ReplaceAll(filename, "\\", "/")
384+
filename = path.Base(filename)
385+
if filename == "" || filename == "." || filename == ".." {
386+
return ""
387+
}
388+
return filename
389+
}
390+
314391
// preferredExt overrides Go's mime.ExtensionsByType which returns alphabetically sorted
315392
// results (e.g. .m4v before .mp4 for video/mp4).
316393
var preferredExt = map[string]string{

0 commit comments

Comments
 (0)