|
24 | 24 |
|
25 | 25 | LINUX_STAT_COMMAND = "stat -c 'user=%U group=%G mode=%A atime=%X mtime=%Y ctime=%Z size=%s %N'"
|
26 | 26 | BSD_STAT_COMMAND = "stat -f 'user=%Su group=%Sg mode=%Sp atime=%a mtime=%m ctime=%c size=%z %N%SY'"
|
| 27 | +LS_COMMAND = "ls -ld" |
27 | 28 |
|
28 | 29 | STAT_REGEX = (
|
29 | 30 | r"user=(.*) group=(.*) mode=(.*) "
|
30 | 31 | r"atime=([0-9]*) mtime=([0-9]*) ctime=([0-9]*) "
|
31 | 32 | r"size=([0-9]*) (.*)"
|
32 | 33 | )
|
33 | 34 |
|
| 35 | +# ls -ld output: permissions links user group size month day year/time path |
| 36 | +# Supports attribute markers: . (SELinux), @ (extended attrs), + (ACL) |
| 37 | +# Handles both "MMM DD" and "DD MMM" date formats |
| 38 | +LS_REGEX = ( |
| 39 | + r"^([dlbcsp-][-rwxstST]{9}[.@+]?)\s+\d+\s+(\S+)\s+(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.+)$" |
| 40 | +) |
| 41 | + |
34 | 42 | FLAG_TO_TYPE = {
|
35 | 43 | "b": "block",
|
36 | 44 | "c": "character",
|
@@ -82,6 +90,108 @@ def _parse_datetime(value: str) -> Optional[datetime]:
|
82 | 90 | return None
|
83 | 91 |
|
84 | 92 |
|
| 93 | +def _parse_ls_timestamp(month: str, day: str, year_or_time: str) -> Optional[datetime]: |
| 94 | + """ |
| 95 | + Parse ls timestamp format. |
| 96 | + Examples: "Jan 1 1970", "Apr 2 2025", "Dec 31 12:34" |
| 97 | + """ |
| 98 | + try: |
| 99 | + # Month abbreviation to number mapping |
| 100 | + month_map = { |
| 101 | + "Jan": 1, |
| 102 | + "Feb": 2, |
| 103 | + "Mar": 3, |
| 104 | + "Apr": 4, |
| 105 | + "May": 5, |
| 106 | + "Jun": 6, |
| 107 | + "Jul": 7, |
| 108 | + "Aug": 8, |
| 109 | + "Sep": 9, |
| 110 | + "Oct": 10, |
| 111 | + "Nov": 11, |
| 112 | + "Dec": 12, |
| 113 | + } |
| 114 | + |
| 115 | + month_num = month_map.get(month) |
| 116 | + if month_num is None: |
| 117 | + return None |
| 118 | + |
| 119 | + day_num = int(day) |
| 120 | + |
| 121 | + # Check if year_or_time is a year (4 digits) or time (HH:MM) |
| 122 | + if ":" in year_or_time: |
| 123 | + # It's a time, assume current year |
| 124 | + import time |
| 125 | + |
| 126 | + current_year = time.gmtime().tm_year |
| 127 | + hour, minute = map(int, year_or_time.split(":")) |
| 128 | + return datetime(current_year, month_num, day_num, hour, minute) |
| 129 | + else: |
| 130 | + # It's a year |
| 131 | + year_num = int(year_or_time) |
| 132 | + return datetime(year_num, month_num, day_num) |
| 133 | + |
| 134 | + except (ValueError, TypeError): |
| 135 | + return None |
| 136 | + |
| 137 | + |
| 138 | +def _parse_ls_output(output: str) -> Optional[tuple[FileDict, str]]: |
| 139 | + """ |
| 140 | + Parse ls -ld output and extract file information. |
| 141 | + Example: drwxr-xr-x 1 root root 416 Jan 1 1970 / |
| 142 | + """ |
| 143 | + match = re.match(LS_REGEX, output.strip()) |
| 144 | + if not match: |
| 145 | + return None |
| 146 | + |
| 147 | + permissions = match.group(1) |
| 148 | + user = match.group(2) |
| 149 | + group = match.group(3) |
| 150 | + size = match.group(4) |
| 151 | + date_part1 = match.group(5) |
| 152 | + date_part2 = match.group(6) |
| 153 | + year_or_time = match.group(7) |
| 154 | + path = match.group(8) |
| 155 | + |
| 156 | + # Determine if it's "MMM DD" or "DD MMM" format |
| 157 | + if date_part1.isdigit(): |
| 158 | + # "DD MMM" format (e.g., "22 Jun") |
| 159 | + day = date_part1 |
| 160 | + month = date_part2 |
| 161 | + else: |
| 162 | + # "MMM DD" format (e.g., "Jun 22") |
| 163 | + month = date_part1 |
| 164 | + day = date_part2 |
| 165 | + |
| 166 | + # Extract file type from first character of permissions |
| 167 | + path_type = FLAG_TO_TYPE[permissions[0]] |
| 168 | + |
| 169 | + # Parse mode (skip first character which is file type, and any trailing attribute markers) |
| 170 | + # Remove trailing attribute markers (.@+) if present |
| 171 | + mode_str = permissions[1:10] # Take exactly 9 characters after file type |
| 172 | + mode = _parse_mode(mode_str) |
| 173 | + |
| 174 | + # Parse timestamp - ls shows modification time |
| 175 | + mtime = _parse_ls_timestamp(month, day, year_or_time) |
| 176 | + |
| 177 | + data: FileDict = { |
| 178 | + "user": user, |
| 179 | + "group": group, |
| 180 | + "mode": mode, |
| 181 | + "atime": None, # ls doesn't provide atime |
| 182 | + "mtime": mtime, |
| 183 | + "ctime": None, # ls doesn't provide ctime |
| 184 | + "size": try_int(size), |
| 185 | + } |
| 186 | + |
| 187 | + # Handle symbolic links |
| 188 | + if path_type == "link" and " -> " in path: |
| 189 | + filename, target = path.split(" -> ", 1) |
| 190 | + data["link_target"] = target.strip("'").lstrip("`") |
| 191 | + |
| 192 | + return data, path_type |
| 193 | + |
| 194 | + |
85 | 195 | class FileDict(TypedDict):
|
86 | 196 | mode: int
|
87 | 197 | size: Union[int, str]
|
@@ -127,41 +237,54 @@ def command(self, path):
|
127 | 237 | (
|
128 | 238 | # only stat if the path exists (file or symlink)
|
129 | 239 | "! (test -e {0} || test -L {0} ) || "
|
130 |
| - "( {linux_stat_command} {0} 2> /dev/null || {bsd_stat_command} {0} )" |
| 240 | + "( {linux_stat_command} {0} 2> /dev/null || " |
| 241 | + "{bsd_stat_command} {0} || {ls_command} {0} )" |
131 | 242 | ),
|
132 | 243 | path,
|
133 | 244 | linux_stat_command=LINUX_STAT_COMMAND,
|
134 | 245 | bsd_stat_command=BSD_STAT_COMMAND,
|
| 246 | + ls_command=LS_COMMAND, |
135 | 247 | )
|
136 | 248 |
|
137 | 249 | @override
|
138 | 250 | def process(self, output) -> Union[FileDict, Literal[False], None]:
|
| 251 | + # Try to parse as stat output first |
139 | 252 | match = re.match(STAT_REGEX, output[0])
|
140 |
| - if not match: |
141 |
| - return None |
| 253 | + if match: |
| 254 | + mode = match.group(3) |
| 255 | + path_type = FLAG_TO_TYPE[mode[0]] |
142 | 256 |
|
143 |
| - mode = match.group(3) |
144 |
| - path_type = FLAG_TO_TYPE[mode[0]] |
145 |
| - |
146 |
| - data: FileDict = { |
147 |
| - "user": match.group(1), |
148 |
| - "group": match.group(2), |
149 |
| - "mode": _parse_mode(mode[1:]), |
150 |
| - "atime": _parse_datetime(match.group(4)), |
151 |
| - "mtime": _parse_datetime(match.group(5)), |
152 |
| - "ctime": _parse_datetime(match.group(6)), |
153 |
| - "size": try_int(match.group(7)), |
154 |
| - } |
| 257 | + data: FileDict = { |
| 258 | + "user": match.group(1), |
| 259 | + "group": match.group(2), |
| 260 | + "mode": _parse_mode(mode[1:]), |
| 261 | + "atime": _parse_datetime(match.group(4)), |
| 262 | + "mtime": _parse_datetime(match.group(5)), |
| 263 | + "ctime": _parse_datetime(match.group(6)), |
| 264 | + "size": try_int(match.group(7)), |
| 265 | + } |
| 266 | + |
| 267 | + if path_type != self.type: |
| 268 | + return False |
| 269 | + |
| 270 | + if path_type == "link": |
| 271 | + filename = match.group(8) |
| 272 | + filename, target = filename.split(" -> ") |
| 273 | + data["link_target"] = target.strip("'").lstrip("`") |
155 | 274 |
|
156 |
| - if path_type != self.type: |
157 |
| - return False |
| 275 | + return data |
158 | 276 |
|
159 |
| - if path_type == "link": |
160 |
| - filename = match.group(8) |
161 |
| - filename, target = filename.split(" -> ") |
162 |
| - data["link_target"] = target.strip("'").lstrip("`") |
| 277 | + # Try to parse as ls output |
| 278 | + ls_result = _parse_ls_output(output[0]) |
| 279 | + if ls_result is not None: |
| 280 | + data, path_type = ls_result |
163 | 281 |
|
164 |
| - return data |
| 282 | + if path_type != self.type: |
| 283 | + return False |
| 284 | + |
| 285 | + return data |
| 286 | + |
| 287 | + return None |
165 | 288 |
|
166 | 289 |
|
167 | 290 | class Link(File):
|
|
0 commit comments