diff --git a/frontend/dockerfile/dockerfile_test.go b/frontend/dockerfile/dockerfile_test.go index 70ef31069520..847a5b15577c 100644 --- a/frontend/dockerfile/dockerfile_test.go +++ b/frontend/dockerfile/dockerfile_test.go @@ -166,6 +166,7 @@ var allTests = integration.TestFuncs( testSBOMScannerArgs, testMultiPlatformWarnings, testNilContextInSolveGateway, + testCopyUnicodePath, ) // Tests that depend on the `security.*` entitlements @@ -6732,6 +6733,46 @@ func testNilContextInSolveGateway(t *testing.T, sb integration.Sandbox) { require.ErrorContains(t, err, "invalid nil input definition to definition op") } +func testCopyUnicodePath(t *testing.T, sb integration.Sandbox) { + f := getFrontend(t, sb) + c, err := client.New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + dockerfile := []byte(` +FROM alpine +COPY test-äöü.txt / +`) + + dir, err := integration.Tmpdir( + t, + fstest.CreateFile("Dockerfile", dockerfile, 0600), + fstest.CreateFile("test-äöü.txt", []byte("test"), 0644), + ) + require.NoError(t, err) + + destDir, err := integration.Tmpdir(t) + require.NoError(t, err) + + _, err = f.Solve(sb.Context(), c, client.SolveOpt{ + Exports: []client.ExportEntry{ + { + Type: client.ExporterLocal, + OutputDir: destDir, + }, + }, + LocalDirs: map[string]string{ + dockerui.DefaultLocalNameDockerfile: dir, + dockerui.DefaultLocalNameContext: dir, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(filepath.Join(destDir, "test-äöü.txt")) + require.NoError(t, err) + require.Equal(t, "test", string(dt)) +} + func runShell(dir string, cmds ...string) error { for _, args := range cmds { var cmd *exec.Cmd diff --git a/session/filesync/filesync.go b/session/filesync/filesync.go index e31354262930..522f453203b4 100644 --- a/session/filesync/filesync.go +++ b/session/filesync/filesync.go @@ -4,8 +4,11 @@ import ( "context" "fmt" io "io" + "net/url" "os" + "strconv" "strings" + "unicode" "github.com/moby/buildkit/session" "github.com/pkg/errors" @@ -24,6 +27,7 @@ const ( keyFollowPaths = "followpaths" keyDirName = "dir-name" keyExporterMetaPrefix = "exporter-md-" + keyOptsEncoded = "opts-encoded" ) type fsSyncProvider struct { @@ -83,6 +87,17 @@ func (sp *fsSyncProvider) handle(method string, stream grpc.ServerStream) (retEr opts, _ := metadata.FromIncomingContext(stream.Context()) // if no metadata continue with empty object + isDecoded := false + if v, ok := opts[keyOptsEncoded]; ok && len(v) > 0 { + if b, _ := strconv.ParseBool(v[0]); b { + isDecoded = true + } + } + + if isDecoded { + opts = decodeOpts(opts) + } + dirName := "" name, ok := opts[keyDirName] if ok && len(name) > 0 { @@ -209,6 +224,11 @@ func FSSync(ctx context.Context, c session.Caller, opt FSSendRequestOpt) error { var stream grpc.ClientStream + // mark that we have encoded options so older versions with raw values can be detected on client side + opts[keyOptsEncoded] = []string{"1"} + + opts = encodeOpts(opts) + ctx = metadata.NewOutgoingContext(ctx, opts) switch pr.name { @@ -337,3 +357,44 @@ func (e InvalidSessionError) Error() string { func (e InvalidSessionError) Unwrap() error { return e.err } + +func encodeOpts(opts map[string][]string) map[string][]string { + md := make(map[string][]string, len(opts)) + for k, v := range opts { + out := make([]string, len(v)) + for i, s := range v { + out[i] = encodeStringForHeader(s) + } + md[k] = out + } + return md +} + +func decodeOpts(opts map[string][]string) map[string][]string { + md := make(map[string][]string, len(opts)) + for k, v := range opts { + out := make([]string, len(v)) + for i, s := range v { + out[i], _ = url.QueryUnescape(s) + } + md[k] = out + } + return md +} + +// encodeStringForHeader encodes a string value so it can be used in grpc header. This encoding +// is backwards compatible and avoids encoding ASCII characters. +func encodeStringForHeader(input string) string { + var output strings.Builder + for _, runeVal := range input { + // Only encode non-ASCII characters. + if runeVal > unicode.MaxASCII { + // Encode each non-ASCII character individually. + output.WriteString(url.QueryEscape(string(runeVal))) + } else { + // Directly append ASCII characters and '*' to the output. + output.WriteRune(runeVal) + } + } + return output.String() +}