diff --git a/cmd/root_test.go b/cmd/root_test.go index 096c404de0..21836ea66d 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -624,7 +624,7 @@ func TestApplyNeedAuthorizationHint_ShortcutUsesDeclaredScopesWhenNoUAT(t *testi t.Errorf("Subtype = %q, want %q", problem.Subtype, errs.SubtypeUnknown) } - if !strings.Contains(problem.Hint, `auth login --scope "docx:document:create" --no-wait --json`) { + if !strings.Contains(problem.Hint, `auth login --scope "docx:document:create docs:document.media:upload docx:document:write_only docx:document:readonly" --no-wait --json`) { t.Errorf("expected shortcut scoped recovery, got %q", problem.Hint) } } @@ -699,7 +699,12 @@ func TestApplyNeedAuthorizationHint_AppendsExistingHint(t *testing.T) { t.Errorf("rendered error lost need-authorization cause %v: %v", authErr.Cause, rendered) } - want := "existing hint\n" + recovery.UserAuthorization("docx:document:create").String() + want := "existing hint\n" + recovery.UserAuthorization( + "docx:document:create", + "docs:document.media:upload", + "docx:document:write_only", + "docx:document:readonly", + ).String() if problem.Hint != want { t.Errorf("expected appended hint %q, got %q", want, problem.Hint) } diff --git a/extension/fileio/types.go b/extension/fileio/types.go index 386bda198e..a028b23157 100644 --- a/extension/fileio/types.go +++ b/extension/fileio/types.go @@ -65,9 +65,13 @@ type SaveResult interface { } // SaveOptions carries metadata for Save. -// The default (local) implementation ignores these fields; +// The default (local) implementation honors NoReplace and ignores the other +// 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 + // NoReplace makes Save fail if the destination already exists. The local + // implementation preserves the atomic write guarantee for this mode. + NoReplace bool } diff --git a/go.mod b/go.mod index 26497138b2..8839b7fc71 100644 --- a/go.mod +++ b/go.mod @@ -19,11 +19,12 @@ require ( github.com/stretchr/testify v1.11.1 github.com/tidwall/gjson v1.18.0 github.com/zalando/go-keyring v0.2.8 + golang.org/x/image v0.30.0 golang.org/x/net v0.33.0 - golang.org/x/sync v0.15.0 + golang.org/x/sync v0.16.0 golang.org/x/sys v0.33.0 golang.org/x/term v0.27.0 - golang.org/x/text v0.23.0 + golang.org/x/text v0.28.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 7e42f36199..574157832b 100644 --- a/go.sum +++ b/go.sum @@ -139,6 +139,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4= +golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -150,8 +152,8 @@ golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -163,8 +165,8 @@ golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= diff --git a/internal/vfs/default.go b/internal/vfs/default.go index 508c2399b9..e5f2eeedc0 100644 --- a/internal/vfs/default.go +++ b/internal/vfs/default.go @@ -33,5 +33,6 @@ func ReadDir(name string) ([]os.DirEntry, error) { return DefaultFS.ReadDi func Remove(name string) error { return DefaultFS.Remove(name) } func RemoveAll(path string) error { return DefaultFS.RemoveAll(path) } func Rename(oldpath, newpath string) error { return DefaultFS.Rename(oldpath, newpath) } +func Link(oldpath, newpath string) error { return DefaultFS.Link(oldpath, newpath) } func EvalSymlinks(path string) (string, error) { return DefaultFS.EvalSymlinks(path) } func Executable() (string, error) { return DefaultFS.Executable() } diff --git a/internal/vfs/fs.go b/internal/vfs/fs.go index 10825bd946..5e3b245aef 100644 --- a/internal/vfs/fs.go +++ b/internal/vfs/fs.go @@ -31,6 +31,7 @@ type FS interface { Remove(name string) error RemoveAll(path string) error Rename(oldpath, newpath string) error + Link(oldpath, newpath string) error // Path resolution EvalSymlinks(path string) (string, error) diff --git a/internal/vfs/localfileio/atomicwrite.go b/internal/vfs/localfileio/atomicwrite.go index 00fc6f91af..b3ce4bcc8a 100644 --- a/internal/vfs/localfileio/atomicwrite.go +++ b/internal/vfs/localfileio/atomicwrite.go @@ -14,7 +14,7 @@ import ( // 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 { + return atomicWrite(path, perm, false, func(tmp *os.File) error { _, err := tmp.Write(data) return err }) @@ -22,8 +22,18 @@ func AtomicWrite(path string, data []byte, perm os.FileMode) error { // AtomicWriteFromReader atomically copies reader contents into path. func AtomicWriteFromReader(path string, reader io.Reader, perm os.FileMode) (int64, error) { + return atomicWriteFromReader(path, reader, perm, false) +} + +// AtomicWriteFromReaderNoReplace atomically creates path and fails when the +// destination already exists. +func AtomicWriteFromReaderNoReplace(path string, reader io.Reader, perm os.FileMode) (int64, error) { + return atomicWriteFromReader(path, reader, perm, true) +} + +func atomicWriteFromReader(path string, reader io.Reader, perm os.FileMode, noReplace bool) (int64, error) { var copied int64 - err := atomicWrite(path, perm, func(tmp *os.File) error { + err := atomicWrite(path, perm, noReplace, func(tmp *os.File) error { n, err := io.Copy(tmp, reader) copied = n return err @@ -34,7 +44,7 @@ func AtomicWriteFromReader(path string, reader io.Reader, perm os.FileMode) (int return copied, nil } -func atomicWrite(path string, perm os.FileMode, writeFn func(tmp *os.File) error) error { +func atomicWrite(path string, perm os.FileMode, noReplace bool, writeFn func(tmp *os.File) error) error { dir := filepath.Dir(path) tmp, err := vfs.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp") if err != nil { @@ -66,6 +76,19 @@ func atomicWrite(path string, perm os.FileMode, writeFn func(tmp *os.File) error return err } closed = true + if noReplace { + // A hard link publishes the fully written temp file under the final + // name without replacing an existing destination. Both files live in + // the same directory, so this is atomic on supported filesystems. + if err := vfs.Link(tmpName, path); err != nil { + return err + } + if err := vfs.Remove(tmpName); err != nil { + return fmt.Errorf("remove temporary file: %w", err) + } + success = true + return nil + } if err := vfs.Rename(tmpName, path); err != nil { return err } diff --git a/internal/vfs/localfileio/atomicwrite_test.go b/internal/vfs/localfileio/atomicwrite_test.go index d8dbbb7510..6e095eda11 100644 --- a/internal/vfs/localfileio/atomicwrite_test.go +++ b/internal/vfs/localfileio/atomicwrite_test.go @@ -4,6 +4,7 @@ package localfileio import ( + "bytes" "os" "path/filepath" "runtime" @@ -71,6 +72,25 @@ func TestAtomicWrite_OverwritesExistingFile(t *testing.T) { } } +func TestAtomicWriteFromReaderNoReplacePreservesExistingFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "draft.json") + if err := AtomicWrite(path, []byte("original"), 0600); err != nil { + t.Fatal(err) + } + + if _, err := AtomicWriteFromReaderNoReplace(path, bytes.NewReader([]byte("replacement")), 0600); err == nil { + t.Fatal("AtomicWriteFromReaderNoReplace succeeded for an existing file") + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != "original" { + t.Fatalf("existing file content = %q, want original", got) + } +} + func TestAtomicWrite_LeavesNoResidualTempFileOnError(t *testing.T) { // GIVEN: a target path in a non-existent nested directory path := filepath.Join(t.TempDir(), "nonexistent", "subdir", "file.txt") diff --git a/internal/vfs/localfileio/localfileio.go b/internal/vfs/localfileio/localfileio.go index 9fd60bc1ca..2aea9bf9fe 100644 --- a/internal/vfs/localfileio/localfileio.go +++ b/internal/vfs/localfileio/localfileio.go @@ -65,7 +65,7 @@ func (l *LocalFileIO) ResolvePath(path string) (string, error) { // 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) { +func (l *LocalFileIO) Save(path string, opts fileio.SaveOptions, body io.Reader) (fileio.SaveResult, error) { safePath, err := SafeOutputPath(path) if err != nil { return nil, &fileio.PathValidationError{Err: err} @@ -73,7 +73,11 @@ func (l *LocalFileIO) Save(path string, _ fileio.SaveOptions, body io.Reader) (f if err := vfs.MkdirAll(filepath.Dir(safePath), 0700); err != nil { return nil, &fileio.MkdirError{Err: err} } - n, err := AtomicWriteFromReader(safePath, body, 0600) + write := AtomicWriteFromReader + if opts.NoReplace { + write = AtomicWriteFromReaderNoReplace + } + n, err := write(safePath, body, 0600) if err != nil { return nil, &fileio.WriteError{Err: err} } diff --git a/internal/vfs/localfileio/localfileio_test.go b/internal/vfs/localfileio/localfileio_test.go index 9581165ac1..f877ef7759 100644 --- a/internal/vfs/localfileio/localfileio_test.go +++ b/internal/vfs/localfileio/localfileio_test.go @@ -176,6 +176,26 @@ func TestLocalFileIO_Save_WritesContent(t *testing.T) { } } +func TestLocalFileIO_Save_NoReplacePreservesExistingContent(t *testing.T) { + dir := t.TempDir() + testChdir(t, dir) + + fio := &LocalFileIO{} + if _, err := fio.Save("draft.json", fileio.SaveOptions{}, strings.NewReader("original")); err != nil { + t.Fatalf("initial Save failed: %v", err) + } + if _, err := fio.Save("draft.json", fileio.SaveOptions{NoReplace: true}, strings.NewReader("replacement")); err == nil { + t.Fatal("NoReplace Save succeeded for an existing file") + } + got, err := os.ReadFile(filepath.Join(dir, "draft.json")) + if err != nil { + t.Fatal(err) + } + if string(got) != "original" { + t.Fatalf("existing content = %q, want original", got) + } +} + func TestLocalFileIO_Save_CreatesParentDirs(t *testing.T) { dir := t.TempDir() testChdir(t, dir) diff --git a/internal/vfs/osfs.go b/internal/vfs/osfs.go index 95922d5728..da9ac914b6 100644 --- a/internal/vfs/osfs.go +++ b/internal/vfs/osfs.go @@ -36,6 +36,7 @@ func (OsFs) ReadDir(name string) ([]os.DirEntry, error) { return os.ReadDir(n func (OsFs) Remove(name string) error { return os.Remove(name) } func (OsFs) RemoveAll(path string) error { return os.RemoveAll(path) } func (OsFs) Rename(oldpath, newpath string) error { return os.Rename(oldpath, newpath) } +func (OsFs) Link(oldpath, newpath string) error { return os.Link(oldpath, newpath) } // Path resolution func (OsFs) EvalSymlinks(path string) (string, error) { return filepath.EvalSymlinks(path) } diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index e12224f0cd..fe433678b7 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -711,6 +711,13 @@ func (ctx *RuntimeContext) handleEmitterError(err error) { ctx.outputErrOnce.Do(func() { ctx.outputErr = err }) } +// OutputError returns the first deferred output failure captured by Out, +// OutRaw, or OutFormat. Commands that create local artifacts can use it to +// roll those artifacts back before returning the final command error. +func (ctx *RuntimeContext) OutputError() error { + return ctx.outputErr +} + func wrapLegacyPrettyRenderer(prettyFn func(w io.Writer)) output.PrettyRenderer { if prettyFn == nil { return nil @@ -766,6 +773,21 @@ func (ctx *RuntimeContext) OutPartialFailure(data interface{}, meta *output.Meta return output.PartialFailure(output.ExitAPI) } +// OutPartialFailureRaw is like OutPartialFailure but disables HTML escaping +// for payloads that contain document markup in warning or result fields. +func (ctx *RuntimeContext) OutPartialFailureRaw(data interface{}, meta *output.Meta) error { + ctx.handleEmitterError(ctx.newEmitter().PartialFailure(data, output.EmitOptions{ + Format: "", + Raw: true, + JQ: ctx.JqExpr, + Meta: meta, + })) + if ctx.outputErr != nil { + return ctx.outputErr + } + return output.PartialFailure(output.ExitAPI) +} + // OutFormat prints output based on --format flag. // "json" (default) outputs JSON envelope; "pretty" calls prettyFn; others delegate to FormatValue. // When JqExpr is set, envelope filtering takes precedence over format. diff --git a/shortcuts/common/runner_partial_failure_test.go b/shortcuts/common/runner_partial_failure_test.go index 3147abbe32..016bfae611 100644 --- a/shortcuts/common/runner_partial_failure_test.go +++ b/shortcuts/common/runner_partial_failure_test.go @@ -4,6 +4,7 @@ package common import ( + "bytes" "context" "encoding/json" "errors" @@ -61,3 +62,29 @@ func TestOutPartialFailure(t *testing.T) { t.Fatalf("both succeeded and failed items must ride on stdout, got %d items\nstdout: %s", len(items), stdout.String()) } } + +func TestOutPartialFailureRawPreservesDocumentMarkup(t *testing.T) { + cfg := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_x"} + f, stdout, _, _ := cmdutil.TestFactory(t, cfg) + rt := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+script"}, cfg, f, core.AsUser) + + err := rt.OutPartialFailureRaw(map[string]interface{}{ + "warning": []string{`use `}, + }, nil) + var partialFailure *output.PartialFailureError + if !errors.As(err, &partialFailure) || partialFailure.Code != output.ExitAPI { + t.Fatalf("error = %T %v, want ExitAPI partial failure", err, err) + } + if bytes.Contains(stdout.Bytes(), []byte(`\u003cimg`)) || !bytes.Contains(stdout.Bytes(), []byte(`