Files
dingtalk-workspace-cli/internal/helpers/drive_sync_confirmation_test.go

528 lines
22 KiB
Go

package helpers
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
// pull / push / sync 声明 Safety.Confirmation = user_required。本文件断言两件事:
// 1. 未确认(无 --yes、非 dry-run)时命令必须在任何写操作之前拒绝执行;
// 2. 明确确认(--yes)后才发出精确的工具调用并落盘。
// status 是只读叶子(not_required),不受确认门约束。
// ──────────────────────────────────────────────────────────
// 1. 未确认即拒绝,且一个写操作都不发生
// ──────────────────────────────────────────────────────────
func TestCrossPlatformCoverageDriveSyncFamily_refusesWithoutConfirmation(t *testing.T) {
cases := []struct {
name string
args []string
setup func(t *testing.T, dir string)
listJSON string
}{
{
name: "pull",
args: []string{"pull"},
// 远端有文件、本地为空:即便是纯新增也必须先确认。
listJSON: `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A","modifyTime":1}],"nextToken":""}}`,
},
{
name: "push",
args: []string{"push"},
setup: func(t *testing.T, dir string) { mustWrite(t, filepath.Join(dir, "a.txt"), "local") },
listJSON: `{"result":{"items":[],"nextToken":""}}`,
},
{
name: "sync",
args: []string{"sync", "--quick", "--on-conflict", "remote-wins"},
setup: func(t *testing.T, dir string) { mustWrite(t, filepath.Join(dir, "a.txt"), "local") },
listJSON: `{"result":{"items":[{"name":"b.txt","type":"file","fileId":"B","modifyTime":2}],"nextToken":""}}`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
if tc.setup != nil {
tc.setup(t, dir)
}
caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, nth int) (string, error) {
switch tool {
case "list_files":
if nth > 0 {
return `{"result":{"items":[],"nextToken":""}}`, nil
}
return tc.listJSON, nil
case "download_file":
return `{"result":{"downloadUrl":"https://oss.example.com/get","headers":{}}}`, nil
case "get_upload_info":
return `{"result":{"resourceUrls":[{"url":"https://oss.example.com/put","headers":{}}],"uploadId":"U1"}}`, nil
}
return `{"result":{"fileId":"NEW"},"success":true}`, nil
}}
swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error {
t.Error("no download may happen before confirmation")
return os.WriteFile(dest, []byte("leaked"), 0o644)
})
testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error {
t.Error("no upload may happen before confirmation")
return nil
})
args := append(append([]string{}, tc.args...), "--local-folder", dir, "--remote-folder", "ROOT")
err := runDriveCmdWithoutConfirm(t, caller, args...)
if err == nil {
t.Fatal("expected the command to refuse without --yes")
}
if !strings.Contains(err.Error(), "--yes") {
t.Errorf("error must tell the user to confirm with --yes, got %q", err.Error())
}
// 拒绝必须发生在任何写工具调用之前。
for _, tool := range []string{"download_file", "get_upload_info", "create_folder", "ln"} {
if got := caller.callsFor(tool); len(got) != 0 {
t.Errorf("%s must not be called before confirmation, got %v", tool, got)
}
}
// 本地目录不得新增任何文件。
entries, _ := os.ReadDir(dir)
for _, e := range entries {
if e.Name() == "b.txt" || e.Name() == "a.txt" && tc.name == "pull" {
t.Errorf("%s must not be materialized before confirmation", e.Name())
}
}
})
}
}
// --dry-run 无需 --yes 也能预览:这是让用户看清将发生什么的必要出口,且不写任何东西。
func TestCrossPlatformCoverageDriveSyncFamily_dryRunNeedsNoConfirmation(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "local.txt"), "l")
caller := syncCaller(map[string]string{
"ROOT": `{"result":{"items":[{"name":"remote.txt","type":"file","fileId":"R","modifyTime":2}],"nextToken":""}}`,
})
caller.dryRun = true
swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error {
t.Error("dry-run must not download")
return nil
})
testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error {
t.Error("dry-run must not upload")
return nil
})
if err := runDriveCmdWithoutConfirm(t, caller, "sync",
"--local-folder", dir, "--remote-folder", "ROOT", "--quick"); err != nil {
t.Fatalf("dry-run must not require confirmation: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "remote.txt")); !os.IsNotExist(err) {
t.Error("dry-run must not write remote files locally")
}
}
func TestCrossPlatformCoverageDrivePull_dryRunPlansWithoutLocalWrites(t *testing.T) {
parent := t.TempDir()
target := filepath.Join(parent, "not-created")
caller := pullListingCaller(`{"name":"remote.txt","type":"file","fileId":"R","modifyTime":2}`)
caller.dryRun = true
swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error {
t.Error("dry-run must not download")
return nil
})
var out bytes.Buffer
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}})
testseam.Swap(t, &os.Args, []string{"dws", "drive", "pull"})
cmd := findDriveSubcommand(t, "pull")
mustSetFlags(t, cmd, map[string]string{"local-folder": target, "remote-folder": "ROOT"})
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("dry-run pull: %v", err)
}
if _, err := os.Stat(target); !os.IsNotExist(err) {
t.Fatalf("dry-run must not create local root: %v", err)
}
if got := len(caller.callsFor("download_file")); got != 0 {
t.Fatalf("download_file calls = %d, want 0", got)
}
if text := out.String(); !strings.Contains(text, `"dry_run": true`) || !strings.Contains(text, `"remote.txt"`) {
t.Fatalf("missing pull plan: %s", text)
}
}
func TestCrossPlatformCoverageDrivePush_dryRunPlansWithoutRemoteWrites(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil {
t.Fatal(err)
}
mustWrite(t, filepath.Join(dir, "sub", "local.txt"), "local")
caller := syncCaller(nil)
caller.dryRun = true
testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error {
t.Error("dry-run must not upload")
return nil
})
var out bytes.Buffer
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}})
testseam.Swap(t, &os.Args, []string{"dws", "drive", "push"})
cmd := findDriveSubcommand(t, "push")
mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT"})
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("dry-run push: %v", err)
}
for _, tool := range []string{"create_folder", "get_upload_info", "commit_upload"} {
if got := len(caller.callsFor(tool)); got != 0 {
t.Fatalf("%s calls = %d, want 0", tool, got)
}
}
if text := out.String(); !strings.Contains(text, `"dry_run": true`) || !strings.Contains(text, `"sub/local.txt"`) {
t.Fatalf("missing push plan: %s", text)
}
}
func TestCrossPlatformCoverageDrivePushDryRunPublishesPlannedNotExecutedActions(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "local.txt"), "local")
caller := syncCaller(nil)
caller.dryRun = true
var out bytes.Buffer
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}})
testseam.Swap(t, &os.Args, []string{"dws", "drive", "push"})
cmd := findDriveSubcommand(t, "push")
mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT"})
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("dry-run push: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("decode dry-run push: %v\n%s", err, out.String())
}
plan := payload["plan"].(map[string]any)
summary := plan["summary"].(map[string]any)
if summary["uploaded"] != float64(0) || summary["planned_uploads"] != float64(1) {
t.Fatalf("dry-run summary reports execution instead of a plan: %#v", summary)
}
items := plan["items"].([]any)
if got := items[0].(map[string]any)["action"]; got != "planned_upload" {
t.Fatalf("dry-run action = %v, want planned_upload", got)
}
}
func TestCrossPlatformCoverageDriveSyncDryRunReturnsRealActionPlan(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "local.txt"), "local")
mustWrite(t, filepath.Join(dir, "shared.txt"), "shared")
if err := os.Mkdir(filepath.Join(dir, "empty-folder"), 0o755); err != nil {
t.Fatal(err)
}
caller := syncCaller(map[string]string{
"ROOT": `{"result":{"items":[{"name":"remote.txt","type":"file","fileId":"R","modifyTime":2},{"name":"shared.txt","type":"file","fileId":"S","modifyTime":2}],"nextToken":""}}`,
})
caller.dryRun = true
var out bytes.Buffer
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}})
testseam.Swap(t, &os.Args, []string{"dws", "drive", "sync"})
cmd := findDriveSubcommand(t, "sync")
mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT"})
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("dry-run sync: %v", err)
}
var payload driveSyncDryRunResult
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("decode dry-run sync: %v\n%s", err, out.String())
}
if payload.Executed || payload.Plan.Summary.Pulled != 0 || payload.Plan.Summary.Pushed != 0 || payload.Plan.Summary.Skipped != 0 {
t.Fatalf("dry-run plan summary = %+v, executed=%v", payload.Plan.Summary, payload.Executed)
}
if payload.Plan.Summary.PlannedPulls != 1 || payload.Plan.Summary.PlannedPushes != 1 ||
payload.Plan.Summary.PlannedSkips != 1 || payload.Plan.Summary.PlannedFolders != 1 {
t.Fatalf("dry-run planned summary = %+v", payload.Plan.Summary)
}
actions := map[string]int{}
for _, item := range payload.Plan.Items {
actions[item.Action]++
}
if len(payload.Plan.Items) != 4 || actions["planned_download"] != 1 || actions["planned_upload"] != 1 ||
actions["planned_skip"] != 1 || actions["planned_folder_create"] != 1 {
t.Fatalf("dry-run action plan = %#v", payload.Plan.Items)
}
for _, tool := range []string{"download_file", "get_upload_info", "commit_upload", "create_folder"} {
if calls := caller.callsFor(tool); len(calls) != 0 {
t.Fatalf("dry-run called %s: %#v", tool, calls)
}
}
}
func TestCrossPlatformCoverageDriveSyncDryRunPlanBranchMatrix(t *testing.T) {
remoteFiles := map[string]*remoteFile{
"conflict.txt": {FileID: "remote-file"},
}
remoteFolders := map[string]string{"": "root", "existing": "existing-id"}
localDirs := []string{"existing", "new", "missing/child"}
localFiles := map[string]localPushFile{"conflict.txt": {}, "new/file.txt": {}}
for _, policy := range []string{syncConflictRemoteWins, syncConflictLocalWins, syncConflictKeepBoth, syncConflictAsk, syncConflictSkip} {
t.Run(policy, func(t *testing.T) {
res := &driveSyncResult{}
appendDriveSyncDryRunPlan(res, driveSyncDryRunPlanInput{
LocalDirs: localDirs, LocalByRel: localFiles, RemoteFiles: remoteFiles, RemoteFolders: remoteFolders,
NewLocal: []string{"new/file.txt", "missing/file.txt"}, NewRemote: []string{"remote.txt"},
Modified: []string{"conflict.txt"}, Unknown: []string{"unknown.txt"}, OnConflict: policy,
})
if res.Summary.PlannedSkips == 0 || res.Summary.PlannedFolders == 0 || res.Summary.PlannedPulls == 0 || res.Summary.Failed != 2 {
t.Fatalf("dry-run plan summary for %s = %#v; items=%#v", policy, res.Summary, res.Items)
}
switch policy {
case syncConflictRemoteWins, syncConflictKeepBoth:
if res.Summary.PlannedPulls < 2 {
t.Fatalf("%s planned pulls = %d", policy, res.Summary.PlannedPulls)
}
case syncConflictLocalWins:
if res.Summary.PlannedPushes < 2 {
t.Fatalf("local wins planned pushes = %d", res.Summary.PlannedPushes)
}
}
})
}
}
func TestCrossPlatformCoverageDrivePull_dryRunPlanBranches(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "skip.txt"), "skip")
mustWrite(t, filepath.Join(root, "smart.txt"), "smart")
remote := map[string]*remoteFile{
"A.txt": {RelPath: "A.txt"},
"a.txt": {RelPath: "a.txt"},
"../escape": {RelPath: "../escape"},
"skip.txt": {RelPath: "skip.txt"},
"smart.txt": {RelPath: "smart.txt", ModifiedTime: 1, ModifiedTimeValid: true},
"new.txt": {RelPath: "new.txt"},
}
var out bytes.Buffer
testseam.Swap(t, &deps, &Deps{Out: &Formatter{w: &out}})
if err := printDrivePullDryRun(root, ifExistsSkip, remote,
[]string{"A.txt", "a.txt", "../escape", "skip.txt", "new.txt"}, true); err != nil {
t.Fatalf("skip plan: %v", err)
}
if text := out.String(); !strings.Contains(text, pullActionFailed) || !strings.Contains(text, pullActionSkipped) || !strings.Contains(text, pullActionDownloaded) {
t.Fatalf("skip plan did not cover failed/skipped/downloaded: %s", text)
}
out.Reset()
if err := printDrivePullDryRun(root, ifExistsSmart, remote, []string{"smart.txt"}, false); err != nil {
t.Fatalf("smart plan: %v", err)
}
if !strings.Contains(out.String(), pullActionSkipped) {
t.Fatalf("smart plan must skip newer local file: %s", out.String())
}
}
func TestCrossPlatformCoverageDrivePush_dryRunPlanBranches(t *testing.T) {
var out bytes.Buffer
testseam.Swap(t, &deps, &Deps{Out: &Formatter{w: &out}})
remoteFolders := map[string]string{"": "ROOT", "existing": "EXISTING"}
remoteFiles := map[string]*remoteFile{
"skip.txt": {RelPath: "skip.txt"},
"smart-skip.txt": {RelPath: "smart-skip.txt", ModifiedTime: 20, ModifiedTimeValid: true},
"smart-overwrite.txt": {RelPath: "smart-overwrite.txt", ModifiedTime: 1, ModifiedTimeValid: true},
"overwrite.txt": {RelPath: "overwrite.txt"},
}
files := func(paths ...string) []localPushFile {
result := make([]localPushFile, 0, len(paths))
for _, path := range paths {
result = append(result, localPushFile{RelPath: path, Size: 1, ModTimeMillis: 10})
}
return result
}
if err := printDrivePushDryRun(ifExistsSkip, remoteFiles, remoteFolders,
[]string{"existing", "missing/child"}, files("orphan/a.txt", "skip.txt", "new.txt")); err != nil {
t.Fatalf("skip plan: %v", err)
}
if text := out.String(); !strings.Contains(text, pushActionFailed) || !strings.Contains(text, pushActionPlannedSkip) || !strings.Contains(text, pushActionPlannedUpload) {
t.Fatalf("skip plan did not cover failed/skipped/uploaded: %s", text)
}
out.Reset()
if err := printDrivePushDryRun(ifExistsSmart, remoteFiles, remoteFolders, nil,
files("smart-skip.txt", "smart-overwrite.txt")); err != nil {
t.Fatalf("smart plan: %v", err)
}
if text := out.String(); !strings.Contains(text, pushActionPlannedSkip) || !strings.Contains(text, pushActionPlannedOverwrite) {
t.Fatalf("smart plan did not cover skip/overwrite: %s", text)
}
out.Reset()
if err := printDrivePushDryRun(ifExistsOverwrite, remoteFiles, remoteFolders, nil, files("overwrite.txt")); err != nil {
t.Fatalf("overwrite plan: %v", err)
}
if !strings.Contains(out.String(), pushActionPlannedOverwrite) {
t.Fatalf("overwrite plan must overwrite existing file: %s", out.String())
}
}
// ──────────────────────────────────────────────────────────
// 2. 明确确认后才发出精确的工具调用
// ──────────────────────────────────────────────────────────
// sync --on-conflict remote-wins --yes:确认后只下载指定远端文件,且不上传。
func TestCrossPlatformCoverageDriveSync_confirmedRemoteWinsIssuesExactCalls(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "f.txt")
mustWrite(t, p, "local-version")
local := readFileMTimeMillis(t, p)
withSyncTransport(t, "remote-version")
caller := syncCaller(map[string]string{
"ROOT": `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"F","modifyTime":` +
differentMillis(local) + `}],"nextToken":""}}`,
})
if err := runDriveCmdWithoutConfirm(t, caller, "sync", "--local-folder", dir,
"--remote-folder", "ROOT", "--quick", "--on-conflict", "remote-wins", "--yes"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
dl := caller.callsFor("download_file")
if len(dl) != 1 || dl[0].args["fileId"] != "F" {
t.Fatalf("expected exactly one download_file(fileId=F), got %v", dl)
}
if up := caller.callsFor("get_upload_info"); len(up) != 0 {
t.Errorf("remote-wins must not upload, got %v", up)
}
if b, _ := os.ReadFile(p); string(b) != "remote-version" {
t.Errorf("remote-wins must replace the local content, got %q", string(b))
}
}
// pull --if-exists overwrite --yes:确认后才 download_file(fileId=A) 并覆盖本地内容。
func TestCrossPlatformCoverageDrivePull_confirmedOverwriteIssuesExactCalls(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "a.txt")
mustWrite(t, p, "local-old")
caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":9}`)
swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error {
return os.WriteFile(dest, []byte("remote-new"), 0o644)
})
if err := runDriveCmdWithoutConfirm(t, caller, "pull", "--local-folder", dir,
"--remote-folder", "ROOT", "--if-exists", "overwrite", "--yes"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
dl := caller.callsFor("download_file")
if len(dl) != 1 || dl[0].args["fileId"] != "A" {
t.Fatalf("expected exactly one download_file(fileId=A), got %v", dl)
}
if b, _ := os.ReadFile(p); string(b) != "remote-new" {
t.Errorf("confirmed overwrite must replace the local file, got %q", string(b))
}
}
// push --if-exists overwrite --yes:确认后原地覆盖,必须带 overwriteFileId 且不带 parentId。
func TestCrossPlatformCoverageDrivePush_confirmedOverwriteIssuesExactCalls(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "a.txt"), "local")
withNoopPut(t)
caller := pushOKCaller(map[string]string{
"ROOT": `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A","modifyTime":1}],"nextToken":""}}`,
})
if err := runDriveCmdWithoutConfirm(t, caller, "push", "--local-folder", dir,
"--remote-folder", "ROOT", "--if-exists", "overwrite", "--yes"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
up := caller.callsFor("get_upload_info")
if len(up) != 1 || up[0].args["overwriteFileId"] != "A" {
t.Fatalf("expected get_upload_info(overwriteFileId=A), got %v", up)
}
if _, has := up[0].args["parentId"]; has {
t.Error("in-place overwrite must not carry parentId")
}
}
// sync --on-conflict local-wins --yes:确认后覆盖上传远端,且不下载。
func TestCrossPlatformCoverageDriveSync_confirmedLocalWinsIssuesExactCalls(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "f.txt")
mustWrite(t, p, "local-version")
local := readFileMTimeMillis(t, p)
withSyncTransport(t, "unused")
caller := syncCaller(map[string]string{
"ROOT": `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"F","modifyTime":` +
differentMillis(local) + `}],"nextToken":""}}`,
})
if err := runDriveCmdWithoutConfirm(t, caller, "sync", "--local-folder", dir,
"--remote-folder", "ROOT", "--quick", "--on-conflict", "local-wins", "--yes"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
up := caller.callsFor("get_upload_info")
if len(up) != 1 || up[0].args["overwriteFileId"] != "F" {
t.Fatalf("expected get_upload_info(overwriteFileId=F), got %v", up)
}
if dl := caller.callsFor("download_file"); len(dl) != 0 {
t.Errorf("local-wins must not download, got %v", dl)
}
if b, _ := os.ReadFile(p); string(b) != "local-version" {
t.Errorf("local-wins must keep the local content, got %q", string(b))
}
}
// ──────────────────────────────────────────────────────────
// 3. 安全默认值本身
// ──────────────────────────────────────────────────────────
// sync 默认 --on-conflict=skip:两侧都变更时两边内容都保留,且不发出任何传输调用。
func TestCrossPlatformCoverageDriveSync_defaultSkipsConflictsEntirely(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "f.txt")
mustWrite(t, p, "local-version")
local := readFileMTimeMillis(t, p)
withSyncTransport(t, "remote-version")
caller := syncCaller(map[string]string{
"ROOT": `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"F","modifyTime":` +
differentMillis(local) + `}],"nextToken":""}}`,
})
if err := runDriveCmd(t, caller, "sync", "--local-folder", dir, "--remote-folder", "ROOT", "--quick"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := len(caller.callsFor("download_file")) + len(caller.callsFor("get_upload_info")); got != 0 {
t.Errorf("default skip must not transfer anything, got %d calls", got)
}
if b, _ := os.ReadFile(p); string(b) != "local-version" {
t.Errorf("default skip must keep the local content, got %q", string(b))
}
}
// pull 默认 --if-exists=skip:本地已存在的文件不被覆盖。
func TestCrossPlatformCoverageDrivePull_defaultSkipsExistingLocalFiles(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "a.txt")
mustWrite(t, p, "local-old")
caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":9}`)
swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error {
t.Error("default skip must not download over an existing local file")
return nil
})
if err := runDriveCmd(t, caller, "pull", "--local-folder", dir, "--remote-folder", "ROOT"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if b, _ := os.ReadFile(p); string(b) != "local-old" {
t.Errorf("default skip must keep the local file, got %q", string(b))
}
}