From e4e2d6eba30bb743efe1665eefea46b152b94790 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Thu, 18 Jun 2026 17:30:50 +0000 Subject: [PATCH 1/9] acc: add Windows reproduction for export-dir on illegal notebook name Reproduces databricks/cli#5171: `workspace export-dir` aborts on Windows when a notebook name contains a ':' (e.g. an untitled "New Notebook "), because the colon is illegal in a local Windows filename and os.Create fails. The name is legal on Linux/macOS, so the test is gated to Windows via GOOS. output.txt is intentionally omitted: the golden cannot be generated on a non-Windows machine, so the Windows CI leg will surface the real output to commit verbatim. --- .../export-dir-illegal-filename/notebook.py | 2 ++ .../export-dir-illegal-filename/out.test.toml | 6 ++++ .../export-dir-illegal-filename/script | 7 +++++ .../export-dir-illegal-filename/test.toml | 28 +++++++++++++++++++ 4 files changed, 43 insertions(+) create mode 100644 acceptance/cmd/workspace/export-dir-illegal-filename/notebook.py create mode 100644 acceptance/cmd/workspace/export-dir-illegal-filename/out.test.toml create mode 100644 acceptance/cmd/workspace/export-dir-illegal-filename/script create mode 100644 acceptance/cmd/workspace/export-dir-illegal-filename/test.toml diff --git a/acceptance/cmd/workspace/export-dir-illegal-filename/notebook.py b/acceptance/cmd/workspace/export-dir-illegal-filename/notebook.py new file mode 100644 index 00000000000..4914a7436d9 --- /dev/null +++ b/acceptance/cmd/workspace/export-dir-illegal-filename/notebook.py @@ -0,0 +1,2 @@ +# Databricks notebook source +print("hello") diff --git a/acceptance/cmd/workspace/export-dir-illegal-filename/out.test.toml b/acceptance/cmd/workspace/export-dir-illegal-filename/out.test.toml new file mode 100644 index 00000000000..fee96cc336e --- /dev/null +++ b/acceptance/cmd/workspace/export-dir-illegal-filename/out.test.toml @@ -0,0 +1,6 @@ +Local = true +Cloud = false +GOOS.darwin = false +GOOS.linux = false +GOOS.windows = true +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/cmd/workspace/export-dir-illegal-filename/script b/acceptance/cmd/workspace/export-dir-illegal-filename/script new file mode 100644 index 00000000000..015d2e245c9 --- /dev/null +++ b/acceptance/cmd/workspace/export-dir-illegal-filename/script @@ -0,0 +1,7 @@ +$CLI workspace import "/test-dir/New Notebook 2026-05-04 13:54:24.py" --file notebook.py --format AUTO --language PYTHON + +mkdir -p "$TEST_TMP_DIR/export" + +# On Windows the notebook name contains ':' which is illegal in a local filename, +# so os.Create fails and the whole export aborts (databricks/cli#5171). +musterr trace $CLI workspace export-dir /test-dir "$TEST_TMP_DIR/export" diff --git a/acceptance/cmd/workspace/export-dir-illegal-filename/test.toml b/acceptance/cmd/workspace/export-dir-illegal-filename/test.toml new file mode 100644 index 00000000000..c8c749d4f76 --- /dev/null +++ b/acceptance/cmd/workspace/export-dir-illegal-filename/test.toml @@ -0,0 +1,28 @@ +Local = true +Cloud = false + +# This reproduces databricks/cli#5171, which only manifests on Windows: a ':' in +# the notebook name is a legal filename character on Linux/macOS but illegal on +# Windows, so os.Create rejects the local target path there. +[GOOS] +darwin = false +linux = false +windows = true + +[Env] +MSYS_NO_PATHCONV = "1" + +[[Server]] +Pattern = "GET /api/2.0/workspace/list" +Response.Body = ''' +{ + "objects": [ + { + "path": "/test-dir/New Notebook 2026-05-04 13:54:24", + "object_type": "NOTEBOOK", + "language": "PYTHON", + "object_id": 123 + } + ] +} +''' From 193a0e752d42b0a7cb8001d4596035663ce88217 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Thu, 18 Jun 2026 19:16:24 +0000 Subject: [PATCH 2/9] acc: add captured Windows golden for export-dir illegal filename Captured verbatim from the Windows CI run of the test added in the previous commit. Documents the current (buggy) behavior of #5171: export-dir aborts on Windows when a notebook name contains ':'. --- .../cmd/workspace/export-dir-illegal-filename/output.txt | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 acceptance/cmd/workspace/export-dir-illegal-filename/output.txt diff --git a/acceptance/cmd/workspace/export-dir-illegal-filename/output.txt b/acceptance/cmd/workspace/export-dir-illegal-filename/output.txt new file mode 100644 index 00000000000..c4ef07323d3 --- /dev/null +++ b/acceptance/cmd/workspace/export-dir-illegal-filename/output.txt @@ -0,0 +1,4 @@ + +>>> [CLI] workspace export-dir /test-dir [TEST_TMP_DIR]/export +Exporting files from /test-dir +Error: open [TEST_TMP_DIR]\export\New Notebook [TIMESTAMP].py: The filename, directory name, or volume label syntax is incorrect. From 3a7aca246e387cedd5b14560f98b01bb94e9e3a5 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Thu, 18 Jun 2026 19:30:56 +0000 Subject: [PATCH 3/9] Skip workspace objects with names illegal as local files in export-dir A workspace object name can be illegal as a local filename (e.g. the ':' in an untitled "New Notebook 2026-05-04 13:54:24" on Windows), which made os.Create fail and abort the entire export. Skip such files with a warning and continue, matching the existing skip paths for non-exportable and oversized objects. Detection uses a build-tagged helper checking the Windows ERROR_INVALID_NAME code; on other platforms no filename character (other than '/' and NUL, which cannot appear in a workspace object name) is illegal, so it is a no-op. Flips the Windows-gated acceptance test from asserting the abort to expecting skip-with-warning. Fixes #5171. --- NEXT_CHANGELOG.md | 1 + .../export-dir-illegal-filename/output.txt | 3 ++- .../export-dir-illegal-filename/script | 6 +++--- cmd/workspace/workspace/export_dir.go | 7 +++++++ cmd/workspace/workspace/export_dir_other.go | 11 ++++++++++ cmd/workspace/workspace/export_dir_windows.go | 21 +++++++++++++++++++ 6 files changed, 45 insertions(+), 4 deletions(-) create mode 100644 cmd/workspace/workspace/export_dir_other.go create mode 100644 cmd/workspace/workspace/export_dir_windows.go diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 1f46a0a57c6..6343eb4a1f4 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -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 skipped 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//runs/`) so that non-admin users permitted to view the run are taken to the run instead of the workspace homepage. diff --git a/acceptance/cmd/workspace/export-dir-illegal-filename/output.txt b/acceptance/cmd/workspace/export-dir-illegal-filename/output.txt index c4ef07323d3..c234e37825d 100644 --- a/acceptance/cmd/workspace/export-dir-illegal-filename/output.txt +++ b/acceptance/cmd/workspace/export-dir-illegal-filename/output.txt @@ -1,4 +1,5 @@ >>> [CLI] workspace export-dir /test-dir [TEST_TMP_DIR]/export Exporting files from /test-dir -Error: open [TEST_TMP_DIR]\export\New Notebook [TIMESTAMP].py: The filename, directory name, or volume label syntax is incorrect. +/test-dir/New Notebook [TIMESTAMP] (skipped; invalid name for local file system) +Export complete diff --git a/acceptance/cmd/workspace/export-dir-illegal-filename/script b/acceptance/cmd/workspace/export-dir-illegal-filename/script index 015d2e245c9..37b90688fd1 100644 --- a/acceptance/cmd/workspace/export-dir-illegal-filename/script +++ b/acceptance/cmd/workspace/export-dir-illegal-filename/script @@ -2,6 +2,6 @@ $CLI workspace import "/test-dir/New Notebook 2026-05-04 13:54:24.py" --file not mkdir -p "$TEST_TMP_DIR/export" -# On Windows the notebook name contains ':' which is illegal in a local filename, -# so os.Create fails and the whole export aborts (databricks/cli#5171). -musterr trace $CLI workspace export-dir /test-dir "$TEST_TMP_DIR/export" +# On Windows the notebook name contains ':' which is illegal in a local filename. +# export-dir should skip it with a warning and still complete (databricks/cli#5171). +trace $CLI workspace export-dir /test-dir "$TEST_TMP_DIR/export" diff --git a/cmd/workspace/workspace/export_dir.go b/cmd/workspace/workspace/export_dir.go index e7965bcda91..f06d7d16b2f 100644 --- a/cmd/workspace/workspace/export_dir.go +++ b/cmd/workspace/workspace/export_dir.go @@ -125,6 +125,13 @@ func (opts *exportDirOptions) callback(ctx context.Context, workspaceFiler filer // create the file f, err := os.Create(targetPath) if err != nil { + // A workspace name can be illegal as a local filename (e.g. a ':' + // in "New Notebook 2026-05-04 13:54:24" on Windows). Skip those with + // a warning rather than aborting the whole export (#5171). + if isInvalidLocalNameError(err) { + cmdio.LogString(ctx, sourcePath+" (skipped; invalid name for local file system)") + return nil + } return err } defer f.Close() diff --git a/cmd/workspace/workspace/export_dir_other.go b/cmd/workspace/workspace/export_dir_other.go new file mode 100644 index 00000000000..e2cd1741d17 --- /dev/null +++ b/cmd/workspace/workspace/export_dir_other.go @@ -0,0 +1,11 @@ +//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 +} diff --git a/cmd/workspace/workspace/export_dir_windows.go b/cmd/workspace/workspace/export_dir_windows.go new file mode 100644 index 00000000000..d1276ce2b3f --- /dev/null +++ b/cmd/workspace/workspace/export_dir_windows.go @@ -0,0 +1,21 @@ +//go:build windows + +package workspace + +import ( + "errors" + "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) +} From bba3ce77c0e3751a99f93ee0ed6f6564c8fe9096 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Thu, 18 Jun 2026 19:37:26 +0000 Subject: [PATCH 4/9] Add boundary unit test for isInvalidLocalNameError Locks in the narrow contract that only the Windows ERROR_INVALID_NAME error is treated as skippable, while permission, not-exist, generic, and nil errors are not. The acceptance test only exercises the positive (invalid-name) path, so this guards against accidentally broadening the check and silently dropping files on real I/O failures. --- .../workspace/export_dir_windows_test.go | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 cmd/workspace/workspace/export_dir_windows_test.go diff --git a/cmd/workspace/workspace/export_dir_windows_test.go b/cmd/workspace/workspace/export_dir_windows_test.go new file mode 100644 index 00000000000..aba1e735aa4 --- /dev/null +++ b/cmd/workspace/workspace/export_dir_windows_test.go @@ -0,0 +1,56 @@ +//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)) + }) + } +} From 50a000c6831f566833c7812c3e0cf698f1be29f1 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Fri, 19 Jun 2026 12:55:31 +0000 Subject: [PATCH 5/9] acc: run export-dir illegal-filename test on all OSes The test reproducing databricks/cli#5171 was gated to Windows only because a ':' in the notebook name is illegal as a local filename on Windows but legal on Linux/macOS, so export-dir skips it there and exports it everywhere else. Run it on every OS and capture the divergent output in out..txt. Teach the acceptance runner to compare only the current OS's out..txt and skip the others (extending the existing variant-skip machinery), since otherwise the non-current goldens would fail as "Missing output file". Add a fake WorkspaceList handler so the imported notebook is listed and actually exported, replacing the hardcoded [[Server]] list stub. --- acceptance/acceptance_test.go | 32 +++++++++++++++---- .../out.darwin.txt | 5 +++ .../export-dir-illegal-filename/out.linux.txt | 5 +++ .../export-dir-illegal-filename/out.test.toml | 3 -- .../out.windows.txt | 5 +++ .../export-dir-illegal-filename/output.txt | 5 +-- .../export-dir-illegal-filename/script | 15 +++++++-- .../export-dir-illegal-filename/test.toml | 31 ++++++------------ libs/testserver/fake_workspace.go | 26 +++++++++++++++ libs/testserver/handlers.go | 5 +++ 10 files changed, 93 insertions(+), 39 deletions(-) create mode 100644 acceptance/cmd/workspace/export-dir-illegal-filename/out.darwin.txt create mode 100644 acceptance/cmd/workspace/export-dir-illegal-filename/out.linux.txt create mode 100644 acceptance/cmd/workspace/export-dir-illegal-filename/out.windows.txt diff --git a/acceptance/acceptance_test.go b/acceptance/acceptance_test.go index 2d3326b23d6..09bd0fd973a 100644 --- a/acceptance/acceptance_test.go +++ b/acceptance/acceptance_test.go @@ -1633,18 +1633,38 @@ type pathFilter struct { // contains substrings from the variants other than current. // E.g. if EnvVaryOutput is DATABRICKS_BUNDLE_ENGINE and current test running DATABRICKS_BUNDLE_ENGINE="terraform" then // notSelected contains ".direct." meaning if filename contains that (e.g. out.deploy.direct.txt) then we ignore it here. + // It also contains the infixes of every GOOS other than runtime.GOOS, so out..txt files for other OSes are ignored. notSelected []string } -// preparePathFilter builds filter based on EnvVaryOutput and current variant env. +// osVariants are the GOOS values that can tag an output file (e.g. +// out.windows.txt). Files tagged with an OS other than runtime.GOOS are skipped +// during comparison, so a single test can capture per-OS output differences +// (see cmd/workspace/export-dir-illegal-filename, databricks/cli#5171). +var osVariants = []string{"darwin", "linux", "windows"} + +// preparePathFilter builds filter based on the current GOOS and EnvVaryOutput. func preparePathFilter(config internal.TestConfig, customEnv []string) pathFilter { + var notSelected []string + for _, goos := range osVariants { + if goos != runtime.GOOS { + notSelected = append(notSelected, "."+goos+".") + } + } + notSelected = append(notSelected, envVaryNotSelected(config, customEnv)...) + return pathFilter{notSelected: notSelected} +} + +// envVaryNotSelected returns the infixes of EnvVaryOutput variants other than +// the one selected for the current test run. +func envVaryNotSelected(config internal.TestConfig, customEnv []string) []string { if config.EnvVaryOutput == nil { - return pathFilter{} + return nil } key := *config.EnvVaryOutput vals := config.EnvMatrix[key] if len(vals) <= 1 { - return pathFilter{} + return nil } selected := "" for _, kv := range customEnv { @@ -1655,7 +1675,7 @@ func preparePathFilter(config internal.TestConfig, customEnv []string) pathFilte } } if selected == "" { - return pathFilter{} + return nil } var others []string for _, v := range vals { @@ -1664,9 +1684,7 @@ func preparePathFilter(config internal.TestConfig, customEnv []string) pathFilte } others = append(others, "."+v+".") } - return pathFilter{ - notSelected: others, - } + return others } // shouldSkip returns true if the given file belongs to a non-selected variant. diff --git a/acceptance/cmd/workspace/export-dir-illegal-filename/out.darwin.txt b/acceptance/cmd/workspace/export-dir-illegal-filename/out.darwin.txt new file mode 100644 index 00000000000..846fd8cab36 --- /dev/null +++ b/acceptance/cmd/workspace/export-dir-illegal-filename/out.darwin.txt @@ -0,0 +1,5 @@ + +>>> [CLI] workspace export-dir /test-dir [TEST_TMP_DIR]/export +Exporting files from /test-dir +/test-dir/New Notebook [TIMESTAMP] -> [TEST_TMP_DIR]/export/New Notebook [TIMESTAMP].py +Export complete diff --git a/acceptance/cmd/workspace/export-dir-illegal-filename/out.linux.txt b/acceptance/cmd/workspace/export-dir-illegal-filename/out.linux.txt new file mode 100644 index 00000000000..846fd8cab36 --- /dev/null +++ b/acceptance/cmd/workspace/export-dir-illegal-filename/out.linux.txt @@ -0,0 +1,5 @@ + +>>> [CLI] workspace export-dir /test-dir [TEST_TMP_DIR]/export +Exporting files from /test-dir +/test-dir/New Notebook [TIMESTAMP] -> [TEST_TMP_DIR]/export/New Notebook [TIMESTAMP].py +Export complete diff --git a/acceptance/cmd/workspace/export-dir-illegal-filename/out.test.toml b/acceptance/cmd/workspace/export-dir-illegal-filename/out.test.toml index fee96cc336e..f784a183258 100644 --- a/acceptance/cmd/workspace/export-dir-illegal-filename/out.test.toml +++ b/acceptance/cmd/workspace/export-dir-illegal-filename/out.test.toml @@ -1,6 +1,3 @@ Local = true Cloud = false -GOOS.darwin = false -GOOS.linux = false -GOOS.windows = true EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/cmd/workspace/export-dir-illegal-filename/out.windows.txt b/acceptance/cmd/workspace/export-dir-illegal-filename/out.windows.txt new file mode 100644 index 00000000000..c234e37825d --- /dev/null +++ b/acceptance/cmd/workspace/export-dir-illegal-filename/out.windows.txt @@ -0,0 +1,5 @@ + +>>> [CLI] workspace export-dir /test-dir [TEST_TMP_DIR]/export +Exporting files from /test-dir +/test-dir/New Notebook [TIMESTAMP] (skipped; invalid name for local file system) +Export complete diff --git a/acceptance/cmd/workspace/export-dir-illegal-filename/output.txt b/acceptance/cmd/workspace/export-dir-illegal-filename/output.txt index c234e37825d..3ba158aa4b1 100644 --- a/acceptance/cmd/workspace/export-dir-illegal-filename/output.txt +++ b/acceptance/cmd/workspace/export-dir-illegal-filename/output.txt @@ -1,5 +1,2 @@ ->>> [CLI] workspace export-dir /test-dir [TEST_TMP_DIR]/export -Exporting files from /test-dir -/test-dir/New Notebook [TIMESTAMP] (skipped; invalid name for local file system) -Export complete +=== export-dir output is OS-specific; see out..txt diff --git a/acceptance/cmd/workspace/export-dir-illegal-filename/script b/acceptance/cmd/workspace/export-dir-illegal-filename/script index 37b90688fd1..9b5070885a2 100644 --- a/acceptance/cmd/workspace/export-dir-illegal-filename/script +++ b/acceptance/cmd/workspace/export-dir-illegal-filename/script @@ -2,6 +2,15 @@ $CLI workspace import "/test-dir/New Notebook 2026-05-04 13:54:24.py" --file not mkdir -p "$TEST_TMP_DIR/export" -# On Windows the notebook name contains ':' which is illegal in a local filename. -# export-dir should skip it with a warning and still complete (databricks/cli#5171). -trace $CLI workspace export-dir /test-dir "$TEST_TMP_DIR/export" +# The notebook name contains ':', which is illegal in a local filename on Windows +# but legal on Linux/macOS. export-dir skips it with a warning on Windows and +# exports it normally elsewhere (databricks/cli#5171). The output therefore +# differs per OS, so capture it in out..txt. +case "$OSTYPE" in + msys | cygwin | win32) goos=windows ;; + darwin*) goos=darwin ;; + *) goos=linux ;; +esac + +title "export-dir output is OS-specific; see out..txt\n" +trace $CLI workspace export-dir /test-dir "$TEST_TMP_DIR/export" >"out.$goos.txt" 2>&1 diff --git a/acceptance/cmd/workspace/export-dir-illegal-filename/test.toml b/acceptance/cmd/workspace/export-dir-illegal-filename/test.toml index c8c749d4f76..3dfbea904a5 100644 --- a/acceptance/cmd/workspace/export-dir-illegal-filename/test.toml +++ b/acceptance/cmd/workspace/export-dir-illegal-filename/test.toml @@ -1,28 +1,15 @@ Local = true Cloud = false -# This reproduces databricks/cli#5171, which only manifests on Windows: a ':' in -# the notebook name is a legal filename character on Linux/macOS but illegal on -# Windows, so os.Create rejects the local target path there. -[GOOS] -darwin = false -linux = false -windows = true +# This reproduces databricks/cli#5171. The notebook name contains a ':', which is +# a legal filename character on Linux/macOS but illegal on Windows. The export is +# therefore skipped with a warning on Windows and succeeds elsewhere, so the test +# runs on every OS and captures the divergent output in out..txt. + +# output.txt only holds an OS-independent header; the captured output lives in +# out..txt. The exported notebook lands under the temp dir on Linux/macOS; +# it is not part of the comparison, only the captured output is. +Ignore = ["export/"] [Env] MSYS_NO_PATHCONV = "1" - -[[Server]] -Pattern = "GET /api/2.0/workspace/list" -Response.Body = ''' -{ - "objects": [ - { - "path": "/test-dir/New Notebook 2026-05-04 13:54:24", - "object_type": "NOTEBOOK", - "language": "PYTHON", - "object_id": 123 - } - ] -} -''' diff --git a/libs/testserver/fake_workspace.go b/libs/testserver/fake_workspace.go index d2925fb1e38..9b7a6fcc91c 100644 --- a/libs/testserver/fake_workspace.go +++ b/libs/testserver/fake_workspace.go @@ -8,6 +8,7 @@ import ( "os" "path" "path/filepath" + "slices" "strings" "sync" "time" @@ -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{ diff --git a/libs/testserver/handlers.go b/libs/testserver/handlers.go index 8f611a7c7c9..0756af5d0ff 100644 --- a/libs/testserver/handlers.go +++ b/libs/testserver/handlers.go @@ -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 { From ffbf66e314ea695098677b3572185f76c21b294f Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Mon, 22 Jun 2026 06:14:22 +0000 Subject: [PATCH 6/9] acc: split export-dir illegal-filename test by OS instead of in-runner GOOS filtering Revert the per-GOOS output filtering added to the acceptance runner (osVariants / preparePathFilter) and instead express the OS-specific behaviour with two GOOS-gated leaf tests that share a single _script and notebook fixture: export-dir-illegal-filename/ _script # import + export-dir, shared by both leaves notebook.py # shared fixture, referenced via $TESTDIR/../notebook.py posix/ # GOOS.windows = false -> notebook exported windows/ # GOOS.{linux,darwin} = false -> skipped with a warning The ':' in the notebook name is legal on Linux/macOS but illegal as a local Windows filename, so export-dir skips it on Windows (databricks/cli#5171). The two captured outputs differ, but everything else is shared, so the leaves only carry their GOOS gate and golden. This keeps OS knowledge out of the shared acceptance runner. --- acceptance/acceptance_test.go | 32 ++++--------------- .../export-dir-illegal-filename/_script | 9 ++++++ .../export-dir-illegal-filename/out.linux.txt | 5 --- .../export-dir-illegal-filename/output.txt | 2 -- .../{ => posix}/out.test.toml | 1 + .../{out.darwin.txt => posix/output.txt} | 0 .../export-dir-illegal-filename/posix/script | 1 + .../posix/test.toml | 3 ++ .../export-dir-illegal-filename/script | 16 ---------- .../export-dir-illegal-filename/test.toml | 13 ++++---- .../windows/out.test.toml | 5 +++ .../{out.windows.txt => windows/output.txt} | 0 .../windows/script | 1 + .../windows/test.toml | 4 +++ 14 files changed, 37 insertions(+), 55 deletions(-) create mode 100644 acceptance/cmd/workspace/export-dir-illegal-filename/_script delete mode 100644 acceptance/cmd/workspace/export-dir-illegal-filename/out.linux.txt delete mode 100644 acceptance/cmd/workspace/export-dir-illegal-filename/output.txt rename acceptance/cmd/workspace/export-dir-illegal-filename/{ => posix}/out.test.toml (80%) rename acceptance/cmd/workspace/export-dir-illegal-filename/{out.darwin.txt => posix/output.txt} (100%) create mode 100644 acceptance/cmd/workspace/export-dir-illegal-filename/posix/script create mode 100644 acceptance/cmd/workspace/export-dir-illegal-filename/posix/test.toml delete mode 100644 acceptance/cmd/workspace/export-dir-illegal-filename/script create mode 100644 acceptance/cmd/workspace/export-dir-illegal-filename/windows/out.test.toml rename acceptance/cmd/workspace/export-dir-illegal-filename/{out.windows.txt => windows/output.txt} (100%) create mode 100644 acceptance/cmd/workspace/export-dir-illegal-filename/windows/script create mode 100644 acceptance/cmd/workspace/export-dir-illegal-filename/windows/test.toml diff --git a/acceptance/acceptance_test.go b/acceptance/acceptance_test.go index 09bd0fd973a..2d3326b23d6 100644 --- a/acceptance/acceptance_test.go +++ b/acceptance/acceptance_test.go @@ -1633,38 +1633,18 @@ type pathFilter struct { // contains substrings from the variants other than current. // E.g. if EnvVaryOutput is DATABRICKS_BUNDLE_ENGINE and current test running DATABRICKS_BUNDLE_ENGINE="terraform" then // notSelected contains ".direct." meaning if filename contains that (e.g. out.deploy.direct.txt) then we ignore it here. - // It also contains the infixes of every GOOS other than runtime.GOOS, so out..txt files for other OSes are ignored. notSelected []string } -// osVariants are the GOOS values that can tag an output file (e.g. -// out.windows.txt). Files tagged with an OS other than runtime.GOOS are skipped -// during comparison, so a single test can capture per-OS output differences -// (see cmd/workspace/export-dir-illegal-filename, databricks/cli#5171). -var osVariants = []string{"darwin", "linux", "windows"} - -// preparePathFilter builds filter based on the current GOOS and EnvVaryOutput. +// preparePathFilter builds filter based on EnvVaryOutput and current variant env. func preparePathFilter(config internal.TestConfig, customEnv []string) pathFilter { - var notSelected []string - for _, goos := range osVariants { - if goos != runtime.GOOS { - notSelected = append(notSelected, "."+goos+".") - } - } - notSelected = append(notSelected, envVaryNotSelected(config, customEnv)...) - return pathFilter{notSelected: notSelected} -} - -// envVaryNotSelected returns the infixes of EnvVaryOutput variants other than -// the one selected for the current test run. -func envVaryNotSelected(config internal.TestConfig, customEnv []string) []string { if config.EnvVaryOutput == nil { - return nil + return pathFilter{} } key := *config.EnvVaryOutput vals := config.EnvMatrix[key] if len(vals) <= 1 { - return nil + return pathFilter{} } selected := "" for _, kv := range customEnv { @@ -1675,7 +1655,7 @@ func envVaryNotSelected(config internal.TestConfig, customEnv []string) []string } } if selected == "" { - return nil + return pathFilter{} } var others []string for _, v := range vals { @@ -1684,7 +1664,9 @@ func envVaryNotSelected(config internal.TestConfig, customEnv []string) []string } others = append(others, "."+v+".") } - return others + return pathFilter{ + notSelected: others, + } } // shouldSkip returns true if the given file belongs to a non-selected variant. diff --git a/acceptance/cmd/workspace/export-dir-illegal-filename/_script b/acceptance/cmd/workspace/export-dir-illegal-filename/_script new file mode 100644 index 00000000000..02aa74de7e0 --- /dev/null +++ b/acceptance/cmd/workspace/export-dir-illegal-filename/_script @@ -0,0 +1,9 @@ +# 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 skips it +# with a warning on Windows and exports it normally elsewhere (databricks/cli#5171). +$CLI workspace import "/test-dir/New Notebook 2026-05-04 13:54:24.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" diff --git a/acceptance/cmd/workspace/export-dir-illegal-filename/out.linux.txt b/acceptance/cmd/workspace/export-dir-illegal-filename/out.linux.txt deleted file mode 100644 index 846fd8cab36..00000000000 --- a/acceptance/cmd/workspace/export-dir-illegal-filename/out.linux.txt +++ /dev/null @@ -1,5 +0,0 @@ - ->>> [CLI] workspace export-dir /test-dir [TEST_TMP_DIR]/export -Exporting files from /test-dir -/test-dir/New Notebook [TIMESTAMP] -> [TEST_TMP_DIR]/export/New Notebook [TIMESTAMP].py -Export complete diff --git a/acceptance/cmd/workspace/export-dir-illegal-filename/output.txt b/acceptance/cmd/workspace/export-dir-illegal-filename/output.txt deleted file mode 100644 index 3ba158aa4b1..00000000000 --- a/acceptance/cmd/workspace/export-dir-illegal-filename/output.txt +++ /dev/null @@ -1,2 +0,0 @@ - -=== export-dir output is OS-specific; see out..txt diff --git a/acceptance/cmd/workspace/export-dir-illegal-filename/out.test.toml b/acceptance/cmd/workspace/export-dir-illegal-filename/posix/out.test.toml similarity index 80% rename from acceptance/cmd/workspace/export-dir-illegal-filename/out.test.toml rename to acceptance/cmd/workspace/export-dir-illegal-filename/posix/out.test.toml index f784a183258..1baaa898c5b 100644 --- a/acceptance/cmd/workspace/export-dir-illegal-filename/out.test.toml +++ b/acceptance/cmd/workspace/export-dir-illegal-filename/posix/out.test.toml @@ -1,3 +1,4 @@ Local = true Cloud = false +GOOS.windows = false EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/cmd/workspace/export-dir-illegal-filename/out.darwin.txt b/acceptance/cmd/workspace/export-dir-illegal-filename/posix/output.txt similarity index 100% rename from acceptance/cmd/workspace/export-dir-illegal-filename/out.darwin.txt rename to acceptance/cmd/workspace/export-dir-illegal-filename/posix/output.txt diff --git a/acceptance/cmd/workspace/export-dir-illegal-filename/posix/script b/acceptance/cmd/workspace/export-dir-illegal-filename/posix/script new file mode 100644 index 00000000000..6125f11b143 --- /dev/null +++ b/acceptance/cmd/workspace/export-dir-illegal-filename/posix/script @@ -0,0 +1 @@ +source "$TESTDIR/../_script" diff --git a/acceptance/cmd/workspace/export-dir-illegal-filename/posix/test.toml b/acceptance/cmd/workspace/export-dir-illegal-filename/posix/test.toml new file mode 100644 index 00000000000..96be3f28dce --- /dev/null +++ b/acceptance/cmd/workspace/export-dir-illegal-filename/posix/test.toml @@ -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 diff --git a/acceptance/cmd/workspace/export-dir-illegal-filename/script b/acceptance/cmd/workspace/export-dir-illegal-filename/script deleted file mode 100644 index 9b5070885a2..00000000000 --- a/acceptance/cmd/workspace/export-dir-illegal-filename/script +++ /dev/null @@ -1,16 +0,0 @@ -$CLI workspace import "/test-dir/New Notebook 2026-05-04 13:54:24.py" --file notebook.py --format AUTO --language PYTHON - -mkdir -p "$TEST_TMP_DIR/export" - -# The notebook name contains ':', which is illegal in a local filename on Windows -# but legal on Linux/macOS. export-dir skips it with a warning on Windows and -# exports it normally elsewhere (databricks/cli#5171). The output therefore -# differs per OS, so capture it in out..txt. -case "$OSTYPE" in - msys | cygwin | win32) goos=windows ;; - darwin*) goos=darwin ;; - *) goos=linux ;; -esac - -title "export-dir output is OS-specific; see out..txt\n" -trace $CLI workspace export-dir /test-dir "$TEST_TMP_DIR/export" >"out.$goos.txt" 2>&1 diff --git a/acceptance/cmd/workspace/export-dir-illegal-filename/test.toml b/acceptance/cmd/workspace/export-dir-illegal-filename/test.toml index 3dfbea904a5..20b44bad782 100644 --- a/acceptance/cmd/workspace/export-dir-illegal-filename/test.toml +++ b/acceptance/cmd/workspace/export-dir-illegal-filename/test.toml @@ -1,14 +1,13 @@ Local = true Cloud = false -# This reproduces databricks/cli#5171. The notebook name contains a ':', which is -# a legal filename character on Linux/macOS but illegal on Windows. The export is -# therefore skipped with a warning on Windows and succeeds elsewhere, so the test -# runs on every OS and captures the divergent output in out..txt. +# 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. -# output.txt only holds an OS-independent header; the captured output lives in -# out..txt. The exported notebook lands under the temp dir on Linux/macOS; -# it is not part of the comparison, only the captured output is. +# 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] diff --git a/acceptance/cmd/workspace/export-dir-illegal-filename/windows/out.test.toml b/acceptance/cmd/workspace/export-dir-illegal-filename/windows/out.test.toml new file mode 100644 index 00000000000..8471d88c7f3 --- /dev/null +++ b/acceptance/cmd/workspace/export-dir-illegal-filename/windows/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false +GOOS.darwin = false +GOOS.linux = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/cmd/workspace/export-dir-illegal-filename/out.windows.txt b/acceptance/cmd/workspace/export-dir-illegal-filename/windows/output.txt similarity index 100% rename from acceptance/cmd/workspace/export-dir-illegal-filename/out.windows.txt rename to acceptance/cmd/workspace/export-dir-illegal-filename/windows/output.txt diff --git a/acceptance/cmd/workspace/export-dir-illegal-filename/windows/script b/acceptance/cmd/workspace/export-dir-illegal-filename/windows/script new file mode 100644 index 00000000000..6125f11b143 --- /dev/null +++ b/acceptance/cmd/workspace/export-dir-illegal-filename/windows/script @@ -0,0 +1 @@ +source "$TESTDIR/../_script" diff --git a/acceptance/cmd/workspace/export-dir-illegal-filename/windows/test.toml b/acceptance/cmd/workspace/export-dir-illegal-filename/windows/test.toml new file mode 100644 index 00000000000..ba1520ad133 --- /dev/null +++ b/acceptance/cmd/workspace/export-dir-illegal-filename/windows/test.toml @@ -0,0 +1,4 @@ +# The ':' in the notebook name is illegal as a local Windows filename, so +# export-dir skips it with a warning. Linux/macOS are covered by ../posix. +GOOS.linux = false +GOOS.darwin = false From 47a2aa8a5681cfc9ffcefad106b45735f6388166 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Mon, 22 Jun 2026 13:39:44 +0000 Subject: [PATCH 7/9] Rename workspace objects with locally-illegal names in export-dir instead of skipping Previously export-dir skipped workspace objects whose names are not legal local filenames (e.g. the ':' in "New Notebook 2026-05-04 13:54:24" on Windows). Skipping silently dropped the file. Now the object is exported under a sanitized name (illegal characters replaced with '_') and a warning is emitted, so the content is preserved. Adds a build-tagged sanitizeLocalName helper (Windows replaces the reserved set plus control characters; no-op elsewhere) and a unit test covering it. Flips the Windows-gated acceptance golden from skip to rename-with-warning. Relates to #5171. --- NEXT_CHANGELOG.md | 2 +- .../export-dir-illegal-filename/_script | 5 +-- .../windows/output.txt | 3 +- .../windows/test.toml | 3 +- cmd/workspace/workspace/export_dir.go | 17 +++++---- cmd/workspace/workspace/export_dir_other.go | 7 ++++ cmd/workspace/workspace/export_dir_windows.go | 17 +++++++++ .../workspace/export_dir_windows_test.go | 35 +++++++++++++++++++ 8 files changed, 78 insertions(+), 11 deletions(-) diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 6343eb4a1f4..4ad8ebccffd 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -5,7 +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 skipped with a warning and the export completes ([#5171](https://github.com/databricks/cli/issues/5171)). +* `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//runs/`) so that non-admin users permitted to view the run are taken to the run instead of the workspace homepage. diff --git a/acceptance/cmd/workspace/export-dir-illegal-filename/_script b/acceptance/cmd/workspace/export-dir-illegal-filename/_script index 02aa74de7e0..50af95c6962 100644 --- a/acceptance/cmd/workspace/export-dir-illegal-filename/_script +++ b/acceptance/cmd/workspace/export-dir-illegal-filename/_script @@ -1,7 +1,8 @@ # 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 skips it -# with a warning on Windows and exports it normally elsewhere (databricks/cli#5171). +# 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 +# (databricks/cli#5171). $CLI workspace import "/test-dir/New Notebook 2026-05-04 13:54:24.py" --file "$TESTDIR/../notebook.py" --format AUTO --language PYTHON mkdir -p "$TEST_TMP_DIR/export" diff --git a/acceptance/cmd/workspace/export-dir-illegal-filename/windows/output.txt b/acceptance/cmd/workspace/export-dir-illegal-filename/windows/output.txt index c234e37825d..7cde3c92f51 100644 --- a/acceptance/cmd/workspace/export-dir-illegal-filename/windows/output.txt +++ b/acceptance/cmd/workspace/export-dir-illegal-filename/windows/output.txt @@ -1,5 +1,6 @@ >>> [CLI] workspace export-dir /test-dir [TEST_TMP_DIR]/export Exporting files from /test-dir -/test-dir/New Notebook [TIMESTAMP] (skipped; invalid name for local file system) +Warn: /test-dir/New Notebook [TIMESTAMP]: name is not valid for the local file system, exporting as "New Notebook 2026-05-04 13_54_24.py" +/test-dir/New Notebook [TIMESTAMP] -> [TEST_TMP_DIR]\export\New Notebook 2026-05-04 13_54_24.py Export complete diff --git a/acceptance/cmd/workspace/export-dir-illegal-filename/windows/test.toml b/acceptance/cmd/workspace/export-dir-illegal-filename/windows/test.toml index ba1520ad133..9893864def7 100644 --- a/acceptance/cmd/workspace/export-dir-illegal-filename/windows/test.toml +++ b/acceptance/cmd/workspace/export-dir-illegal-filename/windows/test.toml @@ -1,4 +1,5 @@ # The ':' in the notebook name is illegal as a local Windows filename, so -# export-dir skips it with a warning. Linux/macOS are covered by ../posix. +# export-dir renames it to a legal name with a warning. Linux/macOS are covered +# by ../posix. GOOS.linux = false GOOS.darwin = false diff --git a/cmd/workspace/workspace/export_dir.go b/cmd/workspace/workspace/export_dir.go index f06d7d16b2f..77056f4d28c 100644 --- a/cmd/workspace/workspace/export_dir.go +++ b/cmd/workspace/workspace/export_dir.go @@ -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" @@ -126,13 +127,17 @@ func (opts *exportDirOptions) callback(ctx context.Context, workspaceFiler filer f, err := os.Create(targetPath) if err != nil { // A workspace name can be illegal as a local filename (e.g. a ':' - // in "New Notebook 2026-05-04 13:54:24" on Windows). Skip those with - // a warning rather than aborting the whole export (#5171). - if isInvalidLocalNameError(err) { - cmdio.LogString(ctx, sourcePath+" (skipped; invalid name for local file system)") - return nil + // 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 } - return err } defer f.Close() diff --git a/cmd/workspace/workspace/export_dir_other.go b/cmd/workspace/workspace/export_dir_other.go index e2cd1741d17..3ada8e8d3eb 100644 --- a/cmd/workspace/workspace/export_dir_other.go +++ b/cmd/workspace/workspace/export_dir_other.go @@ -9,3 +9,10 @@ package workspace 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 +} diff --git a/cmd/workspace/workspace/export_dir_windows.go b/cmd/workspace/workspace/export_dir_windows.go index d1276ce2b3f..40881e0d4e1 100644 --- a/cmd/workspace/workspace/export_dir_windows.go +++ b/cmd/workspace/workspace/export_dir_windows.go @@ -4,6 +4,7 @@ package workspace import ( "errors" + "strings" "syscall" ) @@ -19,3 +20,19 @@ const errorInvalidName = syscall.Errno(0x7b) 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 +// (the reserved set plus ASCII control characters) 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 r < 0x20 || strings.ContainsRune(reservedNameChars, r) { + return '_' + } + return r + }, name) +} diff --git a/cmd/workspace/workspace/export_dir_windows_test.go b/cmd/workspace/workspace/export_dir_windows_test.go index aba1e735aa4..9bdd3e67086 100644 --- a/cmd/workspace/workspace/export_dir_windows_test.go +++ b/cmd/workspace/workspace/export_dir_windows_test.go @@ -54,3 +54,38 @@ func TestIsInvalidLocalNameError(t *testing.T) { }) } } + +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: `ac:d"e|f?g*h`, + want: "a_b_c_d_e_f_g_h", + }, + { + name: "control character replaced", + in: "a\tb", + want: "a_b", + }, + { + 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)) + }) + } +} From 44366dde17c21457b35cd27f5a4b13454b54c7bb Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Tue, 23 Jun 2026 11:59:24 +0000 Subject: [PATCH 8/9] acc: address review on export-dir illegal-filename test Rename the fixture notebook to "New Notebook a:b" so the acceptance timestamp redaction no longer applies and the golden stays literal. Drop the unreachable control-character branch in sanitizeLocalName, since workspace object names cannot contain control characters. --- .../cmd/workspace/export-dir-illegal-filename/_script | 7 ++++--- .../workspace/export-dir-illegal-filename/posix/output.txt | 2 +- .../export-dir-illegal-filename/windows/output.txt | 4 ++-- cmd/workspace/workspace/export_dir_windows.go | 6 +++--- cmd/workspace/workspace/export_dir_windows_test.go | 5 ----- 5 files changed, 10 insertions(+), 14 deletions(-) diff --git a/acceptance/cmd/workspace/export-dir-illegal-filename/_script b/acceptance/cmd/workspace/export-dir-illegal-filename/_script index 50af95c6962..705b8394d07 100644 --- a/acceptance/cmd/workspace/export-dir-illegal-filename/_script +++ b/acceptance/cmd/workspace/export-dir-illegal-filename/_script @@ -1,9 +1,10 @@ # 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 -# (databricks/cli#5171). -$CLI workspace import "/test-dir/New Notebook 2026-05-04 13:54:24.py" --file "$TESTDIR/../notebook.py" --format AUTO --language PYTHON +# 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 " name; +# we use a plain "a:b" here so the timestamp redaction doesn't obscure the golden. +$CLI workspace import "/test-dir/New Notebook a:b.py" --file "$TESTDIR/../notebook.py" --format AUTO --language PYTHON mkdir -p "$TEST_TMP_DIR/export" diff --git a/acceptance/cmd/workspace/export-dir-illegal-filename/posix/output.txt b/acceptance/cmd/workspace/export-dir-illegal-filename/posix/output.txt index 846fd8cab36..5bf32c52229 100644 --- a/acceptance/cmd/workspace/export-dir-illegal-filename/posix/output.txt +++ b/acceptance/cmd/workspace/export-dir-illegal-filename/posix/output.txt @@ -1,5 +1,5 @@ >>> [CLI] workspace export-dir /test-dir [TEST_TMP_DIR]/export Exporting files from /test-dir -/test-dir/New Notebook [TIMESTAMP] -> [TEST_TMP_DIR]/export/New Notebook [TIMESTAMP].py +/test-dir/New Notebook a:b -> [TEST_TMP_DIR]/export/New Notebook a:b.py Export complete diff --git a/acceptance/cmd/workspace/export-dir-illegal-filename/windows/output.txt b/acceptance/cmd/workspace/export-dir-illegal-filename/windows/output.txt index 7cde3c92f51..554ac73434d 100644 --- a/acceptance/cmd/workspace/export-dir-illegal-filename/windows/output.txt +++ b/acceptance/cmd/workspace/export-dir-illegal-filename/windows/output.txt @@ -1,6 +1,6 @@ >>> [CLI] workspace export-dir /test-dir [TEST_TMP_DIR]/export Exporting files from /test-dir -Warn: /test-dir/New Notebook [TIMESTAMP]: name is not valid for the local file system, exporting as "New Notebook 2026-05-04 13_54_24.py" -/test-dir/New Notebook [TIMESTAMP] -> [TEST_TMP_DIR]\export\New Notebook 2026-05-04 13_54_24.py +Warn: /test-dir/New Notebook a:b: name is not valid for the local file system, exporting as "New Notebook a_b.py" +/test-dir/New Notebook a:b -> [TEST_TMP_DIR]\export\New Notebook a_b.py Export complete diff --git a/cmd/workspace/workspace/export_dir_windows.go b/cmd/workspace/workspace/export_dir_windows.go index 40881e0d4e1..358846ca1e3 100644 --- a/cmd/workspace/workspace/export_dir_windows.go +++ b/cmd/workspace/workspace/export_dir_windows.go @@ -26,11 +26,11 @@ func isInvalidLocalNameError(err error) bool { const reservedNameChars = `<>:"/\|?*` // sanitizeLocalName replaces characters that are illegal in a Windows filename -// (the reserved set plus ASCII control characters) with '_' so an object whose -// name is invalid locally can still be written under a legal name. +// 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 r < 0x20 || strings.ContainsRune(reservedNameChars, r) { + if strings.ContainsRune(reservedNameChars, r) { return '_' } return r diff --git a/cmd/workspace/workspace/export_dir_windows_test.go b/cmd/workspace/workspace/export_dir_windows_test.go index 9bdd3e67086..1a26f47383e 100644 --- a/cmd/workspace/workspace/export_dir_windows_test.go +++ b/cmd/workspace/workspace/export_dir_windows_test.go @@ -71,11 +71,6 @@ func TestSanitizeLocalName(t *testing.T) { in: `ac:d"e|f?g*h`, want: "a_b_c_d_e_f_g_h", }, - { - name: "control character replaced", - in: "a\tb", - want: "a_b", - }, { name: "legal name unchanged", in: "hello world.py", From ce5e23d832cb314312cc3764b3e8d0f3cb7556c9 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Tue, 23 Jun 2026 12:55:52 +0000 Subject: [PATCH 9/9] acc: use two-colon name in export-dir illegal-filename test 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, leaving the Windows golden unreproducible. Real #5171 names ("13:54:24") fail because they contain two colons, which makes the stream type invalid. Switch the fixture to "a:b:c" so it genuinely triggers the rename-with-warning path on Windows, matching the unit test, while staying clear of the timestamp redaction. --- .../cmd/workspace/export-dir-illegal-filename/_script | 10 +++++++--- .../export-dir-illegal-filename/posix/output.txt | 2 +- .../export-dir-illegal-filename/windows/output.txt | 4 ++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/acceptance/cmd/workspace/export-dir-illegal-filename/_script b/acceptance/cmd/workspace/export-dir-illegal-filename/_script index 705b8394d07..8515606362c 100644 --- a/acceptance/cmd/workspace/export-dir-illegal-filename/_script +++ b/acceptance/cmd/workspace/export-dir-illegal-filename/_script @@ -2,9 +2,13 @@ # 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 " name; -# we use a plain "a:b" here so the timestamp redaction doesn't obscure the golden. -$CLI workspace import "/test-dir/New Notebook a:b.py" --file "$TESTDIR/../notebook.py" --format AUTO --language PYTHON +# The real #5171 trigger is the auto-generated "New Notebook " 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" diff --git a/acceptance/cmd/workspace/export-dir-illegal-filename/posix/output.txt b/acceptance/cmd/workspace/export-dir-illegal-filename/posix/output.txt index 5bf32c52229..c540d77622e 100644 --- a/acceptance/cmd/workspace/export-dir-illegal-filename/posix/output.txt +++ b/acceptance/cmd/workspace/export-dir-illegal-filename/posix/output.txt @@ -1,5 +1,5 @@ >>> [CLI] workspace export-dir /test-dir [TEST_TMP_DIR]/export Exporting files from /test-dir -/test-dir/New Notebook a:b -> [TEST_TMP_DIR]/export/New Notebook a:b.py +/test-dir/New Notebook a:b:c -> [TEST_TMP_DIR]/export/New Notebook a:b:c.py Export complete diff --git a/acceptance/cmd/workspace/export-dir-illegal-filename/windows/output.txt b/acceptance/cmd/workspace/export-dir-illegal-filename/windows/output.txt index 554ac73434d..e2d1f680ddf 100644 --- a/acceptance/cmd/workspace/export-dir-illegal-filename/windows/output.txt +++ b/acceptance/cmd/workspace/export-dir-illegal-filename/windows/output.txt @@ -1,6 +1,6 @@ >>> [CLI] workspace export-dir /test-dir [TEST_TMP_DIR]/export Exporting files from /test-dir -Warn: /test-dir/New Notebook a:b: name is not valid for the local file system, exporting as "New Notebook a_b.py" -/test-dir/New Notebook a:b -> [TEST_TMP_DIR]\export\New Notebook a_b.py +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