just-bash 0.1.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. just_bash/__init__.py +55 -0
  2. just_bash/ast/__init__.py +213 -0
  3. just_bash/ast/factory.py +320 -0
  4. just_bash/ast/types.py +953 -0
  5. just_bash/bash.py +220 -0
  6. just_bash/commands/__init__.py +23 -0
  7. just_bash/commands/argv/__init__.py +5 -0
  8. just_bash/commands/argv/argv.py +21 -0
  9. just_bash/commands/awk/__init__.py +5 -0
  10. just_bash/commands/awk/awk.py +1168 -0
  11. just_bash/commands/base64/__init__.py +5 -0
  12. just_bash/commands/base64/base64.py +138 -0
  13. just_bash/commands/basename/__init__.py +5 -0
  14. just_bash/commands/basename/basename.py +72 -0
  15. just_bash/commands/bash/__init__.py +5 -0
  16. just_bash/commands/bash/bash.py +188 -0
  17. just_bash/commands/cat/__init__.py +5 -0
  18. just_bash/commands/cat/cat.py +173 -0
  19. just_bash/commands/checksum/__init__.py +5 -0
  20. just_bash/commands/checksum/checksum.py +179 -0
  21. just_bash/commands/chmod/__init__.py +5 -0
  22. just_bash/commands/chmod/chmod.py +216 -0
  23. just_bash/commands/column/__init__.py +5 -0
  24. just_bash/commands/column/column.py +180 -0
  25. just_bash/commands/comm/__init__.py +5 -0
  26. just_bash/commands/comm/comm.py +150 -0
  27. just_bash/commands/compression/__init__.py +5 -0
  28. just_bash/commands/compression/compression.py +298 -0
  29. just_bash/commands/cp/__init__.py +5 -0
  30. just_bash/commands/cp/cp.py +149 -0
  31. just_bash/commands/curl/__init__.py +5 -0
  32. just_bash/commands/curl/curl.py +801 -0
  33. just_bash/commands/cut/__init__.py +5 -0
  34. just_bash/commands/cut/cut.py +327 -0
  35. just_bash/commands/date/__init__.py +5 -0
  36. just_bash/commands/date/date.py +258 -0
  37. just_bash/commands/diff/__init__.py +5 -0
  38. just_bash/commands/diff/diff.py +118 -0
  39. just_bash/commands/dirname/__init__.py +5 -0
  40. just_bash/commands/dirname/dirname.py +56 -0
  41. just_bash/commands/du/__init__.py +5 -0
  42. just_bash/commands/du/du.py +150 -0
  43. just_bash/commands/echo/__init__.py +5 -0
  44. just_bash/commands/echo/echo.py +125 -0
  45. just_bash/commands/env/__init__.py +5 -0
  46. just_bash/commands/env/env.py +163 -0
  47. just_bash/commands/expand/__init__.py +5 -0
  48. just_bash/commands/expand/expand.py +299 -0
  49. just_bash/commands/expr/__init__.py +5 -0
  50. just_bash/commands/expr/expr.py +273 -0
  51. just_bash/commands/file/__init__.py +5 -0
  52. just_bash/commands/file/file.py +274 -0
  53. just_bash/commands/find/__init__.py +5 -0
  54. just_bash/commands/find/find.py +623 -0
  55. just_bash/commands/fold/__init__.py +5 -0
  56. just_bash/commands/fold/fold.py +160 -0
  57. just_bash/commands/grep/__init__.py +5 -0
  58. just_bash/commands/grep/grep.py +418 -0
  59. just_bash/commands/head/__init__.py +5 -0
  60. just_bash/commands/head/head.py +167 -0
  61. just_bash/commands/help/__init__.py +5 -0
  62. just_bash/commands/help/help.py +67 -0
  63. just_bash/commands/hostname/__init__.py +5 -0
  64. just_bash/commands/hostname/hostname.py +21 -0
  65. just_bash/commands/html_to_markdown/__init__.py +5 -0
  66. just_bash/commands/html_to_markdown/html_to_markdown.py +191 -0
  67. just_bash/commands/join/__init__.py +5 -0
  68. just_bash/commands/join/join.py +252 -0
  69. just_bash/commands/jq/__init__.py +5 -0
  70. just_bash/commands/jq/jq.py +280 -0
  71. just_bash/commands/ln/__init__.py +5 -0
  72. just_bash/commands/ln/ln.py +127 -0
  73. just_bash/commands/ls/__init__.py +5 -0
  74. just_bash/commands/ls/ls.py +280 -0
  75. just_bash/commands/mkdir/__init__.py +5 -0
  76. just_bash/commands/mkdir/mkdir.py +92 -0
  77. just_bash/commands/mv/__init__.py +5 -0
  78. just_bash/commands/mv/mv.py +142 -0
  79. just_bash/commands/nl/__init__.py +5 -0
  80. just_bash/commands/nl/nl.py +180 -0
  81. just_bash/commands/od/__init__.py +5 -0
  82. just_bash/commands/od/od.py +157 -0
  83. just_bash/commands/paste/__init__.py +5 -0
  84. just_bash/commands/paste/paste.py +100 -0
  85. just_bash/commands/printf/__init__.py +5 -0
  86. just_bash/commands/printf/printf.py +157 -0
  87. just_bash/commands/pwd/__init__.py +5 -0
  88. just_bash/commands/pwd/pwd.py +23 -0
  89. just_bash/commands/read/__init__.py +5 -0
  90. just_bash/commands/read/read.py +185 -0
  91. just_bash/commands/readlink/__init__.py +5 -0
  92. just_bash/commands/readlink/readlink.py +86 -0
  93. just_bash/commands/registry.py +844 -0
  94. just_bash/commands/rev/__init__.py +5 -0
  95. just_bash/commands/rev/rev.py +74 -0
  96. just_bash/commands/rg/__init__.py +5 -0
  97. just_bash/commands/rg/rg.py +1048 -0
  98. just_bash/commands/rm/__init__.py +5 -0
  99. just_bash/commands/rm/rm.py +106 -0
  100. just_bash/commands/search_engine/__init__.py +13 -0
  101. just_bash/commands/search_engine/matcher.py +170 -0
  102. just_bash/commands/search_engine/regex.py +159 -0
  103. just_bash/commands/sed/__init__.py +5 -0
  104. just_bash/commands/sed/sed.py +863 -0
  105. just_bash/commands/seq/__init__.py +5 -0
  106. just_bash/commands/seq/seq.py +190 -0
  107. just_bash/commands/shell/__init__.py +5 -0
  108. just_bash/commands/shell/shell.py +206 -0
  109. just_bash/commands/sleep/__init__.py +5 -0
  110. just_bash/commands/sleep/sleep.py +62 -0
  111. just_bash/commands/sort/__init__.py +5 -0
  112. just_bash/commands/sort/sort.py +411 -0
  113. just_bash/commands/split/__init__.py +5 -0
  114. just_bash/commands/split/split.py +237 -0
  115. just_bash/commands/sqlite3/__init__.py +5 -0
  116. just_bash/commands/sqlite3/sqlite3_cmd.py +505 -0
  117. just_bash/commands/stat/__init__.py +5 -0
  118. just_bash/commands/stat/stat.py +150 -0
  119. just_bash/commands/strings/__init__.py +5 -0
  120. just_bash/commands/strings/strings.py +150 -0
  121. just_bash/commands/tac/__init__.py +5 -0
  122. just_bash/commands/tac/tac.py +158 -0
  123. just_bash/commands/tail/__init__.py +5 -0
  124. just_bash/commands/tail/tail.py +180 -0
  125. just_bash/commands/tar/__init__.py +5 -0
  126. just_bash/commands/tar/tar.py +1067 -0
  127. just_bash/commands/tee/__init__.py +5 -0
  128. just_bash/commands/tee/tee.py +63 -0
  129. just_bash/commands/timeout/__init__.py +5 -0
  130. just_bash/commands/timeout/timeout.py +188 -0
  131. just_bash/commands/touch/__init__.py +5 -0
  132. just_bash/commands/touch/touch.py +91 -0
  133. just_bash/commands/tr/__init__.py +5 -0
  134. just_bash/commands/tr/tr.py +297 -0
  135. just_bash/commands/tree/__init__.py +5 -0
  136. just_bash/commands/tree/tree.py +139 -0
  137. just_bash/commands/true/__init__.py +5 -0
  138. just_bash/commands/true/true.py +32 -0
  139. just_bash/commands/uniq/__init__.py +5 -0
  140. just_bash/commands/uniq/uniq.py +323 -0
  141. just_bash/commands/wc/__init__.py +5 -0
  142. just_bash/commands/wc/wc.py +169 -0
  143. just_bash/commands/which/__init__.py +5 -0
  144. just_bash/commands/which/which.py +52 -0
  145. just_bash/commands/xan/__init__.py +5 -0
  146. just_bash/commands/xan/xan.py +1663 -0
  147. just_bash/commands/xargs/__init__.py +5 -0
  148. just_bash/commands/xargs/xargs.py +136 -0
  149. just_bash/commands/yq/__init__.py +5 -0
  150. just_bash/commands/yq/yq.py +848 -0
  151. just_bash/fs/__init__.py +29 -0
  152. just_bash/fs/in_memory_fs.py +621 -0
  153. just_bash/fs/mountable_fs.py +504 -0
  154. just_bash/fs/overlay_fs.py +894 -0
  155. just_bash/fs/read_write_fs.py +455 -0
  156. just_bash/interpreter/__init__.py +37 -0
  157. just_bash/interpreter/builtins/__init__.py +92 -0
  158. just_bash/interpreter/builtins/alias.py +154 -0
  159. just_bash/interpreter/builtins/cd.py +76 -0
  160. just_bash/interpreter/builtins/control.py +127 -0
  161. just_bash/interpreter/builtins/declare.py +336 -0
  162. just_bash/interpreter/builtins/export.py +56 -0
  163. just_bash/interpreter/builtins/let.py +44 -0
  164. just_bash/interpreter/builtins/local.py +57 -0
  165. just_bash/interpreter/builtins/mapfile.py +152 -0
  166. just_bash/interpreter/builtins/misc.py +378 -0
  167. just_bash/interpreter/builtins/readonly.py +80 -0
  168. just_bash/interpreter/builtins/set.py +234 -0
  169. just_bash/interpreter/builtins/shopt.py +201 -0
  170. just_bash/interpreter/builtins/source.py +136 -0
  171. just_bash/interpreter/builtins/test.py +290 -0
  172. just_bash/interpreter/builtins/unset.py +53 -0
  173. just_bash/interpreter/conditionals.py +387 -0
  174. just_bash/interpreter/control_flow.py +381 -0
  175. just_bash/interpreter/errors.py +116 -0
  176. just_bash/interpreter/expansion.py +1156 -0
  177. just_bash/interpreter/interpreter.py +813 -0
  178. just_bash/interpreter/types.py +134 -0
  179. just_bash/network/__init__.py +1 -0
  180. just_bash/parser/__init__.py +39 -0
  181. just_bash/parser/lexer.py +948 -0
  182. just_bash/parser/parser.py +2162 -0
  183. just_bash/py.typed +0 -0
  184. just_bash/query_engine/__init__.py +83 -0
  185. just_bash/query_engine/builtins/__init__.py +1283 -0
  186. just_bash/query_engine/evaluator.py +578 -0
  187. just_bash/query_engine/parser.py +525 -0
  188. just_bash/query_engine/tokenizer.py +329 -0
  189. just_bash/query_engine/types.py +373 -0
  190. just_bash/types.py +180 -0
  191. just_bash-0.1.5.dist-info/METADATA +410 -0
  192. just_bash-0.1.5.dist-info/RECORD +193 -0
  193. just_bash-0.1.5.dist-info/WHEEL +4 -0
