fauxnix-cli 0.9.3 → 0.12.0

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.
@@ -28,19 +28,89 @@ const PS_GZ_FNS = [
28
28
  ' $fx_gz.Close()',
29
29
  ' return ,$fx_ms.ToArray()',
30
30
  '}',
31
- 'function fx-gz-dbytes($src) {',
32
- ' $fx_fs = [IO.File]::OpenRead($src)',
33
- ' $fx_gz = New-Object System.IO.Compression.GZipStream($fx_fs, [System.IO.Compression.CompressionMode]::Decompress)',
34
- ' $fx_ms = New-Object System.IO.MemoryStream',
31
+ 'function fx-gz-open($src, $isBytes) {',
32
+ ' if ($isBytes) { $fx_raw = New-Object System.IO.MemoryStream(,$src) }',
33
+ ' else { $fx_raw = [IO.File]::OpenRead($src) }',
34
+ ' try {',
35
+ ' return (New-Object System.IO.Compression.GZipStream($fx_raw, [System.IO.Compression.CompressionMode]::Decompress))',
36
+ ' } catch {',
37
+ ' $fx_raw.Dispose()',
38
+ ' throw',
39
+ ' }',
40
+ '}',
41
+ // Drain the complete stream before producing stdout. Besides keeping -t
42
+ // bounded, this preserves the old all-or-nothing behavior for malformed
43
+ // inputs while remembering which text decoder the second pass should use.
44
+ 'function fx-gz-validate($src, $isBytes) {',
45
+ ' $fx_gz = fx-gz-open $src $isBytes',
35
46
  ' $fx_buf = New-Object byte[] 65536',
36
- ' while ($true) {',
37
- ' $fx_n = $fx_gz.Read($fx_buf, 0, $fx_buf.Length)',
38
- ' if ($fx_n -le 0) { break }',
39
- ' $fx_ms.Write($fx_buf, 0, $fx_n)',
47
+ ' $fx_dec = (New-Object System.Text.UTF8Encoding($false, $true)).GetDecoder()',
48
+ ' $fx_utf8 = $true',
49
+ ' try {',
50
+ ' while ($true) {',
51
+ ' $fx_n = $fx_gz.Read($fx_buf, 0, $fx_buf.Length)',
52
+ ' if ($fx_n -le 0) { break }',
53
+ ' if ($fx_utf8) {',
54
+ ' try { [void]$fx_dec.GetCharCount($fx_buf, 0, $fx_n, $false) }',
55
+ ' catch [System.Text.DecoderFallbackException] { $fx_utf8 = $false }',
56
+ ' }',
57
+ ' }',
58
+ ' if ($fx_utf8) {',
59
+ ' try { [void]$fx_dec.GetCharCount($fx_buf, 0, 0, $true) }',
60
+ ' catch [System.Text.DecoderFallbackException] { $fx_utf8 = $false }',
61
+ ' }',
62
+ ' return $fx_utf8',
63
+ ' } finally {',
64
+ ' $fx_gz.Dispose()',
65
+ ' }',
66
+ '}',
67
+ 'function fx-gz-stream-text($src, $isBytes) {',
68
+ ' $fx_utf8 = fx-gz-validate $src $isBytes',
69
+ ' if ($fx_utf8) { $fx_enc = New-Object System.Text.UTF8Encoding($false, $true) }',
70
+ ' else { try { $fx_enc = [System.Text.Encoding]::GetEncoding(936) } catch { $fx_enc = [System.Text.Encoding]::ASCII } }',
71
+ ' $fx_gz = fx-gz-open $src $isBytes',
72
+ ' $fx_sr = New-Object System.IO.StreamReader($fx_gz, $fx_enc, $false, 65536)',
73
+ ' try {',
74
+ ' while ($true) {',
75
+ ' $fx_line = $fx_sr.ReadLine()',
76
+ ' if ($null -eq $fx_line) { break }',
77
+ ' Write-Output -NoEnumerate $fx_line',
78
+ ' }',
79
+ ' } finally {',
80
+ ' $fx_sr.Dispose()',
81
+ ' }',
82
+ '}',
83
+ // File output is streamed into a sibling temporary file. The destination
84
+ // changes only after a complete decompression, and the temporary is removed
85
+ // on every failed path.
86
+ 'function fx-gz-stream-file($src, $dst) {',
87
+ ' $fx_full = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($dst)',
88
+ ' $fx_parent = [IO.Path]::GetDirectoryName($fx_full)',
89
+ " $fx_tmp = [IO.Path]::Combine($fx_parent, '.fauxnix-gzip-' + [Guid]::NewGuid().ToString('N') + '.tmp')",
90
+ ' $fx_gz = $null',
91
+ ' $fx_os = $null',
92
+ ' try {',
93
+ ' $fx_gz = fx-gz-open $src $false',
94
+ " $fx_os = New-Object System.IO.FileStream($fx_tmp, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)",
95
+ ' $fx_buf = New-Object byte[] 65536',
96
+ ' while ($true) {',
97
+ ' $fx_n = $fx_gz.Read($fx_buf, 0, $fx_buf.Length)',
98
+ ' if ($fx_n -le 0) { break }',
99
+ ' $fx_os.Write($fx_buf, 0, $fx_n)',
100
+ ' }',
101
+ ' $fx_os.Dispose(); $fx_os = $null',
102
+ ' $fx_gz.Dispose(); $fx_gz = $null',
103
+ ' if ([IO.File]::Exists($fx_full)) {',
104
+ ' $fx_null = [System.Management.Automation.Language.NullString]::Value',
105
+ ' [IO.File]::Replace($fx_tmp, $fx_full, $fx_null)',
106
+ ' }',
107
+ ' else { [IO.File]::Move($fx_tmp, $fx_full) }',
108
+ ' } catch {',
109
+ ' if ($null -ne $fx_os) { $fx_os.Dispose() }',
110
+ ' if ($null -ne $fx_gz) { $fx_gz.Dispose() }',
111
+ ' if ([IO.File]::Exists($fx_tmp)) { [IO.File]::Delete($fx_tmp) }',
112
+ ' throw',
40
113
  ' }',
41
- ' $fx_gz.Close()',
42
- ' $fx_fs.Close()',
43
- ' return ,$fx_ms.ToArray()',
44
114
  '}',
45
115
  ].join('\n');
