返回 DeepSeek-Reasonix
open_local_path.go
根目录 / desktop / open_local_path.go
1 package main
2
3 import (
4 "fmt"
5 "net/url"
6 "os"
7 "path/filepath"
8 "strings"
9 )
10
11 // normalizeLocalOpenPath validates and normalizes a user-clicked local path
12 // before it is handed to the OS opener. It accepts either a plain absolute
13 // path (D:\a\b.md or D:/a/b.md) or a file URL. The native boundary validates
14 // URLs independently because desktop commands are callable without the frontend.
15 func normalizeLocalOpenPath(path string) (string, error) {
16 path = strings.TrimSpace(path)
17 if path == "" {
18 return "", os.ErrInvalid
19 }
20 if strings.HasPrefix(path, "file://") {
21 parsed, err := url.Parse(path)
22 if err != nil || parsed.Scheme != "file" || parsed.Opaque != "" || parsed.User != nil || parsed.Port() != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
23 return "", fmt.Errorf("invalid local file URL %q", path)
24 }
25 decoded, err := url.PathUnescape(parsed.EscapedPath())
26 if err != nil {
27 return "", fmt.Errorf("invalid local file URL %q: %w", path, err)
28 }
29 host := parsed.Hostname()
30 if host == "." || host == "?" {
31 return "", fmt.Errorf("unsafe local file URL authority %q", host)
32 }
33 if strings.EqualFold(host, "localhost") {
34 host = ""
35 }
36 if host != "" {
37 decoded = "//" + host + decoded
38 }
39 if len(decoded) >= 4 && decoded[0] == '/' && isASCIILetter(decoded[1]) && decoded[2] == ':' && decoded[3] == '/' {
40 decoded = decoded[1:]
41 }
42 path = decoded
43 }
44 if hasDisallowedWindowsPathSyntax(path) {
45 return "", fmt.Errorf("unsafe local path syntax %q", path)
46 }
47 // Normalize forward slashes (file URLs, slash-form UNC "//nas/share")
48 // to the platform-native separators the opener expects.
49 path = filepath.FromSlash(path)
50 if !filepath.IsAbs(path) {
51 return "", fmt.Errorf("path is not absolute: %q", path)
52 }
53 return path, nil
54 }
55
56 func isASCIILetter(value byte) bool {
57 return value >= 'a' && value <= 'z' || value >= 'A' && value <= 'Z'
58 }
59
60 func hasDisallowedWindowsPathSyntax(path string) bool {
61 slashPath := strings.ReplaceAll(path, "\\", "/")
62 if strings.ContainsRune(slashPath, '\x00') {
63 return true
64 }
65 if slashPath == "//." || slashPath == "//?" || strings.HasPrefix(slashPath, "//./") || strings.HasPrefix(slashPath, "//?/") {
66 return true
67 }
68 isDrivePath := len(slashPath) >= 3 && isASCIILetter(slashPath[0]) && slashPath[1] == ':' && slashPath[2] == '/'
69 isUNCPath := strings.HasPrefix(slashPath, "//")
70 if !isDrivePath && !isUNCPath {
71 return false
72 }
73 remainder := slashPath[2:]
74 if strings.Contains(remainder, ":") {
75 return true
76 }
77 for component := range strings.SplitSeq(remainder, "/") {
78 component = strings.TrimRight(component, " .")
79 if dot := strings.IndexByte(component, '.'); dot >= 0 {
80 component = component[:dot]
81 }
82 switch strings.ToUpper(component) {
83 case "CON", "PRN", "AUX", "NUL", "CLOCK$", "CONIN$", "CONOUT$",
84 "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
85 "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
86 "COM¹", "COM²", "COM³", "LPT¹", "LPT²", "LPT³":
87 return true
88 }
89 }
90 return false
91 }
92
93 // openTargetAllowed reports whether a resolved path may be handed to the OS
94 // "open" verb. Directories and documents open normally; executable targets
95 // are refused because OpenLocalPath is fed by AI-generated chat content —
96 // a prompt-injected or hallucinated ".bat" path must not run on click.
97 // openWorkspacePath remains outside this executable-target guard because its
98 // callers use it for paths already authorized by the workspace boundary.
99 var executableOpenSuffixes = map[string]bool{
100 ".app": true,
101 ".bat": true, ".cmd": true, ".com": true, ".exe": true,
102 ".desktop": true,
103 ".ps1": true, ".vbs": true, ".jse": true, ".js": true,
104 ".lnk": true, ".url": true, ".scr": true, ".msi": true,
105 ".reg": true, ".pif": true, ".hta": true, ".wsf": true,
106 }
107
108 func openTargetAllowed(path string, isDir bool, mode os.FileMode) bool {
109 // Windows resolves trailing dots and spaces away before opening a path, and
110 // filepath.Clean removes a trailing separator from macOS app bundles.
111 base := strings.TrimRight(filepath.Base(filepath.Clean(path)), " .")
112 if executableOpenSuffixes[strings.ToLower(filepath.Ext(base))] {
113 return false
114 }
115 return isDir || mode.Perm()&0o111 == 0
116 }
117
118 func openTargetPathAllowed(path string, info os.FileInfo) bool {
119 if !openTargetAllowed(path, info.IsDir(), info.Mode()) {
120 return false
121 }
122 cleanPath := filepath.Clean(path)
123 linkInfo, err := os.Lstat(cleanPath)
124 if err != nil {
125 return false
126 }
127 if linkInfo.Mode()&os.ModeSymlink == 0 {
128 return true
129 }
130 resolved, err := filepath.EvalSymlinks(cleanPath)
131 if err != nil {
132 return false
133 }
134 return openTargetAllowed(resolved, info.IsDir(), info.Mode())
135 }
136
137 // OpenLocalPath opens an arbitrary local absolute path (file or directory)
138 // with the OS default application. It backs clicking a local path rendered in
139 // chat markdown (issue #7426) — Windows drive paths, UNC paths and file:///
140 // URLs included.
141 func (a *App) OpenLocalPath(path string) error {
142 path, err := normalizeLocalOpenPath(path)
143 if err != nil {
144 return err
145 }
146 info, err := os.Stat(path)
147 if err != nil {
148 return err
149 }
150 if !openTargetPathAllowed(path, info) {
151 return fmt.Errorf("refusing to open executable target %q", path)
152 }
153 return openWorkspacePathWithType(path, info.IsDir())
154 }
155
155 lines GO