@@ -0,0 +1,1067 @@
1
+ """Tar command implementation."""
2
+
3
+ import io
4
+ import tarfile
5
+ import gzip
6
+ import bz2
7
+ import lzma
8
+ from datetime import datetime
9
+ from fnmatch import fnmatch
10
+
11
+ from ...types import CommandContext, ExecResult
12
+
13
+
14
+ class TarCommand:
15
+ """The tar command - manipulate tape archives."""
16
+
17
+ name = "tar"
18
+
19
+ async def execute(self, args: list[str], ctx: CommandContext) -> ExecResult:
20
+ """Execute the tar command."""
21
+ if "--help" in args:
22
+ return ExecResult(
23
+ stdout=(
24
+ "Usage: tar [options] [file...]\n"
25
+ "Create, extract, or list contents of tar archives.\n\n"
26
+ "Options:\n"
27
+ " -c, --create create a new archive\n"
28
+ " -x, --extract extract files from an archive\n"
29
+ " -t, --list list contents of an archive\n"
30
+ " -f FILE use archive file FILE\n"
31
+ " -z, --gzip filter archive through gzip\n"
32
+ " -v, --verbose verbosely list files processed\n"
33
+ " -C DIR change to directory DIR\n"
34
+ " --help display this help\n"
35
+ ),
36
+ stderr="",
37
+ exit_code=0,
38
+ )
39
+
40
+ # Parse options
41
+ create = False
42
+ extract = False
43
+ list_mode = False
44
+ append_mode = False
45
+ update_mode = False
46
+ archive_file = ""
47
+ use_gzip = False
48
+ use_bzip2 = False
49
+ use_xz = False
50
+ auto_compress = False
51
+ verbose = False
52
+ directory = ""
53
+ exclude_patterns: list[str] = []
54
+ strip_components = 0
55
+ to_stdout = False
56
+ keep_old_files = False
57
+ no_mtime = False
58
+ preserve_permissions = False
59
+ files_from: str = ""
60
+ exclude_from: str = ""
61
+ files: list[str] = []
62
+
63
+ i = 0
64
+ while i < len(args):
65
+ arg = args[i]
66
+
67
+ # Handle combined short options (e.g., -cvzf)
68
+ if arg.startswith("-") and not arg.startswith("--") and len(arg) > 2:
69
+ j = 1
70
+ while j < len(arg):
71
+ char = arg[j]
72
+ if char == "c":
73
+ create = True
74
+ elif char == "x":
75
+ extract = True
76
+ elif char == "t":
77
+ list_mode = True
78
+ elif char == "z":
79
+ use_gzip = True
80
+ elif char == "j":
81
+ use_bzip2 = True
82
+ elif char == "J":
83
+ use_xz = True
84
+ elif char == "a":
85
+ auto_compress = True
86
+ elif char == "v":
87
+ verbose = True
88
+ elif char == "O":
89
+ to_stdout = True
90
+ elif char == "k":
91
+ keep_old_files = True
92
+ elif char == "m":
93
+ no_mtime = True
94
+ elif char == "p":
95
+ preserve_permissions = True
96
+ elif char == "r":
97
+ append_mode = True
98
+ elif char == "u":
99
+ update_mode = True
100
+ elif char == "f":
101
+ # -f requires a value
102
+ if j < len(arg) - 1:
103
+ archive_file = arg[j + 1:]
104
+ j = len(arg)
105
+ else:
106
+ i += 1
107
+ if i >= len(args):
108
+ return ExecResult(
109
+ stdout="",
110
+ stderr="tar: option requires an argument -- 'f'\n",
111
+ exit_code=2,
112
+ )
113
+ archive_file = args[i]
114
+ elif char == "C":
115
+ if j < len(arg) - 1:
116
+ directory = arg[j + 1:]
117
+ j = len(arg)
118
+ else:
119
+ i += 1
120
+ if i >= len(args):
121
+ return ExecResult(
122
+ stdout="",
123
+ stderr="tar: option requires an argument -- 'C'\n",
124
+ exit_code=2,
125
+ )
126
+ directory = args[i]
127
+ elif char == "T":
128
+ # -T requires a value
129
+ if j < len(arg) - 1:
130
+ files_from = arg[j + 1:]
131
+ j = len(arg)
132
+ else:
133
+ i += 1
134
+ if i >= len(args):
135
+ return ExecResult(
136
+ stdout="",
137
+ stderr="tar: option requires an argument -- 'T'\n",
138
+ exit_code=2,
139
+ )
140
+ files_from = args[i]
141
+ elif char == "X":
142
+ # -X requires a value
143
+ if j < len(arg) - 1:
144
+ exclude_from = arg[j + 1:]
145
+ j = len(arg)
146
+ else:
147
+ i += 1
148
+ if i >= len(args):
149
+ return ExecResult(
150
+ stdout="",
151
+ stderr="tar: option requires an argument -- 'X'\n",
152
+ exit_code=2,
153
+ )
154
+ exclude_from = args[i]
155
+ else:
156
+ return ExecResult(
157
+ stdout="",
158
+ stderr=f"tar: invalid option -- '{char}'\n",
159
+ exit_code=2,
160
+ )
161
+ j += 1
162
+ i += 1
163
+ continue
164
+
165
+ # Long options and single short options
166
+ if arg in ("-c", "--create"):
167
+ create = True
168
+ elif arg in ("-x", "--extract", "--get"):
169
+ extract = True
170
+ elif arg in ("-t", "--list"):
171
+ list_mode = True
172
+ elif arg in ("-z", "--gzip", "--gunzip"):
173
+ use_gzip = True
174
+ elif arg in ("-j", "--bzip2"):
175
+ use_bzip2 = True
176
+ elif arg in ("-J", "--xz"):
177
+ use_xz = True
178
+ elif arg in ("-a", "--auto-compress"):
179
+ auto_compress = True
180
+ elif arg in ("-v", "--verbose"):
181
+ verbose = True
182
+ elif arg in ("-O", "--to-stdout"):
183
+ to_stdout = True
184
+ elif arg in ("-k", "--keep-old-files"):
185
+ keep_old_files = True
186
+ elif arg in ("-m", "--touch"):
187
+ no_mtime = True
188
+ elif arg in ("-p", "--preserve-permissions", "--same-permissions"):
189
+ preserve_permissions = True
190
+ elif arg in ("-r", "--append"):
191
+ append_mode = True
192
+ elif arg in ("-u", "--update"):
193
+ update_mode = True
194
+ elif arg == "-f" or arg == "--file":
195
+ i += 1
196
+ if i >= len(args):
197
+ return ExecResult(
198
+ stdout="",
199
+ stderr="tar: option requires an argument -- 'f'\n",
200
+ exit_code=2,
201
+ )
202
+ archive_file = args[i]
203
+ elif arg.startswith("--file="):
204
+ archive_file = arg[7:]
205
+ elif arg in ("-C", "--directory"):
206
+ i += 1
207
+ if i >= len(args):
208
+ return ExecResult(
209
+ stdout="",
210
+ stderr="tar: option requires an argument -- 'C'\n",
211
+ exit_code=2,
212
+ )
213
+ directory = args[i]
214
+ elif arg.startswith("--directory="):
215
+ directory = arg[12:]
216
+ elif arg.startswith("--exclude="):
217
+ exclude_patterns.append(arg[10:])
218
+ elif arg == "--exclude":
219
+ i += 1
220
+ if i >= len(args):
221
+ return ExecResult(
222
+ stdout="",
223
+ stderr="tar: option requires an argument -- 'exclude'\n",
224
+ exit_code=2,
225
+ )
226
+ exclude_patterns.append(args[i])
227
+ elif arg.startswith("--strip-components="):
228
+ try:
229
+ strip_components = int(arg[19:])
230
+ except ValueError:
231
+ return ExecResult(
232
+ stdout="",
233
+ stderr=f"tar: {arg}: invalid argument\n",
234
+ exit_code=2,
235
+ )
236
+ elif arg == "--strip-components":
237
+ i += 1
238
+ if i >= len(args):
239
+ return ExecResult(
240
+ stdout="",
241
+ stderr="tar: option requires an argument -- 'strip-components'\n",
242
+ exit_code=2,
243
+ )
244
+ try:
245
+ strip_components = int(args[i])
246
+ except ValueError:
247
+ return ExecResult(
248
+ stdout="",
249
+ stderr=f"tar: {args[i]}: invalid argument\n",
250
+ exit_code=2,
251
+ )
252
+ elif arg == "-T" or arg == "--files-from":
253
+ i += 1
254
+ if i >= len(args):
255
+ return ExecResult(
256
+ stdout="",
257
+ stderr="tar: option requires an argument -- 'T'\n",
258
+ exit_code=2,
259
+ )
260
+ files_from = args[i]
261
+ elif arg.startswith("--files-from="):
262
+ files_from = arg[13:]
263
+ elif arg == "-X" or arg == "--exclude-from":
264
+ i += 1
265
+ if i >= len(args):
266
+ return ExecResult(
267
+ stdout="",
268
+ stderr="tar: option requires an argument -- 'X'\n",
269
+ exit_code=2,
270
+ )
271
+ exclude_from = args[i]
272
+ elif arg.startswith("--exclude-from="):
273
+ exclude_from = arg[15:]
274
+ elif arg == "--":
275
+ files.extend(args[i + 1:])
276
+ break
277
+ elif arg.startswith("-"):
278
+ return ExecResult(
279
+ stdout="",
280
+ stderr=f"tar: invalid option -- '{arg}'\n",
281
+ exit_code=2,
282
+ )
283
+ else:
284
+ files.append(arg)
285
+ i += 1
286
+
287
+ # Validate operation mode
288
+ op_count = sum([create, extract, list_mode, append_mode, update_mode])
289
+ if op_count == 0:
290
+ return ExecResult(
291
+ stdout="",
292
+ stderr="tar: You must specify one of -c, -x, -t, -r, or -u\n",
293
+ exit_code=2,
294
+ )
295
+ if op_count > 1:
296
+ return ExecResult(
297
+ stdout="",
298
+ stderr="tar: You may not specify more than one of -c, -x, -t, -r, -u\n",
299
+ exit_code=2,
300
+ )
301
+
302
+ # Determine work directory
303
+ work_dir = ctx.fs.resolve_path(ctx.cwd, directory) if directory else ctx.cwd
304
+
305
+ # Read files-from if specified
306
+ if files_from:
307
+ files_from_path = ctx.fs.resolve_path(ctx.cwd, files_from)
308
+ try:
309
+ content = await ctx.fs.read_file(files_from_path)
310
+ for line in content.strip().split("\n"):
311
+ line = line.strip()
312
+ if line:
313
+ files.append(line)
314
+ except FileNotFoundError:
315
+ return ExecResult(
316
+ stdout="",
317
+ stderr=f"tar: {files_from}: Cannot open: No such file or directory\n",
318
+ exit_code=2,
319
+ )
320
+
321
+ # Read exclude-from if specified
322
+ if exclude_from:
323
+ exclude_from_path = ctx.fs.resolve_path(ctx.cwd, exclude_from)
324
+ try:
325
+ content = await ctx.fs.read_file(exclude_from_path)
326
+ for line in content.strip().split("\n"):
327
+ line = line.strip()
328
+ if line:
329
+ exclude_patterns.append(line)
330
+ except FileNotFoundError:
331
+ return ExecResult(
332
+ stdout="",
333
+ stderr=f"tar: {exclude_from}: Cannot open: No such file or directory\n",
334
+ exit_code=2,
335
+ )
336
+
337
+ if create:
338
+ return await self._create_archive(
339
+ ctx, archive_file, files, work_dir,
340
+ use_gzip=use_gzip, use_bzip2=use_bzip2, use_xz=use_xz,
341
+ auto_compress=auto_compress, verbose=verbose,
342
+ exclude_patterns=exclude_patterns
343
+ )
344
+ elif append_mode:
345
+ return await self._append_archive(
346
+ ctx, archive_file, files, work_dir,
347
+ verbose=verbose, exclude_patterns=exclude_patterns
348
+ )
349
+ elif update_mode:
350
+ return await self._update_archive(
351
+ ctx, archive_file, files, work_dir,
352
+ verbose=verbose, exclude_patterns=exclude_patterns
353
+ )
354
+ elif extract:
355
+ return await self._extract_archive(
356
+ ctx, archive_file, files, work_dir,
357
+ use_gzip=use_gzip, use_bzip2=use_bzip2, use_xz=use_xz,
358
+ verbose=verbose, strip_components=strip_components,
359
+ to_stdout=to_stdout, keep_old_files=keep_old_files,
360
+ no_mtime=no_mtime, preserve_permissions=preserve_permissions
361
+ )
362
+ else: # list_mode
363
+ return await self._list_archive(
364
+ ctx, archive_file, files,
365
+ use_gzip=use_gzip, use_bzip2=use_bzip2, use_xz=use_xz,
366
+ verbose=verbose
367
+ )
368
+
369
+ def _detect_compression_from_filename(self, filename: str) -> str | None:
370
+ """Detect compression type from filename extension."""
371
+ if filename.endswith(".tar.gz") or filename.endswith(".tgz"):
372
+ return "gz"
373
+ elif filename.endswith(".tar.bz2") or filename.endswith(".tbz2"):
374
+ return "bz2"
375
+ elif filename.endswith(".tar.xz") or filename.endswith(".txz"):
376
+ return "xz"
377
+ elif filename.endswith(".tar"):
378
+ return None
379
+ return None
380
+
381
+ async def _create_archive(
382
+ self,
383
+ ctx: CommandContext,
384
+ archive_file: str,
385
+ files: list[str],
386
+ work_dir: str,
387
+ use_gzip: bool = False,
388
+ use_bzip2: bool = False,
389
+ use_xz: bool = False,
390
+ auto_compress: bool = False,
391
+ verbose: bool = False,
392
+ exclude_patterns: list[str] | None = None,
393
+ ) -> ExecResult:
394
+ """Create a tar archive."""
395
+ if exclude_patterns is None:
396
+ exclude_patterns = []
397
+
398
+ if not files:
399
+ return ExecResult(
400
+ stdout="",
401
+ stderr="tar: Cowardly refusing to create an empty archive\n",
402
+ exit_code=2,
403
+ )
404
+
405
+ # Handle auto-compress: detect compression from filename
406
+ if auto_compress and archive_file and archive_file != "-":
407
+ detected = self._detect_compression_from_filename(archive_file)
408
+ if detected == "gz":
409
+ use_gzip = True
410
+ elif detected == "bz2":
411
+ use_bzip2 = True
412
+ elif detected == "xz":
413
+ use_xz = True
414
+
415
+ # Create archive in memory - first as uncompressed tar
416
+ buffer = io.BytesIO()
417
+ # For bz2 and xz, we create uncompressed tar first then compress
418
+ if use_bzip2 or use_xz:
419
+ mode = "w"
420
+ elif use_gzip:
421
+ mode = "w:gz"
422
+ else:
423
+ mode = "w"
424
+
425
+ try:
426
+ tar = tarfile.open(fileobj=buffer, mode=mode)
427
+ except Exception as e:
428
+ return ExecResult(
429
+ stdout="",
430
+ stderr=f"tar: error opening archive: {e}\n",
431
+ exit_code=2,
432
+ )
433
+
434
+ verbose_output = ""
435
+ errors: list[str] = []
436
+
437
+ for file_path in files:
438
+ try:
439
+ added = await self._add_to_archive(
440
+ ctx, tar, work_dir, file_path, verbose, errors, exclude_patterns
441
+ )
442
+ if verbose:
443
+ verbose_output += added
444
+ except Exception as e:
445
+ errors.append(f"tar: {file_path}: {e}")
446
+
447
+ tar.close()
448
+
449
+ # Write archive to file or stdout
450
+ archive_data = buffer.getvalue()
451
+
452
+ # Apply bz2 or xz compression if needed
453
+ if use_bzip2:
454
+ archive_data = bz2.compress(archive_data)
455
+ elif use_xz:
456
+ archive_data = lzma.compress(archive_data)
457
+
458
+ if archive_file and archive_file != "-":
459
+ archive_path = ctx.fs.resolve_path(ctx.cwd, archive_file)
460
+ try:
461
+ await ctx.fs.write_file(archive_path, archive_data)
462
+ except Exception as e:
463
+ return ExecResult(
464
+ stdout="",
465
+ stderr=f"tar: {archive_file}: {e}\n",
466
+ exit_code=2,
467
+ )
468
+ stdout = ""
469
+ else:
470
+ # Output binary to stdout
471
+ stdout = archive_data.decode("latin-1")
472
+
473
+ # Verbose output always goes to stderr (matching real tar behavior)
474
+ stderr = verbose_output
475
+ if errors:
476
+ stderr = "\n".join(errors) + "\n"
477
+ return ExecResult(
478
+ stdout=stdout,
479
+ stderr=stderr,
480
+ exit_code=2 if errors else 0,
481
+ )
482
+
483
+ async def _add_to_archive(
484
+ self,
485
+ ctx: CommandContext,
486
+ tar: tarfile.TarFile,
487
+ base_path: str,
488
+ relative_path: str,
489
+ verbose: bool,
490
+ errors: list[str],
491
+ exclude_patterns: list[str],
492
+ ) -> str:
493
+ """Add a file or directory to the archive. Returns verbose output."""
494
+ full_path = ctx.fs.resolve_path(base_path, relative_path)
495
+ verbose_output = ""
496
+
497
+ # Check exclusion patterns
498
+ for pattern in exclude_patterns:
499
+ if fnmatch(relative_path, pattern) or fnmatch(relative_path.split("/")[-1], pattern):
500
+ return ""
501
+
502
+ try:
503
+ stat = await ctx.fs.stat(full_path)
504
+ except FileNotFoundError:
505
+ errors.append(f"tar: {relative_path}: No such file or directory")
506
+ return ""
507
+
508
+ # Get mtime - handle both float and datetime
509
+ mtime = stat.mtime
510
+ if hasattr(mtime, 'timestamp'):
511
+ mtime = int(mtime.timestamp())
512
+ elif isinstance(mtime, (int, float)):
513
+ mtime = int(mtime)
514
+ else:
515
+ mtime = 0
516
+
517
+ if stat.is_directory:
518
+ # Add directory entry
519
+ info = tarfile.TarInfo(name=relative_path)
520
+ info.type = tarfile.DIRTYPE
521
+ info.mode = stat.mode
522
+ info.mtime = mtime
523
+ tar.addfile(info)
524
+ if verbose:
525
+ verbose_output += f"{relative_path}\n"
526
+
527
+ # Add contents recursively
528
+ items = await ctx.fs.readdir(full_path)
529
+ for item in items:
530
+ child_path = f"{relative_path}/{item}" if relative_path else item
531
+ verbose_output += await self._add_to_archive(
532
+ ctx, tar, base_path, child_path, verbose, errors, exclude_patterns
533
+ )
534
+
535
+ elif stat.is_file:
536
+ content = await ctx.fs.read_file_bytes(full_path)
537
+ info = tarfile.TarInfo(name=relative_path)
538
+ info.size = len(content)
539
+ info.mode = stat.mode
540
+ info.mtime = mtime
541
+ tar.addfile(info, io.BytesIO(content))
542
+ if verbose:
543
+ verbose_output += f"{relative_path}\n"
544
+
545
+ elif stat.is_symlink:
546
+ target = await ctx.fs.readlink(full_path)
547
+ info = tarfile.TarInfo(name=relative_path)
548
+ info.type = tarfile.SYMTYPE
549
+ info.linkname = target
550
+ info.mode = stat.mode
551
+ tar.addfile(info)
552
+ if verbose:
553
+ verbose_output += f"{relative_path}\n"
554
+
555
+ return verbose_output
556
+
557
+ def _is_bzip2(self, data: bytes) -> bool:
558
+ """Check if data is bzip2 compressed."""
559
+ return len(data) >= 2 and data[0:2] == b"BZ"
560
+
561
+ def _is_xz(self, data: bytes) -> bool:
562
+ """Check if data is xz compressed."""
563
+ return len(data) >= 6 and data[0:6] == b"\xfd7zXZ\x00"
564
+
565
+ def _decompress_data(
566
+ self, data: bytes, use_gzip: bool, use_bzip2: bool, use_xz: bool
567
+ ) -> bytes:
568
+ """Decompress data based on flags or auto-detection."""
569
+ # Try explicit flags first
570
+ if use_bzip2:
571
+ return bz2.decompress(data)
572
+ if use_xz:
573
+ return lzma.decompress(data)
574
+ if use_gzip:
575
+ return gzip.decompress(data)
576
+
577
+ # Auto-detect compression
578
+ if self._is_bzip2(data):
579
+ return bz2.decompress(data)
580
+ if self._is_xz(data):
581
+ return lzma.decompress(data)
582
+ if self._is_gzip(data):
583
+ return gzip.decompress(data)
584
+
585
+ return data # No compression detected
586
+
587
+ async def _extract_archive(
588
+ self,
589
+ ctx: CommandContext,
590
+ archive_file: str,
591
+ specific_files: list[str],
592
+ work_dir: str,
593
+ use_gzip: bool = False,
594
+ use_bzip2: bool = False,
595
+ use_xz: bool = False,
596
+ verbose: bool = False,
597
+ strip_components: int = 0,
598
+ to_stdout: bool = False,
599
+ keep_old_files: bool = False,
600
+ no_mtime: bool = False,
601
+ preserve_permissions: bool = False,
602
+ ) -> ExecResult:
603
+ """Extract a tar archive."""
604
+ # Read archive
605
+ if archive_file and archive_file != "-":
606
+ archive_path = ctx.fs.resolve_path(ctx.cwd, archive_file)
607
+ try:
608
+ archive_data = await ctx.fs.read_file_bytes(archive_path)
609
+ except FileNotFoundError:
610
+ return ExecResult(
611
+ stdout="",
612
+ stderr=f"tar: {archive_file}: Cannot open: No such file or directory\n",
613
+ exit_code=2,
614
+ )
615
+ else:
616
+ archive_data = ctx.stdin.encode("latin-1")
617
+
618
+ # Decompress if needed
619
+ try:
620
+ decompressed = self._decompress_data(
621
+ archive_data, use_gzip, use_bzip2, use_xz
622
+ )
623
+ except Exception as e:
624
+ return ExecResult(
625
+ stdout="",
626
+ stderr=f"tar: error decompressing archive: {e}\n",
627
+ exit_code=2,
628
+ )
629
+
630
+ # Open archive
631
+ buffer = io.BytesIO(decompressed)
632
+ try:
633
+ tar = tarfile.open(fileobj=buffer, mode="r")
634
+ except Exception as e:
635
+ return ExecResult(
636
+ stdout="",
637
+ stderr=f"tar: error opening archive: {e}\n",
638
+ exit_code=2,
639
+ )
640
+
641
+ verbose_output = ""
642
+ stdout_content = ""
643
+ errors: list[str] = []
644
+
645
+ # Create work directory if needed (unless extracting to stdout)
646
+ if not to_stdout:
647
+ try:
648
+ await ctx.fs.mkdir(work_dir, recursive=True)
649
+ except Exception:
650
+ pass
651
+
652
+ for member in tar.getmembers():
653
+ name = member.name
654
+
655
+ # Apply strip-components
656
+ if strip_components > 0:
657
+ parts = name.split("/")
658
+ if len(parts) <= strip_components:
659
+ continue # Skip if not enough components
660
+ name = "/".join(parts[strip_components:])
661
+ if not name:
662
+ continue
663
+
664
+ # Check if specific files requested
665
+ if specific_files:
666
+ if not any(
667
+ name == f or name.startswith(f"{f}/") or fnmatch(name, f)
668
+ for f in specific_files
669
+ ):
670
+ continue
671
+
672
+ target_path = ctx.fs.resolve_path(work_dir, name)
673
+
674
+ try:
675
+ if to_stdout:
676
+ # Extract file contents to stdout
677
+ if member.isfile():
678
+ f = tar.extractfile(member)
679
+ if f:
680
+ content = f.read()
681
+ try:
682
+ stdout_content += content.decode("utf-8")
683
+ except UnicodeDecodeError:
684
+ stdout_content += content.decode("latin-1")
685
+ elif member.isdir():
686
+ if not keep_old_files or not await ctx.fs.exists(target_path):
687
+ await ctx.fs.mkdir(target_path, recursive=True)
688
+ elif member.isfile():
689
+ # Check if file exists and -k flag is set
690
+ if keep_old_files and await ctx.fs.exists(target_path):
691
+ # Skip this file, keep the existing one
692
+ if verbose:
693
+ verbose_output += f"{name}\n"
694
+ continue
695
+
696
+ # Ensure parent directory exists
697
+ parent = target_path.rsplit("/", 1)[0]
698
+ if parent:
699
+ try:
700
+ await ctx.fs.mkdir(parent, recursive=True)
701
+ except Exception:
702
+ pass
703
+
704
+ f = tar.extractfile(member)
705
+ if f:
706
+ content = f.read()
707
+ await ctx.fs.write_file(target_path, content)
708
+
709
+ # Preserve permissions if requested
710
+ if preserve_permissions:
711
+ await ctx.fs.chmod(target_path, member.mode)
712
+
713
+ elif member.issym():
714
+ if keep_old_files and await ctx.fs.exists(target_path):
715
+ if verbose:
716
+ verbose_output += f"{name}\n"
717
+ continue
718
+
719
+ parent = target_path.rsplit("/", 1)[0]
720
+ if parent:
721
+ try:
722
+ await ctx.fs.mkdir(parent, recursive=True)
723
+ except Exception:
724
+ pass
725
+ try:
726
+ await ctx.fs.symlink(member.linkname, target_path)
727
+ except Exception:
728
+ pass
729
+
730
+ if verbose:
731
+ verbose_output += f"{name}\n"
732
+
733
+ except Exception as e:
734
+ errors.append(f"tar: {name}: {e}")
735
+
736
+ tar.close()
737
+
738
+ stderr = verbose_output
739
+ if errors:
740
+ stderr += "\n".join(errors) + "\n"
741
+ return ExecResult(
742
+ stdout=stdout_content,
743
+ stderr=stderr,
744
+ exit_code=2 if errors else 0,
745
+ )
746
+
747
+ async def _list_archive(
748
+ self,
749
+ ctx: CommandContext,
750
+ archive_file: str,
751
+ specific_files: list[str],
752
+ use_gzip: bool = False,
753
+ use_bzip2: bool = False,
754
+ use_xz: bool = False,
755
+ verbose: bool = False,
756
+ ) -> ExecResult:
757
+ """List contents of a tar archive."""
758
+ # Read archive
759
+ if archive_file and archive_file != "-":
760
+ archive_path = ctx.fs.resolve_path(ctx.cwd, archive_file)
761
+ try:
762
+ archive_data = await ctx.fs.read_file_bytes(archive_path)
763
+ except FileNotFoundError:
764
+ return ExecResult(
765
+ stdout="",
766
+ stderr=f"tar: {archive_file}: Cannot open: No such file or directory\n",
767
+ exit_code=2,
768
+ )
769
+ else:
770
+ archive_data = ctx.stdin.encode("latin-1")
771
+
772
+ # Decompress if needed
773
+ try:
774
+ decompressed = self._decompress_data(
775
+ archive_data, use_gzip, use_bzip2, use_xz
776
+ )
777
+ except Exception as e:
778
+ return ExecResult(
779
+ stdout="",
780
+ stderr=f"tar: error decompressing archive: {e}\n",
781
+ exit_code=2,
782
+ )
783
+
784
+ # Open archive
785
+ buffer = io.BytesIO(decompressed)
786
+ try:
787
+ tar = tarfile.open(fileobj=buffer, mode="r")
788
+ except Exception as e:
789
+ return ExecResult(
790
+ stdout="",
791
+ stderr=f"tar: error opening archive: {e}\n",
792
+ exit_code=2,
793
+ )
794
+
795
+ stdout = ""
796
+
797
+ for member in tar.getmembers():
798
+ name = member.name
799
+
800
+ # Check if specific files requested
801
+ if specific_files:
802
+ if not any(
803
+ name == f or name.startswith(f"{f}/") or fnmatch(name, f)
804
+ for f in specific_files
805
+ ):
806
+ continue
807
+
808
+ if verbose:
809
+ # Verbose format
810
+ mode_str = self._format_mode(member.mode, member.isdir())
811
+ owner = f"{member.uid}/{member.gid}"
812
+ size = str(member.size).rjust(8)
813
+ mtime = datetime.fromtimestamp(member.mtime)
814
+ date_str = mtime.strftime("%b %d %H:%M")
815
+ line = f"{mode_str} {owner:<10} {size} {date_str} {name}"
816
+ if member.issym():
817
+ line += f" -> {member.linkname}"
818
+ stdout += f"{line}\n"
819
+ else:
820
+ stdout += f"{name}\n"
821
+
822
+ tar.close()
823
+ return ExecResult(stdout=stdout, stderr="", exit_code=0)
824
+
825
+ async def _append_archive(
826
+ self,
827
+ ctx: CommandContext,
828
+ archive_file: str,
829
+ files: list[str],
830
+ work_dir: str,
831
+ verbose: bool = False,
832
+ exclude_patterns: list[str] | None = None,
833
+ ) -> ExecResult:
834
+ """Append files to an existing tar archive."""
835
+ if exclude_patterns is None:
836
+ exclude_patterns = []
837
+
838
+ if not archive_file:
839
+ return ExecResult(
840
+ stdout="",
841
+ stderr="tar: -r requires an archive file\n",
842
+ exit_code=2,
843
+ )
844
+
845
+ # Read existing archive
846
+ archive_path = ctx.fs.resolve_path(ctx.cwd, archive_file)
847
+ try:
848
+ archive_data = await ctx.fs.read_file_bytes(archive_path)
849
+ except FileNotFoundError:
850
+ return ExecResult(
851
+ stdout="",
852
+ stderr=f"tar: {archive_file}: Cannot open: No such file or directory\n",
853
+ exit_code=2,
854
+ )
855
+
856
+ # Open existing archive
857
+ buffer = io.BytesIO(archive_data)
858
+ try:
859
+ existing_tar = tarfile.open(fileobj=buffer, mode="r")
860
+ existing_members = list(existing_tar.getmembers())
861
+ existing_contents: dict[str, bytes] = {}
862
+ for member in existing_members:
863
+ if member.isfile():
864
+ f = existing_tar.extractfile(member)
865
+ if f:
866
+ existing_contents[member.name] = f.read()
867
+ existing_tar.close()
868
+ except Exception as e:
869
+ return ExecResult(
870
+ stdout="",
871
+ stderr=f"tar: error opening archive: {e}\n",
872
+ exit_code=2,
873
+ )
874
+
875
+ # Create new archive with existing + new files
876
+ new_buffer = io.BytesIO()
877
+ try:
878
+ tar = tarfile.open(fileobj=new_buffer, mode="w")
879
+ except Exception as e:
880
+ return ExecResult(
881
+ stdout="",
882
+ stderr=f"tar: error creating archive: {e}\n",
883
+ exit_code=2,
884
+ )
885
+
886
+ # Add existing members
887
+ for member in existing_members:
888
+ if member.isfile() and member.name in existing_contents:
889
+ tar.addfile(member, io.BytesIO(existing_contents[member.name]))
890
+ else:
891
+ tar.addfile(member)
892
+
893
+ verbose_output = ""
894
+ errors: list[str] = []
895
+
896
+ # Add new files
897
+ for file_path in files:
898
+ try:
899
+ added = await self._add_to_archive(
900
+ ctx, tar, work_dir, file_path, verbose, errors, exclude_patterns
901
+ )
902
+ if verbose:
903
+ verbose_output += added
904
+ except Exception as e:
905
+ errors.append(f"tar: {file_path}: {e}")
906
+
907
+ tar.close()
908
+
909
+ # Write back the archive
910
+ archive_data = new_buffer.getvalue()
911
+ try:
912
+ await ctx.fs.write_file(archive_path, archive_data)
913
+ except Exception as e:
914
+ return ExecResult(
915
+ stdout="",
916
+ stderr=f"tar: {archive_file}: {e}\n",
917
+ exit_code=2,
918
+ )
919
+
920
+ stderr = verbose_output
921
+ if errors:
922
+ stderr = "\n".join(errors) + "\n"
923
+ return ExecResult(
924
+ stdout="",
925
+ stderr=stderr,
926
+ exit_code=2 if errors else 0,
927
+ )
928
+
929
+ async def _update_archive(
930
+ self,
931
+ ctx: CommandContext,
932
+ archive_file: str,
933
+ files: list[str],
934
+ work_dir: str,
935
+ verbose: bool = False,
936
+ exclude_patterns: list[str] | None = None,
937
+ ) -> ExecResult:
938
+ """Update archive with files that are newer or don't exist in archive."""
939
+ if exclude_patterns is None:
940
+ exclude_patterns = []
941
+
942
+ if not archive_file:
943
+ return ExecResult(
944
+ stdout="",
945
+ stderr="tar: -u requires an archive file\n",
946
+ exit_code=2,
947
+ )
948
+
949
+ # Read existing archive
950
+ archive_path = ctx.fs.resolve_path(ctx.cwd, archive_file)
951
+ try:
952
+ archive_data = await ctx.fs.read_file_bytes(archive_path)
953
+ except FileNotFoundError:
954
+ return ExecResult(
955
+ stdout="",
956
+ stderr=f"tar: {archive_file}: Cannot open: No such file or directory\n",
957
+ exit_code=2,
958
+ )
959
+
960
+ # Open existing archive and get member info
961
+ buffer = io.BytesIO(archive_data)
962
+ try:
963
+ existing_tar = tarfile.open(fileobj=buffer, mode="r")
964
+ existing_members = list(existing_tar.getmembers())
965
+ existing_contents: dict[str, bytes] = {}
966
+ existing_mtimes: dict[str, float] = {}
967
+ for member in existing_members:
968
+ existing_mtimes[member.name] = member.mtime
969
+ if member.isfile():
970
+ f = existing_tar.extractfile(member)
971
+ if f:
972
+ existing_contents[member.name] = f.read()
973
+ existing_tar.close()
974
+ except Exception as e:
975
+ return ExecResult(
976
+ stdout="",
977
+ stderr=f"tar: error opening archive: {e}\n",
978
+ exit_code=2,
979
+ )
980
+
981
+ # Create new archive
982
+ new_buffer = io.BytesIO()
983
+ try:
984
+ tar = tarfile.open(fileobj=new_buffer, mode="w")
985
+ except Exception as e:
986
+ return ExecResult(
987
+ stdout="",
988
+ stderr=f"tar: error creating archive: {e}\n",
989
+ exit_code=2,
990
+ )
991
+
992
+ # Add existing members first
993
+ for member in existing_members:
994
+ if member.isfile() and member.name in existing_contents:
995
+ tar.addfile(member, io.BytesIO(existing_contents[member.name]))
996
+ else:
997
+ tar.addfile(member)
998
+
999
+ verbose_output = ""
1000
+ errors: list[str] = []
1001
+
1002
+ # Add new/updated files
1003
+ for file_path in files:
1004
+ full_path = ctx.fs.resolve_path(work_dir, file_path)
1005
+ try:
1006
+ stat = await ctx.fs.stat(full_path)
1007
+ mtime = stat.mtime
1008
+ if hasattr(mtime, 'timestamp'):
1009
+ mtime = mtime.timestamp()
1010
+
1011
+ # Check if file needs updating
1012
+ if file_path in existing_mtimes:
1013
+ if mtime <= existing_mtimes[file_path]:
1014
+ continue # Skip, archive version is not older
1015
+
1016
+ # Add the file
1017
+ added = await self._add_to_archive(
1018
+ ctx, tar, work_dir, file_path, verbose, errors, exclude_patterns
1019
+ )
1020
+ if verbose:
1021
+ verbose_output += added
1022
+ except FileNotFoundError:
1023
+ errors.append(f"tar: {file_path}: No such file or directory")
1024
+ except Exception as e:
1025
+ errors.append(f"tar: {file_path}: {e}")
1026
+
1027
+ tar.close()
1028
+
1029
+ # Write back the archive
1030
+ archive_data = new_buffer.getvalue()
1031
+ try:
1032
+ await ctx.fs.write_file(archive_path, archive_data)
1033
+ except Exception as e:
1034
+ return ExecResult(
1035
+ stdout="",
1036
+ stderr=f"tar: {archive_file}: {e}\n",
1037
+ exit_code=2,
1038
+ )
1039
+
1040
+ stderr = verbose_output
1041
+ if errors:
1042
+ stderr = "\n".join(errors) + "\n"
1043
+ return ExecResult(
1044
+ stdout="",
1045
+ stderr=stderr,
1046
+ exit_code=2 if errors else 0,
1047
+ )
1048
+
1049
+ def _is_gzip(self, data: bytes) -> bool:
1050
+ """Check if data is gzip compressed."""
1051
+ return len(data) >= 2 and data[0] == 0x1F and data[1] == 0x8B
1052
+
1053
+ def _format_mode(self, mode: int, is_dir: bool) -> str:
1054
+ """Format file mode like ls -l."""
1055
+ chars = "d" if is_dir else "-"
1056
+ perms = [
1057
+ "r" if mode & 0o400 else "-",
1058
+ "w" if mode & 0o200 else "-",
1059
+ "x" if mode & 0o100 else "-",
1060
+ "r" if mode & 0o040 else "-",
1061
+ "w" if mode & 0o020 else "-",
1062
+ "x" if mode & 0o010 else "-",
1063
+ "r" if mode & 0o004 else "-",
1064
+ "w" if mode & 0o002 else "-",
1065
+ "x" if mode & 0o001 else "-",
1066
+ ]
1067
+ return chars + "".join(perms)