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
1 change: 1 addition & 0 deletions NEXT_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Notable Changes

### CLI
* `workspace export-dir` no longer aborts when a workspace object's name is not a legal local filename (e.g. a notebook named `New Notebook 2026-05-04 13:54:24` whose `:` is illegal on Windows). Such files are now exported under a sanitized name with a warning and the export completes ([#5171](https://github.com/databricks/cli/issues/5171)).

### Bundles
* `bundle run` now prints the modern job run URL (`/jobs/<id>/runs/<id>`) so that non-admin users permitted to view the run are taken to the run instead of the workspace homepage.
Expand Down
15 changes: 15 additions & 0 deletions acceptance/cmd/workspace/export-dir-illegal-filename/_script
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Shared by the posix/ and windows/ leaf tests, which differ only in their GOOS
# gate and golden output. The notebook name contains ':', which is a legal local
# filename character on Linux/macOS but illegal on Windows, so export-dir renames
# it to a legal name with a warning on Windows and exports it normally elsewhere.
# The real #5171 trigger is the auto-generated "New Notebook <timestamp>" name.
# We use two colons ("a:b:c") rather than one: a single ':' is the Windows
# alternate-data-stream separator, so os.Create("New Notebook a:b.py") silently
# succeeds (writing stream "b.py") instead of failing with ERROR_INVALID_NAME.
# Two colons make the stream type invalid, which is what real timestamp names
# ("13:54:24") hit. A plain name keeps the timestamp redaction out of the golden.
$CLI workspace import "/test-dir/New Notebook a:b:c.py" --file "$TESTDIR/../notebook.py" --format AUTO --language PYTHON

mkdir -p "$TEST_TMP_DIR/export"

trace $CLI workspace export-dir /test-dir "$TEST_TMP_DIR/export"
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Databricks notebook source
print("hello")

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@

>>> [CLI] workspace export-dir /test-dir [TEST_TMP_DIR]/export
Exporting files from /test-dir
/test-dir/New Notebook a:b:c -> [TEST_TMP_DIR]/export/New Notebook a:b:c.py
Export complete
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
source "$TESTDIR/../_script"
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# The ':' in the notebook name is a legal local filename on Linux/macOS, so the
# notebook is exported. Windows diverges and is covered by ../windows.
GOOS.windows = false
14 changes: 14 additions & 0 deletions acceptance/cmd/workspace/export-dir-illegal-filename/test.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
Local = true
Cloud = false

# This reproduces databricks/cli#5171. The behaviour is OS-specific (see _script),
# so the actual test cases live in the posix/ and windows/ leaf directories, each
# GOOS-gated with its own golden. This directory only holds the shared _script,
# the notebook fixture, and the config they inherit.

# The exported notebook lands under $TEST_TMP_DIR/export and is not part of the
# comparison; only the captured command output is.
Ignore = ["export/"]

[Env]
MSYS_NO_PATHCONV = "1"

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@