46
116
  /** Parse GNU gzip-style argv (works for gzip/gunzip/zcat). */
@@ -121,7 +191,7 @@ function gzBlock(args, ctx, forced) {
121
191
  : " [Console]::Error.WriteLine('gzip: compressed data not written to a terminal. Use -f to force compression.')",
122
192
  ' $script:fx_exit = 1',
123
193
  ].join('\n')
124
- : p.decompress
194
+ : p.decompress || p.test
125
195
  ? [
126
196
  // NOTE stdin fidelity limitation: pipeline stdin arrives as decoded
127
197
  // text lines, so only gzip streams that survived the text pipeline
@@ -130,13 +200,7 @@ function gzBlock(args, ctx, forced) {
130
200
  " if ($fx_txt -ne '') { $fx_txt = $fx_txt + \"`n\" }",
131
201
  ' $fx_in = [System.Text.Encoding]::UTF8.GetBytes($fx_txt)',
132
202
  ' try {',
133
- ' $fx_ms = New-Object System.IO.MemoryStream(,$fx_in)',
134
- ' $fx_gz = New-Object System.IO.Compression.GZipStream($fx_ms, [System.IO.Compression.CompressionMode]::Decompress)',
135
- ' $fx_mo = New-Object System.IO.MemoryStream',
136
- ' $fx_buf = New-Object byte[] 65536',
137
- ' while ($true) { $fx_n = $fx_gz.Read($fx_buf, 0, $fx_buf.Length); if ($fx_n -le 0) { break }; $fx_mo.Write($fx_buf, 0, $fx_n) }',
138
- ' $fx_gz.Close()',
139
- ' fx-lines (fx-dec $fx_mo.ToArray())',
203
+ p.test ? ' [void](fx-gz-validate $fx_in $true)' : ' fx-gz-stream-text $fx_in $true',
140
204
  ' } catch {',
141
205
  " [Console]::Error.WriteLine('gzip: stdin: not in gzip format')",
142
206
  ' $script:fx_exit = 1',
@@ -163,14 +227,13 @@ function gzBlock(args, ctx, forced) {
163
227
  ' }',
164
228
  ];
165
229
  if (p.test) {
166
- fileLoop.push(' try { $fx_tb = fx-gz-dbytes $fx_f }', " catch { [Console]::Error.WriteLine('gzip: ' + $fx_f + ': not in gzip format'); $script:fx_exit = 1 }", ' continue');
230
+ fileLoop.push(' try { [void](fx-gz-validate $fx_f $false) }', " catch { [Console]::Error.WriteLine('gzip: ' + $fx_f + ': not in gzip format'); $script:fx_exit = 1 }", ' continue');
167
231
  }
168
232
  else if (p.decompress) {
169
233
  fileLoop.push(' $fx_low = $fx_f.ToLower()', " if (-not ($fx_low.EndsWith('.gz') -or $fx_low.EndsWith('.tgz'))) {", " [Console]::Error.WriteLine('gzip: ' + $fx_f + ': unknown suffix -- ignored')", ' $script:fx_exit = 2', ' continue', ' }', ' $fx_out = $fx_f.Substring(0, $fx_f.Length - 3)', " if ($fx_low.EndsWith('.tgz')) { $fx_out = $fx_f.Substring(0, $fx_f.Length - 4) + '.tar' }", ' try {', p.stdout
170
- ? ' fx-lines (fx-dec (fx-gz-dbytes $fx_f))'
234
+ ? ' fx-gz-stream-text $fx_f $false'
171
235
  : [
172
- ' $fx_o = fx-gz-dbytes $fx_f',
173
- ' [IO.File]::WriteAllBytes($fx_out, $fx_o)',
236
+ ' fx-gz-stream-file $fx_f $fx_out',
174
237
  ' try { (Get-Item -LiteralPath $fx_out).LastWriteTime = (Get-Item -LiteralPath $fx_f).LastWriteTime } catch {}',
175
238
  ' if (-not ' + b(p.keep) + ') { Remove-Item -LiteralPath $fx_f -Force }',
176
239
  ].join('\n'), " } catch { [Console]::Error.WriteLine('gzip: ' + $fx_f + ': not in gzip format'); $script:fx_exit = 1 }");
@@ -201,6 +264,8 @@ const gunzip = (args, ctx) => gzBlock(args, ctx, { decompress: true });
201
264
  const zcat = (args, ctx) => gzBlock(args, ctx, { decompress: true, stdout: true });
202
265
  /* ------------------------------------------------------------------ */
203
266
  /* tar — Windows 10+ ships bsdtar as tar.exe, pass everything through */
267
+ /* Intentionally unspec'd: registerSpec fail-loud would reject GNU */
268
+ /* flags bsdtar accepts (--numeric-owner, …). Same class as find. */
204
269
  /* ------------------------------------------------------------------ */
205
270
  const tar = (args) => {
206
271
  return [
@@ -276,7 +341,7 @@ const zip = (args) => {
276
341
  " $fx_dst = $fx_arc",
277
342
  ' $fx_rename = $false',
278
343
  " if (-not $fx_arc.ToLower().EndsWith('.zip')) { $fx_dst = $fx_arc + '.zip'; $fx_rename = $true }",
279
- ' Compress-Archive -Path $fx_valid -DestinationPath $fx_dst -Force -ErrorAction Stop',
344
+ ' Compress-Archive -LiteralPath $fx_valid -DestinationPath $fx_dst -Force -ErrorAction Stop',
280
345
  // PS 5.1 Compress-Archive writes `\` entry separators; rewrite them to `/`
281
346
  // so GNU unzip/bsdtar see proper directory entries.
282
347
  ' Add-Type -AssemblyName System.IO.Compression.FileSystem',
@@ -374,8 +439,87 @@ const unzip = (args) => {
374
439
  ].join('\n');
375
440
  };
376
441
  /* ------------------------------------------------------------------ */
377
- /* exports */
442
+ /* CommandSpec (C-5 archive slice) */
443
+ /* tar stays unspec'd: fx-native to tar.exe; unknown GNU flags must */
444
+ /* reach bsdtar. gzip -f is a no-op today and the stdin-from-terminal */
445
+ /* message advertises it, so it is unsupported (force from terminal) */
446
+ /* rather than a silent ignore. */
378
447
  /* ------------------------------------------------------------------ */
448
+ const gzipOptions = [
449
+ { short: 'd', long: '--decompress', support: 'implemented' },
450
+ { long: '--uncompress', support: 'implemented' },
451
+ { short: 'k', long: '--keep', support: 'implemented' },
452
+ { short: 'c', long: '--stdout', support: 'implemented' },
453
+ { long: '--to-stdout', support: 'implemented' },
454
+ { short: 't', long: '--test', support: 'implemented' },
455
+ { short: '1', long: '--fast', support: 'implemented' },
456
+ { short: '2', support: 'implemented' },
457
+ { short: '3', support: 'implemented' },
458
+ { short: '4', support: 'implemented' },
459
+ { short: '5', support: 'implemented' },
460
+ { short: '6', support: 'implemented' },
461
+ { short: '7', support: 'implemented' },
462
+ { short: '8', support: 'implemented' },
463
+ { short: '9', long: '--best', support: 'implemented' },
464
+ { short: 'f', long: '--force', support: 'unsupported', reason: 'force from terminal' },
465
+ { short: 'q', long: '--quiet', support: 'unsupported', reason: 'quiet' },
466
+ { short: 'v', long: '--verbose', support: 'unsupported', reason: 'verbose' },
467
+ { short: 'n', long: '--no-name', support: 'unsupported', reason: 'no-name' },
468
+ { short: 'r', long: '--recursive', support: 'unsupported', reason: 'recursive' },
469
+ { short: 'S', long: '--suffix', takesValue: true, support: 'unsupported', reason: 'suffix' },
470
+ ];
471
+ export const specs = [
472
+ {
473
+ names: ['gzip'],
474
+ options: gzipOptions,
475
+ effects: ['read', 'write', 'delete'],
476
+ platform: 'windows-ps51',
477
+ dispatch: 'translated',
478
+ handler: gzip,
479
+ },
480
+ {
481
+ names: ['gunzip'],
482
+ options: gzipOptions,
483
+ effects: ['read', 'write', 'delete'],
484
+ platform: 'windows-ps51',
485
+ dispatch: 'translated',
486
+ handler: gunzip,
487
+ },
488
+ {
489
+ names: ['zcat'],
490
+ options: gzipOptions,
491
+ effects: ['read'],
492
+ platform: 'windows-ps51',
493
+ dispatch: 'translated',
494
+ handler: zcat,
495
+ },
496
+ {
497
+ names: ['zip'],
498
+ options: [
499
+ // Compress-Archive always recurses directory inputs; -q is already quiet.
500
+ { short: 'r', support: 'implemented' },
501
+ { short: 'q', support: 'implemented' },
502
+ { short: 'x', long: '--exclude', takesValue: true, support: 'unsupported', reason: 'exclude patterns' },
503
+ ],
504
+ effects: ['read', 'write'],
505
+ platform: 'windows-ps51',
506
+ dispatch: 'translated',
507
+ handler: zip,
508
+ },
509
+ {
510
+ names: ['unzip'],
511
+ options: [
512
+ { short: 'l', support: 'implemented' },
513
+ { short: 'o', support: 'implemented' },
514
+ { short: 'q', support: 'implemented' },
515
+ { short: 'd', long: '--directory', takesValue: true, support: 'implemented' },
516
+ ],
517
+ effects: ['read', 'write'],
518
+ platform: 'windows-ps51',
519
+ dispatch: 'translated',
520
+ handler: unzip,
521
+ },
522
+ ];
379
523
  export const handlers = {
380
524
  tar,
381
525
  gzip,
@@ -2,9 +2,9 @@ import { registerAll, registerSpecs } from '../registry.js';
2
2
  import { handlers as files, specs as fileSpecs } from './files.js';
3
3
  import { handlers as textFilters, specs as textFilterSpecs } from './text-filters.js';
4
4
  import { handlers as textIo, specs as textIoSpecs } from './text-io.js';
5
- import { handlers as sysinfo } from './sysinfo.js';
5
+ import { handlers as sysinfo, specs as sysinfoSpecs } from './sysinfo.js';
6
6
  import { handlers as net } from './net.js';
7
- import { handlers as archive } from './archive.js';
7
+ import { handlers as archive, specs as archiveSpecs } from './archive.js';
8
8
  /** Register every built-in Linux command translator. Side-effectful import. */
9
9
  export function installAll() {
10
10
  registerAll(files);
@@ -16,5 +16,7 @@ export function installAll() {
16
16
  registerSpecs(fileSpecs);
17
17
  registerSpecs(textIoSpecs);
18
18
  registerSpecs(textFilterSpecs);
19
+ registerSpecs(archiveSpecs);
20
+ registerSpecs(sysinfoSpecs);
19
21
  }
20
22
  installAll();
@@ -1,2 +1,11 @@
1
1
  import { Handler } from '../registry.js';
2
+ /**
3
+ * GNU wget-style default output basename for the curl fallback.
4
+ *
5
+ * A valid UTF-8 percent sequence is decoded exactly once. Malformed sequences
6
+ * remain literal, while characters Windows cannot put in a filename are
7
+ * percent-encoded again. This keeps encoded separators inside one basename
8
+ * instead of accidentally turning them into local path components.
9
+ */
10
+ export declare function urlFileName(u: string): string;
2
11
  export declare const handlers: Record<string, Handler>;
@@ -105,21 +105,57 @@ const curl = (args) => {
105
105
  /* ------------------------------------------------------------------ */
106
106
  /* wget */
107
107
  /* ------------------------------------------------------------------ */
108
- /** GNU wget default output filename derived from a literal URL (index.html when bare). */
109
- function urlFileName(u) {
110
- // keep only the path component (drop scheme, host, query, fragment)
111
- let p = u.split('#')[0].split('?')[0];
112
- const sm = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.exec(p);
113
- if (sm)
114
- p = p.slice(sm[0].length);
115
- const slash = p.indexOf('/');
116
- if (slash >= 0)
117
- p = p.slice(slash + 1);
118
- else
119
- p = '';
120
- if (p === '')
121
- return 'index.html'; // no path (or trailing /) → GNU default
122
- return p;
108
+ const WINDOWS_DEVICE_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i;
109
+ function percentEncodeChar(char) {
110
+ return Array.from(Buffer.from(char, 'utf8'), (byte) => '%' + byte.toString(16).toUpperCase()).join('');
111
+ }
112
+ /**
113
+ * GNU wget-style default output basename for the curl fallback.
114
+ *
115
+ * A valid UTF-8 percent sequence is decoded exactly once. Malformed sequences
116
+ * remain literal, while characters Windows cannot put in a filename are
117
+ * percent-encoded again. This keeps encoded separators inside one basename
118
+ * instead of accidentally turning them into local path components.
119
+ */
120
+ export function urlFileName(u) {
121
+ let pathname;
122
+ try {
123
+ pathname = new URL(u).pathname;
124
+ }
125
+ catch {
126
+ return 'index.html';
127
+ }
128
+ // URL normalisation handles dot segments. A bare path or trailing slash uses
129
+ // wget's conventional index filename.
130
+ if (pathname === '' || pathname.endsWith('/'))
131
+ return 'index.html';
132
+ const rawName = pathname.slice(pathname.lastIndexOf('/') + 1);
133
+ let decoded = rawName;
134
+ try {
135
+ decoded = decodeURIComponent(rawName);
136
+ }
137
+ catch {
138
+ // Keep malformed percent escapes literal so naming remains deterministic.
139
+ }
140
+ let safe = '';
141
+ for (const char of decoded) {
142
+ const code = char.codePointAt(0) ?? 0;
143
+ if (code <= 0x1f || code === 0x7f || /[<>:"/\\|?*]/.test(char)) {
144
+ safe += percentEncodeChar(char);
145
+ }
146
+ else {
147
+ safe += char;
148
+ }
149
+ }
150
+ // Windows strips trailing dots/spaces and treats DOS device basenames as
151
+ // special even when an extension is present. Preserve the intended text in
152
+ // a stable, ordinary filename instead.
153
+ safe = safe.replace(/[. ]+$/, (suffix) => Array.from(suffix, (char) => percentEncodeChar(char)).join(''));
154
+ if (safe === '' || safe === '.' || safe === '..')
155
+ return 'index.html';
156
+ if (WINDOWS_DEVICE_NAME.test(safe))
157
+ safe = '_' + safe;
158
+ return safe;
123
159
  }
124
160
  /** Map GNU wget argv → curl.exe argv (only literal words are rewritten). */
125
161
  function mapWgetArgs(args) {
@@ -1,2 +1,3 @@
1
- import { Handler } from '../registry.js';
1
+ import { CommandSpec, Handler } from '../registry.js';
2
+ export declare const specs: CommandSpec[];
2
3
  export declare const handlers: Record<string, Handler>;