mirror of
https://github.com/curlconverter/curlconverter.git
synced 2022-05-22 02:35:29 +03:00
1720 lines
56 KiB
TypeScript
1720 lines
56 KiB
TypeScript
import URL from "url";
|
|
|
|
import nunjucks from "nunjucks";
|
|
|
|
import parser from "./bash-parser.js";
|
|
|
|
const env = nunjucks.configure(["templates/"], {
|
|
// set folders with templates
|
|
autoescape: false,
|
|
});
|
|
env.addFilter("isArr", (something: any): boolean => Array.isArray(something));
|
|
env.addFilter(
|
|
"isString",
|
|
(something: any): boolean => typeof something === "string"
|
|
);
|
|
|
|
// TODO: this type doesn't work.
|
|
function has<T, K extends PropertyKey>(
|
|
obj: T,
|
|
prop: K
|
|
): obj is T & Record<K, unknown> {
|
|
return Object.prototype.hasOwnProperty.call(obj, prop);
|
|
}
|
|
|
|
export class CCError extends Error {}
|
|
|
|
function pushProp<Type>(
|
|
obj: { [key: string]: Type[] },
|
|
prop: string,
|
|
value: Type
|
|
) {
|
|
if (!has(obj, prop)) {
|
|
// TODO: I have no idea what
|
|
// Type 'never[]' is not assignable to type 'never'.
|
|
// means
|
|
(obj[prop] as Type[]) = [];
|
|
}
|
|
obj[prop].push(value);
|
|
return obj;
|
|
}
|
|
|
|
interface _LongShort {
|
|
name?: string; // added dynamically
|
|
type: "string" | "number" | "bool";
|
|
expand?: boolean;
|
|
removed?: string;
|
|
}
|
|
|
|
interface LongShort {
|
|
name: string;
|
|
type: "string" | "number" | "bool";
|
|
expand?: boolean;
|
|
removed?: string;
|
|
}
|
|
|
|
interface _LongOpts {
|
|
[key: string]: _LongShort;
|
|
}
|
|
interface LongOpts {
|
|
[key: string]: LongShort | null;
|
|
}
|
|
interface ShortOpts {
|
|
[key: string]: string;
|
|
}
|
|
|
|
type Query = Array<[string, string | null]>;
|
|
interface QueryDict {
|
|
[key: string]: string | null | Array<string | null>;
|
|
}
|
|
|
|
type Headers = Array<[string, string | null]>;
|
|
|
|
type Cookie = [string, string];
|
|
type Cookies = Array<Cookie>;
|
|
|
|
interface ParsedArguments {
|
|
request?: string; // the HTTP method
|
|
data?: string[];
|
|
"data-binary"?: string[];
|
|
"data-ascii"?: string[];
|
|
"data-raw"?: string[];
|
|
"data-urlencode"?: string[];
|
|
json?: string[];
|
|
[key: string]: any;
|
|
}
|
|
|
|
interface Request {
|
|
url: string;
|
|
urlWithoutQuery: string;
|
|
query?: Query;
|
|
queryDict?: QueryDict;
|
|
method: string;
|
|
headers?: Headers;
|
|
stdin?: string;
|
|
input?: string;
|
|
multipartUploads?: [string, string][];
|
|
auth?: [string, string];
|
|
cookies?: Cookies;
|
|
compressed?: boolean;
|
|
isDataBinary?: boolean;
|
|
isDataRaw?: boolean;
|
|
digest?: boolean;
|
|
dataArray?: string[];
|
|
data?: string;
|
|
insecure?: boolean;
|
|
cert?: string | [string, string];
|
|
cacert?: string;
|
|
capath?: string;
|
|
proxy?: string;
|
|
proxyAuth?: string;
|
|
timeout?: string;
|
|
followRedirects?: boolean;
|
|
output?: string;
|
|
http2?: boolean;
|
|
http3?: boolean;
|
|
}
|
|
|
|
// BEGIN GENERATED CURL OPTIONS
|
|
const _curlLongOpts: _LongOpts = {
|
|
url: { type: "string" },
|
|
"dns-ipv4-addr": { type: "string" },
|
|
"dns-ipv6-addr": { type: "string" },
|
|
"random-file": { type: "string" },
|
|
"egd-file": { type: "string" },
|
|
"oauth2-bearer": { type: "string" },
|
|
"connect-timeout": { type: "string" },
|
|
"doh-url": { type: "string" },
|
|
ciphers: { type: "string" },
|
|
"dns-interface": { type: "string" },
|
|
"disable-epsv": { type: "bool", name: "epsv" },
|
|
"no-disable-epsv": { type: "bool", name: "epsv", expand: false },
|
|
"disallow-username-in-url": { type: "bool" },
|
|
"no-disallow-username-in-url": {
|
|
type: "bool",
|
|
name: "disallow-username-in-url",
|
|
expand: false,
|
|
},
|
|
epsv: { type: "bool" },
|
|
"no-epsv": { type: "bool", name: "epsv", expand: false },
|
|
"dns-servers": { type: "string" },
|
|
trace: { type: "string" },
|
|
npn: { type: "bool" },
|
|
"no-npn": { type: "bool", name: "npn", expand: false },
|
|
"trace-ascii": { type: "string" },
|
|
alpn: { type: "bool" },
|
|
"no-alpn": { type: "bool", name: "alpn", expand: false },
|
|
"limit-rate": { type: "string" },
|
|
compressed: { type: "bool" },
|
|
"no-compressed": { type: "bool", name: "compressed", expand: false },
|
|
"tr-encoding": { type: "bool" },
|
|
"no-tr-encoding": { type: "bool", name: "tr-encoding", expand: false },
|
|
digest: { type: "bool" },
|
|
"no-digest": { type: "bool", name: "digest", expand: false },
|
|
negotiate: { type: "bool" },
|
|
"no-negotiate": { type: "bool", name: "negotiate", expand: false },
|
|
ntlm: { type: "bool" },
|
|
"no-ntlm": { type: "bool", name: "ntlm", expand: false },
|
|
"ntlm-wb": { type: "bool" },
|
|
"no-ntlm-wb": { type: "bool", name: "ntlm-wb", expand: false },
|
|
basic: { type: "bool" },
|
|
"no-basic": { type: "bool", name: "basic", expand: false },
|
|
anyauth: { type: "bool" },
|
|
"no-anyauth": { type: "bool", name: "anyauth", expand: false },
|
|
wdebug: { type: "bool" },
|
|
"no-wdebug": { type: "bool", name: "wdebug", expand: false },
|
|
"ftp-create-dirs": { type: "bool" },
|
|
"no-ftp-create-dirs": {
|
|
type: "bool",
|
|
name: "ftp-create-dirs",
|
|
expand: false,
|
|
},
|
|
"create-dirs": { type: "bool" },
|
|
"no-create-dirs": { type: "bool", name: "create-dirs", expand: false },
|
|
"create-file-mode": { type: "string" },
|
|
"max-redirs": { type: "string" },
|
|
"proxy-ntlm": { type: "bool" },
|
|
"no-proxy-ntlm": { type: "bool", name: "proxy-ntlm", expand: false },
|
|
crlf: { type: "bool" },
|
|
"no-crlf": { type: "bool", name: "crlf", expand: false },
|
|
stderr: { type: "string" },
|
|
"aws-sigv4": { type: "string" },
|
|
interface: { type: "string" },
|
|
krb: { type: "string" },
|
|
krb4: { type: "string", name: "krb" },
|
|
"haproxy-protocol": { type: "bool" },
|
|
"no-haproxy-protocol": {
|
|
type: "bool",
|
|
name: "haproxy-protocol",
|
|
expand: false,
|
|
},
|
|
"max-filesize": { type: "string" },
|
|
"disable-eprt": { type: "bool", name: "eprt" },
|
|
"no-disable-eprt": { type: "bool", name: "eprt", expand: false },
|
|
eprt: { type: "bool" },
|
|
"no-eprt": { type: "bool", name: "eprt", expand: false },
|
|
xattr: { type: "bool" },
|
|
"no-xattr": { type: "bool", name: "xattr", expand: false },
|
|
"ftp-ssl": { type: "bool", name: "ssl" },
|
|
"no-ftp-ssl": { type: "bool", name: "ssl", expand: false },
|
|
ssl: { type: "bool" },
|
|
"no-ssl": { type: "bool", name: "ssl", expand: false },
|
|
"ftp-pasv": { type: "bool" },
|
|
"no-ftp-pasv": { type: "bool", name: "ftp-pasv", expand: false },
|
|
socks5: { type: "string" },
|
|
"tcp-nodelay": { type: "bool" },
|
|
"no-tcp-nodelay": { type: "bool", name: "tcp-nodelay", expand: false },
|
|
"proxy-digest": { type: "bool" },
|
|
"no-proxy-digest": { type: "bool", name: "proxy-digest", expand: false },
|
|
"proxy-basic": { type: "bool" },
|
|
"no-proxy-basic": { type: "bool", name: "proxy-basic", expand: false },
|
|
retry: { type: "string" },
|
|
"retry-connrefused": { type: "bool" },
|
|
"no-retry-connrefused": {
|
|
type: "bool",
|
|
name: "retry-connrefused",
|
|
expand: false,
|
|
},
|
|
"retry-delay": { type: "string" },
|
|
"retry-max-time": { type: "string" },
|
|
"proxy-negotiate": { type: "bool" },
|
|
"no-proxy-negotiate": {
|
|
type: "bool",
|
|
name: "proxy-negotiate",
|
|
expand: false,
|
|
},
|
|
"form-escape": { type: "bool" },
|
|
"no-form-escape": { type: "bool", name: "form-escape", expand: false },
|
|
"ftp-account": { type: "string" },
|
|
"proxy-anyauth": { type: "bool" },
|
|
"no-proxy-anyauth": { type: "bool", name: "proxy-anyauth", expand: false },
|
|
"trace-time": { type: "bool" },
|
|
"no-trace-time": { type: "bool", name: "trace-time", expand: false },
|
|
"ignore-content-length": { type: "bool" },
|
|
"no-ignore-content-length": {
|
|
type: "bool",
|
|
name: "ignore-content-length",
|
|
expand: false,
|
|
},
|
|
"ftp-skip-pasv-ip": { type: "bool" },
|
|
"no-ftp-skip-pasv-ip": {
|
|
type: "bool",
|
|
name: "ftp-skip-pasv-ip",
|
|
expand: false,
|
|
},
|
|
"ftp-method": { type: "string" },
|
|
"local-port": { type: "string" },
|
|
socks4: { type: "string" },
|
|
socks4a: { type: "string" },
|
|
"ftp-alternative-to-user": { type: "string" },
|
|
"ftp-ssl-reqd": { type: "bool", name: "ssl-reqd" },
|
|
"no-ftp-ssl-reqd": { type: "bool", name: "ssl-reqd", expand: false },
|
|
"ssl-reqd": { type: "bool" },
|
|
"no-ssl-reqd": { type: "bool", name: "ssl-reqd", expand: false },
|
|
sessionid: { type: "bool" },
|
|
"no-sessionid": { type: "bool", name: "sessionid", expand: false },
|
|
"ftp-ssl-control": { type: "bool" },
|
|
"no-ftp-ssl-control": {
|
|
type: "bool",
|
|
name: "ftp-ssl-control",
|
|
expand: false,
|
|
},
|
|
"ftp-ssl-ccc": { type: "bool" },
|
|
"no-ftp-ssl-ccc": { type: "bool", name: "ftp-ssl-ccc", expand: false },
|
|
"ftp-ssl-ccc-mode": { type: "string" },
|
|
libcurl: { type: "string" },
|
|
raw: { type: "bool" },
|
|
"no-raw": { type: "bool", name: "raw", expand: false },
|
|
post301: { type: "bool" },
|
|
"no-post301": { type: "bool", name: "post301", expand: false },
|
|
keepalive: { type: "bool" },
|
|
"no-keepalive": { type: "bool", name: "keepalive", expand: false },
|
|
"socks5-hostname": { type: "string" },
|
|
"keepalive-time": { type: "string" },
|
|
post302: { type: "bool" },
|
|
"no-post302": { type: "bool", name: "post302", expand: false },
|
|
noproxy: { type: "string" },
|
|
"socks5-gssapi-nec": { type: "bool" },
|
|
"no-socks5-gssapi-nec": {
|
|
type: "bool",
|
|
name: "socks5-gssapi-nec",
|
|
expand: false,
|
|
},
|
|
"proxy1.0": { type: "string" },
|
|
"tftp-blksize": { type: "string" },
|
|
"mail-from": { type: "string" },
|
|
"mail-rcpt": { type: "string" },
|
|
"ftp-pret": { type: "bool" },
|
|
"no-ftp-pret": { type: "bool", name: "ftp-pret", expand: false },
|
|
proto: { type: "string" },
|
|
"proto-redir": { type: "string" },
|
|
resolve: { type: "string" },
|
|
delegation: { type: "string" },
|
|
"mail-auth": { type: "string" },
|
|
post303: { type: "bool" },
|
|
"no-post303": { type: "bool", name: "post303", expand: false },
|
|
metalink: { type: "bool" },
|
|
"no-metalink": { type: "bool", name: "metalink", expand: false },
|
|
"sasl-authzid": { type: "string" },
|
|
"sasl-ir": { type: "bool" },
|
|
"no-sasl-ir": { type: "bool", name: "sasl-ir", expand: false },
|
|
"test-event": { type: "bool" },
|
|
"no-test-event": { type: "bool", name: "test-event", expand: false },
|
|
"unix-socket": { type: "string" },
|
|
"path-as-is": { type: "bool" },
|
|
"no-path-as-is": { type: "bool", name: "path-as-is", expand: false },
|
|
"socks5-gssapi-service": { type: "string", name: "proxy-service-name" },
|
|
"proxy-service-name": { type: "string" },
|
|
"service-name": { type: "string" },
|
|
"proto-default": { type: "string" },
|
|
"expect100-timeout": { type: "string" },
|
|
"tftp-no-options": { type: "bool" },
|
|
"no-tftp-no-options": {
|
|
type: "bool",
|
|
name: "tftp-no-options",
|
|
expand: false,
|
|
},
|
|
"connect-to": { type: "string" },
|
|
"abstract-unix-socket": { type: "string" },
|
|
"tls-max": { type: "string" },
|
|
"suppress-connect-headers": { type: "bool" },
|
|
"no-suppress-connect-headers": {
|
|
type: "bool",
|
|
name: "suppress-connect-headers",
|
|
expand: false,
|
|
},
|
|
"compressed-ssh": { type: "bool" },
|
|
"no-compressed-ssh": { type: "bool", name: "compressed-ssh", expand: false },
|
|
"happy-eyeballs-timeout-ms": { type: "string" },
|
|
"retry-all-errors": { type: "bool" },
|
|
"no-retry-all-errors": {
|
|
type: "bool",
|
|
name: "retry-all-errors",
|
|
expand: false,
|
|
},
|
|
"http1.0": { type: "bool" },
|
|
"http1.1": { type: "bool" },
|
|
http2: { type: "bool" },
|
|
"http2-prior-knowledge": { type: "bool" },
|
|
http3: { type: "bool" },
|
|
"http0.9": { type: "bool" },
|
|
"no-http0.9": { type: "bool", name: "http0.9", expand: false },
|
|
tlsv1: { type: "bool" },
|
|
"tlsv1.0": { type: "bool" },
|
|
"tlsv1.1": { type: "bool" },
|
|
"tlsv1.2": { type: "bool" },
|
|
"tlsv1.3": { type: "bool" },
|
|
"tls13-ciphers": { type: "string" },
|
|
"proxy-tls13-ciphers": { type: "string" },
|
|
sslv2: { type: "bool" },
|
|
sslv3: { type: "bool" },
|
|
ipv4: { type: "bool" },
|
|
ipv6: { type: "bool" },
|
|
append: { type: "bool" },
|
|
"no-append": { type: "bool", name: "append", expand: false },
|
|
"user-agent": { type: "string" },
|
|
cookie: { type: "string" },
|
|
"alt-svc": { type: "string" },
|
|
hsts: { type: "string" },
|
|
"use-ascii": { type: "bool" },
|
|
"no-use-ascii": { type: "bool", name: "use-ascii", expand: false },
|
|
"cookie-jar": { type: "string" },
|
|
"continue-at": { type: "string" },
|
|
data: { type: "string" },
|
|
"data-raw": { type: "string" },
|
|
"data-ascii": { type: "string" },
|
|
"data-binary": { type: "string" },
|
|
"data-urlencode": { type: "string" },
|
|
json: { type: "string" },
|
|
"dump-header": { type: "string" },
|
|
referer: { type: "string" },
|
|
cert: { type: "string" },
|
|
cacert: { type: "string" },
|
|
"cert-type": { type: "string" },
|
|
key: { type: "string" },
|
|
"key-type": { type: "string" },
|
|
pass: { type: "string" },
|
|
engine: { type: "string" },
|
|
capath: { type: "string" },
|
|
pubkey: { type: "string" },
|
|
hostpubmd5: { type: "string" },
|
|
hostpubsha256: { type: "string" },
|
|
crlfile: { type: "string" },
|
|
tlsuser: { type: "string" },
|
|
tlspassword: { type: "string" },
|
|
tlsauthtype: { type: "string" },
|
|
"ssl-allow-beast": { type: "bool" },
|
|
"no-ssl-allow-beast": {
|
|
type: "bool",
|
|
name: "ssl-allow-beast",
|
|
expand: false,
|
|
},
|
|
"ssl-auto-client-cert": { type: "bool" },
|
|
"no-ssl-auto-client-cert": {
|
|
type: "bool",
|
|
name: "ssl-auto-client-cert",
|
|
expand: false,
|
|
},
|
|
"proxy-ssl-auto-client-cert": { type: "bool" },
|
|
"no-proxy-ssl-auto-client-cert": {
|
|
type: "bool",
|
|
name: "proxy-ssl-auto-client-cert",
|
|
expand: false,
|
|
},
|
|
pinnedpubkey: { type: "string" },
|
|
"proxy-pinnedpubkey": { type: "string" },
|
|
"cert-status": { type: "bool" },
|
|
"no-cert-status": { type: "bool", name: "cert-status", expand: false },
|
|
"doh-cert-status": { type: "bool" },
|
|
"no-doh-cert-status": {
|
|
type: "bool",
|
|
name: "doh-cert-status",
|
|
expand: false,
|
|
},
|
|
"false-start": { type: "bool" },
|
|
"no-false-start": { type: "bool", name: "false-start", expand: false },
|
|
"ssl-no-revoke": { type: "bool" },
|
|
"no-ssl-no-revoke": { type: "bool", name: "ssl-no-revoke", expand: false },
|
|
"ssl-revoke-best-effort": { type: "bool" },
|
|
"no-ssl-revoke-best-effort": {
|
|
type: "bool",
|
|
name: "ssl-revoke-best-effort",
|
|
expand: false,
|
|
},
|
|
"tcp-fastopen": { type: "bool" },
|
|
"no-tcp-fastopen": { type: "bool", name: "tcp-fastopen", expand: false },
|
|
"proxy-tlsuser": { type: "string" },
|
|
"proxy-tlspassword": { type: "string" },
|
|
"proxy-tlsauthtype": { type: "string" },
|
|
"proxy-cert": { type: "string" },
|
|
"proxy-cert-type": { type: "string" },
|
|
"proxy-key": { type: "string" },
|
|
"proxy-key-type": { type: "string" },
|
|
"proxy-pass": { type: "string" },
|
|
"proxy-ciphers": { type: "string" },
|
|
"proxy-crlfile": { type: "string" },
|
|
"proxy-ssl-allow-beast": { type: "bool" },
|
|
"no-proxy-ssl-allow-beast": {
|
|
type: "bool",
|
|
name: "proxy-ssl-allow-beast",
|
|
expand: false,
|
|
},
|
|
"login-options": { type: "string" },
|
|
"proxy-cacert": { type: "string" },
|
|
"proxy-capath": { type: "string" },
|
|
"proxy-insecure": { type: "bool" },
|
|
"no-proxy-insecure": { type: "bool", name: "proxy-insecure", expand: false },
|
|
"proxy-tlsv1": { type: "bool" },
|
|
"socks5-basic": { type: "bool" },
|
|
"no-socks5-basic": { type: "bool", name: "socks5-basic", expand: false },
|
|
"socks5-gssapi": { type: "bool" },
|
|
"no-socks5-gssapi": { type: "bool", name: "socks5-gssapi", expand: false },
|
|
"etag-save": { type: "string" },
|
|
"etag-compare": { type: "string" },
|
|
curves: { type: "string" },
|
|
fail: { type: "bool" },
|
|
"no-fail": { type: "bool", name: "fail", expand: false },
|
|
"fail-early": { type: "bool" },
|
|
"no-fail-early": { type: "bool", name: "fail-early", expand: false },
|
|
"styled-output": { type: "bool" },
|
|
"no-styled-output": { type: "bool", name: "styled-output", expand: false },
|
|
"mail-rcpt-allowfails": { type: "bool" },
|
|
"no-mail-rcpt-allowfails": {
|
|
type: "bool",
|
|
name: "mail-rcpt-allowfails",
|
|
expand: false,
|
|
},
|
|
"fail-with-body": { type: "bool" },
|
|
"no-fail-with-body": { type: "bool", name: "fail-with-body", expand: false },
|
|
form: { type: "string" },
|
|
"form-string": { type: "string" },
|
|
globoff: { type: "bool" },
|
|
"no-globoff": { type: "bool", name: "globoff", expand: false },
|
|
get: { type: "bool" },
|
|
"request-target": { type: "string" },
|
|
help: { type: "bool" },
|
|
"no-help": { type: "bool", name: "help", expand: false },
|
|
header: { type: "string" },
|
|
"proxy-header": { type: "string" },
|
|
include: { type: "bool" },
|
|
"no-include": { type: "bool", name: "include", expand: false },
|
|
head: { type: "bool" },
|
|
"no-head": { type: "bool", name: "head", expand: false },
|
|
"junk-session-cookies": { type: "bool" },
|
|
"no-junk-session-cookies": {
|
|
type: "bool",
|
|
name: "junk-session-cookies",
|
|
expand: false,
|
|
},
|
|
"remote-header-name": { type: "bool" },
|
|
"no-remote-header-name": {
|
|
type: "bool",
|
|
name: "remote-header-name",
|
|
expand: false,
|
|
},
|
|
insecure: { type: "bool" },
|
|
"no-insecure": { type: "bool", name: "insecure", expand: false },
|
|
"doh-insecure": { type: "bool" },
|
|
"no-doh-insecure": { type: "bool", name: "doh-insecure", expand: false },
|
|
config: { type: "string" },
|
|
"list-only": { type: "bool" },
|
|
"no-list-only": { type: "bool", name: "list-only", expand: false },
|
|
location: { type: "bool" },
|
|
"no-location": { type: "bool", name: "location", expand: false },
|
|
"location-trusted": { type: "bool" },
|
|
"no-location-trusted": {
|
|
type: "bool",
|
|
name: "location-trusted",
|
|
expand: false,
|
|
},
|
|
"max-time": { type: "string" },
|
|
manual: { type: "bool" },
|
|
"no-manual": { type: "bool", name: "manual", expand: false },
|
|
netrc: { type: "bool" },
|
|
"no-netrc": { type: "bool", name: "netrc", expand: false },
|
|
"netrc-optional": { type: "bool" },
|
|
"no-netrc-optional": { type: "bool", name: "netrc-optional", expand: false },
|
|
"netrc-file": { type: "string" },
|
|
buffer: { type: "bool" },
|
|
"no-buffer": { type: "bool", name: "buffer", expand: false },
|
|
output: { type: "string" },
|
|
"remote-name": { type: "bool" },
|
|
"remote-name-all": { type: "bool" },
|
|
"no-remote-name-all": {
|
|
type: "bool",
|
|
name: "remote-name-all",
|
|
expand: false,
|
|
},
|
|
"output-dir": { type: "string" },
|
|
proxytunnel: { type: "bool" },
|
|
"no-proxytunnel": { type: "bool", name: "proxytunnel", expand: false },
|
|
"ftp-port": { type: "string" },
|
|
disable: { type: "bool" },
|
|
"no-disable": { type: "bool", name: "disable", expand: false },
|
|
quote: { type: "string" },
|
|
range: { type: "string" },
|
|
"remote-time": { type: "bool" },
|
|
"no-remote-time": { type: "bool", name: "remote-time", expand: false },
|
|
silent: { type: "bool" },
|
|
"no-silent": { type: "bool", name: "silent", expand: false },
|
|
"show-error": { type: "bool" },
|
|
"no-show-error": { type: "bool", name: "show-error", expand: false },
|
|
"telnet-option": { type: "string" },
|
|
"upload-file": { type: "string" },
|
|
user: { type: "string" },
|
|
"proxy-user": { type: "string" },
|
|
verbose: { type: "bool" },
|
|
"no-verbose": { type: "bool", name: "verbose", expand: false },
|
|
version: { type: "bool" },
|
|
"no-version": { type: "bool", name: "version", expand: false },
|
|
"write-out": { type: "string" },
|
|
proxy: { type: "string" },
|
|
preproxy: { type: "string" },
|
|
request: { type: "string" },
|
|
"speed-limit": { type: "string" },
|
|
"speed-time": { type: "string" },
|
|
"time-cond": { type: "string" },
|
|
parallel: { type: "bool" },
|
|
"no-parallel": { type: "bool", name: "parallel", expand: false },
|
|
"parallel-max": { type: "string" },
|
|
"parallel-immediate": { type: "bool" },
|
|
"no-parallel-immediate": {
|
|
type: "bool",
|
|
name: "parallel-immediate",
|
|
expand: false,
|
|
},
|
|
"progress-bar": { type: "bool" },
|
|
"no-progress-bar": { type: "bool", name: "progress-bar", expand: false },
|
|
"progress-meter": { type: "bool" },
|
|
"no-progress-meter": { type: "bool", name: "progress-meter", expand: false },
|
|
next: { type: "bool" },
|
|
};
|
|
|
|
const curlShortOpts: ShortOpts = {
|
|
0: "http1.0",
|
|
1: "tlsv1",
|
|
2: "sslv2",
|
|
3: "sslv3",
|
|
4: "ipv4",
|
|
6: "ipv6",
|
|
a: "append",
|
|
A: "user-agent",
|
|
b: "cookie",
|
|
B: "use-ascii",
|
|
c: "cookie-jar",
|
|
C: "continue-at",
|
|
d: "data",
|
|
D: "dump-header",
|
|
e: "referer",
|
|
E: "cert",
|
|
f: "fail",
|
|
F: "form",
|
|
g: "globoff",
|
|
G: "get",
|
|
h: "help",
|
|
H: "header",
|
|
i: "include",
|
|
I: "head",
|
|
j: "junk-session-cookies",
|
|
J: "remote-header-name",
|
|
k: "insecure",
|
|
K: "config",
|
|
l: "list-only",
|
|
L: "location",
|
|
m: "max-time",
|
|
M: "manual",
|
|
n: "netrc",
|
|
N: "no-buffer",
|
|
o: "output",
|
|
O: "remote-name",
|
|
p: "proxytunnel",
|
|
P: "ftp-port",
|
|
q: "disable",
|
|
Q: "quote",
|
|
r: "range",
|
|
R: "remote-time",
|
|
s: "silent",
|
|
S: "show-error",
|
|
t: "telnet-option",
|
|
T: "upload-file",
|
|
u: "user",
|
|
U: "proxy-user",
|
|
v: "verbose",
|
|
V: "version",
|
|
w: "write-out",
|
|
x: "proxy",
|
|
X: "request",
|
|
Y: "speed-limit",
|
|
y: "speed-time",
|
|
z: "time-cond",
|
|
Z: "parallel",
|
|
"#": "progress-bar",
|
|
":": "next",
|
|
};
|
|
// END GENERATED CURL OPTIONS
|
|
|
|
// These are options that curl used to have.
|
|
// Those that don't conflict with the current options are supported by curlconverter.
|
|
// TODO: curl's --long-options can be shortened.
|
|
// For example if curl used to only have a single option, "--blah" then
|
|
// "--bla" "--bl" and "--b" all used to be valid options as well. If later
|
|
// "--blaz" was added, suddenly those 3 shortened options are removed (because
|
|
// they are now ambiguous).
|
|
// https://github.com/curlconverter/curlconverter/pull/280#issuecomment-931241328
|
|
const _removedLongOpts: { [key: string]: _LongShort } = {
|
|
"ftp-ascii": { type: "bool", name: "use-ascii", removed: "7.10.7" },
|
|
port: { type: "string", removed: "7.3" },
|
|
upload: { type: "bool", removed: "7.7" },
|
|
continue: { type: "bool", removed: "7.9" },
|
|
"3p-url": { type: "string", removed: "7.16.0" },
|
|
"3p-user": { type: "string", removed: "7.16.0" },
|
|
"3p-quote": { type: "string", removed: "7.16.0" },
|
|
"http2.0": { type: "bool", name: "http2", removed: "7.36.0" },
|
|
"no-http2.0": { type: "bool", name: "http2", removed: "7.36.0" },
|
|
"telnet-options": {
|
|
type: "string",
|
|
name: "telnet-option",
|
|
removed: "7.49.0",
|
|
},
|
|
"http-request": { type: "string", name: "request", removed: "7.49.0" },
|
|
socks: { type: "string", name: "socks5", removed: "7.49.0" },
|
|
"capath ": { type: "string", name: "capath", removed: "7.49.0" }, // trailing space
|
|
ftpport: { type: "string", name: "ftp-port", removed: "7.49.0" },
|
|
environment: { type: "bool", removed: "7.54.1" },
|
|
// These --no-<option> flags were automatically generated and never had any effect
|
|
"no-tlsv1": { type: "bool", name: "tlsv1", removed: "7.54.1" },
|
|
"no-tlsv1.2": { type: "bool", name: "tlsv1.2", removed: "7.54.1" },
|
|
"no-http2-prior-knowledge": {
|
|
type: "bool",
|
|
name: "http2-prior-knowledge",
|
|
removed: "7.54.1",
|
|
},
|
|
"no-ipv6": { type: "bool", name: "ipv6", removed: "7.54.1" },
|
|
"no-ipv4": { type: "bool", name: "ipv4", removed: "7.54.1" },
|
|
"no-sslv2": { type: "bool", name: "sslv2", removed: "7.54.1" },
|
|
"no-tlsv1.0": { type: "bool", name: "tlsv1.0", removed: "7.54.1" },
|
|
"no-tlsv1.1": { type: "bool", name: "tlsv1.1", removed: "7.54.1" },
|
|
"no-remote-name": { type: "bool", name: "remote-name", removed: "7.54.1" },
|
|
"no-sslv3": { type: "bool", name: "sslv3", removed: "7.54.1" },
|
|
"no-get": { type: "bool", name: "get", removed: "7.54.1" },
|
|
"no-http1.0": { type: "bool", name: "http1.0", removed: "7.54.1" },
|
|
"no-next": { type: "bool", name: "next", removed: "7.54.1" },
|
|
"no-tlsv1.3": { type: "bool", name: "tlsv1.3", removed: "7.54.1" },
|
|
"no-environment": { type: "bool", name: "environment", removed: "7.54.1" },
|
|
"no-http1.1": { type: "bool", name: "http1.1", removed: "7.54.1" },
|
|
"no-proxy-tlsv1": { type: "bool", name: "proxy-tlsv1", removed: "7.54.1" },
|
|
"no-http2": { type: "bool", name: "http2", removed: "7.54.1" },
|
|
};
|
|
for (const [opt, val] of Object.entries(_removedLongOpts)) {
|
|
if (!has(val, "name")) {
|
|
val.name = opt;
|
|
}
|
|
}
|
|
const removedLongOpts = _removedLongOpts as LongOpts; // could be stricter, there's no null values
|
|
// TODO: use this to warn users when they specify a short option that
|
|
// used to be for something else?
|
|
const changedShortOpts: { [key: string]: string } = {
|
|
p: "used to be short for --port <port> (a since-deleted flag) until curl 7.3",
|
|
// TODO: some of these might be renamed options
|
|
t: "used to be short for --upload (a since-deleted boolean flag) until curl 7.7",
|
|
c: "used to be short for --continue (a since-deleted boolean flag) until curl 7.9",
|
|
// TODO: did -@ actually work?
|
|
"@": "used to be short for --create-dirs until curl 7.10.7",
|
|
Z: "used to be short for --max-redirs <num> until curl 7.10.7",
|
|
9: "used to be short for --crlf until curl 7.10.8",
|
|
8: "used to be short for --stderr <file> until curl 7.10.8",
|
|
7: "used to be short for --interface <name> until curl 7.10.8",
|
|
6: "used to be short for --krb <level> (which itself used to be --krb4 <level>) until curl 7.10.8",
|
|
// TODO: did these short options ever actually work?
|
|
5: "used to be another way to specify the url until curl 7.10.8",
|
|
"*": "used to be another way to specify the url until curl 7.49.0",
|
|
"~": "used to be short for --xattr until curl 7.49.0",
|
|
};
|
|
|
|
// These options can be specified more than once, they
|
|
// are always returned as a list.
|
|
// Normally, if you specify some option more than once,
|
|
// curl will just take the last one.
|
|
// TODO: extract this from curl's source code?
|
|
const canBeList = new Set([
|
|
// TODO: unlike curl, we don't support multiple
|
|
// URLs and just take the last one.
|
|
"url",
|
|
"header",
|
|
"proxy-header",
|
|
"form",
|
|
"data",
|
|
"data-binary",
|
|
"data-ascii",
|
|
"data-raw",
|
|
"data-urlencode",
|
|
"json",
|
|
"mail-rcpt",
|
|
"resolve",
|
|
"connect-to",
|
|
"cookie",
|
|
"quote",
|
|
"telnet-option",
|
|
]);
|
|
|
|
const shortened: { [key: string]: LongShort[] } = {};
|
|
for (const [opt, val] of Object.entries(_curlLongOpts)) {
|
|
if (!has(val, "name")) {
|
|
val.name = opt;
|
|
}
|
|
}
|
|
const curlLongOpts = _curlLongOpts as LongOpts;
|
|
for (const [opt, val] of Object.entries(_curlLongOpts)) {
|
|
// curl lets you not type the full argument as long as it's unambiguous.
|
|
// So --sil instead of --silent is okay, --s is not.
|
|
// This doesn't apply to options starting with --no-
|
|
// Default 'expand' to true if not specified
|
|
const shouldExpand = !has(val, "expand") || val.expand;
|
|
delete val.expand;
|
|
if (shouldExpand) {
|
|
for (let i = 1; i < opt.length; i++) {
|
|
const shortenedOpt = opt.slice(0, i);
|
|
pushProp(shortened, shortenedOpt, val);
|
|
}
|
|
}
|
|
}
|
|
for (const [shortenedOpt, vals] of Object.entries(shortened)) {
|
|
if (!has(curlLongOpts, shortenedOpt)) {
|
|
if (vals.length === 1) {
|
|
(curlLongOpts[shortenedOpt] as LongShort | null) = vals[0];
|
|
} else if (vals.length > 1) {
|
|
// More than one option shortens to this, it's ambiguous
|
|
(curlLongOpts[shortenedOpt] as LongShort | null) = null;
|
|
}
|
|
}
|
|
}
|
|
for (const [removedOpt, val] of Object.entries(removedLongOpts)) {
|
|
if (!has(curlLongOpts, removedOpt)) {
|
|
(curlLongOpts[removedOpt] as LongShort | null) = val;
|
|
} else if (curlLongOpts[removedOpt] === null) {
|
|
// This happens with --socks because it became --socks5 and there are multiple options
|
|
// that start with "--socks"
|
|
// console.error("couldn't add removed option --" + removedOpt + " to curlLongOpts because it's already ambiguous")
|
|
// TODO: do we want to do this?
|
|
// curlLongOpts[removedOpt] = val
|
|
} else {
|
|
// Almost certainly a shortened form of a still-existing option
|
|
// This happens with --continue (now short for --continue-at)
|
|
// and --upload (now short for --upload-file)
|
|
// console.error("couldn't add removed option --" + removedOpt + ' to curlLongOpts because it already exists')
|
|
}
|
|
}
|
|
|
|
function toBoolean(opt: string): boolean {
|
|
if (opt.startsWith("no-disable-")) {
|
|
return true;
|
|
}
|
|
if (opt.startsWith("disable-") || opt.startsWith("no-")) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
const parseWord = (str: string): string => {
|
|
const BACKSLASHES = /\\./gs;
|
|
const unescapeChar = (m: string) => (m.charAt(1) === "\n" ? "" : m.charAt(1));
|
|
return str.replace(BACKSLASHES, unescapeChar);
|
|
};
|
|
const parseSingleQuoteString = (str: string): string => {
|
|
const BACKSLASHES = /\\(\n|')/gs;
|
|
const unescapeChar = (m: string) => (m.charAt(1) === "\n" ? "" : m.charAt(1));
|
|
return str.slice(1, -1).replace(BACKSLASHES, unescapeChar);
|
|
};
|
|
const parseDoubleQuoteString = (str: string): string => {
|
|
const BACKSLASHES = /\\(\n|\\|")/gs;
|
|
const unescapeChar = (m: string) => (m.charAt(1) === "\n" ? "" : m.charAt(1));
|
|
return str.slice(1, -1).replace(BACKSLASHES, unescapeChar);
|
|
};
|
|
// ANSI-C quoted strings look $'like this'.
|
|
// Not all shells have them but bash does
|
|
// https://www.gnu.org/software/bash/manual/html_node/ANSI_002dC-Quoting.html
|
|
//
|
|
// https://git.savannah.gnu.org/cgit/bash.git/tree/lib/sh/strtrans.c
|
|
const parseAnsiCString = (str: string): string => {
|
|
const ANSI_BACKSLASHES =
|
|
/\\(\\|a|b|e|E|f|n|r|t|v|'|"|\?|[0-7]{1,3}|x[0-9A-Fa-f]{1,2}|u[0-9A-Fa-f]{1,4}|U[0-9A-Fa-f]{1,8}|c.)/gs;
|
|
const unescapeChar = (m: string) => {
|
|
switch (m.charAt(1)) {
|
|
case "\\":
|
|
return "\\";
|
|
case "a":
|
|
return "\x07";
|
|
case "b":
|
|
return "\b";
|
|
case "e":
|
|
case "E":
|
|
return "\x1B";
|
|
case "f":
|
|
return "\f";
|
|
case "n":
|
|
return "\n";
|
|
case "r":
|
|
return "\r";
|
|
case "t":
|
|
return "\t";
|
|
case "v":
|
|
return "\v";
|
|
case "'":
|
|
return "'";
|
|
case '"':
|
|
return '"';
|
|
case "?":
|
|
return "?";
|
|
case "c":
|
|
// bash handles all characters by considering the first byte
|
|
// of its UTF-8 input and can produce invalid UTF-8, whereas
|
|
// JavaScript stores strings in UTF-16
|
|
if (m.codePointAt(2)! > 127) {
|
|
throw new CCError(
|
|
"non-ASCII control character in ANSI-C quoted string: '\\u{" +
|
|
m.codePointAt(2)!.toString(16) +
|
|
"}'"
|
|
);
|
|
}
|
|
// If this produces a 0x00 (null) character, it will cause bash to
|
|
// terminate the string at that character, but we return the null
|
|
// character in the result.
|
|
return m[2] === "?"
|
|
? "\x7F"
|
|
: String.fromCodePoint(
|
|
m[2].toUpperCase().codePointAt(0)! & 0b00011111
|
|
);
|
|
case "x":
|
|
case "u":
|
|
case "U":
|
|
// Hexadecimal character literal
|
|
// Unlike bash, this will error if the the code point is greater than 10FFFF
|
|
return String.fromCodePoint(parseInt(m.slice(2), 16));
|
|
case "0":
|
|
case "1":
|
|
case "2":
|
|
case "3":
|
|
case "4":
|
|
case "5":
|
|
case "6":
|
|
case "7":
|
|
// Octal character literal
|
|
return String.fromCodePoint(parseInt(m.slice(1), 8) % 256);
|
|
default:
|
|
// There must be a mis-match between ANSI_BACKSLASHES and the switch statement
|
|
throw new CCError(
|
|
"unhandled character in ANSI-C escape code: " + JSON.stringify(m)
|
|
);
|
|
}
|
|
};
|
|
|
|
return str.slice(2, -1).replace(ANSI_BACKSLASHES, unescapeChar);
|
|
};
|
|
|
|
function toVal(node: any): string {
|
|
// TODO: typing node is hard because of the browser/nodejs import difference
|
|
switch (node.type) {
|
|
case "word":
|
|
case "simple_expansion": // TODO: handle variables properly downstream
|
|
return parseWord(node.text);
|
|
case "string":
|
|
return parseDoubleQuoteString(node.text);
|
|
case "raw_string":
|
|
return parseSingleQuoteString(node.text);
|
|
case "ansii_c_string":
|
|
return parseAnsiCString(node.text);
|
|
case "concatenation":
|
|
// item[]=1 turns into item=1 if we don't do this
|
|
// https://github.com/tree-sitter/tree-sitter-bash/issues/104
|
|
// TODO: type `n` if you type `node`
|
|
if (node.children.every((n: any) => n.type === "word")) {
|
|
return node.text;
|
|
}
|
|
return node.children.map(toVal).join("");
|
|
default:
|
|
// console.error(curlCommand)
|
|
// console.error(curlArgs.rootNode.toString())
|
|
throw new CCError(
|
|
"unexpected argument type " +
|
|
JSON.stringify(node.type) +
|
|
'. Must be one of "word", "string", "raw_string", "ascii_c_string", "simple_expansion" or "concatenation"'
|
|
);
|
|
}
|
|
}
|
|
|
|
interface TokenizeResult {
|
|
cmdName: string;
|
|
args: string[];
|
|
stdin?: string;
|
|
input?: string;
|
|
}
|
|
const tokenize = (curlCommand: string): TokenizeResult => {
|
|
const curlArgs = parser.parse(curlCommand);
|
|
// The AST must be in a nice format, i.e.
|
|
// (program
|
|
// (command
|
|
// name: (command_name (word))
|
|
// argument+: (
|
|
// word |
|
|
// string ('') |
|
|
// raw_string ("") |
|
|
// ansii_c_string ($'') |
|
|
// simple_expansion (variable_name))))
|
|
//
|
|
// TODO: support strings with variable expansions inside
|
|
// TODO: support prefixed variables, e.g. "MY_VAR=hello curl example.com"
|
|
// TODO: get only named children?
|
|
if (curlArgs.rootNode.type !== "program") {
|
|
// TODO: better error message.
|
|
throw new CCError(
|
|
"expected a 'program' top-level AST node, got " +
|
|
curlArgs.rootNode.type +
|
|
" instead"
|
|
);
|
|
}
|
|
|
|
if (curlArgs.rootNode.childCount < 1 || !curlArgs.rootNode.children) {
|
|
// TODO: better error message.
|
|
throw new CCError('empty "program" node');
|
|
}
|
|
|
|
// Get the curl call AST node. Skip comments
|
|
let command, stdin, input;
|
|
for (const programChildNode of curlArgs.rootNode.children) {
|
|
if (programChildNode.type === "comment") {
|
|
continue;
|
|
} else if (programChildNode.type === "command") {
|
|
command = programChildNode;
|
|
// TODO: if there are more `command` nodes,
|
|
// warn that everything after the first one is ignored
|
|
break;
|
|
} else if (programChildNode.type === "redirected_statement") {
|
|
if (!programChildNode.childCount) {
|
|
throw new CCError("got empty 'redirected_statement' AST node");
|
|
}
|
|
let redirect;
|
|
[command, redirect] = programChildNode.children;
|
|
if (command.type !== "command") {
|
|
throw new CCError(
|
|
"got 'redirected_statement' AST node whose first child is not a 'command', got " +
|
|
command.type +
|
|
" instead"
|
|
);
|
|
}
|
|
if (programChildNode.childCount < 2) {
|
|
throw new CCError(
|
|
"got 'redirected_statement' AST node with only one child - no redirect"
|
|
);
|
|
}
|
|
if (redirect.type === "file_redirect") {
|
|
stdin = toVal(redirect.namedChildren[0]);
|
|
} else if (redirect.type === "heredoc_redirect") {
|
|
// heredoc bodies are children of the parent program node
|
|
// https://github.com/tree-sitter/tree-sitter-bash/issues/118
|
|
if (redirect.namedChildCount < 1) {
|
|
throw new CCError(
|
|
"got 'redirected_statement' AST node with heredoc but no heredoc start"
|
|
);
|
|
}
|
|
const heredocStart = redirect.namedChildren[0].text;
|
|
const heredocBody = programChildNode.nextNamedSibling;
|
|
if (!heredocBody) {
|
|
throw new CCError(
|
|
"got 'redirected_statement' AST node with no heredoc body"
|
|
);
|
|
}
|
|
// TODO: herestrings and heredocs are different
|
|
if (heredocBody.type !== "heredoc_body") {
|
|
throw new CCError(
|
|
"got 'redirected_statement' AST node with heredoc but no heredoc body, got " +
|
|
heredocBody.type +
|
|
" instead"
|
|
);
|
|
}
|
|
// TODO: heredocs do variable expansion and stuff
|
|
if (heredocStart.length) {
|
|
input = heredocBody.text.slice(0, -heredocStart.length);
|
|
} else {
|
|
// this shouldn't happen
|
|
input = heredocBody.text;
|
|
}
|
|
// Curl remove newlines when you pass any @filename including @- for stdin
|
|
input = input.replace(/\n/g, "");
|
|
} else if (redirect.type === "herestring_redirect") {
|
|
if (redirect.namedChildCount < 1 || !redirect.firstNamedChild) {
|
|
throw new CCError(
|
|
"got 'redirected_statement' AST node with empty herestring"
|
|
);
|
|
}
|
|
// TODO: this just converts bash code to text
|
|
input = redirect.firstNamedChild.text;
|
|
} else {
|
|
throw new CCError(
|
|
"got 'redirected_statement' AST node whose second child is not one of 'file_redirect', 'heredoc_redirect' or 'herestring_redirect', got " +
|
|
command.type +
|
|
" instead"
|
|
);
|
|
}
|
|
|
|
break;
|
|
} else {
|
|
// TODO: better error message.
|
|
throw new CCError(
|
|
"expected a 'command' or 'redirected_statement' AST node, instead got " +
|
|
curlArgs.rootNode.children[0].type
|
|
);
|
|
}
|
|
}
|
|
if (!command) {
|
|
// NOTE: if you add more node types in the `for` loop above, this error needs to be updated.
|
|
// We would probably need to keep track of the node types we've seen.
|
|
throw new CCError(
|
|
"expected a 'command' or 'redirected_statement' AST node, only found 'comment' nodes"
|
|
);
|
|
}
|
|
|
|
if (command.childCount < 1) {
|
|
// TODO: better error message.
|
|
throw new CCError('empty "command" node');
|
|
}
|
|
// TODO: add childrenForFieldName to tree-sitter node/web bindings and then
|
|
// use that here instead
|
|
// TODO: you can have variable_assignment before the actual command
|
|
// MY_VAR=foo curl example.com
|
|
const [cmdName, ...args] = command.children;
|
|
if (cmdName.type !== "command_name") {
|
|
// TODO: better error message.
|
|
throw new CCError(
|
|
"expected a 'command_name' AST node, got " + cmdName.type + " instead"
|
|
);
|
|
}
|
|
|
|
return {
|
|
cmdName: cmdName.text.trim(),
|
|
args: args.map(toVal),
|
|
stdin,
|
|
input,
|
|
};
|
|
};
|
|
|
|
const parseArgs = (args: string[], opts?: [LongOpts, ShortOpts]) => {
|
|
const [longOpts, shortOpts] = opts || [curlLongOpts, curlShortOpts];
|
|
|
|
const parsedArguments: ParsedArguments = {};
|
|
for (let i = 0, stillflags = true; i < args.length; i++) {
|
|
let arg: string | string[] = args[i];
|
|
let argRepr = arg;
|
|
if (stillflags && arg.startsWith("-")) {
|
|
if (arg === "--") {
|
|
/* This indicates the end of the flags and thus enables the
|
|
following (URL) argument to start with -. */
|
|
stillflags = false;
|
|
} else if (arg.startsWith("--")) {
|
|
const longArg = longOpts[arg.slice(2)];
|
|
if (longArg === null) {
|
|
throw new CCError("option " + arg + ": is ambiguous");
|
|
}
|
|
if (typeof longArg === "undefined") {
|
|
// TODO: extract a list of deleted arguments to check here
|
|
throw new CCError("option " + arg + ": is unknown");
|
|
}
|
|
|
|
if (longArg.type === "string") {
|
|
if (i + 1 < args.length) {
|
|
i++;
|
|
pushProp(parsedArguments, longArg.name, args[i]);
|
|
} else {
|
|
throw new CCError("option " + arg + ": requires parameter");
|
|
}
|
|
} else {
|
|
parsedArguments[longArg.name] = toBoolean(arg.slice(2)); // TODO: all shortened args work correctly?
|
|
}
|
|
} else {
|
|
// Short option. These can look like
|
|
// -X POST -> {request: 'POST'}
|
|
// or
|
|
// -XPOST -> {request: 'POST'}
|
|
// or multiple options
|
|
// -ABCX POST
|
|
// -> {A: true, B: true, C: true, request: 'POST'}
|
|
// or multiple options and a value for the last one
|
|
// -ABCXPOST
|
|
// -> {A: true, B: true, C: true, request: 'POST'}
|
|
|
|
// "-" passed to curl on its own raises an error,
|
|
// curlconverter's command line uses it to read from stdin
|
|
if (arg.length === 1) {
|
|
if (has(shortOpts, "")) {
|
|
arg = ["-", ""];
|
|
argRepr = "-";
|
|
} else {
|
|
throw new CCError("option " + argRepr + ": is unknown");
|
|
}
|
|
}
|
|
for (let j = 1; j < arg.length; j++) {
|
|
if (!has(shortOpts, arg[j])) {
|
|
if (has(changedShortOpts, arg[j])) {
|
|
throw new CCError(
|
|
"option " + argRepr + ": " + changedShortOpts[arg[j]]
|
|
);
|
|
}
|
|
// TODO: there are a few deleted short options we could report
|
|
throw new CCError("option " + argRepr + ": is unknown");
|
|
}
|
|
const shortFor = shortOpts[arg[j]];
|
|
const longArg = longOpts[shortFor];
|
|
if (longArg === null) {
|
|
// This could happen if curlShortOpts points to a renamed option or has a typo
|
|
throw new CCError("ambiguous short option -" + arg[j]);
|
|
}
|
|
if (longArg.type === "string") {
|
|
let val;
|
|
if (j + 1 < arg.length) {
|
|
// treat -XPOST as -X POST
|
|
val = arg.slice(j + 1);
|
|
j = arg.length;
|
|
} else if (i + 1 < args.length) {
|
|
i++;
|
|
val = args[i];
|
|
} else {
|
|
throw new CCError("option " + argRepr + ": requires parameter");
|
|
}
|
|
pushProp(parsedArguments, longArg.name, val);
|
|
} else {
|
|
// Use shortFor because -N is short for --no-buffer
|
|
// and we want to end up with {buffer: false}
|
|
parsedArguments[longArg.name] = toBoolean(shortFor);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
pushProp(parsedArguments, "url", arg);
|
|
}
|
|
}
|
|
|
|
for (const [arg, values] of Object.entries(parsedArguments)) {
|
|
if (Array.isArray(values) && !canBeList.has(arg)) {
|
|
parsedArguments[arg] = values[values.length - 1];
|
|
}
|
|
}
|
|
return parsedArguments;
|
|
};
|
|
|
|
export function parseQueryString(
|
|
s: string | null
|
|
): [Query | null, QueryDict | null] {
|
|
// if url is 'example.com?' => s is ''
|
|
// if url is 'example.com' => s is null
|
|
if (!s) {
|
|
return [null, null];
|
|
}
|
|
|
|
const asList: Query = [];
|
|
for (const param of s.split("&")) {
|
|
const [key, _val] = param.split(/=(.*)/s, 2);
|
|
const val = _val === undefined ? null : _val;
|
|
let decodedKey;
|
|
let decodedVal;
|
|
try {
|
|
// https://url.spec.whatwg.org/#urlencoded-parsing recommends replacing + with space
|
|
// before decoding.
|
|
decodedKey = decodeURIComponent(key.replace(/\+/g, " "));
|
|
decodedVal =
|
|
val === null ? null : decodeURIComponent(val.replace(/\+/g, " "));
|
|
} catch (e) {
|
|
if (e instanceof URIError) {
|
|
// Query string contains invalid percent encoded characters,
|
|
// we cannot properly convert it.
|
|
return [null, null];
|
|
}
|
|
throw e;
|
|
}
|
|
try {
|
|
// If the query string doesn't round-trip, we cannot properly convert it.
|
|
// TODO: this is too strict. Ideally we want to check how each runtime/library
|
|
// percent encodes query strings. For example, a %27 character in the input query
|
|
// string will be decoded to a ' but won't be re-encoded into a %27 by encodeURIComponent
|
|
const percentEncodeChar = (c: string): string =>
|
|
"%" + c.charCodeAt(0).toString(16).padStart(2, "0").toUpperCase();
|
|
// Match Python's urllib.parse.quote() behavior
|
|
// https://stackoverflow.com/questions/946170/equivalent-javascript-functions-for-pythons-urllib-parse-quote-and-urllib-par
|
|
const percentEncode = (s: string): string =>
|
|
encodeURIComponent(s).replace(/[()*!']/g, percentEncodeChar); // .replace('%20', '+')
|
|
const roundTripKey = percentEncode(decodedKey);
|
|
const roundTripVal =
|
|
decodedVal === null ? null : percentEncode(decodedVal);
|
|
// If the original data used %20 instead of + (what requests will send), that's close enough
|
|
if (
|
|
(roundTripKey !== key && roundTripKey.replace(/%20/g, "+") !== key) ||
|
|
(roundTripVal !== null &&
|
|
roundTripVal !== val &&
|
|
roundTripVal.replace(/%20/g, "+") !== val)
|
|
) {
|
|
return [null, null];
|
|
}
|
|
} catch (e) {
|
|
if (e instanceof URIError) {
|
|
return [null, null];
|
|
}
|
|
throw e;
|
|
}
|
|
asList.push([decodedKey, decodedVal]);
|
|
}
|
|
|
|
// Group keys
|
|
const asDict: QueryDict = {};
|
|
let prevKey = null;
|
|
for (const [key, val] of asList) {
|
|
if (prevKey === key) {
|
|
(asDict[key] as Array<string | null>).push(val);
|
|
} else {
|
|
if (!has(asDict, key)) {
|
|
(asDict[key] as Array<string | null>) = [val];
|
|
} else {
|
|
// If there's a repeated key with a different key between
|
|
// one of its repetitions, there is no way to represent
|
|
// this query string as a dictionary.
|
|
return [asList, null];
|
|
}
|
|
}
|
|
prevKey = key;
|
|
}
|
|
|
|
// Convert lists with 1 element to the element
|
|
for (const [key, val] of Object.entries(asDict)) {
|
|
if ((val as Array<string | null>).length === 1) {
|
|
asDict[key] = (val as Array<string | null>)[0];
|
|
}
|
|
}
|
|
|
|
return [asList, asDict];
|
|
}
|
|
|
|
function buildRequest(parsedArguments: ParsedArguments): Request {
|
|
// TODO: handle multiple URLs
|
|
if (!parsedArguments.url || !parsedArguments.url.length) {
|
|
// TODO: better error message (could be parsing fail)
|
|
throw new CCError("no URL specified!");
|
|
}
|
|
let url = parsedArguments.url[parsedArguments.url.length - 1];
|
|
|
|
const headers: Headers = [];
|
|
if (parsedArguments.header) {
|
|
for (const header of parsedArguments.header) {
|
|
if (header.includes(":")) {
|
|
const [name, value] = header.split(/:(.*)/s, 2);
|
|
if (!value.trim()) {
|
|
headers.push([name, null]);
|
|
} else {
|
|
headers.push([name, value.replace(/^ /, "")]);
|
|
}
|
|
} else if (header.includes(";")) {
|
|
const [name] = header.split(/;(.*)/s, 2);
|
|
headers.push([name, ""]);
|
|
}
|
|
}
|
|
}
|
|
const lowercase =
|
|
headers.length > 0 && headers.every((h) => h[0] === h[0].toLowerCase());
|
|
|
|
let cookies;
|
|
const cookieHeaders = headers.filter((h) => h[0].toLowerCase() === "cookie");
|
|
if (cookieHeaders.length === 1 && cookieHeaders[0][1] !== null) {
|
|
const parsedCookies = parseCookiesStrict(cookieHeaders[0][1]);
|
|
if (parsedCookies) {
|
|
cookies = parsedCookies;
|
|
}
|
|
} else if (cookieHeaders.length === 0) {
|
|
// If there is a Cookie header, --cookies is ignored
|
|
if (parsedArguments.cookie) {
|
|
// TODO: a --cookie without a = character reads from it as a filename
|
|
const cookieString = parsedArguments.cookie.join(";");
|
|
_setHeaderIfMissing(headers, "Cookie", cookieString, lowercase);
|
|
cookies = parseCookies(cookieString);
|
|
}
|
|
}
|
|
|
|
if (parsedArguments["user-agent"]) {
|
|
_setHeaderIfMissing(
|
|
headers,
|
|
"User-Agent",
|
|
parsedArguments["user-agent"],
|
|
lowercase
|
|
);
|
|
}
|
|
|
|
if (parsedArguments.referer) {
|
|
// referer can be ";auto" or followed by ";auto", we ignore that.
|
|
const referer = parsedArguments.referer.replace(/;auto$/, "");
|
|
if (referer) {
|
|
_setHeaderIfMissing(headers, "Referer", referer, lowercase);
|
|
}
|
|
}
|
|
|
|
// curl expects you to uppercase methods always. If you do -X PoSt, that's what it
|
|
// will send, but most APIs will helpfully uppercase what you pass in as the method.
|
|
// TODO: read curl's source to figure out precedence rules.
|
|
let method = "GET";
|
|
if (parsedArguments.head) {
|
|
method = "HEAD";
|
|
} else if (
|
|
has(parsedArguments, "request") &&
|
|
parsedArguments.request !== "null"
|
|
) {
|
|
// Safari adds `-Xnull` if it can't determine the request type
|
|
method = parsedArguments.request as string;
|
|
} else if (parsedArguments["upload-file"]) {
|
|
// --upload-file '' doesn't do anything.
|
|
method = "PUT";
|
|
} else if (
|
|
(has(parsedArguments, "data") ||
|
|
has(parsedArguments, "data-ascii") ||
|
|
has(parsedArguments, "data-binary") ||
|
|
has(parsedArguments, "data-raw") ||
|
|
has(parsedArguments, "form") ||
|
|
has(parsedArguments, "json")) &&
|
|
!parsedArguments.get
|
|
) {
|
|
method = "POST";
|
|
}
|
|
|
|
const urlObject = URL.parse(url); // eslint-disable-line
|
|
// if GET request with data, convert data to query string
|
|
// NB: the -G flag does not change the http verb. It just moves the data into the url.
|
|
// TODO: this probably has a lot of mismatches with curl
|
|
if (parsedArguments.get) {
|
|
urlObject.query = urlObject.query ? urlObject.query : "";
|
|
if (has(parsedArguments, "data") && parsedArguments.data !== undefined) {
|
|
let urlQueryString = "";
|
|
|
|
if (url.indexOf("?") < 0) {
|
|
url += "?";
|
|
} else {
|
|
urlQueryString += "&";
|
|
}
|
|
|
|
urlQueryString += parsedArguments.data.join("&");
|
|
urlObject.query += urlQueryString;
|
|
// TODO: url and urlObject will be different if url has an #id
|
|
url += urlQueryString;
|
|
delete parsedArguments.data;
|
|
}
|
|
}
|
|
if (urlObject.query && urlObject.query.endsWith("&")) {
|
|
urlObject.query = urlObject.query.slice(0, -1);
|
|
}
|
|
const [queryAsList, queryAsDict] = parseQueryString(urlObject.query);
|
|
const useParsedQuery =
|
|
queryAsList &&
|
|
queryAsList.length &&
|
|
queryAsList.every((p) => p[1] !== null);
|
|
// Most software libraries don't let you distinguish between a=&b= and a&b,
|
|
// so if we get an `a&b`-type query string, don't bother.
|
|
let urlWithoutQuery;
|
|
if (useParsedQuery) {
|
|
urlObject.search = null; // Clean out the search/query portion.
|
|
urlWithoutQuery = URL.format(urlObject);
|
|
} else {
|
|
urlWithoutQuery = url; // TODO: rename?
|
|
}
|
|
|
|
const request: Request = { url, method, urlWithoutQuery };
|
|
if (useParsedQuery) {
|
|
request.query = queryAsList;
|
|
if (queryAsDict) {
|
|
request.queryDict = queryAsDict;
|
|
}
|
|
}
|
|
|
|
if (cookies) {
|
|
// generators that use .cookies need to do
|
|
// deleteHeader(request, 'cookie')
|
|
request.cookies = cookies;
|
|
}
|
|
|
|
if (parsedArguments.compressed) {
|
|
request.compressed = true;
|
|
}
|
|
|
|
// TODO: all of these could be specified in the same command.
|
|
// They also need to maintain order.
|
|
// TODO: do all of these allow @file?
|
|
let data;
|
|
if (parsedArguments.data) {
|
|
data = parsedArguments.data;
|
|
_setHeaderIfMissing(
|
|
headers,
|
|
"Content-Type",
|
|
"application/x-www-form-urlencoded",
|
|
lowercase
|
|
);
|
|
} else if (parsedArguments["data-binary"]) {
|
|
data = parsedArguments["data-binary"];
|
|
request.isDataBinary = true;
|
|
_setHeaderIfMissing(
|
|
headers,
|
|
"Content-Type",
|
|
"application/x-www-form-urlencoded",
|
|
lowercase
|
|
);
|
|
} else if (parsedArguments["data-ascii"]) {
|
|
data = parsedArguments["data-ascii"];
|
|
_setHeaderIfMissing(
|
|
headers,
|
|
"Content-Type",
|
|
"application/x-www-form-urlencoded",
|
|
lowercase
|
|
);
|
|
} else if (parsedArguments["data-raw"]) {
|
|
data = parsedArguments["data-raw"];
|
|
request.isDataRaw = true;
|
|
_setHeaderIfMissing(
|
|
headers,
|
|
"Content-Type",
|
|
"application/x-www-form-urlencoded",
|
|
lowercase
|
|
);
|
|
} else if (parsedArguments["data-urlencode"]) {
|
|
// TODO: this doesn't exactly match curl
|
|
// all '&' and all but the first '=' need to be escaped
|
|
data = parsedArguments["data-urlencode"];
|
|
_setHeaderIfMissing(
|
|
headers,
|
|
"Content-Type",
|
|
"application/x-www-form-urlencoded",
|
|
lowercase
|
|
);
|
|
} else if (parsedArguments.json) {
|
|
data = parsedArguments.json;
|
|
_setHeaderIfMissing(headers, "Content-Type", "application/json", lowercase);
|
|
_setHeaderIfMissing(headers, "Accept", "application/json", lowercase);
|
|
} else if (parsedArguments.form) {
|
|
request.multipartUploads = [];
|
|
for (const multipartArgument of parsedArguments.form) {
|
|
// -F is the most complicated option, we just assume it looks
|
|
// like key=value and some generators handle value being @filepath
|
|
// TODO: https://curl.se/docs/manpage.html#-F
|
|
const [key, value] = multipartArgument.split(/=(.*)/s, 2);
|
|
request.multipartUploads.push([key, value || ""]);
|
|
}
|
|
}
|
|
|
|
if (headers.length > 0) {
|
|
for (let i = headers.length - 1; i >= 0; i--) {
|
|
if (headers[i][1] === null) {
|
|
// TODO: ideally we should generate code that explicitly unsets the header too
|
|
headers.splice(i, 1);
|
|
}
|
|
}
|
|
request.headers = headers;
|
|
}
|
|
|
|
if (parsedArguments.user) {
|
|
const [user, pass] = parsedArguments.user.split(/:(.*)/s, 2);
|
|
request.auth = [user, pass || ""];
|
|
}
|
|
if (parsedArguments.digest) {
|
|
request.digest = parsedArguments.digest;
|
|
}
|
|
if (data) {
|
|
if (data.length > 1) {
|
|
request.dataArray = data;
|
|
request.data = data.join(parsedArguments.json ? "" : "&");
|
|
} else {
|
|
request.data = data[0];
|
|
}
|
|
}
|
|
|
|
if (parsedArguments.insecure) {
|
|
request.insecure = true;
|
|
}
|
|
// TODO: if the URL doesn't start with https://, curl doesn't verify
|
|
// certificates, etc.
|
|
if (parsedArguments.cert) {
|
|
// --key has no effect if --cert isn't passed
|
|
request.cert = parsedArguments.key
|
|
? [parsedArguments.cert, parsedArguments.key]
|
|
: parsedArguments.cert;
|
|
}
|
|
if (parsedArguments.cacert) {
|
|
request.cacert = parsedArguments.cacert;
|
|
}
|
|
if (parsedArguments.capath) {
|
|
request.capath = parsedArguments.capath;
|
|
}
|
|
if (parsedArguments.proxy) {
|
|
request.proxy = parsedArguments.proxy;
|
|
if (parsedArguments["proxy-user"]) {
|
|
request.proxyAuth = parsedArguments["proxy-user"];
|
|
}
|
|
}
|
|
if (parsedArguments["max-time"]) {
|
|
request.timeout = parsedArguments["max-time"];
|
|
}
|
|
if (parsedArguments.location) {
|
|
request.followRedirects = true;
|
|
}
|
|
if (parsedArguments.output) {
|
|
request.output = parsedArguments.output;
|
|
}
|
|
|
|
if (parsedArguments.http2) {
|
|
request.http2 = parsedArguments.http2;
|
|
}
|
|
if (parsedArguments.http3) {
|
|
request.http3 = parsedArguments.http3;
|
|
}
|
|
|
|
return request;
|
|
}
|
|
|
|
const parseCurlCommand = (curlCommand: string | string[]): Request => {
|
|
let cmdName: string,
|
|
args: string[],
|
|
stdin: undefined | string,
|
|
input: undefined | string;
|
|
if (Array.isArray(curlCommand)) {
|
|
[cmdName, ...args] = curlCommand;
|
|
if (typeof cmdName === "undefined") {
|
|
throw new CCError("no arguments provided");
|
|
}
|
|
} else {
|
|
({ cmdName, args, stdin, input } = tokenize(curlCommand));
|
|
if (typeof cmdName === "undefined") {
|
|
throw new CCError("failed to parse input");
|
|
}
|
|
}
|
|
if (cmdName.trim() !== "curl") {
|
|
const shortenedCmdName =
|
|
cmdName.length > 30 ? cmdName.slice(0, 27) + "..." : cmdName;
|
|
if (cmdName.startsWith("curl ")) {
|
|
throw new CCError(
|
|
'command should begin with a single token "curl" but instead begins with ' +
|
|
JSON.stringify(shortenedCmdName)
|
|
);
|
|
} else {
|
|
throw new CCError(
|
|
'command should begin with "curl" but instead begins with ' +
|
|
JSON.stringify(shortenedCmdName)
|
|
);
|
|
}
|
|
}
|
|
|
|
const parsedArguments = parseArgs(args);
|
|
const request = buildRequest(parsedArguments);
|
|
if (stdin) {
|
|
request.stdin = stdin;
|
|
}
|
|
if (input) {
|
|
request.input = input;
|
|
}
|
|
return request;
|
|
};
|
|
|
|
// Gets the first header, matching case-insensitively
|
|
const getHeader = (
|
|
request: Request,
|
|
header: string
|
|
): string | null | undefined => {
|
|
if (!request.headers) {
|
|
return undefined;
|
|
}
|
|
const lookup = header.toLowerCase();
|
|
for (const [h, v] of request.headers) {
|
|
if (h.toLowerCase() === lookup) {
|
|
return v;
|
|
}
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const _hasHeader = (headers: Headers, header: string): boolean => {
|
|
const lookup = header.toLowerCase();
|
|
for (const h of headers) {
|
|
if (h[0].toLowerCase() === lookup) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const hasHeader = (request: Request, header: string): boolean | undefined => {
|
|
if (!request.headers) {
|
|
return;
|
|
}
|
|
return _hasHeader(request.headers, header);
|
|
};
|
|
|
|
const _setHeaderIfMissing = (
|
|
headers: Headers,
|
|
header: string,
|
|
value: string,
|
|
lowercase: boolean | number = false
|
|
): boolean => {
|
|
if (_hasHeader(headers, header)) {
|
|
return false;
|
|
}
|
|
headers.push([lowercase ? header.toLowerCase() : header, value]);
|
|
return true;
|
|
};
|
|
const setHeaderIfMissing = (
|
|
request: Request,
|
|
header: string,
|
|
value: string,
|
|
lowercase: boolean | number = false
|
|
) => {
|
|
if (!request.headers) {
|
|
return;
|
|
}
|
|
return _setHeaderIfMissing(request.headers, header, value, lowercase);
|
|
};
|
|
|
|
const _deleteHeader = (headers: Headers, header: string) => {
|
|
const lookup = header.toLowerCase();
|
|
for (let i = headers.length - 1; i >= 0; i--) {
|
|
if (headers[i][0].toLowerCase() === lookup) {
|
|
headers.splice(i, 1);
|
|
}
|
|
}
|
|
};
|
|
|
|
const deleteHeader = (request: Request, header: string) => {
|
|
if (!request.headers) {
|
|
return;
|
|
}
|
|
return _deleteHeader(request.headers, header);
|
|
};
|
|
|
|
const countHeader = (request: Request, header: string) => {
|
|
let count = 0;
|
|
const lookup = header.toLowerCase();
|
|
for (const h of request.headers || []) {
|
|
if (h[0].toLowerCase() === lookup) {
|
|
count += 1;
|
|
}
|
|
}
|
|
return count;
|
|
};
|
|
|
|
const parseCookiesStrict = (cookieString: string): Cookies | null => {
|
|
const cookies: Cookies = [];
|
|
for (let cookie of cookieString.split(";")) {
|
|
cookie = cookie.replace(/^ /, "");
|
|
const [name, value] = cookie.split(/=(.*)/s, 2);
|
|
if (value === undefined) {
|
|
return null;
|
|
}
|
|
cookies.push([name, value]);
|
|
}
|
|
return cookies;
|
|
};
|
|
|
|
const parseCookies = (cookieString: string): Cookies => {
|
|
const cookies: Cookies = [];
|
|
for (let cookie of cookieString.split(";")) {
|
|
cookie = cookie.trim();
|
|
if (!cookie) {
|
|
continue;
|
|
}
|
|
const [name, value] = cookie.split(/=(.*)/s, 2);
|
|
cookies.push([name, value || ""]);
|
|
}
|
|
return cookies;
|
|
};
|
|
|
|
export {
|
|
curlLongOpts,
|
|
curlShortOpts,
|
|
parseCurlCommand,
|
|
parseArgs,
|
|
buildRequest,
|
|
getHeader,
|
|
hasHeader,
|
|
countHeader,
|
|
setHeaderIfMissing,
|
|
deleteHeader,
|
|
has,
|
|
};
|
|
|
|
export type { LongOpts, ShortOpts, Request, Cookie, Cookies, Query, QueryDict };
|