-
Notifications
You must be signed in to change notification settings - Fork 434
/
errors.ts
1066 lines (934 loc) · 38 KB
/
errors.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/*!
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
import * as vscode from 'vscode'
import { AWSError } from 'aws-sdk'
import { ServiceException } from '@aws-sdk/smithy-client'
import { isThrottlingError, isTransientError } from '@smithy/service-error-classification'
import { Result } from './telemetry/telemetry'
import { CancellationError } from './utilities/timeoutUtils'
import { hasKey, isNonNullable } from './utilities/tsUtils'
import type * as nodefs from 'fs' // eslint-disable-line no-restricted-imports
import type * as os from 'os'
import { CodeWhispererStreamingServiceException } from '@amzn/codewhisperer-streaming'
import { driveLetterRegex } from './utilities/pathUtils'
import { getLogger } from './logger/logger'
import { crashMonitoringDirName } from './constants'
let _username = 'unknown-user'
let _isAutomation = false
/** Performs one-time initialization, to avoid circular dependencies. */
export function init(username: string, isAutomation: boolean) {
_username = username
_isAutomation = isAutomation
}
export const errorCode = {
invalidConnection: 'InvalidConnection',
}
export interface ErrorInformation {
/**
* Error names are optional, but if provided they should be generic yet self-explanatory.
*
* A name does not need to precisely describe why something failed and instead should focus
* on the overarching theme or module that caused the problem. For example, VS Code uses
* `FileSystemError` for a diverse range of problems relating to their file system API.
*
* The choice of granularity for an error name largely comes down to what other errors are
* possible; names are meant to immediately disambiguate from the potentially thousands of
* other causes for a failure. If there is any doubt that a certain name might be confusing,
* opt for something more specific and verbose.
*/
readonly name?: string
/**
* An error code is used to describe specific failure modes within entire classes of errors.
*
* The exact value used here is free-form but should be kept stable whenever possible.
* Value stability is especially important if any error handling logic directly relies on
* error codes. Prefer using {@link ToolkitError.named} and narrowing this class if any
* substantial logic is needed for handling specific error codes.
*
* As an example, one might choose to describe SSO error codes for extra type-safety:
* ```ts
* enum CreateTokenErrorCode {
* SlowDown = 'SlowDownException',
* ExpiredToken = 'ExpiredTokenException',
* UnauthorizedClient = 'UnauthorizedClientException',
* AuthorizationPending = 'AuthorizationPendingException',
* }
*
* class CreateTokenError extends ToolkitError.named('CreateTokenError') {
* public constructor(public readonly code: CreateTokenErrorCode) {
* super('Failed to create SSO access token', { code })
* }
* }
* ```
*/
readonly code?: string
/**
* Used to describe errors with a direct cause-effect relationship.
*
* See {@link code} for creating descriptive errors without a chainable root cause.
*/
readonly cause?: Error
/**
* Structured information that may be used for logging.
*
* Consumers should strictly use this field for observability purposes as producers can
* put whatever they want here.
*/
readonly details?: Record<string, unknown>
/**
* Flag to determine if the error was from a user-initiated cancellation.
*/
readonly cancelled?: boolean
/**
* A link to documentation relevant to this error.
*
* TODO: use this throughout the codebase.
* TODO: prefer `Error.error_uri` if present (from OIDC/OAuth service)?
*/
readonly documentationUri?: vscode.Uri
}
export class UnknownError extends Error {
public override readonly name = 'UnknownError'
public constructor(public readonly cause: unknown) {
super(String(cause))
}
public static cast(obj: unknown): Error {
return obj instanceof Error ? obj : new UnknownError(obj)
}
}
/**
* Anonymous class with a pre-defined error name.
*/
export interface NamedErrorConstructor {
/**
* See {@link ToolkitError}
*/
new (message: string, info?: Omit<ErrorInformation, 'name'>): ToolkitError
/**
* See {@link ToolkitError.chain}
*/
chain<T extends this>(
this: T,
error: unknown,
message: string,
info?: Omit<ErrorInformation, 'name' | 'cause'>
): InstanceType<T>
}
/**
* Generic error class for handling exceptions within the Toolkit.
*/
export class ToolkitError extends Error implements ErrorInformation {
/**
* A message that could potentially be shown to the user. This should not contain any
* sensitive information and should be limited in technical detail.
*/
public override readonly message: string
public readonly code: string | undefined
public readonly details: Record<string, unknown> | undefined
/**
* We guard against mutation to stop a developer from creating a circular chain of errors.
* The alternative is to truncate errors to an arbitrary depth though that doesn't address
* why the error chain is deep.
*/
readonly #cause: Error | undefined
readonly #name: string
readonly #documentationUri: any
readonly #cancelled: boolean | undefined
public constructor(message: string, info: ErrorInformation = {}) {
super(message)
this.message = message
this.code = info.code
this.details = info.details
this.#cause = info.cause
this.#name = info.name ?? super.name
this.#cancelled = info.cancelled
this.#documentationUri = info.documentationUri
}
/**
* The original error that caused this error (if any).
*/
public get cause(): Error | undefined {
return this.#cause
}
/**
* The name of the error. This is not necessarily the same as the class name.
*/
public override get name(): string {
return this.#name
}
/**
* See {@link ErrorInformation.cancelled cancelled}.
*
* Whether or not an error is considered 'cancelled' is determined either by explicit
* assignment on construction or by finding a 'cancelled' error within its causal chain.
*/
public get cancelled(): boolean {
return this.#cancelled ?? isUserCancelledError(this.cause)
}
/**
* The associated documentation, if it exists. Otherwise undefined.
*/
public get documentationUri(): vscode.Uri | undefined {
return this.#documentationUri
}
/**
* A formatted string that is analogous to a stack trace. While stack traces enumerate every
* call site, this trace enumerates every throw site.
*
* The motivation here is that stack traces are often not very useful to anyone but the original
* developers. This is especially true for JavaScript where source maps are needed to parse traces
* from bundled applications. We want a trace that is informative but not excessively noisy.
*/
public get trace(): string {
const message = formatError(this)
if (!this.cause) {
return message
}
// Stack overflows are only possible if `cause` is changed after instantiation
const residual = this.cause instanceof ToolkitError ? this.cause.trace : formatError(this.cause)
return `${message}\n\t -> ${residual}`
}
/**
* Creates a new {@link ToolkitError} instance that was directly caused by another error.
*
* @param error - The original error that caused this error.
* @param message - A descriptive message for the new error.
* @param info - Additional information about the error.
* @returns {ToolkitError} The new ToolkitError instance.
*
* @recommendation It is recommended to throw the returned ToolkitError instance instead of just returning it.
* This way, the error can be properly propagated and handled in the calling code.
*
* Example:
* ```typescript
* try {
* // Some code that might throw an error
* } catch (error) {
* throw ToolkitError.chain(error, 'An error occurred during operation', { operation: 'someOperation' });
* }
* ```
*/
public static chain(error: unknown, message: string, info?: Omit<ErrorInformation, 'cause'>): ToolkitError {
return new this(message, {
...info,
cause: UnknownError.cast(error),
})
}
/**
* Creates a new {@link ToolkitError} _class_ with a constant name.
*
* Constructor (class) names are not preserved when bundling due to name mangling. Extending off
* this new anonymous class will ensure that the specified name is always present.
*/
public static named(name: string): NamedErrorConstructor {
return class extends ToolkitError {
public override get name() {
return name
}
// TypeScript does not allow the use of `this` types for generic prototype methods unfortunately
// This implementation is equivalent to re-assignment i.e. an unbound method on the prototype
public static override chain<
T extends new (...args: ConstructorParameters<NamedErrorConstructor>) => ToolkitError,
>(this: T, ...args: Parameters<NamedErrorConstructor['chain']>): InstanceType<T> {
return ToolkitError.chain.call(this, ...args) as InstanceType<T>
}
}
}
}
/**
* Derives an error message from the given error object.
* Depending on the Error, the property used to derive the message can vary.
*
* @param withCause Append the message(s) from the cause chain, recursively.
* The message(s) are delimited by ' | '. Eg: msg1 | causeMsg1 | causeMsg2
*/
export function getErrorMsg(err: Error | undefined, withCause: boolean = false): string | undefined {
if (err === undefined) {
return undefined
}
// Non-standard SDK fields added by the OIDC service, to conform to the OAuth spec
// (https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1) :
// - error: code per the OAuth spec
// - error_description: improved error message provided by OIDC service. Prefer this to
// `message` if present.
// https://github.com/aws/aws-toolkit-jetbrains/commit/cc9ed87fa9391dd39ac05cbf99b4437112fa3d10
// - error_uri: not provided by OIDC currently?
//
// Example:
//
// [error] API response (oidc.us-east-1.amazonaws.com /token): {
// name: 'InvalidGrantException',
// '$fault': 'client',
// '$metadata': {
// httpStatusCode: 400,
// requestId: '7f5af448-5af7-45f2-8e47-5808deaea4ab',
// extendedRequestId: undefined,
// cfId: undefined
// },
// error: 'invalid_grant',
// error_description: 'Invalid refresh token provided',
// message: 'UnknownError'
// }
const anyDesc = (err as any).error_description
const errDesc = typeof anyDesc === 'string' ? anyDesc.trim() : ''
let msg = errDesc !== '' ? errDesc : err.message?.trim()
if (typeof msg !== 'string') {
return undefined
}
// append the cause's message
if (withCause) {
const errorId = getErrorId(err)
// - prepend id to message
// - If a generic error does not have the `name` field explicitly set, it returns a generic 'Error' name. So skip since it is useless.
if (errorId && errorId !== 'Error') {
msg = `${errorId}: ${msg}`
}
const cause = (err as any).cause
return `${msg}${cause ? ' | ' + getErrorMsg(cause, withCause) : ''}`
}
return msg
}
export function formatError(err: Error): string {
const code = hasCode(err) && err.code !== err.name ? `[${err.code}]` : undefined
const parts = [`${err.name}:`, getErrorMsg(err), code, formatDetails(err)]
return parts.filter(isNonNullable).join(' ')
}
function formatDetails(err: Error): string | undefined {
const details: Record<string, string | undefined> = {}
if (err instanceof ToolkitError && err.details !== undefined) {
for (const [k, v] of Object.entries(err.details)) {
details[k] = String(v)
}
} else if (isAwsError(err)) {
details['statusCode'] = String(err.statusCode ?? '')
details['requestId'] = getRequestId(err)
details['extendedRequestId'] = err.extendedRequestId
}
if (Object.keys(details).length === 0) {
return
}
const joined = Object.entries(details)
.filter(([_, v]) => !!v)
.map(([k, v]) => `${k}: ${v}`)
.join('; ')
return `(${joined})`
}
export function getTelemetryResult(error: unknown | undefined): Result {
if (error === undefined) {
return 'Succeeded'
} else if (isUserCancelledError(error)) {
return 'Cancelled'
}
return 'Failed'
}
/**
* Removes potential PII from a string, for logging/telemetry.
*
* Examples:
* - "Failed to save c:/fooß/bar/baz.txt" => "Failed to save c:/xß/x/x.txt"
* - "EPERM for dir c:/Users/user1/.aws/sso/cache/abc123.json" => "EPERM for dir c:/Users/x/.aws/sso/cache/x.json"
*/
export function scrubNames(s: string, username?: string) {
let r = ''
const fileExtRe = /\.[^.\/]+$/
const slashdot = /^[~.]*[\/\\]*/
/** Allowlisted filepath segments. */
const keep = new Set<string>([
'~',
'.',
'..',
'.aws',
'aws',
'sso',
'cache',
'credentials',
'config',
'Users',
'users',
'home',
'tmp',
'aws-toolkit-vscode',
'globalStorage', // from vscode globalStorageUri
crashMonitoringDirName,
])
if (username && username.length > 2) {
s = s.replaceAll(username, 'x')
}
// Replace contiguous whitespace with 1 space.
s = s.replace(/\s+/g, ' ')
// 1. split on whitespace.
// 2. scrub words that match username or look like filepaths.
const words = s.split(/\s+/)
for (const word of words) {
const pathSegments = word.split(/[\/\\]/)
if (pathSegments.length < 2) {
// Not a filepath.
r += ' ' + word
continue
}
// Replace all (non-allowlisted) ASCII filepath segments with "x".
// "/foo/bar/aws/sso/" => "/x/x/aws/sso/"
let scrubbed = ''
// Get the frontmatter ("/", "../", "~/", or "./").
const start = word.trimStart().match(slashdot)?.[0] ?? ''
pathSegments[0] = pathSegments[0].trimStart().replace(slashdot, '')
for (const seg of pathSegments) {
if (driveLetterRegex.test(seg)) {
scrubbed += seg
} else if (keep.has(seg)) {
scrubbed += '/' + seg
} else {
// Save the first non-ASCII (unicode) char, if any.
const nonAscii = seg.match(/[^\p{ASCII}]/u)?.[0] ?? ''
// Replace all chars (except [^…]) with "x" .
const ascii = seg.replace(/[^$[\](){}:;'" ]+/g, 'x')
scrubbed += `/${ascii}${nonAscii}`
}
}
// includes leading '.', eg: '.json'
const fileExt = pathSegments[pathSegments.length - 1].match(fileExtRe) ?? ''
r += ` ${start.replace(/\\/g, '/')}${scrubbed.replace(/^[\/\\]+/, '')}${fileExt}`
}
return r.trim()
}
/**
* Gets the (partial) error message detail for the `reasonDesc` field.
*
* @param err Error object, or message text
*/
export function getTelemetryReasonDesc(err: unknown | undefined): string | undefined {
const m = typeof err === 'string' ? err : (getErrorMsg(err as Error, true) ?? '')
const msg = scrubNames(m, _username)
// Truncate message as these strings can be very long.
return msg && msg.length > 0 ? msg.substring(0, 350) : undefined
}
export function getTelemetryReason(error: unknown | undefined): string | undefined {
// Currently the `code` field is favored over the error name even though both are useful
// for describing the reason. We're only using a single `reason` field and it's just simpler
// to not encode more information.
if (error === undefined) {
return undefined
} else if (error instanceof CancellationError) {
return error.agent
} else if (error instanceof ToolkitError) {
// TODO: prefer the error.error field if present? (see comment in `getErrorMsg`)
return getTelemetryReason(error.cause) ?? error.code ?? error.name
} else if (error instanceof Error) {
return (error as { code?: string }).code ?? error.name
}
return 'Unknown'
}
/**
* Tries to build the most intuitive/relevant message to show to the user.
*
* User can see the full error chain in the logs Output channel.
*/
export function resolveErrorMessageToDisplay(error: unknown, defaultMessage: string): string {
const mainMsg = error instanceof ToolkitError ? error.message : defaultMessage
// Try to find the most useful/relevant error in the `cause` chain.
const bestErr = error ? findBestErrorInChain(error as Error) : undefined
const bestMsg = getErrorMsg(bestErr)
return bestMsg && bestMsg !== mainMsg ? `${mainMsg}: ${bestMsg}` : mainMsg
}
/**
* Patterns that match the value of {@link AWSError.code}
*/
const _preferredErrors: RegExp[] = [
/^ConflictException$/,
/^ValidationException$/,
/^ResourceNotFoundException$/,
/^ServiceQuotaExceededException$/,
/^AccessDeniedException$/,
/^InvalidPermissions$/,
/^EPIPE$/,
/^EPERM$/,
]
/**
* Searches the `cause` chain (if any) for the most useful/relevant {@link AWSError} to surface to
* the user, preferring "deeper" errors (lower-level, closer to the root cause) when all else is equal.
*
* These conditions determine precedence (in order):
* - is AWSError
* - has `error_description` field
* - has `code` matching `preferredErrors`
* - is filesystem error
* - cause chain depth (the deepest error wins)
*
* @param error Error whose `cause` chain will be searched.
* @param preferredErrors Error `code` field must match one of these, else it is discarded. Pass `[/./]` to match any AWSError.
*
* @returns Best match, or `error` if a better match is not found.
*/
export function findBestErrorInChain(error: Error, preferredErrors = _preferredErrors): Error | undefined {
// TODO: Base Error has 'cause' in ES2022. So does our own `ToolkitError`.
// eslint-disable-next-line @typescript-eslint/naming-convention
let bestErr: Error & { code?: string; cause?: Error; error_description?: string } = error
let err: typeof bestErr | undefined
for (let i = 0; i < 100; i++) {
err = i === 0 ? bestErr.cause : err?.cause
if (!err) {
break
}
// const bestErrCode = bestErr.code?.trim() ?? ''
// const preferBest = ...
const errCode = err.code?.trim() ?? ''
const prefer =
(errCode !== '' && preferredErrors.some((re) => re.test(errCode))) ||
// In priority order:
isFilesystemError(err) ||
isPermissionsError(err)
if (isAwsError(err) || (prefer && !isAwsError(bestErr))) {
if (isAwsError(err) && !isAwsError(bestErr)) {
bestErr = err // Prefer AWSError.
continue
}
const errDesc = err.error_description
if (typeof errDesc === 'string' && errDesc.trim() !== '') {
bestErr = err // Prefer (deepest) error with error_description.
continue
}
if (!bestErr.error_description && prefer) {
bestErr = err
}
}
}
return bestErr
}
export function isCodeWhispererStreamingServiceException(
error: unknown
): error is CodeWhispererStreamingServiceException {
if (error === undefined) {
return false
}
return error instanceof Error && hasFault(error) && hasMetadata(error) && hasName(error)
}
function hasFault<T>(error: T): error is T & { $fault: 'client' | 'server' } {
const fault = (error as { $fault?: unknown }).$fault
return typeof fault === 'string' && (fault === 'client' || fault === 'server')
}
function hasMetadata<T>(error: T): error is T & Pick<CodeWhispererStreamingServiceException, '$metadata'> {
return typeof (error as { $metadata?: unknown })?.$metadata === 'object'
}
function hasResponse<T>(error: T): error is T & Pick<ServiceException, '$response'> {
return typeof (error as { $response?: unknown })?.$response === 'object'
}
function hasName<T>(error: T): error is T & { name: string } {
return typeof (error as { name?: unknown })?.name === 'string'
}
// eslint-disable-next-line @typescript-eslint/naming-convention
export function isAwsError(error: unknown): error is AWSError & { error_description?: string } {
if (error === undefined) {
return false
}
return error instanceof Error && hasCode(error) && hasTime(error)
}
function hasCode<T>(error: T): error is T & { code: string } {
return typeof (error as { code?: unknown }).code === 'string'
}
/**
* Returns the identifier the given error.
* Depending on the implementation, the identifier may exist on a
* different property.
*/
export function getErrorId(error: Error): string {
// prioritize code over the name
return hasCode(error) ? error.code : error.name
}
function hasTime(error: Error): error is typeof error & { time: Date } {
return (error as { time?: unknown }).time instanceof Date
}
export function isUserCancelledError(error: unknown): boolean {
return CancellationError.isUserCancelled(error) || (error instanceof ToolkitError && error.cancelled)
}
/**
* Checks if the AWS SDK v3 error was caused by the client and not due to a service issue.
*/
export function isClientFault(error: ServiceException): boolean {
return error.$fault === 'client' && !(isThrottlingError(error) || isTransientError(error))
}
export function getRequestId(err: unknown): string | undefined {
// XXX: Checking `err instanceof ServiceException` fails for `SSOOIDCServiceException` even
// though it subclasses @aws-sdk/smithy-client.ServiceException
if (typeof (err as any)?.$metadata?.requestId === 'string') {
return (err as any).$metadata.requestId
}
if (isAwsError(err)) {
return err.requestId
}
}
export function getHttpStatusCode(err: unknown): number | undefined {
if (hasResponse(err) && err?.$response?.statusCode !== undefined) {
return err?.$response?.statusCode
}
if (hasMetadata(err) && err.$metadata?.httpStatusCode !== undefined) {
return err.$metadata?.httpStatusCode
}
return undefined
}
export function isFilesystemError(err: unknown): boolean {
if (
err instanceof vscode.FileSystemError ||
(hasCode(err) &&
(err.code === 'EEXIST' ||
err.code === 'EISDIR' ||
err.code === 'ENOTDIR' ||
err.code === 'EMFILE' ||
err.code === 'ENOENT' ||
err.code === 'ENOTEMPTY'))
) {
return true
}
return false
}
// export function isIsDirError(err: unknown): boolean {
// if (err instanceof vscode.FileSystemError) {
// return err.code === vscode.FileSystemError.FileIsADirectory().code
// } else if (hasCode(err)) {
// return err.code === 'EISDIR'
// }
// return false
// }
//
// export function isFileExistsError(err: unknown): boolean {
// if (err instanceof vscode.FileSystemError) {
// return err.code === vscode.FileSystemError.FileExists().code
// } else if (hasCode(err)) {
// return err.code === 'EEXIST'
// }
// return false
// }
export function isFileNotFoundError(err: unknown): boolean {
if (err instanceof vscode.FileSystemError) {
return err.code === vscode.FileSystemError.FileNotFound().code
} else if (hasCode(err)) {
return err.code === 'ENOENT' || err.code === 'FileNotFound'
}
return false
}
export function isPermissionsError(err: unknown): boolean {
if (err instanceof vscode.FileSystemError) {
return (
err.code === vscode.FileSystemError.NoPermissions().code ||
// The code _should_ be `NoPermissions`, maybe this is a bug?
(err.code === 'Unknown' && err.message.includes('EACCES: permission denied'))
)
} else if (hasCode(err)) {
// " Some operating systems report EPERM in situations unrelated to permissions."
// || err.code === 'EPERM'
return err.code === 'EACCES'
}
return false
}
function modeToString(mode: number) {
return Array.from('rwxrwxrwx')
.map((c, i, a) => ((mode >> (a.length - (i + 1))) & 1 ? c : '-'))
.join('')
}
function vscodeModeToString(mode: vscode.FileStat['permissions']) {
// XXX: vscode.FileStat.permissions only indicates "readonly" or nothing (aka "writable").
if (mode === undefined) {
return 'rwx------'
} else if (mode === vscode.FilePermission.Readonly) {
return 'r-x------'
}
// XXX: future-proof in case vscode.FileStat.permissions gains more granularity.
if (_isAutomation) {
throw new Error('vscode.FileStat.permissions gained new fields, update this logic')
}
}
function getEffectivePerms(uid: number, gid: number, stats: nodefs.Stats) {
const mode = stats.mode
const isOwner = uid === stats.uid
const isGroup = gid === stats.gid && !isOwner
// Many unix systems support multiple groups but we only know the primary
// The user can still have group permissions despite not having the same `gid`
// These situations are ambiguous, so the effective permissions are the
// intersection of the two bitfields
if (!isOwner && !isGroup) {
return {
isAmbiguous: true,
effective: mode & 0o007 & ((mode & 0o070) >> 3),
}
}
const ownerMask = isOwner ? 0o700 : 0
const groupMask = isGroup ? 0o070 : 0
return {
isAmbiguous: false,
effective: ((mode & groupMask) >> 3) | ((mode & ownerMask) >> 6),
}
}
// The wildcard (`*`) symbol is non-standard. It's used to represent "don't cares" and takes
// on the actual flag once known.
export type PermissionsTriplet = `${'r' | '-' | '*'}${'w' | '-' | '*'}${'x' | '-' | '*'}`
export class PermissionsError extends ToolkitError {
public readonly actual: string // This is a resolved triplet, _not_ the full bits
static fromNodeFileStats(stats: nodefs.Stats, userInfo: os.UserInfo<string>) {
const mode = `${stats.isDirectory() ? 'd' : '-'}${modeToString(stats.mode)}`
const owner = stats.uid === userInfo.uid ? (stats.uid === -1 ? '' : userInfo.username) : String(stats.uid)
const group = String(stats.gid)
const { effective, isAmbiguous } = getEffectivePerms(userInfo.uid, userInfo.gid, stats)
const actual = modeToString(effective).slice(-3)
const isOwner = stats.uid === -1 ? 'unknown' : userInfo.uid === stats.uid
return { mode, owner, group, actual, isAmbiguous, isOwner }
}
static fromVscodeFileStats(stats: vscode.FileStat, userInfo: os.UserInfo<string>) {
const isDir = !!(stats.type & vscode.FileType.Directory)
const mode = `${isDir ? 'd' : '-'}${vscodeModeToString(stats.permissions)}`
const owner = '' // vscode.FileStat does not currently provide file owner.
const group = '' // vscode.FileStat does not currently provide file group.
const isAmbiguous = true // vscode.workspace.fs.stat() is currently always ambiguous.
const actual = mode
const isOwner = 'unknown' // vscode.FileStat does not currently provide file owner.
return { mode, owner, group, actual, isAmbiguous, isOwner }
}
/**
* Creates a PermissionsError from a file stat() result.
*
* Note: pass `nodefs.Stats` when possible (in a nodejs context), because it gives much better info.
*/
public constructor(
public readonly uri: vscode.Uri,
public readonly stats: nodefs.Stats | vscode.FileStat,
public readonly userInfo: os.UserInfo<string>,
public readonly expected: PermissionsTriplet,
source?: unknown
) {
const o = (stats as any).type
? PermissionsError.fromVscodeFileStats(stats as vscode.FileStat, userInfo)
: PermissionsError.fromNodeFileStats(stats as nodefs.Stats, userInfo)
const resolvedExpected = Array.from(expected)
.map((c, i) => (c === '*' ? o.actual[i] : c))
.join('')
const actualText = !o.isAmbiguous ? o.actual : `${o.mode.slice(-6, -3)} & ${o.mode.slice(-3)} (ambiguous)`
// Guard against surfacing confusing error messages. If the actual perms equal the resolved
// perms then odds are it wasn't really a permissions error. Some operating systems report EPERM
// in situations that aren't related to permissions at all.
if (o.actual === resolvedExpected && !o.isAmbiguous && source !== undefined) {
throw source
}
super(`${uri.fsPath} has incorrect permissions. Expected ${resolvedExpected}, found ${actualText}.`, {
code: 'InvalidPermissions',
details: {
isOwner: o.isOwner,
mode: `${o.mode}${o.owner === '' ? '' : ` ${o.owner}`}${o.group === '' ? '' : ` ${o.group}`}`,
},
})
this.actual = o.actual
}
}
export function isNetworkError(err?: unknown): err is Error & { code: string } {
if (!(err instanceof Error)) {
return false
}
if (
isVSCodeProxyError(err) ||
isSocketTimeoutError(err) ||
isEnoentError(err) ||
isEaccesError(err) ||
isEbadfError(err) ||
isEconnRefusedError(err) ||
err instanceof AwsClientResponseError ||
isBadResponseCode(err) ||
isEbusyError(err)
) {
return true
}
if (!hasCode(err)) {
return false
}
return [
'ENOTFOUND',
'EAI_AGAIN',
'ECONNRESET',
'ECONNREFUSED',
'ETIMEDOUT',
'ENETUNREACH',
'ERR_TLS_CERT_ALTNAME_INVALID', // dns issue?
'EPROTO', // due to legacy server "unsafe legacy renegotiation"?
'EHOSTUNREACH',
'EADDRINUSE',
'ENOBUFS', // client side memory issue during http request?
'EADDRNOTAVAIL', // port not available/allowed?,
'SELF_SIGNED_CERT_IN_CHAIN',
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
'HPE_INVALID_VERSION',
'DEPTH_ZERO_SELF_SIGNED_CERT',
'ENOTCONN',
'ENETDOWN',
'ECONNABORTED',
'CERT_HAS_EXPIRED',
'EAI_FAIL',
'502', // This may be irrelevant as isBadResponseCode() may be all we need
'InternalServerException',
'ERR_SSL_WRONG_VERSION_NUMBER',
].includes(err.code)
}
/**
* This error occurs on a network call if the user has set up a proxy in the
* VS Code settings but the proxy is not reachable.
*
* Setting ID: http.proxy
*/
function isVSCodeProxyError(err: Error): boolean {
return isError(err, 'Error', 'Failed to establish a socket connection to proxies')
}
/**
* When making SSO OIDC calls, we were seeing errors in telemetry and narrowing it down brings us to:
* https://github.com/smithy-lang/smithy-typescript/blob/6aac850af4b5b07b3941854d21e3b0158aefcacb/packages/node-http-handler/src/set-socket-timeout.ts#L7
* This looks to be thrown during http requests, so we'll consider it a network error.
*
* The scenario where we are actually getting the error though might actually be a bug:
* https://github.com/aws/aws-sdk-js-v3/issues/6271
*/
function isSocketTimeoutError(err: Error): boolean {
return isError(err, 'TimeoutError', 'Connection timed out after')
}
/**
* We were seeing errors of ENOENT for the oidc FQDN (eg: oidc.us-east-1.amazonaws.com) during the SSO flow.
* Our assumption is that this is an intermittent error.
*/
function isEnoentError(err: Error): boolean {
return isError(err, 'ENOENT', 'getaddrinfo ENOENT')
}
function isEaccesError(err: Error): boolean {
return isError(err, 'EACCES', 'connect EACCES')
}
function isEbadfError(err: Error): boolean {
return isError(err, 'EBADF', 'connect EBADF')
}
function isEconnRefusedError(err: Error): boolean {
return isError(err, 'Error', 'connect ECONNREFUSED')
}
function isEbusyError(err: Error) {
// we were seeing errors with the message 'getaddrinfo EBUSY oidc.us-east-1.amazonaws.com'
return isError(err, 'EBUSY', 'getaddrinfo EBUSY')
}
/** Helper function to assert given error has the expected properties */
export function isError(err: Error, id: string, messageIncludes: string = '') {
// It is not always clear if the error has the expected value in the `name` or `code` field
// so this checks both.
return (err.name === id || (err as any).code === id) && err.message.includes(messageIncludes)
}
/**
* These are the errors explicitly seen in telemetry. We can instead do any non-200 response code
* later, but this will give us better visibility in to the actual error codes we are currently getting.
*/
const errorResponseCodes = [302, 403, 404, 502, 503]
/**
* Returns true if the given error is a bad response code
*/
function isBadResponseCode(error: Error) {
if (isNaN(Number(error.name))) {
return
}
const statusCode = parseInt(error.name, 10)
return errorResponseCodes.includes(statusCode)
}
/**
* AWS SDK clients make requests with the expected result to be JSON data.
* But in some cases the request may fail and result in an error HTML page being returned instead
* of the JSON. This will cause the client to throw a `SyntaxError` as a result
* of attempt to deserialize the non-JSON data.
*
* But within the `SyntaxError` instance is the real reason for the failure.
* This class attempts to extract the underlying issue from the SyntaxError.
*
* Example SyntaxError message before extracting the underlying issue:
* - "Unexpected token '<', "<html><bod"... is not valid JSON Deserialization error: to see the raw response, inspect the hidden field {error}.$response on this object."
* Once we extract the real error message from the hidden field, `$response.reason`, we get messages similar to:
* - "SDK Client unexpected error response: data response code: 403, data reason: Forbidden | Unexpected ..."
*/
export class AwsClientResponseError extends Error {
/** Use {@link instanceIf} to create instance. */
protected constructor(err: unknown) {
const underlyingErrorMsg = AwsClientResponseError.tryExtractReasonFromSyntaxError(err)
/**
* This condition should never be hit since {@link AwsClientResponseError.instanceIf}
* is the only way to create an instance of this class, due to the constructor not being public.
*
* The following only exists to make the type checker happy.
*/
if (!(underlyingErrorMsg && err instanceof Error)) {
throw Error(`Cannot create AwsClientResponseError from ${JSON.stringify(err)}}`)
}
super(underlyingErrorMsg)
}
/**
* Resolves an instance of {@link AwsClientResponseError} if the given error matches certain criteria.
* Otherwise the original error is returned.
*/
static instanceIf<T>(err: T): AwsClientResponseError | T {
const reason = AwsClientResponseError.tryExtractReasonFromSyntaxError(err)
if (reason) {
getLogger().debug(`Creating AwsClientResponseError from SyntaxError: %O`, err)
return new AwsClientResponseError(err)
}
return err
}
/**
* Returns the true underlying error message from a `SyntaxError`, if possible.
* Otherwise returning undefined.
*/