From a0bd11845a3fc5b2ccaff3fb68a1de4db9d0b0f0 Mon Sep 17 00:00:00 2001 From: Xiwen Cheng Date: Fri, 9 Jan 2026 22:19:31 +0100 Subject: [PATCH 1/6] add support for mxlint.readfile function in js rules --- lint/lint_javascript.go | 41 +++++++++- lint/lint_javascript_test.go | 144 +++++++++++++++++++++++++++++++++++ lint/rules.go | 5 +- 3 files changed, 188 insertions(+), 2 deletions(-) create mode 100644 lint/lint_javascript_test.go diff --git a/lint/lint_javascript.go b/lint/lint_javascript.go index dad5453..05aab00 100644 --- a/lint/lint_javascript.go +++ b/lint/lint_javascript.go @@ -3,6 +3,7 @@ package lint import ( "fmt" "os" + "path/filepath" "strings" "time" @@ -10,6 +11,42 @@ import ( "gopkg.in/yaml.v3" ) +// setupJavascriptVM creates a new sobek VM with the mxlint object exposed. +// The mxlint object provides utility functions for JavaScript rules: +// - mxlint.readfile(path): Reads a file and returns its contents as a string. +// The path is resolved relative to the workingDirectory. +func setupJavascriptVM(workingDirectory string) *sobek.Runtime { + vm := sobek.New() + + // Create the mxlint object + mxlint := vm.NewObject() + vm.Set("mxlint", mxlint) + + // Set the readfile function + mxlint.Set("readfile", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) == 0 { + panic(vm.NewGoError(fmt.Errorf("mxlint.readfile requires a file path argument"))) + } + filename := call.Argument(0).String() + + // Resolve the path relative to working directory + var fullPath string + if filepath.IsAbs(filename) { + fullPath = filename + } else { + fullPath = filepath.Join(workingDirectory, filename) + } + + content, err := os.ReadFile(fullPath) + if err != nil { + panic(vm.NewGoError(err)) + } + return vm.ToValue(string(content)) + }) + + return vm +} + func evalTestcase_Javascript(rulePath string, inputFilePath string, ruleNumber string, ignoreNoqa bool) (*Testcase, error) { ruleContent, _ := os.ReadFile(rulePath) log.Debugf("js file: \n%s", ruleContent) @@ -48,7 +85,9 @@ func evalTestcase_Javascript(rulePath string, inputFilePath string, ruleNumber s startTime := time.Now() - vm := sobek.New() + // Use the directory containing the input file as the working directory + workingDirectory := filepath.Dir(inputFilePath) + vm := setupJavascriptVM(workingDirectory) _, err = vm.RunString(string(ruleContent)) if err != nil { panic(err) diff --git a/lint/lint_javascript_test.go b/lint/lint_javascript_test.go new file mode 100644 index 0000000..a1ed8f2 --- /dev/null +++ b/lint/lint_javascript_test.go @@ -0,0 +1,144 @@ +package lint + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSetupJavascriptVM_MxlintReadfile(t *testing.T) { + // Create a temporary directory for test files + tempDir := t.TempDir() + + // Create a test file to read + testContent := "Hello, mxlint!" + testFilePath := filepath.Join(tempDir, "test.txt") + err := os.WriteFile(testFilePath, []byte(testContent), 0644) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + t.Run("read file with relative path", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := `mxlint.readfile("test.txt")` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() != testContent { + t.Errorf("Expected %q, got %q", testContent, result.String()) + } + }) + + t.Run("read file with absolute path", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := `mxlint.readfile("` + testFilePath + `")` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() != testContent { + t.Errorf("Expected %q, got %q", testContent, result.String()) + } + }) + + t.Run("read nonexistent file throws error", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := ` + try { + mxlint.readfile("nonexistent.txt"); + "no error"; + } catch (e) { + "error: " + e.message; + } + ` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() == "no error" { + t.Error("Expected an error when reading nonexistent file") + } + }) + + t.Run("readfile without argument throws error", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := ` + try { + mxlint.readfile(); + "no error"; + } catch (e) { + "error: " + e.message; + } + ` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() == "no error" { + t.Error("Expected an error when calling readfile without argument") + } + }) + + t.Run("read file in subdirectory", func(t *testing.T) { + // Create a subdirectory with a test file + subDir := filepath.Join(tempDir, "subdir") + err := os.Mkdir(subDir, 0755) + if err != nil { + t.Fatalf("Failed to create subdirectory: %v", err) + } + + subFileContent := "Content in subdirectory" + subFilePath := filepath.Join(subDir, "subfile.txt") + err = os.WriteFile(subFilePath, []byte(subFileContent), 0644) + if err != nil { + t.Fatalf("Failed to create subfile: %v", err) + } + + vm := setupJavascriptVM(tempDir) + + script := `mxlint.readfile("subdir/subfile.txt")` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() != subFileContent { + t.Errorf("Expected %q, got %q", subFileContent, result.String()) + } + }) +} + +func TestMxlintObjectAvailable(t *testing.T) { + vm := setupJavascriptVM(".") + + // Check that mxlint object is available + script := `typeof mxlint` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() != "object" { + t.Errorf("Expected mxlint to be an object, got %q", result.String()) + } + + // Check that mxlint.readfile is a function + script = `typeof mxlint.readfile` + result, err = vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() != "function" { + t.Errorf("Expected mxlint.readfile to be a function, got %q", result.String()) + } +} diff --git a/lint/rules.go b/lint/rules.go index b04ef4d..4b247c8 100644 --- a/lint/rules.go +++ b/lint/rules.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "path/filepath" "strings" "github.com/grafana/sobek" @@ -106,7 +107,9 @@ func runJavaScriptTestCases(rule Rule) error { return fmt.Errorf("unexpected testCase type: %T", testCase) } - vm := sobek.New() + // Use the directory containing the rule file as the working directory + workingDirectory := filepath.Dir(rule.Path) + vm := setupJavascriptVM(workingDirectory) _, err = vm.RunString(string(ruleContent)) if err != nil { panic(err) From b371265c1ccdd4e689fbb17498edb92493d05877 Mon Sep 17 00:00:00 2001 From: Xiwen Cheng Date: Fri, 9 Jan 2026 22:26:35 +0100 Subject: [PATCH 2/6] Prevent rules from reading files outside of input directory --- lint/lint_javascript.go | 29 +++++++++++++++++++++---- lint/lint_javascript_test.go | 42 ++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/lint/lint_javascript.go b/lint/lint_javascript.go index 05aab00..fe7b01c 100644 --- a/lint/lint_javascript.go +++ b/lint/lint_javascript.go @@ -27,16 +27,37 @@ func setupJavascriptVM(workingDirectory string) *sobek.Runtime { if len(call.Arguments) == 0 { panic(vm.NewGoError(fmt.Errorf("mxlint.readfile requires a file path argument"))) } - filename := call.Argument(0).String() + filepathArg := call.Argument(0).String() // Resolve the path relative to working directory var fullPath string - if filepath.IsAbs(filename) { - fullPath = filename + if filepath.IsAbs(filepathArg) { + fullPath = filepathArg } else { - fullPath = filepath.Join(workingDirectory, filename) + fullPath = filepath.Join(workingDirectory, filepathArg) } + // Convert both paths to absolute and clean them to resolve any ".." or "." components + absFullPath, err := filepath.Abs(fullPath) + if err != nil { + panic(vm.NewGoError(fmt.Errorf("failed to resolve file path: %w", err))) + } + absFullPath = filepath.Clean(absFullPath) + + absWorkingDir, err := filepath.Abs(workingDirectory) + if err != nil { + panic(vm.NewGoError(fmt.Errorf("failed to resolve working directory: %w", err))) + } + absWorkingDir = filepath.Clean(absWorkingDir) + + // Check that the resolved path is within the working directory + if !strings.HasPrefix(absFullPath, absWorkingDir+string(filepath.Separator)) && absFullPath != absWorkingDir { + panic(vm.NewGoError(fmt.Errorf("mxlint.readfile: path %q is outside working directory %q", filepathArg, workingDirectory))) + } + + // Use the absolute path for reading + fullPath = absFullPath + content, err := os.ReadFile(fullPath) if err != nil { panic(vm.NewGoError(err)) diff --git a/lint/lint_javascript_test.go b/lint/lint_javascript_test.go index a1ed8f2..1a54f76 100644 --- a/lint/lint_javascript_test.go +++ b/lint/lint_javascript_test.go @@ -67,6 +67,48 @@ func TestSetupJavascriptVM_MxlintReadfile(t *testing.T) { } }) + t.Run("path traversal with .. is blocked", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := ` + try { + mxlint.readfile("../../../etc/passwd"); + "no error"; + } catch (e) { + e.message.includes("outside working directory") ? "blocked" : "other error: " + e.message; + } + ` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() != "blocked" { + t.Errorf("Expected path traversal to be blocked, got: %s", result.String()) + } + }) + + t.Run("absolute path outside working directory is blocked", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := ` + try { + mxlint.readfile("/etc/passwd"); + "no error"; + } catch (e) { + e.message.includes("outside working directory") ? "blocked" : "other error: " + e.message; + } + ` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() != "blocked" { + t.Errorf("Expected absolute path outside working dir to be blocked, got: %s", result.String()) + } + }) + t.Run("readfile without argument throws error", func(t *testing.T) { vm := setupJavascriptVM(tempDir) From f92c6b8bbad3902a07ca8039985f527b91725eaa Mon Sep 17 00:00:00 2001 From: Xiwen Cheng Date: Fri, 9 Jan 2026 22:26:42 +0100 Subject: [PATCH 3/6] improve logging --- lint/lint_javascript.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lint/lint_javascript.go b/lint/lint_javascript.go index fe7b01c..9b696c5 100644 --- a/lint/lint_javascript.go +++ b/lint/lint_javascript.go @@ -116,7 +116,7 @@ func evalTestcase_Javascript(rulePath string, inputFilePath string, ruleNumber s ruleFunction, ok := sobek.AssertFunction(vm.Get("rule")) if !ok { - panic("rule(...) function not found") + panic("rule(...) function not found in rule file: " + rulePath) } res, err := ruleFunction(sobek.Undefined(), vm.ToValue(data)) From 917b1a6521d7b71149521bba167bb708a385e325 Mon Sep 17 00:00:00 2001 From: Xiwen Cheng Date: Fri, 9 Jan 2026 22:32:56 +0100 Subject: [PATCH 4/6] implement mxlint.listdir function --- lint/lint_javascript.go | 80 ++++++++++---- lint/lint_javascript_test.go | 206 +++++++++++++++++++++++++++++++++++ 2 files changed, 264 insertions(+), 22 deletions(-) diff --git a/lint/lint_javascript.go b/lint/lint_javascript.go index 9b696c5..3463c29 100644 --- a/lint/lint_javascript.go +++ b/lint/lint_javascript.go @@ -11,10 +11,44 @@ import ( "gopkg.in/yaml.v3" ) +// resolvePath resolves the given path relative to the working directory and validates +// that it stays within the working directory. Returns the absolute path or an error. +func resolvePath(pathArg string, workingDirectory string) (string, error) { + // Resolve the path relative to working directory + var fullPath string + if filepath.IsAbs(pathArg) { + fullPath = pathArg + } else { + fullPath = filepath.Join(workingDirectory, pathArg) + } + + // Convert both paths to absolute and clean them to resolve any ".." or "." components + absFullPath, err := filepath.Abs(fullPath) + if err != nil { + return "", fmt.Errorf("failed to resolve path: %w", err) + } + absFullPath = filepath.Clean(absFullPath) + + absWorkingDir, err := filepath.Abs(workingDirectory) + if err != nil { + return "", fmt.Errorf("failed to resolve working directory: %w", err) + } + absWorkingDir = filepath.Clean(absWorkingDir) + + // Check that the resolved path is within the working directory + if !strings.HasPrefix(absFullPath, absWorkingDir+string(filepath.Separator)) && absFullPath != absWorkingDir { + return "", fmt.Errorf("path %q is outside working directory %q", pathArg, workingDirectory) + } + + return absFullPath, nil +} + // setupJavascriptVM creates a new sobek VM with the mxlint object exposed. // The mxlint object provides utility functions for JavaScript rules: // - mxlint.readfile(path): Reads a file and returns its contents as a string. // The path is resolved relative to the workingDirectory. +// - mxlint.listdir(path): Lists the contents of a directory and returns an array of filenames. +// The path is resolved relative to the workingDirectory. func setupJavascriptVM(workingDirectory string) *sobek.Runtime { vm := sobek.New() @@ -29,40 +63,42 @@ func setupJavascriptVM(workingDirectory string) *sobek.Runtime { } filepathArg := call.Argument(0).String() - // Resolve the path relative to working directory - var fullPath string - if filepath.IsAbs(filepathArg) { - fullPath = filepathArg - } else { - fullPath = filepath.Join(workingDirectory, filepathArg) - } - - // Convert both paths to absolute and clean them to resolve any ".." or "." components - absFullPath, err := filepath.Abs(fullPath) + absPath, err := resolvePath(filepathArg, workingDirectory) if err != nil { - panic(vm.NewGoError(fmt.Errorf("failed to resolve file path: %w", err))) + panic(vm.NewGoError(fmt.Errorf("mxlint.readfile: %w", err))) } - absFullPath = filepath.Clean(absFullPath) - absWorkingDir, err := filepath.Abs(workingDirectory) + content, err := os.ReadFile(absPath) if err != nil { - panic(vm.NewGoError(fmt.Errorf("failed to resolve working directory: %w", err))) + panic(vm.NewGoError(err)) } - absWorkingDir = filepath.Clean(absWorkingDir) + return vm.ToValue(string(content)) + }) - // Check that the resolved path is within the working directory - if !strings.HasPrefix(absFullPath, absWorkingDir+string(filepath.Separator)) && absFullPath != absWorkingDir { - panic(vm.NewGoError(fmt.Errorf("mxlint.readfile: path %q is outside working directory %q", filepathArg, workingDirectory))) + // Set the listdir function + mxlint.Set("listdir", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) == 0 { + panic(vm.NewGoError(fmt.Errorf("mxlint.listdir requires a directory path argument"))) } + dirpathArg := call.Argument(0).String() - // Use the absolute path for reading - fullPath = absFullPath + absPath, err := resolvePath(dirpathArg, workingDirectory) + if err != nil { + panic(vm.NewGoError(fmt.Errorf("mxlint.listdir: %w", err))) + } - content, err := os.ReadFile(fullPath) + entries, err := os.ReadDir(absPath) if err != nil { panic(vm.NewGoError(err)) } - return vm.ToValue(string(content)) + + // Convert directory entries to a slice of names + names := make([]string, len(entries)) + for i, entry := range entries { + names[i] = entry.Name() + } + + return vm.ToValue(names) }) return vm diff --git a/lint/lint_javascript_test.go b/lint/lint_javascript_test.go index 1a54f76..51d3df9 100644 --- a/lint/lint_javascript_test.go +++ b/lint/lint_javascript_test.go @@ -159,6 +159,201 @@ func TestSetupJavascriptVM_MxlintReadfile(t *testing.T) { }) } +func TestSetupJavascriptVM_MxlintListdir(t *testing.T) { + // Create a temporary directory for test files + tempDir := t.TempDir() + + // Create some test files and directories + err := os.WriteFile(filepath.Join(tempDir, "file1.txt"), []byte("content1"), 0644) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + err = os.WriteFile(filepath.Join(tempDir, "file2.txt"), []byte("content2"), 0644) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + err = os.Mkdir(filepath.Join(tempDir, "subdir"), 0755) + if err != nil { + t.Fatalf("Failed to create subdirectory: %v", err) + } + err = os.WriteFile(filepath.Join(tempDir, "subdir", "nested.txt"), []byte("nested"), 0644) + if err != nil { + t.Fatalf("Failed to create nested file: %v", err) + } + + t.Run("list directory with relative path", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := `JSON.stringify(mxlint.listdir(".").sort())` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + expected := `["file1.txt","file2.txt","subdir"]` + if result.String() != expected { + t.Errorf("Expected %q, got %q", expected, result.String()) + } + }) + + t.Run("list directory with absolute path", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := `JSON.stringify(mxlint.listdir("` + tempDir + `").sort())` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + expected := `["file1.txt","file2.txt","subdir"]` + if result.String() != expected { + t.Errorf("Expected %q, got %q", expected, result.String()) + } + }) + + t.Run("list subdirectory", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := `JSON.stringify(mxlint.listdir("subdir"))` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + expected := `["nested.txt"]` + if result.String() != expected { + t.Errorf("Expected %q, got %q", expected, result.String()) + } + }) + + t.Run("list nonexistent directory throws error", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := ` + try { + mxlint.listdir("nonexistent"); + "no error"; + } catch (e) { + "error: " + e.message; + } + ` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() == "no error" { + t.Error("Expected an error when listing nonexistent directory") + } + }) + + t.Run("path traversal with .. is blocked", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := ` + try { + mxlint.listdir("../../../etc"); + "no error"; + } catch (e) { + e.message.includes("outside working directory") ? "blocked" : "other error: " + e.message; + } + ` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() != "blocked" { + t.Errorf("Expected path traversal to be blocked, got: %s", result.String()) + } + }) + + t.Run("absolute path outside working directory is blocked", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := ` + try { + mxlint.listdir("/etc"); + "no error"; + } catch (e) { + e.message.includes("outside working directory") ? "blocked" : "other error: " + e.message; + } + ` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() != "blocked" { + t.Errorf("Expected absolute path outside working dir to be blocked, got: %s", result.String()) + } + }) + + t.Run("listdir without argument throws error", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := ` + try { + mxlint.listdir(); + "no error"; + } catch (e) { + "error: " + e.message; + } + ` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() == "no error" { + t.Error("Expected an error when calling listdir without argument") + } + }) + + t.Run("list empty directory", func(t *testing.T) { + // Create an empty subdirectory + emptyDir := filepath.Join(tempDir, "empty") + err := os.Mkdir(emptyDir, 0755) + if err != nil { + t.Fatalf("Failed to create empty directory: %v", err) + } + + vm := setupJavascriptVM(tempDir) + + script := `JSON.stringify(mxlint.listdir("empty"))` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + expected := `[]` + if result.String() != expected { + t.Errorf("Expected %q, got %q", expected, result.String()) + } + }) + + t.Run("listdir on file throws error", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := ` + try { + mxlint.listdir("file1.txt"); + "no error"; + } catch (e) { + e.message.includes("not a directory") ? "not a directory" : "error: " + e.message; + } + ` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() == "no error" { + t.Error("Expected an error when calling listdir on a file") + } + }) +} + func TestMxlintObjectAvailable(t *testing.T) { vm := setupJavascriptVM(".") @@ -183,4 +378,15 @@ func TestMxlintObjectAvailable(t *testing.T) { if result.String() != "function" { t.Errorf("Expected mxlint.readfile to be a function, got %q", result.String()) } + + // Check that mxlint.listdir is a function + script = `typeof mxlint.listdir` + result, err = vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() != "function" { + t.Errorf("Expected mxlint.listdir to be a function, got %q", result.String()) + } } From cc11505660cc626b4aff904ae294f9731ab72eff Mon Sep 17 00:00:00 2001 From: Xiwen Cheng Date: Fri, 9 Jan 2026 22:35:02 +0100 Subject: [PATCH 5/6] implement mxlint.isdir function --- lint/lint_javascript.go | 25 +++++ lint/lint_javascript_test.go | 180 +++++++++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+) diff --git a/lint/lint_javascript.go b/lint/lint_javascript.go index 3463c29..32c21ec 100644 --- a/lint/lint_javascript.go +++ b/lint/lint_javascript.go @@ -49,6 +49,8 @@ func resolvePath(pathArg string, workingDirectory string) (string, error) { // The path is resolved relative to the workingDirectory. // - mxlint.listdir(path): Lists the contents of a directory and returns an array of filenames. // The path is resolved relative to the workingDirectory. +// - mxlint.isdir(path): Returns true if the path is a directory, false otherwise. +// The path is resolved relative to the workingDirectory. func setupJavascriptVM(workingDirectory string) *sobek.Runtime { vm := sobek.New() @@ -101,6 +103,29 @@ func setupJavascriptVM(workingDirectory string) *sobek.Runtime { return vm.ToValue(names) }) + // Set the isdir function + mxlint.Set("isdir", func(call sobek.FunctionCall) sobek.Value { + if len(call.Arguments) == 0 { + panic(vm.NewGoError(fmt.Errorf("mxlint.isdir requires a path argument"))) + } + pathArg := call.Argument(0).String() + + absPath, err := resolvePath(pathArg, workingDirectory) + if err != nil { + panic(vm.NewGoError(fmt.Errorf("mxlint.isdir: %w", err))) + } + + info, err := os.Stat(absPath) + if err != nil { + if os.IsNotExist(err) { + return vm.ToValue(false) + } + panic(vm.NewGoError(err)) + } + + return vm.ToValue(info.IsDir()) + }) + return vm } diff --git a/lint/lint_javascript_test.go b/lint/lint_javascript_test.go index 51d3df9..2280f32 100644 --- a/lint/lint_javascript_test.go +++ b/lint/lint_javascript_test.go @@ -354,6 +354,175 @@ func TestSetupJavascriptVM_MxlintListdir(t *testing.T) { }) } +func TestSetupJavascriptVM_MxlintIsdir(t *testing.T) { + // Create a temporary directory for test files + tempDir := t.TempDir() + + // Create some test files and directories + err := os.WriteFile(filepath.Join(tempDir, "file.txt"), []byte("content"), 0644) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + err = os.Mkdir(filepath.Join(tempDir, "subdir"), 0755) + if err != nil { + t.Fatalf("Failed to create subdirectory: %v", err) + } + + t.Run("isdir returns true for directory with relative path", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := `mxlint.isdir("subdir")` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.ToBoolean() != true { + t.Errorf("Expected true for directory, got %v", result.ToBoolean()) + } + }) + + t.Run("isdir returns true for directory with absolute path", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := `mxlint.isdir("` + filepath.Join(tempDir, "subdir") + `")` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.ToBoolean() != true { + t.Errorf("Expected true for directory, got %v", result.ToBoolean()) + } + }) + + t.Run("isdir returns false for file", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := `mxlint.isdir("file.txt")` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.ToBoolean() != false { + t.Errorf("Expected false for file, got %v", result.ToBoolean()) + } + }) + + t.Run("isdir returns false for nonexistent path", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := `mxlint.isdir("nonexistent")` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.ToBoolean() != false { + t.Errorf("Expected false for nonexistent path, got %v", result.ToBoolean()) + } + }) + + t.Run("isdir returns true for current directory", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := `mxlint.isdir(".")` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.ToBoolean() != true { + t.Errorf("Expected true for current directory, got %v", result.ToBoolean()) + } + }) + + t.Run("path traversal with .. is blocked", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := ` + try { + mxlint.isdir("../../../etc"); + "no error"; + } catch (e) { + e.message.includes("outside working directory") ? "blocked" : "other error: " + e.message; + } + ` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() != "blocked" { + t.Errorf("Expected path traversal to be blocked, got: %s", result.String()) + } + }) + + t.Run("absolute path outside working directory is blocked", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := ` + try { + mxlint.isdir("/etc"); + "no error"; + } catch (e) { + e.message.includes("outside working directory") ? "blocked" : "other error: " + e.message; + } + ` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() != "blocked" { + t.Errorf("Expected absolute path outside working dir to be blocked, got: %s", result.String()) + } + }) + + t.Run("isdir without argument throws error", func(t *testing.T) { + vm := setupJavascriptVM(tempDir) + + script := ` + try { + mxlint.isdir(); + "no error"; + } catch (e) { + "error: " + e.message; + } + ` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() == "no error" { + t.Error("Expected an error when calling isdir without argument") + } + }) + + t.Run("isdir with nested directory path", func(t *testing.T) { + // Create a nested directory + nestedDir := filepath.Join(tempDir, "subdir", "nested") + err := os.Mkdir(nestedDir, 0755) + if err != nil { + t.Fatalf("Failed to create nested directory: %v", err) + } + + vm := setupJavascriptVM(tempDir) + + script := `mxlint.isdir("subdir/nested")` + result, err := vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.ToBoolean() != true { + t.Errorf("Expected true for nested directory, got %v", result.ToBoolean()) + } + }) +} + func TestMxlintObjectAvailable(t *testing.T) { vm := setupJavascriptVM(".") @@ -389,4 +558,15 @@ func TestMxlintObjectAvailable(t *testing.T) { if result.String() != "function" { t.Errorf("Expected mxlint.listdir to be a function, got %q", result.String()) } + + // Check that mxlint.isdir is a function + script = `typeof mxlint.isdir` + result, err = vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() != "function" { + t.Errorf("Expected mxlint.isdir to be a function, got %q", result.String()) + } } From edb4e1d913218633be4c69beb71f2545e14571cc Mon Sep 17 00:00:00 2001 From: Xiwen Cheng Date: Fri, 9 Jan 2026 22:49:48 +0100 Subject: [PATCH 6/6] rename to mxlint.io.* --- lint/lint_javascript.go | 28 +++++++------ lint/lint_javascript_test.go | 79 ++++++++++++++++++++---------------- 2 files changed, 61 insertions(+), 46 deletions(-) diff --git a/lint/lint_javascript.go b/lint/lint_javascript.go index 32c21ec..b0fce4b 100644 --- a/lint/lint_javascript.go +++ b/lint/lint_javascript.go @@ -45,11 +45,11 @@ func resolvePath(pathArg string, workingDirectory string) (string, error) { // setupJavascriptVM creates a new sobek VM with the mxlint object exposed. // The mxlint object provides utility functions for JavaScript rules: -// - mxlint.readfile(path): Reads a file and returns its contents as a string. +// - mxlint.io.readfile(path): Reads a file and returns its contents as a string. // The path is resolved relative to the workingDirectory. -// - mxlint.listdir(path): Lists the contents of a directory and returns an array of filenames. +// - mxlint.io.listdir(path): Lists the contents of a directory and returns an array of filenames. // The path is resolved relative to the workingDirectory. -// - mxlint.isdir(path): Returns true if the path is a directory, false otherwise. +// - mxlint.io.isdir(path): Returns true if the path is a directory, false otherwise. // The path is resolved relative to the workingDirectory. func setupJavascriptVM(workingDirectory string) *sobek.Runtime { vm := sobek.New() @@ -58,16 +58,20 @@ func setupJavascriptVM(workingDirectory string) *sobek.Runtime { mxlint := vm.NewObject() vm.Set("mxlint", mxlint) + // Create the io sub-object + io := vm.NewObject() + mxlint.Set("io", io) + // Set the readfile function - mxlint.Set("readfile", func(call sobek.FunctionCall) sobek.Value { + io.Set("readfile", func(call sobek.FunctionCall) sobek.Value { if len(call.Arguments) == 0 { - panic(vm.NewGoError(fmt.Errorf("mxlint.readfile requires a file path argument"))) + panic(vm.NewGoError(fmt.Errorf("mxlint.io.readfile requires a file path argument"))) } filepathArg := call.Argument(0).String() absPath, err := resolvePath(filepathArg, workingDirectory) if err != nil { - panic(vm.NewGoError(fmt.Errorf("mxlint.readfile: %w", err))) + panic(vm.NewGoError(fmt.Errorf("mxlint.io.readfile: %w", err))) } content, err := os.ReadFile(absPath) @@ -78,15 +82,15 @@ func setupJavascriptVM(workingDirectory string) *sobek.Runtime { }) // Set the listdir function - mxlint.Set("listdir", func(call sobek.FunctionCall) sobek.Value { + io.Set("listdir", func(call sobek.FunctionCall) sobek.Value { if len(call.Arguments) == 0 { - panic(vm.NewGoError(fmt.Errorf("mxlint.listdir requires a directory path argument"))) + panic(vm.NewGoError(fmt.Errorf("mxlint.io.listdir requires a directory path argument"))) } dirpathArg := call.Argument(0).String() absPath, err := resolvePath(dirpathArg, workingDirectory) if err != nil { - panic(vm.NewGoError(fmt.Errorf("mxlint.listdir: %w", err))) + panic(vm.NewGoError(fmt.Errorf("mxlint.io.listdir: %w", err))) } entries, err := os.ReadDir(absPath) @@ -104,15 +108,15 @@ func setupJavascriptVM(workingDirectory string) *sobek.Runtime { }) // Set the isdir function - mxlint.Set("isdir", func(call sobek.FunctionCall) sobek.Value { + io.Set("isdir", func(call sobek.FunctionCall) sobek.Value { if len(call.Arguments) == 0 { - panic(vm.NewGoError(fmt.Errorf("mxlint.isdir requires a path argument"))) + panic(vm.NewGoError(fmt.Errorf("mxlint.io.isdir requires a path argument"))) } pathArg := call.Argument(0).String() absPath, err := resolvePath(pathArg, workingDirectory) if err != nil { - panic(vm.NewGoError(fmt.Errorf("mxlint.isdir: %w", err))) + panic(vm.NewGoError(fmt.Errorf("mxlint.io.isdir: %w", err))) } info, err := os.Stat(absPath) diff --git a/lint/lint_javascript_test.go b/lint/lint_javascript_test.go index 2280f32..15094d7 100644 --- a/lint/lint_javascript_test.go +++ b/lint/lint_javascript_test.go @@ -21,7 +21,7 @@ func TestSetupJavascriptVM_MxlintReadfile(t *testing.T) { t.Run("read file with relative path", func(t *testing.T) { vm := setupJavascriptVM(tempDir) - script := `mxlint.readfile("test.txt")` + script := `mxlint.io.readfile("test.txt")` result, err := vm.RunString(script) if err != nil { t.Fatalf("Failed to run script: %v", err) @@ -35,7 +35,7 @@ func TestSetupJavascriptVM_MxlintReadfile(t *testing.T) { t.Run("read file with absolute path", func(t *testing.T) { vm := setupJavascriptVM(tempDir) - script := `mxlint.readfile("` + testFilePath + `")` + script := `mxlint.io.readfile("` + testFilePath + `")` result, err := vm.RunString(script) if err != nil { t.Fatalf("Failed to run script: %v", err) @@ -51,7 +51,7 @@ func TestSetupJavascriptVM_MxlintReadfile(t *testing.T) { script := ` try { - mxlint.readfile("nonexistent.txt"); + mxlint.io.readfile("nonexistent.txt"); "no error"; } catch (e) { "error: " + e.message; @@ -72,7 +72,7 @@ func TestSetupJavascriptVM_MxlintReadfile(t *testing.T) { script := ` try { - mxlint.readfile("../../../etc/passwd"); + mxlint.io.readfile("../../../etc/passwd"); "no error"; } catch (e) { e.message.includes("outside working directory") ? "blocked" : "other error: " + e.message; @@ -93,7 +93,7 @@ func TestSetupJavascriptVM_MxlintReadfile(t *testing.T) { script := ` try { - mxlint.readfile("/etc/passwd"); + mxlint.io.readfile("/etc/passwd"); "no error"; } catch (e) { e.message.includes("outside working directory") ? "blocked" : "other error: " + e.message; @@ -114,7 +114,7 @@ func TestSetupJavascriptVM_MxlintReadfile(t *testing.T) { script := ` try { - mxlint.readfile(); + mxlint.io.readfile(); "no error"; } catch (e) { "error: " + e.message; @@ -147,7 +147,7 @@ func TestSetupJavascriptVM_MxlintReadfile(t *testing.T) { vm := setupJavascriptVM(tempDir) - script := `mxlint.readfile("subdir/subfile.txt")` + script := `mxlint.io.readfile("subdir/subfile.txt")` result, err := vm.RunString(script) if err != nil { t.Fatalf("Failed to run script: %v", err) @@ -184,7 +184,7 @@ func TestSetupJavascriptVM_MxlintListdir(t *testing.T) { t.Run("list directory with relative path", func(t *testing.T) { vm := setupJavascriptVM(tempDir) - script := `JSON.stringify(mxlint.listdir(".").sort())` + script := `JSON.stringify(mxlint.io.listdir(".").sort())` result, err := vm.RunString(script) if err != nil { t.Fatalf("Failed to run script: %v", err) @@ -199,7 +199,7 @@ func TestSetupJavascriptVM_MxlintListdir(t *testing.T) { t.Run("list directory with absolute path", func(t *testing.T) { vm := setupJavascriptVM(tempDir) - script := `JSON.stringify(mxlint.listdir("` + tempDir + `").sort())` + script := `JSON.stringify(mxlint.io.listdir("` + tempDir + `").sort())` result, err := vm.RunString(script) if err != nil { t.Fatalf("Failed to run script: %v", err) @@ -214,7 +214,7 @@ func TestSetupJavascriptVM_MxlintListdir(t *testing.T) { t.Run("list subdirectory", func(t *testing.T) { vm := setupJavascriptVM(tempDir) - script := `JSON.stringify(mxlint.listdir("subdir"))` + script := `JSON.stringify(mxlint.io.listdir("subdir"))` result, err := vm.RunString(script) if err != nil { t.Fatalf("Failed to run script: %v", err) @@ -231,7 +231,7 @@ func TestSetupJavascriptVM_MxlintListdir(t *testing.T) { script := ` try { - mxlint.listdir("nonexistent"); + mxlint.io.listdir("nonexistent"); "no error"; } catch (e) { "error: " + e.message; @@ -252,7 +252,7 @@ func TestSetupJavascriptVM_MxlintListdir(t *testing.T) { script := ` try { - mxlint.listdir("../../../etc"); + mxlint.io.listdir("../../../etc"); "no error"; } catch (e) { e.message.includes("outside working directory") ? "blocked" : "other error: " + e.message; @@ -273,7 +273,7 @@ func TestSetupJavascriptVM_MxlintListdir(t *testing.T) { script := ` try { - mxlint.listdir("/etc"); + mxlint.io.listdir("/etc"); "no error"; } catch (e) { e.message.includes("outside working directory") ? "blocked" : "other error: " + e.message; @@ -294,7 +294,7 @@ func TestSetupJavascriptVM_MxlintListdir(t *testing.T) { script := ` try { - mxlint.listdir(); + mxlint.io.listdir(); "no error"; } catch (e) { "error: " + e.message; @@ -320,7 +320,7 @@ func TestSetupJavascriptVM_MxlintListdir(t *testing.T) { vm := setupJavascriptVM(tempDir) - script := `JSON.stringify(mxlint.listdir("empty"))` + script := `JSON.stringify(mxlint.io.listdir("empty"))` result, err := vm.RunString(script) if err != nil { t.Fatalf("Failed to run script: %v", err) @@ -337,7 +337,7 @@ func TestSetupJavascriptVM_MxlintListdir(t *testing.T) { script := ` try { - mxlint.listdir("file1.txt"); + mxlint.io.listdir("file1.txt"); "no error"; } catch (e) { e.message.includes("not a directory") ? "not a directory" : "error: " + e.message; @@ -371,7 +371,7 @@ func TestSetupJavascriptVM_MxlintIsdir(t *testing.T) { t.Run("isdir returns true for directory with relative path", func(t *testing.T) { vm := setupJavascriptVM(tempDir) - script := `mxlint.isdir("subdir")` + script := `mxlint.io.isdir("subdir")` result, err := vm.RunString(script) if err != nil { t.Fatalf("Failed to run script: %v", err) @@ -385,7 +385,7 @@ func TestSetupJavascriptVM_MxlintIsdir(t *testing.T) { t.Run("isdir returns true for directory with absolute path", func(t *testing.T) { vm := setupJavascriptVM(tempDir) - script := `mxlint.isdir("` + filepath.Join(tempDir, "subdir") + `")` + script := `mxlint.io.isdir("` + filepath.Join(tempDir, "subdir") + `")` result, err := vm.RunString(script) if err != nil { t.Fatalf("Failed to run script: %v", err) @@ -399,7 +399,7 @@ func TestSetupJavascriptVM_MxlintIsdir(t *testing.T) { t.Run("isdir returns false for file", func(t *testing.T) { vm := setupJavascriptVM(tempDir) - script := `mxlint.isdir("file.txt")` + script := `mxlint.io.isdir("file.txt")` result, err := vm.RunString(script) if err != nil { t.Fatalf("Failed to run script: %v", err) @@ -413,7 +413,7 @@ func TestSetupJavascriptVM_MxlintIsdir(t *testing.T) { t.Run("isdir returns false for nonexistent path", func(t *testing.T) { vm := setupJavascriptVM(tempDir) - script := `mxlint.isdir("nonexistent")` + script := `mxlint.io.isdir("nonexistent")` result, err := vm.RunString(script) if err != nil { t.Fatalf("Failed to run script: %v", err) @@ -427,7 +427,7 @@ func TestSetupJavascriptVM_MxlintIsdir(t *testing.T) { t.Run("isdir returns true for current directory", func(t *testing.T) { vm := setupJavascriptVM(tempDir) - script := `mxlint.isdir(".")` + script := `mxlint.io.isdir(".")` result, err := vm.RunString(script) if err != nil { t.Fatalf("Failed to run script: %v", err) @@ -443,7 +443,7 @@ func TestSetupJavascriptVM_MxlintIsdir(t *testing.T) { script := ` try { - mxlint.isdir("../../../etc"); + mxlint.io.isdir("../../../etc"); "no error"; } catch (e) { e.message.includes("outside working directory") ? "blocked" : "other error: " + e.message; @@ -464,7 +464,7 @@ func TestSetupJavascriptVM_MxlintIsdir(t *testing.T) { script := ` try { - mxlint.isdir("/etc"); + mxlint.io.isdir("/etc"); "no error"; } catch (e) { e.message.includes("outside working directory") ? "blocked" : "other error: " + e.message; @@ -485,7 +485,7 @@ func TestSetupJavascriptVM_MxlintIsdir(t *testing.T) { script := ` try { - mxlint.isdir(); + mxlint.io.isdir(); "no error"; } catch (e) { "error: " + e.message; @@ -511,7 +511,7 @@ func TestSetupJavascriptVM_MxlintIsdir(t *testing.T) { vm := setupJavascriptVM(tempDir) - script := `mxlint.isdir("subdir/nested")` + script := `mxlint.io.isdir("subdir/nested")` result, err := vm.RunString(script) if err != nil { t.Fatalf("Failed to run script: %v", err) @@ -537,36 +537,47 @@ func TestMxlintObjectAvailable(t *testing.T) { t.Errorf("Expected mxlint to be an object, got %q", result.String()) } - // Check that mxlint.readfile is a function - script = `typeof mxlint.readfile` + // Check that mxlint.io is an object + script = `typeof mxlint.io` + result, err = vm.RunString(script) + if err != nil { + t.Fatalf("Failed to run script: %v", err) + } + + if result.String() != "object" { + t.Errorf("Expected mxlint.io to be an object, got %q", result.String()) + } + + // Check that mxlint.io.readfile is a function + script = `typeof mxlint.io.readfile` result, err = vm.RunString(script) if err != nil { t.Fatalf("Failed to run script: %v", err) } if result.String() != "function" { - t.Errorf("Expected mxlint.readfile to be a function, got %q", result.String()) + t.Errorf("Expected mxlint.io.readfile to be a function, got %q", result.String()) } - // Check that mxlint.listdir is a function - script = `typeof mxlint.listdir` + // Check that mxlint.io.listdir is a function + script = `typeof mxlint.io.listdir` result, err = vm.RunString(script) if err != nil { t.Fatalf("Failed to run script: %v", err) } if result.String() != "function" { - t.Errorf("Expected mxlint.listdir to be a function, got %q", result.String()) + t.Errorf("Expected mxlint.io.listdir to be a function, got %q", result.String()) } - // Check that mxlint.isdir is a function - script = `typeof mxlint.isdir` + // Check that mxlint.io.isdir is a function + script = `typeof mxlint.io.isdir` result, err = vm.RunString(script) if err != nil { t.Fatalf("Failed to run script: %v", err) } if result.String() != "function" { - t.Errorf("Expected mxlint.isdir to be a function, got %q", result.String()) + t.Errorf("Expected mxlint.io.isdir to be a function, got %q", result.String()) } }