|
5 | 5 | "io/fs" |
6 | 6 | "net/url" |
7 | 7 | "os" |
| 8 | + "path" |
8 | 9 | "path/filepath" |
9 | 10 | "strings" |
10 | 11 |
|
@@ -522,12 +523,24 @@ func (r *Rule) checkRelativeTarget( |
522 | 523 | // resolveTargetFile when only file existence (not the read |
523 | 524 | // helper) matters. |
524 | 525 | func targetExists(f *lint.File, linkPath, resolvedRoot string) bool { |
525 | | - if path, ok := resolveTargetOSPath(f.Path, linkPath); ok && cachedStatExists(path) { |
526 | | - if resolvedRoot == "" || isWithinRoot(resolvedRoot, path) { |
| 526 | + if osPath, ok := resolveTargetOSPath(f.Path, linkPath); ok && cachedStatExists(osPath) { |
| 527 | + if resolvedRoot == "" || isWithinRoot(resolvedRoot, osPath) { |
527 | 528 | return true |
528 | 529 | } |
529 | 530 | return false |
530 | 531 | } |
| 532 | + // In-memory / workspace-relative resolution: the WASM and LSP engines |
| 533 | + // have no OS disk for the branch above. Resolve the link against the |
| 534 | + // source file's directory within the project-root FS, collapsing ".." |
| 535 | + // so io/fs — which rejects paths containing ".." — can stat an |
| 536 | + // up-and-over target (e.g. docs/x.md -> ../../internal/y.md). |
| 537 | + if f.RootFS != nil && !filepath.IsAbs(f.Path) { |
| 538 | + if rel, ok := resolveWorkspaceRelTarget(f.Path, linkPath); ok { |
| 539 | + if _, err := fs.Stat(f.RootFS, rel); err == nil { |
| 540 | + return true |
| 541 | + } |
| 542 | + } |
| 543 | + } |
531 | 544 | fsPath := filepath.ToSlash(linkPath) |
532 | 545 | fsPath = strings.TrimPrefix(fsPath, "./") |
533 | 546 | if fsPath == "" || strings.HasPrefix(fsPath, "/") { |
@@ -843,23 +856,40 @@ func buildAnchorsForTarget(target targetFile) (map[string]struct{}, error) { |
843 | 856 |
|
844 | 857 | func resolveTargetFile(f *lint.File, linkPath, resolvedRoot string) (targetFile, bool) { |
845 | 858 | maxBytes := f.MaxInputBytes |
846 | | - if path, ok := resolveTargetOSPath(f.Path, linkPath); ok { |
847 | | - if cachedStatExists(path) { |
| 859 | + if osPath, ok := resolveTargetOSPath(f.Path, linkPath); ok { |
| 860 | + if cachedStatExists(osPath) { |
848 | 861 | // Reject links that resolve outside the project root, |
849 | 862 | // evaluating symlinks to prevent bypass via symlinked dirs. |
850 | | - if resolvedRoot != "" && !isWithinRoot(resolvedRoot, path) { |
| 863 | + if resolvedRoot != "" && !isWithinRoot(resolvedRoot, osPath) { |
851 | 864 | return targetFile{}, false |
852 | 865 | } |
853 | 866 | return targetFile{ |
854 | | - cacheKey: "os:" + path, |
855 | | - runCacheKey: path, |
| 867 | + cacheKey: "os:" + osPath, |
| 868 | + runCacheKey: osPath, |
856 | 869 | read: func() ([]byte, error) { |
857 | | - return bytelimit.ReadFileLimited(path, maxBytes) |
| 870 | + return bytelimit.ReadFileLimited(osPath, maxBytes) |
858 | 871 | }, |
859 | 872 | }, true |
860 | 873 | } |
861 | 874 | } |
862 | 875 |
|
| 876 | + // In-memory / workspace-relative resolution (see targetExists): resolve |
| 877 | + // the link within the project-root FS so an up-and-over ".." target, |
| 878 | + // which io/fs rejects as a raw path, still reads on the WASM/LSP engines. |
| 879 | + if f.RootFS != nil && !filepath.IsAbs(f.Path) { |
| 880 | + if rel, ok := resolveWorkspaceRelTarget(f.Path, linkPath); ok { |
| 881 | + if _, err := fs.Stat(f.RootFS, rel); err == nil { |
| 882 | + rootFS := f.RootFS |
| 883 | + return targetFile{ |
| 884 | + cacheKey: "fs:" + rel, |
| 885 | + read: func() ([]byte, error) { |
| 886 | + return bytelimit.ReadFSFileLimited(rootFS, rel, maxBytes) |
| 887 | + }, |
| 888 | + }, true |
| 889 | + } |
| 890 | + } |
| 891 | + } |
| 892 | + |
863 | 893 | fsPath := filepath.ToSlash(linkPath) |
864 | 894 | fsPath = strings.TrimPrefix(fsPath, "./") |
865 | 895 | if fsPath == "" || strings.HasPrefix(fsPath, "/") { |
@@ -936,6 +966,26 @@ func resolveTargetOSPath(sourcePath, linkPath string) (string, bool) { |
936 | 966 | return filepath.Clean(filepath.Join(filepath.Dir(sourcePath), linkPath)), true |
937 | 967 | } |
938 | 968 |
|
| 969 | +// resolveWorkspaceRelTarget maps a workspace-relative source path and a |
| 970 | +// file-relative link to a slash path valid for fs.Stat against the |
| 971 | +// project-root FS (f.RootFS). It joins the link onto the source file's |
| 972 | +// directory and cleans ".." away — io/fs rejects any path containing |
| 973 | +// ".." — and returns ("", false) when the result escapes the workspace |
| 974 | +// root, is empty, or is absolute, none of which name a file inside the |
| 975 | +// in-memory workspace. |
| 976 | +func resolveWorkspaceRelTarget(sourcePath, linkPath string) (string, bool) { |
| 977 | + lp := filepath.ToSlash(linkPath) |
| 978 | + if lp == "" || strings.HasPrefix(lp, "/") { |
| 979 | + return "", false |
| 980 | + } |
| 981 | + dir := path.Dir(filepath.ToSlash(sourcePath)) |
| 982 | + rel := path.Clean(path.Join(dir, lp)) |
| 983 | + if rel == "." || rel == ".." || strings.HasPrefix(rel, "../") { |
| 984 | + return "", false |
| 985 | + } |
| 986 | + return rel, true |
| 987 | +} |
| 988 | + |
939 | 989 | func isMarkdownPath(path string) bool { |
940 | 990 | ext := strings.ToLower(filepath.Ext(path)) |
941 | 991 | return ext == ".md" || ext == ".markdown" |
|
0 commit comments