Coverage for lintro / utils / path_utils.py: 91%

76 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-04-03 18:53 +0000

1"""Path utilities for Lintro. 

2 

3Small helpers to normalize paths for display consistency and path safety validation. 

4""" 

5 

6from pathlib import Path 

7 

8from loguru import logger 

9 

10 

11def validate_safe_path(path: str | Path, base_dir: Path | None = None) -> bool: 

12 """Validate that a path doesn't escape the project boundaries. 

13 

14 This function prevents path traversal attacks by ensuring the resolved path 

15 stays within the specified base directory (or current working directory). 

16 

17 Args: 

18 path: The path to validate (can be absolute or relative). 

19 base_dir: The base directory that paths must stay within. 

20 Defaults to current working directory if not specified. 

21 

22 Returns: 

23 True if the path is safe (within boundaries), False otherwise. 

24 

25 Examples: 

26 >>> validate_safe_path("./src/file.py") # Safe relative path 

27 True 

28 >>> validate_safe_path("../../../etc/passwd") # Escapes project 

29 False 

30 >>> validate_safe_path("/absolute/path/outside") # Outside project 

31 False 

32 """ 

33 try: 

34 base = (base_dir or Path.cwd()).resolve() 

35 resolved = Path(path).resolve() 

36 

37 # Check if resolved path is within base directory 

38 resolved.relative_to(base) 

39 return True 

40 except ValueError: 

41 # Path escapes the base directory 

42 return False 

43 except OSError: 

44 # Invalid path (e.g., too long, invalid characters on some systems) 

45 return False 

46 

47 

48def find_lintro_ignore() -> Path | None: 

49 """Find .lintro-ignore file by searching upward from current directory. 

50 

51 Searches upward from the current working directory to find the project root 

52 by looking for .lintro-ignore or pyproject.toml files. 

53 

54 Returns: 

55 Path | None: Path to .lintro-ignore file if found, None otherwise. 

56 """ 

57 current_dir = Path.cwd() 

58 # Limit search to prevent infinite loops (e.g., if we're in /) 

59 max_depth = 20 

60 depth = 0 

61 

62 while depth < max_depth: 

63 lintro_ignore_path = current_dir / ".lintro-ignore" 

64 if lintro_ignore_path.exists(): 

65 return lintro_ignore_path 

66 

67 # Also check for pyproject.toml as project root indicator 

68 pyproject_path = current_dir / "pyproject.toml" 

69 if pyproject_path.exists(): 

70 # If pyproject.toml exists, check for .lintro-ignore in same directory 

71 lintro_ignore_path = current_dir / ".lintro-ignore" 

72 if lintro_ignore_path.exists(): 

73 return lintro_ignore_path 

74 # Even if .lintro-ignore doesn't exist, we found project root 

75 # Return None to indicate no .lintro-ignore found 

76 return None 

77 

78 # Move up one directory 

79 parent_dir = current_dir.parent 

80 if parent_dir == current_dir: 

81 # Reached filesystem root 

82 break 

83 current_dir = parent_dir 

84 depth += 1 

85 

86 return None 

87 

88 

89def load_lintro_ignore() -> list[str]: 

90 """Load ignore patterns from .lintro-ignore file. 

91 

92 Returns: 

93 list[str]: List of ignore patterns. 

94 """ 

95 ignore_patterns: list[str] = [] 

96 lintro_ignore_path = find_lintro_ignore() 

97 

98 if lintro_ignore_path and lintro_ignore_path.exists(): 

99 try: 

100 with open(lintro_ignore_path, encoding="utf-8") as f: 

101 for line in f: 

102 line_stripped = line.strip() 

103 if not line_stripped or line_stripped.startswith("#"): 

104 continue 

105 ignore_patterns.append(line_stripped) 

106 except (OSError, UnicodeDecodeError) as e: 

107 logger.warning(f"Failed to load .lintro-ignore: {e}") 

108 

109 return ignore_patterns 

110 

111 

112def normalize_file_path_for_display(file_path: str) -> str: 

113 """Normalize file path to be relative to project root for consistent display. 

114 

115 This ensures all tools show file paths in the same format: 

116 - Relative to project root (like ./src/file.py) 

117 - Consistent across all tools regardless of how they output paths 

118 

119 Args: 

120 file_path: File path (can be absolute or relative). If empty, returns as is. 

121 

122 Returns: 

123 Normalized relative path from project root (e.g., "./src/file.py") 

124 """ 

125 # Fast-path: empty or whitespace-only input 

126 if not file_path or not str(file_path).strip(): 

127 return file_path 

128 

129 try: 

130 project_root = Path.cwd().resolve() 

131 abs_path = Path(file_path).resolve() 

132 

133 # Attempt to make path relative to project root 

134 try: 

135 rel_path = abs_path.relative_to(project_root) 

136 rel_path_str = str(rel_path) 

137 

138 # Ensure it starts with "./" for consistency 

139 if not rel_path_str.startswith("./"): 

140 rel_path_str = "./" + rel_path_str 

141 

142 return rel_path_str 

143 

144 except ValueError: 

145 # Path is outside project root - log warning and return with ../ 

146 logger.debug(f"Path '{file_path}' is outside project root") 

147 # Use the original behavior for paths outside project 

148 # Calculate relative path that may include ../ 

149 try: 

150 # Find common ancestor and build relative path 

151 rel_parts: list[str] = [] 

152 # Walk up from project_root to find common ancestor 

153 project_parts = project_root.parts 

154 path_parts = abs_path.parts 

155 

156 # Find common prefix length 

157 common_len = 0 

158 for p1, p2 in zip(project_parts, path_parts, strict=False): 

159 if p1 == p2: 

160 common_len += 1 

161 else: 

162 break 

163 

164 # Build relative path 

165 ups = len(project_parts) - common_len 

166 rel_parts = [".."] * ups + list(path_parts[common_len:]) 

167 return "/".join(rel_parts) if rel_parts else "." 

168 

169 except (ValueError, IndexError): 

170 return file_path 

171 

172 except (OSError, ValueError): 

173 # If path normalization fails, return the original path 

174 return file_path