>>> [CLI] workspace export-dir /test-dir [TEST_TMP_DIR]/export
Exporting files from /test-dir
Warn: /test-dir/New Notebook a:b:c: name is not valid for the local file system, exporting as "New Notebook a_b_c.py"
/test-dir/New Notebook a:b:c -> [TEST_TMP_DIR]\export\New Notebook a_b_c.py
Export complete
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
source "$TESTDIR/../_script"
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# The ':' in the notebook name is illegal as a local Windows filename, so
# export-dir renames it to a legal name with a warning. Linux/macOS are covered
# by ../posix.
GOOS.linux = false
GOOS.darwin = false
14 changes: 13 additions & 1 deletion cmd/workspace/workspace/export_dir.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/databricks/cli/libs/cmdctx"
"github.com/databricks/cli/libs/cmdio"
"github.com/databricks/cli/libs/filer"
"github.com/databricks/cli/libs/log"
"github.com/databricks/cli/libs/notebook"
"github.com/databricks/databricks-sdk-go/apierr"
"github.com/databricks/databricks-sdk-go/service/workspace"
Expand Down Expand Up @@ -125,7 +126,18 @@ func (opts *exportDirOptions) callback(ctx context.Context, workspaceFiler filer
// create the file
f, err := os.Create(targetPath)
if err != nil {
return err
// A workspace name can be illegal as a local filename (e.g. a ':'
// in "New Notebook 2026-05-04 13:54:24" on Windows). Rename it to a
// legal name with a warning rather than aborting the export (#5171).
if !isInvalidLocalNameError(err) {
return err
}
targetPath = filepath.Join(filepath.Dir(targetPath), sanitizeLocalName(filepath.Base(targetPath)))
log.Warnf(ctx, "%s: name is not valid for the local file system, exporting as %q", sourcePath, filepath.Base(targetPath))
f, err = os.Create(targetPath)
if err != nil {
return err
}
}
defer f.Close()

Expand Down
18 changes: 18 additions & 0 deletions cmd/workspace/workspace/export_dir_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//go:build !windows

package workspace

// isInvalidLocalNameError reports whether err means the workspace object could
// not be written because its name is not a legal filename on the local OS. On
// non-Windows platforms the only bytes illegal in a filename are '/' and NUL,
// neither of which can appear in a workspace object name, so this never fires.
func isInvalidLocalNameError(err error) bool {
return false
}

// sanitizeLocalName is a no-op on non-Windows platforms. It exists to satisfy
// the call in export_dir.go, which is only reached when isInvalidLocalNameError
// returns true, so this is never invoked here.
func sanitizeLocalName(name string) string {
return name
}
38 changes: 38 additions & 0 deletions cmd/workspace/workspace/export_dir_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//go:build windows

package workspace

import (
"errors"
"strings"
"syscall"
)

// errorInvalidName is the Windows ERROR_INVALID_NAME code. The file APIs return
// it when a path contains characters that are illegal in a local filename, such
// as the ':' in a notebook named "New Notebook 2026-05-04 13:54:24". It is not
// declared in the standard syscall package, so we use the well-known code.
// https://learn.microsoft.com/en-us/windows/win32/debug/system-error-codes--0-499-
const errorInvalidName = syscall.Errno(0x7b)

// isInvalidLocalNameError reports whether err means the workspace object could
// not be written because its name is not a legal filename on the local OS.
func isInvalidLocalNameError(err error) bool {
return errors.Is(err, errorInvalidName)
}

// reservedNameChars are the characters that are illegal in a Windows filename.
// https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file
const reservedNameChars = `<>:"/\|?*`

// sanitizeLocalName replaces characters that are illegal in a Windows filename
// with '_' so an object whose name is invalid locally can still be written under
// a legal name.
func sanitizeLocalName(name string) string {
return strings.Map(func(r rune) rune {
if strings.ContainsRune(reservedNameChars, r) {
return '_'
}
return r
}, name)
}
86 changes: 86 additions & 0 deletions cmd/workspace/workspace/export_dir_windows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
//go:build windows

package workspace

import (
"errors"
"io/fs"
"os"
"syscall"
"testing"

"github.com/stretchr/testify/assert"
)

// The narrow contract: only the Windows "invalid file name" error is treated as
// skippable. Genuine failures (permission, missing path, anything else) must not
// be swallowed, otherwise export-dir would silently drop files on real errors.
func TestIsInvalidLocalNameError(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{
name: "invalid name wrapped in PathError",
err: &os.PathError{Op: "open", Path: `C:\tmp\New Notebook 13:54:24.py`, Err: syscall.Errno(0x7b)},
want: true,
},
{
name: "permission denied is not skipped",
err: fs.ErrPermission,
want: false,
},
{
name: "not exist is not skipped",
err: fs.ErrNotExist,
want: false,
},
{
name: "generic error is not skipped",
err: errors.New("boom"),
want: false,
},
{
name: "nil is not skipped",
err: nil,
want: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, isInvalidLocalNameError(tt.err))
})
}
}

func TestSanitizeLocalName(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{
name: "colons replaced",
in: "New Notebook 2026-05-04 13:54:24.py",
want: "New Notebook 2026-05-04 13_54_24.py",
},
{
name: "all reserved characters replaced",
in: `a<b>c:d"e|f?g*h`,
want: "a_b_c_d_e_f_g_h",
},
{
name: "legal name unchanged",
in: "hello world.py",
want: "hello world.py",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, sanitizeLocalName(tt.in))
})
}
}
26 changes: 26 additions & 0 deletions libs/testserver/fake_workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os"
"path"
"path/filepath"
"slices"
"strings"
"sync"
"time"
Expand Down Expand Up @@ -370,6 +371,31 @@ func (s *FakeWorkspace) WorkspaceGetStatus(path string) Response {
}
}

func (s *FakeWorkspace) WorkspaceList(listPath string) Response {
defer s.LockUnlock()()

var objects []workspace.ObjectInfo

for filePath, entry := range s.files {
if path.Dir(filePath) == listPath {
objects = append(objects, entry.Info)
}
}
for dirPath, dirInfo := range s.directories {
if dirPath != listPath && path.Dir(dirPath) == listPath {
objects = append(objects, dirInfo)
}
}

slices.SortFunc(objects, func(a, b workspace.ObjectInfo) int {
return strings.Compare(a.Path, b.Path)
})

return Response{
Body: workspace.ListResponse{Objects: objects},
}
}

func (s *FakeWorkspace) WorkspaceMkdirs(request workspace.Mkdirs) {
defer s.LockUnlock()()
s.directories[request.Path] = workspace.ObjectInfo{
Expand Down
5 changes: 5 additions & 0 deletions libs/testserver/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ func AddDefaultHandlers(server *Server) {
return req.Workspace.WorkspaceGetStatus(path)
})

server.Handle("GET", "/api/2.0/workspace/list", func(req Request) any {
path := req.URL.Query().Get("path")
return req.Workspace.WorkspaceList(path)
})

server.Handle("POST", "/api/2.0/workspace/mkdirs", func(req Request) any {
var request workspace.Mkdirs
if err := json.Unmarshal(req.Body, &request); err != nil {
Expand Down
Loading