@amritk/nish-x86_64-linux 0.28.0 → 0.30.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.
@@ -83,6 +83,7 @@ import { Url, urlDecode, urlDefaultPort, urlOrigin, urlParse, urlResolve } from
83
83
  import { resolveHost } from "nish/net/dns"
84
84
  import { DNS_ADDRESS_BYTES } from "nish/net/dns-message"
85
85
  import { DNS_RESOLVE_MAX_ADDRESSES, DNS_RESOLVE_TIMEOUT } from "nish/net/dns-resolver"
86
+ import { OutboundPolicy, outboundAddressText, outboundFilter, outboundHostAllowed } from "nish/net/outbound"
86
87
  import {
87
88
  connectResult,
88
89
  netClose,
@@ -136,6 +137,8 @@ export const HTTP_E_REQUEST: i32 = 14
136
137
  export const HTTP_E_IO: i32 = 15
137
138
  /** A proxy setting that is not an `http://` URL with a host. */
138
139
  export const HTTP_E_PROXY: i32 = 16
140
+ /** The outbound policy refused the URL's host or every address it resolved to. */
141
+ export const HTTP_E_BLOCKED: i32 = 17
139
142
 
140
143
  /** A typed zero: a bare literal is an `f64` under `--number-mode f64`. */
141
144
  const HTTP_CLIENT_ZERO: i32 = 0
@@ -185,6 +188,7 @@ export const httpErrorName = (code: i32): string => {
185
188
  "request",
186
189
  "io",
187
190
  "proxy",
191
+ "blocked",
188
192
  ]
189
193
  return code >= 0 && code < toI32(names.length) ? names[code] : "unknown"
190
194
  }
