server/aws-lsp-identity/src/language-server/profiles/sharedConfigProfileStore.ts (165 lines of code) (raw):
import {
normalizeSettingList,
ProfileData,
profileDuckTypers,
ProfileStore,
ssoSessionDuckTyper,
} from './profileService'
import { parseKnownFiles, SharedConfigInit } from '@smithy/shared-ini-file-loader'
import { IniSection, IniSectionType, ParsedIniData } from '@smithy/types'
import { AwsErrorCodes, ProfileKind, SsoSession } from '@aws/language-server-runtimes/server-interface'
import { SectionHeader } from '../../sharedConfig/types'
import { saveKnownFiles } from '../../sharedConfig'
import { normalizeParsedIniData } from '../../sharedConfig/saveKnownFiles'
import { AwsError, Observability } from '@aws/lsp-core'
// Uses AWS SDK for JavaScript v3
// Applies shared config files location resolution, but JVM system properties are not supported
// https://docs.aws.amazon.com/sdkref/latest/guide/file-location.html
type Section = { name: string; settings?: object }
// eslint-disable-next-line no-control-regex
const controlCharsRegex = /[\x00-\x1F\x7F-\x9F]/
export class SharedConfigProfileStore implements ProfileStore {
constructor(
private readonly observability: Observability,
private readonly init: SharedConfigInit = { ignoreCache: true }
) {}
async load(init?: SharedConfigInit): Promise<ProfileData> {
const result: ProfileData = {
profiles: [],
ssoSessions: [],
}
const parsedIni = normalizeParsedIniData(
await parseKnownFiles(this.getSharedConfigInit(init)).catch(reason => {
this.observability.logging.log(`Unable to load shared config. ${reason}`)
throw AwsError.wrap(reason, AwsErrorCodes.E_CANNOT_READ_SHARED_CONFIG)
})
)
for (const [parsedSectionName, settings] of Object.entries(parsedIni)) {
const sectionHeader = SectionHeader.fromParsedSectionName(parsedSectionName)
switch (sectionHeader.type) {
case IniSectionType.PROFILE:
result.profiles.push({
kinds: [
// As more profile kinds are added this will get more complex and need refactored
profileDuckTypers.SsoTokenProfile.eval(settings)
? ProfileKind.SsoTokenProfile
: ProfileKind.Unknown,
],
name: sectionHeader.name,
settings: {
// Only apply settings expected on Profile
region: settings.region,
sso_session: settings.sso_session,
},
})
break
case IniSectionType.SSO_SESSION: {
if (!ssoSessionDuckTyper.eval(settings)) {
continue
}
const ssoSession: SsoSession = {
name: sectionHeader.name,
settings: {
// Only apply settings expected on SsoSession
sso_region: settings.sso_region!,
sso_start_url: settings.sso_start_url!,
},
}
if (settings.sso_registration_scopes) {
ssoSession.settings!.sso_registration_scopes = normalizeSettingList(
settings.sso_registration_scopes
)
}
result.ssoSessions.push(ssoSession)
break
}
// IniSectionType.SERVICES not currently supported
}
}
this.observability.logging.log('Loaded shared config.')
return result
}
// If a setting is set to undefined or null, it will be removed from shared config files
// If the settings property is set to undefined or null, the entire section will be removed
// from the shared config files. This is equivalent to deleting a section.
// Any settings or sections in the shared config files that are not passed into data will
// be preserved as-is.
async save(data: ProfileData, init?: SharedConfigInit): Promise<void> {
if (!(data?.profiles?.length || data?.ssoSessions?.length)) {
return
}
init = this.getSharedConfigInit(init)
const parsedKnownFiles = normalizeParsedIniData(
await parseKnownFiles(this.getSharedConfigInit(init)).catch(reason => {
this.observability.logging.log(`Unable to load shared config for saving. ${reason}`)
throw AwsError.wrap(reason, AwsErrorCodes.E_CANNOT_READ_SHARED_CONFIG)
})
)
if (data.profiles) {
this.applySectionsToParsedIni(
IniSectionType.PROFILE,
data.profiles,
parsedKnownFiles,
(section, parsedSection) =>
!section.kinds.includes(ProfileKind.SsoTokenProfile) ||
profileDuckTypers.SsoTokenProfile.eval(parsedSection)
)
}
if (data.ssoSessions) {
this.applySectionsToParsedIni(
IniSectionType.SSO_SESSION,
data.ssoSessions,
parsedKnownFiles,
(_, parsedSection) => ssoSessionDuckTyper.eval(parsedSection)
)
}
await saveKnownFiles(parsedKnownFiles, init).catch(reason => {
this.observability.logging.log(`Unable to save shared config. ${reason}`)
throw AwsError.wrap(reason, AwsErrorCodes.E_CANNOT_WRITE_SHARED_CONFIG)
})
this.observability.logging.log('Saved shared config.')
}
private applySectionsToParsedIni<T extends Section>(
sectionType: IniSectionType.PROFILE | IniSectionType.SSO_SESSION, // SERVICES not currently supported
sections: T[],
parsedKnownFiles: ParsedIniData,
validator: (section: T, parsedSection: IniSection) => boolean
): void {
const throwAwsError = (message: string) => {
throw new AwsError(
message,
sectionType === IniSectionType.PROFILE
? AwsErrorCodes.E_INVALID_PROFILE
: AwsErrorCodes.E_INVALID_SSO_SESSION
)
}
for (const section of sections) {
if (!section?.name) {
throwAwsError('Section name is required.')
}
const parsedSectionName = new SectionHeader(section.name, sectionType).toParsedSectionName()
// Remove sections that have no settings
if (
section.settings === undefined ||
section.settings === null ||
Object.keys(section.settings).length === 0
) {
delete parsedKnownFiles[parsedSectionName]
continue
}
// Settings must be an object
if (section.settings !== Object(section.settings)) {
throwAwsError('Section contains invalid settings value.')
}
const parsedSection = (parsedKnownFiles[parsedSectionName] ||= {})
// eslint-disable-next-line prefer-const
for (let [name, value] of Object.entries(section.settings)) {
if (Array.isArray(value)) {
value = normalizeSettingList(value)?.join(',') ?? ''
}
// If and when needed in the future, handle object types for subsections (e.g. api_versions)
if (value === Object(value)) {
throwAwsError(`Setting [${name}] cannot be an object.`)
}
// If setting passed with null or undefined then remove setting
// If setting passed with any other value then update setting
// If setting not passed then preserve setting in file as-is
value = value?.toString().trim()
if (value === undefined || value === null || value === '') {
Object.hasOwn(parsedSection, name) && delete parsedSection[name]
} else {
if (controlCharsRegex.test(value)) {
throwAwsError(`Setting [${name}] cannot contain control characters.`)
}
parsedSection[name] = value.toString()
}
}
if (!validator(section, parsedSection)) {
throwAwsError('Section is invalid.')
}
}
}
private getSharedConfigInit(init?: SharedConfigInit): SharedConfigInit {
return init ?? this.init ?? {}
}
}