@@ -230,6 +234,12 @@ export class HttpOptions {
230
234
  maxBodyBytes: i64 = 268435456
231
235
  /** A PEM file of the roots an https server's chain must end at; null for the system's. */
232
236
  rootsPemPath: string | null = null
237
+ /**
238
+ * For a URL the program did not choose: only the addresses and hosts the
239
+ * policy allows are reached, no proxy is used, and its caps apply (see
240
+ * `nish/net/outbound`). Null for none.
241
+ */
242
+ outbound: OutboundPolicy | null = null
233
243
  /** The budget for the lookup and the connect together; 0 or less for none but the total. */
234
244
  connectTimeoutMs: i32 = 10000
235
245
  /** The longest wait for the server to send or take a byte; 0 or less for none. */
@@ -371,6 +381,8 @@ export class HttpClient {
371
381
  noProxy: string = ""
372
382
  /** Where `fd` leads: an origin, or the proxy an http request went through. */
373
383
  route: string = ""
384
+ /** The outbound policy `fd` was dialled under; a connection is reused only under the same one. */
385
+ routePolicy: OutboundPolicy | null = null
374
386
  parser: Http1ResponseParser
375
387
  /** The TLS connection over `fd` to an https origin; null over plain http. */
376
388
  tls: TlsRecordClient | null = null
@@ -442,13 +454,18 @@ export class HttpClient {
442
454
  this.drop()
443
455
  }
444
456
  this.generation += 1
445
- const total: i32 = this.options.totalTimeoutMs
457
+ const total: i32 = httpClientTotalMs(this.options)
446
458
  this.deadline = total > 0 ? monotonicNanos() + toI64(total) * HTTP_CLIENT_NS_PER_MS : HTTP_CLIENT_NEVER
447
459
  let url: Url | null = urlParse(req.url)
448
460
  if (url === null || (url.scheme !== "http" && url.scheme !== "https")) {
449
461
  this.fail(HTTP_E_URL, `not an absolute http or https URL: ${httpClientRedact(url)}`)
450
462
  return null
451
463
  }
464
+ const refusal: string = this.hostRefusal(url)
465
+ if (refusal !== "") {
466
+ this.fail(HTTP_E_BLOCKED, refusal)
467
+ return null
468
+ }
452
469
  let method: string = req.method
453
470
  let body: u8[] | null = req.body
454
471
  const names: string[] = []
@@ -468,10 +485,11 @@ export class HttpClient {
468
485
  return res
469
486
  }
470
487
  const next: Url | null = urlResolve(url, location)
471
- if (hops >= this.options.maxRedirects) {
488
+ const maxRedirects: i32 = httpClientMaxRedirects(this.options)
489
+ if (hops >= maxRedirects) {
472
490
  return this.refuse(
473
491
  HTTP_E_TOO_MANY_REDIRECTS,
474
- `more than ${this.options.maxRedirects} redirects, the last to ${httpClientRedact(next)}`
492
+ `more than ${maxRedirects} redirects, the last to ${httpClientRedact(next)}`
475
493
  )
476
494
  }
477
495
  if (next === null || (next.scheme !== "http" && next.scheme !== "https")) {
@@ -486,6 +504,10 @@ export class HttpClient {
486
504
  `a redirect from https to http (${httpClientRedact(next)})`
487
505
  )
488
506
  }
507
+ const hopRefusal: string = this.hostRefusal(next)
508
+ if (hopRefusal !== "") {
509
+ return this.refuse(HTTP_E_BLOCKED, hopRefusal)
510
+ }
489
511
  const from: string | null = urlOrigin(url)
490
512
  const to: string | null = urlOrigin(next)
491
513
  if (from === null || to === null || from !== to) {
@@ -517,6 +539,15 @@ export class HttpClient {
517
539
  return null
518
540
  }
519
541
 
542
+ /** Why the outbound policy refuses to let a URL name `url`'s host; "" when there is no policy or it allows the host. */
543
+ hostRefusal(url: Url): string {
544
+ const policy: OutboundPolicy | null = this.options.outbound
545
+ if (policy === null || outboundHostAllowed(policy, url.host)) {
546
+ return ""
547
+ }
548
+ return `${httpClientRedact(url)} names a host the outbound policy does not allow`
549
+ }
550
+
520
551
  /** Closes the connection; the client can still fetch, on a new one. */
521
552
  close(): void {
522
553
  this.drop()
@@ -564,14 +595,11 @@ export class HttpClient {
564
595
  */
565
596
  open(url: Url): boolean {
566
597
  const port: i32 = url.port >= 0 ? url.port : urlDefaultPort(url.scheme)
567
- const proxyText: string = httpProxyFor(
568
- url.scheme,
569
- url.host,
570
- port,
571
- this.httpProxy,
572
- this.httpsProxy,
573
- this.noProxy
574
- )
598
+ // A proxy would look the name up itself, past the policy's address check.
599
+ const proxyText: string =
600
+ this.options.outbound !== null
601
+ ? ""
602
+ : httpProxyFor(url.scheme, url.host, port, this.httpProxy, this.httpsProxy, this.noProxy)
575
603
  let proxy: Url | null = null
576
604
  if (proxyText !== "") {
577
605
  proxy = httpClientProxyUrl(proxyText)
@@ -592,7 +620,12 @@ export class HttpClient {
592
620
  } else if (proxy !== null) {
593
621
  route = `${origin} via ${httpClientHostText(proxy.host)}:${proxy.port}`
594
622
  }
595
- if (this.fd >= 0 && this.route === route && this.quiet()) {
623
+ if (
624
+ this.fd >= 0 &&
625
+ this.route === route &&
626
+ httpClientSamePolicy(this.routePolicy, this.options.outbound) &&
627
+ this.quiet()
628
+ ) {
596
629
  this.reused = true
597
630
  return true
598
631
  }
@@ -610,6 +643,7 @@ export class HttpClient {
610
643
  }
611
644
  this.fd = fd
612
645
  this.route = route
646
+ this.routePolicy = this.options.outbound
613
647
  this.parser = httpClientParser(this.options)
614
648
  if (url.scheme === "https") {
615
649
  let early: u8[] = []
@@ -863,7 +897,7 @@ export class HttpClient {
863
897
  if (code === HTTP1_E_BODY_LIMIT) {
864
898
  this.fail(
865
899
  HTTP_E_BODY_TOO_LARGE,
866
- `the body is larger than maxBodyBytes (${this.options.maxBodyBytes})`
900
+ `the body is larger than maxBodyBytes (${httpClientBodyCap(this.options)})`
867
901
  )
868
902
  } else {
869
903
  // A response the server closed part way through is a connection lost,
@@ -1080,9 +1114,22 @@ export class HttpClient {
1080
1114
  )
1081
1115
  return -1
1082
1116
  }
1117
+ let usable: i32 = count < DNS_RESOLVE_MAX_ADDRESSES ? count : DNS_RESOLVE_MAX_ADDRESSES
1118
+ const policy: OutboundPolicy | null = this.options.outbound
1119
+ if (policy !== null) {
1120
+ // Only the bytes checked here are connected to: no second lookup a rebinding answer could change.
1121
+ usable = outboundFilter(policy, addresses, usable)
1122
+ if (usable === 0) {
1123
+ this.fail(
1124
+ HTTP_E_BLOCKED,
1125
+ `${host} resolves to ${outboundAddressText(addresses, 0)}, which the outbound policy does not allow`
1126
+ )
1127
+ return -1
1128
+ }
1129
+ }
1083
1130
  const one: u8[] = new Array<u8>(DNS_ADDRESS_BYTES)
1084
1131
  let last: i32 = 0
1085
- for (let i: i32 = 0; i < count && i < DNS_RESOLVE_MAX_ADDRESSES; i += 1) {
1132
+ for (let i: i32 = 0; i < usable && i < DNS_RESOLVE_MAX_ADDRESSES; i += 1) {
1086
1133
  copyBytes(one, 0, addresses, i * DNS_ADDRESS_BYTES, DNS_ADDRESS_BYTES)
1087
1134
  const fd: i32 = tcpConnect(one)
1088
1135
  if (fd < 0) {
@@ -1320,7 +1367,7 @@ export class HttpClient {
1320
1367
  /** Sets the error for a wait that ended with `code`, `text` saying what timed out. */
1321
1368
  failWait(code: i32, text: string): void {
1322
1369
  if (code === HTTP_E_TOTAL_TIMEOUT) {
1323
- this.fail(code, `the fetch ran past ${this.options.totalTimeoutMs} ms`)
1370
+ this.fail(code, `the fetch ran past ${httpClientTotalMs(this.options)} ms`)
1324
1371
  } else if (code === HTTP_E_IO) {
1325
1372
  this.fail(code, "the poll loop failed")
1326
1373
  } else {
@@ -1468,10 +1515,44 @@ const httpClientParser = (options: HttpOptions): Http1ResponseParser =>
1468
1515
  options.maxHeaderBytes,
1469
1516
  options.maxHeaderBytes,
1470
1517
  HTTP_CLIENT_MAX_HEADERS,
1471
- options.maxBodyBytes,
1518
+ httpClientBodyCap(options),
1472
1519
  options.maxHeaderBytes
1473
1520
  )
1474
1521
 
1522
+ /** Whether `a` and `b` are the same outbound policy, or both none. */
1523
+ const httpClientSamePolicy = (a: OutboundPolicy | null, b: OutboundPolicy | null): boolean => {
1524
+ if (a === null || b === null) {
1525
+ return a === null && b === null
1526
+ }
1527
+ return a === b
1528
+ }
1529
+
1530
+ /** `maxBodyBytes`, or the outbound policy's cap when it is lower. */
1531
+ const httpClientBodyCap = (options: HttpOptions): i64 => {
1532
+ const policy: OutboundPolicy | null = options.outbound
1533
+ return policy !== null && policy.maxBodyBytes < options.maxBodyBytes
1534
+ ? policy.maxBodyBytes
1535
+ : options.maxBodyBytes
1536
+ }
1537
+
1538
+ /** `totalTimeoutMs`, or the outbound policy's budget when it is set and the options' is not, or is longer. */
1539
+ const httpClientTotalMs = (options: HttpOptions): i32 => {
1540
+ const policy: OutboundPolicy | null = options.outbound
1541
+ const own: i32 = options.totalTimeoutMs
1542
+ if (policy === null || policy.totalTimeoutMs <= 0) {
1543
+ return own
1544
+ }
1545
+ return own > 0 && own < policy.totalTimeoutMs ? own : policy.totalTimeoutMs
1546
+ }
1547
+
1548
+ /** `maxRedirects`, or the outbound policy's limit when it is lower. */
1549
+ const httpClientMaxRedirects = (options: HttpOptions): i32 => {
1550
+ const policy: OutboundPolicy | null = options.outbound
1551
+ return policy !== null && policy.maxRedirects < options.maxRedirects
1552
+ ? policy.maxRedirects
1553
+ : options.maxRedirects
1554
+ }
1555
+
1475
1556
  /** The statuses a redirect is followed for: 301, 302, 303, 307 and 308. */
1476
1557
  const httpClientIsRedirect = (status: i32): boolean =>
1477
1558
  status === 301 || status === 302 || status === 303 || status === 307 || status === 308
@@ -32,7 +32,9 @@
32
32
  * default): a 1300-byte body that gzips to 700 still fits in one segment
33
33
  * either way. After each call `decision` says why (`HTTP_COMPRESS_SENT`,
34
34
  * `_NOT_ACCEPTED`, `_TOO_SMALL`, `_TOO_LARGE`, `_TOO_LITTLE_SAVED`,
35
- * `_NO_FEWER_SEGMENTS`), for a program to count.
35
+ * `_NO_FEWER_SEGMENTS`), for a program to count. A response whose identity
36
+ * form already fits one segment is `_NO_FEWER_SEGMENTS` without running gzip
37
+ * at all, as no compression could take fewer.
36
38
  *
37
39
  * **Two encoders.** Bodies up to `fixedLimit` (16 KB) go through
38
40
  * `GzipFixedCompressor`: one fixed-Huffman block, a 16 KB window, no state
@@ -69,6 +71,7 @@
69
71
  */
70
72
  import { DEFLATE_FAST, compressBound } from "nish/compress/deflate"
71
73
  import { GzipCompressor, GzipFixedCompressor } from "nish/compress/gzip"
74
+ import { wrappingAdd } from "nish:unsafe"
72
75
 
73
76
  /** No content coding: the body as it is. */
74
77
  export const HTTP_CODING_IDENTITY: i32 = 0
@@ -92,7 +95,8 @@ export const HTTP_OFFER_DEFLATE: i32 = 2
92
95
  * went out compressed; the client did not take gzip; it was shorter than
93
96
  * `minLength`; longer than `maxLength`; gzip did not save `minSaving` bytes
94
97
  * and `minSavingPercent` percent; or the compressed response would not have
95
- * taken fewer TCP segments (`segmentMode`).
98
+ * taken fewer TCP segments (`segmentMode`), which includes an identity
99
+ * response that already fits one segment, decided without compressing.
96
100
  */
97
101
  export const HTTP_COMPRESS_SENT: i32 = 0
98
102
  export const HTTP_COMPRESS_NOT_ACCEPTED: i32 = 1
@@ -374,12 +378,6 @@ export class HttpCompressor {
374
378
  /** The shortest body worth compressing, and the longest the output can hold. */
375
379
  readonly minLength: i32
376
380
  readonly maxLength: i32
377
- /**
378
- * Whether `addFields` adds `Vary: Accept-Encoding` (RFC 9110 §12.5.5), which
379
- * a cache needs whenever the same resource may be sent either way. On by
380
- * default; turn it off when the program sets its own `Vary`.
381
- */
382
- vary: boolean
383
381
  /**
384
382
  * The least a compressed body must save to be sent: `minSaving` bytes and
385
383
  * `minSavingPercent` percent of the body, whichever is more. 1 and 0 by
@@ -387,6 +385,12 @@ export class HttpCompressor {
387
385
  */
388
386
  minSaving: i32
389
387
  minSavingPercent: i32
388
+ /**
389
+ * Whether `addFields` adds `Vary: Accept-Encoding` (RFC 9110 §12.5.5), which
390
+ * a cache needs whenever the same resource may be sent either way. On by
391
+ * default; turn it off when the program sets its own `Vary`.
392
+ */
393
+ vary: boolean
390
394
  /**
391
395
  * Whether `respondEncoded` also sends the compressed response only when,
392
396
  * head included, it takes fewer TCP segments of `mss` bytes than the body
@@ -401,6 +405,13 @@ export class HttpCompressor {
401
405
  fixedLimit: i32
402
406
  /** The last decision, an `HTTP_COMPRESS_*`; `HTTP_COMPRESS_NOT_ACCEPTED` before any. */
403
407
  decision: i32
408
+ /**
409
+ * How many times `compress` and `compressAnyway` ran an encoder, from 0: a
410
+ * count for a test or a profile to read, never consulted by a decision. It
411
+ * wraps past 2^31 - 1 to -2^31 rather than panic, as a server can run that
412
+ * many; read it as a difference between two reads.
413
+ */
414
+ encoderRuns: i32
404
415
 
405
416
  /**
406
417
  * A compressor at `level` (`DEFLATE_FAST` suits a server) for bodies of
@@ -430,6 +441,7 @@ export class HttpCompressor {
430
441
  this.encoder = HTTP_ENCODER_AUTO
431
442
  this.fixedLimit = 16384
432
443
  this.decision = HTTP_COMPRESS_NOT_ACCEPTED
444
+ this.encoderRuns = 0
433
445
  }
434
446
 
435
447
  /**
@@ -462,6 +474,7 @@ export class HttpCompressor {
462
474
  if (keep > 0) {
463
475
  const fixed: boolean =
464
476
  this.encoder === HTTP_ENCODER_FIXED || (this.encoder !== HTTP_ENCODER_LEVEL && len <= this.fixedLimit)
477
+ this.encoderRuns = wrappingAdd(this.encoderRuns, 1)
465
478
  n = fixed
466
479
  ? this.fixed.compress(body, off, len, this.output, toI32(0), keep)
467
480
  : this.gzip.compress(body, off, len, this.output, toI32(0))
@@ -493,6 +506,7 @@ export class HttpCompressor {
493
506
  this.decision = HTTP_COMPRESS_NOT_ACCEPTED
494
507
  return -1
495
508
  }
509
+ this.encoderRuns = wrappingAdd(this.encoderRuns, 1)
496
510
  let n: i32 = -1
497
511
  if (
498
512
  this.encoder === HTTP_ENCODER_FIXED ||
@@ -532,10 +546,7 @@ export class HttpCompressor {
532
546
  * driving HTTP/2 or HTTP/3 by hand may with its own head sizes.
533
547
  */
534
548
  fewerSegments(identityBytes: i32, encodedBytes: i32, mss: i32): boolean {
535
- let size: i32 = mss
536
- if (size <= 0) {
537
- size = this.mss > 0 ? this.mss : HTTP_DEFAULT_MSS
538
- }
549
+ const size: i32 = this.segmentSize(mss)
539
550
  if (httpSegments(encodedBytes, size) < httpSegments(identityBytes, size)) {
540
551
  return true
541
552
  }
@@ -543,6 +554,37 @@ export class HttpCompressor {
543
554
  return false
544
555
  }
545
556
 
557
+ /** The segment `segmentMode` counts in: `mss` when above 0, else `this.mss` when above 0, else `HTTP_DEFAULT_MSS`. */
558
+ segmentSize(mss: i32): i32 {
559
+ if (mss > 0) {
560
+ return mss
561
+ }
562
+ return this.mss > 0 ? this.mss : HTTP_DEFAULT_MSS
563
+ }
564
+
565
+ /**
566
+ * `segmentMode`'s shortcut, before any encoder runs: whether a `len`-byte
567
+ * body that `compress` would try (its length in `[minLength, maxLength]`
568
+ * and `coding` gzip) is no longer than one segment of `mss` bytes (as
569
+ * `fewerSegments` reads it). Sent as it is, such a response takes one
570
+ * segment once its head fits beside it, and no compression takes fewer, so
571
+ * `decision` becomes `HTTP_COMPRESS_NO_FEWER_SEGMENTS` and the caller checks
572
+ * the head: when it does not fit, the caller goes on to `compress`, which
573
+ * decides again. Anything else answers false and leaves `decision` alone.
574
+ */
575
+ oneSegment(coding: i32, len: i32, mss: i32): boolean {
576
+ if (
577
+ coding !== HTTP_CODING_GZIP ||
578
+ len < this.minLength ||
579
+ len > this.maxLength ||
580
+ len > this.segmentSize(mss)
581
+ ) {
582
+ return false
583
+ }
584
+ this.decision = HTTP_COMPRESS_NO_FEWER_SEGMENTS
585
+ return true
586
+ }
587
+
546
588
  /**
547
589
  * The bytes of the HTTP/1.1 head a `length`-byte body sent as it is would
548
590
  * get, from `encodedHead`, that of the same response sent compressed as